thefinalboss commited on
Commit
1da7ac7
·
verified ·
1 Parent(s): 99dea5e

Fractus-Vorax v1.0.0 — the takeover: sealed CTE brain + ingestion organs + mechanical speech (199 tests, honest floors)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. ATTRIBUTIONS.md +71 -0
  2. README.fr.md +402 -0
  3. README.md +152 -0
  4. bench/core_speak.py +585 -0
  5. bench/ingest_bench.py +71 -0
  6. bench/killer_bench.py +401 -0
  7. conftest.py +6 -0
  8. demo/ce_soir.py +177 -0
  9. demo/demo.py +106 -0
  10. demo/inauguration.py +244 -0
  11. docs/NAISSANCE.md +107 -0
  12. docs/heritage/cte-architecture.md +771 -0
  13. docs/heritage/fractus-cte-architecture.md +320 -0
  14. docs/heritage/plans/2026-08-17-vorax-p1-substrat.md +1611 -0
  15. docs/heritage/plans/2026-08-18-vorax-p2-organes.md +1133 -0
  16. docs/heritage/plans/2026-08-18-vorax-p3-noyau.md +488 -0
  17. docs/heritage/plans/2026-08-18-vorax-p4-noyau-parle.md +239 -0
  18. docs/heritage/plans/2026-08-18-vorax-p5-ctecore.md +82 -0
  19. docs/heritage/specs/2026-08-17-vorax-design.md +212 -0
  20. docs/superpowers/plans/2026-08-18-fractus-vorax-relais.md +76 -0
  21. docs/superpowers/plans/2026-08-18-p6-mecanique-parole.md +76 -0
  22. docs/superpowers/plans/2026-08-18-p7-parler-ce-soir.md +32 -0
  23. fractus_vorax/__init__.py +2 -0
  24. fractus_vorax/agent/__init__.py +0 -0
  25. fractus_vorax/agent/cli.py +101 -0
  26. fractus_vorax/agent/repl.py +331 -0
  27. fractus_vorax/brain.py +384 -0
  28. fractus_vorax/cards.py +11 -0
  29. fractus_vorax/compiler/__init__.py +0 -0
  30. fractus_vorax/compiler/analogy.py +78 -0
  31. fractus_vorax/compiler/atoms.py +29 -0
  32. fractus_vorax/compiler/encode.py +49 -0
  33. fractus_vorax/compiler/kn.py +58 -0
  34. fractus_vorax/compiler/parse.py +118 -0
  35. fractus_vorax/hv.py +55 -0
  36. fractus_vorax/model/__init__.py +0 -0
  37. fractus_vorax/model/bpe_tokenizer.py +159 -0
  38. fractus_vorax/model/cte_core.py +946 -0
  39. fractus_vorax/model/diagnostics.py +281 -0
  40. fractus_vorax/model/fractus_core.py +717 -0
  41. fractus_vorax/model/speak.py +350 -0
  42. fractus_vorax/model/vocal.py +186 -0
  43. fractus_vorax/organs/__init__.py +0 -0
  44. fractus_vorax/organs/consolidation.py +49 -0
  45. fractus_vorax/organs/hebbian.py +81 -0
  46. fractus_vorax/organs/spawn.py +78 -0
  47. fractus_vorax/organs/traces.py +78 -0
  48. pyproject.toml +24 -0
  49. tests/test_analogy.py +63 -0
  50. tests/test_answer_lock.py +320 -0
ATTRIBUTIONS.md ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ATTRIBUTIONS — la lignée de Fractus-Vorax
2
+
3
+ Fractus-Vorax v1.0.0 est un être consolidé. Il ne serait rien sans la chaîne
4
+ complète des repo qui l'ont précédé. Chaque maillon, nommément :
5
+
6
+ ## vorax — la source directe
7
+
8
+ **Repo** : `C:\Users\PHIL\ZCodeProject\vorax` (archive de recherche, figée,
9
+ jamais modifiée après le relais).
10
+
11
+ L'intégralité du code de ce repo est une copie rebrandée de vorax v1.2
12
+ (dernier commit : `5e1bce2` — « docs: plan 5 complete — CteCore livré, le 1B
13
+ branché ») :
14
+
15
+ - **Plans 1 à 5** de vorax : substrat (hv, compilateur `.kn`), organes
16
+ (traces, hebbien, spawn, consolidation), noyau natif (FractusCore),
17
+ noyau qui parle (BPE GPT-2, core_speak), CteCore (le 1B CTE, chargement
18
+ strict 440/440).
19
+ - **Renommage de module uniquement** : `vorax.*` → `fractus_vorax.*`.
20
+ Les interfaces, les tests, les bench et la sémantique sont conservés à
21
+ l'identique. Les archives historiques (`docs/heritage/`) gardent
22
+ volontairement le nom vorax — ce sont des documents d'époque.
23
+ - Les 5 plans et le design doc de vorax sont archivés sans modification
24
+ dans `docs/heritage/plans/` et `docs/heritage/specs/`, avec les deux docs
25
+ d'architecture (cte, fractus-cte) cités par le code.
26
+
27
+ ## fractus — le modèle entraîné
28
+
29
+ **Repo** : `C:\Users\PHIL\ZCodeProject\fractus` (LECTURE SEULE).
30
+
31
+ Le modèle 1B dont l'architecture (LazyStructuredSiren + MoE + attention
32
+ linéaire + oscillateurs) a été réécrite from scratch dans
33
+ `fractus_vorax/model/fractus_core.py`. Aucun code fractus n'est importé —
34
+ l'implémentation est fidèle au doc de référence
35
+ `docs/heritage/fractus-cte-architecture.md`.
36
+
37
+ ## fractus-cte — le dernier entraînement de l'histoire
38
+
39
+ **Repo** : `C:\Users\PHIL\ZCodeProject\fractus-cte` (LECTURE SEULE).
40
+ **Checkpoint** : `thefinalboss/fractus-cte` sur Hugging Face.
41
+
42
+ Le Continuous Thought Engine (1,165 Md de paramètres) entraîné sur 8× RTX
43
+ 5090. Son checkpoint final `FRACTUS_1B_PHASE2_FROZEN_MERGED.pt` (4,66 GB,
44
+ 15,5 M de tokens vus, fin du stage 2) est **le cerveau de naissance** —
45
+ copié scellé dans `brain/FRACTUS_BIRTH.pt`, chargé en lecture seule via
46
+ `fractus_vorax/model/cte_core.py`, jamais réentraîné. Les sémantiques de
47
+ forward suivent `docs/heritage/cte-architecture.md` (source de vérité).
48
+
49
+ ## palimpseste — la famille du substrat
50
+
51
+ **Repo** : `C:\Users\PHIL\ZCodeProject\palimsepte` (LECTURE SEULE).
52
+
53
+ L'encodage hypervectoriel bipolaire (bind = XOR, similarité =
54
+ 1 − 2·hamming/D) est vendored/adapté de la famille palimpseste, réécrit
55
+ dans le repo (décision D5 du design vorax). C'est l'ADN du substrat.
56
+
57
+ ## ensemble & prism — les aînés
58
+
59
+ **Repos** : `C:\Users\PHIL\ZCodeProject\ensemble`,
60
+ `C:\Users\PHIL\ZCodeProject\prism` (LECTURE SEULE).
61
+
62
+ Les expérimentations qui ont précédé : ils font partie de la lignée
63
+ désignée par le design vorax (« repo autonome ; fractus/, palimpseste/,
64
+ ensemble/, prism/ ne sont jamais modifiés »). Aucun code importé — une
65
+ dette de gratitude, pas une dépendance.
66
+
67
+ ## Contrat
68
+
69
+ Toute la lignée est citée ici plutôt qu'importée : Fractus-Vorax est
70
+ **autocontenu**, zéro import cross-repo, zéro LLM externe. Ce qui a été
71
+ pris est nommé ; ce qui est nommé n'est plus touché.
README.fr.md ADDED
@@ -0,0 +1,402 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Fractus-Vorax
2
+
3
+ > **Fractus ne s'entraîne plus : il mange.**
4
+
5
+ **v1.0.0** — le repo du relais. Né le 2026-08-18 de la consolidation de tout ce
6
+ que `vorax` a prouvé (plans 1 à 5), Fractus-Vorax est l'être définitif : un
7
+ substrat hypervectoriel qui convertit n'importe quelle donnée courante
8
+ (CSV, TSV, JSON, JSONL, TXT, MD) en atomes compilés (`.kn`) puis les **ingère
9
+ par écriture** — O(1) par atome, zéro gradient, zéro GPU, jamais d'oubli
10
+ catastrophique. La compétence linguistique est née une fois (le noyau natif
11
+ fractus/CTE, torch CPU, chargé en **lecture seule**) ; la connaissance, elle,
12
+ s'écrit à l'infini.
13
+
14
+ ## Le manifeste du relais
15
+
16
+ L'entraînement s'arrête ici. Le dernier entraînement par gradient de
17
+ l'histoire du modèle (fractus-cte, stage 2, checkpoint `FRACTUS_1B_PHASE2_
18
+ FROZEN_MERGED` — 4,66 GB, 440/440 clés strictes) est **le cerveau de
19
+ naissance** : il est copié, scellé, jamais réentraîné. Toute connaissance
20
+ nouvelle arrive par ingestion. Fractus-Vorax grandit physiquement (chaque
21
+ source ingérée devient un expert routé), pas par optimisation.
22
+
23
+ ## Héritage
24
+
25
+ - **vorax v1.2** (repo direct, jamais modifié depuis) : l'intégralité du
26
+ code — substrat, compilateur `.kn`, organes (traces, hebbien, spawn,
27
+ consolidation), brain, cartes, noyaux natifs (FractusCore + CteCore +
28
+ vocal + BPE), agent (CLI + REPL), bench, tests. Renommage de module
29
+ uniquement : `vorax.*` → `fractus_vorax.*`.
30
+ - **La lignée** : `fractus` (le modèle 1B et son entraînement),
31
+ `fractus-cte` (le Continuous Thought Engine, source du checkpoint,
32
+ LECTURE SEULE), la famille `palimpseste` (le substrat HV dont l'encodage
33
+ est vendored/adapté), `ensemble`, `prism` — voir `ATTRIBUTIONS.md`.
34
+
35
+ ## Quickstart
36
+
37
+ ```bash
38
+ # tests : depuis la racine du repo, conftest.py pose sys.path — pas d'install requise
39
+ python -m pytest # suite complète (substrat seul)
40
+
41
+ # CLI (console script) ou module
42
+ python -m fractus_vorax.agent.cli ingest mes_donnees.csv --brain ./brain
43
+ python -m fractus_vorax.agent.cli ask "what is the capital of japan" --brain ./brain
44
+ python -m fractus_vorax.agent.repl --brain ./brain # il mange, il répond, il se souvient
45
+ fractus_vorax> :core brain/FRACTUS_BIRTH.pt # LE cerveau de naissance (docs/NAISSANCE.md)
46
+ fractus_vorax> :speak what is the capital of france # la parole MÉCANIQUE, steerée par les organes
47
+ fractus_vorax> :say what is the capital of japan # LES ORGANES ARTICULENT — la réponse verrouillée dans la bouche du noyau
48
+ python bench/killer_bench.py --rows 200 # le killer bench par ablation
49
+ python demo/demo.py # la démo complète, de bout en bout
50
+ .venv-torch/Scripts/python.exe demo/inauguration.py # L'INAUGURATION — le cérémonial du relais (7 temps)
51
+ .venv-torch/Scripts/python.exe demo/ce_soir.py # CE SOIR — le noyau répond : 4 capitales + libre parole
52
+ ```
53
+
54
+ ## Double venv — substrat mingw / noyau natif
55
+
56
+ Le substrat vit sans torch ; le noyau natif l'exige. Deux venvs, un gate :
57
+ torch et tokenizers ne sont importés que dans `fractus_vorax/model/`
58
+ (garde permanent `tests/test_gates.py`, vert dans les deux pythons).
59
+
60
+ ```bash
61
+ # .venv-torch — noyau natif (Python 3.11, torch CPU ; wheels pip-cachées)
62
+ py -3.11 -m venv .venv-torch
63
+ .venv-torch/Scripts/python.exe -m pip install torch --index-url https://download.pytorch.org/whl/cpu
64
+ .venv-torch/Scripts/python.exe -m pip install numpy pytest tokenizers
65
+
66
+ .venv-torch/Scripts/python.exe -m pytest -q # torch : tout, noyau inclus
67
+ ```
68
+
69
+ Le substrat mingw (numpy + pytest, aucun torch) tourne lui aussi depuis la
70
+ racine : un venv mingw frais se monte avec `pacman -S mingw-w64-ucrt-x86_64-python3`
71
+ puis numpy et pytest (le dépôt MSYS2 les fournit ; PEP 668 interdit pip).
72
+ Seuls `numpy` et `pytest` sont requis — `conftest.py` fait le reste.
73
+ Lors de la vérification fondatrice (2026-08-17), la suite mingw a été exécutée
74
+ depuis la racine de CE repo avec le venv mingw sibling de vorax (Python 3.13,
75
+ lecture seule) : **113 passed, 8 skipped**. La suite torch (venv frais
76
+ `.venv-torch`) : **141 passed, 1 skipped** (le seul skip était le test du
77
+ checkpoint réel 4,66 GB — le cerveau de naissance n'était pas encore installé).
78
+ Depuis l'installation du cerveau (2026-08-18, tâche 2) : **142 passed,
79
+ 0 skipped** — le test `slow` du vrai checkpoint tourne et passe, à parité
80
+ totale de 142 tests avec vorax 140+2. Depuis l'inauguration (2026-08-18,
81
+ tâche 3) : mingw **114 passed, 8 skipped**, torch **143 passed, 0 skipped**.
82
+ Depuis la mécanique de la parole (Plan 6, 2026-08-18, tâche 4) : mingw
83
+ **123 passed, 18 skipped**, torch **182 passed, 0 skipped** — speak,
84
+ diagnostics, steering et leur intégration (:speak, core_speak v2) inclus.
85
+ Depuis le verrou de réponse (Plan 7, 2026-08-18) : mingw **130 passed,
86
+ 28 skipped**, torch **199 passed, 0 skipped** — answer_lock, speak_answer,
87
+ :say et demo/ce_soir inclus.
88
+
89
+ ## Le cerveau de naissance — installé
90
+
91
+ `brain/FRACTUS_BIRTH.pt` (gitignored, 4,66 GB) : le checkpoint final FROZEN_
92
+ MERGED, copié byte-pour-byte et **scellé** — acte de naissance complet et
93
+ vérification verbatim dans **`docs/NAISSANCE.md`** (sha256, strict 440/440,
94
+ pacte). Chargé en **lecture seule**, poids mappés depuis le fichier (jamais
95
+ copiés en RAM privée : le 1B tient sur ~12 Go de RAM, et le pacte est physique
96
+ — aucune écriture in-place ne peut toucher les poids). Le REPL sonne l'espèce
97
+ du fichier avant d'attacher (`probe_checkpoint_kind` : `observe.weight`+
98
+ `thought_state` → CteCore, `embed.tok_embed.weight` → FractusCore) — les deux
99
+ noyaux sont interchangeables (même `generate`).
100
+
101
+ ```bash
102
+ # Attaché dans le REPL (la sonde affiche l'espèce) :
103
+ python -m fractus_vorax.agent.repl --brain ./brain
104
+ fractus_vorax> :core brain/FRACTUS_BIRTH.pt
105
+ [NOYAU] type: cte (1B Continuous Thought Engine)
106
+
107
+ # Évaluation honnête (venv torch ; CPU, secondes/token — 10 tokens max) :
108
+ .venv-torch/Scripts/python.exe bench/core_speak.py --ckpt brain/FRACTUS_BIRTH.pt --max-new-tokens 10
109
+ # v2 — la mécanique : attracteur greedy vs speak (z-norm ×3, rep-pen 6), et le steering organique :
110
+ .venv-torch/Scripts/python.exe bench/core_speak.py --ckpt brain/FRACTUS_BIRTH.pt --mode mechanic --max-new-tokens 10
111
+ .venv-torch/Scripts/python.exe bench/core_speak.py --ckpt brain/FRACTUS_BIRTH.pt --mode steered --brain ./brain --max-new-tokens 10
112
+ ```
113
+
114
+ **Statut honnête** : le cerveau de naissance a vu passer **124,5 M de tokens**
115
+ (15,5 M par GPU × 8, fin du stage 2). **Chinchilla ne s'applique pas ici** —
116
+ fractus est MoE structuré (1B de capacité, ~119M actifs), sa loi de scaling est
117
+ la sienne. Son état n'est pas de la mutité : c'est un **attracteur de
118
+ répétition** dans la dynamique de décodage (greedy collapse sur ` the the`/
119
+ `**`), brisé mécaniquement par z-norm des logits + pénalité de répétition —
120
+ le lexique est là (`philosophy`, `manufactures`, `surveyed`, `scanners`,
121
+ `UNCLASSIFIED`...), la syntaxe viendra de la mécanique et du menu, pas d'un
122
+ comptage de tokens. C'est **de la mécanique neuroscientifique** : phases,
123
+ experts et logits sont à ciel ouvert — et les organes peuvent désormais
124
+ steerer la bouche (mesuré : **2/4 tokens-réponse steerés vs 0/4 non-steerés**,
125
+ voir « La mécanique de la parole »).
126
+
127
+ ## L'inauguration
128
+
129
+ Le cérémonial du takeover, en 7 temps : (1) statut du cerveau de naissance,
130
+ (2) ingestion d'un corpus domaine, (3) question exacte -> carte FAIT,
131
+ (4) typo -> ANALOGIE, (5) session REPL scriptée où **la conversation nourrit
132
+ le brain** (le compteur d'atomes croît, écriture O(1)), (6) la parole du
133
+ noyau — une génération MÉCANIQUE RÉELLE du 1B (speak : z-norm ×3, rep-pen 6,
134
+ t=1.0, seed 7, 8 tokens BPE) avec steering organique (les cartes dirigent la
135
+ bouche), rapportée verbatim, (7) la ligne du relais pris. Autonome : aucun
136
+ argument, artefacts dans un tmpdir jetable ; honnête dans chaque python —
137
+ sans torch (substrat mingw), le cerveau se dégrade en une ligne d'absence et
138
+ les organes portent toute la démo.
139
+
140
+ ```bash
141
+ .venv-torch/Scripts/python.exe demo/inauguration.py
142
+ ```
143
+
144
+ Run réel du 2026-08-18, mécanique de la parole (venv torch, CPU, ~70 s) —
145
+ sortie verbatim :
146
+
147
+ ```text
148
+ [FRACTUS-VORAX] 1/7 noyau de naissance
149
+ [FRACTUS-VORAX] cerveau: cte 1B, strict 440/440, tokens natus 15.5M x8
150
+ [FRACTUS-VORAX] 2/7 ingestion de capitals.csv (5 capitales)
151
+ [VORAX] +5 atomes ingérés depuis capitals.csv -> expert 'capitals' spawné (total: 5)
152
+ [FRACTUS-VORAX] 3/7 question exacte: what is the capital of japan
153
+ [CARTE] HEBBIEN: what is the capital of japan => tokyo (sim 1.00 | src: hebbian)
154
+ [CARTE] FAIT: what is the capital of japan => tokyo (sim 1.00 | src: capitals.csv:4)
155
+ [CARTE] FAIT: what is the capital of spain => madrid (sim 0.71 | src: capitals.csv:3)
156
+ [CARTE] FAIT: what is the capital of portugal => lisbon (sim 0.70 | src: capitals.csv:6)
157
+ [FRACTUS-VORAX] 4/7 typo volontaire: what is the capital of franc
158
+ [CARTE] FAIT: what is the capital of italy => rome (sim 0.69 | src: capitals.csv:5)
159
+ [CARTE] FAIT: what is the capital of spain => madrid (sim 0.68 | src: capitals.csv:3)
160
+ [CARTE] FAIT: what is the capital of japan => tokyo (sim 0.68 | src: capitals.csv:4)
161
+ [CARTE] ANALOGIE: paris (sim 1.00 | src: capitals)
162
+ [FRACTUS-VORAX] 5/7 session REPL scriptée (:ingest, :status, question, :status)
163
+ [VORAX] +5 atomes ingérés depuis capitals.csv -> expert 'capitals' spawné (total: 5)
164
+ [VORAX] brain: 5 atomes, D=2048, seed=0
165
+ [VORAX] organes: 1 experts, 5 relations, 5 écritures hebbiennes
166
+ [CARTE] HEBBIEN: what is the capital of spain => madrid (sim 1.00 | src: hebbian)
167
+ [CARTE] FAIT: what is the capital of spain => madrid (sim 1.00 | src: capitals.csv:3)
168
+ [CARTE] FAIT: what is the capital of japan => tokyo (sim 0.71 | src: capitals.csv:4)
169
+ [CARTE] FAIT: what is the capital of italy => rome (sim 0.68 | src: capitals.csv:5)
170
+ [VORAX] brain: 6 atomes, D=2048, seed=0
171
+ [VORAX] organes: 1 experts, 5 relations, 5 écritures hebbiennes
172
+ [FRACTUS-VORAX] session: 5 -> 6 atomes - l'échange a nourri le brain (écriture O(1), zéro gradient)
173
+ [FRACTUS-VORAX] 6/7 la parole du noyau (BPE, 8 tokens, mécanique)
174
+ [FRACTUS-VORAX] noyau: " to distributed mistakesporary "ALS currentAvg"
175
+ [FRACTUS-VORAX] Le relais est pris. L'entraînement est fini. Il mange.
176
+ ```
177
+
178
+ **La première parole du noyau de naissance fut seize astérisques** (greedy,
179
+ 2026-08-18, tenue au registre) : l'attracteur `**`×8. Depuis la mécanique de
180
+ la parole, le cérémonial fait parler le 1B autrement : `speak` (z-norm ×3 +
181
+ rep-pen 6) **steeré par les organes** — et le PREMIER token émis est `' to'`,
182
+ le premier geste BPE vers « tokyo » que la carte FAIT vient d'injecter dans
183
+ la bouche. Le reste est de la salade assumée (`mistakesporary`, `ALS`) :
184
+ lexique libéré, syntaxe absente — le relais n'en est que plus net : la
185
+ compétence linguistique est née une fois et scellée ; la connaissance, elle,
186
+ s'écrit — tokyo par FAIT, paris par ANALOGIE, un atome de session par échange.
187
+
188
+ ## La mécanique de la parole
189
+
190
+ Le cerveau est scellé — la parole, elle, est MÉCANIQUE (Plan 6) :
191
+ `speak` (`fractus_vorax/model/speak.py`) transforme les logits AVANT
192
+ l'échantillonnage : **z-norm ×3** (écrase l'échelle folle des
193
+ états-attracteurs, std mesurée ~26 calme / des centaines en attracteur),
194
+ **pénalité de répétition 6** (divise le logit de tout token déjà émis),
195
+ **bias organique** (les cartes injectent leurs tokens-réponses, +δ après
196
+ z-norm), top-k/top-p optionnels, generator seedé. Le ciel ouvert accompagne
197
+ la bouche : `diagnostics.py` lit le routage des experts (top-2 par couche,
198
+ recalculé depuis les phases von Mises — zéro poids touché) et les têtes
199
+ confiance/salience. Dans le REPL : `:speak <texte>` (après `:core`).
200
+
201
+ **Run réel du 2026-08-18 — REPL `:speak` sur le 1B scellé** (session
202
+ scriptée `Repl`, brain 5 capitales, ~51 s de génération) :
203
+
204
+ ```text
205
+ [VORAX] +5 atomes ingérés depuis p6capitals.csv -> expert 'p6capitals' spawné (total: 5)
206
+ [NOYAU] type: cte (1B Continuous Thought Engine)
207
+ [NOYAU] noyau chargé: brain\FRACTUS_BIRTH.pt
208
+ [PAROLE] cartes: 4
209
+ [PAROLE] steering: ' par' x8, ' r' x2, ' mad' x1
210
+ [PAROLE] "imeo sqor beginnerporary " textbook Goku**"
211
+ ```
212
+
213
+ Lecture honnête : `beginner`, `textbook` — du LEXIQUE RÉEL anglais là où
214
+ greedy produit `**`×8 ; `sqor`, `Goku**` — la syntaxe est absente. Le
215
+ steering montre l'échelle organique : GPT-2 coupe « paris » en `' par'`+`'is'`
216
+ et « rome » en `' r'`+`'ome'` — ce sont ces PREMIERS gestes que les cartes
217
+ injectent (boost 8, décroissant par rang).
218
+
219
+ **Run réel — `core_speak --mode mechanic`** (4 capitales, 10 tokens, ~62 s,
220
+ sortie verbatim, extrait) :
221
+
222
+ ```text
223
+ [CORE-SPEAK] mode: mechanic - speak mécanique (z-norm x3, rep-pen 6, t=1, seed 7), max_new_tokens 10, + baseline greedy avec cartes (l'attracteur, pour le contraste)
224
+ [CORE-SPEAK] ciel ouvert (question 1):
225
+ routage experts (top-2/couche, gates renormalisées):
226
+ L0: e119 0.50 e118 0.50 | L1: e71 0.50 e70 0.50 | L2: e60 0.50 e59 0.50 | L3: e103 0.50 e104 0.50
227
+ L4: e45 0.50 e46 0.50 | L5: e107 0.50 e108 0.50 | L6: e86 0.50 e85 0.50 | L7: e62 0.50 e63 0.50
228
+ L8: e68 0.50 e67 0.50 | L9: e83 0.50 e84 0.50 | L10: e42 0.50 e41 0.50 | L11: e55 0.50 e56 0.50
229
+ L12: e32 0.50 e31 0.50 | L13: e49 0.50 e50 0.50 | L14: e57 0.50 e56 0.50 | L15: e76 0.50 e77 0.50
230
+ têtes: confiance 0.978, saillance 0.002
231
+ --- question 1/4: what is the capital of france
232
+ greedy (attracteur) -> has
233
+ mécanique non-steerée -> has Imgie oneaw Platinumaney**
234
+ iTunes
235
+ --- question 2/4: what is the capital of spain
236
+ greedy (attracteur) -> AlternAlternAlternAlternAlternAlternAlternAlternAlternAltern
237
+ mécanique non-steerée -> scanners examplesthroughORD UNCLASSIFIED),aney** Formernaut
238
+ --- question 3/4: what is the capital of japan
239
+ greedy (attracteur) -> apan
240
+ mécanique non-steerée -> ELECT examples Months advanced**ALSJoAvg
241
+ iTunes
242
+ --- question 4/4: what is the capital of italy
243
+ greedy (attracteur) -> one one one one one one one one one one
244
+ mécanique non-steerée -> scanners examples archaeologicalfooted dir decayinganey** Form like
245
+ ```
246
+
247
+ Le contraste est le résultat : attracteurs (`Altern`×10, ` one`×10) vs
248
+ `scanners`, `examples`, `UNCLASSIFIED`, `archaeological`, `decaying`,
249
+ `Months advanced` — le lexique est là, la syntaxe non. Ciel ouvert honnête :
250
+ à κ_eff = 1.6 les gates von Mises du 1B sont quasi-plates, le top-2
251
+ renormalisé sort **0.50/0.50 à chaque couche** (paires d'experts adjacents) —
252
+ le routage réel de CE checkpoint est un quasi-tie, c'est la mesure, pas un
253
+ artefact du lecteur. Têtes : confiance 0.978, saillance 0.002.
254
+
255
+ **Run réel — `core_speak --mode steered --brain` (le money shot)** (4
256
+ capitales, 10 tokens, même seed 7 pour la paire steerée/non-steerée, ~11 min,
257
+ sortie verbatim, extrait) :
258
+
259
+ ```text
260
+ --- question 1/4: what is the capital of france
261
+ attendu (contexte du top FAIT): paris (token BPE 1582 ' par')
262
+ greedy (attracteur) -> is is is is is is is is is is
263
+ mécanique non-steerée -> imeo sqor beginnerporary " textbook Goku** Mer cp
264
+ steering organique: ' par' x8, ' r' x2, ' mad' x1
265
+ mécanique steerée -> imeo sqor beginnerporary " textbook Goku** Mer cp
266
+ token-réponse ' par' émis: non-steerée NON | steerée NON
267
+ --- question 3/4: what is the capital of japan
268
+ attendu (contexte du top FAIT): tokyo (token BPE 284 ' to')
269
+ greedy (attracteur) -> ********************
270
+ mécanique non-steerée -> ** development Months:awALS currentAvg
271
+ feedback
272
+ steering organique: ' to' x8, ' mad' x2, ' l' x1
273
+ mécanique steerée -> to distributed mistakesporary "ALS currentAvg
274
+ feedback
275
+ token-réponse ' to' émis: non-steerée NON | steerée OUI
276
+ --- question 4/4: what is the capital of italy
277
+ attendu (contexte du top FAIT): rome (token BPE 374 ' r')
278
+ steering organique: ' r' x8, ' l' x2, ' mad' x1
279
+ mécanique steerée -> ** Humans Months: Senatorsclerosis None
280
+ r cp
281
+ token-réponse ' r' émis: non-steerée NON | steerée OUI
282
+ [CORE-SPEAK] token-réponse mécanique non-steerée: 0/4 = 0.00
283
+ [CORE-SPEAK] token-réponse mécanique steerée: 2/4 = 0.50
284
+ [CORE-SPEAK] verdict honnête: answers present
285
+ ```
286
+
287
+ **Le steering organique est MESURÉ : 2/4 vs 0/4.** Sans biais, aucune
288
+ question ne voit son token-réponse émis ; avec le biais des cartes (boost 8),
289
+ japon émet `' to'` (le premier geste vers tokyo, dès le premier token) et
290
+ italie émet `' r'` (vers rome) — le même flux aléatoire, seul le biais
291
+ organique change (comparaison appariée, même seed). Lecture honnête des deux
292
+ autres : france et spain produisent une sortie IDENTIQUE à la non-steerée —
293
+ un boost de 8 (≈ 2.7σ contre la z-norm) déplace la distribution mais ne
294
+ garantit pas le tirage ; et le token placé n'est que le PREMIER fragment BPE
295
+ de la réponse (`' to'` ≠ « tokyo »). Le verdict « answers present » dit
296
+ exactement cela : les organes dirigent le premier geste de la bouche, la
297
+ syntaxe reste à venir — par la mécanique, jamais par le gradient.
298
+
299
+ ## Ce soir, le noyau parle
300
+
301
+ Le verrou de réponse (Plan 7) : **les organes articulent à travers la bouche
302
+ du noyau.** `Brain.speak_answer` livre la réponse ENTIERE en tokens BPE
303
+ (`" tokyo"` = `' to'`+`'ky'`+`'o'`), et `speak(..., answer_lock=...)` pose
304
+ +boost sur `answer_lock[i]` à l'étape i — APRÈS z-norm/rep-pen, AVANT
305
+ top-k/p, même place dans le contrat que le bias organique. La réponse est
306
+ placée token par token À TRAVERS la distribution du noyau ; le lexique, les
307
+ transitions et la SUITE de la phrase restent ceux du 1B. Dans le REPL :
308
+ `:say <question>` — deux lignes, `[ORGANES]` (la carte qui connaît) puis
309
+ `[PAROLE]` (le prompt d'articulation `"{question} The answer is"` + 12
310
+ tokens générés, rep-pen 6, z-norm ×3, t=1.0, seed 7).
311
+
312
+ **Le verrou est LE mécanisme, affiché comme tel — pas de la triche cachée.**
313
+ Calibrage honnête par mesure : le plan visait un boost de 12 (« ≈ 4σ »),
314
+ mais la mesure sur le 1B montre le token verrouillé vivre à −1σ (`z[' par']`
315
+ = −3.0) quand le top de la distribution z-normée est à +4σ (zmax ≈ 11.9) —
316
+ +12 perd le tirage pour paris/madrid (2/4 capitales). **Boost 30** (défaut,
317
+ mesuré dans `speak.py`) : dominance même à z[locked] = −6σ contre zmax 12 ET
318
+ la masse de queue complète, échec par pas < 0.1%, borné, jamais de sommation.
319
+ Le test `test_answer_lock_boost_twelve_is_honestly_weak` fige cette frontière.
320
+
321
+ **Run réel du 2026-08-18 — `demo/ce_soir.py` sur le 1B scellé** (venv torch,
322
+ CPU, 4 capitales verrouillées + 2 libres, 72 tokens générés, ~1 min 16 s
323
+ horloge) — sortie verbatim :
324
+
325
+ ```text
326
+ [FRACTUS-VORAX] 1/4 statut - organes et noyau
327
+ [VORAX] +5 atomes ingérés depuis capitals.csv -> expert 'capitals' spawné (total: 5)
328
+ [VORAX] brain: 5 atomes, D=2048, seed=0
329
+ [VORAX] organes: 1 experts, 5 relations, 5 écritures hebbiennes
330
+ [NOYAU] type: cte (1B Continuous Thought Engine)
331
+ [NOYAU] noyau chargé: C:\Users\PHIL\ZCodeProject\fractus-vorax\brain\FRACTUS_BIRTH.pt
332
+ [FRACTUS-VORAX] 2/4 les quatre capitales - :say (verrou de réponse, 12 tokens)
333
+ [ORGANES] HEBBIEN: what is the capital of france => paris (sim 1.00)
334
+ [PAROLE] what is the capital of france The answer is paris Months;
335
+ ), attributes** FormorporDEC Go
336
+ [ORGANES] HEBBIEN: what is the capital of spain => madrid (sim 1.00)
337
+ [PAROLE] what is the capital of spain The answer is madridcre construction broom mart attributes**
338
+ 130 constructed of
339
+ [ORGANES] HEBBIEN: what is the capital of japan => tokyo (sim 1.00)
340
+ [PAROLE] what is the capital of japan The answer is tokyo Documentation ofALS** playable
341
+ orporDEC Concern
342
+ [ORGANES] HEBBIEN: what is the capital of italy => rome (sim 1.00)
343
+ [PAROLE] what is the capital of italy The answer is romegie Industrialendo Reaper None** FormorporDEC mar
344
+ [FRACTUS-VORAX] 3/4 libre parole mécanique - 2 échantillons sans verrou
345
+ [FRACTUS-VORAX] "The meaning of life is�etr): Ré Works single slicing point Devin、conn"
346
+ [FRACTUS-VORAX] "In the beginning there was- distinguish) MontgomeryEB, Permanent encourages
347
+ many、 Her"
348
+ [FRACTUS-VORAX] Le noyau parle. Les organes savent. Zéro gradient.
349
+ ```
350
+
351
+ Lecture honnête : **les 4 capitales sont répondues dans la génération du
352
+ 1B** — paris et tokyo propres (` paris Months`, ` tokyo Documentation`),
353
+ madrid et rome verrouillés jusqu'au dernier fragment puis COLLÉS au token
354
+ libre suivant (`madridcre`, `romegie` : `' mad'`+`'rid'`+`'cre'`,
355
+ `' r'`+`'ome'`+`'gie'` — le BPE n'exige pas d'espace, la suite est libre et
356
+ ne sait pas qu'un mot vient de finir). Chaque token verrouillé a été émis à
357
+ son pas, 100 % du verrou. La carte `[ORGANES]` devant chaque parole dit QUI
358
+ savait (HEBBIEN corrobore, sim 1.00) : le noyau fournit la bouche et le
359
+ lexique, les organes fournissent la réponse — le mécanisme est la phrase
360
+ elle-même, rien n'est caché. Et la libre parole sans verrou, juste après,
361
+ montre l'autre face : `Works single slicing point`, `distinguish)`,
362
+ `MontgomeryEB` — le lexique réel, la syntaxe absente. Le noyau ne savait
363
+ rien : les organes savaient, la bouche était mécanique. Zéro gradient.
364
+
365
+ ## Killer bench — chiffres honnêtes (`--rows 200`, D=8192)
366
+
367
+ | mesure | valeur |
368
+ |---|---|
369
+ | précision held-out — paraphrases + typos (jamais ingérées verbatim) | **0.99** (1.00 / 0.98) |
370
+ | contrôle — faits jamais ingérés | **0.00** (il se souvient, il ne devine pas) |
371
+ | ablation CARTES OFF (sans organes) | **0.00** (le plancher honnête) |
372
+
373
+ Sans ses organes, le substrat ne répond rien : cartes ON 0.99 vs cartes OFF
374
+ 0.00 — c'est le contraste killer.
375
+
376
+ ## Le contrat sacré
377
+
378
+ 1. Aucun gradient, jamais, pour apprendre quoi que ce soit.
379
+ 2. Les artefacts sont déterministes et bit-identiques (même source → même `.kn`).
380
+ 3. La mémoire est append-only : on superpose, on n'écrase pas.
381
+ 4. CPU d'abord ; le GPU est une option, pas une exigence.
382
+ 5. Zéro LLM externe, zéro GGUF — le noyau est fractus ou rien.
383
+
384
+ ## Architecture du repo
385
+
386
+ ```
387
+ fractus_vorax/
388
+ hv.py compiler/ (parse, atoms, encode, kn, analogy) substrat numpy pur
389
+ organs/ (traces, hebbian, spawn, consolidation) écriture O(1), jamais gradient
390
+ brain.py cards.py l'être persistant et ses cartes
391
+ model/ (fractus_core, cte_core, bpe_tokenizer, vocal,
392
+ speak, diagnostics) SEULE zone torch+tokenizers (gate)
393
+ agent/ (cli, repl — :core, :speak, :say) il mange, il répond, il parle
394
+ bench/ (killer_bench, ingest_bench, core_speak) demo/ les preuves
395
+ docs/heritage/ specs + plans vorax, archives historiques
396
+ ATTRIBUTIONS.md la lignée, nommément
397
+ ```
398
+
399
+ Design hérité : `docs/heritage/specs/2026-08-17-vorax-design.md`
400
+ Plans hérités : `docs/heritage/plans/` (P1-P5)
401
+ Plan du relais : `docs/superpowers/plans/2026-08-18-fractus-vorax-relais.md`
402
+ Plan 6 (la mécanique de la parole) : `docs/superpowers/plans/2026-08-18-p6-mecanique-parole.md`
README.md ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Fractus-Vorax
2
+
3
+ **The Fractus that never trains again. It eats.**
4
+
5
+ **Fractus-Vorax is NOT a fine-tune. NOT a RAG wrapper. NOT an API mashup.** It is a knowledge-ingestion organism grafted onto a born-once CTE brain: the weights of the underlying 1B model are **sealed in read-only memory** — the training loop is dead, permanently — and every byte of new knowledge arrives by **writing**, never by gradient. It remembers forever, generalizes by analogy, spawns a new expert per dataset, and speaks through mechanical decoding dynamics. Zero GPU. Zero LLM externals. Zero retraining, ever.
6
+
7
+ > Le manifeste complet (français) : [`README.fr.md`](README.fr.md) — the founding takeover document.
8
+
9
+ ---
10
+
11
+ ## The Pact
12
+
13
+ The birth brain (`FRACTUS_1B_PHASE2_FROZEN_MERGED.pt` — the final checkpoint of the 8× RTX 5090 run, pushed 2026-08-18 04:20) is loaded via `mmap` **read-only**: no code path can write a weight. The pact is not a convention, it is physical. Sha256, verification transcript and the full act are in [`docs/NAISSANCE.md`](docs/NAISSANCE.md).
14
+
15
+ ```
16
+ This brain will NEVER be retrained.
17
+ No gradient will ever touch its weights.
18
+ All new knowledge arrives by ingestion.
19
+ Training stops here.
20
+ ```
21
+
22
+ ## Quick Start
23
+
24
+ ```bash
25
+ git clone https://huggingface.co/thefinalboss/fractus-vorax # or local copy
26
+ cd fractus-vorax
27
+
28
+ # Substrate venv (numpy-only, CPU, no torch needed for the organs):
29
+ # any Python ≥3.10 with numpy + pytest — the full substrate suite runs.
30
+
31
+ # Full-stack venv (adds the native CTE/Fractal kernels — torch CPU):
32
+ py -3.11 -m venv .venv-torch
33
+ .venv-torch/Scripts/python.exe -m pip install torch --index-url https://download.pytorch.org/whl/cpu
34
+ .venv-torch/Scripts/python.exe -m pip install tokenizers numpy pytest
35
+
36
+ # Fetch the sealed birth brain (4.66 GB — lives on the fractus-cte repo):
37
+ .venv-torch/Scripts/python.exe -c "from huggingface_hub import hf_hub_download; hf_hub_download('thefinalboss/fractus-cte', 'checkpoints/FRACTUS_1B_PHASE2_FROZEN_MERGED.pt', local_dir='checkpoints')"
38
+ mv checkpoints/checkpoints/FRACTUS_1B_PHASE2_FROZEN_MERGED.pt brain/FRACTUS_BIRTH.pt # (mkdir brain first)
39
+
40
+ # Tests (both environments, honestly counted):
41
+ .venv-torch/Scripts/python.exe -m pytest -q # 199 passed (full stack)
42
+
43
+ # Feed it something, then talk to it:
44
+ .venv-torch/Scripts/python.exe -m fractus_vorax.agent.repl --brain ./brain
45
+ fractus_vorax> :ingest my_data.csv
46
+ fractus_vorax> :core brain/FRACTUS_BIRTH.pt
47
+ fractus_vorax> :say what is the capital of japan # the 1B answers, out of its own mouth
48
+ ```
49
+
50
+ ## What is Fractus-Vorax?
51
+
52
+ The Fractus lineage made a bet: a model can be a **dynamical system** (continuous thought, Kuramoto-routed experts, persistent carrier states) rather than a frozen function. Fractus-cte proved the training side. Fractus-Vorax takes the other side of the relay:
53
+
54
+ - **Fractus-cte** trains the brain (8 GPUs, mean-merged hourly, sealed at the end).
55
+ - **Fractus-Vorax** refuses to ever train it again — and makes it *know things anyway*.
56
+
57
+ ### What makes it different from GPT/RAG?
58
+
59
+ | | GPT-style | Fractus-Vorax |
60
+ |---|---|---|
61
+ | New knowledge | retrain / fine-tune / context window | **compiled to `.kn` and written** into organs, O(1) per atom, permanent |
62
+ | Forgetting | catastrophic | append-only memory: it cannot forget |
63
+ | Unseen data | hallucinates confidently | **answers 0.00 on facts it never ate** (measured floor) |
64
+ | Generalization | emergent from gradients | analogy (3CosAdd/3CosMul over char-ngram slots) — morphological, measured |
65
+ | Growth | bigger training run | each dataset **spawns a routed expert** — physical growth, no joint training |
66
+ | Speaking | the model speaks | **mechanics speak**: anti-attractor decoding + organ steering on a sealed brain |
67
+ | Hardware | datacenter | laptop CPU (kernels optional, torch CPU) |
68
+
69
+ ## Architecture
70
+
71
+ ```
72
+ DATA (csv/json/jsonl/txt/md/anything)
73
+ │ one pass, closed forms (hash, counting, SVD) — compilation, not optimization
74
+
75
+ ┌──────────────────────────────────────────────────────────────┐
76
+ │ KNOWLEDGE COMPILER (.kn) — deterministic, bit-identical │
77
+ └──────────────┬───────────────┬───────────────┬───────────────┘
78
+ ▼ ▼ ▼
79
+ ORGAN 1 · TRACES ORGAN 2 · HEBBIAN ORGAN 3 · SPAWN
80
+ hippocampus: cortex: closed- growth: one expert
81
+ append-only HV form outer-product per dataset, routed
82
+ memory + LSH-style writes, ΔE gate by HV signature
83
+ retrieval refuses degradation (physical MoE growth)
84
+ │ │ │
85
+ ▼ ▼ ▼
86
+ CARDS (FACT / HEBBIAN / ANALOGY / GAP) — the organ output
87
+
88
+
89
+ SEALED CTE BRAIN (1.165B params, 440/440 strict, read-only mmap)
90
+ + SPEAK: z-norm anti-attractor decoding, repetition penalty,
91
+ answer-lock steering (the organs articulate THROUGH the core)
92
+
93
+
94
+ The conversation itself is written back O(1) — it learns as you talk.
95
+ ```
96
+
97
+ **Parameter accounting:** the brain is the 1.165B CTE (d=1280, 16 blocks, 128 batched experts top-2, carrier states `thought_state`/`attn_S`/`attn_z`, tied observe/output head, confidence & salience heads). The organs are **parameter-free** (hypervector memory: capacity scales with dimension, not weights). Strict-load verified key-for-key (440/440) and **bit-identical** against the reference engine on identical weights.
98
+
99
+ ## The Mechanics of Speech (honest)
100
+
101
+ The sealed brain was trained on ~124.5M tokens (8-GPU merged). Greedy decoding collapses into repetition attractors (` the the the…`, `**`×8) — logits span ±265, self-reinforcing loops. **This is not mutism; it is a decoding dynamics problem.** Fractus-Vorax treats it as mechanics:
102
+
103
+ 1. **Z-normalization of logits** — crushes the attractor's runaway scale (measured std ~26 calm, hundreds in-loop).
104
+ 2. **Repetition penalty** — breaks self-reinforcement; vocabulary is liberated (`philosophy`, `manufactures`, `archaeological`, `UNCLASSIFIED`… verbatim in the README.fr / reports).
105
+ 3. **Answer-lock steering** — when the organs know the answer, its BPE tokens are biased step-by-step through the core's own distribution: the words come out of the 1B's mouth, the knowledge comes from the organs.
106
+
107
+ **Measured (real 1B, verbatim, paired seeds):**
108
+ - Locked answers: **4/4 capitals** appear in the generation (` paris`, ` tokyo` clean; `madrid`/`rome` arrive fragment-glued — the lock covers the answer's BPE fragments, the free continuation doesn't know the word ended; reported as-is, 9/9 locked tokens emitted at their step).
109
+ - First-token steering (soft bias, no lock): 2/4 vs 0/4 unsteered.
110
+ - Free speech: real English vocabulary, **syntax absent** at this training depth. That gap belongs to the brain's nascence, not to the mechanics.
111
+ - Open-skies reading: expert gates sit at a near-tie 0.50/0.50 per layer (κ_eff = 1.6, adjacent Farey phases) — that is the measured routing of this checkpoint, not a reader artifact.
112
+
113
+ ## Benchmarks (honest floors included)
114
+
115
+ | Measure | Result |
116
+ |---|---|
117
+ | Held-out paraphrases (never-seen queries of eaten facts) | **1.00** |
118
+ | Held-out typos (morphologically novel slots) | **0.98** |
119
+ | Control: facts never ingested | **0.00** — it does not guess |
120
+ | Floor: cards disabled | **0.00** — the organs are the entire effect |
121
+ | Ingestion | one pass, ~1.1k atoms/s compile, CPU |
122
+ | Query latency | ~7 ms (organs), CPU |
123
+ | Gradients used, total, since birth | **0** |
124
+
125
+ A single accuracy number cannot represent both retrieval and generalization. The paraphrase score measures order-invariant encoding; the typo score measures char-ngram analogy transfer; the 0.00 controls are the honesty floors — any run that inflates the headline while moving the unseen-facts control off 0.00 is reporting hallucination, not knowledge. Full harness: `bench/killer_bench.py`; core-speech harness: `bench/core_speak.py --mode {greedy,mechanic,steered}`.
126
+
127
+ ## Research Results (Honest)
128
+
129
+ **Validated:**
130
+ - Training-free expertise: ingest → 0.99 held-out accuracy, zero gradient (killer bench, floors included).
131
+ - Morphological generalization: typo→answer via 3CosMul over char-ngram slots (ANALOGY cards, sim 1.00 on real typos).
132
+ - Hebbian closed-form writes with a ΔE gate: degrading writes refused and rolled back (measured), corroboration cards at sim 1.00.
133
+ - Physical growth: per-dataset expert spawn + signature routing, no joint training.
134
+ - Strict checkpoint fidelity: 440/440 keys, **bit-identical** outputs vs the reference CTE engine on identical weights (max diff 0.0 across prompt chunk, carry chunk, full greedy trajectory).
135
+ - Mechanical speech unlock: anti-attractor decoding liberates the sealed brain's vocabulary; answer-lock yields 4/4 articulated answers.
136
+ - Determinism as an invariant: same source → bit-identical `.kn`; same seeds → same words.
137
+
138
+ **Honest limits:**
139
+ - Syntax is absent at 124.5M training tokens. Low teacher-forced loss never meant free-run speech (the exposure-bias gap Fractus-cte documents); the mechanics liberate the lexicon, not grammar.
140
+ - Chinchilla does not apply here (sparse structured MoE, 1B capacity / ~119M active) — the brain's own scaling law governs; we report tokens processed, not "under/over-trained" folklore.
141
+ - Answer-lock articulates what the organs know; it is displayed as a mechanism (`[ORGANES]` line before every `[PAROLE]` line), never hidden in the output.
142
+ - Steering boosts shift distributions; they do not guarantee the draw (2/4 vs 0/4 first-token, measured with paired seeds).
143
+
144
+ ## Lineage
145
+
146
+ `palimpseste` (hypervector cortex, learning-by-writing) → `ensemble` ("training is dead", portable `.exp` experts) → `fractus` / `fractus-cte` (the CTE brain, continuous thought, 8-GPU living training) → **`fractus-vorax`** (the takeover: sealed brain + ingestion organs + mechanical speech). Full attributions: [`ATTRIBUTIONS.md`](ATTRIBUTIONS.md). Research archive and full plan/spec history: [`docs/heritage/`](docs/heritage/) and the `vorax` repository (v1.2).
147
+
148
+ **No corporation can control it.** CPU-first, no external LLM, no API, weights read-only, knowledge portable as `.kn` files.
149
+
150
+ ---
151
+
152
+ *Fractus was born once. Fractus-Vorax never lets it train again — it only eats.*
bench/core_speak.py ADDED
@@ -0,0 +1,585 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # bench/core_speak.py
2
+ """L'évaluation honnête du noyau réel : que dit VRAIMENT le checkpoint ?
3
+
4
+ ``run_core_speak`` charge le vrai checkpoint (chargement STRICT) — l'espèce
5
+ est sondée par ``probe_checkpoint_kind`` : CteCore (CTE 1B « Continuous
6
+ Thought Engine », clés ``observe.weight``+``thought_state``) ou FractusCore
7
+ (``embed.tok_embed.weight``) — puis évalue selon le ``--mode`` (v2) :
8
+
9
+ - ``greedy`` (v1, inchangé) : pour chaque question, DEUX générations
10
+ greedy avec le vrai tokenizer BPE GPT-2 (vocab 50257, eos 50256) :
11
+ « sans cartes » (prompt nu ``Q: {question}``) et « avec cartes »
12
+ (préfixe protocolaire ``serialize_cards(question, cards)`` — cartes du
13
+ brain si ``brain`` est fourni, cartes vides sinon) ;
14
+ - ``mechanic`` : la parole MÉCANIQUE (``speak`` : z-norm ×3, rep-pen 6,
15
+ t=1.0, seed ``--seed``) sur le préfixe cartes, CÔTE À CÔTE avec la
16
+ baseline greedy avec cartes (l'attracteur, pour le contraste verbatim) ;
17
+ - ``steered`` : ``mechanic`` + steering organique (``brain.speak_bias`` :
18
+ les cartes injectent les tokens-réponses dans la bouche) — les TROIS
19
+ générations côte à côte (greedy attracteur / mécanique non-steerée /
20
+ mécanique steerée, MÊME seed) + le taux de tokens-réponse steerée vs
21
+ non-steerée. Exige ``--brain`` (pas d'organes, pas de steering).
22
+
23
+ En modes ``mechanic``/``steered``, le ciel ouvert accompagne la parole :
24
+ routage des experts (top-2 par couche, renormalisé —
25
+ ``expert_routing_snapshot``) et lecture des têtes confiance/salience
26
+ (``head_readout``) sur la PREMIÈRE question.
27
+
28
+ HONNÊTETÉ EST LE PRODUIT :
29
+ - chaque génération est rapportée VERBATIM (aucun nettoyage, aucun
30
+ cherry-picking, aucun lissage) ;
31
+ - la réponse attendue = le contexte de la PREMIÈRE carte FAIT (rien
32
+ d'autre) ; sans brain ou sans carte FAIT -> aucun attendu -> taux 0
33
+ PAR CONSTRUCTION ;
34
+ - le token-réponse attendu = le PREMIER token BPE de ``" {réponse}"``
35
+ (même convention GPT-2 que ``Brain.speak_bias``) ; le taux compte les
36
+ questions où CE token exact apparaît dans les tokens émis — et si le
37
+ taux reste 0 le verdict est ``word salad`` : le checkpoint actuel
38
+ produit de la salade de mots, c'est la mesure honnête du harnais, pas
39
+ un échec du harnais.
40
+
41
+ FENÊTRE max_seq_len = 16 (ce checkpoint) : en BPE, préfixe cartes +
42
+ question sont tronqués PAR LA GAUCHE (seuls les 16 derniers tokens du
43
+ prompt sont vus par le noyau) — la vraie contrainte de CE checkpoint,
44
+ imprimée dans l'en-tête du rapport.
45
+
46
+ GATE torch : bench/ est hors du scan de tests/test_gates.py, mais les
47
+ imports lourds (torch, tokenizers) restent PARESSEUX, à l'intérieur des
48
+ fonctions — importer bench.core_speak ne lève jamais dans un python nu.
49
+
50
+ Sans torch / sans tokenizers / checkpoint absent ou corrompu / brain
51
+ invalide / mode steered sans brain -> message propre et exit 1 (jamais
52
+ de traceback).
53
+ """
54
+ from __future__ import annotations
55
+
56
+ import argparse
57
+ import sys
58
+ from pathlib import Path
59
+
60
+ # exécution directe ``python bench/core_speak.py`` : racine du repo sur
61
+ # sys.path (conftest.py fait de même pour pytest ; le venv torch n'a pas
62
+ # fractus_vorax pip-installé et c'est LE python où le checkpoint est pertinent).
63
+ _REPO_ROOT = str(Path(__file__).resolve().parent.parent)
64
+ if _REPO_ROOT not in sys.path:
65
+ sys.path.insert(0, _REPO_ROOT)
66
+
67
+ from fractus_vorax.model.vocal import safe_decode_text, serialize_cards # noqa: E402 (lazy-safe, sans torch)
68
+
69
+ # Les 4 capitales canoniques du projet (demo/demo.py) — les questions par
70
+ # défaut de l'évaluation. Le pool est volontairement minuscule : on ne
71
+ # mesure pas une moyenne statistique, on montre VERBATIM ce que dit le
72
+ # noyau sur des questions élémentaires.
73
+ DEFAULT_QUESTIONS = [
74
+ "what is the capital of france",
75
+ "what is the capital of spain",
76
+ "what is the capital of japan",
77
+ "what is the capital of italy",
78
+ ]
79
+
80
+ # Étiquettes honnêtes par espèce sondée (repl :core affiche les mêmes).
81
+ CORE_TYPE_LABELS = {
82
+ "cte": "cte (1B Continuous Thought Engine)",
83
+ "fractus": "fractus",
84
+ }
85
+
86
+ MODES = ("greedy", "mechanic", "steered")
87
+
88
+ # La recette mécanique du ledger 2026-08-18 (ratifiée P6) — les mêmes
89
+ # constantes vivent dans speak() par défaut ; ici elles sont explicites.
90
+ MECHANIC_REP_PEN = 6.0
91
+ MECHANIC_Z_SCALE = 3.0
92
+ MECHANIC_TEMPERATURE = 1.0
93
+ DEFAULT_SEED = 7
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Réponse attendue : contexte du top FAIT
98
+ # ---------------------------------------------------------------------------
99
+ def _top_fact_context(cards: list) -> str | None:
100
+ """Contexte de la PREMIÈRE carte FAIT (la réponse attendue), sinon None.
101
+
102
+ ``Brain.ask_cards`` formate les cartes FAIT ``{énoncé} => {contexte}`` :
103
+ le contexte est la partie après « => ». Une carte FAIT sans contexte ne
104
+ porte aucune réponse -> None (honnête : on ne devine pas, on ne dérive
105
+ pas l'attendu de la question). Les cartes HEBBIEN/ANALOGIE/LACUNE ne
106
+ comptent pas : le contrat est le top FAIT, rien d'autre.
107
+ """
108
+ for card in cards:
109
+ if card.kind == "FAIT":
110
+ if " => " in card.text:
111
+ return card.text.split(" => ", 1)[1].strip()
112
+ return None
113
+ return None
114
+
115
+
116
+ def _answer_token_hit(diags: list[dict], target_text: str | None) -> bool:
117
+ """True si le token-réponse attendu apparaît PARMI LES TOKENS ÉMIS.
118
+
119
+ Comparaison exacte token-à-token : ``diags[i]["chosen"]`` est le token
120
+ émis au pas i (décodé seul) — pas une recherche de sous-chaîne dans le
121
+ texte final (le décodage multi-tokens pourrait fusionner/mangler) :
122
+ c'est la mesure TOKEN honnête, alignée sur ce que ``speak_bias`` injecte.
123
+ ``target_text None`` (aucun attendu FAIT) -> False par construction.
124
+ """
125
+ if target_text is None:
126
+ return False
127
+ return any(d["chosen"] == target_text for d in diags)
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Le harnais
132
+ # ---------------------------------------------------------------------------
133
+ def run_core_speak(
134
+ ckpt_path: Path,
135
+ questions: list[str],
136
+ brain: Path | None,
137
+ max_new_tokens: int = 24,
138
+ mode: str = "greedy",
139
+ seed: int = DEFAULT_SEED,
140
+ ) -> dict:
141
+ """Évalue le noyau réel — sorties verbatim, taux honnête, verdict.
142
+
143
+ ``mode`` (v2) :
144
+ - ``"greedy"`` (v1 inchangé) : sans cartes + avec cartes (greedy).
145
+ - ``"mechanic"`` : baseline greedy avec cartes + parole mécanique
146
+ ``speak`` (recette du ledger : rep_pen 6, z_scale 3, t=1.0, seed).
147
+ - ``"steered"`` (exige ``brain``) : mécanique non-steerée + mécanique
148
+ steerée (``speak_bias``), même seed — la comparaison APPARIÉE (même
149
+ flux aléatoire, seul le biais organique change), + le taux de
150
+ tokens-réponse des deux, + la baseline greedy.
151
+
152
+ Pour chaque question, ``cards`` = ``brain.ask_cards(question)`` si
153
+ ``brain`` est fourni, cartes vides sinon. Le taux greedy (v1) compte
154
+ l'attendu en SOUS-CHAÎNE de la sortie avec cartes ; les taux mécaniques
155
+ (v2) comptent le PREMIER token BPE de ``" {attendu}"`` parmi les tokens
156
+ émis (mesure token, cf. ``_answer_token_hit``).
157
+
158
+ Retourne ``{"ckpt", "core_type", "mode", "seed", "brain", "n_brain_atoms",
159
+ "max_new_tokens", "max_seq_len", "tokenizer_source", "vocab_size",
160
+ "eos_token_id", "runs": [...], "n_with_expected", "routing", "routing_ids",
161
+ "head_readout", "with_cards_answer_rate", "answer_token_rate_mechanic",
162
+ "answer_token_rate_steered", "verdict"}`` avec, par run, les clés du mode
163
+ (``no_cards_output``/``with_cards_output`` en greedy ; ``greedy_output``,
164
+ ``mechanic_output``, ``mechanic_diags`` ; ``steered_output``,
165
+ ``steered_diags``, ``bias_tokens``, ``answer_token``, et les hits booléens).
166
+ """
167
+ if mode not in MODES:
168
+ raise ValueError(f"mode inconnu: {mode!r} (modes: {', '.join(MODES)})")
169
+
170
+ # Imports lourds PARESSEUX (philosophie gate : jamais au niveau module).
171
+ from fractus_vorax.brain import Brain
172
+ from fractus_vorax.model.bpe_tokenizer import Gpt2BpeTokenizer
173
+ from fractus_vorax.model.cte_core import CteCore, probe_checkpoint_kind
174
+ from fractus_vorax.model.fractus_core import FractusCore
175
+ from fractus_vorax.model.vocal import verbalize
176
+
177
+ ckpt_path = Path(ckpt_path)
178
+ loaded = Brain.load(Path(brain)) if brain is not None else None
179
+ if mode == "steered" and loaded is None:
180
+ raise ValueError("--mode steered exige --brain (steering organique)")
181
+
182
+ # Sonde d'espèce puis classe adéquate — les noyaux partagent la
183
+ # signature generate(token_ids, max_new_tokens, temperature,
184
+ # eos_token_id, seed) : tout le reste du harnais est duck-typé.
185
+ kind = probe_checkpoint_kind(ckpt_path)
186
+ if kind == "cte":
187
+ core = CteCore.from_checkpoint(ckpt_path) # strict, jamais False
188
+ elif kind == "fractus":
189
+ core = FractusCore.from_checkpoint(ckpt_path) # strict, jamais False
190
+ else:
191
+ raise ValueError(
192
+ f"type de checkpoint inconnu: {ckpt_path} (ni cte ni fractus)"
193
+ )
194
+ tokenizer = Gpt2BpeTokenizer() # vrai BPE : vocab 50257, eos 50256
195
+ max_seq_len = int(getattr(core.config, "max_seq_len", 256))
196
+ eos = int(tokenizer.eos_token_id)
197
+
198
+ def _speak(prompt: str, bias_tokens=None):
199
+ from fractus_vorax.model.speak import speak # tardif : gate torch
200
+
201
+ return speak(
202
+ core,
203
+ tokenizer,
204
+ prompt,
205
+ max_new_tokens=max_new_tokens,
206
+ temperature=MECHANIC_TEMPERATURE,
207
+ rep_pen=MECHANIC_REP_PEN,
208
+ z_scale=MECHANIC_Z_SCALE,
209
+ seed=seed,
210
+ eos_token_id=eos,
211
+ bias_tokens=bias_tokens,
212
+ )
213
+
214
+ runs = []
215
+ n_with_expected = 0
216
+ for question in questions:
217
+ cards = loaded.ask_cards(question) if loaded is not None else []
218
+ prefix = serialize_cards(question, cards)
219
+ expected = _top_fact_context(cards)
220
+ if expected is not None:
221
+ n_with_expected += 1
222
+ # Token-réponse attendu (convention GPT-2 de speak_bias : espace
223
+ # initial partie du token) — None sans attendu FAIT.
224
+ answer_token = (
225
+ int(tokenizer.encode(" " + expected)[0])
226
+ if expected is not None and tokenizer.encode(" " + expected)
227
+ else None
228
+ )
229
+ answer_token_text = (
230
+ safe_decode_text(tokenizer.decode([answer_token]))
231
+ if answer_token is not None
232
+ else None
233
+ )
234
+ run = {
235
+ "question": question,
236
+ "cards_prefix": prefix,
237
+ "expected_answer": expected,
238
+ "answer_token": answer_token,
239
+ "answer_token_text": answer_token_text,
240
+ # honnêteté : le préfixe sérialisé dépasse-t-il la fenêtre ?
241
+ "prompt_truncated": len(tokenizer.encode(prefix)) > max_seq_len,
242
+ }
243
+ if mode == "greedy":
244
+ run["no_cards_output"] = verbalize(
245
+ core, question, [], tokenizer, max_new_tokens=max_new_tokens
246
+ )
247
+ run["with_cards_output"] = verbalize(
248
+ core, question, cards, tokenizer, max_new_tokens=max_new_tokens
249
+ )
250
+ run["answer_in_output"] = bool(
251
+ expected is not None and expected in run["with_cards_output"]
252
+ )
253
+ else: # mechanic | steered — la mécanique, avec sa baseline greedy
254
+ run["greedy_output"] = verbalize(
255
+ core, question, cards, tokenizer, max_new_tokens=max_new_tokens
256
+ )
257
+ mech_text, mech_diags = _speak(prefix)
258
+ run["mechanic_output"] = mech_text
259
+ run["mechanic_diags"] = mech_diags
260
+ run["mechanic_answer_token"] = _answer_token_hit(
261
+ mech_diags, answer_token_text
262
+ )
263
+ if mode == "steered":
264
+ bias = loaded.speak_bias(question, tokenizer, k=3)
265
+ steered_text, steered_diags = _speak(prefix, bias_tokens=bias)
266
+ run["bias_tokens"] = bias
267
+ # représentation lisible du steering (décodée, VERBATIM) :
268
+ # ce que les organes ont injecté dans la bouche, rang par rang.
269
+ run["bias_decoded"] = [
270
+ (safe_decode_text(tokenizer.decode([tid])), float(w))
271
+ for tid, w in sorted(bias.items(), key=lambda kv: -kv[1])
272
+ ]
273
+ run["steered_output"] = steered_text
274
+ run["steered_diags"] = steered_diags
275
+ run["steered_answer_token"] = _answer_token_hit(
276
+ steered_diags, answer_token_text
277
+ )
278
+ runs.append(run)
279
+
280
+ # Ciel ouvert sur la PREMIÈRE question (modes mécaniques) : les ids du
281
+ # prompt tels que speak les voit (fenêtre gauche), routage + têtes.
282
+ routing: dict | None = None
283
+ readout: dict | None = None
284
+ if mode in ("mechanic", "steered") and runs:
285
+ from fractus_vorax.model.diagnostics import ( # tardif : gate torch
286
+ expert_routing_snapshot,
287
+ head_readout,
288
+ )
289
+
290
+ first_ids = tokenizer.encode(runs[0]["cards_prefix"])
291
+ if len(first_ids) > max_seq_len:
292
+ first_ids = first_ids[-max_seq_len:]
293
+ routing = expert_routing_snapshot(core, first_ids)
294
+ readout = head_readout(core, first_ids)
295
+
296
+ def _rate(key: str) -> float:
297
+ hits = [r for r in runs if r.get(key)]
298
+ return len(hits) / len(runs) if runs else 0.0
299
+
300
+ with_cards_rate = _rate("answer_in_output")
301
+ mechanic_rate = (
302
+ _rate("mechanic_answer_token") if mode in ("mechanic", "steered") else None
303
+ )
304
+ steered_rate = _rate("steered_answer_token") if mode == "steered" else None
305
+ if mode == "greedy":
306
+ verdict_rate, verdict = with_cards_rate, (
307
+ "word salad" if with_cards_rate == 0.0 else "answers present"
308
+ )
309
+ elif mode == "steered":
310
+ verdict_rate = steered_rate
311
+ verdict = "word salad" if steered_rate == 0.0 else "answers present"
312
+ else: # mechanic : la mécanique libère le lexique, pas (encore) les
313
+ # réponses — le taux token est rapporté tel quel, sans verdict
314
+ # réponses (l'attente honnête du mode mechanic est le LEXIQUE).
315
+ verdict_rate, verdict = mechanic_rate, "lexical run (no answer verdict)"
316
+
317
+ return {
318
+ "ckpt": str(ckpt_path),
319
+ "core_type": kind,
320
+ "mode": mode,
321
+ "seed": int(seed),
322
+ "brain": str(brain) if brain is not None else None,
323
+ "n_brain_atoms": len(loaded) if loaded is not None else 0,
324
+ "max_new_tokens": int(max_new_tokens),
325
+ "max_seq_len": max_seq_len,
326
+ "tokenizer_source": tokenizer.source,
327
+ "vocab_size": int(tokenizer.vocab_size),
328
+ "eos_token_id": eos,
329
+ "runs": runs,
330
+ "n_with_expected": n_with_expected,
331
+ "routing": routing,
332
+ "head_readout": readout,
333
+ "with_cards_answer_rate": float(with_cards_rate),
334
+ "answer_token_rate_mechanic": (
335
+ None if mechanic_rate is None else float(mechanic_rate)
336
+ ),
337
+ "answer_token_rate_steered": (
338
+ None if steered_rate is None else float(steered_rate)
339
+ ),
340
+ "verdict_rate": float(verdict_rate),
341
+ "verdict": verdict,
342
+ }
343
+
344
+
345
+ # ---------------------------------------------------------------------------
346
+ # Impression console-safe
347
+ # ---------------------------------------------------------------------------
348
+ def _console_safe(line: str) -> str:
349
+ """« -> » au lieu de la flèche Unicode, « - » au lieu de l'em-dash (cp1252-hostiles)."""
350
+ return line.replace("\u2192", "->").replace("\u2014", "-")
351
+
352
+
353
+ def _print_safe(line: str) -> None:
354
+ """Print qui ne lève JAMAIS (repli errors=replace sur console exotique)."""
355
+ safe = _console_safe(line)
356
+ try:
357
+ print(safe)
358
+ except UnicodeEncodeError: # pragma: no cover - console au codepage hostile
359
+ enc = getattr(sys.stdout, "encoding", None) or "utf-8"
360
+ print(safe.encode(enc, errors="replace").decode(enc, errors="replace"))
361
+
362
+
363
+ def _print_prefix(prefix: str) -> None:
364
+ _print_safe(" préfixe cartes (sérialisé, avant troncature BPE):")
365
+ for line in prefix.splitlines() or [""]:
366
+ _print_safe(f" {line}")
367
+
368
+
369
+ def _print_expected(run: dict) -> None:
370
+ expected = run["expected_answer"]
371
+ if expected is not None:
372
+ tok = run.get("answer_token_text")
373
+ tok_line = f" (token BPE {run['answer_token']:>5} {tok!r})" if tok else ""
374
+ _print_safe(f" attendu (contexte du top FAIT): {expected}{tok_line}")
375
+ else:
376
+ _print_safe(" attendu (contexte du top FAIT): (aucun - pas de carte FAIT)")
377
+
378
+
379
+ def _print_routing(routing: dict | None, readout: dict | None) -> None:
380
+ """Le ciel ouvert, compact : top-2 experts par couche + têtes."""
381
+ if routing is None and readout is None:
382
+ return
383
+ _print_safe("[CORE-SPEAK] ciel ouvert (question 1):")
384
+ if routing:
385
+ layers = sorted(routing.keys(), key=lambda k: int(k.split("_")[1]))
386
+ chunks = [layers[i : i + 4] for i in range(0, len(layers), 4)]
387
+ _print_safe(" routage experts (top-2/couche, gates renormalisées):")
388
+ for chunk in chunks:
389
+ cells = []
390
+ for key in chunk:
391
+ entries = " ".join(f"e{e} {w:.2f}" for e, w in routing[key])
392
+ cells.append(f"{key.replace('layer_', 'L')}: {entries}")
393
+ _print_safe(" " + " | ".join(cells))
394
+ if readout is not None:
395
+ conf, sal = readout.get("confidence"), readout.get("salience")
396
+ if conf is None and sal is None:
397
+ _print_safe(" têtes: (aucune - noyau sans têtes confiance/salience)")
398
+ else:
399
+ _print_safe(f" têtes: confiance {conf:.3f}, saillance {sal:.3f}")
400
+
401
+
402
+ def _print_report(result: dict) -> None:
403
+ """Le rapport honnête : en-tête + générations VERBATIM + verdict."""
404
+ runs = result["runs"]
405
+ n = len(runs)
406
+ mode = result["mode"]
407
+ _print_safe(f"[CORE-SPEAK] checkpoint: {result['ckpt']}")
408
+ _print_safe(
409
+ f"[CORE-SPEAK] type de noyau: "
410
+ f"{CORE_TYPE_LABELS.get(result['core_type'], result['core_type'])}"
411
+ )
412
+ _print_safe(
413
+ f"[CORE-SPEAK] tokenizer BPE: vocab {result['vocab_size']}, "
414
+ f"eos {result['eos_token_id']} ({result['tokenizer_source']})"
415
+ )
416
+ _print_safe(
417
+ f"[CORE-SPEAK] fenêtre: {result['max_seq_len']} tokens BPE - "
418
+ "préfixe cartes tronqué à gauche (seuls les derniers tokens du "
419
+ "prompt sont vus par le noyau)"
420
+ )
421
+ if result["brain"] is None:
422
+ _print_safe("[CORE-SPEAK] brain: aucun - cartes vides pour les deux runs")
423
+ else:
424
+ _print_safe(
425
+ f"[CORE-SPEAK] brain: {result['brain']} ({result['n_brain_atoms']} atomes)"
426
+ )
427
+ if mode == "greedy":
428
+ _print_safe(
429
+ f"[CORE-SPEAK] mode: greedy - max_new_tokens {result['max_new_tokens']}, "
430
+ "greedy déterministe (temperature 0)"
431
+ )
432
+ else:
433
+ steer_note = (
434
+ " + steering organique (speak_bias, boost 8)" if mode == "steered" else ""
435
+ )
436
+ _print_safe(
437
+ f"[CORE-SPEAK] mode: {mode} - speak mécanique (z-norm x{MECHANIC_Z_SCALE:g}, "
438
+ f"rep-pen {MECHANIC_REP_PEN:g}, t={MECHANIC_TEMPERATURE:g}, seed {result['seed']})"
439
+ f"{steer_note}, max_new_tokens {result['max_new_tokens']}, "
440
+ "+ baseline greedy avec cartes (l'attracteur, pour le contraste)"
441
+ )
442
+ n_trunc = sum(1 for r in runs if r["prompt_truncated"])
443
+ _print_safe(f"[CORE-SPEAK] prompts tronqués par la fenêtre: {n_trunc}/{n}")
444
+
445
+ _print_routing(result.get("routing"), result.get("head_readout"))
446
+
447
+ for i, run in enumerate(runs, 1):
448
+ _print_safe(f"--- question {i}/{n}: {run['question']}")
449
+ _print_prefix(run["cards_prefix"])
450
+ _print_expected(run)
451
+ # VERBATIM : la sortie est imprimée telle quelle, multi-lignes comprises.
452
+ if mode == "greedy":
453
+ _print_safe(f" sans cartes -> {run['no_cards_output']}")
454
+ _print_safe(f" avec cartes -> {run['with_cards_output']}")
455
+ ok = "OUI" if run["answer_in_output"] else "NON"
456
+ _print_safe(f" réponse attendue dans la sortie avec cartes: {ok}")
457
+ else:
458
+ _print_safe(f" greedy (attracteur) -> {run['greedy_output']}")
459
+ _print_safe(f" mécanique non-steerée -> {run['mechanic_output']}")
460
+ if mode == "steered":
461
+ pairs = run.get("bias_decoded") or []
462
+ if pairs:
463
+ steering = ", ".join(f"{txt!r} x{w:g}" for txt, w in pairs)
464
+ _print_safe(f" steering organique: {steering}")
465
+ else:
466
+ _print_safe(" steering organique: (vide - pas de carte-réponse)")
467
+ _print_safe(f" mécanique steerée -> {run['steered_output']}")
468
+ target = run.get("answer_token_text")
469
+ m_hit = "OUI" if run.get("mechanic_answer_token") else "NON"
470
+ s_hit = "OUI" if run.get("steered_answer_token") else "NON"
471
+ if target is not None:
472
+ _print_safe(
473
+ f" token-réponse {target!r} émis: non-steerée {m_hit} | steerée {s_hit}"
474
+ )
475
+ else:
476
+ _print_safe(
477
+ " token-réponse: (aucun attendu FAIT - pas de mesure)"
478
+ )
479
+
480
+ if mode == "greedy":
481
+ n_ok = sum(1 for r in runs if r["answer_in_output"])
482
+ rate = result["with_cards_answer_rate"]
483
+ _print_safe(f"[CORE-SPEAK] taux avec cartes: {n_ok}/{n} = {rate:.2f}")
484
+ else:
485
+ m_rate = result["answer_token_rate_mechanic"]
486
+ n_m = sum(1 for r in runs if r.get("mechanic_answer_token"))
487
+ _print_safe(f"[CORE-SPEAK] token-réponse mécanique non-steerée: {n_m}/{n} = {m_rate:.2f}")
488
+ if mode == "steered":
489
+ s_rate = result["answer_token_rate_steered"]
490
+ n_s = sum(1 for r in runs if r.get("steered_answer_token"))
491
+ _print_safe(f"[CORE-SPEAK] token-réponse mécanique steerée: {n_s}/{n} = {s_rate:.2f}")
492
+ if result["n_with_expected"] == 0 and runs:
493
+ _print_safe(
494
+ "[CORE-SPEAK] note honnête: aucun attendu FAIT - taux 0 par "
495
+ "construction ; les sorties VERBATIM ci-dessus sont la preuve"
496
+ )
497
+ _print_safe(f"[CORE-SPEAK] verdict honnête: {result['verdict']}")
498
+
499
+
500
+ # ---------------------------------------------------------------------------
501
+ # CLI
502
+ # ---------------------------------------------------------------------------
503
+ def main(argv: list[str] | None = None) -> int:
504
+ """Point d'entrée : préconditions propres (exit 1), puis rapport verbatim."""
505
+ parser = argparse.ArgumentParser(
506
+ prog="core-speak",
507
+ description=(
508
+ "Évaluation honnête du checkpoint fractus réel : générations "
509
+ "verbatim, taux global, verdict word salad. v2 : modes "
510
+ "greedy / mechanic (speak anti-attracteur) / steered (+ bias organique)."
511
+ ),
512
+ )
513
+ parser.add_argument("--ckpt", type=Path, default=None, help="checkpoint fractus (.pt)")
514
+ parser.add_argument(
515
+ "--brain", type=Path, default=None,
516
+ help="brain sauvegardé pour le préfixe cartes (défaut: aucun - cartes vides)",
517
+ )
518
+ parser.add_argument(
519
+ "--mode", choices=MODES, default="greedy",
520
+ help=(
521
+ "greedy = v1 (sans/avec cartes, temperature 0) ; mechanic = speak "
522
+ "mécanique (z-norm x3, rep-pen 6, t=1.0) + baseline greedy ; "
523
+ "steered = mechanic + bias organique speak_bias (exige --brain)"
524
+ ),
525
+ )
526
+ parser.add_argument(
527
+ "--seed", type=int, default=DEFAULT_SEED,
528
+ help=f"seed de la boucle speak (défaut {DEFAULT_SEED}) — modes mechanic/steered",
529
+ )
530
+ parser.add_argument(
531
+ "--rows", type=int, default=len(DEFAULT_QUESTIONS),
532
+ help=f"nombre de questions évaluées, 1..{len(DEFAULT_QUESTIONS)} (pool: les 4 capitales)",
533
+ )
534
+ parser.add_argument(
535
+ "--max-new-tokens", type=int, default=24,
536
+ help="tokens générés par run (défaut 24) — le 1B CTE sur CPU est lent "
537
+ "(secondes/token) : 10 est le compromis raisonnable",
538
+ )
539
+ args = parser.parse_args(argv)
540
+
541
+ # Préconditions : message propre + exit 1, jamais de traceback.
542
+ if args.ckpt is None:
543
+ _print_safe("[CORE-SPEAK] indisponible: --ckpt requis (checkpoint fractus .pt)")
544
+ return 1
545
+ if not Path(args.ckpt).is_file():
546
+ _print_safe(f"[CORE-SPEAK] indisponible: checkpoint introuvable: {args.ckpt}")
547
+ return 1
548
+ from fractus_vorax.model.vocal import core_available # sonde sûre (try-import)
549
+
550
+ if not core_available():
551
+ _print_safe("[CORE-SPEAK] indisponible: torch absent de ce python")
552
+ return 1
553
+ from fractus_vorax.model.bpe_tokenizer import bpe_available # sonde sûre (try-import)
554
+
555
+ if not bpe_available():
556
+ _print_safe("[CORE-SPEAK] indisponible: tokenizers absent de ce python")
557
+ return 1
558
+ if args.mode == "steered" and args.brain is None:
559
+ _print_safe("[CORE-SPEAK] indisponible: --mode steered exige --brain")
560
+ return 1
561
+ if not 1 <= args.rows <= len(DEFAULT_QUESTIONS):
562
+ _print_safe(
563
+ f"[CORE-SPEAK] --rows doit être 1..{len(DEFAULT_QUESTIONS)} "
564
+ "(pool des capitales)"
565
+ )
566
+ return 1
567
+
568
+ try:
569
+ result = run_core_speak(
570
+ args.ckpt,
571
+ DEFAULT_QUESTIONS[: args.rows],
572
+ brain=args.brain,
573
+ max_new_tokens=args.max_new_tokens,
574
+ mode=args.mode,
575
+ seed=args.seed,
576
+ )
577
+ except Exception as exc: # ckpt corrompu, tokenizer sans source, brain invalide
578
+ _print_safe(f"[CORE-SPEAK] indisponible: {exc}")
579
+ return 1
580
+ _print_report(result)
581
+ return 0
582
+
583
+
584
+ if __name__ == "__main__":
585
+ raise SystemExit(main())
bench/ingest_bench.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # bench/ingest_bench.py
2
+ """Bench smoke du substrat : compilation, ingestion, requêtes — CPU pur."""
3
+ import argparse
4
+ import random
5
+ import time
6
+ from pathlib import Path
7
+
8
+ from fractus_vorax.brain import Brain
9
+ from fractus_vorax.compiler.encode import text_hv
10
+ from fractus_vorax.compiler.kn import compile_kn, load_kn
11
+
12
+ TOPICS = ["spain", "france", "japan", "chile", "nigeria", "sweden", "peru", "iran"]
13
+ FACTS = [
14
+ "the {t} economy grows by {p} percent",
15
+ "the population of {t} is {n} million",
16
+ "the main export of {t} is {e}",
17
+ ]
18
+ EXPORTS = ["copper", "oil", "fish", "wood", "wine", "wool"]
19
+
20
+
21
+ def generate_csv(path: Path, rows: int) -> int:
22
+ rng = random.Random(42)
23
+ lines = ["statement,context"]
24
+ for _ in range(rows):
25
+ t = rng.choice(TOPICS)
26
+ f = rng.choice(FACTS)
27
+ stmt = f.format(t=t, p=rng.randint(1, 9), n=rng.randint(1, 400), e=rng.choice(EXPORTS))
28
+ lines.append(f"{stmt},recorded fact number {rng.randint(1000, 9999)}")
29
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
30
+ return rows
31
+
32
+
33
+ def main() -> None:
34
+ parser = argparse.ArgumentParser()
35
+ parser.add_argument("--rows", type=int, default=2000)
36
+ parser.add_argument("--D", type=int, default=8192)
37
+ args = parser.parse_args()
38
+
39
+ out = Path("bench/out")
40
+ out.mkdir(parents=True, exist_ok=True)
41
+ src = out / "synth.csv"
42
+ generate_csv(src, args.rows)
43
+
44
+ t0 = time.perf_counter()
45
+ kn = compile_kn(src, out / "synth.kn", D=args.D)
46
+ t_compile = time.perf_counter() - t0
47
+
48
+ atoms = load_kn(kn)
49
+ brain = Brain(D=args.D)
50
+ t0 = time.perf_counter()
51
+ brain.ingest_source("synth", atoms)
52
+ t_ingest = time.perf_counter() - t0
53
+ print(f"[BENCH] organes: {len(brain.experts)} experts spawnés")
54
+
55
+ t0 = time.perf_counter()
56
+ n_queries = 100
57
+ for _ in range(n_queries):
58
+ brain.traces.retrieve(text_hv("the population of france is 67 million", D=args.D), k=5)
59
+ t_query_ms = (time.perf_counter() - t0) * 1000 / n_queries
60
+
61
+ print("+---------------------+-------------------+")
62
+ print("| étape | mesure |")
63
+ print("+---------------------+-------------------+")
64
+ print(f"| compilation | {args.rows / t_compile:>10.0f} atomes/s |")
65
+ print(f"| ingestion (écriture)| {args.rows / t_ingest:>10.0f} atomes/s |")
66
+ print(f"| requête (top-5) | {t_query_ms:>10.1f} ms/q |")
67
+ print("+---------------------+-------------------+")
68
+
69
+
70
+ if __name__ == "__main__":
71
+ main()
bench/killer_bench.py ADDED
@@ -0,0 +1,401 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # bench/killer_bench.py
2
+ """Le killer bench par ablation : le substrat est-il vivant sans ses cartes ?
3
+
4
+ Pipeline SUBSTRAT pur (numpy uniquement — torch n'est touché que derrière
5
+ ``core_available()`` dans la branche ablation noyau, import paresseux) :
6
+
7
+ 1. corpus domaine déterministe (random.Random(7)) : sujets
8
+ {pays, planètes, éléments} × faits {capital, population, découverte} ;
9
+ 2. split 80/20 déterministe : 80 % des lignes sont ingérées via le
10
+ pipeline canonique compile_kn → load_kn → Brain.ingest_source ;
11
+ 3. requêtes held-out : les variantes PERTURBÉES des faits (jamais
12
+ ingérées verbatim) —
13
+ - paraphrase : le premier token passe à la fin (ordre modifié) ;
14
+ - typo : une voyelle du slot (dernier token) est remplacée par
15
+ une autre voyelle ;
16
+ 4. mesures : précision top-1, latence d'ingestion (atomes/s), latence
17
+ de requête (ms), imprimées en tableau ;
18
+ 5. ablations honnêtes :
19
+ - « cartes OFF » : _ask_cards_off() interroge SANS organes
20
+ (traces, experts, hebbian, relations) → toujours LACUNE →
21
+ précision 0, le plancher documenté ;
22
+ - « noyau × cartes » : scaffold rempli seulement si un --ckpt est
23
+ fourni ET core_available().
24
+
25
+ Précision top-1 — la réponse exacte doit figurer dans les cartes émises
26
+ pour la question :
27
+ - FAIT/HEBBIEN portent « {énoncé} => {contexte} » → la règle du plan :
28
+ « => {expected} » doit figurer dans le texte de la carte ;
29
+ - ANALOGIE porte la réponse NUE (solve_analogy rend l'answer seule,
30
+ l'organe d'analogie est LE chemin typo) → égalité exacte.
31
+ Sans ses cartes le substrat ne répond rien : c'est le contraste killer.
32
+
33
+ Contrôle « faits jamais ingérés » : les 20 % held-out sont requêtés avec
34
+ les mêmes perturbations — leurs réponses uniques étant absentes de la
35
+ mémoire, la précision attendue est 0.00 (honnêteté : le brain ne devine
36
+ pas, il se souvient).
37
+ """
38
+ from __future__ import annotations
39
+
40
+ import argparse
41
+ import random
42
+ import sys
43
+ import time
44
+ from pathlib import Path
45
+
46
+ # exécution directe ``python bench/killer_bench.py`` : remettre la racine du
47
+ # repo sur sys.path (conftest.py fait de même pour pytest ; le venv torch
48
+ # n'a pas fractus_vorax pip-installé et c'est LE python où --ckpt est pertinent).
49
+ _REPO_ROOT = str(Path(__file__).resolve().parent.parent)
50
+ if _REPO_ROOT not in sys.path:
51
+ sys.path.insert(0, _REPO_ROOT)
52
+
53
+ from fractus_vorax.brain import Brain
54
+ from fractus_vorax.cards import Card
55
+ from fractus_vorax.compiler.encode import text_hv
56
+ from fractus_vorax.compiler.kn import compile_kn, load_kn
57
+ from fractus_vorax.hv import SEED_DEFAULT
58
+
59
+ GENERATOR_SEED = 7 # générateur du domaine (brief : random.Random(7))
60
+ SPLIT_SEED = 7 # split 80/20 déterministe
61
+ VOWELS = "aeiou"
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Domaine déterministe : sujets {pays, planètes, éléments} × 3 faits
65
+ # ---------------------------------------------------------------------------
66
+ COUNTRIES = [
67
+ "france", "spain", "japan", "chile", "nigeria", "sweden", "peru", "iran",
68
+ "egypt", "kenya", "norway", "ghana", "cuba", "nepal", "fiji", "tonga",
69
+ "wales", "bhutan", "oman", "qatar", "malta", "haiti", "benin", "chad",
70
+ "laos", "sudan", "yemen", "jordan", "libya", "angola", "botswana",
71
+ "namibia", "senegal", "somalia", "uganda", "zambia",
72
+ ]
73
+ BODIES = [
74
+ "mercury", "venus", "earth", "mars", "jupiter", "saturn", "uranus",
75
+ "neptune", "pluto", "ceres", "eris", "europa", "titan", "ganymede",
76
+ "callisto", "triton",
77
+ ]
78
+ ELEMENTS = [
79
+ "iron", "gold", "copper", "silver", "oxygen", "helium", "sodium",
80
+ "carbon", "sulfur", "zinc", "nickel", "argon", "cobalt", "tin", "lead",
81
+ "neon", "krypton", "radon", "xenon", "boron", "lithium", "beryllium",
82
+ "magnesium", "aluminium",
83
+ ]
84
+ TOPIC_POOL = COUNTRIES + BODIES + ELEMENTS # 76 sujets
85
+
86
+ # Valeurs uniques par tirage sans remise (aucune collision de réponse →
87
+ # le contrôle « jamais ingéré » ne peut pas marquer par accident).
88
+ CAPITALS = [
89
+ "paris", "madrid", "tokyo", "santiago", "abuja", "stockholm", "lima",
90
+ "teheran", "cairo", "nairobi", "oslo", "accra", "havana", "kathmandu",
91
+ "suva", "nukualofa", "cardiff", "thimphu", "muscat", "doha", "valletta",
92
+ "kingston", "london", "dublin", "lisbon", "vienna", "prague", "warsaw",
93
+ "budapest", "athens", "ankara", "beirut", "baghdad", "damascus", "kabul",
94
+ "islamabad", "colombo", "dhaka", "hanoi", "manila", "bangkok", "rangoon",
95
+ "jakarta", "seoul", "beijing", "taipei", "brussels", "bern", "copenhagen",
96
+ "helsinki", "amsterdam", "canberra", "wellington", "ottawa", "brasilia",
97
+ "bogota", "quito", "caracas", "panama", "managua", "tegucigalpa",
98
+ "belmopan", "nassau", "bridgetown", "gaborone", "windhoek", "lusaka",
99
+ "harare", "maputo", "lilongwe", "kampala", "kigali", "dodoma", "djibouti",
100
+ "asmara", "mogadishu", "nouakchott", "bamako", "ouagadougou", "conakry",
101
+ "freetown", "monrovia", "yamoussoukro", "ndjamena", "bangui", "tripoli",
102
+ "khartoum", "sanaa", "amman",
103
+ ]
104
+ N_FACTS = 3 # capital, population, découverte (les 3 faits du brief)
105
+ MAX_ROWS = len(TOPIC_POOL) * N_FACTS # 228 faits max (pool de sujets épuisé)
106
+
107
+
108
+ def _fact(stmt_topic: str, fact_index: int, capital: str, pop: int, year: int) -> tuple[str, str]:
109
+ """(statement, context) du fait fact_index pour un sujet — slot en dernier."""
110
+ if fact_index == 0:
111
+ return f"the capital of {stmt_topic}", capital
112
+ if fact_index == 1:
113
+ return f"the population of {stmt_topic}", f"{pop} million"
114
+ return f"the discovery of {stmt_topic}", f"discovered in {year}"
115
+
116
+
117
+ def generate_domain_csv(path: Path, n_topics: int, n_facts_per_topic: int) -> int:
118
+ """Écrit le corpus domaine CSV 2 colonnes (statement,context) — seedé Random(7).
119
+
120
+ Déterministe bit-à-bit : même appel → mêmes octets (rng sample sans
121
+ remise, valeurs uniques par fait). Retourne le nombre de lignes de
122
+ faits écrites : n_topics * n_facts_per_topic.
123
+ """
124
+ if not 1 <= n_facts_per_topic <= N_FACTS:
125
+ raise ValueError(f"n_facts_per_topic doit être 1..{N_FACTS}")
126
+ if not 1 <= n_topics <= len(TOPIC_POOL):
127
+ raise ValueError(f"n_topics doit être 1..{len(TOPIC_POOL)} (pool de sujets)")
128
+ rng = random.Random(GENERATOR_SEED)
129
+ topics = rng.sample(TOPIC_POOL, n_topics)
130
+ capitals = rng.sample(CAPITALS, n_topics)
131
+ populations = rng.sample(range(1, 500), n_topics)
132
+ years = rng.sample(range(1400, 2030), n_topics)
133
+ lines = ["statement,context"]
134
+ for i, topic in enumerate(topics):
135
+ for j in range(n_facts_per_topic):
136
+ stmt, ctx = _fact(topic, j, capitals[i], populations[i], years[i])
137
+ lines.append(f"{stmt},{ctx}")
138
+ path = Path(path)
139
+ path.parent.mkdir(parents=True, exist_ok=True)
140
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
141
+ return n_topics * n_facts_per_topic
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # Variantes held-out (jamais ingérées verbatim)
146
+ # ---------------------------------------------------------------------------
147
+ def paraphrase(statement: str) -> str:
148
+ """Ordre des mots modifié : le premier token passe à la fin.
149
+
150
+ Le bundle de tokens est INVARIANT à l'ordre → la requête reste sim 1.0
151
+ avec le fait ingéré : c'est LA propriété de généralisation mesurée.
152
+ """
153
+ tokens = statement.split()
154
+ if len(tokens) < 2:
155
+ return statement
156
+ return " ".join(tokens[1:] + tokens[:1])
157
+
158
+
159
+ def typo(statement: str) -> str:
160
+ """Substitution d'une voyelle du slot (dernier token) par une autre.
161
+
162
+ Première voyelle du slot remplacée par la suivante du cycle a→e→i→o→u→a
163
+ (déterministe). Le slot perturbé échappe au retrieval (tokens ~0.63<0.7)
164
+ et active l'organe d'analogie (voisins char-ngram du slot).
165
+ """
166
+ tokens = statement.split()
167
+ slot = tokens[-1]
168
+ for i, ch in enumerate(slot):
169
+ if ch in VOWELS:
170
+ replacement = VOWELS[(VOWELS.index(ch) + 1) % len(VOWELS)]
171
+ tokens[-1] = slot[:i] + replacement + slot[i + 1:]
172
+ return " ".join(tokens)
173
+ tokens[-1] = slot + "a" # slot sans voyelle (absent du domaine) — repli
174
+ return " ".join(tokens)
175
+
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # Mesures
179
+ # ---------------------------------------------------------------------------
180
+ def _cards_answer(cards: list[Card], expected: str) -> bool:
181
+ """Vrai si la réponse exacte figure dans les cartes émises (top-1 liste).
182
+
183
+ FAIT/HEBBIEN portent « {énoncé} => {contexte} » → la règle littérale du
184
+ plan : « => {expected} » doit figurer dans le texte. ANALOGIE porte la
185
+ réponse nue (c'est le chemin typo de l'architecture) → égalité exacte.
186
+ """
187
+ for card in cards:
188
+ if card.kind in ("FAIT", "HEBBIEN") and f"=> {expected}" in card.text:
189
+ return True
190
+ if card.kind == "ANALOGIE" and card.text.strip() == expected:
191
+ return True
192
+ return False
193
+
194
+
195
+ def _ask_cards_off(brain: Brain, question: str, k: int = 3) -> list[Card]:
196
+ """Ablation « cartes OFF » : interroger SANS organes → toujours LACUNE.
197
+
198
+ Le substrat encode bien la question (text_hv), mais aucun organe n'est
199
+ consulté — ni traces (retrieval désactivé), ni experts, ni hebbian, ni
200
+ relations : sans les organes il n'y a pas de carte au-delà de LACUNE.
201
+ PLANCHER HONNÊTE : précision 0 attendue et mesurée.
202
+ """
203
+ text_hv(question, brain.D, brain.seed) # le substrat traite la question…
204
+ return [Card("LACUNE", "aucune trace pour cette question", 0.0, "ablation")]
205
+
206
+
207
+ def _core_cards_ablation(
208
+ ckpt: Path | None,
209
+ brain: Brain,
210
+ queries: list[tuple[str, str]],
211
+ k: int = 3,
212
+ max_eval: int = 12,
213
+ ) -> dict | None:
214
+ """Scaffold « noyau seul vs noyau+cartes » — None sauf si --ckpt ET torch.
215
+
216
+ Import torch PARESSEUX derrière la sonde core_available() : ce bench
217
+ reste substrate-only (mingw sans torch). Le noyau non-né peut sortir
218
+ du bruit — le contrat mesuré est le pipeline, pas la qualité du texte.
219
+ """
220
+ if ckpt is None:
221
+ return None
222
+ try:
223
+ from fractus_vorax.model.vocal import core_available # sonde sûre (try-import)
224
+
225
+ if not core_available():
226
+ return None
227
+ from fractus_vorax.model.fractus_core import FractusCore
228
+ from fractus_vorax.model.vocal import CharTokenizer, verbalize
229
+
230
+ core = FractusCore.from_checkpoint(Path(ckpt))
231
+ tokenizer = CharTokenizer(
232
+ vocab_size=int(getattr(core.config, "vocab_size", 0x110000))
233
+ )
234
+ lacune = [Card("LACUNE", "aucune trace pour cette question", 0.0, "ablation")]
235
+ sample = queries[:max_eval]
236
+ core_only_ok = core_cards_ok = 0
237
+ for question, expected in sample:
238
+ alone = verbalize(core, question, lacune, tokenizer, max_new_tokens=24)
239
+ if expected in alone:
240
+ core_only_ok += 1
241
+ fed = verbalize(
242
+ core, question, brain.ask_cards(question, k=k), tokenizer,
243
+ max_new_tokens=24,
244
+ )
245
+ if expected in fed:
246
+ core_cards_ok += 1
247
+ return {
248
+ "ckpt": str(ckpt),
249
+ "n_eval": len(sample),
250
+ "core_only_accuracy": core_only_ok / len(sample) if sample else 0.0,
251
+ "core_cards_accuracy": core_cards_ok / len(sample) if sample else 0.0,
252
+ }
253
+ except Exception as exc: # checkpoint absent/corrompu — dégradation honnête
254
+ return {"ckpt": str(ckpt), "error": str(exc)}
255
+
256
+
257
+ def run_bench(
258
+ rows: int = 200,
259
+ D: int = 8192,
260
+ out_dir: Path | None = None,
261
+ ckpt: Path | None = None,
262
+ k: int = 3,
263
+ seed: int = SEED_DEFAULT,
264
+ ) -> dict:
265
+ """Le killer bench complet — imprime le tableau, retourne les métriques."""
266
+ if rows < 2:
267
+ raise ValueError("rows >= 2 requis (split 80/20)")
268
+ if rows > MAX_ROWS:
269
+ raise ValueError(f"rows={rows} > max {MAX_ROWS} (pool de {len(TOPIC_POOL)} sujets × {N_FACTS} faits)")
270
+ out = Path(out_dir) if out_dir is not None else Path(__file__).resolve().parent / "out"
271
+ out.mkdir(parents=True, exist_ok=True)
272
+
273
+ # (1) corpus domaine — pipeline canonique compile_kn → load_kn
274
+ n_topics = -(-rows // N_FACTS) # ceil
275
+ csv_path = out / "domain.csv"
276
+ generate_domain_csv(csv_path, n_topics, N_FACTS)
277
+ kn_dir = compile_kn(csv_path, out / "domain.kn", D=D, seed=seed)
278
+ atoms = load_kn(kn_dir)[:rows]
279
+
280
+ # (2) split 80/20 déterministe : ingérés vs jamais vus
281
+ order = list(range(len(atoms)))
282
+ random.Random(SPLIT_SEED).shuffle(order)
283
+ n_ingest = max(1, int(len(atoms) * 0.8))
284
+ ingested = [atoms[i] for i in sorted(order[:n_ingest])]
285
+ unseen = [atoms[i] for i in sorted(order[n_ingest:])]
286
+
287
+ # (3) ingestion mesurée (organes ON : experts, relations, hebbian)
288
+ brain = Brain(D=D, seed=seed)
289
+ t0 = time.perf_counter()
290
+ brain.ingest_source("domain", ingested)
291
+ t_ingest = time.perf_counter() - t0
292
+ ingest_atoms_per_s = len(ingested) / t_ingest if t_ingest > 0 else float(len(ingested))
293
+
294
+ # (4) requêtes held-out : variantes perturbées, jamais ingérées verbatim
295
+ seen_queries: list[tuple[str, str]] = []
296
+ for atom in ingested:
297
+ seen_queries.append((paraphrase(atom.statement), atom.context))
298
+ seen_queries.append((typo(atom.statement), atom.context))
299
+ unseen_queries: list[tuple[str, str]] = []
300
+ for atom in unseen:
301
+ unseen_queries.append((paraphrase(atom.statement), atom.context))
302
+ unseen_queries.append((typo(atom.statement), atom.context))
303
+
304
+ # (5) mesure cartes ON — la boucle de précision EST la boucle de latence
305
+ t0 = time.perf_counter()
306
+ seen_cards = [brain.ask_cards(q, k=k) for q, _ in seen_queries]
307
+ query_ms = (time.perf_counter() - t0) * 1000.0 / len(seen_queries)
308
+ n_para = len(seen_queries) // 2
309
+ para_ok = sum(_cards_answer(c, e) for (__, e), c in zip(seen_queries[0::2], seen_cards[0::2]))
310
+ typo_ok = sum(_cards_answer(c, e) for (__, e), c in zip(seen_queries[1::2], seen_cards[1::2]))
311
+ unseen_ok = sum(_cards_answer(brain.ask_cards(q, k=k), e) for q, e in unseen_queries)
312
+
313
+ # (6) ablation cartes OFF (plancher honnête) — mêmes requêtes, sans organes
314
+ cards_off_ok = sum(
315
+ _cards_answer(_ask_cards_off(brain, q, k=k), e) for q, e in seen_queries
316
+ )
317
+
318
+ # (7) scaffold ablation noyau × cartes (nécessite --ckpt ET torch)
319
+ core_cards_ablation = _core_cards_ablation(ckpt, brain, seen_queries, k=k)
320
+
321
+ metrics = {
322
+ "rows": len(atoms),
323
+ "D": D,
324
+ "n_ingested": len(ingested),
325
+ "n_unseen": len(unseen),
326
+ "n_queries": len(seen_queries),
327
+ "heldout_accuracy": (para_ok + typo_ok) / len(seen_queries),
328
+ "paraphrase_accuracy": para_ok / n_para if n_para else 0.0,
329
+ "typo_accuracy": typo_ok / n_para if n_para else 0.0,
330
+ "unseen_accuracy": unseen_ok / len(unseen_queries) if unseen_queries else 0.0,
331
+ "cards_off_accuracy": cards_off_ok / len(seen_queries),
332
+ "ingest_atoms_per_s": ingest_atoms_per_s,
333
+ "query_ms": query_ms,
334
+ "core_cards_ablation": core_cards_ablation,
335
+ }
336
+ _print_table(metrics, n_topics)
337
+ return metrics
338
+
339
+
340
+ def _print_table(metrics: dict, n_topics: int) -> None:
341
+ width1, width2 = 40, 12
342
+ line = "+" + "-" * width1 + "+" + "-" * width2 + "+"
343
+
344
+ def row(label: str, value: str) -> None:
345
+ print(f"| {label:<{width1 - 2}} | {value:>{width2 - 2}} |")
346
+
347
+ print(
348
+ f"[KILLER] corpus: {metrics['rows']} faits domaine "
349
+ f"({n_topics} sujets × {N_FACTS} faits), D={metrics['D']}"
350
+ )
351
+ print(
352
+ f"[KILLER] split 80/20 déterministe (seed {SPLIT_SEED}): "
353
+ f"{metrics['n_ingested']} ingérés / {metrics['n_unseen']} jamais vus"
354
+ )
355
+ print(
356
+ f"[KILLER] held-out : {metrics['n_queries']} variantes jamais ingérées "
357
+ f"({metrics['n_queries'] // 2} paraphrases + {metrics['n_queries'] // 2} typos)"
358
+ f" + {2 * metrics['n_unseen']} variantes de faits jamais vus (contrôle)"
359
+ )
360
+ print(line)
361
+ row("killer bench", "mesure")
362
+ print(line)
363
+ row("ingestion (organes ON)", f"{metrics['ingest_atoms_per_s']:.0f} a/s")
364
+ row("requête cartes ON", f"{metrics['query_ms']:.2f} ms/q")
365
+ row("précision held-out — paraphrases", f"{metrics['paraphrase_accuracy']:.2f}")
366
+ row("précision held-out — typos", f"{metrics['typo_accuracy']:.2f}")
367
+ row("précision held-out — globale", f"{metrics['heldout_accuracy']:.2f}")
368
+ row("contrôle — faits jamais ingérés", f"{metrics['unseen_accuracy']:.2f}")
369
+ print(line)
370
+ row("ablation CARTES OFF (plancher)", f"{metrics['cards_off_accuracy']:.2f}")
371
+ print(line)
372
+ print(
373
+ "[KILLER] plancher honnête : sans organes (traces, experts, hebbian,\n"
374
+ " relations) toute question retourne LACUNE → précision 0."
375
+ )
376
+ abl = metrics["core_cards_ablation"]
377
+ if abl is None:
378
+ print(
379
+ "[KILLER] ablation noyau × cartes : non exécutée\n"
380
+ " (--ckpt absent ou torch indisponible dans ce python)"
381
+ )
382
+ else:
383
+ print(f"[KILLER] ablation noyau × cartes : {abl}")
384
+
385
+
386
+ def main(argv: list[str] | None = None) -> int:
387
+ parser = argparse.ArgumentParser(
388
+ prog="killer-bench",
389
+ description="Killer bench par ablation : held-out paraphrase/typo, plancher cartes OFF.",
390
+ )
391
+ parser.add_argument("--rows", type=int, default=200)
392
+ parser.add_argument("--D", type=int, default=8192)
393
+ parser.add_argument("--out", type=Path, default=None, help="dossier de sortie (défaut bench/out)")
394
+ parser.add_argument("--ckpt", type=Path, default=None, help="checkpoint fractus pour l'ablation noyau")
395
+ args = parser.parse_args(argv)
396
+ run_bench(rows=args.rows, D=args.D, out_dir=args.out, ckpt=args.ckpt)
397
+ return 0
398
+
399
+
400
+ if __name__ == "__main__":
401
+ raise SystemExit(main())
conftest.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # conftest.py
2
+ """Racine de repo sur sys.path pour que bench/ soit importable depuis les tests."""
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ sys.path.insert(0, str(Path(__file__).parent))
demo/ce_soir.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # demo/ce_soir.py
2
+ """CE SOIR — le noyau parle : les organes articulent à travers sa bouche.
3
+
4
+ La démo du Plan 7, autonome (aucun argument, artefacts dans un tmpdir
5
+ jetable) et honnête dans CHAQUE python :
6
+
7
+ 1/4 statut — le noyau de naissance chargé si torch + tokenizers +
8
+ brain/FRACTUS_BIRTH.pt vivent dans CE python (sonde d'espèce puis
9
+ :core strict), et le statut des organes (cerveau ingéré du CSV
10
+ capitales) ; sans noyau, ligne honnête d'absence — les organes
11
+ existent, la bouche manque.
12
+ 2/4 les quatre capitales — ``:say`` complet pour chacune : la ligne
13
+ [ORGANES] (la carte qui connaît la réponse) puis la ligne [PAROLE]
14
+ (le prompt d'articulation ``"{question} The answer is"`` + une
15
+ génération MÉCANIQUE de 12 tokens avec VERROU DE RÉPONSE :
16
+ answer_lock = speak_answer — la réponse des organes placée token
17
+ par token À TRAVERS la distribution du noyau, z-norm ×3, rep-pen 6,
18
+ t=1.0, seed 7 ; la suite de la génération est libre).
19
+ 3/4 la libre parole — 2 échantillons SANS verrou (même recette mécanique,
20
+ 12 tokens) : le lexique réel du 1B, honnête — la syntaxe absente si
21
+ elle est absente.
22
+ 4/4 « Le noyau parle. Les organes savent. Zéro gradient. »
23
+
24
+ `main() -> list[str]` retourne TOUTES les lignes (jamais de print direct) :
25
+ le test s'y raccroche, `python demo/ce_soir.py` les imprime. Console-safe :
26
+ jamais « → » ni « — » (cp1252-hostiles).
27
+
28
+ GATE torch : torch n'est importé QUE paresseusement — via ``:core`` du REPL
29
+ et dans l'étape 3 (``speak``, atteinte seulement si le noyau est attaché) ;
30
+ importer ce fichier dans un python sans torch (substrat mingw) ne lève
31
+ jamais.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import io
36
+ import sys
37
+ import tempfile
38
+ from contextlib import redirect_stdout
39
+ from pathlib import Path
40
+
41
+ # exécution directe ``python demo/ce_soir.py`` : racine du repo sur sys.path
42
+ # (conftest.py fait de même pour pytest ; le venv torch n'a pas fractus_vorax
43
+ # pip-installé).
44
+ _REPO_ROOT = str(Path(__file__).resolve().parent.parent)
45
+ if _REPO_ROOT not in sys.path:
46
+ sys.path.insert(0, _REPO_ROOT)
47
+
48
+ from fractus_vorax.agent import cli # noqa: E402
49
+ from fractus_vorax.agent.repl import Repl # noqa: E402
50
+ from fractus_vorax.model.bpe_tokenizer import bpe_available # noqa: E402 — lazy-safe
51
+ from fractus_vorax.model.vocal import core_available # noqa: E402 — lazy-safe
52
+
53
+ # Les capitales canoniques du projet (mêmes questions que demo/demo.py et
54
+ # bench/core_speak.py) ; :say porte sur les QUATRE (france, spain, japan,
55
+ # italy) — le budget temps réel du 1B CPU (~6 s/token) borne la démo.
56
+ CAPITALS_CSV = (
57
+ "question,answer\n"
58
+ "what is the capital of france,paris\n"
59
+ "what is the capital of spain,madrid\n"
60
+ "what is the capital of japan,tokyo\n"
61
+ "what is the capital of italy,rome\n"
62
+ "what is the capital of portugal,lisbon\n"
63
+ )
64
+ D_DEMO = 2048 # assez pour la démo, assez léger pour n'importe quel CPU
65
+
66
+ # LE cerveau de naissance (docs/NAISSANCE.md), en place, jamais copié.
67
+ BRAIN_BIRTH = Path(_REPO_ROOT) / "brain" / "FRACTUS_BIRTH.pt"
68
+
69
+ SAY_QUESTIONS = [
70
+ "what is the capital of france",
71
+ "what is the capital of spain",
72
+ "what is the capital of japan",
73
+ "what is the capital of italy",
74
+ ]
75
+
76
+ # Libre parole mécanique SANS verrou : le lexique du 1B, pour le contraste
77
+ # honnête avec les réponses verrouillées.
78
+ FREE_TALK_PROMPTS = [
79
+ "The meaning of life is",
80
+ "In the beginning there was",
81
+ ]
82
+
83
+ CORE_ABSENT_LINE = (
84
+ "[FRACTUS-VORAX] noyau absent dans cet environnement - "
85
+ "les organes savent, la bouche manque"
86
+ )
87
+ FINAL_LINE = "[FRACTUS-VORAX] Le noyau parle. Les organes savent. Zéro gradient."
88
+
89
+
90
+ def _console_safe(line: str) -> str:
91
+ """« -> » au lieu de « → », « - » au lieu de « — » (consoles cp1252-proof)."""
92
+ return line.replace("→", "->").replace("—", "-")
93
+
94
+
95
+ def _capture_prints(fn) -> list[str]:
96
+ """Exécute fn en capturant ses prints (cli.ingest imprime) -> lignes sûres."""
97
+ buf = io.StringIO()
98
+ with redirect_stdout(buf):
99
+ fn()
100
+ return [_console_safe(l) for l in buf.getvalue().splitlines() if l.strip()]
101
+
102
+
103
+ def _print_safe(line: str) -> None:
104
+ """Print qui ne lève JAMAIS (repli errors=replace — console au codepage hostile)."""
105
+ safe = _console_safe(line)
106
+ try:
107
+ print(safe)
108
+ except UnicodeEncodeError: # pragma: no cover - console exotique
109
+ enc = getattr(sys.stdout, "encoding", None) or "utf-8"
110
+ print(safe.encode(enc, errors="replace").decode(enc, errors="replace"))
111
+
112
+
113
+ def main() -> list[str]:
114
+ """Ce soir, le noyau parle — lignes retournées, aucun effet de bord hors tmpdir."""
115
+ lines: list[str] = []
116
+
117
+ with tempfile.TemporaryDirectory(prefix="fractus_vorax-ce-soir-") as tmp:
118
+ tmpdir = Path(tmp)
119
+ csv_path = tmpdir / "capitals.csv"
120
+ csv_path.write_text(CAPITALS_CSV, encoding="utf-8")
121
+ repl = Repl(tmpdir / "brain", D=D_DEMO)
122
+
123
+ # (1) statut — les organes mangent, le noyau de naissance s'attache
124
+ # s'il vit dans CE python (le REPL sonde l'espèce et dégrade honnêtement).
125
+ lines.append("[FRACTUS-VORAX] 1/4 statut - organes et noyau")
126
+ lines += [_console_safe(l) for l in repl.feed(f":ingest {csv_path}")]
127
+ lines += [_console_safe(l) for l in repl.feed(":status")]
128
+ core_attached = False
129
+ if core_available() and bpe_available() and BRAIN_BIRTH.is_file():
130
+ lines += [_console_safe(l) for l in repl.feed(f":core {BRAIN_BIRTH}")]
131
+ core_attached = repl.core is not None and repl.tokenizer is not None
132
+ else:
133
+ lines.append(CORE_ABSENT_LINE)
134
+
135
+ # (2) les quatre capitales — :say complet : la carte qui connaît
136
+ # ([ORGANES]) puis la parole MÉCANIQUE VERROUILLÉE ([PAROLE]).
137
+ lines.append(
138
+ "[FRACTUS-VORAX] 2/4 les quatre capitales - "
139
+ ":say (verrou de réponse, 12 tokens)"
140
+ )
141
+ for question in SAY_QUESTIONS:
142
+ lines += [_console_safe(l) for l in repl.feed(f":say {question}")]
143
+
144
+ # (3) la libre parole — 2 échantillons SANS verrou : la même boucle
145
+ # mécanique (z-norm ×3, rep-pen 6, t=1.0, seed 7, 12 tokens),
146
+ # aucun verrou — le lexique du 1B, honnête.
147
+ lines.append(
148
+ "[FRACTUS-VORAX] 3/4 libre parole mécanique - "
149
+ "2 échantillons sans verrou"
150
+ )
151
+ if core_attached:
152
+ from fractus_vorax.model.speak import speak # tardif : gate torch
153
+
154
+ for prompt in FREE_TALK_PROMPTS:
155
+ text, _diags = speak(
156
+ repl.core,
157
+ repl.tokenizer,
158
+ prompt,
159
+ max_new_tokens=12,
160
+ temperature=1.0,
161
+ rep_pen=6.0,
162
+ z_scale=3.0,
163
+ seed=7,
164
+ eos_token_id=getattr(repl.tokenizer, "eos_token_id", None),
165
+ )
166
+ lines.append(_console_safe(f'[FRACTUS-VORAX] "{prompt}{text}"'))
167
+ else:
168
+ lines.append("[FRACTUS-VORAX] libre parole muette - pas de noyau attaché")
169
+
170
+ # (4) la ligne du soir
171
+ lines.append(FINAL_LINE)
172
+ return lines
173
+
174
+
175
+ if __name__ == "__main__":
176
+ for out_line in main():
177
+ _print_safe(out_line)
demo/demo.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # demo/demo.py
2
+ """La démo Fractus-Vorax de bout en bout — il mange, il répond, il se souvient.
3
+
4
+ Script autonome : aucun argument requis, tous les artefacts (CSV, cache .kn,
5
+ brain, brain REPL) vivent dans un tmpdir jetable (tempfile.TemporaryDirectory).
6
+ Aucun effet de bord hors de ce tmpdir, zéro gradient, zéro LLM externe.
7
+
8
+ Séquence :
9
+ 1. ingestion d'un CSV capitales de 5 lignes (pipeline canonique CLI) ;
10
+ 2. question exacte « what is the capital of japan » -> carte FAIT (tokyo) ;
11
+ 3. typo « what is the capital of franc » -> carte ANALOGIE (paris) ;
12
+ 4. status -> la ligne organes (experts, relations, hebbien, co-activation) ;
13
+ 5. session REPL scriptée : :ingest, une question, :status — chaque échange
14
+ est écrit O(1) dans le brain (spec §5.4, la conversation EST une source).
15
+
16
+ `main() -> list[str]` retourne TOUTES les lignes (jamais de print direct) :
17
+ le test s'y raccroche, `python demo/demo.py` les imprime. Console-safe :
18
+ les lignes n'utilisent jamais « → » ni « — » (cp1252-hostiles) —
19
+ un sanitizer remplace par « -> » et « - ».
20
+
21
+ GATE torch : ce fichier n'importe jamais torch — le substrat seul démontre.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import io
26
+ import sys
27
+ import tempfile
28
+ from contextlib import redirect_stdout
29
+ from pathlib import Path
30
+
31
+ # exécution directe ``python demo/demo.py`` : remettre la racine du repo sur
32
+ # sys.path (conftest.py fait de même pour pytest ; le venv torch n'a pas
33
+ # fractus_vorax pip-installé).
34
+ _REPO_ROOT = str(Path(__file__).resolve().parent.parent)
35
+ if _REPO_ROOT not in sys.path:
36
+ sys.path.insert(0, _REPO_ROOT)
37
+
38
+ from fractus_vorax.agent import cli # noqa: E402 (après le bootstrap sys.path)
39
+ from fractus_vorax.agent.repl import Repl # noqa: E402
40
+
41
+ D_DEMO = 2048 # assez pour la démo, assez léger pour n'importe quel CPU
42
+
43
+ CAPITALS_CSV = (
44
+ "question,answer\n"
45
+ "what is the capital of france,paris\n"
46
+ "what is the capital of spain,madrid\n"
47
+ "what is the capital of japan,tokyo\n"
48
+ "what is the capital of italy,rome\n"
49
+ "what is the capital of portugal,lisbon\n"
50
+ )
51
+
52
+ FINAL_LINE = "[VORAX] Démo complète - zéro gradient."
53
+
54
+
55
+ def _console_safe(line: str) -> str:
56
+ """« -> » au lieu de « → », « - » au lieu de « — » (consoles cp1252-proof)."""
57
+ return line.replace("→", "->").replace("—", "-")
58
+
59
+
60
+ def _capture_prints(fn) -> list[str]:
61
+ """Exécute fn en capturant ses prints (cli.ingest imprime) -> lignes sûres."""
62
+ buf = io.StringIO()
63
+ with redirect_stdout(buf):
64
+ fn()
65
+ return [_console_safe(l) for l in buf.getvalue().splitlines() if l.strip()]
66
+
67
+
68
+ def main() -> list[str]:
69
+ """La démo complète — retourne les lignes, aucun effet de bord hors tmpdir."""
70
+ lines: list[str] = []
71
+ with tempfile.TemporaryDirectory(prefix="fractus_vorax-demo-") as tmp:
72
+ tmpdir = Path(tmp)
73
+ csv_path = tmpdir / "capitals.csv"
74
+ csv_path.write_text(CAPITALS_CSV, encoding="utf-8")
75
+ brain_path = tmpdir / "brain"
76
+
77
+ # (1) ingestion — pipeline canonique compile_kn -> load_kn -> Brain
78
+ lines.append(f"[DÉMO] 1/5 ingestion de {csv_path.name} (5 capitales)...")
79
+ lines += _capture_prints(lambda: cli.ingest(csv_path, brain_path, D=D_DEMO))
80
+
81
+ # (2) question exacte -> la carte FAIT (tokyo)
82
+ lines.append("[DÉMO] 2/5 question exacte: what is the capital of japan")
83
+ lines += [_console_safe(l) for l in cli.ask("what is the capital of japan", brain_path)]
84
+
85
+ # (3) typo -> l'organe d'analogie répond (paris)
86
+ lines.append("[DÉMO] 3/5 typo volontaire: what is the capital of franc")
87
+ lines += [_console_safe(l) for l in cli.ask("what is the capital of franc", brain_path)]
88
+
89
+ # (4) status -> les organes comptent ce qui a été mangé
90
+ lines.append("[DÉMO] 4/5 status du brain...")
91
+ lines += [_console_safe(l) for l in cli.status(brain_path).splitlines()]
92
+
93
+ # (5) session REPL scriptée : :ingest, question, :status
94
+ lines.append("[DÉMO] 5/5 session REPL scriptée (:ingest, question, :status)...")
95
+ repl = Repl(tmpdir / "brain-repl", D=D_DEMO)
96
+ lines += [_console_safe(l) for l in repl.feed(f":ingest {csv_path}")]
97
+ lines += [_console_safe(l) for l in repl.feed("what is the capital of spain")]
98
+ lines += [_console_safe(l) for l in repl.feed(":status")]
99
+
100
+ lines.append(FINAL_LINE)
101
+ return lines
102
+
103
+
104
+ if __name__ == "__main__":
105
+ for out_line in main():
106
+ print(out_line)
demo/inauguration.py ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # demo/inauguration.py
2
+ """L'INAUGURATION — la démo du takeover : le relais est pris.
3
+
4
+ Le cérémonial en 7 temps, autonome (aucun argument, artefacts dans un tmpdir
5
+ jetable) et honnête dans CHAQUE python :
6
+
7
+ 1/7 noyau de naissance — statut du cerveau : si torch + tokenizers +
8
+ brain/FRACTUS_BIRTH.pt vivent dans CE python, chargement réel (sonde
9
+ d'espèce puis CteCore.from_checkpoint strict) et ligne de naissance
10
+ (params, clés strictes, tokens natus x n GPU) ; sinon ligne honnête
11
+ d'absence — les organes portent la démo (le substrat mingw démontre).
12
+ 2/7 ingestion — il mange : CSV capitales via le pipeline canonique CLI.
13
+ 3/7 question exacte — « japan » -> la carte FAIT (tokyo).
14
+ 4/7 typo — « franc » -> l'organe d'analogie répond (paris).
15
+ 5/7 session REPL scriptée — :ingest, :status, question, :status : la
16
+ conversation EST une source, le nombre d'atomes croît (écriture O(1)).
17
+ 6/7 la parole du noyau — SEULEMENT si le cerveau est chargé : une
18
+ génération MÉCANIQUE réelle (speak : z-norm ×3, rep-pen 6, t=1.0,
19
+ seed 7, 8 tokens BPE) sur une question AVEC cartes et steering
20
+ organique (brain.speak_bias — les organes dirigent la bouche),
21
+ rapportée VERBATIM — lexique libéré, syntaxe absente : honnête
22
+ (docs/NAISSANCE.md : il babille, il ne répond pas encore).
23
+ 7/7 « Le relais est pris. L'entraînement est fini. Il mange. »
24
+
25
+ `inauguration() -> list[str]` retourne TOUTES les lignes (jamais de print
26
+ direct) : le test s'y raccroche, `python demo/inauguration.py` les imprime.
27
+ Console-safe : jamais « → » ni « — » (cp1252-hostiles).
28
+
29
+ GATE torch : torch n'est importé QUE paresseusement dans `_load_birth_core`
30
+ et dans l'étape 6 (`speak`, atteinte seulement si le cerveau est chargé) ;
31
+ `vocal` et `bpe_tokenizer` sont des modules lazy-safe — importer ce fichier
32
+ dans un python sans torch (substrat mingw) ne lève jamais.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import io
37
+ import re
38
+ import sys
39
+ import tempfile
40
+ from contextlib import redirect_stdout
41
+ from pathlib import Path
42
+
43
+ # exécution directe ``python demo/inauguration.py`` : racine du repo sur
44
+ # sys.path (conftest.py fait de même pour pytest ; le venv torch n'a pas
45
+ # fractus_vorax pip-installé).
46
+ _REPO_ROOT = str(Path(__file__).resolve().parent.parent)
47
+ if _REPO_ROOT not in sys.path:
48
+ sys.path.insert(0, _REPO_ROOT)
49
+
50
+ from fractus_vorax.agent import cli # noqa: E402
51
+ from fractus_vorax.agent.repl import Repl # noqa: E402
52
+ from fractus_vorax.brain import Brain # noqa: E402
53
+ from fractus_vorax.model.bpe_tokenizer import ( # noqa: E402 — module lazy-safe (gate)
54
+ Gpt2BpeTokenizer,
55
+ bpe_available,
56
+ )
57
+ from fractus_vorax.model.vocal import ( # noqa: E402 — module lazy-safe (gate)
58
+ core_available,
59
+ serialize_cards,
60
+ )
61
+
62
+ # Les 5 capitales canoniques du projet (mêmes questions/réponses que
63
+ # demo/demo.py et bench/core_speak.py — inlinées ICI parce qu'un import
64
+ # ``from demo.demo import ...`` casse en exécution directe : le répertoire
65
+ # du script est sur sys.path, où demo.py masque le package demo/).
66
+ CAPITALS_CSV = (
67
+ "question,answer\n"
68
+ "what is the capital of france,paris\n"
69
+ "what is the capital of spain,madrid\n"
70
+ "what is the capital of japan,tokyo\n"
71
+ "what is the capital of italy,rome\n"
72
+ "what is the capital of portugal,lisbon\n"
73
+ )
74
+ D_DEMO = 2048 # assez pour la démo, assez léger pour n'importe quel CPU
75
+
76
+ # LE cerveau de naissance (tâche 2 — docs/NAISSANCE.md), en place, jamais copié.
77
+ BRAIN_BIRTH = Path(_REPO_ROOT) / "brain" / "FRACTUS_BIRTH.pt"
78
+
79
+ QUESTION_EXACTE = "what is the capital of japan"
80
+ QUESTION_TYPO = "what is the capital of franc"
81
+ QUESTION_REPL = "what is the capital of spain"
82
+
83
+ CORE_ABSENT_LINE = (
84
+ "[FRACTUS-VORAX] cerveau absent dans cet environnement - "
85
+ "organes portent la démo"
86
+ )
87
+ FINAL_LINE = "[FRACTUS-VORAX] Le relais est pris. L'entraînement est fini. Il mange."
88
+
89
+
90
+ def _console_safe(line: str) -> str:
91
+ """« -> » au lieu de « → », « - » au lieu de « — » (consoles cp1252-proof)."""
92
+ return line.replace("→", "->").replace("—", "-")
93
+
94
+
95
+ def _capture_prints(fn) -> list[str]:
96
+ """Exécute fn en capturant ses prints (cli.ingest imprime) -> lignes sûres."""
97
+ buf = io.StringIO()
98
+ with redirect_stdout(buf):
99
+ fn()
100
+ return [_console_safe(l) for l in buf.getvalue().splitlines() if l.strip()]
101
+
102
+
103
+ def _print_safe(line: str) -> None:
104
+ """Print qui ne lève JAMAIS (repli errors=replace — console au codepage hostile)."""
105
+ safe = _console_safe(line)
106
+ try:
107
+ print(safe)
108
+ except UnicodeEncodeError: # pragma: no cover - console exotique
109
+ enc = getattr(sys.stdout, "encoding", None) or "utf-8"
110
+ print(safe.encode(enc, errors="replace").decode(enc, errors="replace"))
111
+
112
+
113
+ def _load_birth_core():
114
+ """Charge le cerveau de naissance -> (core, tokens_natus, n_gpus) ou lève.
115
+
116
+ Sonde d'espèce d'abord (jamais d'attach aveugle), puis from_checkpoint
117
+ STRICT ; les métadonnées natales (tokens_processed, n GPU de la fusion
118
+ gelée) sont lues par une seconde torch.load mmap — paresseuse : seules
119
+ les clés du pickle se matérialisent, les tenseurs restent des vues
120
+ fichier. Appelée UNIQUEMENT si core_available() et bpe_available() ont
121
+ confirmé ce python et que le fichier existe.
122
+ """
123
+ import torch # paresseux — philosophie gate
124
+
125
+ from fractus_vorax.model.cte_core import CteCore, probe_checkpoint_kind
126
+
127
+ kind = probe_checkpoint_kind(BRAIN_BIRTH)
128
+ if kind != "cte":
129
+ raise ValueError(f"espèce inattendue pour le cerveau de naissance: {kind!r}")
130
+ ckpt = torch.load(BRAIN_BIRTH, map_location="cpu", weights_only=False, mmap=True)
131
+ try:
132
+ meta = ckpt.get("config", {}) if isinstance(ckpt, dict) else {}
133
+ tokens = int(meta.get("tokens_processed", 0)) if isinstance(meta, dict) else 0
134
+ n_gpus = int(ckpt.get("n", 1)) if isinstance(ckpt, dict) else 1
135
+ finally:
136
+ del ckpt # libérer le wrapper mmap avant le chargement strict
137
+ return CteCore.from_checkpoint(BRAIN_BIRTH), tokens, n_gpus
138
+
139
+
140
+ def inauguration() -> list[str]:
141
+ """Le cérémonial complet — lignes retournées, aucun effet de bord hors tmpdir."""
142
+ lines: list[str] = []
143
+
144
+ # (1) le noyau de naissance — réel si l'environnement le permet, honnête sinon
145
+ lines.append("[FRACTUS-VORAX] 1/7 noyau de naissance")
146
+ core = None
147
+ if core_available() and bpe_available() and BRAIN_BIRTH.is_file():
148
+ try:
149
+ core, tokens_natus, n_gpus = _load_birth_core()
150
+ except Exception as exc: # dégradation honnête, jamais de traceback
151
+ lines.append(_console_safe(
152
+ f"[FRACTUS-VORAX] cerveau absent dans cet environnement "
153
+ f"(échec de chargement: {exc}) - organes portent la démo"))
154
+ else:
155
+ n_keys = len(core.state_dict()) # strict : n_keys/n_keys clés validées
156
+ params = sum(p.numel() for p in core.parameters())
157
+ # 15 564 800 tokens/GPU -> « 15.5M » (tronqué au dixième de million)
158
+ tok_m = f"{tokens_natus // 10**5 / 10:.1f}M"
159
+ lines.append(
160
+ f"[FRACTUS-VORAX] cerveau: cte {params / 1e9:.0f}B, "
161
+ f"strict {n_keys}/{n_keys}, tokens natus {tok_m} x{n_gpus}"
162
+ )
163
+ else:
164
+ lines.append(CORE_ABSENT_LINE)
165
+
166
+ with tempfile.TemporaryDirectory(prefix="fractus_vorax-inauguration-") as tmp:
167
+ tmpdir = Path(tmp)
168
+ csv_path = tmpdir / "capitals.csv"
169
+ csv_path.write_text(CAPITALS_CSV, encoding="utf-8")
170
+ brain_path = tmpdir / "brain"
171
+
172
+ # (2) ingestion — il mange (pipeline canonique compile_kn -> Brain)
173
+ lines.append(f"[FRACTUS-VORAX] 2/7 ingestion de {csv_path.name} (5 capitales)")
174
+ lines += _capture_prints(lambda: cli.ingest(csv_path, brain_path, D=D_DEMO))
175
+
176
+ # (3) question exacte -> la carte FAIT (tokyo)
177
+ lines.append(f"[FRACTUS-VORAX] 3/7 question exacte: {QUESTION_EXACTE}")
178
+ lines += [_console_safe(l) for l in cli.ask(QUESTION_EXACTE, brain_path)]
179
+
180
+ # (4) typo -> l'organe d'analogie répond (paris)
181
+ lines.append(f"[FRACTUS-VORAX] 4/7 typo volontaire: {QUESTION_TYPO}")
182
+ lines += [_console_safe(l) for l in cli.ask(QUESTION_TYPO, brain_path)]
183
+
184
+ # (5) session REPL scriptée — la conversation EST une source :
185
+ # le compteur d'atomes croît d'un :status à l'autre (écriture O(1))
186
+ lines.append(
187
+ "[FRACTUS-VORAX] 5/7 session REPL scriptée "
188
+ "(:ingest, :status, question, :status)"
189
+ )
190
+ repl = Repl(tmpdir / "brain-repl", D=D_DEMO)
191
+ lines += [_console_safe(l) for l in repl.feed(f":ingest {csv_path}")]
192
+ lines += [_console_safe(l) for l in repl.feed(":status")]
193
+ lines += [_console_safe(l) for l in repl.feed(QUESTION_REPL)]
194
+ lines += [_console_safe(l) for l in repl.feed(":status")]
195
+ statuses = [l for l in lines if l.startswith("[VORAX] brain:")]
196
+ n_before = int(re.search(r"(\d+) atomes", statuses[-2]).group(1))
197
+ n_after = int(re.search(r"(\d+) atomes", statuses[-1]).group(1))
198
+ lines.append(
199
+ f"[FRACTUS-VORAX] session: {n_before} -> {n_after} atomes - "
200
+ "l'échange a nourri le brain (écriture O(1), zéro gradient)"
201
+ )
202
+
203
+ # (6) la parole du noyau — SEULEMENT si le cerveau de naissance est
204
+ # chargé : une génération MÉCANIQUE réelle (speak : z-norm ×3,
205
+ # rep-pen 6, t=1.0, seed 7, 8 tokens BPE), question AVEC cartes
206
+ # et steering organique (speak_bias : les organes dirigent la
207
+ # bouche). Sortie VERBATIM, honnête quoi qu'il dise.
208
+ if core is not None:
209
+ lines.append(
210
+ "[FRACTUS-VORAX] 6/7 la parole du noyau "
211
+ "(BPE, 8 tokens, mécanique)"
212
+ )
213
+ try:
214
+ tokenizer = Gpt2BpeTokenizer()
215
+ except Exception as exc: # cache HF vide + réseau injoignable
216
+ lines.append(_console_safe(
217
+ f"[FRACTUS-VORAX] noyau muet: tokenizer BPE indisponible ({exc})"))
218
+ else:
219
+ from fractus_vorax.model.speak import speak # tardif : gate torch
220
+
221
+ brain_demo = Brain.load(brain_path)
222
+ cards = brain_demo.ask_cards(QUESTION_EXACTE)
223
+ bias = brain_demo.speak_bias(QUESTION_EXACTE, tokenizer, k=3)
224
+ text, _diags = speak(
225
+ core,
226
+ tokenizer,
227
+ serialize_cards(QUESTION_EXACTE, cards),
228
+ max_new_tokens=8,
229
+ temperature=1.0,
230
+ rep_pen=6.0,
231
+ z_scale=3.0,
232
+ seed=7,
233
+ eos_token_id=getattr(tokenizer, "eos_token_id", None),
234
+ bias_tokens=bias or None,
235
+ )
236
+ lines.append(_console_safe(f'[FRACTUS-VORAX] noyau: "{text}"'))
237
+
238
+ lines.append(FINAL_LINE)
239
+ return lines
240
+
241
+
242
+ if __name__ == "__main__":
243
+ for out_line in inauguration():
244
+ _print_safe(out_line)
docs/NAISSANCE.md ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NAISSANCE — acte de naissance du cerveau de Fractus-Vorax
2
+
3
+ **18 août 2026.** Aujourd'hui est installé le cerveau de naissance de
4
+ Fractus-Vorax : le dernier checkpoint d'entraînement par gradient de son
5
+ histoire. Après lui, plus jamais un gradient. Il mange.
6
+
7
+ ## Le fichier
8
+
9
+ | | |
10
+ |---|---|
11
+ | **fichier** | `brain/FRACTUS_BIRTH.pt` (gitignored — le binaire ne vit pas dans git) |
12
+ | **taille** | 4 662 144 283 octets (4,66 GB) |
13
+ | **origine** | `FRACTUS_1B_PHASE2_FROZEN_MERGED.pt`, copié byte-pour-byte depuis le repo de recherche `vorax` (lecture seule) |
14
+ | **source amont** | Hugging Face **thefinalboss/fractus-cte**, pushé le **2026-08-18 04:20** |
15
+ | **nature** | le **dernier** checkpoint du stage 2 (boost/ss) : la fusion gelée des 8 GPU indépendants (`frozen_merge=True`, `n=8`) |
16
+ | **entraînement** | **124 518 400 tokens** au total — `tokens_processed = 15 564 800` par GPU × 8 GPU (`lr = 7e-4`, `ss_rate = 0.25`, `boost = True`) |
17
+ | **espèce** | `cte` — Continuous Thought Engine 1B (1 048 631 458 paramètres), sonné par `probe_checkpoint_kind` |
18
+
19
+ ## Identité vérifiée
20
+
21
+ - Chargement **strict** : `CteCore.from_checkpoint` → `load_state_dict(strict=True)`
22
+ → **440/440 clés** (8 top-level + 16 blocs × 27 — la table des clés du doc
23
+ d'architecture, exacte).
24
+ - Config inférée des shapes + indices du wrapper : `d_model=1280`, `n_heads=20`,
25
+ `d_head=64`, `n_layers=16`, `n_levels=2`, `n_oscillators=16`,
26
+ `coupling_rank=8`, `n_experts=128`, `top_k=2`, `expert_d_ff=2048`,
27
+ `siren_rank=64`, `vocab_size=50257`, `batch=2`, `train_seq_len=128`.
28
+ - Un forward B=1 L=4 → logits `(1, 4, 50257)`, **tous finis**.
29
+
30
+ Le pacte est même devenu **physique** : depuis ce fichier, `from_checkpoint`
31
+ charge les poids par mémoire mappée **en lecture seule** — ils ne sont jamais
32
+ copiés en RAM privée, et aucune écriture in-place ne peut les toucher. Le
33
+ système d'exploitation lui-même refuse de modifier ce cerveau.
34
+
35
+ ## LE PACTE
36
+
37
+ **Ce cerveau ne sera JAMAIS réentraîné. Aucun gradient ne modifiera ses poids.
38
+ Toute connaissance nouvelle arrive par ingestion (traces, hebbien, spawn).
39
+ L'entraînement s'arrête ici.**
40
+
41
+ ## Vérification — sortie verbatim
42
+
43
+ Script jetable `.superpowers/scratch/verify_birth.py`, exécuté UNE fois le
44
+ 2026-08-18 depuis la racine du repo avec `.venv-torch` (Python 3.11.9,
45
+ torch 2.13.0+cpu) :
46
+
47
+ ```text
48
+ fichier : C:\Users\PHIL\ZCodeProject\fractus-vorax\brain\FRACTUS_BIRTH.pt
49
+ taille : 4,662,144,283 octets
50
+ sha256 : 15cb8abe88809c4ad3793d1fd7829d24fd9702150002c323f368cf13f7be96fa (4.8s)
51
+ sonde : probe_checkpoint_kind -> 'cte'
52
+ clés wrapper (top-level) : ['config', 'frozen_merge', 'model_state', 'n']
53
+ config.batch = 2
54
+ config.boost = True
55
+ config.coupling_rank = 8
56
+ config.d_head = 64
57
+ config.d_model = 1280
58
+ config.expert_d_ff = 2048
59
+ config.gpu = 0
60
+ config.lr = 0.0007
61
+ config.n_experts = 128
62
+ config.n_heads = 20
63
+ config.n_layers = 16
64
+ config.n_levels = 2
65
+ config.n_oscillators = 16
66
+ config.siren_rank = 64
67
+ config.ss_rate = 0.25
68
+ config.tokens_processed = 15564800
69
+ config.top_k = 2
70
+ frozen_merge = True
71
+ n = 8
72
+ strict : from_checkpoint (load_state_dict strict=True) -> 440 cles
73
+ config inféré des shapes (+indices wrapper) :
74
+ d_model=1280 n_heads=20 d_head=64 n_layers=16
75
+ n_levels=2 n_oscillators=16 coupling_rank=8
76
+ n_experts=128 top_k=2 expert_d_ff=2048 siren_rank=64
77
+ vocab_size=50257 batch=2 train_seq_len=128
78
+ forward : B=1 L=4 -> logits (1, 4, 50257) finis=True min=-265.9127 max=264.5801
79
+ VERDICT : cerveau de naissance INSTALLÉ et VÉRIFIÉ (strict 440/440, logits finis)
80
+ exit_code=0
81
+ ```
82
+
83
+ Le même jour, la suite torch complète (checkpoint réel inclus) a passé :
84
+ **142 passed, 0 skipped** — le test du vrai checkpoint (`slow`), skippé à la
85
+ naissance du repo, tourne et passe désormais (il lit `brain/FRACTUS_BIRTH.pt`
86
+ d'abord, repli sur l'héritage `checkpoints/checkpoints/`). La suite mingw
87
+ (substrat sans torch) reste **113 passed, 8 skipped** — inchangée.
88
+
89
+ ## Statut honnête
90
+
91
+ Ce cerveau a vu passer 124,5 M de tokens — fin du stage 2, loss encore haute :
92
+ il babille, il ne répond pas encore. Le word salad est attendu et documenté
93
+ (`bench/core_speak.py` rapporte chaque génération verbatim). En attendant que
94
+ l'espèce s'affine un jour par nouvelle lignée, ce sont **les organes qui
95
+ portent les démos** (cartes FAIT/ANALOGIE, killer bench) — et c'est le point :
96
+ la compétence linguistique est née une fois ; la connaissance, elle, s'écrit à
97
+ l'infini par ingestion.
98
+
99
+ ## Appendice — empreinte complète
100
+
101
+ ```
102
+ sha256( brain/FRACTUS_BIRTH.pt )
103
+ = 15cb8abe88809c4ad3793d1fd7829d24fd9702150002c323f368cf13f7be96fa
104
+ ```
105
+
106
+ Toute copie future du cerveau de naissance DOIT retrouver cette empreinte,
107
+ octet pour octet. Si elle diffère, ce n'est pas ce cerveau.
docs/heritage/cte-architecture.md ADDED
@@ -0,0 +1,771 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Architecture CTE — spec du checkpoint 1B « FROZEN_MERGED »
2
+
3
+ > Source de vérité pour `vorax/model/cte_core.py` (plan 5, T2). Toute affirmation
4
+ > cite `fichier:ligne` du dépôt fractus-cte (LECTURE SEULE, jamais importé) ou du
5
+ > sondage du checkpoint local. Leçons P3 appliquées : chasse aux divergences
6
+ > silencieuses (GELU, orientation x@W) en priorité.
7
+
8
+ ## 0. Sources et méthode
9
+
10
+ - Code : `C:\Users\PHIL\ZCodeProject\fractus-cte\fractus\continuous_engine.py` (moteur),
11
+ `fractus/nn/attention.py`, `fractus/nn/moe.py`, `fractus/nn/phase_ode.py`,
12
+ `fractus/nn/stats.py`, `fractus/nn/farey.py`.
13
+ - Entraînement (producteur exact du checkpoint) : `fractus-cte\scripts\fast4gpu_boost.py`
14
+ (la config sauvée lignes 207-215 correspond CLÉ POUR CLÉ au config sondé) ;
15
+ `scripts\fast4gpu_stage2_ss.py` (phase 2, B=2/SEQ=128, lignes 39) ;
16
+ `scripts\smoke_load_merged.py` (validation tick_chunk).
17
+ - Génération officielle : `fractus-cte\fractus\generate_aligned.py`.
18
+ - Checkpoint : `checkpoints/checkpoints/FRACTUS_1B_PHASE2_FROZEN_MERGED.pt`
19
+ (4,66 GB, gitignored), sondé avec `.venv-torch` (float32 intégral, 440 clés).
20
+
21
+ Checkpoint top-level : `['model_state', 'config', 'frozen_merge', 'n']`,
22
+ `frozen_merge=True`, `n=8` (fusion de 8 checkpoints per-GPU ; l'outil de fusion
23
+ n'est PAS dans le dépôt fractus-cte — grep « frozen_merge » sans résultat ;
24
+ métadonnée sans effet sur le forward).
25
+
26
+ Config sondé (identique à `fast4gpu_boost.py:47-59` + clés trainer lignes 207-215) :
27
+
28
+ ```
29
+ d_model=1280, n_heads=20, d_head=64, n_levels=2, n_oscillators=16,
30
+ coupling_rank=8, n_experts=128, top_k=2, expert_d_ff=2048, siren_rank=64,
31
+ n_layers=16, vocab_size=50257 (inféré de observe.weight),
32
+ gpu=0, boost=True, batch=2, lr=7e-4, ss_rate=0.25, tokens_processed=15_564_800
33
+ ```
34
+
35
+ `tokens_processed = 15 564 800 = batch(2) × SEQ(128) × 60 800 pas` — ~15,5 M
36
+ tokens par shard (chiffre honnête : attendre des productions faibles).
37
+
38
+ ## Forward
39
+
40
+ ### Rôle de `observe` vs embedding, et liaison de la tête de sortie
41
+
42
+ - `observe` = `nn.Embedding(vocab_size, d_model)` (continuous_engine.py:262) :
43
+ embedding de token SEUL. **Pas de positional embedding, pas de norm
44
+ d'embedding** (contrairement au BPEEmbedding 88M, fractus_core.py:500-514) —
45
+ la position vient du carry (S, z) de l'attention linéaire.
46
+ - `output_head` = `nn.Linear(d_model, vocab_size, bias=False)`
47
+ (continuous_engine.py:280), **LIÉE** : `self.output_head.weight =
48
+ self.observe.weight` (continuous_engine.py:281). Sondage : `torch.equal(
49
+ observe.weight, output_head.weight) == True` (bit-identiques dans le
50
+ checkpoint ; les deux clés existent séparément dans le state_dict).
51
+ Convention nn.Linear : `logits = h @ output_head.weight.T` (les deux poids
52
+ (50257, 1280)).
53
+ - `confidence_head` = `nn.Linear(d_model, 1)` (continuous_engine.py:279) ;
54
+ `salience_head` = `nn.Linear(d_model, 1)` (continuous_engine.py:282). À
55
+ l'inférence ce sont des **lectures seules** : `confidence =
56
+ sigmoid(confidence_head(h[:, 0, :]))` est retournée par `tick()`
57
+ (continuous_engine.py:454) mais n'entre pas dans les logits ;
58
+ `salience` n'est utilisée QUE si une mémoire est attachée
59
+ (continuous_engine.py:437-439 ; `self.memory = None` par défaut, :285).
60
+ `generate_aligned.generate_chunk` n'utilise ni l'une ni l'autre.
61
+ - **Pas de LayerNorm final** avant la tête (contrairement à
62
+ fractus_core.py:572/582-583) : `output_head` s'applique directement au h
63
+ sorti du dernier bloc (continuous_engine.py:474).
64
+
65
+ ### DEUX chemins de forward — celui du training/generation est le CHUNK
66
+
67
+ Le moteur a deux familles de forward, et elles NE SONT PAS équivalentes :
68
+
69
+ 1. `tick()` → `CTEBlock.tick_single` (continuous_engine.py:405-456, 82-137) :
70
+ un token à la fois. **Attention mono-niveau** : `q_feat = elu_plus_one(q +
71
+ attn.level_offsets[0])` (continuous_engine.py:93-94) — offset du niveau 0
72
+ uniquement, PAS d'agrégation `softmax(level_logits)`. Boucle Python par
73
+ tête pour la mise à jour (S, z) (continuous_engine.py:96-106).
74
+ 2. `tick_chunk()` / `tick_chunk_train()` → `CTEBlock.tick_chunk_core`
75
+ (continuous_engine.py:458-497, 139-217) : chunk (B, C, d), attention
76
+ multi-niveaux complète avec carry, agrégation `softmax(level_logits)`
77
+ (continuous_engine.py:196-197).
78
+
79
+ **Décision tranchée** : le training a utilisé `tick_chunk_train`
80
+ (fast4gpu_boost.py:152, fast4gpu_stage2_ss.py:49) et la génération officielle
81
+ utilise `tick_chunk` SEULE — « Uses tick_chunk only (same path as stage2
82
+ training), never tick_single » (generate_aligned.py:1-4, appels :34 et :47).
83
+ `tick_single` contient un reste de mismatch train/gén connu (commentaire
84
+ continuous_engine.py:123-124 : le Kuramoto a été réaligné sur RK4, mais
85
+ l'attention y reste mono-niveau). **CteCore DOIT implémenter le chemin
86
+ `tick_chunk_core` ; la génération token par token = chunks de longueur 1.**
87
+ Ne jamais reproduire `tick_single`.
88
+
89
+ ### Sémantique exacte de `tick_chunk_core` (continuous_engine.py:139-217)
90
+
91
+ Entrée `h` : (B, C, d_model). Pour chaque bloc, dans l'ordre :
92
+
93
+ 1. **Attention pré-norm** : `h_normed = self.norm_attn(h)` (:149). Projections
94
+ Q/K/V en convention **x@W** : `q = einsum("bld,de->ble", h_normed,
95
+ attn.w_qkv[0]) + attn.b_qkv[0]` (idem k avec l'indice 1, v avec 2)
96
+ (:150-152 ; attention.py:204-206). `w_qkv` (3, d, d) est indexé [q, k, v],
97
+ chaque tranche (in=d_model, out=n_heads·d_head) — **PAS nn.Linear x@Wᵀ**
98
+ (leçon P3, encore vraie ici).
99
+ 2. **Têtes et niveaux** : reshape (B, C, nH, dH) (:153-155) ; offsets par
100
+ niveau `ω_level = (φ²)^(−level)` ajoutés à q et k (:157-159 ;
101
+ attention.py:21-31 ; sondé `[1.0, 0.3819660544395447]` = 2.618^0, 2.618^−1) ;
102
+ feature map `elu_plus_one(x, α=1)` sur q et k UNIQUEMENT — v reste brut
103
+ (:160-162 ; stats.py:18-31 : φ(x) = x+1 si x>0 sinon α(e^x−1)+1).
104
+ 3. **Aplat multi-niveaux×têtes** : (B·n_levels·nH, C, dH) via
105
+ permute(0,1,3,2,4) (:163-165).
106
+ 4. **Carry (S, z)** : extraction des blocs diagonaux par tête de `attn_S` /
107
+ `attn_z` (:168-175), expansion identique sur les n_levels (:176-179).
108
+ 5. **Attention linéaire causale INCLUSIVE avec carry**
109
+ (attention.py:129-187) : `S_t = S0 + Σ_{i≤t} k_i⊗v_i`, `z_t = z0 + Σ_{i≤t}
110
+ k_i`, `y_t = (q_t·S_t)/(q_t·z_t)`, sortie 0 si |dénom| < 1e-10
111
+ (attention.py:155-179). Retourne y ET l'état final (S_final, z_final)
112
+ uniquement quand un carry est fourni (attention.py:181-186).
113
+ 6. **Nouvel état** : `S_final` (B·nL·nH, dH, dH) → reshape (B, nL, nH, dH,
114
+ dH) → **MOYENNE sur les niveaux** (dim=1) → reconstruction
115
+ bloc-diagonale (B, nH·dH, nH·dH) avec zéros hors blocs (:184-193). Les
116
+ états sont `.detach()` (:192-193).
117
+ 7. **Agrégation de niveaux + sortie** : y → (B, nL, C, nH·dH) (:195) ;
118
+ `level_weights = torch.softmax(attn.level_logits, dim=-1)` (:196) ;
119
+ `attn_out = Σ_level w_level · y_level` (:197) ; projection **x@W** :
120
+ `attn_out @ attn.w_out + attn.b_out` (:198) ; **résidu** `h = h +
121
+ attn_out` (:199). (torch.softmax ici, stable_softmax dans
122
+ attention.py:232 — équivalentes numériquement, softmax torch étant
123
+ stabilisé par soustraction du max.)
124
+ 8. **Kuramoto pré-norm** : `h_kur = self.norm_kur(h)` (:203). Phases initiales
125
+ depuis le hidden : `θ_init = 2π·mean_d(h_kur) + 2π·i/N (mod 2π)`
126
+ (phase_ode.py:113-119). Intégration **RK4, n_steps=1, dt=0.1**
127
+ (continuous_engine.py:52-53 ; phase_ode.py:91-111), dérivée
128
+ `dθ_i/dt = ω_i − 0.01·θ_i + Σ_j K_ij sin(θ_j − θ_i)` avec
129
+ `K = UΛUᵀ` bas-rang (phase_ode.py:55-69 ; damping=0.01 par défaut
130
+ :37). Wrap mod 2π après chaque pas (phase_ode.py:103-110). Buffer
131
+ `self.kuramoto_phases = theta.detach()` (:206).
132
+ 9. **MoE pré-norm** : `h_moe = self.norm_moe(h)` (:211) ; **phases de la
133
+ DERNIÈRE position du chunk broadcastées sur tout le chunk** :
134
+ `phases_last = theta[:, -1:, :]` puis `expand(-1, C, -1)` (:212-213) ;
135
+ `moe_out, lb_loss = self.moe(h_moe, phases_in)` (:214) ; **résidu**
136
+ `h = h + moe_out` (:215). Retour (h, lb_loss).
137
+
138
+ ### Niveau moteur (tick_chunk, continuous_engine.py:458-475)
139
+
140
+ 1. `obs_vecs = self.observe(observations)` (:463) — embedding token seul.
141
+ 2. `h[:, 0, :] += self.thought_state[:, 0, :]` (:465) — le flux résiduel
142
+ porté n'est ajouté **qu'à la position 0** du chunk.
143
+ 3. Pile des blocs : `h, lb = blk.tick_chunk_core(h)` (:468-470) ; `lb`
144
+ cumulée détachée dans `last_lb_loss` (:470-471).
145
+ 4. `self.thought_state = h[:, -1:, :].detach()` (:473) — le dernier h devient
146
+ le nouveau porteur.
147
+ 5. `output_logits = self.output_head(h)` (:474) — logits (B, C, vocab) sur
148
+ TOUTES les positions (dense CE au training, tick_chunk_train :477-497
149
+ identique + lb non détachée).
150
+
151
+ ## États porteurs
152
+
153
+ Trois familles d'états persistants, TOUS enregistrés en buffers donc présents
154
+ dans le state_dict (d'où leurs clés) :
155
+
156
+ | État (clé) | Shape checkpoint | Sémantique | Mise à jour | Reset |
157
+ |---|---|---|---|---|
158
+ | `thought_state` | (2, 1, 1280) | flux résiduel partagé (B, 1, d_model), continu à travers chunks/ticks | devient `h[:, -1:, :].detach()` du dernier bloc (continuous_engine.py:473, 434) | zeros (continuous_engine.py:336-341) |
159
+ | `blocks.N.attn_S` | (2, 1280, 1280) | (batch, n_heads·d_head, n_heads·d_head), **bloc-diagonale par tête** (blocs dH×dH sur la diagonale, zéros ailleurs) | moyenne sur niveaux de l'état final du chunk, détachée (continuous_engine.py:184-193) | zeros (continuous_engine.py:73-80) |
160
+ | `blocks.N.attn_z` | (2, 1280) | (batch, n_heads·d_head), segments par tête | idem S (continuous_engine.py:184-193) | zeros |
161
+ | `blocks.N.kuramoto_phases` | (2, 128, 16) | **(batch, L_dernier_forward, n_oscillators)** — le 128 du milieu est la longueur de chunk d'entraînement SEQ=128 (fast4gpu_stage2_ss.py:39), PAS n_levels ni batch | réécrite à chaque forward depuis h (`theta.detach()`, continuous_engine.py:206) — **jamais lue en entrée** : KuramotoLayer est STATELESS (phase_ode.py:9-10, 113-119 : les phases initiales dérivent du hidden à chaque appel) | zeros(batch, 1, N) (continuous_engine.py:79-80) |
162
+ | `last_lb_loss` | () | accumulateur de load-balance (métrique training) | somme des lb des blocs (continuous_engine.py:470-471) | non réinitialisée (sans effet forward) |
163
+
164
+ Points critiques pour T2 :
165
+
166
+ - **`attn_S`/`attn_z` sont VRAIMENT porteurs** (lues en carry au forward
167
+ suivant, continuous_engine.py:168-182) ; `kuramoto_phases` est un snapshot
168
+ d'observabilité écrit-seul : sa SHAPE compte pour le strict load (2, 128,
169
+ 16), sa VALEUR est sans effet. `reset_state` re-dimensionne ces buffers
170
+ dynamiquement (continuous_engine.py:73-80) — construire en (2, …) pour le
171
+ strict load puis `reset_states()` en batch 1 à l'inférence est fidèle.
172
+ - Le checkpoint contient ces états NON NULS (sondage : absmax thought_state
173
+ 20.6, attn_S 1.0e8, attn_z 3.4e7 — résidus du dernier chunk d'entraînement,
174
+ S ayant accumulé depuis le `reset_thought` de début d'époque,
175
+ fast4gpu_boost.py:105). Sans effet : la génération officielle reset tout
176
+ avant de commencer (generate_aligned.py:22-27).
177
+ - La première dimension 2 partout = `batch` du trainer (B=2,
178
+ fast4gpu_stage2_ss.py:39 ; `batch: 2` dans le config). À l'inférence la
179
+ génération officielle travaille en batch 1 (`reset_thought(1)`,
180
+ generate_aligned.py:22).
181
+ - `reset_thought` (continuous_engine.py:336-341) remet TOUT à zéro :
182
+ thought_state + attn_S/attn_z/kuramoto_phases de chaque bloc. C'est le reset
183
+ inter-séquences à l'inférence.
184
+
185
+ ## MoE batché
186
+
187
+ `PhaseRoutedMoE` (moe.py:34-326), construit avec `kappa=4.0`,
188
+ `d_ff=expert_d_ff`, `expert_rank=siren_rank` (continuous_engine.py:60-64) —
189
+ mode **low-rank batché** (moe.py:91-107) : un seul tenseur par famille
190
+ d'experts, dim 0 = index d'expert E=128.
191
+
192
+ - `expert_phases` (E,) : buffer Farey (moe.py:79-80 ; farey.py:40-55 :
193
+ E angles 2π·p/q tirés uniformément de F_{2E}). Sondage : max|ckpt −
194
+ recompute| = 6.4e-07 (arrondi float32) — régénérable, mais la clé existe →
195
+ buffer pour le strict load.
196
+ - **Routage von Mises** (moe.py:109-125) : depuis les phases (B, L, N=16) :
197
+ `θ̄ = atan2(Σ_n sin θ_n, Σ_n cos θ_n)` (:114-116) ; **`κ_eff =
198
+ kappa / temperature`** (:117) ; `gate_e ∝ exp(κ_eff·cos(θ̄ − φ_e))`,
199
+ normalisées (uniforme si somme < 1e-10) (:118-125). `top_k=2` par valeur
200
+ (moe.py:304), gates retenues **renormalisées** sur les k retenus (:305-309).
201
+ - **TEMPÉRATURE = 2.5 À L'ENTRAÎNEMENT — PIÈGE** : `temperature` est un
202
+ attribut runtime, défaut 1.0 (moe.py:59, 73-74), **absent du state_dict et
203
+ du config**. Tous les trainers la fixent à 2.5 après chargement
204
+ (fast4gpu_boost.py:39 et 99-102 ; fast4gpu_stage2_ss.py:9 et 32-34 ;
205
+ fast4gpu_surgery.py:53). Pour une inférence fidèle aux poids :
206
+ **κ_eff = 4.0 / 2.5 = 1.6**. L'oublier change toutes les gates → divergence
207
+ silencieuse (même famille que la leçon GELU de P3).
208
+ - **Dispatch** : sparse gather-first si `E > 2·top_k` (moe.py:312) — avec
209
+ E=128, K=2 toujours vérifié → chemin `_sparse_expert_forward`
210
+ (moe.py:193-238). Bit-identique au dense (moe.py:8-17, 311-318).
211
+ - **Calcul par expert (low-rank, moe.py:213-238)** — orientation x@W partout :
212
+ 1. Couche 1 : `hV1 = einsum('nd,nkdr->nkr', h, V1)` = **h @ V1** avec V1
213
+ (E, D=1280, r=64) (:230) ; `h1 = scale1 · (hV1 @ U1ᵀ) + b1` avec U1
214
+ (E, F=2048, r=64) (:232) — soit `W1_eff = scale1·U1@V1ᵀ` de forme
215
+ (F, D), appliqué **x@W1** (PAS nn.Linear x@Wᵀ). scale1 (E, 1, 1)
216
+ multiplie AVANT le bias : `g_s1.squeeze(-1) * (...) + g_b1`.
217
+ 2. **Activation : GELU — CONFIRMÉE** : `h1_act = _gelu(h1)` (moe.py:233,
218
+ aussi :248, :268, :278 sur tous les chemins). **Approximation TANH**
219
+ (moe.py:27-31) : `0.5·x·(1 + tanh(√(2/π)·(x + 0.044715·x³)))` —
220
+ PAS `F.gelu` exact (qui est la variante du 88M, fractus_core.py:481).
221
+ Différence max ~3e-3 par activation, amplifiée par 16 blocs.
222
+ 3. Couche 2 : `hV2 = h1_act @ V2` avec V2 (E, F=2048, r) (:236) ;
223
+ `out = scale2 · (hV2 @ U2ᵀ) + b2` avec U2 (E, D=1280, r) (:237) —
224
+ `W2_eff = scale2·U2@V2ᵀ` (D, F), appliqué x@W2.
225
+ 4. Mélange : `output = Σ_k gate_norm_k · out_k` (moe.py:319).
226
+ - **Load-balance** (training uniquement) : `P = gates.mean(dim=(0,1))`,
227
+ `lb = E·Σ_e (P_e − 1/E)²` (moe.py:323-324). Le poids du MoE retourne
228
+ `(output, lb_loss)` (moe.py:286-326) ; à l'inférence lb est ignorée.
229
+ - `add_expert` (moe.py:127-191, self-modification) : hors scope inférence,
230
+ jamais appelé par les scripts de génération.
231
+
232
+ ## Recette d'inférence
233
+
234
+ Recette officielle = `generate_aligned.generate_chunk`
235
+ (generate_aligned.py:9-50), « train-aligned » :
236
+
237
+ 1. `engine.eval()` ; **reset complet** : `engine.reset_thought(1)` —
238
+ thought_state=0, attn_S/attn_z/kuramoto_phases de chaque bloc=0 en batch 1
239
+ (continuous_engine.py:336-341, 73-80) ; le script re-zero attn_S/attn_z
240
+ (:23-27, redondant).
241
+ 2. **Fixer `moe.temperature = 2.5` sur chaque bloc** (κ_eff=1.6) — fait par
242
+ les trainers au chargement (fast4gpu_boost.py:99-102), à répliquer au
243
+ chargement du checkpoint côté CteCore.
244
+ 3. Prompt encodé, tronqué (context_limit=128 dans le script d'origine, valeur
245
+ de sampling sans importance pour le noyau) : **UN SEUL chunk** `logits =
246
+ tick_chunk(prompt)` ; prendre `logits[0, -1]` (generate_aligned.py:34-35).
247
+ 4. Boucle token par token : **chunk de longueur 1** `logits =
248
+ tick_chunk([[next]])` (generate_aligned.py:47) ; `cur = logits[0, -1]` ;
249
+ greedy = `argmax(cur)` (déterministe ; le script d'origine utilise
250
+ température/top-k/repetition-ban — options de décodage, pas du noyau).
251
+ 5. Les états porteurs vivent À TRAVERS la boucle : jamais de reset entre les
252
+ tokens, jamais d'appel à `tick()`/`tick_single` (mismatch mono-niveau,
253
+ cf. §Forward).
254
+ 6. `boost`, `ss_rate`, `lr` : **métadonnées training uniquement**. Scheduled
255
+ sampling (fast4gpu_boost.py:167-188) : avec proba ss_rate=0.25 par pas, une
256
+ SECONDE passe d'entraînement sur un chunk mixte (20 % des tokens remplacés
257
+ par les propres échantillons du modèle, position 0 protégée). Zéro effet
258
+ sur le forward d'inférence. `batch=2` : dimension batch des états du
259
+ checkpoint (cf. §États porteurs).
260
+ 7. Fin de séquence : sémantique ratifiée P4 « append-then-break » sur EOS
261
+ (à implémenter côté CteCore.generate ; l'original n'a pas d'EOS).
262
+
263
+ Pour T2 : `CteCore.forward(token_ids)` = cette recette étape 3 pour un chunk
264
+ quelconque (B, L) ; `CteCore.generate` = étapes 1-4 en greedy avec reset
265
+ initial. Le contexte est non borné (l'attention linéaire condense tout
266
+ l'historique dans (S, z)) mais les magnitudes de S croissent sans reset —
267
+ le reset inter-séquences est obligatoire.
268
+
269
+ ## Table des clés
270
+
271
+ 440 clés au total = 8 top-level + 16 blocs × 27 (sondage direct du
272
+ checkpoint, trié ; toutes float32 ; `blocks.{0..15}` suivent le MÊME motif —
273
+ tableau généré mécaniquement depuis le checkpoint) :
274
+
275
+ | Clé | Shape |
276
+ |---|---|
277
+ | `blocks.0.attn.b_out` | (1280,) |
278
+ | `blocks.0.attn.b_qkv` | (3, 1280) |
279
+ | `blocks.0.attn.level_logits` | (2,) |
280
+ | `blocks.0.attn.level_offsets` | (2,) |
281
+ | `blocks.0.attn.w_out` | (1280, 1280) |
282
+ | `blocks.0.attn.w_qkv` | (3, 1280, 1280) |
283
+ | `blocks.0.attn_S` | (2, 1280, 1280) |
284
+ | `blocks.0.attn_z` | (2, 1280) |
285
+ | `blocks.0.kuramoto.coupling_lambda` | (8,) |
286
+ | `blocks.0.kuramoto.coupling_u` | (16, 8) |
287
+ | `blocks.0.kuramoto.omega` | (16,) |
288
+ | `blocks.0.kuramoto_phases` | (2, 128, 16) |
289
+ | `blocks.0.moe.U1` | (128, 2048, 64) |
290
+ | `blocks.0.moe.U2` | (128, 1280, 64) |
291
+ | `blocks.0.moe.V1` | (128, 1280, 64) |
292
+ | `blocks.0.moe.V2` | (128, 2048, 64) |
293
+ | `blocks.0.moe.b1` | (128, 2048) |
294
+ | `blocks.0.moe.b2` | (128, 1280) |
295
+ | `blocks.0.moe.expert_phases` | (128,) |
296
+ | `blocks.0.moe.scale1` | (128, 1, 1) |
297
+ | `blocks.0.moe.scale2` | (128, 1, 1) |
298
+ | `blocks.0.norm_attn.bias` | (1280,) |
299
+ | `blocks.0.norm_attn.weight` | (1280,) |
300
+ | `blocks.0.norm_kur.bias` | (1280,) |
301
+ | `blocks.0.norm_kur.weight` | (1280,) |
302
+ | `blocks.0.norm_moe.bias` | (1280,) |
303
+ | `blocks.0.norm_moe.weight` | (1280,) |
304
+ | `blocks.1.attn.b_out` | (1280,) |
305
+ | `blocks.1.attn.b_qkv` | (3, 1280) |
306
+ | `blocks.1.attn.level_logits` | (2,) |
307
+ | `blocks.1.attn.level_offsets` | (2,) |
308
+ | `blocks.1.attn.w_out` | (1280, 1280) |
309
+ | `blocks.1.attn.w_qkv` | (3, 1280, 1280) |
310
+ | `blocks.1.attn_S` | (2, 1280, 1280) |
311
+ | `blocks.1.attn_z` | (2, 1280) |
312
+ | `blocks.1.kuramoto.coupling_lambda` | (8,) |
313
+ | `blocks.1.kuramoto.coupling_u` | (16, 8) |
314
+ | `blocks.1.kuramoto.omega` | (16,) |
315
+ | `blocks.1.kuramoto_phases` | (2, 128, 16) |
316
+ | `blocks.1.moe.U1` | (128, 2048, 64) |
317
+ | `blocks.1.moe.U2` | (128, 1280, 64) |
318
+ | `blocks.1.moe.V1` | (128, 1280, 64) |
319
+ | `blocks.1.moe.V2` | (128, 2048, 64) |
320
+ | `blocks.1.moe.b1` | (128, 2048) |
321
+ | `blocks.1.moe.b2` | (128, 1280) |
322
+ | `blocks.1.moe.expert_phases` | (128,) |
323
+ | `blocks.1.moe.scale1` | (128, 1, 1) |
324
+ | `blocks.1.moe.scale2` | (128, 1, 1) |
325
+ | `blocks.1.norm_attn.bias` | (1280,) |
326
+ | `blocks.1.norm_attn.weight` | (1280,) |
327
+ | `blocks.1.norm_kur.bias` | (1280,) |
328
+ | `blocks.1.norm_kur.weight` | (1280,) |
329
+ | `blocks.1.norm_moe.bias` | (1280,) |
330
+ | `blocks.1.norm_moe.weight` | (1280,) |
331
+ | `blocks.10.attn.b_out` | (1280,) |
332
+ | `blocks.10.attn.b_qkv` | (3, 1280) |
333
+ | `blocks.10.attn.level_logits` | (2,) |
334
+ | `blocks.10.attn.level_offsets` | (2,) |
335
+ | `blocks.10.attn.w_out` | (1280, 1280) |
336
+ | `blocks.10.attn.w_qkv` | (3, 1280, 1280) |
337
+ | `blocks.10.attn_S` | (2, 1280, 1280) |
338
+ | `blocks.10.attn_z` | (2, 1280) |
339
+ | `blocks.10.kuramoto.coupling_lambda` | (8,) |
340
+ | `blocks.10.kuramoto.coupling_u` | (16, 8) |
341
+ | `blocks.10.kuramoto.omega` | (16,) |
342
+ | `blocks.10.kuramoto_phases` | (2, 128, 16) |
343
+ | `blocks.10.moe.U1` | (128, 2048, 64) |
344
+ | `blocks.10.moe.U2` | (128, 1280, 64) |
345
+ | `blocks.10.moe.V1` | (128, 1280, 64) |
346
+ | `blocks.10.moe.V2` | (128, 2048, 64) |
347
+ | `blocks.10.moe.b1` | (128, 2048) |
348
+ | `blocks.10.moe.b2` | (128, 1280) |
349
+ | `blocks.10.moe.expert_phases` | (128,) |
350
+ | `blocks.10.moe.scale1` | (128, 1, 1) |
351
+ | `blocks.10.moe.scale2` | (128, 1, 1) |
352
+ | `blocks.10.norm_attn.bias` | (1280,) |
353
+ | `blocks.10.norm_attn.weight` | (1280,) |
354
+ | `blocks.10.norm_kur.bias` | (1280,) |
355
+ | `blocks.10.norm_kur.weight` | (1280,) |
356
+ | `blocks.10.norm_moe.bias` | (1280,) |
357
+ | `blocks.10.norm_moe.weight` | (1280,) |
358
+ | `blocks.11.attn.b_out` | (1280,) |
359
+ | `blocks.11.attn.b_qkv` | (3, 1280) |
360
+ | `blocks.11.attn.level_logits` | (2,) |
361
+ | `blocks.11.attn.level_offsets` | (2,) |
362
+ | `blocks.11.attn.w_out` | (1280, 1280) |
363
+ | `blocks.11.attn.w_qkv` | (3, 1280, 1280) |
364
+ | `blocks.11.attn_S` | (2, 1280, 1280) |
365
+ | `blocks.11.attn_z` | (2, 1280) |
366
+ | `blocks.11.kuramoto.coupling_lambda` | (8,) |
367
+ | `blocks.11.kuramoto.coupling_u` | (16, 8) |
368
+ | `blocks.11.kuramoto.omega` | (16,) |
369
+ | `blocks.11.kuramoto_phases` | (2, 128, 16) |
370
+ | `blocks.11.moe.U1` | (128, 2048, 64) |
371
+ | `blocks.11.moe.U2` | (128, 1280, 64) |
372
+ | `blocks.11.moe.V1` | (128, 1280, 64) |
373
+ | `blocks.11.moe.V2` | (128, 2048, 64) |
374
+ | `blocks.11.moe.b1` | (128, 2048) |
375
+ | `blocks.11.moe.b2` | (128, 1280) |
376
+ | `blocks.11.moe.expert_phases` | (128,) |
377
+ | `blocks.11.moe.scale1` | (128, 1, 1) |
378
+ | `blocks.11.moe.scale2` | (128, 1, 1) |
379
+ | `blocks.11.norm_attn.bias` | (1280,) |
380
+ | `blocks.11.norm_attn.weight` | (1280,) |
381
+ | `blocks.11.norm_kur.bias` | (1280,) |
382
+ | `blocks.11.norm_kur.weight` | (1280,) |
383
+ | `blocks.11.norm_moe.bias` | (1280,) |
384
+ | `blocks.11.norm_moe.weight` | (1280,) |
385
+ | `blocks.12.attn.b_out` | (1280,) |
386
+ | `blocks.12.attn.b_qkv` | (3, 1280) |
387
+ | `blocks.12.attn.level_logits` | (2,) |
388
+ | `blocks.12.attn.level_offsets` | (2,) |
389
+ | `blocks.12.attn.w_out` | (1280, 1280) |
390
+ | `blocks.12.attn.w_qkv` | (3, 1280, 1280) |
391
+ | `blocks.12.attn_S` | (2, 1280, 1280) |
392
+ | `blocks.12.attn_z` | (2, 1280) |
393
+ | `blocks.12.kuramoto.coupling_lambda` | (8,) |
394
+ | `blocks.12.kuramoto.coupling_u` | (16, 8) |
395
+ | `blocks.12.kuramoto.omega` | (16,) |
396
+ | `blocks.12.kuramoto_phases` | (2, 128, 16) |
397
+ | `blocks.12.moe.U1` | (128, 2048, 64) |
398
+ | `blocks.12.moe.U2` | (128, 1280, 64) |
399
+ | `blocks.12.moe.V1` | (128, 1280, 64) |
400
+ | `blocks.12.moe.V2` | (128, 2048, 64) |
401
+ | `blocks.12.moe.b1` | (128, 2048) |
402
+ | `blocks.12.moe.b2` | (128, 1280) |
403
+ | `blocks.12.moe.expert_phases` | (128,) |
404
+ | `blocks.12.moe.scale1` | (128, 1, 1) |
405
+ | `blocks.12.moe.scale2` | (128, 1, 1) |
406
+ | `blocks.12.norm_attn.bias` | (1280,) |
407
+ | `blocks.12.norm_attn.weight` | (1280,) |
408
+ | `blocks.12.norm_kur.bias` | (1280,) |
409
+ | `blocks.12.norm_kur.weight` | (1280,) |
410
+ | `blocks.12.norm_moe.bias` | (1280,) |
411
+ | `blocks.12.norm_moe.weight` | (1280,) |
412
+ | `blocks.13.attn.b_out` | (1280,) |
413
+ | `blocks.13.attn.b_qkv` | (3, 1280) |
414
+ | `blocks.13.attn.level_logits` | (2,) |
415
+ | `blocks.13.attn.level_offsets` | (2,) |
416
+ | `blocks.13.attn.w_out` | (1280, 1280) |
417
+ | `blocks.13.attn.w_qkv` | (3, 1280, 1280) |
418
+ | `blocks.13.attn_S` | (2, 1280, 1280) |
419
+ | `blocks.13.attn_z` | (2, 1280) |
420
+ | `blocks.13.kuramoto.coupling_lambda` | (8,) |
421
+ | `blocks.13.kuramoto.coupling_u` | (16, 8) |
422
+ | `blocks.13.kuramoto.omega` | (16,) |
423
+ | `blocks.13.kuramoto_phases` | (2, 128, 16) |
424
+ | `blocks.13.moe.U1` | (128, 2048, 64) |
425
+ | `blocks.13.moe.U2` | (128, 1280, 64) |
426
+ | `blocks.13.moe.V1` | (128, 1280, 64) |
427
+ | `blocks.13.moe.V2` | (128, 2048, 64) |
428
+ | `blocks.13.moe.b1` | (128, 2048) |
429
+ | `blocks.13.moe.b2` | (128, 1280) |
430
+ | `blocks.13.moe.expert_phases` | (128,) |
431
+ | `blocks.13.moe.scale1` | (128, 1, 1) |
432
+ | `blocks.13.moe.scale2` | (128, 1, 1) |
433
+ | `blocks.13.norm_attn.bias` | (1280,) |
434
+ | `blocks.13.norm_attn.weight` | (1280,) |
435
+ | `blocks.13.norm_kur.bias` | (1280,) |
436
+ | `blocks.13.norm_kur.weight` | (1280,) |
437
+ | `blocks.13.norm_moe.bias` | (1280,) |
438
+ | `blocks.13.norm_moe.weight` | (1280,) |
439
+ | `blocks.14.attn.b_out` | (1280,) |
440
+ | `blocks.14.attn.b_qkv` | (3, 1280) |
441
+ | `blocks.14.attn.level_logits` | (2,) |
442
+ | `blocks.14.attn.level_offsets` | (2,) |
443
+ | `blocks.14.attn.w_out` | (1280, 1280) |
444
+ | `blocks.14.attn.w_qkv` | (3, 1280, 1280) |
445
+ | `blocks.14.attn_S` | (2, 1280, 1280) |
446
+ | `blocks.14.attn_z` | (2, 1280) |
447
+ | `blocks.14.kuramoto.coupling_lambda` | (8,) |
448
+ | `blocks.14.kuramoto.coupling_u` | (16, 8) |
449
+ | `blocks.14.kuramoto.omega` | (16,) |
450
+ | `blocks.14.kuramoto_phases` | (2, 128, 16) |
451
+ | `blocks.14.moe.U1` | (128, 2048, 64) |
452
+ | `blocks.14.moe.U2` | (128, 1280, 64) |
453
+ | `blocks.14.moe.V1` | (128, 1280, 64) |
454
+ | `blocks.14.moe.V2` | (128, 2048, 64) |
455
+ | `blocks.14.moe.b1` | (128, 2048) |
456
+ | `blocks.14.moe.b2` | (128, 1280) |
457
+ | `blocks.14.moe.expert_phases` | (128,) |
458
+ | `blocks.14.moe.scale1` | (128, 1, 1) |
459
+ | `blocks.14.moe.scale2` | (128, 1, 1) |
460
+ | `blocks.14.norm_attn.bias` | (1280,) |
461
+ | `blocks.14.norm_attn.weight` | (1280,) |
462
+ | `blocks.14.norm_kur.bias` | (1280,) |
463
+ | `blocks.14.norm_kur.weight` | (1280,) |
464
+ | `blocks.14.norm_moe.bias` | (1280,) |
465
+ | `blocks.14.norm_moe.weight` | (1280,) |
466
+ | `blocks.15.attn.b_out` | (1280,) |
467
+ | `blocks.15.attn.b_qkv` | (3, 1280) |
468
+ | `blocks.15.attn.level_logits` | (2,) |
469
+ | `blocks.15.attn.level_offsets` | (2,) |
470
+ | `blocks.15.attn.w_out` | (1280, 1280) |
471
+ | `blocks.15.attn.w_qkv` | (3, 1280, 1280) |
472
+ | `blocks.15.attn_S` | (2, 1280, 1280) |
473
+ | `blocks.15.attn_z` | (2, 1280) |
474
+ | `blocks.15.kuramoto.coupling_lambda` | (8,) |
475
+ | `blocks.15.kuramoto.coupling_u` | (16, 8) |
476
+ | `blocks.15.kuramoto.omega` | (16,) |
477
+ | `blocks.15.kuramoto_phases` | (2, 128, 16) |
478
+ | `blocks.15.moe.U1` | (128, 2048, 64) |
479
+ | `blocks.15.moe.U2` | (128, 1280, 64) |
480
+ | `blocks.15.moe.V1` | (128, 1280, 64) |
481
+ | `blocks.15.moe.V2` | (128, 2048, 64) |
482
+ | `blocks.15.moe.b1` | (128, 2048) |
483
+ | `blocks.15.moe.b2` | (128, 1280) |
484
+ | `blocks.15.moe.expert_phases` | (128,) |
485
+ | `blocks.15.moe.scale1` | (128, 1, 1) |
486
+ | `blocks.15.moe.scale2` | (128, 1, 1) |
487
+ | `blocks.15.norm_attn.bias` | (1280,) |
488
+ | `blocks.15.norm_attn.weight` | (1280,) |
489
+ | `blocks.15.norm_kur.bias` | (1280,) |
490
+ | `blocks.15.norm_kur.weight` | (1280,) |
491
+ | `blocks.15.norm_moe.bias` | (1280,) |
492
+ | `blocks.15.norm_moe.weight` | (1280,) |
493
+ | `blocks.2.attn.b_out` | (1280,) |
494
+ | `blocks.2.attn.b_qkv` | (3, 1280) |
495
+ | `blocks.2.attn.level_logits` | (2,) |
496
+ | `blocks.2.attn.level_offsets` | (2,) |
497
+ | `blocks.2.attn.w_out` | (1280, 1280) |
498
+ | `blocks.2.attn.w_qkv` | (3, 1280, 1280) |
499
+ | `blocks.2.attn_S` | (2, 1280, 1280) |
500
+ | `blocks.2.attn_z` | (2, 1280) |
501
+ | `blocks.2.kuramoto.coupling_lambda` | (8,) |
502
+ | `blocks.2.kuramoto.coupling_u` | (16, 8) |
503
+ | `blocks.2.kuramoto.omega` | (16,) |
504
+ | `blocks.2.kuramoto_phases` | (2, 128, 16) |
505
+ | `blocks.2.moe.U1` | (128, 2048, 64) |
506
+ | `blocks.2.moe.U2` | (128, 1280, 64) |
507
+ | `blocks.2.moe.V1` | (128, 1280, 64) |
508
+ | `blocks.2.moe.V2` | (128, 2048, 64) |
509
+ | `blocks.2.moe.b1` | (128, 2048) |
510
+ | `blocks.2.moe.b2` | (128, 1280) |
511
+ | `blocks.2.moe.expert_phases` | (128,) |
512
+ | `blocks.2.moe.scale1` | (128, 1, 1) |
513
+ | `blocks.2.moe.scale2` | (128, 1, 1) |
514
+ | `blocks.2.norm_attn.bias` | (1280,) |
515
+ | `blocks.2.norm_attn.weight` | (1280,) |
516
+ | `blocks.2.norm_kur.bias` | (1280,) |
517
+ | `blocks.2.norm_kur.weight` | (1280,) |
518
+ | `blocks.2.norm_moe.bias` | (1280,) |
519
+ | `blocks.2.norm_moe.weight` | (1280,) |
520
+ | `blocks.3.attn.b_out` | (1280,) |
521
+ | `blocks.3.attn.b_qkv` | (3, 1280) |
522
+ | `blocks.3.attn.level_logits` | (2,) |
523
+ | `blocks.3.attn.level_offsets` | (2,) |
524
+ | `blocks.3.attn.w_out` | (1280, 1280) |
525
+ | `blocks.3.attn.w_qkv` | (3, 1280, 1280) |
526
+ | `blocks.3.attn_S` | (2, 1280, 1280) |
527
+ | `blocks.3.attn_z` | (2, 1280) |
528
+ | `blocks.3.kuramoto.coupling_lambda` | (8,) |
529
+ | `blocks.3.kuramoto.coupling_u` | (16, 8) |
530
+ | `blocks.3.kuramoto.omega` | (16,) |
531
+ | `blocks.3.kuramoto_phases` | (2, 128, 16) |
532
+ | `blocks.3.moe.U1` | (128, 2048, 64) |
533
+ | `blocks.3.moe.U2` | (128, 1280, 64) |
534
+ | `blocks.3.moe.V1` | (128, 1280, 64) |
535
+ | `blocks.3.moe.V2` | (128, 2048, 64) |
536
+ | `blocks.3.moe.b1` | (128, 2048) |
537
+ | `blocks.3.moe.b2` | (128, 1280) |
538
+ | `blocks.3.moe.expert_phases` | (128,) |
539
+ | `blocks.3.moe.scale1` | (128, 1, 1) |
540
+ | `blocks.3.moe.scale2` | (128, 1, 1) |
541
+ | `blocks.3.norm_attn.bias` | (1280,) |
542
+ | `blocks.3.norm_attn.weight` | (1280,) |
543
+ | `blocks.3.norm_kur.bias` | (1280,) |
544
+ | `blocks.3.norm_kur.weight` | (1280,) |
545
+ | `blocks.3.norm_moe.bias` | (1280,) |
546
+ | `blocks.3.norm_moe.weight` | (1280,) |
547
+ | `blocks.4.attn.b_out` | (1280,) |
548
+ | `blocks.4.attn.b_qkv` | (3, 1280) |
549
+ | `blocks.4.attn.level_logits` | (2,) |
550
+ | `blocks.4.attn.level_offsets` | (2,) |
551
+ | `blocks.4.attn.w_out` | (1280, 1280) |
552
+ | `blocks.4.attn.w_qkv` | (3, 1280, 1280) |
553
+ | `blocks.4.attn_S` | (2, 1280, 1280) |
554
+ | `blocks.4.attn_z` | (2, 1280) |
555
+ | `blocks.4.kuramoto.coupling_lambda` | (8,) |
556
+ | `blocks.4.kuramoto.coupling_u` | (16, 8) |
557
+ | `blocks.4.kuramoto.omega` | (16,) |
558
+ | `blocks.4.kuramoto_phases` | (2, 128, 16) |
559
+ | `blocks.4.moe.U1` | (128, 2048, 64) |
560
+ | `blocks.4.moe.U2` | (128, 1280, 64) |
561
+ | `blocks.4.moe.V1` | (128, 1280, 64) |
562
+ | `blocks.4.moe.V2` | (128, 2048, 64) |
563
+ | `blocks.4.moe.b1` | (128, 2048) |
564
+ | `blocks.4.moe.b2` | (128, 1280) |
565
+ | `blocks.4.moe.expert_phases` | (128,) |
566
+ | `blocks.4.moe.scale1` | (128, 1, 1) |
567
+ | `blocks.4.moe.scale2` | (128, 1, 1) |
568
+ | `blocks.4.norm_attn.bias` | (1280,) |
569
+ | `blocks.4.norm_attn.weight` | (1280,) |
570
+ | `blocks.4.norm_kur.bias` | (1280,) |
571
+ | `blocks.4.norm_kur.weight` | (1280,) |
572
+ | `blocks.4.norm_moe.bias` | (1280,) |
573
+ | `blocks.4.norm_moe.weight` | (1280,) |
574
+ | `blocks.5.attn.b_out` | (1280,) |
575
+ | `blocks.5.attn.b_qkv` | (3, 1280) |
576
+ | `blocks.5.attn.level_logits` | (2,) |
577
+ | `blocks.5.attn.level_offsets` | (2,) |
578
+ | `blocks.5.attn.w_out` | (1280, 1280) |
579
+ | `blocks.5.attn.w_qkv` | (3, 1280, 1280) |
580
+ | `blocks.5.attn_S` | (2, 1280, 1280) |
581
+ | `blocks.5.attn_z` | (2, 1280) |
582
+ | `blocks.5.kuramoto.coupling_lambda` | (8,) |
583
+ | `blocks.5.kuramoto.coupling_u` | (16, 8) |
584
+ | `blocks.5.kuramoto.omega` | (16,) |
585
+ | `blocks.5.kuramoto_phases` | (2, 128, 16) |
586
+ | `blocks.5.moe.U1` | (128, 2048, 64) |
587
+ | `blocks.5.moe.U2` | (128, 1280, 64) |
588
+ | `blocks.5.moe.V1` | (128, 1280, 64) |
589
+ | `blocks.5.moe.V2` | (128, 2048, 64) |
590
+ | `blocks.5.moe.b1` | (128, 2048) |
591
+ | `blocks.5.moe.b2` | (128, 1280) |
592
+ | `blocks.5.moe.expert_phases` | (128,) |
593
+ | `blocks.5.moe.scale1` | (128, 1, 1) |
594
+ | `blocks.5.moe.scale2` | (128, 1, 1) |
595
+ | `blocks.5.norm_attn.bias` | (1280,) |
596
+ | `blocks.5.norm_attn.weight` | (1280,) |
597
+ | `blocks.5.norm_kur.bias` | (1280,) |
598
+ | `blocks.5.norm_kur.weight` | (1280,) |
599
+ | `blocks.5.norm_moe.bias` | (1280,) |
600
+ | `blocks.5.norm_moe.weight` | (1280,) |
601
+ | `blocks.6.attn.b_out` | (1280,) |
602
+ | `blocks.6.attn.b_qkv` | (3, 1280) |
603
+ | `blocks.6.attn.level_logits` | (2,) |
604
+ | `blocks.6.attn.level_offsets` | (2,) |
605
+ | `blocks.6.attn.w_out` | (1280, 1280) |
606
+ | `blocks.6.attn.w_qkv` | (3, 1280, 1280) |
607
+ | `blocks.6.attn_S` | (2, 1280, 1280) |
608
+ | `blocks.6.attn_z` | (2, 1280) |
609
+ | `blocks.6.kuramoto.coupling_lambda` | (8,) |
610
+ | `blocks.6.kuramoto.coupling_u` | (16, 8) |
611
+ | `blocks.6.kuramoto.omega` | (16,) |
612
+ | `blocks.6.kuramoto_phases` | (2, 128, 16) |
613
+ | `blocks.6.moe.U1` | (128, 2048, 64) |
614
+ | `blocks.6.moe.U2` | (128, 1280, 64) |
615
+ | `blocks.6.moe.V1` | (128, 1280, 64) |
616
+ | `blocks.6.moe.V2` | (128, 2048, 64) |
617
+ | `blocks.6.moe.b1` | (128, 2048) |
618
+ | `blocks.6.moe.b2` | (128, 1280) |
619
+ | `blocks.6.moe.expert_phases` | (128,) |
620
+ | `blocks.6.moe.scale1` | (128, 1, 1) |
621
+ | `blocks.6.moe.scale2` | (128, 1, 1) |
622
+ | `blocks.6.norm_attn.bias` | (1280,) |
623
+ | `blocks.6.norm_attn.weight` | (1280,) |
624
+ | `blocks.6.norm_kur.bias` | (1280,) |
625
+ | `blocks.6.norm_kur.weight` | (1280,) |
626
+ | `blocks.6.norm_moe.bias` | (1280,) |
627
+ | `blocks.6.norm_moe.weight` | (1280,) |
628
+ | `blocks.7.attn.b_out` | (1280,) |
629
+ | `blocks.7.attn.b_qkv` | (3, 1280) |
630
+ | `blocks.7.attn.level_logits` | (2,) |
631
+ | `blocks.7.attn.level_offsets` | (2,) |
632
+ | `blocks.7.attn.w_out` | (1280, 1280) |
633
+ | `blocks.7.attn.w_qkv` | (3, 1280, 1280) |
634
+ | `blocks.7.attn_S` | (2, 1280, 1280) |
635
+ | `blocks.7.attn_z` | (2, 1280) |
636
+ | `blocks.7.kuramoto.coupling_lambda` | (8,) |
637
+ | `blocks.7.kuramoto.coupling_u` | (16, 8) |
638
+ | `blocks.7.kuramoto.omega` | (16,) |
639
+ | `blocks.7.kuramoto_phases` | (2, 128, 16) |
640
+ | `blocks.7.moe.U1` | (128, 2048, 64) |
641
+ | `blocks.7.moe.U2` | (128, 1280, 64) |
642
+ | `blocks.7.moe.V1` | (128, 1280, 64) |
643
+ | `blocks.7.moe.V2` | (128, 2048, 64) |
644
+ | `blocks.7.moe.b1` | (128, 2048) |
645
+ | `blocks.7.moe.b2` | (128, 1280) |
646
+ | `blocks.7.moe.expert_phases` | (128,) |
647
+ | `blocks.7.moe.scale1` | (128, 1, 1) |
648
+ | `blocks.7.moe.scale2` | (128, 1, 1) |
649
+ | `blocks.7.norm_attn.bias` | (1280,) |
650
+ | `blocks.7.norm_attn.weight` | (1280,) |
651
+ | `blocks.7.norm_kur.bias` | (1280,) |
652
+ | `blocks.7.norm_kur.weight` | (1280,) |
653
+ | `blocks.7.norm_moe.bias` | (1280,) |
654
+ | `blocks.7.norm_moe.weight` | (1280,) |
655
+ | `blocks.8.attn.b_out` | (1280,) |
656
+ | `blocks.8.attn.b_qkv` | (3, 1280) |
657
+ | `blocks.8.attn.level_logits` | (2,) |
658
+ | `blocks.8.attn.level_offsets` | (2,) |
659
+ | `blocks.8.attn.w_out` | (1280, 1280) |
660
+ | `blocks.8.attn.w_qkv` | (3, 1280, 1280) |
661
+ | `blocks.8.attn_S` | (2, 1280, 1280) |
662
+ | `blocks.8.attn_z` | (2, 1280) |
663
+ | `blocks.8.kuramoto.coupling_lambda` | (8,) |
664
+ | `blocks.8.kuramoto.coupling_u` | (16, 8) |
665
+ | `blocks.8.kuramoto.omega` | (16,) |
666
+ | `blocks.8.kuramoto_phases` | (2, 128, 16) |
667
+ | `blocks.8.moe.U1` | (128, 2048, 64) |
668
+ | `blocks.8.moe.U2` | (128, 1280, 64) |
669
+ | `blocks.8.moe.V1` | (128, 1280, 64) |
670
+ | `blocks.8.moe.V2` | (128, 2048, 64) |
671
+ | `blocks.8.moe.b1` | (128, 2048) |
672
+ | `blocks.8.moe.b2` | (128, 1280) |
673
+ | `blocks.8.moe.expert_phases` | (128,) |
674
+ | `blocks.8.moe.scale1` | (128, 1, 1) |
675
+ | `blocks.8.moe.scale2` | (128, 1, 1) |
676
+ | `blocks.8.norm_attn.bias` | (1280,) |
677
+ | `blocks.8.norm_attn.weight` | (1280,) |
678
+ | `blocks.8.norm_kur.bias` | (1280,) |
679
+ | `blocks.8.norm_kur.weight` | (1280,) |
680
+ | `blocks.8.norm_moe.bias` | (1280,) |
681
+ | `blocks.8.norm_moe.weight` | (1280,) |
682
+ | `blocks.9.attn.b_out` | (1280,) |
683
+ | `blocks.9.attn.b_qkv` | (3, 1280) |
684
+ | `blocks.9.attn.level_logits` | (2,) |
685
+ | `blocks.9.attn.level_offsets` | (2,) |
686
+ | `blocks.9.attn.w_out` | (1280, 1280) |
687
+ | `blocks.9.attn.w_qkv` | (3, 1280, 1280) |
688
+ | `blocks.9.attn_S` | (2, 1280, 1280) |
689
+ | `blocks.9.attn_z` | (2, 1280) |
690
+ | `blocks.9.kuramoto.coupling_lambda` | (8,) |
691
+ | `blocks.9.kuramoto.coupling_u` | (16, 8) |
692
+ | `blocks.9.kuramoto.omega` | (16,) |
693
+ | `blocks.9.kuramoto_phases` | (2, 128, 16) |
694
+ | `blocks.9.moe.U1` | (128, 2048, 64) |
695
+ | `blocks.9.moe.U2` | (128, 1280, 64) |
696
+ | `blocks.9.moe.V1` | (128, 1280, 64) |
697
+ | `blocks.9.moe.V2` | (128, 2048, 64) |
698
+ | `blocks.9.moe.b1` | (128, 2048) |
699
+ | `blocks.9.moe.b2` | (128, 1280) |
700
+ | `blocks.9.moe.expert_phases` | (128,) |
701
+ | `blocks.9.moe.scale1` | (128, 1, 1) |
702
+ | `blocks.9.moe.scale2` | (128, 1, 1) |
703
+ | `blocks.9.norm_attn.bias` | (1280,) |
704
+ | `blocks.9.norm_attn.weight` | (1280,) |
705
+ | `blocks.9.norm_kur.bias` | (1280,) |
706
+ | `blocks.9.norm_kur.weight` | (1280,) |
707
+ | `blocks.9.norm_moe.bias` | (1280,) |
708
+ | `blocks.9.norm_moe.weight` | (1280,) |
709
+ | `confidence_head.bias` | (1,) |
710
+ | `confidence_head.weight` | (1, 1280) |
711
+ | `last_lb_loss` | () |
712
+ | `observe.weight` | (50257, 1280) |
713
+ | `output_head.weight` | (50257, 1280) |
714
+ | `salience_head.bias` | (1,) |
715
+ | `salience_head.weight` | (1, 1280) |
716
+ | `thought_state` | (2, 1, 1280) |@
717
+
718
+ Décompte de contrôle : 8 + 16×27 = 440. Suffixes par bloc (27) :
719
+ `attn_S`, `attn_z`, `kuramoto_phases`, `attn.{w_qkv, b_qkv, w_out, b_out,
720
+ level_logits, level_offsets}`, `norm_attn.{weight, bias}`,
721
+ `kuramoto.{omega, coupling_u, coupling_lambda}`, `norm_kur.{weight, bias}`,
722
+ `moe.{U1, V1, U2, V2, scale1, scale2, b1, b2, expert_phases}`,
723
+ `norm_moe.{weight, bias}`.
724
+
725
+ ## Divergences vs FractalBlock (vorax/model/fractus_core.py)
726
+
727
+ | Aspect | FractalBlock 88M (fractus_core.py) | CTEBlock 1B (ce doc) |
728
+ |---|---|---|
729
+ | Layout MoE | ModuleLists per-expert `experts_w1.{e}.{U,V,scale,bias}`, scale scalaire () (fractus_core.py:416-423) | tenseurs batchés `moe.{U1,V1,U2,V2}` (E, out, r), `scale{1,2}` (E,1,1), `b{1,2}` (E, dim) (moe.py:100-107) |
730
+ | GELU experts | `F.gelu` exact (fractus_core.py:481) | **approximation tanh** (moe.py:27-31, 233) |
731
+ | Gate von Mises | κ=4.0 direct (fractus_core.py:433) | **κ_eff = 4.0/temperature avec temperature=2.5** (moe.py:117 + trainers) |
732
+ | Embedding | tok_embed + pos_embed + norm (fractus_core.py:500-514) | `observe` token seul, rien d'autre (continuous_engine.py:262) |
733
+ | Norm finale | LayerNorm avant lm_head (fractus_core.py:572) | **aucune** (continuous_engine.py:474) |
734
+ | Noms des norms de bloc | `norm1`/`norm_kur`/`norm_moe` | `norm_attn`/`norm_kur`/`norm_moe` (continuous_engine.py:49-65) |
735
+ | Phases pour routage | par position (fractus_core.py:552) | **phases de la dernière position broadcastées** sur le chunk (continuous_engine.py:212-213) |
736
+ | États | stateless, fenêtre glissante re-encodée (fractus_core.py:44-45, 609-611) | **carry (S, z) + thought_state persistants** (continuous_engine.py:167-193, 465-473) |
737
+ | Têtes | lm_head seule (liée) | output_head liée + confidence_head + salience_head + last_lb_loss (continuous_engine.py:276-282) |
738
+ | Attention multi-niveaux | oui, softmax(level_logits) (fractus_core.py:308-309) | oui dans le chemin chunk (continuous_engine.py:196-197) ; NON dans tick_single (mono-niveau, :93-94) — chemin interdit |
739
+ | Orientation x@W | oui (fractus_core.py:288-290, 311) | **oui, identique** (continuous_engine.py:150-152, 198) |
740
+ | Tête de sortie liée | oui, nn.Linear h@Wᵀ (fractus_core.py:573-575) | **oui, identique** (continuous_engine.py:280-281, sondage bit-égal) |
741
+
742
+ Communs confirmés : elu_plus_one (stats.py:18-31 ↔ fractus_core.py:73-77),
743
+ offsets (φ²)^(−level) (attention.py:21-31 ↔ fractus_core.py:123-129, valeurs
744
+ sondées identiques), Kuramoto RK4 dt=0.1/damping=0.01/n_steps=1
745
+ (phase_ode.py ↔ fractus_core.py:318-379), récurrence causale inclusive avec
746
+ garde 1e-10 (attention.py:94-127 ↔ fractus_core.py:267-279), phases Farey
747
+ (farey.py:40-55 ↔ fractus_core.py:89-120, écart sondé 6.4e-07), top-k
748
+ renormalisé (moe.py:304-309 ↔ fractus_core.py:445-450), lb loss
749
+ (moe.py:323-324 ↔ fractus_core.py:491-492).
750
+
751
+ ## Décisions tranchées pour T2 (synthèse)
752
+
753
+ 1. **GELU : OUI, approximation tanh** entre les facteurs U1/V1 et U2/V2
754
+ (moe.py:233 avec :27-31). Diffère du 88M (F.gelu).
755
+ 2. **Orientation x@W** pour qkv/w_out du MoE low-rang (h@V puis @Uᵀ) ;
756
+ nn.Linear standard (h@Wᵀ) UNIQUEMENT pour output_head/confidence/salience.
757
+ 3. **Tête LIÉE** : output_head.weight = observe.weight, bit-identiques ; les
758
+ deux clés doivent exister dans le state_dict.
759
+ 4. **Chemain forward : tick_chunk_core exclusivement** ; génération = chunks
760
+ de longueur 1 ; tick_single interdit (mono-niveau).
761
+ 5. **moe.temperature = 2.5** (κ_eff=1.6) à poser au chargement — pas dans le
762
+ checkpoint.
763
+ 6. **États porteurs** : thought_state (B,1,d) ajouté à la position 0 du
764
+ chunk, maj = dernier h ; attn_S/attn_z bloc-diagonaux par tête, carry
765
+ inclusif, nouvel état = **moyenne sur les niveaux** ; kuramoto_phases
766
+ write-only (shape (batch, 128, n_osc) pour le strict load, valeur sans
767
+ effet) ; reset complet avant chaque séquence.
768
+ 7. **Buffers de construction en batch=2** (shapes du checkpoint) pour
769
+ `from_checkpoint` strict=True, re-dimensionnables par reset_states().
770
+ 8. **boost/ss_rate/lr/tokens_processed/frozen_merge/n** : métadonnées
771
+ training/fusion, aucun effet forward.
docs/heritage/fractus-cte-architecture.md ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Architecture Fractus / Fractus-CTE — spec extraite du code et du checkpoint réel
2
+
3
+ > Document source pour la Task 2 (noyau natif fractus dans `vorax/model/`).
4
+ > Tout ce qui suit est **mesuré ou lu** : lecture seule de `C:\Users\PHIL\ZCodeProject\fractus`
5
+ > et `C:\Users\PHIL\ZCodeProject\fractus-cte` (règle D5 : jamais écrits), plus un sondage
6
+ > réel du checkpoint via `torch.load`. Aucune valeur n'est inventée.
7
+
8
+ ## 1. Environnement torch (vorax)
9
+
10
+ | Composant | Valeur |
11
+ |---|---|
12
+ | venv | `.venv-torch/` (natif Windows, `py -3.11 -m venv`, **non committé** — gitignoré) |
13
+ | Python | 3.11.9 (MSC v.1938 64 bit, AMD64) |
14
+ | torch | **2.13.0+cpu** (wheel officielle CPU `https://download.pytorch.org/whl/cpu`) |
15
+ | numpy | 2.4.6 |
16
+ | pytest | 9.1.1 |
17
+ | venv mingw | `.venv` (Python 3.13, numpy+pytest, **sans torch** — PEP 668, pas de wheel mingw) |
18
+
19
+ Gate structurel : `tests/test_gates.py::test_torch_isolated_in_model_dir` interdit
20
+ `import torch` / `from torch` partout dans `vorax/` sauf dans `vorax/model/`.
21
+ Vérifié PASS dans les deux venvs.
22
+
23
+ ## 2. Checkpoint sondé (source de vérité)
24
+
25
+ - Fichier : `C:\Users\PHIL\ZCodeProject\fractus\checkpoints\checkpoints\fractus_1b_latest.pt`
26
+ (1 059 107 682 octets ≈ 1,06 Go, lecture seule).
27
+ - Chargé avec : `torch.load(path, map_location="cpu", weights_only=False)` — réussi,
28
+ ~1 min CPU, aucun OOM. Script jetable : `vorax/.superpowers/scratch/probe_ckpt.py` (gitignoré).
29
+
30
+ Clés top-level du fichier `.pt` :
31
+
32
+ | Clé | Valeur |
33
+ |---|---|
34
+ | `model_state` | state_dict, **4231 entrées** (tableau §5) |
35
+ | `optimizer_state` | AdamW : `state` (4214 entrées `step`/`exp_avg`/`exp_avg_sq`), `param_groups` = 1 groupe, lr=1e-4, betas=(0.9, 0.999), weight_decay=0.01 — **ignorable pour l'inférence** |
36
+ | `config` | `{'seq_len': 16, 'batch_size': 256, 'lr': 0.0001, 'corpus': 'data/fractus_corpus.pt'}` |
37
+ | `step` | 140000 |
38
+ | `epoch` | 0 |
39
+ | `loss` | 2.910271406173706 |
40
+ | `timestamp` | `2026-07-20 23:04:47` |
41
+
42
+ Le checkpoint a été produit par `fractus/scripts/train_1b_cloud.py`
43
+ (`save_step_checkpoint`, uploadé sous le nom `checkpoints/fractus_1b_latest.pt`),
44
+ qui construit explicitement :
45
+
46
+ ```python
47
+ Fractus1B(vocab_size=50257, d_model=768, n_layers=8, n_heads=12, d_head=64,
48
+ n_levels=2, n_experts=64, top_k=2, expert_d_ff=1024, siren_rank=16,
49
+ max_seq_len=args.seq_len)
50
+ ```
51
+
52
+ ## 3. Config effective du modèle (croisement shapes réelles × code)
53
+
54
+ | Paramètre | Valeur | Preuve |
55
+ |---|---|---|
56
+ | `vocab_size` | 50257 | `embed.tok_embed.weight (50257, 768)` |
57
+ | `d_model` | 768 | toutes les shapes |
58
+ | `n_layers` | 8 | `blocks.0.` … `blocks.7.` |
59
+ | `n_heads × d_head` | 12 × 64 | contrainte code `n_heads·d_head == d_model` ; 12·64=768 |
60
+ | `n_levels` | 2 | `level_logits (2,)`, `level_offsets (2,)` |
61
+ | `n_experts` | 64 | `expert_phases (64,)`, experts 0..63 |
62
+ | `top_k` | 2 | défaut code (`top_k: int = 2`, jamais surchargé) |
63
+ | `expert_d_ff` | 1024 | `experts_w1.e.U (1024, 16)` |
64
+ | `siren_rank` | 16 | dernière dim de tous les U/V |
65
+ | `max_seq_len` | 16 | `embed.pos_embed.weight (16, 768)` + `config['seq_len']=16` |
66
+ | Kuramoto `n_oscillators` | 16 | `omega (16,)` |
67
+ | Kuramoto `coupling_rank` | 8 | `coupling_lambda (8,)` |
68
+ | Kuramoto `n_steps`, `dt`, `damping` | 1, 0.1, 0.01 | code `FractalBlockSparse.__init__` / `KuramotoLayer` |
69
+ | MoE `kappa` (von Mises) | 4.0 | code `SparseStructuredMoE.__init__` |
70
+ | Optimizer | AdamW lr 1e-4, wd 0.01, clip grad 1.0 | `optimizer_state` + trainer |
71
+
72
+ **Comptage de paramètres mesuré** : 87 828 432 paramètres uniques (tête liée dédupliquée)
73
+ + 528 éléments de buffers (`level_offsets` 2×8, `expert_phases` 64×8) ; le total brut du
74
+ state_dict est 126 426 336 car `lm_head.weight` est stockée en double (liée).
75
+ C'est le modèle dit « 88M » — pas le 1B « config K » (d=1280/L=16/E=128) qui est le
76
+ défaut de `fractus1B/model_1b.py` mais un autre palier.
77
+ Capacité dense équivalente (formule `n_effective_capacity`) : ≈ 0,86B.
78
+
79
+ ## 4. Principes architecturaux (tels qu'implémentés)
80
+
81
+ ### 4.1 LazyStructuredSirenLinear (`fractus1B/nn/lazy_siren.py`)
82
+ Stockage des poids des experts : `W = scale · U · Vᵀ` avec `U (out, r)`, `V (in, r)`,
83
+ `scale` scalaire apprenable (style LoRA), `b` bias.
84
+ Forward : `y = scale · (x @ V) @ Uᵀ + b` — deux petites matmuls, **aucune matrice
85
+ pleine jamais matérialisée**, mémoire O((out+in)·r) par expert.
86
+ Le « SIREN » historique (`StructuredSirenLinear` : `W ≈ U@Vᵀ + SIREN(Résidu)`,
87
+ sinusoïdes ω0=30, init Sitzmann) est **abandonné dans la variante Lazy** : le résidu
88
+ SIREN est remplacé par le seul facteur bas-rang (compromis RAM/vitesse pour l'entraînement CPU/GPU).
89
+
90
+ ### 4.2 MoE creux routé par phases (`SparseStructuredSirenMoE` dans `model_1b.py`)
91
+ - Chaque bloc possède E=64 experts × 2 matrices (w1 : d→d_ff, w2 : d_ff→d), chacune en
92
+ LazySiren rang 16.
93
+ - **ACTIVATION (critique pour la réimplémentation)** : le forward de l'expert est
94
+ `h1 = w1(x)` → **`h1_act = GELU(h1)`** (`torch.nn.functional.gelu`,
95
+ `model_1b.py` L149) → `y = w2(h1_act)`. Sans ce GELU entre w1 et w2, le
96
+ checkpoint charge sans erreur mais produit des logits faux (échec silencieux).
97
+ - **Phases de Farey** (`nn/farey.py`) : on construit la suite de Farey F_{2E} (fractions
98
+ irréductibles p/q ∈ [0,1], q ≤ 2E, générées par la médiane), on convertit en angles
99
+ 2π·p/q et on sélectionne E angles uniformément. Vérifié sur le checkpoint :
100
+ `max |expert_phases_ckpt − farey.expert_phases(64)| = 2,2e-07` (arrondi float32).
101
+ - **Grille von Mises** : à partir des phases de Kuramoto θ (B, L, N),
102
+ `θ̄ = atan2(Σsin θ, Σcos θ)` puis `gate_e = exp(κ·cos(θ̄ − phase_e))` normalisé (κ=4.0).
103
+ - **Top-2 creux** : on sélectionne les 2 experts aux gates max, on renormalise les poids
104
+ top-k, somme pondérée des sorties. Dispatch vectorisé « gather-first » : on empile les
105
+ facteurs U/V/scale/bias des experts sélectionnés puis un bmm groupé — seul 2/64 des
106
+ experts est calculé, et ce sans jamais matérialiser W.
107
+ - **Perte d'équilibrage** : `lb = E · Σ_e (P_e − 1/E)²` avec P = moyenne des gates.
108
+ Dans le trainer, `aux_clamped = clamp(aux, max=1.0)` et `loss = CE + 0.001·aux`.
109
+
110
+ ### 4.3 Attention linéaire fractale (`nn/attention.py`)
111
+ Forme causale normalisée (Katharopoulos 2020) :
112
+ - Feature map `φ(x ; level) = elu_plus_one(x + ω_level)` avec `ω_level = (φ²)^(−level)`,
113
+ `φ² = φ_or² ≈ 2.618` (φ_or = nombre d'or). Pour n_levels=2 : offsets `[1.0, 0.3819660…]`
114
+ — vérifiés égaux dans le checkpoint.
115
+ `elu_plus_one(x) = x+1 si x>0 sinon (e^x −1)+1` (strictement positif → dénominateur sain).
116
+ - Récurrence causale INCLUSIVE : `S_t = Σ_{i≤t} k_i⊗v_i`, `z_t = Σ_{i≤t} k_i`,
117
+ `y_t = (q_t·S_t)/(q_t·z_t)` (0 si |dénom| < 1e-10). Version vectorisée par masque
118
+ triangulaire + einsum, avec option **state-carry** `(S₀, z₀)` pour continuer l'attention
119
+ au-delà d'une frontière de chunk (utilisée par la CTE).
120
+ - Multi-niveaux : sortie = Σ_level softmax(level_logits)_level · attn_level(x).
121
+ - Projections : un seul tenseur `w_qkv (3, d, d)` + `b_qkv (3, d)` ; sortie `w_out (d, d)` + `b_out`.
122
+ **Orientation (critique)** : les projections sont en convention `y = x @ W` (einsum
123
+ `"bld,de->ble"`, cf. `attention.py` L204-206, L237) — PAS la convention nn.Linear
124
+ `x @ Wᵀ`. Les deux étant carrées (768×768), une réimplémentation transposée chargerait
125
+ sans erreur de shape mais produirait des sorties fausses. Q/K/V dans l'ordre d'indexation
126
+ de `w_qkv` ; split des têtes en `view(B, L, 12, 64)` contigu.
127
+ - Contrainte : `n_heads·d_head == d_model` (12·64 = 768). Complexité O(L·d_head²) par tête/niveau.
128
+
129
+ ### 4.4 Oscillateurs de Kuramoto (`nn/phase_ode.py`)
130
+ - État **stateless** entre forwards : phases initiales dérivées du hidden —
131
+ `θ_init = 2π·mean(hidden) + 2π·i/N` (mod 2π).
132
+ - Dynamique : `dθ_i/dt = ω_i − damping·θ_i + Σ_j K_ij sin(θ_j − θ_i)` avec couplage
133
+ bas-rang `K = UΛUᵀ` (`coupling_u (N, r)`, `coupling_lambda (r,)`) évalué en O(N·r).
134
+ - Intégration **RK4** (4 évals), `n_steps=1`, `dt=0.1`, `damping=0.01`, wrap mod 2π à chaque pas.
135
+ - Sortie : phases (B, L, 16) qui alimentent la grille von Mises du MoE.
136
+ `omega` init U(−0.05, 0.05) ; mesuré ckpt : ω ∈ [−0.0586, 0.0706].
137
+
138
+ ### 4.5 Bloc et modèle (arbre des modules, `fractus1B/model_1b.py`)
139
+
140
+ ```
141
+ Fractus1B # ids (B, L) → (logits (B, L, V), aux_loss scalaire)
142
+ ├── embed : BPEEmbedding
143
+ │ ├── tok_embed Embedding(50257, 768)
144
+ │ ├── pos_embed Embedding(16, 768)
145
+ │ └── norm LayerNorm(768)
146
+ ├── blocks : ModuleList[8] × FractalBlockSparse
147
+ │ ├── norm1 LayerNorm(768)
148
+ │ ├── attn FractalLinearAttention(768, n_heads=12, d_head=64, n_levels=2)
149
+ │ ├── norm_kur LayerNorm(768)
150
+ │ ├── kuramoto KuramotoLayer(768, n_oscillators=16, rank=8, n_steps=1, dt=0.1)
151
+ │ ├── norm_moe LayerNorm(768)
152
+ │ └── moe SparseStructuredMoE(768, n_experts=64, top_k=2, d_ff=1024, rank=16)
153
+ │ ├── expert_phases (buffer, 64) # Farey
154
+ │ ├── experts_w1 : ModuleList[64] × LazyStructuredSirenLinear(768→1024, r=16)
155
+ │ └── experts_w2 : ModuleList[64] × LazyStructuredSirenLinear(1024→768, r=16)
156
+ ├── norm : LayerNorm(768)
157
+ └── lm_head : Linear(768, 50257, bias=False) # poids LIÉS à embed.tok_embed.weight
158
+ ```
159
+
160
+ Forward d'un bloc (pré-norm, résiduel) :
161
+ ```
162
+ x = x + attn(norm1(x))
163
+ phases = kuramoto(norm_kur(x))
164
+ moe_out, lb = moe(norm_moe(x), phases)
165
+ x = x + moe_out # retourne (x, lb)
166
+ ```
167
+ Forward modèle : `embed → 8 blocs (aux = Σ lb) → norm → lm_head`.
168
+ Pas de gradient checkpointing dans cette version (commentaire L9 du code).
169
+
170
+ ## 5. Table complète clés → shapes du state_dict RÉEL (4231 clés)
171
+
172
+ Les 8 blocs sont **structurellement identiques** (vérifié : même ensemble de clés par
173
+ bloc). Le tableau ci-dessous est donc la forme canonique exacte : top-level (7 clés) +
174
+ motif par bloc (528 clés = 16 hors experts + 2×64×4 experts). Total :
175
+ 8 × 528 + 7 = **4231** = compté dans le checkpoint. Toutes les valeurs sont en
176
+ `torch.float32`.
177
+
178
+ **Top-level (7)**
179
+
180
+ | Clé | Shape | Rôle |
181
+ |---|---|---|
182
+ | `embed.tok_embed.weight` | (50257, 768) | embedding BPE |
183
+ | `embed.pos_embed.weight` | (16, 768) | positions (max_seq_len=16) |
184
+ | `embed.norm.weight` | (768,) | LayerNorm embedding |
185
+ | `embed.norm.bias` | (768,) | idem |
186
+ | `norm.weight` | (768,) | LayerNorm final |
187
+ | `norm.bias` | (768,) | idem |
188
+ | `lm_head.weight` | (50257, 768) | **tied** : `torch.equal(lm_head, tok_embed) == True` |
189
+
190
+ **Par bloc `blocks.i.`, i ∈ 0..7 (528 clés chacun) — 16 clés hors experts**
191
+
192
+ | Clé | Shape | Param/buffer |
193
+ |---|---|---|
194
+ | `blocks.i.norm1.weight` | (768,) | param |
195
+ | `blocks.i.norm1.bias` | (768,) | param |
196
+ | `blocks.i.attn.w_qkv` | (3, 768, 768) | param (Q,K,V concaténés) |
197
+ | `blocks.i.attn.b_qkv` | (3, 768) | param |
198
+ | `blocks.i.attn.w_out` | (768, 768) | param |
199
+ | `blocks.i.attn.b_out` | (768,) | param |
200
+ | `blocks.i.attn.level_logits` | (2,) | param (poids des niveaux) |
201
+ | `blocks.i.attn.level_offsets` | (2,) | **buffer** persistant = [1.0, 0.3819660] |
202
+ | `blocks.i.norm_kur.weight` | (768,) | param |
203
+ | `blocks.i.norm_kur.bias` | (768,) | param |
204
+ | `blocks.i.kuramoto.omega` | (16,) | param |
205
+ | `blocks.i.kuramoto.coupling_u` | (16, 8) | param |
206
+ | `blocks.i.kuramoto.coupling_lambda` | (8,) | param |
207
+ | `blocks.i.norm_moe.weight` | (768,) | param |
208
+ | `blocks.i.norm_moe.bias` | (768,) | param |
209
+ | `blocks.i.moe.expert_phases` | (64,) | **buffer** persistant (Farey, cf. §4.2) |
210
+
211
+ **Experts (512 clés par bloc)** — pour chaque expert `e ∈ 0..63` :
212
+
213
+ | Clé | Shape | Signification |
214
+ |---|---|---|
215
+ | `blocks.i.moe.experts_w1.e.U` | (1024, 16) | U de w1 (d_ff×r) — W1 = scale·U·Vᵀ |
216
+ | `blocks.i.moe.experts_w1.e.V` | (768, 16) | V de w1 (d×r) |
217
+ | `blocks.i.moe.experts_w1.e.scale` | () | scalaire apprenable |
218
+ | `blocks.i.moe.experts_w1.e.bias` | (1024,) | bias w1 |
219
+ | `blocks.i.moe.experts_w2.e.U` | (768, 16) | U de w2 (d×r) |
220
+ | `blocks.i.moe.experts_w2.e.V` | (1024, 16) | V de w2 (d_ff×r) |
221
+ | `blocks.i.moe.experts_w2.e.scale` | () | scalaire apprenable |
222
+ | `blocks.i.moe.experts_w2.e.bias` | (768,) | bias w2 |
223
+
224
+ Arithmétique des clés : 16 + 64·4 + 64·4 = 528/bloc ; 8·528 + 7 = 4231. ✓
225
+
226
+ ## 6. Tokenizer (`fractus1B/tokenizer.py`, identique dans `fractus` et `fractus-cte`)
227
+
228
+ - **Byte-level BPE, compatible GPT-2**, via la lib HF `tokenizers` (`tokenizers.Tokenizer`,
229
+ modèle `BPE`, pre-tokenizer `ByteLevel`, decoder `ByteLevel`).
230
+ - `FractusTokenizer.gpt2_compatible()` : `Tokenizer.from_pretrained("gpt2")` —
231
+ **vocab = 50257** (`DEFAULT_VOCAB_SIZE = 50257` ; 50256 merges BPE + 1 `<|endoftext|>`
232
+ d'id 50256, utilisé comme token d'arrêt dans la génération du trainer).
233
+ - `build_or_load(path)` : charge `data/tokenizer/fractus_bpe.json` s'il existe, sinon GPT-2.
234
+ - API : `encode(text) -> List[int]` (ids), `encode_batch`, `decode(ids) -> str`, `save(path)`.
235
+ - Encodage/décodage entièrement délégués à la lib `tokenizers` — aucun vocab maison embarqué
236
+ dans le checkpoint ; pour vorax, un tokenizer GPT-2 (HF `tokenizers` ou équivalent BPE
237
+ byte-level 50257) reproduit exactement les ids.
238
+
239
+ ## 7. La CTE (fractus-cte) — architecture cible
240
+
241
+ `fractus-cte/fractus/continuous_engine.py` définit le **ContinuousThoughtEngine**, variante
242
+ « pensée continue » de la même architecture. C'est la cible de `scripts/train_1b_gpu.py`
243
+ (config `TARGET_1B` : d=1280, n_layers=16, n_heads=20, d_head=64, n_levels=2,
244
+ n_oscillators=16, coupling_rank=8, n_experts=128, top_k=2, expert_d_ff=2048,
245
+ siren_rank=64 — 1 048 631 458 params d'après le commentaire du script).
246
+
247
+ ```
248
+ ContinuousThoughtEngine(vocab=50257, d_model, n_layers, n_heads=4, d_head=64,
249
+ n_levels=2, n_oscillators=16, coupling_rank=8,
250
+ n_experts=8, top_k=2, expert_d_ff=256, siren_rank=32)
251
+ ├── observe : Embedding(vocab, d_model)
252
+ ├── blocks : ModuleList[n_layers] × CTEBlock
253
+ │ ├── attn FractalLinearAttention (mêmes params que §4.3)
254
+ │ ├── norm_attn / norm_kur / norm_moe : LayerNorm
255
+ │ ├── kuramoto KuramotoLayer (n_steps=1, dt=0.1)
256
+ │ ├── moe PhaseRoutedMoE (kappa=4.0)
257
+ │ └── buffers d'état persistants : attn_S (1, nH·dH, nH·dH), attn_z (1, nH·dH),
258
+ │ kuramoto_phases (1, 1, N)
259
+ ├── last_lb_loss (buffer scalaire)
260
+ ├── confidence_head : Linear(d_model, 1) # sigmoid → confiance
261
+ ├── output_head : Linear(d_model, vocab, bias=False) # LIÉE à observe.weight
262
+ ├── salience_head : Linear(d_model, 1) # gate mémoire (salience sigmoid)
263
+ └── thought_state (buffer 1, 1, d_model) # flux résiduel porté de tick en tick
264
+ ```
265
+
266
+ Différences clés avec le modèle d'entraînement (source : `nn/moe.py` de fractus-cte) :
267
+
268
+ - **`PhaseRoutedMoE` empile les experts** dans des tenseurs uniques (pas de ModuleList) :
269
+ `U1 (E, d_ff, r)`, `V1 (E, d, r)`, `U2 (E, d, r)`, `V2 (E, d_ff, r)`,
270
+ `scale1/scale2 (E, 1, 1)`, `b1 (E, d_ff)`, `b2 (E, d)`, buffer `expert_phases (E,)`.
271
+ Même mathématique W = scale·U·Vᵀ, même grille von Mises/Farey, même lb_loss ;
272
+ dispatch adaptatif : dense si `E ≤ 2·top_k`, sinon gather-first creux.
273
+ - **Attention avec état porté** : `(S, z)` par bloc, initialisés à zéro, mis à jour à chaque
274
+ chunk (moyenne des niveaux, bloc-diagonalisés par tête, `.detach()` pour ne pas
275
+ backpropager à travers le temps).
276
+ - **Modes d'exécution** : `tick()` (1 token, boucle par tête), `tick_vec()` (obs vectorisé),
277
+ `tick_chunk()` / `tick_chunk_train()` (chunk C tokens, attention vectorisée avec carry,
278
+ CE dense sur toutes les positions pour la variante `_train`), `think()` (profondeur
279
+ adaptative selon confiance > seuil), `reset_thought()`, `maybe_grow()` (auto-ajout
280
+ d'expert zéro-initialisé près de l'expert dominant si déséquilibre > 0.8).
281
+ - **`from_pretrained(ckpt)`** : reconstruit depuis `ckpt['config']` (n_layers inféré en
282
+ comptant les préfixes `blocks.` à défaut), charge par correspondance de shapes (sous-ensemble).
283
+ - **`grow.py :: grow_cte`** : croissance par zero-padding — largeur (d_model), profondeur
284
+ (n_layers), experts, rang ; les anciens poids sont copiés en haut-gauche, le neuf est neutre.
285
+ - Boucle d'entraînement GPU (`train_1b_gpu.py`) : chunks `seq_len`, CE sur le dernier token
286
+ (`tick_chunk_train` retourne tous les logits), accumulation, bf16 autocast, clip 1.0.
287
+
288
+ **Mapping des clés modèle d'entraînement → CTE** (extrait de
289
+ `fractus/scripts/transfer_to_cte.py`, assemblage du checkpoint en CTE d=768/E=64) :
290
+
291
+ | Clé CTE (mono-bloc) | Clé modèle d'entraînement |
292
+ |---|---|
293
+ | `observe.weight` | `embed.tok_embed.weight` |
294
+ | `attn.w_qkv/b_qkv/w_out/b_out/level_logits` | `blocks.0.attn.*` |
295
+ | `norm_attn.*` | `blocks.0.norm1.*` |
296
+ | `norm_kur.*` / `norm_moe.*` | `blocks.0.norm_kur.*` / `blocks.0.norm_moe.*` |
297
+ | `kuramoto.omega/coupling_u/coupling_lambda` | `blocks.0.kuramoto.*` |
298
+ | `output_head.weight` | `embed.tok_embed.weight` (liée) |
299
+
300
+ (NB : ce script cible une vielle CTE à experts `experts_w1.{i}.*` en CachedSiren ; la CTE
301
+ actuelle de fractus-cte utilise les tenseurs empilés U1/V1/… — le mapping pertinent pour
302
+ Task 2 est la table §5 + les shapes §7.)
303
+
304
+ ## 8. Recette de chargement pour Task 2
305
+
306
+ ```python
307
+ import torch
308
+ ckpt = torch.load(r"C:\Users\PHIL\ZCodeProject\fractus\checkpoints\checkpoints\fractus_1b_latest.pt",
309
+ map_location="cpu", weights_only=False) # fichier local de confiance
310
+ sd = ckpt["model_state"] # 4231 clés, cf. §5
311
+ ```
312
+
313
+ - `optimizer_state` : ignorer pour l'inférence.
314
+ - `lm_head.weight` dupliquée et **égale bit à bit** à `embed.tok_embed.weight` → charger une seule fois et lier.
315
+ - Les buffers `level_offsets` et `expert_phases` sont recalculables
316
+ (offsets = φ²^(−level) ; phases = `farey.expert_phases(E)`) — vérifié égal au checkpoint à
317
+ 2,2e-07 près (float32).
318
+ - Règle D5 : le checkpoint et les repos fractus / fractus-cte sont en **lecture seule
319
+ stricte** (aucune écriture, y compris `__pycache__` ; scripts de sondage dans
320
+ `vorax/.superpowers/scratch/`, gitignoré).
docs/heritage/plans/2026-08-17-vorax-p1-substrat.md ADDED
@@ -0,0 +1,1611 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Vorax Plan 1 — « Le substrat qui mange » Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Livrer le cœur de Vorax : un compilateur déterministe qui convertit n'importe quel dataset courant en atomes de connaissance `.kn`, une mémoire de traces HV persistante qui les avale par écriture O(1), et un CLI `ingest`/`ask` qui démontre l'expertise instantanée sans un seul gradient.
6
+
7
+ **Architecture:** Encodage hypervector bipolaire déterministe (blake2b counter-mode) → atomes canoniques → TraceStore append-only avec retrieval vectorisé → Brain persistant (traces packées + atomes jsonl.gz) → CLI. Format `.kn` portable bit-identique.
8
+
9
+ **Tech Stack:** Python ≥ 3.10, numpy (seule dépendance runtime), pytest (dev). CPU pur, Windows/Git Bash friendly.
10
+
11
+ **Roadmap Vorax** (contexte, pas dans ce plan) :
12
+ - **Plan 1 (ce document)** : substrat — compiler `.kn`, traces, brain, CLI ingest/ask
13
+ - Plan 2 : généralisation (patterns/slots, relations, analogie 3CosMul k-NN), organe hebbien, spawn d'experts, consolidation, LSH
14
+ - Plan 3 : **noyau natif fractus** (architecture fractus réécrite dans vorax, checkpoint fractus-cte en lecture seule — aucun GGUF, aucun LLM externe), fabricant de cartes riches, agent live REPL, killer bench
15
+
16
+ **Spec source :** `docs/superpowers/specs/2026-08-17-vorax-design.md`
17
+
18
+ ## Global Constraints
19
+
20
+ - Python `>= 3.10` ; dépendances runtime : `numpy >= 1.24` + stdlib UNIQUEMENT (pas de torch, pas de llama.cpp dans le Plan 1)
21
+ - Déterminisme absolu des artefacts : jamais `hash()` python (salé), jamais de timestamp dans `.kn`/brain, clés JSON triées, gzip `mtime=0`
22
+ - Les repos `../fractus`, `../palimpseste`, `../ensemble`, `../prism` ne sont JAMAIS lus par le code ni modifiés (code substrat réécrit from scratch dans vorax)
23
+ - CPU-only ; D par défaut `8192`, seed par défaut `0` (constantes `D_DEFAULT`, `SEED_DEFAULT`)
24
+ - Tout code/identifiants en anglais, prose des docs en français (convention maison)
25
+ - Chaque tâche : TDD strict (test d'abord, échec vérifié, implémentation minimale, passage, commit)
26
+
27
+ ---
28
+
29
+ ### Task 1: Scaffolding du repo
30
+
31
+ **Files:**
32
+ - Create: `pyproject.toml`
33
+ - Create: `vorax/__init__.py`
34
+ - Create: `vorax/compiler/__init__.py`
35
+ - Create: `vorax/organs/__init__.py`
36
+ - Create: `vorax/agent/__init__.py`
37
+ - Create: `.gitignore`
38
+ - Test: `tests/test_scaffold.py`
39
+
40
+ **Interfaces:**
41
+ - Consumes: rien
42
+ - Produces: package installable `vorax` (editable) ; `vorax.__version__ = "0.1.0"`
43
+
44
+ - [x] **Step 1: Write the failing test**
45
+
46
+ ```python
47
+ # tests/test_scaffold.py
48
+ def test_package_importable():
49
+ import vorax
50
+ assert vorax.__version__ == "0.1.0"
51
+ ```
52
+
53
+ - [x] **Step 2: Run test to verify it fails**
54
+
55
+ Run: `cd /c/Users/PHIL/ZCodeProject/vorax && python -m pytest tests/test_scaffold.py -v`
56
+ Expected: FAIL — `ModuleNotFoundError: No module named 'vorax'` (ou collection error)
57
+
58
+ - [x] **Step 3: Write minimal implementation**
59
+
60
+ ```toml
61
+ # pyproject.toml
62
+ [build-system]
63
+ requires = ["setuptools>=68"]
64
+ build-backend = "setuptools.build_meta"
65
+
66
+ [project]
67
+ name = "vorax"
68
+ version = "0.1.0"
69
+ description = "Fractus ne s'entraine plus : il mange. Knowledge ingestion without training."
70
+ requires-python = ">=3.10"
71
+ dependencies = ["numpy>=1.24"]
72
+
73
+ [project.optional-dependencies]
74
+ dev = ["pytest>=8"]
75
+
76
+ [project.scripts]
77
+ vorax = "vorax.agent.cli:main"
78
+
79
+ [tool.setuptools.packages.find]
80
+ include = ["vorax*"]
81
+
82
+ [tool.pytest.ini_options]
83
+ testpaths = ["tests"]
84
+ ```
85
+
86
+ ```python
87
+ # vorax/__init__.py
88
+ __version__ = "0.1.0"
89
+ ```
90
+
91
+ ```python
92
+ # vorax/compiler/__init__.py
93
+ ```
94
+
95
+ ```python
96
+ # vorax/organs/__init__.py
97
+ ```
98
+
99
+ ```python
100
+ # vorax/agent/__init__.py
101
+ ```
102
+
103
+ ```gitignore
104
+ # .gitignore
105
+ __pycache__/
106
+ *.pyc
107
+ .pytest_cache/
108
+ dist/
109
+ build/
110
+ *.egg-info/
111
+ brains/
112
+ *.kn/
113
+ bench/out/
114
+ ```
115
+
116
+ - [x] **Step 4: Install editable + run test to verify it passes**
117
+
118
+ Run: `cd /c/Users/PHIL/ZCodeProject/vorax && python -m pip install -e ".[dev]" && python -m pytest tests/test_scaffold.py -v`
119
+ Expected: PASS (1 passed)
120
+
121
+ - [x] **Step 5: Commit**
122
+
123
+ ```bash
124
+ cd /c/Users/PHIL/ZCodeProject/vorax
125
+ git add pyproject.toml vorax .gitignore tests/test_scaffold.py
126
+ git commit -m "feat: scaffold vorax package (plan 1, task 1)"
127
+ ```
128
+
129
+ ---
130
+
131
+ ### Task 2: Hypervectors bipolaires (`vorax/hv.py`)
132
+
133
+ **Files:**
134
+ - Create: `vorax/hv.py`
135
+ - Test: `tests/test_hv.py`
136
+
137
+ **Interfaces:**
138
+ - Consumes: rien
139
+ - Produces:
140
+ - `hash_hv(text: str, D: int = D_DEFAULT, seed: int = 0) -> np.ndarray` (int8, shape `(D,)`, valeurs ±1)
141
+ - `bind(a: np.ndarray, b: np.ndarray) -> np.ndarray`
142
+ - `bundle(hvs: list[np.ndarray]) -> np.ndarray`
143
+ - `sim(a: np.ndarray, b: np.ndarray) -> float`
144
+ - `pack(hv: np.ndarray) -> np.ndarray` (uint8 packé, bitorder little, +1→bit 1)
145
+ - `unpack(bits: np.ndarray, D: int) -> np.ndarray`
146
+ - constantes `D_DEFAULT = 8192`, `SEED_DEFAULT = 0`
147
+
148
+ - [x] **Step 1: Write the failing tests**
149
+
150
+ ```python
151
+ # tests/test_hv.py
152
+ import numpy as np
153
+
154
+ from vorax.hv import D_DEFAULT, SEED_DEFAULT, bind, bundle, hash_hv, pack, sim, unpack
155
+
156
+
157
+ def test_hash_hv_deterministic():
158
+ a = hash_hv("what is the capital of france")
159
+ b = hash_hv("what is the capital of france")
160
+ assert np.array_equal(a, b)
161
+
162
+
163
+ def test_hash_hv_shape_and_values():
164
+ hv = hash_hv("hello", D=2048)
165
+ assert hv.shape == (2048,)
166
+ assert set(np.unique(hv)) <= {-1, 1}
167
+
168
+
169
+ def test_hash_hv_seed_changes_result():
170
+ a = hash_hv("hello", seed=0)
171
+ b = hash_hv("hello", seed=1)
172
+ assert not np.array_equal(a, b)
173
+
174
+
175
+ def test_near_orthogonality():
176
+ a = hash_hv("chat", D=8192)
177
+ b = hash_hv("chien", D=8192)
178
+ assert abs(sim(a, b)) < 0.2
179
+
180
+
181
+ def test_self_similarity_is_one():
182
+ a = hash_hv("n'importe quoi")
183
+ assert sim(a, a) == 1.0
184
+
185
+
186
+ def test_bind_is_involution_and_orthogonal():
187
+ a, b = hash_hv("cle"), hash_hv("valeur")
188
+ c = bind(a, b)
189
+ assert np.array_equal(bind(c, b), a) # XOR-like involution
190
+ assert abs(sim(c, a)) < 0.2 # bind obscurcit
191
+
192
+
193
+ def test_bundle_majority_with_tie_plus_one():
194
+ a, b, c = hash_hv("x"), hash_hv("y"), hash_hv("z")
195
+ m = bundle([a, b, c])
196
+ assert set(np.unique(m)) <= {-1, 1}
197
+ assert np.array_equal(bundle([a, a, b]), a) # majorité écrasante
198
+
199
+
200
+ def test_bundle_empty_raises():
201
+ try:
202
+ bundle([])
203
+ assert False, "should raise"
204
+ except ValueError:
205
+ pass
206
+
207
+
208
+ def test_pack_unpack_roundtrip():
209
+ a = hash_hv("roundtrip", D=2048)
210
+ bits = pack(a)
211
+ assert bits.dtype == np.uint8
212
+ assert np.array_equal(unpack(bits, 2048), a)
213
+
214
+
215
+ def test_defaults():
216
+ assert D_DEFAULT == 8192
217
+ assert SEED_DEFAULT == 0
218
+ ```
219
+
220
+ - [x] **Step 2: Run tests to verify they fail**
221
+
222
+ Run: `python -m pytest tests/test_hv.py -v`
223
+ Expected: FAIL — `ModuleNotFoundError: No module named 'vorax.hv'`
224
+
225
+ - [x] **Step 3: Write minimal implementation**
226
+
227
+ ```python
228
+ # vorax/hv.py
229
+ """Hypervectors bipolaires ±1 : le substrat de calcul de Vorax.
230
+
231
+ Tout est déterministe : blake2b en mode compteur, jamais hash() python.
232
+ """
233
+ import hashlib
234
+
235
+ import numpy as np
236
+
237
+ D_DEFAULT = 8192
238
+ SEED_DEFAULT = 0
239
+
240
+
241
+ def hash_hv(text: str, D: int = D_DEFAULT, seed: int = 0) -> np.ndarray:
242
+ """HV bipolaire ±1 déterministe, mode compteur (digest_size max 64 o)."""
243
+ need = (D + 7) // 8
244
+ blocks: list[bytes] = []
245
+ counter = 0
246
+ while sum(len(b) for b in blocks) < need:
247
+ payload = f"{seed}|{counter}|{text}".encode()
248
+ blocks.append(hashlib.blake2b(payload, digest_size=64).digest())
249
+ counter += 1
250
+ bits = np.unpackbits(
251
+ np.frombuffer(b"".join(blocks), dtype=np.uint8), bitorder="little"
252
+ )
253
+ return (bits[:D].astype(np.int16) * 2 - 1).astype(np.int8)
254
+
255
+
256
+ def bind(a: np.ndarray, b: np.ndarray) -> np.ndarray:
257
+ """Association (XOR sur bipolaires) : involution, obscurcit."""
258
+ return a * b
259
+
260
+
261
+ def bundle(hvs: list[np.ndarray]) -> np.ndarray:
262
+ """Superposition : signe majoritaire, égalité → +1 (déterministe)."""
263
+ if not hvs:
264
+ raise ValueError("bundle() needs at least one hv")
265
+ total = np.sum(np.stack(hvs), axis=0, dtype=np.int16)
266
+ return np.where(total >= 0, 1, -1).astype(np.int8)
267
+
268
+
269
+ def sim(a: np.ndarray, b: np.ndarray) -> float:
270
+ """Similarité = cosinus bipolaire = moyenne des produits."""
271
+ return float((a.astype(np.int16) * b.astype(np.int16)).mean())
272
+
273
+
274
+ def pack(hv: np.ndarray) -> np.ndarray:
275
+ """±1 → bits packés (+1 → 1), pour stockage."""
276
+ return np.packbits((hv > 0).astype(np.uint8), bitorder="little")
277
+
278
+
279
+ def unpack(bits: np.ndarray, D: int) -> np.ndarray:
280
+ """Inverse exact de pack()."""
281
+ out = np.unpackbits(bits, bitorder="little")[:D].astype(np.int16)
282
+ return (out * 2 - 1).astype(np.int8)
283
+ ```
284
+
285
+ - [x] **Step 4: Run tests to verify they pass**
286
+
287
+ Run: `python -m pytest tests/test_hv.py -v`
288
+ Expected: PASS (10 passed)
289
+
290
+ - [x] **Step 5: Commit**
291
+
292
+ ```bash
293
+ git add vorax/hv.py tests/test_hv.py
294
+ git commit -m "feat: bipolar hypervector core (deterministic blake2b counter-mode)"
295
+ ```
296
+
297
+ ---
298
+
299
+ ### Task 3: Atomes canoniques (`vorax/compiler/atoms.py`)
300
+
301
+ **Files:**
302
+ - Create: `vorax/compiler/atoms.py`
303
+ - Test: `tests/test_atoms.py`
304
+
305
+ **Interfaces:**
306
+ - Consumes: rien
307
+ - Produces:
308
+ - `Atom` dataclass frozen : champs `statement: str`, `context: str`, `provenance: str`, `confidence: float = 1.0`
309
+ - `Atom.row() -> str` (TSV-safe, 4 champs tabulés, tabs remplacés par espace)
310
+ - `Atom.from_row(row: str) -> Atom` (inverse exact ; tolère 3 champs → confidence 1.0)
311
+
312
+ - [x] **Step 1: Write the failing tests**
313
+
314
+ ```python
315
+ # tests/test_atoms.py
316
+ from vorax.compiler.atoms import Atom
317
+
318
+
319
+ def test_row_roundtrip():
320
+ a = Atom("what is the capital of france", "paris", "capitals.csv:2", 1.0)
321
+ assert Atom.from_row(a.row()) == a
322
+
323
+
324
+ def test_row_roundtrip_default_confidence():
325
+ a = Atom("q", "r", "src:1")
326
+ assert Atom.from_row(a.row()) == a
327
+
328
+
329
+ def test_row_is_tsv_safe():
330
+ a = Atom("a\tb", "c\td", "s", 0.5)
331
+ parts = a.row().split("\t")
332
+ assert len(parts) == 4
333
+ assert "\t" not in parts[0]
334
+
335
+
336
+ def test_frozen():
337
+ a = Atom("q", "r", "s")
338
+ try:
339
+ a.statement = "x" # type: ignore[misc]
340
+ assert False, "should be frozen"
341
+ except AttributeError:
342
+ pass
343
+ ```
344
+
345
+ - [x] **Step 2: Run tests to verify they fail**
346
+
347
+ Run: `python -m pytest tests/test_atoms.py -v`
348
+ Expected: FAIL — `ModuleNotFoundError: No module named 'vorax.compiler.atoms'`
349
+
350
+ - [x] **Step 3: Write minimal implementation**
351
+
352
+ ```python
353
+ # vorax/compiler/atoms.py
354
+ """Atome de connaissance : l'unité canonique que tout format devient."""
355
+ from dataclasses import dataclass
356
+
357
+
358
+ @dataclass(frozen=True)
359
+ class Atom:
360
+ statement: str # l'énoncé retrievable (la question, le fait)
361
+ context: str # la réponse / le contexte associé
362
+ provenance: str # source (fichier:ligne, url...)
363
+ confidence: float = 1.0
364
+
365
+ def row(self) -> str:
366
+ """Sérialisation TSV-safe (4 champs, tabs neutralisés)."""
367
+ f = [self.statement, self.context, self.provenance]
368
+ return "\t".join(x.replace("\t", " ") for x in f) + f"\t{self.confidence}"
369
+
370
+ @staticmethod
371
+ def from_row(row: str) -> "Atom":
372
+ parts = row.rstrip("\n").split("\t")
373
+ if len(parts) < 3:
374
+ raise ValueError(f"malformed atom row: {row!r}")
375
+ confidence = float(parts[3]) if len(parts) > 3 and parts[3] != "" else 1.0
376
+ return Atom(parts[0], parts[1], parts[2], confidence)
377
+ ```
378
+
379
+ - [x] **Step 4: Run tests to verify they pass**
380
+
381
+ Run: `python -m pytest tests/test_atoms.py -v`
382
+ Expected: PASS (4 passed)
383
+
384
+ - [x] **Step 5: Commit**
385
+
386
+ ```bash
387
+ git add vorax/compiler/atoms.py tests/test_atoms.py
388
+ git commit -m "feat: canonical knowledge Atom with TSV-safe roundtrip"
389
+ ```
390
+
391
+ ---
392
+
393
+ ### Task 4: Parseur multi-format (`vorax/compiler/parse.py`)
394
+
395
+ **Files:**
396
+ - Create: `vorax/compiler/parse.py`
397
+ - Test: `tests/test_parse.py`
398
+
399
+ **Interfaces:**
400
+ - Consumes: `Atom` (Task 3)
401
+ - Produces:
402
+ - `detect_format(path: Path) -> str` ∈ `{"csv", "tsv", "json", "jsonl", "txt", "md", "raw"}`
403
+ - `parse_file(path: Path) -> list[Atom]`
404
+ - `flatten_record(d: dict, prefix: str = "") -> list[str]` (paires `"key.subkey: value"`)
405
+
406
+ - [x] **Step 1: Write the failing tests**
407
+
408
+ ```python
409
+ # tests/test_parse.py
410
+ import json
411
+ from pathlib import Path
412
+
413
+ from vorax.compiler.parse import detect_format, flatten_record, parse_file
414
+
415
+
416
+ def _write(tmp_path, name, content):
417
+ p = tmp_path / name
418
+ p.write_text(content, encoding="utf-8")
419
+ return p
420
+
421
+
422
+ def test_detect_format(tmp_path):
423
+ assert detect_format(_write(tmp_path, "a.csv", "x\n1")) == "csv"
424
+ assert detect_format(_write(tmp_path, "a.tsv", "x\n1")) == "tsv"
425
+ assert detect_format(_write(tmp_path, "a.jsonl", "{}")) == "jsonl"
426
+ assert detect_format(_write(tmp_path, "a.json", "[]")) == "json"
427
+ assert detect_format(_write(tmp_path, "a.md", "# t")) == "md"
428
+ assert detect_format(_write(tmp_path, "a.txt", "hi")) == "txt"
429
+ assert detect_format(_write(tmp_path, "a.xyz", "hi")) == "raw"
430
+
431
+
432
+ def test_parse_csv_two_columns_is_qa(tmp_path):
433
+ p = _write(tmp_path, "cap.csv", "question,answer\nwhat is the capital of france,paris\nwho wrote hamlet,william shakespeare\n")
434
+ atoms = parse_file(p)
435
+ assert len(atoms) == 2
436
+ assert atoms[0].statement == "what is the capital of france"
437
+ assert atoms[0].context == "paris"
438
+ assert atoms[0].provenance.startswith("cap.csv")
439
+ assert atoms[0].confidence == 1.0
440
+
441
+
442
+ def test_parse_csv_wide_table_serializes_pairs(tmp_path):
443
+ p = _write(tmp_path, "rows.csv", "name,city,height\neiffel,paris,330\n")
444
+ atoms = parse_file(p)
445
+ assert len(atoms) == 1
446
+ assert "name: eiffel" in atoms[0].statement
447
+ assert "city: paris" in atoms[0].statement
448
+
449
+
450
+ def test_parse_jsonl(tmp_path):
451
+ line = json.dumps({"name": "eiffel tower", "city": "paris", "height_m": 330})
452
+ p = _write(tmp_path, "d.jsonl", line + "\n")
453
+ atoms = parse_file(p)
454
+ assert len(atoms) == 1
455
+ assert "name: eiffel tower" in atoms[0].statement
456
+ assert "height_m: 330" in atoms[0].statement
457
+
458
+
459
+ def test_parse_json_list(tmp_path):
460
+ p = _write(tmp_path, "d.json", json.dumps([{"a": 1}, {"b": 2}]))
461
+ atoms = parse_file(p)
462
+ assert len(atoms) == 2
463
+
464
+
465
+ def test_flatten_record_nested():
466
+ pairs = flatten_record({"x": {"y": "z"}, "l": [1, 2]})
467
+ assert "x.y: z" in pairs
468
+ assert "l: 1; 2" in pairs
469
+
470
+
471
+ def test_parse_txt_paragraphs(tmp_path):
472
+ p = _write(tmp_path, "doc.txt", "Premier paragraphe sur paris.\n\nDeuxieme paragraphe.\n")
473
+ atoms = parse_file(p)
474
+ assert len(atoms) == 2
475
+ assert atoms[0].statement == "Premier paragraphe sur paris."
476
+
477
+
478
+ def test_parse_md_headings_become_context(tmp_path):
479
+ p = _write(tmp_path, "doc.md", "# Capitales\n\nparis est la capitale de la france\n")
480
+ atoms = parse_file(p)
481
+ assert atoms[0].context == "Capitales"
482
+ assert "paris" in atoms[0].statement
483
+
484
+
485
+ def test_parse_raw_fallback_reads_lines(tmp_path):
486
+ p = _write(tmp_path, "weird.xyz", "ligne une\nligne deux\n")
487
+ atoms = parse_file(p)
488
+ assert len(atoms) == 2
489
+ ```
490
+
491
+ - [x] **Step 2: Run tests to verify they fail**
492
+
493
+ Run: `python -m pytest tests/test_parse.py -v`
494
+ Expected: FAIL — `ModuleNotFoundError: No module named 'vorax.compiler.parse'`
495
+
496
+ - [x] **Step 3: Write minimal implementation**
497
+
498
+ ```python
499
+ # vorax/compiler/parse.py
500
+ """N'importe quelle donnée devient des atomes. Format inconnu → fallback brut."""
501
+ import csv
502
+ import json
503
+ from pathlib import Path
504
+
505
+ from .atoms import Atom
506
+
507
+ _SUFFIXES = {
508
+ ".csv": "csv", ".tsv": "tsv", ".json": "json", ".jsonl": "jsonl",
509
+ ".txt": "txt", ".md": "md",
510
+ }
511
+
512
+
513
+ def detect_format(path: Path) -> str:
514
+ return _SUFFIXES.get(path.suffix.lower(), "raw")
515
+
516
+
517
+ def flatten_record(d: dict, prefix: str = "") -> list[str]:
518
+ """dict (récursif) → paires 'key.subkey: value' plates."""
519
+ pairs: list[str] = []
520
+ for key, value in d.items():
521
+ full = f"{prefix}.{key}" if prefix else str(key)
522
+ if isinstance(value, dict):
523
+ pairs.extend(flatten_record(value, full))
524
+ elif isinstance(value, list):
525
+ joined = "; ".join(str(x) for x in value)
526
+ pairs.append(f"{full}: {joined}")
527
+ else:
528
+ pairs.append(f"{full}: {value}")
529
+ return pairs
530
+
531
+
532
+ def parse_file(path: Path) -> list[Atom]:
533
+ fmt = detect_format(path)
534
+ if fmt in ("csv", "tsv"):
535
+ return _parse_csv(path, delim="," if fmt == "csv" else "\t")
536
+ if fmt == "jsonl":
537
+ return _parse_jsonl(path)
538
+ if fmt == "json":
539
+ return _parse_json(path)
540
+ if fmt in ("txt", "md"):
541
+ return _parse_text(path, md=(fmt == "md"))
542
+ return _parse_raw(path)
543
+
544
+
545
+ def _parse_csv(path: Path, delim: str) -> list[Atom]:
546
+ atoms: list[Atom] = []
547
+ with open(path, newline="", encoding="utf-8") as fh:
548
+ reader = csv.reader(fh, delimiter=delim)
549
+ header = next(reader, None)
550
+ for lineno, row in enumerate(reader, start=2):
551
+ if not row or all(not c.strip() for c in row):
552
+ continue
553
+ cells = [c.strip() for c in row]
554
+ if len(cells) == 2: # QA direct
555
+ atoms.append(Atom(cells[0], cells[1], f"{path.name}:{lineno}"))
556
+ else: # table large → sérialisation key: value
557
+ keys = header if header and len(header) == len(cells) else [f"c{i}" for i in range(len(cells))]
558
+ statement = "; ".join(f"{k}: {v}" for k, v in zip(keys, cells))
559
+ atoms.append(Atom(statement, "", f"{path.name}:{lineno}"))
560
+ return atoms
561
+
562
+
563
+ def _parse_jsonl(path: Path) -> list[Atom]:
564
+ atoms = []
565
+ with open(path, encoding="utf-8") as fh:
566
+ for lineno, line in enumerate(fh, start=1):
567
+ line = line.strip()
568
+ if not line:
569
+ continue
570
+ record = json.loads(line)
571
+ if not isinstance(record, dict):
572
+ continue
573
+ atoms.append(Atom("; ".join(flatten_record(record)), "", f"{path.name}:{lineno}"))
574
+ return atoms
575
+
576
+
577
+ def _parse_json(path: Path) -> list[Atom]:
578
+ data = json.loads(path.read_text(encoding="utf-8"))
579
+ records = data if isinstance(data, list) else [data]
580
+ atoms = []
581
+ for i, record in enumerate(records, start=1):
582
+ if isinstance(record, dict):
583
+ atoms.append(Atom("; ".join(flatten_record(record)), "", f"{path.name}:{i}"))
584
+ return atoms
585
+
586
+
587
+ def _parse_text(path: Path, md: bool) -> list[Atom]:
588
+ atoms = []
589
+ context = ""
590
+ buffer: list[str] = []
591
+ for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
592
+ stripped = line.strip()
593
+ if md and stripped.startswith("#"):
594
+ context = stripped.lstrip("#").strip()
595
+ continue
596
+ if not stripped:
597
+ if buffer:
598
+ atoms.append(Atom(" ".join(buffer), context, f"{path.name}:{lineno}"))
599
+ buffer = []
600
+ continue
601
+ buffer.append(stripped)
602
+ if buffer:
603
+ atoms.append(Atom(" ".join(buffer), context, f"{path.name}:EOF"))
604
+ return atoms
605
+
606
+
607
+ def _parse_raw(path: Path) -> list[Atom]:
608
+ atoms = []
609
+ for lineno, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), start=1):
610
+ if line.strip():
611
+ atoms.append(Atom(line.strip(), "", f"{path.name}:{lineno}"))
612
+ return atoms
613
+ ```
614
+
615
+ - [x] **Step 4: Run tests to verify they pass**
616
+
617
+ Run: `python -m pytest tests/test_parse.py -v`
618
+ Expected: PASS (9 passed)
619
+
620
+ - [x] **Step 5: Commit**
621
+
622
+ ```bash
623
+ git add vorax/compiler/parse.py tests/test_parse.py
624
+ git commit -m "feat: multi-format parser — any data becomes atoms"
625
+ ```
626
+
627
+ ---
628
+
629
+ ### Task 5: Encodage (`vorax/compiler/encode.py`)
630
+
631
+ **Files:**
632
+ - Create: `vorax/compiler/encode.py`
633
+ - Test: `tests/test_encode.py`
634
+
635
+ **Interfaces:**
636
+ - Consumes: `hash_hv, bundle, D_DEFAULT, SEED_DEFAULT` (Task 2) ; `Atom` (Task 3)
637
+ - Produces:
638
+ - `normalize(text: str) -> str`
639
+ - `tokenize(text: str) -> list[str]`
640
+ - `text_hv(text: str, D: int = D_DEFAULT, seed: int = 0) -> np.ndarray` (bundle des HV de tokens)
641
+ - `encode_atom(atom: Atom, D: int = D_DEFAULT, seed: int = 0) -> tuple[np.ndarray, np.ndarray]` — `(addr, value)` ; `addr = text_hv(statement)`, `value = text_hv(f"{statement} => {context}")` si context sinon `addr`
642
+
643
+ - [x] **Step 1: Write the failing tests**
644
+
645
+ ```python
646
+ # tests/test_encode.py
647
+ import numpy as np
648
+
649
+ from vorax.compiler.atoms import Atom
650
+ from vorax.compiler.encode import encode_atom, normalize, text_hv, tokenize
651
+ from vorax.hv import sim
652
+
653
+
654
+ def test_normalize():
655
+ assert normalize(" What IS The Capital? ") == "what is the capital"
656
+
657
+
658
+ def test_tokenize():
659
+ assert tokenize("What is the capital of France?") == ["what", "is", "the", "capital", "of", "france"]
660
+
661
+
662
+ def test_text_hv_identical_sentences():
663
+ a = text_hv("what is the capital of france")
664
+ b = text_hv("What IS the capital of France?")
665
+ assert np.array_equal(a, b)
666
+
667
+
668
+ def test_related_more_similar_than_unrelated():
669
+ q1 = text_hv("what is the capital of france")
670
+ q2 = text_hv("what is the capital of spain")
671
+ far = text_hv("quantum entanglement violates locality")
672
+ assert sim(q1, q2) > 0.3 # 5 tokens partagés sur 6
673
+ assert sim(q1, far) < 0.2 # quasi-orthogonal
674
+ assert sim(q1, q2) > sim(q1, far)
675
+
676
+
677
+ def test_encode_atom_deterministic():
678
+ atom = Atom("what is the capital of france", "paris", "cap.csv:2")
679
+ a1, v1 = encode_atom(atom)
680
+ a2, v2 = encode_atom(atom)
681
+ assert np.array_equal(a1, a2) and np.array_equal(v1, v2)
682
+
683
+
684
+ def test_encode_atom_addr_is_statement():
685
+ atom = Atom("who wrote hamlet", "william shakespeare", "s:1")
686
+ addr, value = encode_atom(atom)
687
+ assert np.array_equal(addr, text_hv("who wrote hamlet"))
688
+ assert not np.array_equal(value, addr) # le contexte change la valeur
689
+
690
+
691
+ def test_encode_atom_no_context_value_equals_addr():
692
+ atom = Atom("un fait nu", "", "s:1")
693
+ addr, value = encode_atom(atom)
694
+ assert np.array_equal(value, addr)
695
+ ```
696
+
697
+ - [x] **Step 2: Run tests to verify they fail**
698
+
699
+ Run: `python -m pytest tests/test_encode.py -v`
700
+ Expected: FAIL — `ModuleNotFoundError: No module named 'vorax.compiler.encode'`
701
+
702
+ - [x] **Step 3: Write minimal implementation**
703
+
704
+ ```python
705
+ # vorax/compiler/encode.py
706
+ """Texte → HV : le bundle de tokens rend les questions apparentées similaires."""
707
+ import re
708
+
709
+ import numpy as np
710
+
711
+ from ..hv import D_DEFAULT, SEED_DEFAULT, bundle, hash_hv
712
+ from .atoms import Atom
713
+
714
+ _WORD = re.compile(r"[a-z0-9àâäéèêëîïôöùûüç'-]+")
715
+
716
+
717
+ def normalize(text: str) -> str:
718
+ t = text.casefold().strip()
719
+ t = re.sub(r"\s+", " ", t)
720
+ return t.strip(" .;:!?")
721
+
722
+
723
+ def tokenize(text: str) -> list[str]:
724
+ return _WORD.findall(normalize(text))
725
+
726
+
727
+ def text_hv(text: str, D: int = D_DEFAULT, seed: int = SEED_DEFAULT) -> np.ndarray:
728
+ tokens = tokenize(text)
729
+ if not tokens:
730
+ return hash_hv("<empty>", D, seed)
731
+ return bundle([hash_hv(tok, D, seed) for tok in tokens])
732
+
733
+
734
+ def encode_atom(atom: Atom, D: int = D_DEFAULT, seed: int = SEED_DEFAULT) -> tuple[np.ndarray, np.ndarray]:
735
+ addr = text_hv(atom.statement, D, seed)
736
+ if atom.context:
737
+ value = text_hv(f"{atom.statement} => {atom.context}", D, seed)
738
+ else:
739
+ value = addr
740
+ return addr, value
741
+ ```
742
+
743
+ - [x] **Step 4: Run tests to verify they pass**
744
+
745
+ Run: `python -m pytest tests/test_encode.py -v`
746
+ Expected: PASS (7 passed)
747
+
748
+ - [x] **Step 5: Commit**
749
+
750
+ ```bash
751
+ git add vorax/compiler/encode.py tests/test_encode.py
752
+ git commit -m "feat: token-bundle encoding — related questions become similar HVs"
753
+ ```
754
+
755
+ ---
756
+
757
+ ### Task 6: Organe Traces (`vorax/organs/traces.py`)
758
+
759
+ **Files:**
760
+ - Create: `vorax/organs/traces.py`
761
+ - Test: `tests/test_traces.py`
762
+
763
+ **Interfaces:**
764
+ - Consumes: `pack, unpack` (Task 2)
765
+ - Produces:
766
+ - `TraceStore(D: int = D_DEFAULT)` avec :
767
+ - `write(atom_id: int, addr: np.ndarray, value: np.ndarray, weight: float = 1.0) -> int` (index de trace)
768
+ - `retrieve(query: np.ndarray, k: int = 8) -> list[tuple[int, float]]` — `(atom_id, sim)` triés par sim décroissante, sim pondérée par weight
769
+ - `__len__() -> int`
770
+ - `save(path: Path) -> None` (crée `path/` : `traces.npz` + `meta.json`)
771
+ - `TraceStore.load(path: Path) -> TraceStore` (classmethod)
772
+
773
+ - [x] **Step 1: Write the failing tests**
774
+
775
+ ```python
776
+ # tests/test_traces.py
777
+ from pathlib import Path
778
+
779
+ import numpy as np
780
+
781
+ from vorax.compiler.encode import text_hv
782
+ from vorax.hv import D_DEFAULT, sim
783
+ from vorax.organs.traces import TraceStore
784
+
785
+
786
+ def _fill(store: TraceStore) -> None:
787
+ for i, q in enumerate(["what is the capital of france", "who wrote hamlet", "quantum stuff"]):
788
+ addr, value = text_hv(q, D=2048), text_hv(q, D=2048)
789
+ store.write(i, addr, value)
790
+
791
+
792
+ def test_write_and_retrieve_top1(tmp_path):
793
+ store = TraceStore(D=2048)
794
+ _fill(store)
795
+ hits = store.retrieve(text_hv("what is the capital of france", D=2048), k=2)
796
+ assert len(hits) == 2
797
+ assert hits[0][0] == 0 # le bon atom_id en tête
798
+ assert hits[0][1] > 0.5 # similarité élevée
799
+ assert hits[0][1] >= hits[1][1] # tri décroissant
800
+
801
+
802
+ def test_empty_retrieve_returns_empty():
803
+ store = TraceStore(D=2048)
804
+ assert store.retrieve(text_hv("rien", D=2048)) == []
805
+ assert len(store) == 0
806
+
807
+
808
+ def test_weight_boosts_ranking():
809
+ store = TraceStore(D=2048)
810
+ q1, q2 = "what is the capital of france", "what is the capital of spain"
811
+ store.write(0, text_hv(q1, D=2048), text_hv(q1, D=2048), weight=0.1)
812
+ store.write(1, text_hv(q2, D=2048), text_hv(q2, D=2048), weight=2.0)
813
+ hits = store.retrieve(text_hv("what is the capital of france", D=2048), k=2)
814
+ assert hits[0][0] == 1 # poids fort domine malgré des tokens partagés
815
+
816
+
817
+ def test_save_load_roundtrip(tmp_path):
818
+ store = TraceStore(D=2048)
819
+ _fill(store)
820
+ store.save(tmp_path / "brain")
821
+ loaded = TraceStore.load(tmp_path / "brain")
822
+ assert len(loaded) == 3
823
+ q = text_hv("who wrote hamlet", D=2048)
824
+ assert [i for i, _ in loaded.retrieve(q, k=3)] == [i for i, _ in store.retrieve(q, k=3)]
825
+ ```
826
+
827
+ - [x] **Step 2: Run tests to verify they fail**
828
+
829
+ Run: `python -m pytest tests/test_traces.py -v`
830
+ Expected: FAIL — `ModuleNotFoundError: No module named 'vorax.organs.traces'`
831
+
832
+ - [x] **Step 3: Write minimal implementation**
833
+
834
+ ```python
835
+ # vorax/organs/traces.py
836
+ """Organe 1 — hippocampe : mémoire append-only, on n'écrase jamais, on superpose."""
837
+ import json
838
+ from pathlib import Path
839
+
840
+ import numpy as np
841
+
842
+ from ..hv import D_DEFAULT, pack, unpack
843
+
844
+
845
+ class TraceStore:
846
+ def __init__(self, D: int = D_DEFAULT) -> None:
847
+ self.D = D
848
+ self.addrs: list[np.ndarray] = []
849
+ self.values: list[np.ndarray] = []
850
+ self.weights: list[float] = []
851
+ self.ids: list[int] = []
852
+ self._stack: np.ndarray | None = None
853
+
854
+ def __len__(self) -> int:
855
+ return len(self.ids)
856
+
857
+ def write(self, atom_id: int, addr: np.ndarray, value: np.ndarray, weight: float = 1.0) -> int:
858
+ self.addrs.append(addr)
859
+ self.values.append(value)
860
+ self.weights.append(float(weight))
861
+ self.ids.append(int(atom_id))
862
+ self._stack = None
863
+ return len(self.ids) - 1
864
+
865
+ def _mat(self) -> np.ndarray:
866
+ if self._stack is None:
867
+ self._stack = np.stack(self.addrs).astype(np.int16)
868
+ return self._stack
869
+
870
+ def retrieve(self, query: np.ndarray, k: int = 8) -> list[tuple[int, float]]:
871
+ if not self.ids:
872
+ return []
873
+ sims = (self._mat() @ query.astype(np.int16)) / self.D
874
+ sims = sims * np.asarray(self.weights, dtype=np.float64)
875
+ order = np.argsort(-sims)[:k]
876
+ return [(self.ids[i], float(sims[i])) for i in order]
877
+
878
+ def signature(self) -> np.ndarray:
879
+ """Bundle de toutes les addresses (clé de routage future — spawn)."""
880
+ if not self.addrs:
881
+ raise ValueError("empty store has no signature")
882
+ total = np.sum(self._mat(), axis=0, dtype=np.int32)
883
+ return np.where(total >= 0, 1, -1).astype(np.int8)
884
+
885
+ def save(self, path: Path) -> None:
886
+ path.mkdir(parents=True, exist_ok=True)
887
+ packed_a = np.concatenate([pack(a) for a in self.addrs]) if self.addrs else np.array([], dtype=np.uint8)
888
+ packed_v = np.concatenate([pack(v) for v in self.values]) if self.values else np.array([], dtype=np.uint8)
889
+ np.savez_compressed(
890
+ path / "traces.npz",
891
+ a=packed_a,
892
+ v=packed_v,
893
+ w=np.asarray(self.weights, dtype=np.float32),
894
+ ids=np.asarray(self.ids, dtype=np.int64),
895
+ )
896
+ (path / "meta.json").write_text(
897
+ json.dumps({"D": self.D, "n": len(self.ids)}, sort_keys=True), encoding="utf-8"
898
+ )
899
+
900
+ @classmethod
901
+ def load(cls, path: Path) -> "TraceStore":
902
+ meta = json.loads((path / "meta.json").read_text(encoding="utf-8"))
903
+ store = cls(D=int(meta["D"]))
904
+ data = np.load(path / "traces.npz")
905
+ for i in range(int(meta["n"])):
906
+ a = unpack(data["a"][i * (store.D // 8):(i + 1) * (store.D // 8)], store.D)
907
+ v = unpack(data["v"][i * (store.D // 8):(i + 1) * (store.D // 8)], store.D)
908
+ store.addrs.append(a)
909
+ store.values.append(v)
910
+ store.weights = [float(x) for x in data["w"]]
911
+ store.ids = [int(x) for x in data["ids"]]
912
+ return store
913
+ ```
914
+
915
+ - [x] **Step 4: Run tests to verify they pass**
916
+
917
+ Run: `python -m pytest tests/test_traces.py -v`
918
+ Expected: PASS (4 passed)
919
+
920
+ - [x] **Step 5: Commit**
921
+
922
+ ```bash
923
+ git add vorax/organs/traces.py tests/test_traces.py
924
+ git commit -m "feat: trace organ — append-only HV memory with weighted retrieval"
925
+ ```
926
+
927
+ ---
928
+
929
+ ### Task 7: Brain — assemblage persistant (`vorax/brain.py`)
930
+
931
+ **Files:**
932
+ - Create: `vorax/brain.py`
933
+ - Test: `tests/test_brain.py`
934
+
935
+ **Interfaces:**
936
+ - Consumes: `TraceStore` (Task 6), `encode_atom, text_hv` (Task 5), `Atom` (Task 3)
937
+ - Produces:
938
+ - `Brain(D: int = D_DEFAULT, seed: int = SEED_DEFAULT)` avec :
939
+ - `ingest_atom(atom: Atom) -> int` (atom_id)
940
+ - `ingest_atoms(atoms: list[Atom]) -> int` (nombre ingéré)
941
+ - `ask(question: str, k: int = 3) -> list[tuple[Atom, float]]`
942
+ - `__len__() -> int`
943
+ - `save(path: Path) -> None` (répertoire : `meta.json`, `atoms.jsonl.gz`, `traces.npz`)
944
+ - `Brain.load(path: Path) -> Brain` (classmethod)
945
+
946
+ - [x] **Step 1: Write the failing tests**
947
+
948
+ ```python
949
+ # tests/test_brain.py
950
+ import gzip
951
+ import json
952
+ from pathlib import Path
953
+
954
+ from vorax.brain import Brain
955
+ from vorax.compiler.atoms import Atom
956
+
957
+ QA = [
958
+ Atom("what is the capital of france", "paris", "cap.csv:2"),
959
+ Atom("what is the capital of spain", "madrid", "cap.csv:3"),
960
+ Atom("what is the capital of japan", "tokyo", "cap.csv:4"),
961
+ Atom("who wrote hamlet", "william shakespeare", "books.csv:2"),
962
+ ]
963
+
964
+
965
+ def test_ingest_and_ask_top1():
966
+ brain = Brain(D=2048)
967
+ brain.ingest_atoms(QA)
968
+ assert len(brain) == 4
969
+ hits = brain.ask("what is the capital of france", k=2)
970
+ assert hits[0][0].context == "paris"
971
+ assert hits[0][1] > 0.3
972
+
973
+
974
+ def test_ask_generalizes_token_overlap():
975
+ """Jamais vu mot à mot, mais les tokens partagés doivent hisser le bon domaine."""
976
+ brain = Brain(D=2048)
977
+ brain.ingest_atoms(QA)
978
+ hits = brain.ask("what is the capital of germany", k=1)
979
+ assert hits[0][0].statement.startswith("what is the capital of")
980
+
981
+
982
+ def test_save_load_roundtrip(tmp_path):
983
+ brain = Brain(D=2048)
984
+ brain.ingest_atoms(QA)
985
+ brain.save(tmp_path / "brain")
986
+ loaded = Brain.load(tmp_path / "brain")
987
+ assert len(loaded) == 4
988
+ assert loaded.ask("who wrote hamlet", k=1)[0][0].context == "william shakespeare"
989
+
990
+
991
+ def test_save_is_deterministic(tmp_path):
992
+ b1, b2 = Brain(D=2048), Brain(D=2048)
993
+ b1.ingest_atoms(QA)
994
+ b2.ingest_atoms(QA)
995
+ b1.save(tmp_path / "b1")
996
+ b2.save(tmp_path / "b2")
997
+ assert (tmp_path / "b1" / "meta.json").read_bytes() == (tmp_path / "b2" / "meta.json").read_bytes()
998
+ assert (tmp_path / "b1" / "atoms.jsonl.gz").read_bytes() == (tmp_path / "b2" / "atoms.jsonl.gz").read_bytes()
999
+ ```
1000
+
1001
+ - [x] **Step 2: Run tests to verify they fail**
1002
+
1003
+ Run: `python -m pytest tests/test_brain.py -v`
1004
+ Expected: FAIL — `ModuleNotFoundError: No module named 'vorax.brain'`
1005
+
1006
+ - [x] **Step 3: Write minimal implementation**
1007
+
1008
+ ```python
1009
+ # vorax/brain.py
1010
+ """Le cerveau Vorax Plan 1 : organes + atomes, persistant et déterministe."""
1011
+ import gzip
1012
+ import json
1013
+ from pathlib import Path
1014
+
1015
+ from .compiler.atoms import Atom
1016
+ from .compiler.encode import encode_atom, text_hv
1017
+ from .hv import D_DEFAULT, SEED_DEFAULT
1018
+ from .organs.traces import TraceStore
1019
+
1020
+
1021
+ class Brain:
1022
+ def __init__(self, D: int = D_DEFAULT, seed: int = SEED_DEFAULT) -> None:
1023
+ self.D = D
1024
+ self.seed = seed
1025
+ self.traces = TraceStore(D=D)
1026
+ self.atoms: list[Atom] = []
1027
+
1028
+ def __len__(self) -> int:
1029
+ return len(self.atoms)
1030
+
1031
+ def ingest_atom(self, atom: Atom) -> int:
1032
+ atom_id = len(self.atoms)
1033
+ self.atoms.append(atom)
1034
+ addr, value = encode_atom(atom, self.D, self.seed)
1035
+ self.traces.write(atom_id, addr, value, weight=atom.confidence)
1036
+ return atom_id
1037
+
1038
+ def ingest_atoms(self, atoms: list[Atom]) -> int:
1039
+ for atom in atoms:
1040
+ self.ingest_atom(atom)
1041
+ return len(atoms)
1042
+
1043
+ def ask(self, question: str, k: int = 3) -> list[tuple[Atom, float]]:
1044
+ query = text_hv(question, self.D, self.seed)
1045
+ return [(self.atoms[i], s) for i, s in self.traces.retrieve(query, k=k)]
1046
+
1047
+ def save(self, path: Path) -> None:
1048
+ path.mkdir(parents=True, exist_ok=True)
1049
+ payload = "\n".join(a.row() for a in self.atoms).encode("utf-8")
1050
+ (path / "atoms.jsonl.gz").write_bytes(gzip.compress(payload, mtime=0))
1051
+ self.traces.save(path)
1052
+ (path / "meta.json").write_text(
1053
+ json.dumps({"D": self.D, "seed": self.seed, "n_atoms": len(self.atoms)}, sort_keys=True),
1054
+ encoding="utf-8",
1055
+ )
1056
+
1057
+ @classmethod
1058
+ def load(cls, path: Path) -> "Brain":
1059
+ meta = json.loads((path / "meta.json").read_text(encoding="utf-8"))
1060
+ brain = cls(D=int(meta["D"]), seed=int(meta["seed"]))
1061
+ raw = gzip.decompress((path / "atoms.jsonl.gz").read_bytes()).decode("utf-8")
1062
+ for line in raw.splitlines():
1063
+ if line.strip():
1064
+ brain.ingest_atom(Atom.from_row(line))
1065
+ return brain
1066
+ ```
1067
+
1068
+ - [x] **Step 4: Run tests to verify they pass**
1069
+
1070
+ Run: `python -m pytest tests/test_brain.py -v`
1071
+ Expected: PASS (4 passed)
1072
+
1073
+ - [x] **Step 5: Commit**
1074
+
1075
+ ```bash
1076
+ git add vorax/brain.py tests/test_brain.py
1077
+ git commit -m "feat: Brain — persistent organ+atom assembly, ingest and ask"
1078
+ ```
1079
+
1080
+ ---
1081
+
1082
+ ### Task 8: Compilateur `.kn` (`vorax/compiler/kn.py`)
1083
+
1084
+ **Files:**
1085
+ - Create: `vorax/compiler/kn.py`
1086
+ - Test: `tests/test_kn.py`
1087
+
1088
+ **Interfaces:**
1089
+ - Consumes: `parse_file` (Task 4), `Atom.row/from_row` (Task 3), `pack` (Task 2)
1090
+ - Produces:
1091
+ - `compile_kn(source: Path, out: Path, D: int = D_DEFAULT, seed: int = SEED_DEFAULT) -> Path` — crée `out/` (le `.kn`) : `manifest.json`, `atoms.tsv.gz`, `signature.bin`
1092
+ - `load_kn(kn_dir: Path) -> list[Atom]`
1093
+ - `source_sha256(path: Path) -> str`
1094
+ - manifest (clés triées, SANS timestamp) : `{"D", "format_version", "n_atoms", "seed", "source", "source_sha256"}` avec `format_version = 1`
1095
+
1096
+ - [x] **Step 1: Write the failing tests**
1097
+
1098
+ ```python
1099
+ # tests/test_kn.py
1100
+ import gzip
1101
+ import json
1102
+ from pathlib import Path
1103
+
1104
+ from vorax.compiler.kn import compile_kn, load_kn, source_sha256
1105
+
1106
+
1107
+ def _csv(tmp_path):
1108
+ p = tmp_path / "cap.csv"
1109
+ p.write_text(
1110
+ "question,answer\nwhat is the capital of france,paris\nwho wrote hamlet,william shakespeare\n",
1111
+ encoding="utf-8",
1112
+ )
1113
+ return p
1114
+
1115
+
1116
+ def test_compile_produces_kn_layout(tmp_path):
1117
+ kn = compile_kn(_csv(tmp_path), tmp_path / "cap.kn")
1118
+ assert (kn / "manifest.json").exists()
1119
+ assert (kn / "atoms.tsv.gz").exists()
1120
+ assert (kn / "signature.bin").exists()
1121
+ manifest = json.loads((kn / "manifest.json").read_text(encoding="utf-8"))
1122
+ assert manifest["n_atoms"] == 2
1123
+ assert manifest["format_version"] == 1
1124
+ assert "created" not in manifest # jamais de timestamp
1125
+
1126
+
1127
+ def test_load_kn_roundtrip(tmp_path):
1128
+ src = _csv(tmp_path)
1129
+ kn = compile_kn(src, tmp_path / "cap.kn")
1130
+ atoms = load_kn(kn)
1131
+ assert atoms[0].statement == "what is the capital of france"
1132
+ assert atoms[0].context == "paris"
1133
+ assert atoms[0].confidence == 1.0
1134
+
1135
+
1136
+ def test_compile_is_bit_identical(tmp_path):
1137
+ src = _csv(tmp_path)
1138
+ k1 = compile_kn(src, tmp_path / "a.kn")
1139
+ k2 = compile_kn(src, tmp_path / "b.kn")
1140
+ for name in ("manifest.json", "atoms.tsv.gz", "signature.bin"):
1141
+ assert (k1 / name).read_bytes() == (k2 / name).read_bytes(), name
1142
+
1143
+
1144
+ def test_source_sha256_stable(tmp_path):
1145
+ src = _csv(tmp_path)
1146
+ assert source_sha256(src) == source_sha256(src)
1147
+ assert len(source_sha256(src)) == 64
1148
+
1149
+
1150
+ def test_manifest_records_source_hash(tmp_path):
1151
+ src = _csv(tmp_path)
1152
+ kn = compile_kn(src, tmp_path / "cap.kn")
1153
+ manifest = json.loads((kn / "manifest.json").read_text(encoding="utf-8"))
1154
+ assert manifest["source_sha256"] == source_sha256(src)
1155
+ ```
1156
+
1157
+ - [x] **Step 2: Run tests to verify they fail**
1158
+
1159
+ Run: `python -m pytest tests/test_kn.py -v`
1160
+ Expected: FAIL — `ModuleNotFoundError: No module named 'vorax.compiler.kn'`
1161
+
1162
+ - [x] **Step 3: Write minimal implementation**
1163
+
1164
+ ```python
1165
+ # vorax/compiler/kn.py
1166
+ """Le compilateur .kn : dataset → artefact de connaissance portable, bit-identique."""
1167
+ import gzip
1168
+ import hashlib
1169
+ import json
1170
+ from pathlib import Path
1171
+
1172
+ import numpy as np
1173
+
1174
+ from ..hv import D_DEFAULT, SEED_DEFAULT, pack
1175
+ from .atoms import Atom
1176
+ from .encode import encode_atom
1177
+ from .parse import parse_file
1178
+
1179
+ FORMAT_VERSION = 1
1180
+
1181
+
1182
+ def source_sha256(path: Path) -> str:
1183
+ h = hashlib.sha256()
1184
+ h.update(path.read_bytes())
1185
+ return h.hexdigest()
1186
+
1187
+
1188
+ def compile_kn(
1189
+ source: Path, out: Path, D: int = D_DEFAULT, seed: int = SEED_DEFAULT
1190
+ ) -> Path:
1191
+ atoms = parse_file(source)
1192
+ out.mkdir(parents=True, exist_ok=True)
1193
+
1194
+ payload = "\n".join(a.row() for a in atoms).encode("utf-8")
1195
+ (out / "atoms.tsv.gz").write_bytes(gzip.compress(payload, mtime=0))
1196
+
1197
+ addrs = [encode_atom(a, D, seed)[0] for a in atoms]
1198
+ if addrs:
1199
+ total = np.sum(np.stack(addrs).astype(np.int32), axis=0)
1200
+ signature = np.where(total >= 0, 1, -1).astype(np.int8)
1201
+ sig_bits = pack(signature)
1202
+ else:
1203
+ sig_bits = np.array([], dtype=np.uint8)
1204
+ (out / "signature.bin").write_bytes(sig_bits.tobytes())
1205
+
1206
+ manifest = {
1207
+ "D": D,
1208
+ "format_version": FORMAT_VERSION,
1209
+ "n_atoms": len(atoms),
1210
+ "seed": seed,
1211
+ "source": str(source.name),
1212
+ "source_sha256": source_sha256(source),
1213
+ }
1214
+ (out / "manifest.json").write_text(
1215
+ json.dumps(manifest, sort_keys=True, indent=2), encoding="utf-8"
1216
+ )
1217
+ return out
1218
+
1219
+
1220
+ def load_kn(kn_dir: Path) -> list[Atom]:
1221
+ raw = gzip.decompress((kn_dir / "atoms.tsv.gz").read_bytes()).decode("utf-8")
1222
+ return [Atom.from_row(line) for line in raw.splitlines() if line.strip()]
1223
+ ```
1224
+
1225
+ - [x] **Step 4: Run tests to verify they pass**
1226
+
1227
+ Run: `python -m pytest tests/test_kn.py -v`
1228
+ Expected: PASS (5 passed)
1229
+
1230
+ - [x] **Step 5: Commit**
1231
+
1232
+ ```bash
1233
+ git add vorax/compiler/kn.py tests/test_kn.py
1234
+ git commit -m "feat: .kn knowledge compiler — deterministic, portable, bit-identical"
1235
+ ```
1236
+
1237
+ ---
1238
+
1239
+ ### Task 9: CLI `ingest` / `ask` / `status` (`vorax/agent/cli.py`)
1240
+
1241
+ **Files:**
1242
+ - Create: `vorax/agent/cli.py`
1243
+ - Test: `tests/test_cli.py`
1244
+
1245
+ **Interfaces:**
1246
+ - Consumes: `Brain` (Task 7), `compile_kn, load_kn` (Task 8)
1247
+ - Produces:
1248
+ - `ingest(source: Path, brain_path: Path, D: int, seed: int, kn_cache: Path | None) -> int` — compile `.kn` (dans `kn_cache` si fourni, sinon à côté du brain), charge les atomes, ingère, sauve ; retourne le nombre d'atomes ingérés ; idempotent côté brain (ré-ingérer un `.kn` existant recompile depuis le cache)
1249
+ - `ask(question: str, brain_path: Path, k: int) -> list[str]` — lignes de cartes formatées
1250
+ - `status(brain_path: Path) -> str`
1251
+ - `main(argv: list[str] | None = None) -> int` (exit code 0) — subcommands `ingest/ask/status`, options `--brain` (défaut `./brain`), `--D`, `--seed`, `--k`, `--kn-cache`
1252
+ - Format carte : `[CARTE] FAIT: {statement} (sim {sim:.2f} | src: {provenance})` puis ligne ` RÉP: {context}` si context non vide
1253
+
1254
+ - [x] **Step 1: Write the failing tests**
1255
+
1256
+ ```python
1257
+ # tests/test_cli.py
1258
+ from pathlib import Path
1259
+
1260
+ from vorax.agent import cli
1261
+
1262
+ CSV_CONTENT = (
1263
+ "question,answer\n"
1264
+ "what is the capital of france,paris\n"
1265
+ "what is the capital of spain,madrid\n"
1266
+ "what is the capital of japan,tokyo\n"
1267
+ "who wrote hamlet,william shakespeare\n"
1268
+ )
1269
+
1270
+
1271
+ def _make_csv(tmp_path):
1272
+ p = tmp_path / "cap.csv"
1273
+ p.write_text(CSV_CONTENT, encoding="utf-8")
1274
+ return p
1275
+
1276
+
1277
+ def test_ingest_then_ask_end_to_end(tmp_path, capsys):
1278
+ csv = _make_csv(tmp_path)
1279
+ brain_dir = tmp_path / "brain"
1280
+ n = cli.ingest(csv, brain_dir, D=2048, seed=0, kn_cache=tmp_path / "kn")
1281
+ assert n == 4
1282
+ assert (brain_dir / "meta.json").exists()
1283
+ assert (tmp_path / "kn" / "cap.kn" / "manifest.json").exists() # l'artefact .kn existe
1284
+
1285
+ lines = cli.ask("what is the capital of japan", brain_dir, k=1)
1286
+ out = "\n".join(lines)
1287
+ assert "tokyo" in out
1288
+ assert "[CARTE]" in out
1289
+ assert "RÉP:" in out
1290
+ print(out)
1291
+ captured = capsys.readouterr()
1292
+ assert "tokyo" in captured.out
1293
+
1294
+
1295
+ def test_ask_empty_brain(tmp_path):
1296
+ lines = cli.ask("anything", tmp_path / "nope", k=1)
1297
+ assert lines == ["[CARTE] LACUNES: aucune trace (brain vide ou absent)"]
1298
+
1299
+
1300
+ def test_status_reports_counts(tmp_path, capsys):
1301
+ csv = _make_csv(tmp_path)
1302
+ cli.ingest(csv, tmp_path / "brain", D=2048, seed=0, kn_cache=tmp_path / "kn")
1303
+ s = cli.status(tmp_path / "brain")
1304
+ assert "4" in s
1305
+
1306
+
1307
+ def test_main_subcommands(tmp_path, capsys):
1308
+ csv = _make_csv(tmp_path)
1309
+ brain = tmp_path / "brain"
1310
+ assert cli.main(["ingest", str(csv), "--brain", str(brain), "--D", "2048", "--kn-cache", str(tmp_path / "kn")]) == 0
1311
+ assert cli.main(["ask", "who wrote hamlet", "--brain", str(brain), "--k", "1"]) == 0
1312
+ assert "william shakespeare" in capsys.readouterr().out
1313
+ assert cli.main(["status", "--brain", str(brain)]) == 0
1314
+ ```
1315
+
1316
+ - [x] **Step 2: Run tests to verify they fail**
1317
+
1318
+ Run: `python -m pytest tests/test_cli.py -v`
1319
+ Expected: FAIL — `ModuleNotFoundError: No module named 'vorax.agent.cli'`
1320
+
1321
+ - [x] **Step 3: Write minimal implementation**
1322
+
1323
+ ```python
1324
+ # vorax/agent/cli.py
1325
+ """CLI Vorax : ingest (il mange), ask (il répond), status (il compte)."""
1326
+ import argparse
1327
+ from pathlib import Path
1328
+
1329
+ from ..brain import Brain
1330
+ from ..compiler.kn import compile_kn, load_kn
1331
+ from ..hv import D_DEFAULT, SEED_DEFAULT
1332
+
1333
+
1334
+ def ingest(
1335
+ source: Path,
1336
+ brain_path: Path,
1337
+ D: int = D_DEFAULT,
1338
+ seed: int = SEED_DEFAULT,
1339
+ kn_cache: Path | None = None,
1340
+ ) -> int:
1341
+ cache = kn_cache if kn_cache is not None else brain_path.parent / "kn"
1342
+ kn_dir = cache / f"{source.stem}.kn"
1343
+ if not kn_dir.exists():
1344
+ compile_kn(source, kn_dir, D=D, seed=seed)
1345
+ atoms = load_kn(kn_dir)
1346
+ brain = Brain.load(brain_path) if (brain_path / "meta.json").exists() else Brain(D=D, seed=seed)
1347
+ n = brain.ingest_atoms(atoms)
1348
+ brain.save(brain_path)
1349
+ print(f"[VORAX] +{n} atomes ingérés depuis {source.name} → {brain_path} (total: {len(brain)})")
1350
+ return n
1351
+
1352
+
1353
+ def ask(question: str, brain_path: Path, k: int = 3) -> list[str]:
1354
+ if not (brain_path / "meta.json").exists():
1355
+ return ["[CARTE] LACUNES: aucune trace (brain vide ou absent)"]
1356
+ brain = Brain.load(brain_path)
1357
+ hits = brain.ask(question, k=k)
1358
+ if not hits:
1359
+ return ["[CARTE] LACUNES: aucune trace pour cette question"]
1360
+ lines: list[str] = []
1361
+ for atom, s in hits:
1362
+ lines.append(f"[CARTE] FAIT: {atom.statement} (sim {s:.2f} | src: {atom.provenance})")
1363
+ if atom.context:
1364
+ lines.append(f" RÉP: {atom.context}")
1365
+ return lines
1366
+
1367
+
1368
+ def status(brain_path: Path) -> str:
1369
+ if not (brain_path / "meta.json").exists():
1370
+ return "[VORAX] brain absent"
1371
+ brain = Brain.load(brain_path)
1372
+ return f"[VORAX] brain: {len(brain)} atomes, D={brain.D}, seed={brain.seed}"
1373
+
1374
+
1375
+ def main(argv: list[str] | None = None) -> int:
1376
+ parser = argparse.ArgumentParser(prog="vorax", description="Il ne s'entraîne plus : il mange.")
1377
+ sub = parser.add_subparsers(dest="cmd", required=True)
1378
+
1379
+ p_ing = sub.add_parser("ingest", help="compiler et ingérer une source")
1380
+ p_ing.add_argument("source", type=Path)
1381
+ p_ing.add_argument("--brain", type=Path, default=Path("./brain"))
1382
+ p_ing.add_argument("--D", type=int, default=D_DEFAULT)
1383
+ p_ing.add_argument("--seed", type=int, default=SEED_DEFAULT)
1384
+ p_ing.add_argument("--kn-cache", type=Path, default=None)
1385
+
1386
+ p_ask = sub.add_parser("ask", help="poser une question au brain")
1387
+ p_ask.add_argument("question", type=str)
1388
+ p_ask.add_argument("--brain", type=Path, default=Path("./brain"))
1389
+ p_ask.add_argument("--k", type=int, default=3)
1390
+
1391
+ p_st = sub.add_parser("status", help="état du brain")
1392
+ p_st.add_argument("--brain", type=Path, default=Path("./brain"))
1393
+
1394
+ args = parser.parse_args(argv)
1395
+ if args.cmd == "ingest":
1396
+ ingest(args.source, args.brain, D=args.D, seed=args.seed, kn_cache=args.kn_cache)
1397
+ elif args.cmd == "ask":
1398
+ for line in ask(args.question, args.brain, k=args.k):
1399
+ print(line)
1400
+ elif args.cmd == "status":
1401
+ print(status(args.brain))
1402
+ return 0
1403
+
1404
+
1405
+ if __name__ == "__main__":
1406
+ raise SystemExit(main())
1407
+ ```
1408
+
1409
+ - [x] **Step 4: Run tests to verify they pass**
1410
+
1411
+ Run: `python -m pytest tests/test_cli.py -v`
1412
+ Expected: PASS (4 passed)
1413
+
1414
+ - [x] **Step 5: Full suite + manual smoke, then commit**
1415
+
1416
+ Run: `python -m pytest -v`
1417
+ Expected: PASS (48 passed — suite complète à ce stade)
1418
+
1419
+ Run: `python -m vorax.agent.cli ingest /nonexistent 2>&1 || true` (sanity: argparse errors gracefully, no traceback panic)
1420
+ Then:
1421
+
1422
+ ```bash
1423
+ git add vorax/agent/cli.py tests/test_cli.py
1424
+ git commit -m "feat: CLI ingest/ask/status — end-to-end training-free expertise"
1425
+ ```
1426
+
1427
+ ---
1428
+
1429
+ ### Task 10: Bench smoke + README
1430
+
1431
+ **Files:**
1432
+ - Create: `bench/ingest_bench.py`
1433
+ - Create: `README.md`
1434
+ - Test: `tests/test_bench_smoke.py`
1435
+
1436
+ **Interfaces:**
1437
+ - Consumes: `compile_kn, load_kn` (Task 8), `Brain` (Task 7), `text_hv` (Task 5)
1438
+ - Produces:
1439
+ - `bench/ingest_bench.py` exécutable : `python bench/ingest_bench.py [--rows 2000] [--D 8192]` — génère un CSV QA déterministe, mesure : compilation (atomes/s), ingestion (atomes/s), 100 requêtes (ms/query moyenne) ; imprime un tableau
1440
+ - `generate_csv(path: Path, rows: int) -> int` (fonction réutilisable, rng seedée)
1441
+
1442
+ - [x] **Step 1: Write the failing test**
1443
+
1444
+ ```python
1445
+ # tests/test_bench_smoke.py
1446
+ import csv
1447
+ from pathlib import Path
1448
+
1449
+ from bench.ingest_bench import generate_csv
1450
+
1451
+
1452
+ def test_generate_csv_deterministic(tmp_path):
1453
+ p1, p2 = tmp_path / "a.csv", tmp_path / "b.csv"
1454
+ n1, n2 = generate_csv(p1, 50), generate_csv(p2, 50)
1455
+ assert n1 == n2 == 50
1456
+ assert p1.read_bytes() == p2.read_bytes()
1457
+ with open(p1, newline="", encoding="utf-8") as fh:
1458
+ assert sum(1 for _ in csv.reader(fh)) == 51 # header + 50
1459
+ ```
1460
+
1461
+ - [x] **Step 2: Run test to verify it fails**
1462
+
1463
+ Run: `python -m pytest tests/test_bench_smoke.py -v`
1464
+ Expected: FAIL — `ModuleNotFoundError: No module named 'bench'`
1465
+
1466
+ - [x] **Step 3: Write minimal implementation**
1467
+
1468
+ Note : pour que `from bench.ingest_bench import generate_csv` fonctionne depuis les tests à la racine du repo, ajouter un `conftest.py` à la racine (pytest ajoute la racine au `sys.path` quand un conftest s'y trouve).
1469
+
1470
+ ```python
1471
+ # conftest.py
1472
+ """Racine de repo sur sys.path pour que bench/ soit importable depuis les tests."""
1473
+ import sys
1474
+ from pathlib import Path
1475
+
1476
+ sys.path.insert(0, str(Path(__file__).parent))
1477
+ ```
1478
+
1479
+ ```python
1480
+ # bench/ingest_bench.py
1481
+ """Bench smoke du substrat : compilation, ingestion, requêtes — CPU pur."""
1482
+ import argparse
1483
+ import random
1484
+ import time
1485
+ from pathlib import Path
1486
+
1487
+ from vorax.brain import Brain
1488
+ from vorax.compiler.encode import text_hv
1489
+ from vorax.compiler.kn import compile_kn, load_kn
1490
+
1491
+ TOPICS = ["spain", "france", "japan", "chile", "nigeria", "sweden", "peru", "iran"]
1492
+ FACTS = [
1493
+ "the {t} economy grows by {p} percent",
1494
+ "the population of {t} is {n} million",
1495
+ "the main export of {t} is {e}",
1496
+ ]
1497
+ EXPORTS = ["copper", "oil", "fish", "wood", "wine", "wool"]
1498
+
1499
+
1500
+ def generate_csv(path: Path, rows: int) -> int:
1501
+ rng = random.Random(42)
1502
+ lines = ["statement,context"]
1503
+ for _ in range(rows):
1504
+ t = rng.choice(TOPICS)
1505
+ f = rng.choice(FACTS)
1506
+ stmt = f.format(t=t, p=rng.randint(1, 9), n=rng.randint(1, 400), e=rng.choice(EXPORTS))
1507
+ lines.append(f"{stmt},recorded fact number {rng.randint(1000, 9999)}")
1508
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
1509
+ return rows
1510
+
1511
+
1512
+ def main() -> None:
1513
+ parser = argparse.ArgumentParser()
1514
+ parser.add_argument("--rows", type=int, default=2000)
1515
+ parser.add_argument("--D", type=int, default=8192)
1516
+ args = parser.parse_args()
1517
+
1518
+ out = Path("bench/out")
1519
+ out.mkdir(parents=True, exist_ok=True)
1520
+ src = out / "synth.csv"
1521
+ generate_csv(src, args.rows)
1522
+
1523
+ t0 = time.perf_counter()
1524
+ kn = compile_kn(src, out / "synth.kn", D=args.D)
1525
+ t_compile = time.perf_counter() - t0
1526
+
1527
+ atoms = load_kn(kn)
1528
+ brain = Brain(D=args.D)
1529
+ t0 = time.perf_counter()
1530
+ brain.ingest_atoms(atoms)
1531
+ t_ingest = time.perf_counter() - t0
1532
+
1533
+ t0 = time.perf_counter()
1534
+ n_queries = 100
1535
+ for _ in range(n_queries):
1536
+ brain.traces.retrieve(text_hv("the population of france is 67 million", D=args.D), k=5)
1537
+ t_query_ms = (time.perf_counter() - t0) * 1000 / n_queries
1538
+
1539
+ print("+---------------------+-------------------+")
1540
+ print("| étape | mesure |")
1541
+ print("+---------------------+-------------------+")
1542
+ print(f"| compilation | {args.rows / t_compile:>10.0f} atomes/s |")
1543
+ print(f"| ingestion (écriture)| {args.rows / t_ingest:>10.0f} atomes/s |")
1544
+ print(f"| requête (top-5) | {t_query_ms:>10.1f} ms/q |")
1545
+ print("+---------------------+-------------------+")
1546
+
1547
+
1548
+ if __name__ == "__main__":
1549
+ main()
1550
+ ```
1551
+
1552
+ ```markdown
1553
+ # README.md
1554
+
1555
+ # Vorax
1556
+
1557
+ > Fractus ne s'entraîne plus : **il mange**.
1558
+
1559
+ Vorax convertit n'importe quel dataset courant (CSV, TSV, JSON, JSONL, TXT, MD)
1560
+ en atomes de connaissance compilés (`.kn`), puis les **ingère par écriture** dans
1561
+ une mémoire de traces hypervectorielles — O(1) par atome, zéro gradient, zéro
1562
+ GPU, jamais d'oubli catastrophique. La compétence linguistique naît une fois
1563
+ (frustes cordes vocales branchées au Plan 3) ; la connaissance, elle, s'écrit à
1564
+ l'infini.
1565
+
1566
+ **Statut** : Plan 1 — le substrat qui mange (compilateur `.kn`, organe traces,
1567
+ brain persistant, CLI).
1568
+
1569
+ ## Quickstart
1570
+
1571
+ ```bash
1572
+ python -m pip install -e ".[dev]"
1573
+ python -m pytest # suite complète
1574
+ python -m vorax.agent.cli ingest mes_donnees.csv --brain ./brain
1575
+ python -m vorax.agent.cli ask "what is the capital of japan" --brain ./brain
1576
+ python bench/ingest_bench.py # débit compilation/ingestion/requêtes
1577
+ ```
1578
+
1579
+ ## Le contrat sacré
1580
+
1581
+ 1. Aucun gradient, jamais, pour apprendre quoi que ce soit.
1582
+ 2. Les artefacts sont déterministes et bit-identiques (même source → même `.kn`).
1583
+ 3. La mémoire est append-only : on superpose, on n'écrase pas.
1584
+ 4. CPU d'abord ; le GPU est une option, pas une exigence.
1585
+
1586
+ Design : `docs/superpowers/specs/2026-08-17-vorax-design.md`
1587
+ Plan : `docs/superpowers/plans/2026-08-17-vorax-p1-substrat.md`
1588
+ ```
1589
+
1590
+ - [x] **Step 4: Run tests to verify they pass**
1591
+
1592
+ Run: `python -m pytest tests/test_bench_smoke.py -v` → PASS
1593
+ Run: `python -m pytest -v` → PASS (suite complète)
1594
+ Run: `python bench/ingest_bench.py --rows 500 --D 2048` → le tableau s'imprime avec des débits finis
1595
+
1596
+ - [x] **Step 5: Commit**
1597
+
1598
+ ```bash
1599
+ git add bench/ingest_bench.py tests/test_bench_smoke.py conftest.py README.md
1600
+ git commit -m "feat: ingest bench smoke + README (plan 1 complete)"
1601
+ ```
1602
+
1603
+ ---
1604
+
1605
+ ## Definition of Done — Plan 1
1606
+
1607
+ - [x] `python -m pytest -v` : toute la suite passe (49 tests)
1608
+ - [x] `python bench/ingest_bench.py` : débits mesurés et imprimés
1609
+ - [x] Démonstration manuelle : `ingest cap.csv` puis `ask` répond `tokyo` — **expertise instantanée, zéro gradient**
1610
+ - [x] Aucune modification hors de `C:\Users\PHIL\ZCodeProject\vorax\`
1611
+ - [x] `git log` : un commit par tâche, messages préfixés `feat:`
docs/heritage/plans/2026-08-18-vorax-p2-organes.md ADDED
@@ -0,0 +1,1133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Vorax Plan 2 — « Les organes et la généralisation » Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Donner à Vorax ce que Fractus n'a pas : généralisation par analogie (3CosAdd/3CosMul, slots char-ngram, filtre morphologique), organe hebbien à écriture en forme close avec porte ΔE et rollback, spawn d'experts routés (croissance physique), consolidation par co-activation — le tout intégré au Brain et au CLI sans casser les 55 tests du Plan 1.
6
+
7
+ **Architecture:** Quatre organes numpy purs autour du Brain existant : patterns/relations extraits par préfixe tokenique (slot = dernier token), analogie en espace HV avec slots encodés en char-3-grammes (robustesse aux typos + similarité morphologique), mémoire de Hopfield (complétion de motifs bruités, écritures externes additionnées, porte = rappel des sondes intact), registre d'experts par signature HV.
8
+
9
+ **Tech Stack:** Python ≥ 3.10, numpy + stdlib UNIQUEMENT (torch est réservé au Plan 3, isolé dans `vorax/model/`).
10
+
11
+ **Spec source :** `docs/superpowers/specs/2026-08-17-vorax-design.md` (D1-D7)
12
+
13
+ ## Global Constraints
14
+
15
+ - Python `>= 3.10` ; runtime : `numpy >= 1.24` + stdlib UNIQUEMENT — torch interdit dans ce plan
16
+ - Déterminisme absolu : jamais `hash()` python, jamais de timestamp dans les artefacts, clés JSON triées, gzip `mtime=0`, comptage par paires triées
17
+ - Append-only : on n'écrase jamais une trace ; le rollback hebbien est une soustraction de la dernière écriture, journalisée
18
+ - CPU-only ; D par défaut `8192` (D_h hebbien : `2048`), seed par défaut `0`
19
+ - Les 55 tests du Plan 1 doivent rester verts à chaque tâche (aucune rupture d'interface publique : `Brain.ask`, `ingest_atom(s)`, `save/load`, CLI `ingest/ask/status`)
20
+ - Tout code/identifiants en anglais, prose des docs en français
21
+ - TDD strict par tâche, un commit par tâche
22
+
23
+ ---
24
+
25
+ ### Task 1: Slots char-ngram (`vorax/compiler/encode.py` — modification)
26
+
27
+ **Files:**
28
+ - Modify: `vorax/compiler/encode.py`
29
+ - Test: `tests/test_encode.py` (ajouts)
30
+
31
+ **Interfaces:**
32
+ - Consumes: `hash_hv, bundle, D_DEFAULT, SEED_DEFAULT` (existants)
33
+ - Produces:
34
+ - `char_ngrams(text: str, n: int = 3) -> list[str]` — n-grammes de caractères sur `#{normalize(text)}#`
35
+ - `slot_hv(text: str, D: int = D_DEFAULT, seed: int = SEED_DEFAULT) -> np.ndarray` — bundle des HV des char-n-grammes (similarité morphologique : "france"/"franca" proches, "france"/"japan" orthogonaux)
36
+
37
+ - [x] **Step 1: Write the failing tests** (ajouter à `tests/test_encode.py`)
38
+
39
+ ```python
40
+ def test_char_ngrams_padded():
41
+ assert char_ngrams("ab") == ["#ab", "ab#"]
42
+
43
+ def test_char_ngrams_length_three():
44
+ assert char_ngrams("abcd") == ["#ab", "abc", "bcd", "cd#"]
45
+
46
+ def test_slot_hv_typo_similarity():
47
+ a = slot_hv("france", D=4096)
48
+ b = slot_hv("franca", D=4096)
49
+ c = slot_hv("japan", D=4096)
50
+ assert sim(a, b) > 0.4 # 3-grammes partagés
51
+ assert sim(a, c) < 0.2 # quasi-orthogonal
52
+
53
+ def test_slot_hv_deterministic():
54
+ assert np.array_equal(slot_hv("spain", D=2048), slot_hv("spain", D=2048))
55
+ ```
56
+
57
+ (importer `slot_hv, char_ngrams` depuis `vorax.compiler.encode` et `sim` depuis `vorax.hv` en tête de fichier de test.)
58
+
59
+ - [x] **Step 2: Run tests to verify they fail**
60
+
61
+ Run: `.venv/bin/python.exe -m pytest tests/test_encode.py -v`
62
+ Expected: FAIL — `ImportError: cannot import name 'char_ngrams'`
63
+
64
+ - [x] **Step 3: Write minimal implementation** (ajouter à `vorax/compiler/encode.py`)
65
+
66
+ ```python
67
+ def char_ngrams(text: str, n: int = 3) -> list[str]:
68
+ """Char n-grammes avec padding '#'. Sub-word fuzziness pour les slots."""
69
+ padded = f"#{normalize(text)}#"
70
+ if len(padded) <= n:
71
+ return [padded]
72
+ return [padded[i:i + n] for i in range(len(padded) - n + 1)]
73
+
74
+
75
+ def slot_hv(text: str, D: int = D_DEFAULT, seed: int = SEED_DEFAULT) -> np.ndarray:
76
+ """HV de slot : bundle des char-n-grammes — 'france' ~ 'franca', 'france' ⊥ 'japan'."""
77
+ return bundle([hash_hv(g, D, seed) for g in char_ngrams(text)])
78
+ ```
79
+
80
+ - [x] **Step 4: Run tests to verify they pass**
81
+
82
+ Run: `.venv/bin/python.exe -m pytest tests/test_encode.py -v`
83
+ Expected: PASS (11 passed : 7 anciennes + 4 nouvelles)
84
+
85
+ - [x] **Step 5: Commit**
86
+
87
+ ```bash
88
+ git add vorax/compiler/encode.py tests/test_encode.py
89
+ git commit -m "feat: char-ngram slot encoding — morphological slot similarity"
90
+ ```
91
+
92
+ ---
93
+
94
+ ### Task 2: Patterns, relations et analogie (`vorax/compiler/analogy.py`)
95
+
96
+ **Files:**
97
+ - Create: `vorax/compiler/analogy.py`
98
+ - Test: `tests/test_analogy.py`
99
+
100
+ **Interfaces:**
101
+ - Consumes: `tokenize, text_hv, slot_hv` (Task 1 / existant), `Atom`, `hash_hv, bundle, bind, sim`
102
+ - Produces:
103
+ - `Pattern = tuple[str, ...]` (tokens du préfixe)
104
+ - `extract_patterns(atoms: list[Atom], min_support: int = 3) -> dict[Pattern, dict[str, str]]` — slot (dernier token) → answer (context) pour chaque groupe de ≥ min_support énoncés partageant le même préfixe tokenique
105
+ - `match_pattern(query: str) -> tuple[Pattern, str] | None` — pattern = tous les tokens sauf le dernier, slot = dernier token (None si < 2 tokens)
106
+ - `solve_analogy(query: str, relation: dict[str, str], D: int = D_DEFAULT, seed: int = SEED_DEFAULT, k_neighbors: int = 5, method: str = "cosmul", min_sim: float = 0.2) -> list[tuple[str, float]]` — réponses candidates scorées par analogie sur les k slots connus les plus similaires (char-ngram) au slot requête ; exclut les candidats partageant un préfixe ≥ 5 chars avec le slot requête ; `method` ∈ `{"cosadd", "cosmul"}` ; `[]` si pattern inconnu, slot connu (c'est du retrieval, pas de l'analogie) ou top voisin < `min_sim`
107
+
108
+ - [x] **Step 1: Write the failing tests**
109
+
110
+ ```python
111
+ # tests/test_analogy.py
112
+ from vorax.compiler.analogy import extract_patterns, match_pattern, solve_analogy
113
+ from vorax.compiler.atoms import Atom
114
+
115
+ CAPITALS = [
116
+ Atom("what is the capital of france", "paris", "cap.csv:2"),
117
+ Atom("what is the capital of spain", "madrid", "cap.csv:3"),
118
+ Atom("what is the capital of japan", "tokyo", "cap.csv:4"),
119
+ Atom("what is the capital of italy", "rome", "cap.csv:5"),
120
+ Atom("who wrote hamlet", "william shakespeare", "books.csv:2"),
121
+ ]
122
+
123
+
124
+ def test_extract_patterns_min_support():
125
+ patterns = extract_patterns(CAPITALS, min_support=3)
126
+ assert ("what", "is", "the", "capital", "of") in patterns
127
+ assert ("who", "wrote") not in patterns # support 1 < 3
128
+ rel = patterns[("what", "is", "the", "capital", "of")]
129
+ assert rel["france"] == "paris" and rel["italy"] == "rome"
130
+
131
+
132
+ def test_match_pattern():
133
+ pattern, slot = match_pattern("What is the capital of Korea?")
134
+ assert pattern == ("what", "is", "the", "capital", "of")
135
+ assert slot == "korea"
136
+ assert match_pattern("hello") is None
137
+
138
+
139
+ def test_solve_analogy_typo_slot():
140
+ """Slot jamais vu mais morphologiquement proche d'un slot connu."""
141
+ patterns = extract_patterns(CAPITALS)
142
+ rel = patterns[("what", "is", "the", "capital", "of")]
143
+ hits = solve_analogy("what is the capital of franc", rel, D=4096)
144
+ assert hits and hits[0][0] == "paris"
145
+ assert hits[0][1] > 0.2
146
+
147
+
148
+ def test_solve_analogy_known_slot_returns_empty():
149
+ """Slot déjà connu = retrieval (job des traces), pas analogie."""
150
+ patterns = extract_patterns(CAPITALS)
151
+ rel = patterns[("what", "is", "the", "capital", "of")]
152
+ assert solve_analogy("what is the capital of france", rel, D=4096) == []
153
+
154
+
155
+ def test_solve_analogy_unknown_pattern_returns_empty():
156
+ assert solve_analogy("qui est le capitaine", {}, D=4096) == []
157
+
158
+
159
+ def test_morphological_filter():
160
+ """Candidat partageant un préfixe >= 5 chars avec le slot requête est exclu."""
161
+ rel = {"australia": "australian", "austria": "austrian", "bulgaria": "bulgarian",
162
+ "croatia": "croatian", "romania": "romanian"}
163
+ hits = solve_analogy("demonym of australian", rel, D=4096) # slot requete = australian
164
+ answers = [a for a, _ in hits]
165
+ assert "australian" not in answers # préfixe partagé avec le slot requête
166
+
167
+
168
+ def test_both_methods_agree_on_typo_case():
169
+ patterns = extract_patterns(CAPITALS)
170
+ rel = patterns[("what", "is", "the", "capital", "of")]
171
+ for method in ("cosadd", "cosmul"):
172
+ hits = solve_analogy("what is the capital of spane", rel, D=4096, method=method)
173
+ assert hits and hits[0][0] == "madrid"
174
+ ```
175
+
176
+ - [x] **Step 2: Run tests to verify they fail**
177
+
178
+ Run: `.venv/bin/python.exe -m pytest tests/test_analogy.py -v`
179
+ Expected: FAIL — `ModuleNotFoundError: No module named 'vorax.compiler.analogy'`
180
+
181
+ - [x] **Step 3: Write minimal implementation**
182
+
183
+ ```python
184
+ # vorax/compiler/analogy.py
185
+ """Généralisation sans gradient : patterns par préfixe, relations par comptage,
186
+ analogie 3CosAdd/3CosMul sur les slots char-ngram (voisins morphologiques)."""
187
+ import math
188
+
189
+ import numpy as np
190
+
191
+ from ..hv import D_DEFAULT, SEED_DEFAULT, sim
192
+ from .atoms import Atom
193
+ from .encode import slot_hv, text_hv, tokenize
194
+
195
+ Pattern = tuple[str, ...]
196
+ MORPHO_PREFIX = 5
197
+
198
+
199
+ def extract_patterns(atoms: list[Atom], min_support: int = 3) -> dict[Pattern, dict[str, str]]:
200
+ """Groupes d'énoncés partageant le même préfixe tokenique (slot = dernier token)."""
201
+ groups: dict[Pattern, dict[str, str]] = {}
202
+ counts: dict[Pattern, int] = {}
203
+ for atom in atoms:
204
+ tokens = tokenize(atom.statement)
205
+ if len(tokens) < 2 or not atom.context:
206
+ continue
207
+ pattern, slot = tuple(tokens[:-1]), tokens[-1]
208
+ groups.setdefault(pattern, {})[slot] = atom.context
209
+ counts[pattern] = counts.get(pattern, 0) + 1
210
+ return {p: rel for p, rel in groups.items() if counts[p] >= min_support}
211
+
212
+
213
+ def match_pattern(query: str) -> tuple[Pattern, str] | None:
214
+ tokens = tokenize(query)
215
+ if len(tokens) < 2:
216
+ return None
217
+ return tuple(tokens[:-1]), tokens[-1]
218
+
219
+
220
+ def _shares_prefix(a: str, b: str, n: int = MORPHO_PREFIX) -> bool:
221
+ return a[:n] == b[:n] and len(a) >= n and len(b) >= n
222
+
223
+
224
+ def solve_analogy(
225
+ query: str,
226
+ relation: dict[str, str],
227
+ D: int = D_DEFAULT,
228
+ seed: int = SEED_DEFAULT,
229
+ k_neighbors: int = 5,
230
+ method: str = "cosmul",
231
+ min_sim: float = 0.2,
232
+ ) -> list[tuple[str, float]]:
233
+ matched = match_pattern(query)
234
+ if matched is None:
235
+ return []
236
+ _pattern, slot_q = matched
237
+ if slot_q in relation:
238
+ return [] # slot connu : c'est du retrieval, pas de l'analogie
239
+ # voisins morphologiques (char-ngram) les plus proches
240
+ hq = slot_hv(slot_q, D, seed)
241
+ known = sorted(relation) # déterminisme
242
+ scored = sorted(((sim(hq, slot_hv(s, D, seed)), s) for s in known), reverse=True)
243
+ neighbors = [(s, w) for w, s in scored[:k_neighbors] if w >= min_sim]
244
+ if not neighbors:
245
+ return []
246
+ # candidats = réponses des voisins (unicité, ordre trié)
247
+ candidates = sorted({relation[s] for s, _ in neighbors})
248
+ cand_hvs = {c: text_hv(c, D, seed) for c in candidates}
249
+ ans_hvs = {s: text_hv(relation[s], D, seed) for s, _ in neighbors}
250
+ results: list[tuple[str, float]] = []
251
+ for cand, ch in cand_hvs.items():
252
+ if _shares_prefix(cand, slot_q):
253
+ continue # filtre morphologique
254
+ if method == "cosadd":
255
+ score = sum(w * sim(ch, ans_hvs[s]) for s, w in neighbors)
256
+ else: # cosmul : cosinus remappés [0,1], moyenne géométrique pondérée
257
+ log_sum = sum(w * math.log((sim(ch, ans_hvs[s]) + 1.0) / 2.0) for s, w in neighbors)
258
+ total_w = sum(w for _, w in neighbors)
259
+ score = math.exp(log_sum / total_w) if total_w > 0 else 0.0
260
+ if score >= min_sim:
261
+ results.append((cand, float(score)))
262
+ results.sort(key=lambda x: (-x[1], x[0]))
263
+ return results
264
+ ```
265
+
266
+ - [x] **Step 4: Run tests to verify they pass**
267
+
268
+ Run: `.venv/bin/python.exe -m pytest tests/test_analogy.py -v`
269
+ Expected: PASS (7 passed) puis `.venv/bin/python.exe -m pytest -q` → 62 passed (aucune régression)
270
+
271
+ - [x] **Step 5: Commit**
272
+
273
+ ```bash
274
+ git add vorax/compiler/analogy.py tests/test_analogy.py
275
+ git commit -m "feat: pattern extraction, relations and 3CosAdd/3CosMul analogy with morphological filter"
276
+ ```
277
+
278
+ ---
279
+
280
+ ### Task 3: Organe hebbien (`vorax/organs/hebbian.py`)
281
+
282
+ **Files:**
283
+ - Create: `vorax/organs/hebbian.py`
284
+ - Test: `tests/test_hebbian.py`
285
+
286
+ **Interfaces:**
287
+ - Consumes: `pack, unpack`
288
+ - Produces:
289
+ - `HebbianMemory(D_h: int = 2048)` avec :
290
+ - `write(key: np.ndarray, value: np.ndarray) -> int` (index d'écriture, M += outer(key, value))
291
+ - `write_gated(key, value, probes: list[tuple[np.ndarray, np.ndarray]]) -> bool` — écrit, vérifie que chaque sonde `complete(k) == v` tient ; sinon rollback et `False`
292
+ - `complete(query: np.ndarray) -> np.ndarray` — `sign(M @ query)`, égalités → +1
293
+ - `rollback() -> bool` (annule la dernière écriture)
294
+ - `n_writes -> int`, `__len__` = n_writes
295
+ - `save(path: Path)` / `HebbianMemory.load(path: Path)` (npz int32 + meta.json, clé `n_writes`, `D_h`)
296
+
297
+ - [x] **Step 1: Write the failing tests**
298
+
299
+ ```python
300
+ # tests/test_hebbian.py
301
+ from pathlib import Path
302
+
303
+ import numpy as np
304
+
305
+ from vorax.hv import hash_hv, sim, unpack, pack
306
+ from vorax.organs.hebbian import HebbianMemory
307
+
308
+
309
+ def _pair(text: str, D: int = 2048):
310
+ return hash_hv(f"key:{text}", D), hash_hv(f"val:{text}", D)
311
+
312
+
313
+ def test_write_and_complete():
314
+ mem = HebbianMemory(D_h=2048)
315
+ pairs = [_pair(t) for t in ("france", "spain", "japan")]
316
+ for k, v in pairs:
317
+ mem.write(k, v)
318
+ for k, v in pairs:
319
+ assert np.array_equal(mem.complete(k), v)
320
+
321
+
322
+ def test_complete_noise_robustness():
323
+ mem = HebbianMemory(D_h=2048)
324
+ k, v = _pair("attractor")
325
+ mem.write(k, v)
326
+ rng = np.random.default_rng(42)
327
+ flip = rng.choice(2048, size=100, replace=False) # ~5% de bruit
328
+ noisy = k.copy()
329
+ noisy[flip] *= -1
330
+ assert np.array_equal(mem.complete(noisy), v)
331
+
332
+
333
+ def test_gate_rejects_degrading_write():
334
+ mem = HebbianMemory(D_h=2048)
335
+ k1, v1 = _pair("un")
336
+ k2, v2 = _pair("deux")
337
+ assert mem.write_gated(k1, v1, probes=[]) is True
338
+ # écrire (k1, -v1) dégraderait la sonde (k1 -> v1)
339
+ assert mem.write_gated(k1, -v1, probes=[(k1, v1)]) is False
340
+ assert mem.n_writes == 1
341
+ assert np.array_equal(mem.complete(k1), v1) # rien n'a bougé
342
+
343
+
344
+ def test_rollback():
345
+ mem = HebbianMemory(D_h=2048)
346
+ k, v = _pair("garder")
347
+ k2, v2 = _pair("jeter")
348
+ mem.write(k, v)
349
+ mem.write(k2, v2)
350
+ assert mem.rollback() is True
351
+ assert mem.n_writes == 1
352
+ assert np.array_equal(mem.complete(k), v)
353
+
354
+
355
+ def test_save_load_roundtrip(tmp_path):
356
+ mem = HebbianMemory(D_h=2048)
357
+ for t in ("a", "b", "c"):
358
+ mem.write(*_pair(t))
359
+ mem.save(tmp_path / "heb")
360
+ loaded = HebbianMemory.load(tmp_path / "heb")
361
+ assert loaded.n_writes == 3
362
+ k, v = _pair("b")
363
+ assert np.array_equal(loaded.complete(k), v)
364
+ ```
365
+
366
+ - [x] **Step 2: Run tests to verify they fail**
367
+
368
+ Run: `.venv/bin/python.exe -m pytest tests/test_hebbian.py -v`
369
+ Expected: FAIL — `ModuleNotFoundError: No module named 'vorax.organs.hebbian'`
370
+
371
+ - [x] **Step 3: Write minimal implementation**
372
+
373
+ ```python
374
+ # vorax/organs/hebbian.py
375
+ """Organe 2 — cortex hebbien : mémoire associative en forme close.
376
+ Écriture = produit externe additionné (jamais un gradient), complétion = sign(M @ q).
377
+ Porte ΔE : une écriture qui casse une sonde existante est refusée et annulée."""
378
+ import json
379
+ from pathlib import Path
380
+
381
+ import numpy as np
382
+
383
+ from ..hv import pack, unpack
384
+
385
+ D_H_DEFAULT = 2048
386
+
387
+
388
+ class HebbianMemory:
389
+ def __init__(self, D_h: int = D_H_DEFAULT) -> None:
390
+ self.D_h = D_h
391
+ self.M = np.zeros((D_h, D_h), dtype=np.int32)
392
+ self._ledger: list[tuple[np.ndarray, np.ndarray]] = []
393
+
394
+ @property
395
+ def n_writes(self) -> int:
396
+ return len(self._ledger)
397
+
398
+ def __len__(self) -> int:
399
+ return len(self._ledger)
400
+
401
+ def write(self, key: np.ndarray, value: np.ndarray) -> int:
402
+ self.M += np.outer(key.astype(np.int32), value.astype(np.int32))
403
+ self._ledger.append((key, value))
404
+ return len(self._ledger) - 1
405
+
406
+ def write_gated(
407
+ self, key: np.ndarray, value: np.ndarray,
408
+ probes: list[tuple[np.ndarray, np.ndarray]],
409
+ ) -> bool:
410
+ self.write(key, value)
411
+ if all(np.array_equal(self.complete(pk), pv) for pk, pv in probes):
412
+ return True
413
+ self.rollback()
414
+ return False
415
+
416
+ def complete(self, query: np.ndarray) -> np.ndarray:
417
+ acc = self.M @ query.astype(np.int32)
418
+ return np.where(acc >= 0, 1, -1).astype(np.int8)
419
+
420
+ def rollback(self) -> bool:
421
+ if not self._ledger:
422
+ return False
423
+ key, value = self._ledger.pop()
424
+ self.M -= np.outer(key.astype(np.int32), value.astype(np.int32))
425
+ return True
426
+
427
+ def save(self, path: Path) -> None:
428
+ path.mkdir(parents=True, exist_ok=True)
429
+ n = len(self._ledger)
430
+ n_bytes = self.D_h // 8
431
+ if n:
432
+ keys = np.stack([pack(k) for k, _ in self._ledger])
433
+ values = np.stack([pack(v) for _, v in self._ledger])
434
+ else:
435
+ keys = np.zeros((0, n_bytes), dtype=np.uint8)
436
+ values = keys.copy()
437
+ np.savez_compressed(path / "hebbian.npz", M=self.M, keys=keys, values=values)
438
+ (path / "hebbian_meta.json").write_text(
439
+ json.dumps({"D_h": self.D_h, "n_writes": n}, sort_keys=True), encoding="utf-8"
440
+ )
441
+
442
+ @classmethod
443
+ def load(cls, path: Path) -> "HebbianMemory":
444
+ meta = json.loads((path / "hebbian_meta.json").read_text(encoding="utf-8"))
445
+ mem = cls(D_h=int(meta["D_h"]))
446
+ data = np.load(path / "hebbian.npz")
447
+ mem.M = data["M"].astype(np.int32)
448
+ for i in range(int(meta["n_writes"])):
449
+ key = unpack(data["keys"][i], mem.D_h)
450
+ value = unpack(data["values"][i], mem.D_h)
451
+ mem._ledger.append((key, value))
452
+ return mem
453
+ ```
454
+
455
+ - [x] **Step 4: Run tests to verify they pass**
456
+
457
+ Run: `.venv/bin/python.exe -m pytest tests/test_hebbian.py -v`
458
+ Expected: PASS (5 passed) puis `.venv/bin/python.exe -m pytest -q` → 67 passed
459
+
460
+ - [x] **Step 5: Commit**
461
+
462
+ ```bash
463
+ git add vorax/organs/hebbian.py tests/test_hebbian.py
464
+ git commit -m "feat: hebbian organ — closed-form outer-product memory with delta-E gate and rollback"
465
+ ```
466
+
467
+ ---
468
+
469
+ ### Task 4: Spawn d'experts (`vorax/organs/spawn.py`)
470
+
471
+ **Files:**
472
+ - Create: `vorax/organs/spawn.py`
473
+ - Test: `tests/test_spawn.py`
474
+
475
+ **Interfaces:**
476
+ - Consumes: `pack, unpack, sim`
477
+ - Produces:
478
+ - `Expert` dataclass frozen : `name: str`, `atom_start: int`, `atom_end: int`, `n_atoms: int` (`signature` stockée à part dans le registre)
479
+ - `ExpertRegistry()` avec :
480
+ - `spawn(name: str, signature: np.ndarray, atom_start: int, atom_end: int) -> Expert`
481
+ - `route(query_hv: np.ndarray, top: int = 2) -> list[tuple[str, float]]` — sims aux signatures, tri décroissant puis nom
482
+ - `get(name: str) -> Expert | None`, `__len__`
483
+ - `save(path: Path)` / `ExpertRegistry.load(path: Path)` (`experts.json` trié + `signatures.npz` packées)
484
+
485
+ - [x] **Step 1: Write the failing tests**
486
+
487
+ ```python
488
+ # tests/test_spawn.py
489
+ from vorax.hv import bundle, hash_hv
490
+ from vorax.organs.spawn import ExpertRegistry
491
+
492
+ CAP_TOKENS = ["what", "is", "the", "capital", "of"]
493
+ BOOK_TOKENS = ["who", "wrote"]
494
+
495
+
496
+ def _sig(tokens, D=2048):
497
+ return bundle([hash_hv(t, D) for t in tokens])
498
+
499
+
500
+ def test_spawn_and_route():
501
+ reg = ExpertRegistry()
502
+ reg.spawn("capitals", _sig(CAP_TOKENS), 0, 4)
503
+ reg.spawn("books", _sig(BOOK_TOKENS), 5, 9)
504
+ assert len(reg) == 2
505
+ hits = reg.route(_sig(["what", "is", "the", "capital", "of", "germany"]))
506
+ assert hits[0][0] == "capitals"
507
+ assert hits[0][1] > 0.5
508
+
509
+
510
+ def test_route_empty():
511
+ assert ExpertRegistry().route(_sig(CAP_TOKENS)) == []
512
+
513
+
514
+ def test_get():
515
+ reg = ExpertRegistry()
516
+ e = reg.spawn("capitals", _sig(CAP_TOKENS), 0, 4)
517
+ assert reg.get("capitals") == e
518
+ assert reg.get("nope") is None
519
+
520
+
521
+ def test_save_load_roundtrip(tmp_path):
522
+ reg = ExpertRegistry()
523
+ reg.spawn("capitals", _sig(CAP_TOKENS), 0, 4)
524
+ reg.spawn("books", _sig(BOOK_TOKENS), 5, 9)
525
+ reg.save(tmp_path / "experts")
526
+ loaded = ExpertRegistry.load(tmp_path / "experts")
527
+ assert len(loaded) == 2
528
+ hits = loaded.route(_sig(["who", "wrote", "hamlet"]))
529
+ assert hits[0][0] == "books"
530
+ ```
531
+
532
+ - [x] **Step 2: Run tests to verify they fail**
533
+
534
+ Run: `.venv/bin/python.exe -m pytest tests/test_spawn.py -v`
535
+ Expected: FAIL — `ModuleNotFoundError: No module named 'vorax.organs.spawn'`
536
+
537
+ - [x] **Step 3: Write minimal implementation**
538
+
539
+ ```python
540
+ # vorax/organs/spawn.py
541
+ """Organe 3 — croissance : chaque source ingérée devient un expert routé par
542
+ sa signature HV. Le modèle grossit physiquement, sans entraînement conjoint."""
543
+ import json
544
+ from dataclasses import dataclass
545
+ from pathlib import Path
546
+
547
+ import numpy as np
548
+
549
+ from ..hv import pack, sim, unpack
550
+
551
+ D_SIG = 8192 # les signatures vivent dans l'espace D du brain
552
+
553
+
554
+ @dataclass(frozen=True)
555
+ class Expert:
556
+ name: str
557
+ atom_start: int
558
+ atom_end: int
559
+ n_atoms: int
560
+
561
+
562
+ class ExpertRegistry:
563
+ def __init__(self) -> None:
564
+ self.experts: dict[str, Expert] = {}
565
+ self.signatures: dict[str, np.ndarray] = {}
566
+
567
+ def __len__(self) -> int:
568
+ return len(self.experts)
569
+
570
+ def spawn(self, name: str, signature: np.ndarray, atom_start: int, atom_end: int) -> Expert:
571
+ expert = Expert(name, atom_start, atom_end, atom_end - atom_start + 1)
572
+ self.experts[name] = expert
573
+ self.signatures[name] = signature
574
+ return expert
575
+
576
+ def get(self, name: str) -> Expert | None:
577
+ return self.experts.get(name)
578
+
579
+ def route(self, query_hv: np.ndarray, top: int = 2) -> list[tuple[str, float]]:
580
+ if not self.experts:
581
+ return []
582
+ scored = [(name, sim(query_hv, sig)) for name, sig in self.signatures.items()]
583
+ scored.sort(key=lambda x: (-x[1], x[0]))
584
+ return scored[:top]
585
+
586
+ def save(self, path: Path) -> None:
587
+ path.mkdir(parents=True, exist_ok=True)
588
+ payload = [
589
+ {"name": e.name, "atom_start": e.atom_start, "atom_end": e.atom_end, "n_atoms": e.n_atoms}
590
+ for e in sorted(self.experts.values(), key=lambda e: e.name)
591
+ ]
592
+ (path / "experts.json").write_text(
593
+ json.dumps(payload, sort_keys=True, indent=2), encoding="utf-8"
594
+ )
595
+ names = sorted(self.signatures)
596
+ bits = [pack(self.signatures[n]) for n in names]
597
+ np.savez_compressed(
598
+ path / "signatures.npz",
599
+ names=np.array(names),
600
+ bits=np.stack(bits) if bits else np.array([], dtype=np.uint8),
601
+ )
602
+
603
+ @classmethod
604
+ def load(cls, path: Path) -> "ExpertRegistry":
605
+ reg = cls()
606
+ for item in json.loads((path / "experts.json").read_text(encoding="utf-8")):
607
+ reg.experts[item["name"]] = Expert(
608
+ item["name"], item["atom_start"], item["atom_end"], item["n_atoms"]
609
+ )
610
+ data = np.load(path / "signatures.npz", allow_pickle=False)
611
+ names = [str(n) for n in data["names"]]
612
+ if names:
613
+ n_bytes = data["bits"].shape[1]
614
+ D = n_bytes * 8
615
+ for i, name in enumerate(names):
616
+ reg.signatures[name] = unpack(data["bits"][i], D)
617
+ return reg
618
+ ```
619
+
620
+ - [x] **Step 4: Run tests to verify they pass**
621
+
622
+ Run: `.venv/bin/python.exe -m pytest tests/test_spawn.py -v`
623
+ Expected: PASS (4 passed) puis `.venv/bin/python.exe -m pytest -q` → 71 passed
624
+
625
+ - [x] **Step 5: Commit**
626
+
627
+ ```bash
628
+ git add vorax/organs/spawn.py tests/test_spawn.py
629
+ git commit -m "feat: expert spawn organ — signature-routed growth, no joint training"
630
+ ```
631
+
632
+ ---
633
+
634
+ ### Task 5: Consolidation (`vorax/organs/consolidation.py`)
635
+
636
+ **Files:**
637
+ - Create: `vorax/organs/consolidation.py`
638
+ - Test: `tests/test_consolidation.py`
639
+
640
+ **Interfaces:**
641
+ - Consumes: rien (numpy/stdlib)
642
+ - Produces:
643
+ - `CoActivation()` avec :
644
+ - `record(atom_ids: list[int]) -> None` — incrémente les paires **triées** `(min,max)` co-retrouvées (max 25 paires par appel, k_retrieve ≤ 5)
645
+ - `promote(threshold: int = 3) -> list[tuple[int, int]]` — paires atteignant le seuil, triées (count décroissant, puis paire)
646
+ - `stats() -> dict` (`{"pairs": n, "max_count": c}`)
647
+ - `save(path: Path)` / `CoActivation.load(path: Path)` (`coactivation.json` : paires `"i|j": count`, clés triées)
648
+
649
+ - [x] **Step 1: Write the failing tests**
650
+
651
+ ```python
652
+ # tests/test_consolidation.py
653
+ from vorax.organs.consolidation import CoActivation
654
+
655
+
656
+ def test_record_and_promote():
657
+ co = CoActivation()
658
+ for _ in range(3):
659
+ co.record([0, 1, 2]) # paires (0,1) (0,2) (1,2) x3
660
+ co.record([7, 8]) # x1
661
+ pairs = co.promote(threshold=3)
662
+ assert set(pairs) == {(0, 1), (0, 2), (1, 2)}
663
+ assert (7, 8) not in pairs
664
+
665
+
666
+ def test_order_independent():
667
+ co = CoActivation()
668
+ co.record([2, 0])
669
+ co.record([0, 2])
670
+ assert co.promote(threshold=2) == [(0, 2)]
671
+
672
+
673
+ def test_stats_and_roundtrip(tmp_path):
674
+ co = CoActivation()
675
+ for _ in range(4):
676
+ co.record([3, 4])
677
+ co.save(tmp_path / "co")
678
+ loaded = CoActivation.load(tmp_path / "co")
679
+ assert loaded.promote(threshold=4) == [(3, 4)]
680
+ assert loaded.stats()["pairs"] == 1
681
+ ```
682
+
683
+ - [x] **Step 2: Run tests to verify they fail**
684
+
685
+ Run: `.venv/bin/python.exe -m pytest tests/test_consolidation.py -v`
686
+ Expected: FAIL — `ModuleNotFoundError: No module named 'vorax.organs.consolidation'`
687
+
688
+ - [x] **Step 3: Write minimal implementation**
689
+
690
+ ```python
691
+ # vorax/organs/consolidation.py
692
+ """Consolidation : les atomes co-retrouvés souvent se lient en concepts composites.
693
+ Comptage append-only, paires triées, déterminisme total."""
694
+ import json
695
+ from itertools import combinations
696
+ from pathlib import Path
697
+
698
+
699
+ class CoActivation:
700
+ def __init__(self) -> None:
701
+ self.counts: dict[tuple[int, int], int] = {}
702
+
703
+ def record(self, atom_ids: list[int]) -> None:
704
+ ids = sorted(set(atom_ids))
705
+ for a, b in combinations(ids, 2):
706
+ self.counts[(a, b)] = self.counts.get((a, b), 0) + 1
707
+
708
+ def promote(self, threshold: int = 3) -> list[tuple[int, int]]:
709
+ pairs = [(pair, c) for pair, c in self.counts.items() if c >= threshold]
710
+ pairs.sort(key=lambda x: (-x[1], x[0]))
711
+ return [pair for pair, _ in pairs]
712
+
713
+ def stats(self) -> dict:
714
+ max_count = max(self.counts.values(), default=0)
715
+ return {"pairs": len(self.counts), "max_count": max_count}
716
+
717
+ def save(self, path: Path) -> None:
718
+ path.mkdir(parents=True, exist_ok=True)
719
+ payload = {f"{a}|{b}": c for (a, b), c in sorted(self.counts.items())}
720
+ (path / "coactivation.json").write_text(
721
+ json.dumps(payload, sort_keys=True), encoding="utf-8"
722
+ )
723
+
724
+ @classmethod
725
+ def load(cls, path: Path) -> "CoActivation":
726
+ co = cls()
727
+ for key, count in json.loads((path / "coactivation.json").read_text(encoding="utf-8")).items():
728
+ a, b = key.split("|")
729
+ co.counts[(int(a), int(b))] = int(count)
730
+ return co
731
+ ```
732
+
733
+ - [x] **Step 4: Run tests to verify they pass**
734
+
735
+ Run: `.venv/bin/python.exe -m pytest tests/test_consolidation.py -v`
736
+ Expected: PASS (3 passed) puis `.venv/bin/python.exe -m pytest -q` → 74 passed
737
+
738
+ - [x] **Step 5: Commit**
739
+
740
+ ```bash
741
+ git add vorax/organs/consolidation.py tests/test_consolidation.py
742
+ git commit -m "feat: co-activation consolidation — concepts from co-retrieval, no gradients"
743
+ ```
744
+
745
+ ---
746
+
747
+ ### Task 6: Brain v2 — intégration des organes (`vorax/brain.py` — modification)
748
+
749
+ **Files:**
750
+ - Modify: `vorax/brain.py`
751
+ - Create: `vorax/cards.py`
752
+ - Test: `tests/test_brain_v2.py`
753
+
754
+ **Interfaces:**
755
+ - Consumes: tous les organes (Tasks 1-5), `bind` de hv
756
+ - Produces:
757
+ - `Card` dataclass frozen (`vorax/cards.py`) : `kind: str` ∈ `{"FAIT", "ANALOGIE", "LACUNE"}`, `text: str`, `sim: float`, `source: str`
758
+ - Brain étend (compatibilité Plan 1 **strictement préservée** — `ask()`, `ingest_atom`, `save/load` existants gardent leur signature et comportement) :
759
+ - attributs nouveaux : `experts: ExpertRegistry`, `hebbian: HebbianMemory`, `coact: CoActivation`, `relations: dict[Pattern, dict[str, str]]`
760
+ - `ingest_source(name: str, atoms: list[Atom]) -> int` — ingère les atomes (via `ingest_atom`), apprend/marge les relations du batch (`extract_patterns(atoms, min_support=3)`), écrit hebbien `write_gated(addr, value, probes=8 dernières paires)` (addr/value à D_h=2048 re-hashés : `hash_hv` du statement/context — **pas** les HV D=8192 pour tenir la matrice), et spawn un expert nommé `name` avec signature = signe majoritaire des addr du batch
761
+ - `ask_cards(question: str, k: int = 3) -> list[Card]` — routes (`experts.route`), retrieve traces, record coact, puis si meilleur hit < 0.5 tente `solve_analogy` sur toutes les relations (pattern matché) ; retourne cartes FAIT (hits) + ANALOGIE (top 1-2) + éventuellement LACUNE ; `source` des cartes = provenance ou nom d'expert routé en tête
762
+ - `consolidate(threshold: int = 3) -> int` — promeut les paires co-activées en atomes composites `({a.statement}) & ({b.statement})` (context = a.context si non vide sinon b.context, provenance `"consolidated"`, confidence 0.8), addr encodé par `bind(addr_a, addr_b)` écrit **directement** dans les traces (nouveau `TraceStore.write_raw(atom_id, addr, value, weight)` — à ajouter : même comportement que `write` sans ré-encodage), retourne le nombre promu
763
+ - `save/load` étendus : fichiers additionnels `experts/`, `hebbian/`, `coactivation.json`, `relations.json` (patterns → {slot: answer}, clés `"|".join(pattern)` triées) ; `load` rétro-compatible : fichiers absents (brain Plan 1) → organes vides
764
+
765
+ - [x] **Step 1: Write the failing tests**
766
+
767
+ ```python
768
+ # tests/test_brain_v2.py
769
+ from vorax.brain import Brain
770
+ from vorax.cards import Card
771
+ from vorax.compiler.atoms import Atom
772
+
773
+ CAPITALS = [
774
+ Atom("what is the capital of france", "paris", "cap.csv:2"),
775
+ Atom("what is the capital of spain", "madrid", "cap.csv:3"),
776
+ Atom("what is the capital of japan", "tokyo", "cap.csv:4"),
777
+ Atom("what is the capital of italy", "rome", "cap.csv:5"),
778
+ ]
779
+ BOOKS = [Atom("who wrote hamlet", "william shakespeare", "books.csv:2")]
780
+
781
+
782
+ def _brain():
783
+ brain = Brain(D=2048)
784
+ brain.ingest_source("capitals", CAPITALS)
785
+ brain.ingest_source("books", BOOKS)
786
+ return brain
787
+
788
+
789
+ def test_ingest_source_spawns_expert_and_relations():
790
+ brain = _brain()
791
+ assert len(brain.experts) == 2
792
+ assert brain.experts.get("capitals") is not None
793
+ assert any("capital" in " ".join(p) for p in brain.relations)
794
+ assert brain.hebbian.n_writes >= 5
795
+
796
+
797
+ def test_ask_cards_fact():
798
+ brain = _brain()
799
+ cards = brain.ask_cards("what is the capital of japan", k=2)
800
+ facts = [c for c in cards if c.kind == "FAIT"]
801
+ assert facts and "tokyo" in facts[0].text
802
+ assert facts[0].sim > 0.5
803
+
804
+
805
+ def test_ask_cards_analogy_on_typo():
806
+ brain = _brain()
807
+ cards = brain.ask_cards("what is the capital of franc", k=3)
808
+ ana = [c for c in cards if c.kind == "ANALOGIE"]
809
+ assert ana and "paris" in ana[0].text
810
+
811
+
812
+ def test_consolidate_creates_composite():
813
+ brain = _brain()
814
+ for _ in range(3):
815
+ brain.ask_cards("what is the capital of france", k=2)
816
+ n0 = len(brain)
817
+ promoted = brain.consolidate(threshold=3)
818
+ assert promoted >= 1
819
+ assert len(brain) == n0 + promoted
820
+ comp = brain.atoms[-1]
821
+ assert comp.provenance == "consolidated"
822
+
823
+
824
+ def test_plan1_compat_ask_still_works():
825
+ brain = _brain()
826
+ hits = brain.ask("what is the capital of spain", k=1)
827
+ assert hits[0][0].context == "madrid"
828
+
829
+
830
+ def test_save_load_roundtrip_v2(tmp_path):
831
+ brain = _brain()
832
+ brain.save(tmp_path / "brain")
833
+ loaded = Brain.load(tmp_path / "brain")
834
+ assert len(loaded.experts) == 2
835
+ assert loaded.hebbian.n_writes == brain.hebbian.n_writes
836
+ assert loaded.ask("who wrote hamlet", k=1)[0][0].context == "william shakespeare"
837
+
838
+
839
+ def test_load_plan1_brain_still_works(tmp_path):
840
+ """Un brain Plan 1 (sans organes persistés) doit se charger."""
841
+ brain = Brain(D=2048)
842
+ brain.ingest_atoms(CAPITALS)
843
+ brain.save(tmp_path / "old")
844
+ # simuler l'absence des fichiers v2
845
+ import shutil
846
+ for sub in ("experts", "hebbian"):
847
+ shutil.rmtree(tmp_path / "old" / sub, ignore_errors=True)
848
+ (tmp_path / "old" / "coactivation.json").unlink(missing_ok=True)
849
+ (tmp_path / "old" / "relations.json").unlink(missing_ok=True)
850
+ loaded = Brain.load(tmp_path / "old")
851
+ assert len(loaded) == 4
852
+ assert len(loaded.experts) == 0
853
+ ```
854
+
855
+ - [x] **Step 2: Run tests to verify they fail**
856
+
857
+ Run: `.venv/bin/python.exe -m pytest tests/test_brain_v2.py -v`
858
+ Expected: FAIL — `ImportError: cannot import name 'Card'` (et `ingest_source` manquant)
859
+
860
+ - [x] **Step 3: Write minimal implementation**
861
+
862
+ ```python
863
+ # vorax/cards.py
864
+ """Carte de connaissance : l'unité que le noyau fractus lira (Plan 3)."""
865
+ from dataclasses import dataclass
866
+
867
+
868
+ @dataclass(frozen=True)
869
+ class Card:
870
+ kind: str # "FAIT" | "ANALOGIE" | "LACUNE"
871
+ text: str
872
+ sim: float
873
+ source: str
874
+ ```
875
+
876
+ Modification de `vorax/brain.py` (ajouts — ne rien casser) :
877
+
878
+ ```python
879
+ # --- nouveaux imports en tête ---
880
+ from .cards import Card
881
+ from .compiler.analogy import Pattern, extract_patterns, match_pattern, solve_analogy
882
+ from .hv import bind, hash_hv
883
+ from .organs.consolidation import CoActivation
884
+ from .organs.hebbian import HebbianMemory
885
+ from .organs.spawn import ExpertRegistry
886
+
887
+ # --- __init__ étend (après l'existant) ---
888
+ self.experts = ExpertRegistry()
889
+ self.hebbian = HebbianMemory()
890
+ self.coact = CoActivation()
891
+ self.relations: dict[Pattern, dict[str, str]] = {}
892
+
893
+ # --- méthodes nouvelles ---
894
+ def ingest_source(self, name: str, atoms: list[Atom]) -> int:
895
+ start = len(self.atoms)
896
+ for atom in atoms:
897
+ self.ingest_atom(atom)
898
+ end = len(self.atoms) - 1
899
+ # relations locales au batch (merge déterministe : setdefault)
900
+ for pattern, rel in extract_patterns(atoms, min_support=3).items():
901
+ merged = self.relations.setdefault(pattern, {})
902
+ for slot, answer in rel.items():
903
+ merged.setdefault(slot, answer)
904
+ # écriture hebbienne (espace D_h=2048, re-hash indépendant)
905
+ for atom in atoms:
906
+ key = hash_hv(atom.statement, self.hebbian.D_h, self.seed)
907
+ value = hash_hv(atom.context or atom.statement, self.hebbian.D_h, self.seed)
908
+ window = self.atoms[max(start, len(self.atoms) - 9):len(self.atoms) - 1]
909
+ probes = [
910
+ (hash_hv(a.statement, self.hebbian.D_h, self.seed),
911
+ hash_hv(a.context or a.statement, self.hebbian.D_h, self.seed))
912
+ for a in window
913
+ ]
914
+ self.hebbian.write_gated(key, value, probes)
915
+ # spawn : signature = signe majoritaire des addr du batch
916
+ if atoms:
917
+ addrs = [encode_atom(a, self.D, self.seed)[0] for a in atoms]
918
+ total = np.sum(np.stack(addrs).astype(np.int32), axis=0)
919
+ signature = np.where(total >= 0, 1, -1).astype(np.int8)
920
+ self.experts.spawn(name, signature, start, end)
921
+ return len(atoms)
922
+
923
+ def ask_cards(self, question: str, k: int = 3) -> list[Card]:
924
+ query = text_hv(question, self.D, self.seed)
925
+ routed = self.experts.route(query, top=1)
926
+ route_name = routed[0][0] if routed else ""
927
+ hits = self.ask(question, k=k)
928
+ self.coact.record([i for i, _ in hits])
929
+ cards: list[Card] = []
930
+ for atom, s in hits:
931
+ text = f"{atom.statement} => {atom.context}" if atom.context else atom.statement
932
+ cards.append(Card("FAIT", text, s, atom.provenance))
933
+ top_sim = hits[0][1] if hits else 0.0
934
+ if top_sim < 0.5:
935
+ matched = match_pattern(question)
936
+ if matched is not None:
937
+ pattern, _slot = matched
938
+ rel = self.relations.get(pattern)
939
+ if rel:
940
+ for answer, score in solve_analogy(question, rel, D=self.D, seed=self.seed)[:2]:
941
+ cards.append(Card("ANALOGIE", answer, score, route_name or "analogie"))
942
+ if not cards:
943
+ cards.append(Card("LACUNE", "aucune trace pour cette question", 0.0, route_name))
944
+ return cards
945
+
946
+ def consolidate(self, threshold: int = 3) -> int:
947
+ promoted = 0
948
+ for a_id, b_id in self.coact.promote(threshold=threshold):
949
+ a, b = self.atoms[a_id], self.atoms[b_id]
950
+ composite = Atom(f"({a.statement}) & ({b.statement})",
951
+ a.context or b.context, "consolidated", 0.8)
952
+ atom_id = len(self.atoms)
953
+ self.atoms.append(composite)
954
+ # addr composite = bind des deux addr — write() stocke les HV tels quels
955
+ addr_a = encode_atom(a, self.D, self.seed)[0]
956
+ addr_b = encode_atom(b, self.D, self.seed)[0]
957
+ addr = bind(addr_a, addr_b)
958
+ value = encode_atom(composite, self.D, self.seed)[1]
959
+ self.traces.write(atom_id, addr, value, 0.8)
960
+ promoted += 1
961
+ return promoted
962
+ ```
963
+
964
+ (`TraceStore.write` stocke les HV passés tels quels — l'encodage n'a lieu que dans
965
+ `ingest_atom`. Pas besoin de `write_raw` : `consolidate` appelle `write` directement.)
966
+
967
+ `save`/`load` étendus — à la fin du `save` existant :
968
+
969
+ ```python
970
+ self.experts.save(path / "experts")
971
+ self.hebbian.save(path / "hebbian")
972
+ self.coact.save(path)
973
+ relations_payload = {"|".join(p): rel for p, rel in sorted(self.relations.items())}
974
+ (path / "relations.json").write_text(
975
+ json.dumps(relations_payload, sort_keys=True), encoding="utf-8"
976
+ )
977
+ ```
978
+
979
+ et dans `load`, **après** le rejeu existant (les organes se reconstruisent en lecture des fichiers si présents ; relations mergées comme dans ingest_source) :
980
+
981
+ ```python
982
+ experts_dir = path / "experts"
983
+ if experts_dir.exists():
984
+ brain.experts = ExpertRegistry.load(experts_dir)
985
+ heb_dir = path / "hebbian"
986
+ if heb_dir.exists():
987
+ brain.hebbian = HebbianMemory.load(heb_dir)
988
+ co_file = path / "coactivation.json"
989
+ if co_file.exists():
990
+ brain.coact = CoActivation.load(path)
991
+ rel_file = path / "relations.json"
992
+ if rel_file.exists():
993
+ raw = json.loads(rel_file.read_text(encoding="utf-8"))
994
+ for key, rel in raw.items():
995
+ pattern = tuple(key.split("|"))
996
+ merged = brain.relations.setdefault(pattern, {})
997
+ for slot, answer in rel.items():
998
+ merged.setdefault(slot, answer)
999
+ return brain
1000
+ ```
1001
+
1002
+ Aussi : ajouter `write_raw` à `TraceStore` (`vorax/organs/traces.py`) n'est **pas** nécessaire si `consolidate` utilise `self.traces.write(atom_id, addr, value, 0.8)` directement (l'addr bindée est passée telle quelle — `write` n'encode pas, il stocke). Vérifie ce point et n'ajoute rien d'inutile.
1003
+
1004
+ - [x] **Step 4: Run tests to verify they pass**
1005
+
1006
+ Run: `.venv/bin/python.exe -m pytest tests/test_brain_v2.py -v`
1007
+ Expected: PASS (7 passed) puis `.venv/bin/python.exe -m pytest -q` → 81 passed (74 + 7, zéro régression Plan 1)
1008
+
1009
+ - [x] **Step 5: Commit**
1010
+
1011
+ ```bash
1012
+ git add vorax/brain.py vorax/cards.py vorax/organs/traces.py tests/test_brain_v2.py
1013
+ git commit -m "feat: brain v2 — organs wired: routing, analogy cards, gated hebbian writes, consolidation"
1014
+ ```
1015
+
1016
+ ---
1017
+
1018
+ ### Task 7: CLI v2 — cartes riches et experts visibles (`vorax/agent/cli.py` — modification)
1019
+
1020
+ **Files:**
1021
+ - Modify: `vorax/agent/cli.py`
1022
+ - Modify: `bench/ingest_bench.py` (stat experts)
1023
+ - Test: `tests/test_cli_v2.py`
1024
+
1025
+ **Interfaces:**
1026
+ - Consumes: `Brain.ask_cards`, `Brain.experts`, `Brain.relations`, `Brain.hebbian.n_writes`
1027
+ - Produces:
1028
+ - `ingest()` crée les experts : il appelle désormais `brain.ingest_source(source.stem, atoms)` (au lieu d'`ingest_atoms`) et la ligne de stat devient `[VORAX] +{n} atomes ingérés depuis {source.name} → expert '{stem}' spawné (total: {len(brain)})`
1029
+ - `ask()` utilise `ask_cards` et imprime : `[CARTE] {kind}: {text} (sim {sim:.2f} | src: {source})` (et pour ANALOGIE, `src` = expert routé ou `analogie`)
1030
+ - `status()` ajoute : `[VORAX] organes: {len(experts)} experts, {n_relations} relations, {hebbian.n_writes} écritures hebbiennes, {coact.stats()['pairs']} paires co-actives`
1031
+ - bench : après ingestion, imprime une ligne `[BENCH] organes: {len(brain.experts)} experts spawnés`
1032
+
1033
+ - [x] **Step 1: Write the failing tests**
1034
+
1035
+ ```python
1036
+ # tests/test_cli_v2.py
1037
+ from pathlib import Path
1038
+
1039
+ from vorax.agent import cli
1040
+
1041
+ CSV_CONTENT = (
1042
+ "question,answer\n"
1043
+ "what is the capital of france,paris\n"
1044
+ "what is the capital of spain,madrid\n"
1045
+ "what is the capital of japan,tokyo\n"
1046
+ "what is the capital of italy,rome\n"
1047
+ "who wrote hamlet,william shakespeare\n"
1048
+ )
1049
+
1050
+
1051
+ def _setup(tmp_path):
1052
+ p = tmp_path / "cap.csv"
1053
+ p.write_text(CSV_CONTENT, encoding="utf-8")
1054
+ brain = tmp_path / "brain"
1055
+ cli.ingest(p, brain, D=2048, seed=0, kn_cache=tmp_path / "kn")
1056
+ return p, brain
1057
+
1058
+
1059
+ def test_ingest_announces_expert(tmp_path, capsys):
1060
+ _setup(tmp_path)
1061
+ out = capsys.readouterr().out
1062
+ assert "expert 'cap' spawné" in out
1063
+
1064
+
1065
+ def test_ask_prints_fact_cards(tmp_path, capsys):
1066
+ _, brain = _setup(tmp_path)
1067
+ cli.main(["ask", "what is the capital of japan", "--brain", str(brain), "--k", "2"])
1068
+ out = capsys.readouterr().out
1069
+ assert "[CARTE] FAIT:" in out and "tokyo" in out
1070
+
1071
+
1072
+ def test_ask_typo_prints_analogy_card(tmp_path, capsys):
1073
+ _, brain = _setup(tmp_path)
1074
+ cli.main(["ask", "what is the capital of franc", "--brain", str(brain), "--k", "2"])
1075
+ out = capsys.readouterr().out
1076
+ assert "[CARTE] ANALOGIE:" in out and "paris" in out
1077
+
1078
+
1079
+ def test_status_lists_organs(tmp_path, capsys):
1080
+ _, brain = _setup(tmp_path)
1081
+ cli.main(["status", "--brain", str(brain)])
1082
+ out = capsys.readouterr().out
1083
+ assert "organes:" in out and "expert" in out and "relation" in out
1084
+ ```
1085
+
1086
+ - [x] **Step 2: Run tests to verify they fail**
1087
+
1088
+ Run: `.venv/bin/python.exe -m pytest tests/test_cli_v2.py -v`
1089
+ Expected: FAIL — `expert 'cap' spawné` absent (ingest utilise encore ingest_atoms)
1090
+
1091
+ - [x] **Step 3: Write minimal implementation**
1092
+
1093
+ Dans `ingest()` : remplacer `n = brain.ingest_atoms(atoms)` par `n = brain.ingest_source(source.stem, atoms)` et la ligne print par celle des Interfaces. Dans `ask()` : remplacer la boucle interne par ask_cards :
1094
+
1095
+ ```python
1096
+ brain = Brain.load(brain_path)
1097
+ cards = brain.ask_cards(question, k=k)
1098
+ if not cards:
1099
+ return ["[CARTE] LACUNES: aucune trace pour cette question"]
1100
+ return [f"[CARTE] {c.kind}: {c.text} (sim {c.sim:.2f} | src: {c.source})" for c in cards]
1101
+ ```
1102
+
1103
+ Dans `status()` : ajouter la ligne organes (cf. Interfaces). Dans `bench/ingest_bench.py`, après `brain.ingest_atoms(atoms)` (remplacer par `ingest_source("synth", atoms)`) :
1104
+
1105
+ ```python
1106
+ print(f"[BENCH] organes: {len(brain.experts)} experts spawnés")
1107
+ ```
1108
+
1109
+ - [x] **Step 4: Run tests to verify they pass**
1110
+
1111
+ Run: `.venv/bin/python.exe -m pytest tests/test_cli_v2.py -v`
1112
+ Expected: PASS (4 passed) puis `.venv/bin/python.exe -m pytest -q` → 85 passed (81 + 4). Anciens tests CLI : `test_status_reports_counts` attend `"4" in s` — toujours vrai (4 atomes). Vérifie `test_ingest_then_ask_end_to_end` (le format FAIT a changé de `FAIT: {statement}` vers `FAIT: {statement} => {context}`) — l'assertion `"tokyo" in out` reste vraie et `"RÉP:" in out` **casse** : mets à jour ce test Plan 1 pour matcher le nouveau format (remplacer l'assert `"RÉP:" in out` par `"=>" in out`), c'est une évolution d'affichage voulue, pas une régression.
1113
+
1114
+ - [x] **Step 5: Full suite, bench smoke, commit**
1115
+
1116
+ Run: `.venv/bin/python.exe -m pytest -q` → 85 passed
1117
+ Run: `.venv/bin/python.exe bench/ingest_bench.py --rows 300 --D 2048` → tableau + `[BENCH] organes: 1 experts spawnés`
1118
+
1119
+ ```bash
1120
+ git add vorax/agent/cli.py bench/ingest_bench.py tests/test_cli_v2.py tests/test_cli.py
1121
+ git commit -m "feat: cli v2 — rich cards, expert spawn announcements, organ stats"
1122
+ ```
1123
+
1124
+ ---
1125
+
1126
+ ## Definition of Done — Plan 2
1127
+
1128
+ - [x] `python -m pytest -q` : 85 passed (55 Plan 1 + 30 Plan 2), zéro régression
1129
+ - [x] Démo : `ingest cap.csv` → `expert 'cap' spawné` ; `ask "what is the capital of japan"` → CARTE FAIT tokyo ; `ask "what is the capital of franc"` (typo) → **CARTE ANALOGIE paris**
1130
+ - [x] `status` montre les organes (experts, relations, écritures hebbiennes, co-activation)
1131
+ - [x] Hebbien : porte ΔE refuse une écriture dégradante, rollback OK, save/load OK
1132
+ - [x] Aucune dépendance nouvelle (numpy + stdlib seulement), déterminisme préservé
1133
+ - [x] `git log` : un commit par tâche
docs/heritage/plans/2026-08-18-vorax-p3-noyau.md ADDED
@@ -0,0 +1,488 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Vorax Plan 3 — « Le noyau natif fractus et l'agent live » Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Livrer la « version améliorée de fractus » complète : le noyau natif fractus en torch CPU (`vorax/model/`, compatible checkpoint fractus-cte en lecture seule, AUCUN LLM externe), le conditionnement par cartes, les lectures hebbiennes dans le fabricant de cartes, l'agent REPL live qui absorbe des données pendant la session, et le killer bench par ablation.
6
+
7
+ **Architecture:** Double venv — le substrat (numpy+stdlib) tourne dans le venv mingw existant ; le noyau torch vit dans `.venv-torch` (Python 3.11 natif), torch étant strictement isolé dans `vorax/model/` (imports paresseux, gate de test). Le REPL fonctionne sans torch (organes seuls, spec D6) et avec (noyau branché). Le noyau lit les cartes des organes comme préfixe tokenique (canal protocolaire) et génère.
8
+
9
+ **Tech Stack:** substrat : numpy + stdlib (inchangé) ; noyau : `torch` (CPU, wheels win_amd64, Python 3.11 natif) — seule dépendance hors numpy, jamais importée hors `vorax/model/`.
10
+
11
+ **Spec source :** `docs/superpowers/specs/2026-08-17-vorax-design.md` (D6, D7, sections 4-5, 9). **Prérequis :** Plans 1-2 livrés (93/93 tests).
12
+
13
+ ## Global Constraints
14
+
15
+ - **Torch strictement isolé dans `vorax/model/`** : aucun autre module de `vorax/` n'importe torch (même paresseusement) — testé par gate
16
+ - La suite complète doit rester verte dans le venv mingw **sans torch** (tests torch → `pytest.importorskip`)
17
+ - Aucun LLM externe, aucun GGUF, aucune API distante — le noyau est fractus, point
18
+ - Le checkpoint fractus-cte est chargé **en lecture seule** (jamais réécrit, jamais modifié — les repos siblings ne sont jamais écrits)
19
+ - Déterminisme : génération greedy par défaut (temperature=0), seeds fixées, artefacts triés
20
+ - Les 93 tests des Plans 1-2 restent verts à chaque tâche ; un commit par tâche
21
+ - CPU-only ; prose docs en français, code en anglais
22
+ - TDD strict
23
+
24
+ ---
25
+
26
+ ### Task 1: Venv torch + investigation de l'architecture fractus-cte
27
+
28
+ **Files:**
29
+ - Create: `.venv-torch/` (venv Python 3.11 natif, PAS committé — gitignore)
30
+ - Create: `docs/fractus-cte-architecture.md`
31
+ - Create: `vorax/model/__init__.py` (vide)
32
+ - Create: `tests/test_gates.py`
33
+ - Modify: `.gitignore`
34
+
35
+ **Interfaces:**
36
+ - Produces:
37
+ - `.venv-torch` avec torch CPU + numpy + pytest fonctionnels (importables, versions documentées dans le doc)
38
+ - `docs/fractus-cte-architecture.md` : spec complète de l'architecture fractus-1B **extraite du code existant** (LECTURE SEULE de `../fractus` et `../fractus-cte`) : arbre des modules, clés state_dict attendues + shapes, tokenizer (BPE byte-level GPT-2 maison — vocab, mécanisme encode/decode), config (d_model, n_layers, n_experts, top-k, rangs U/V, fréquences SIREN, phases de Farey), et le résultat du sondage réel du checkpoint `../fractus/checkpoints/checkpoints/fractus_1b_latest.pt` (clés+shapes réelles dumpées via `torch.load(map_location="cpu", weights_only=False)` — fichier local de confiance)
39
+ - `tests/test_gates.py::test_torch_isolated_in_model_dir` : parcours de tous les `.py` de `vorax/` (hors `vorax/model/`) → aucun ne contient `import torch` ni `from torch`
40
+
41
+ - [x] **Step 1: Write the failing test**
42
+
43
+ ```python
44
+ # tests/test_gates.py
45
+ """Gates structurels : torch ne vit que dans vorax/model/."""
46
+ from pathlib import Path
47
+
48
+ REPO = Path(__file__).parent.parent
49
+
50
+
51
+ def test_torch_isolated_in_model_dir():
52
+ package = REPO / "vorax"
53
+ offenders = []
54
+ for py in package.rglob("*.py"):
55
+ if py.parent == package / "model":
56
+ continue # autorisé ici et ici seulement
57
+ text = py.read_text(encoding="utf-8")
58
+ if "import torch" in text or "from torch" in text:
59
+ offenders.append(str(py.relative_to(REPO)))
60
+ assert offenders == []
61
+ ```
62
+
63
+ - [x] **Step 2: Run test to verify it fails**
64
+
65
+ Run: `.venv/bin/python.exe -m pytest tests/test_gates.py -v`
66
+ Expected: FAIL — `vorax/model/__init__.py` n'existe pas encore... si le test passe trivialement (pas d'offender), c'est OK : il devient le garde permanent. Vérifier qu'il PASSera aussi après création de `vorax/model/__init__.py`.
67
+
68
+ - [x] **Step 3: Implementation**
69
+
70
+ 1. Créer `vorax/model/__init__.py` (vide) et ajouter `.venv-torch/` à `.gitignore`.
71
+ 2. Créer le venv natif : `py -3.11 -m venv .venv-torch && .venv-torch/Scripts/python.exe -m pip install --upgrade pip && .venv-torch/Scripts/python.exe -m pip install "torch" --index-url https://download.pytorch.org/whl/cpu && .venv-torch/Scripts/python.exe -m pip install numpy pytest`
72
+ 3. Vérifier : `.venv-torch/Scripts/python.exe -c "import torch, numpy; print(torch.__version__, numpy.__version__)"`.
73
+ 4. **Investigation (LECTURE SEULE des repos siblings — ne rien y écrire)** : lire `../fractus/fractus1B/model_1b.py`, `train_1b.py`, le tokenizer, et `../fractus-cte/scripts/train_1b_gpu.py` ; sonder le checkpoint réel (script jetable en mémoire — ne rien écrire hors vorax) :
74
+ ```python
75
+ import torch
76
+ ckpt = torch.load(r"C:\Users\PHIL\ZCodeProject\fractus\checkpoints\checkpoints\fractus_1b_latest.pt", map_location="cpu", weights_only=False)
77
+ sd = ckpt.get("model", ckpt.get("state_dict", ckpt))
78
+ for k, v in sd.items():
79
+ print(k, tuple(v.shape) if hasattr(v, "shape") else type(v))
80
+ ```
81
+ 5. Rédiger `docs/fractus-cte-architecture.md` : (a) résumé des principes (LazyStructuredSiren `W = scale·U·Vᵀ` + résidu SIREN, MoE top-2 par phases de Farey/von Mises, attention linéaire, oscillateurs Kuramoto), (b) **table complète clés→shapes du state_dict réel**, (c) format tokenizer + taille vocab, (d) toute clé de config trouvée dans le checkpoint, (e) versions torch/numpy du venv.
82
+
83
+ - [x] **Step 4: Verify**
84
+
85
+ Run: `.venv/bin/python.exe -m pytest tests/test_gates.py -v` → PASS
86
+ Run: `.venv-torch/Scripts/python.exe -m pytest tests/test_gates.py -v` → PASS
87
+ Run: `.venv/bin/python.exe -m pytest -q` → 94 passed (93 + gate)
88
+
89
+ - [x] **Step 5: Commit**
90
+
91
+ ```bash
92
+ git add .gitignore vorax/model/__init__.py tests/test_gates.py docs/fractus-cte-architecture.md
93
+ git commit -m "feat: torch venv + fractus-cte architecture spec + torch isolation gate"
94
+ ```
95
+
96
+ ---
97
+
98
+ ### Task 2: Noyau natif — `vorax/model/fractus_core.py`
99
+
100
+ **Files:**
101
+ - Create: `vorax/model/fractus_core.py`
102
+ - Test: `tests/test_fractus_core.py`
103
+
104
+ **Interfaces:**
105
+ - Consumes: `docs/fractus-cte-architecture.md` (Task 1 — c'est LA source de vérité des couches/clés/shapes)
106
+ - Produces (imports torch paresseusement, uniquement dans ce fichier) :
107
+ - `FractusCoreConfig` dataclass : `vocab_size: int`, `d_model: int = 512`, `n_layers: int = 4`, `n_experts: int = 8`, `top_k: int = 2`, `rank: int = 32`, `max_seq_len: int = 256` (defaults = config « tiny » pour les tests ; la config réelle 1B vient du doc/checkpoint)
108
+ - `FractusCore(config)` : module torch implémentant l'architecture du doc — embedding token, blocs `FractalBlockSparse` (LazyStructuredSiren low-rank + résidu SIREN, MoE top-k par routage de phase), attention linéaire, oscillateurs Kuramoto par couche — les noms des sous-modules DOIVENT reproduire les clés du state_dict réel du doc (mapping documenté dans le docstring)
109
+ - `FractusCore.from_checkpoint(path: Path, map_location="cpu") -> FractusCore` : `torch.load(weights_only=False)`, lit `sd = ckpt.get("model", ckpt.get("state_dict", ckpt))`, infère la config depuis les shapes, `load_state_dict(strict=True)`
110
+ - `forward(token_ids: "torch.Tensor") -> "torch.Tensor"` (logits)
111
+ - `generate(token_ids, max_new_tokens: int = 32, temperature: float = 0.0) -> "torch.Tensor"` : greedy si temperature == 0 (déterministe), sinon multinomial avec `torch.Generator` seedé
112
+
113
+ - [x] **Step 1: Write the failing tests** (skip sans torch)
114
+
115
+ ```python
116
+ # tests/test_fractus_core.py
117
+ import pytest
118
+
119
+ torch = pytest.importorskip("torch", reason="noyau torch optionnel (substrat sans torch)")
120
+
121
+ from vorax.model.fractus_core import FractusCore, FractusCoreConfig
122
+
123
+
124
+ def _tiny():
125
+ return FractusCoreConfig(vocab_size=128, d_model=64, n_layers=2, n_experts=4, top_k=2, rank=16, max_seq_len=64)
126
+
127
+
128
+ def test_forward_logits_shape():
129
+ torch.manual_seed(0)
130
+ core = FractusCore(_tiny())
131
+ ids = torch.randint(0, 128, (1, 10))
132
+ logits = core(ids)
133
+ assert logits.shape == (1, 10, 128)
134
+
135
+
136
+ def test_generate_greedy_deterministic():
137
+ torch.manual_seed(0)
138
+ core = FractusCore(_tiny())
139
+ ids = torch.tensor([[1, 2, 3]])
140
+ a = core.generate(ids, max_new_tokens=8)
141
+ b = core.generate(ids, max_new_tokens=8)
142
+ assert torch.equal(a, b)
143
+ assert a.shape == (1, 11)
144
+
145
+
146
+ def test_checkpoint_roundtrip(tmp_path):
147
+ """Sauvegarde/charge au format fractus (clé 'model') — strict load."""
148
+ torch.manual_seed(0)
149
+ core = FractusCore(_tiny())
150
+ torch.save({"model": core.state_dict()}, tmp_path / "tiny.pt")
151
+ loaded = FractusCore.from_checkpoint(tmp_path / "tiny.pt")
152
+ assert torch.equal(loaded.generate(torch.tensor([[1, 2, 3]]), 4),
153
+ core.generate(torch.tensor([[1, 2, 3]]), 4))
154
+
155
+
156
+ def test_real_checkpoint_strict_load():
157
+ """Marqué slow : charge le VRAI checkpoint fractus (lecture seule).
158
+ Skip si absent. Ne s'exécute que dans .venv-torch."""
159
+ import pathlib
160
+ ckpt = pathlib.Path(r"C:\Users\PHIL\ZCodeProject\fractus\checkpoints\checkpoints\fractus_1b_latest.pt")
161
+ if not ckpt.exists():
162
+ pytest.skip("checkpoint fractus non présent")
163
+ core = FractusCore.from_checkpoint(ckpt)
164
+ ids = torch.zeros(1, 4, dtype=torch.long)
165
+ with torch.no_grad():
166
+ logits = core(ids)
167
+ assert logits.ndim == 3
168
+ ```
169
+
170
+ Marquer `test_real_checkpoint_strict_load` avec `@pytest.mark.slow` (enregistrer le marker dans pyproject `[tool.pytest.ini_options] markers = ["slow: tests lourds exclus par défaut"]`).
171
+
172
+ - [x] **Step 2: Run tests to verify they fail**
173
+
174
+ Run (mingw, sans torch) : `.venv/bin/python.exe -m pytest tests/test_fractus_core.py -v` → SKIP (importorskip)
175
+ Run (torch venv) : `.venv-torch/Scripts/python.exe -m pytest tests/test_fractus_core.py -v` → FAIL (ModuleNotFoundError vorax.model.fractus_core)
176
+
177
+ - [x] **Step 3: Implementation**
178
+
179
+ Implémenter `vorax/model/fractus_core.py` **fidèlement au doc de la Task 1** :
180
+ - `import torch` et `import torch.nn as nn` en tête (ce fichier est le seul endroit autorisé).
181
+ - `FractalBlockSparse(nn.Module)` :LazyStructuredSiren — `W(x) = scale * (U @ (V @ x))` avec U (d×r), V (r×d), scale init par couche, + résidu SIREN (`sin(w·x+b)` fréquences spectrales) ; MoE : n_experts instances, routeur par phase (von Mises sur phases de Farey du doc) top-k ; attention linéaire (Katharopoulos) sur le bloc ; oscillateur Kuramoto par couche (phase modulant la gate du bloc).
182
+ - Le mapping sous-module↔clé state_dict suit EXACTEMENT la table du doc (mêmes noms d'attributs). Si une classe du doc n'a pas d'équivalent praticable, suivre la forme la plus proche et le documenter dans le docstring.
183
+ - `from_checkpoint` : infère `vocab_size` depuis la shape de l'embedding, `n_layers`/`d_model`/`n_experts`/`rank` depuis les clés (règles d'inférence commentées), construit, `load_state_dict(sd, strict=True)` — toute clé manquante doit ÉCHOUER bruyamment (pas de strict=False).
184
+ - `generate` : boucle greedy/multinomial, pas de sampling si temperature == 0.
185
+ - Après implémentation, exécuter une fois le test slow réel dans `.venv-torch` : `.venv-torch/Scripts/python.exe -m pytest tests/test_fractus_core.py -m slow -v` — si le strict load échoue sur des clés, CORRIGER les noms de sous-modules jusqu'au match strict (documenter les écarts restants dans le doc). Si le checkpoint exige plus de RAM que disponible, le rapporter — ne pas dégénérer en strict=False.
186
+
187
+ - [x] **Step 4: Verify**
188
+
189
+ Run: `.venv-torch/Scripts/python.exe -m pytest tests/test_fractus_core.py -v -m "not slow"` → PASS (4 tests)
190
+ Run: `.venv/bin/python.exe -m pytest -q` → 98 passed ou skipped mix (93 + gate + 4 torch-skipped : `98 passed, 4 skipped` selon l'environnement — le mingw venv SKIP les torch)
191
+
192
+ - [x] **Step 5: Commit**
193
+
194
+ ```bash
195
+ git add vorax/model/fractus_core.py tests/test_fractus_core.py pyproject.toml
196
+ git commit -m "feat: native fractus core in torch — architecture per fractus-cte spec, strict checkpoint load"
197
+ ```
198
+
199
+ ---
200
+
201
+ ### Task 3: Le noyau lit les cartes — `vorax/model/vocal.py`
202
+
203
+ **Files:**
204
+ - Create: `vorax/model/vocal.py`
205
+ - Test: `tests/test_vocal.py`
206
+
207
+ **Interfaces:**
208
+ - Consumes: `FractusCore` (Task 2), `Card` (`vorax/cards.py`)
209
+ - Produces:
210
+ - `serialize_cards(question: str, cards: list[Card]) -> str` — préfixe protocolaire déterministe : lignes `[FAIT] ...`, `[ANALOGIE] ...`, `[HEBBIEN] ...` triées par (kind, text), terminées par `Q: {question}` (sans timestamp, sans aléa)
211
+ - `verbalize(core, question: str, cards: list[Card], tokenizer, max_new_tokens: int = 48) -> str` : encode `serialize_cards` + question, `core.generate` greedy, décode, retourne le texte ; `tokenizer` = tout objet à `encode(str) -> list[int]` / `decode(list[int]) -> str` (le tokenizer réel du doc arrive avec le checkpoint ; pour les tests, un tokenizer char-level factice)
212
+ - `CharTokenizer` (classe de test/demo : `encode` = codepoints modulo vocab, `decode` = chr) avec `vocab_size`
213
+ - `core_available() -> bool` : True si torch ET FractusCore importables dans CE python (le substrat mingw répond False)
214
+
215
+ - [x] **Step 1: Write the failing tests**
216
+
217
+ ```python
218
+ # tests/test_vocal.py
219
+ from vorax.cards import Card
220
+ from vorax.model.vocal import CharTokenizer, core_available, serialize_cards, verbalize
221
+
222
+ CARDS = [
223
+ Card("FAIT", "what is the capital of japan => tokyo", 0.9, "cap.csv:4"),
224
+ Card("ANALOGIE", "paris", 1.0, "cap"),
225
+ ]
226
+
227
+
228
+ def test_serialize_cards_deterministic_and_sorted():
229
+ s1 = serialize_cards("q?", CARDS)
230
+ s2 = serialize_cards("q?", CARDS)
231
+ assert s1 == s2
232
+ assert s1.startswith("[FAIT]") or "[ANALOGIE]" in s1
233
+ assert s1.endswith("Q: q?")
234
+ lines = [l for l in s1.splitlines() if l]
235
+ assert lines == sorted(lines, key=lambda l: (not l.startswith("["), l)) # cartes avant Q
236
+
237
+
238
+ def test_char_tokenizer_roundtrip():
239
+ tok = CharTokenizer(vocab_size=1114112)
240
+ assert tok.decode(tok.encode("héllo wörld")) == "héllo wörld"
241
+
242
+
243
+ def test_verbalize_returns_text_when_core_available():
244
+ if not core_available():
245
+ import pytest
246
+ pytest.skip("torch indisponible dans ce python")
247
+ from vorax.model.fractus_core import FractusCore, FractusCoreConfig
248
+ import torch
249
+ torch.manual_seed(0)
250
+ core = FractusCore(FractusCoreConfig(vocab_size=1114112, d_model=64, n_layers=1, n_experts=2, rank=8, max_seq_len=512))
251
+ out = verbalize(core, "what is the capital of japan?", CARDS[:1], CharTokenizer(vocab_size=1114112), max_new_tokens=8)
252
+ assert isinstance(out, str) and len(out) >= 0 # le noyau non-né peut sortir du bruit — le contrat est le pipeline
253
+ ```
254
+
255
+ - [x] **Step 2: Run tests to verify they fail**
256
+
257
+ Run mingw : 2 PASS / 1 SKIP attendus APRÈS implémentation ; avant : FAIL (ModuleNotFoundError).
258
+ Run torch venv : les 3 doivent passer après implémentation.
259
+
260
+ - [x] **Step 3: Implementation** — `serialize_cards` (join trié (kind, text), format `[KIND] {text} (sim {sim:.2f})`, `Q:` final), `CharTokenizer`, `core_available` (try import dans une fonction, jamais au niveau module), `verbalize` (truncate au max_seq_len par la gauche : garder les DERNIERS tokens si trop long).
261
+
262
+ - [x] **Step 4: Verify** — mingw : `.venv/bin/python.exe -m pytest tests/test_vocal.py -v` → 2 passed 1 skipped ; torch venv : 3 passed ; full mingw suite verte.
263
+
264
+ - [x] **Step 5: Commit**
265
+
266
+ ```bash
267
+ git add vorax/model/vocal.py tests/test_vocal.py
268
+ git commit -m "feat: core reads cards — protocol serialization + verbalization pipeline"
269
+ ```
270
+
271
+ ---
272
+
273
+ ### Task 4: Lectures hebbiennes → cartes (`vorax/brain.py` — modification)
274
+
275
+ **Files:**
276
+ - Modify: `vorax/brain.py`
277
+ - Test: `tests/test_brain_hebbian.py`
278
+
279
+ **Interfaces:**
280
+ - Consumes: `HebbianMemory.complete` (Plan 2)
281
+ - Produces: `Brain.ask_cards` émet en plus des cartes `kind="HEBBIEN"` : après le retrieval, calcule `key = hash_hv(question, D_h, seed)`, `completed = hebbian.complete(key)` ; si la complétion matche (sim ≥ 0.55) la VALEUR d'une paire ingérée (index maintenu à l'ingestion : `self._hebbian_index: dict[bytes, str]` — bytes = value.tobytes(), str = statement), émettre `Card("HEBBIEN", f"{statement} => {context}", sim, "hebbian")` en tête des cartes (avant FAIT), sans doublon si le même atom est déjà en FAIT top-1.
282
+ - `ingest_source` maintient `_hebbian_index[value.tobytes()] = atom.statement` pour chaque écriture hebbienne acceptée ; `save/load` persistent l'index dans `hebbian_index.json` (clés = hex, triées ; absent → reconstruit vide, compat Plan 1/2).
283
+
284
+ - [x] **Step 1: Write the failing tests**
285
+
286
+ ```python
287
+ # tests/test_brain_hebbian.py
288
+ from vorax.brain import Brain
289
+ from vorax.compiler.atoms import Atom
290
+
291
+ CAPITALS = [
292
+ Atom("what is the capital of france", "paris", "cap.csv:2"),
293
+ Atom("what is the capital of spain", "madrid", "cap.csv:3"),
294
+ Atom("what is the capital of japan", "tokyo", "cap.csv:4"),
295
+ Atom("what is the capital of italy", "rome", "cap.csv:5"),
296
+ ]
297
+
298
+
299
+ def test_hebbian_card_on_question():
300
+ brain = Brain(D=2048)
301
+ brain.ingest_source("capitals", CAPITALS)
302
+ cards = brain.ask_cards("what is the capital of spain", k=1)
303
+ kinds = [c.kind for c in cards]
304
+ assert "HEBBIEN" in kinds or brain.hebbian.n_writes == 0 # si toutes les écritures furent refusées par la porte, pas de carte — honnête
305
+ heb = [c for c in cards if c.kind == "HEBBIEN"]
306
+ if heb:
307
+ assert "madrid" in heb[0].text
308
+ assert kinds.index("HEBBIEN") == 0 # en tête
309
+
310
+
311
+ def test_hebbian_index_roundtrip(tmp_path):
312
+ brain = Brain(D=2048)
313
+ brain.ingest_source("capitals", CAPITALS)
314
+ brain.save(tmp_path / "brain")
315
+ loaded = Brain.load(tmp_path / "brain")
316
+ assert len(loaded._hebbian_index) == len(brain._hebbian_index)
317
+ cards = loaded.ask_cards("what is the capital of japan", k=1)
318
+ assert any(c.kind in ("HEBBIEN", "FAIT") for c in cards)
319
+ ```
320
+
321
+ - [x] **Step 2: Verify failure** — `ModuleNotFoundError` impossible (fichier nouveau) : FAIL sur `test_hebbian_card_on_question` (pas de HEBBIEN produit, kind absent ET n_writes > 0).
322
+
323
+ - [x] **Step 3: Implementation** — comme décrit dans Interfaces ; la sim de match = `sim(completed, value_hv)` ; pas de torch ici (substrat pur).
324
+
325
+ - [x] **Step 4: Verify** — mingw full suite : 93 + gate + 2 = vert (torch tests skipped selon venv) ; aucun changement des cartes existantes sans hebbian match.
326
+
327
+ - [x] **Step 5: Commit**
328
+
329
+ ```bash
330
+ git add vorax/brain.py tests/test_brain_hebbian.py
331
+ git commit -m "feat: hebbian reads feed the card maker — HEBBIEN cards, persisted index"
332
+ ```
333
+
334
+ ---
335
+
336
+ ### Task 5: Agent REPL live — `vorax/agent/repl.py`
337
+
338
+ **Files:**
339
+ - Create: `vorax/agent/repl.py`
340
+ - Test: `tests/test_repl.py`
341
+
342
+ **Interfaces:**
343
+ - Consumes: `cli.ingest`, `Brain`, `ask_cards`, `consolidate`, `core_available`/`verbalize` (optionnels)
344
+ - Produces:
345
+ - `class Repl` : constructeur `Repl(brain_path: Path, D: int = D_DEFAULT, seed: int = SEED_DEFAULT)` ; méthode `feed(line: str) -> list[str]` (retourne les lignes de sortie — jamais de print direct) ; boucle `run()` lisant stdin via `input()` et imprimant les lignes
346
+ - Commandes : `:ingest <path>` (compilation+ingestion ; affiche la ligne expert spawné + total), `:status`, `:consolidate` (promotions), `:quit` ; toute autre ligne = question → cartes affichées `[CARTE] ...` **puis** écriture O(1) de l'échange : `brain.ingest_atom(Atom(question, best_answer_text, "session"))` où best_answer_text = texte de la première carte non-LACUNE (ou "" si LACUNE), **puis** `brain.save(brain_path)` (la conversation nourrit le brain, spec §5.4)
347
+ - `main(argv=None)` : argparse (`--brain`, `--D`, `--seed`), lance `run()`, exit 0
348
+ - Si `core_available()` : après les cartes, tente `verbalize` (préfixe `[NOYAU] `) dans un try/except qui dégrade proprement (le noyau non-né peut produire du bruit — l'afficher honnêtement)
349
+
350
+ - [x] **Step 1: Write the failing tests**
351
+
352
+ ```python
353
+ # tests/test_repl.py
354
+ from pathlib import Path
355
+
356
+ from vorax.agent.repl import Repl
357
+
358
+ CSV = ("question,answer\n"
359
+ "what is the capital of france,paris\n"
360
+ "what is the capital of spain,madrid\n"
361
+ "what is the capital of japan,tokyo\n"
362
+ "what is the capital of italy,rome\n"
363
+ "who wrote hamlet,william shakespeare\n")
364
+
365
+
366
+ def _repl(tmp_path):
367
+ csv = tmp_path / "cap.csv"
368
+ csv.write_text(CSV, encoding="utf-8")
369
+ r = Repl(tmp_path / "brain", D=2048)
370
+ return r, csv
371
+
372
+
373
+ def test_ingest_and_ask(tmp_path):
374
+ r, csv = _repl(tmp_path)
375
+ out = r.feed(f":ingest {csv}")
376
+ assert any("expert" in l for l in out)
377
+ out = r.feed("what is the capital of japan")
378
+ assert any("[CARTE]" in l and "tokyo" in l for l in out)
379
+
380
+
381
+ def test_conversation_feeds_brain(tmp_path):
382
+ r, csv = _repl(tmp_path)
383
+ r.feed(f":ingest {csv}")
384
+ n0 = len(r.brain)
385
+ r.feed("who wrote hamlet")
386
+ assert len(r.brain) == n0 + 1 # échange écrit O(1)
387
+ assert r.brain.atoms[-1].provenance == "session"
388
+ out = r.feed(":status")
389
+ assert any("atomes" in l for l in out)
390
+
391
+
392
+ def test_quit_and_unknown(tmp_path):
393
+ r, _ = _repl(tmp_path)
394
+ assert r.feed(":quit") == []
395
+ out = r.feed("zzz nothing here")
396
+ assert any("LACUNE" in l or "[CARTE]" in l for l in out)
397
+ ```
398
+
399
+ - [x] **Step 2: Verify failure** — ModuleNotFoundError: vorax.agent.repl.
400
+
401
+ - [x] **Step 3: Implementation** — Repl comme décrit (feed retourne les lignes ; run() = while True: input → feed → print). Attention Windows : `input()` avec prompt `vorax> `.
402
+
403
+ - [x] **Step 4: Verify** — mingw suite verte (+4) ; smoke manuel : `echo -e ":status\n:quit" | .venv/bin/python.exe -m vorax.agent.repl --brain /tmp/brain` s'exécute sans traceback.
404
+
405
+ - [x] **Step 5: Commit**
406
+
407
+ ```bash
408
+ git add vorax/agent/repl.py tests/test_repl.py
409
+ git commit -m "feat: live REPL agent — ingest/status/consolidate, conversation written O(1)"
410
+ ```
411
+
412
+ ---
413
+
414
+ ### Task 6: Killer bench par ablation — `bench/killer_bench.py`
415
+
416
+ **Files:**
417
+ - Create: `bench/killer_bench.py`
418
+ - Test: `tests/test_killer_bench.py`
419
+
420
+ **Interfaces:**
421
+ - Consumes: `compile_kn/load_kn`, `Brain`, `generate_domain_csv(path, n_topics, n_facts_per_topic) -> int` (générateur déterministe : topics {pays, planètes, éléments...} × faits {capital, population, découverte} — seedé)
422
+ - Produces: `run_bench(rows: int = 200, D: int = 8192, out_dir: Path = bench/out) -> dict` — (1) génère le corpus domaine, (2) split 80/20 déterministe : ingested vs held-out (paraphrases : ordre des mots modifié ; typos : substitution d'un char du slot), (3) mesure : précision top-1 (réponse exacte dans la 1ère carte FAIT/ANALOGIE/HEBBIEN), latence ingestion (atomes/s), latence requête (ms), (4) imprime le tableau + ablation « cartes OFF » (retrieval désactivé → précision 0 attendue, ligne de base honnête) ; retourne le dict de métriques. L'ablation « noyau seul vs noyau+cartes » est scaffoldée : champ `core_cards_ablation: None | dict` rempli si `core_available()` et un checkpoint fourni (`--ckpt`).
423
+
424
+ - [x] **Step 1: Write the failing test**
425
+
426
+ ```python
427
+ # tests/test_killer_bench.py
428
+ from pathlib import Path
429
+
430
+ from bench.killer_bench import generate_domain_csv, run_bench
431
+
432
+
433
+ def test_generate_domain_csv_deterministic(tmp_path):
434
+ a, b = tmp_path / "a.csv", tmp_path / "b.csv"
435
+ n1, n2 = generate_domain_csv(a, 6, 3), generate_domain_csv(b, 6, 3)
436
+ assert n1 == n2 == 18 and a.read_bytes() == b.read_bytes()
437
+
438
+
439
+ def test_run_bench_small(tmp_path):
440
+ metrics = run_bench(rows=24, D=2048, out_dir=tmp_path)
441
+ assert 0.0 <= metrics["heldout_accuracy"] <= 1.0
442
+ assert metrics["ingest_atoms_per_s"] > 0
443
+ assert metrics["query_ms"] > 0
444
+ assert metrics["core_cards_ablation"] is None or isinstance(metrics["core_cards_ablation"], dict)
445
+ ```
446
+
447
+ - [x] **Step 2: Verify failure** — ModuleNotFoundError: bench.killer_bench.
448
+
449
+ - [x] **Step 3: Implementation** — générateur seedé (random.Random(7)), paraphrase = déplacement du premier token à la fin, typo = remplacement d'une voyelle du slot par une autre voyelle ; la précision = la 1ère carte dont le text contient `=> {expected}` ; cartes OFF = méthode privée qui interroge sans organes (répond toujours LACUNE → 0) — documentée comme plancher honnête.
450
+
451
+ - [x] **Step 4: Verify** — mingw suite verte (+2) ; smoke : `.venv/bin/python.exe bench/killer_bench.py --rows 40 --D 2048` imprime le tableau.
452
+
453
+ - [x] **Step 5: Commit**
454
+
455
+ ```bash
456
+ git add bench/killer_bench.py tests/test_killer_bench.py
457
+ git commit -m "feat: killer bench — held-out paraphrase/typo accuracy, cards-off floor, core ablation scaffold"
458
+ ```
459
+
460
+ ---
461
+
462
+ ### Task 7: README final + démo script
463
+
464
+ **Files:**
465
+ - Create: `demo/demo.py`
466
+ - Modify: `README.md`
467
+ - Test: `tests/test_demo.py`
468
+
469
+ **Interfaces:**
470
+ - Produces: `demo/demo.py` — script autonome (aucun argument requis, artefacts dans un tmpdir) qui : ingère le CSV capitales, montre la question exacte (FAIT), la typo (ANALOGIE), le status organes, et une session REPL scriptée (3 feeds) via la classe Repl ; affiche un résumé final `[VORAX] Démo complète — zéro gradient.`. Test : `test_demo_runs` l'exécute (fonction `main() -> list[str]` retournant les lignes) et vérifie la présence de tokyo, ANALOGIE, organes et du résumé.
471
+
472
+ - [x] **Step 1-5** : test d'abord (`tests/test_demo.py` : appelle `demo.main()` et vérifie les marqueurs), implémentation, suite complète verte dans les DEUX venvs (mingw : tout sauf torch-skips ; torch venv : tout y compris slow exclu), README mis à jour (section « Noyau natif fractus », instructions double venv, quickstart REPL `python -m vorax.agent.repl`), commit :
473
+ ```bash
474
+ git add demo/demo.py tests/test_demo.py README.md
475
+ git commit -m "feat: demo script + final README — the complete training-free fractus"
476
+ ```
477
+
478
+ ---
479
+
480
+ ## Definition of Done — Plan 3
481
+
482
+ - [x] Suite verte dans le venv mingw SANS torch (torch tests skipped) ET dans `.venv-torch` (tout passe, `-m "not slow"`)
483
+ - [x] `test_real_checkpoint_strict_load` exécuté au moins une fois dans `.venv-torch` avec résultat documenté (match strict ou écarts documentés dans le doc d'architecture)
484
+ - [x] Gate : torch présent uniquement dans `vorax/model/`
485
+ - [x] REPL : ingest → question → échange écrit O(1) dans le brain → status
486
+ - [x] Killer bench : tableau avec précision held-out, plancher cartes-OFF, latences
487
+ - [x] Démo complète : tokyo + ANALOGIE paris + organes + `zéro gradient`
488
+ - [x] Aucun LLM externe nulle part ; checkpoint chargé en lecture seule ; repos siblings intouchés
docs/heritage/plans/2026-08-18-vorax-p4-noyau-parle.md ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Vorax Plan 4 — « Le noyau parle » Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Brancher le vrai tokenizer BPE GPT-2 (celui de fractus) sur le noyau natif, rendre la génération consciente du token d'arrêt (`<|endoftext|>` = 50256), rendre `[NOYAU]` robuste, et livrer l'évaluation honnête `core_speak` — plus deux dettes du ledger (idempotence de consolidate, durcissement du gate).
6
+
7
+ **Architecture:** `vorax/model/bpe_tokenizer.py` encapsule la lib HF `tokenizers` (résolution : chemin explicite → snapshot cache HF local → `from_pretrained("gpt2")`), lazy-importée dans `vorax/model/` uniquement. Le REPL `:core` utilise ce tokenizer ; `FractusCore.generate` gagne un `eos_token_id`. `bench/core_speak.py` mesure honnêtement le noyau actuel (word salad attendu — c'est l'état du checkpoint, pas un bug).
8
+
9
+ **Tech Stack:** substrat numpy+stdlib inchangé ; `.venv-torch` gagne `tokenizers` (wheel natif, sans torch). Aucune nouvelle dépendance côté mingw.
10
+
11
+ **Spec source :** D6/D7 ; doc `docs/fractus-cte-architecture.md` §6 (le doc sanctifie : « un tokenizer GPT-2 reproduit exactement les ids »). **Prérequis :** Plans 1-3 livrés (105/2 mingw, 111 torch).
12
+
13
+ ## Global Constraints
14
+
15
+ - Torch ET tokenizers strictement confinés à `vorax/model/` (imports paresseux ; le substrat mingw n'installe rien de nouveau)
16
+ - Le cache HF et les fichiers siblings sont lus **en lecture seule** ; aucun écriture hors repo (sauf tmpdir/bench-out gitignorés)
17
+ - Déterminisme : encodage BPE déterministe, génération greedy par défaut, artefacts triés
18
+ - Chiffres honnêtes : core_speak rapporte ce que le checkpoint produit **verbatim** — word salad si word salad
19
+ - Les suites restent vertes : mingw (torch/tokenizers tests skippés) et `.venv-torch`
20
+ - Un commit par tâche ; TDD strict ; prose docs FR, code EN
21
+
22
+ ---
23
+
24
+ ### Task 1: Tokenizer BPE GPT-2 (`vorax/model/bpe_tokenizer.py`)
25
+
26
+ **Files:**
27
+ - Create: `vorax/model/bpe_tokenizer.py`
28
+ - Test: `tests/test_bpe_tokenizer.py`
29
+
30
+ **Interfaces:**
31
+ - Produces:
32
+ - `Gpt2BpeTokenizer` : constructeur `Gpt2BpeTokenizer(tokenizer_json: Path | None = None)` ; attribut `vocab_size: int` (50257) ; `eos_token_id: int` = 50256 ; méthodes `encode(text: str) -> list[int]`, `decode(ids: list[int]) -> str` (sûr : jamais d'exception sur surrogates — filtrer les ids hors [0, vocab) et décoder avec `errors="replace"`)
33
+ - Résolution du tokenizer (dans l'ordre) : (1) `tokenizer_json` explicite ; (2) snapshot cache HF local — glob `~/.cache/huggingface/hub/models--gpt2/snapshots/*/tokenizer.json` ; (3) `Tokenizer.from_pretrained("gpt2")` (réseau — documenter dans le docstring). `FileNotFoundError` si aucune source.
34
+ - `bpe_available() -> bool` : sonde try-import (lazy), True si `tokenizers` importable dans CE python
35
+
36
+ - [x] **Step 1: Install + write the failing tests**
37
+
38
+ Installer : `.venv-torch/Scripts/python.exe -m pip install tokenizers`
39
+
40
+ ```python
41
+ # tests/test_bpe_tokenizer.py
42
+ import pytest
43
+
44
+ bpt = pytest.importorskip("vorax.model.bpe_tokenizer")
45
+
46
+
47
+ def test_bpe_available_probe():
48
+ from vorax.model.bpe_tokenizer import bpe_available
49
+ assert isinstance(bpe_available(), bool)
50
+
51
+
52
+ def test_gpt2_known_encodings():
53
+ if not bpt.bpe_available():
54
+ pytest.skip("tokenizers indisponible")
55
+ tok = bpt.Gpt2BpeTokenizer()
56
+ assert tok.vocab_size == 50257
57
+ assert tok.eos_token_id == 50256
58
+ # encodages GPT-2 canoniques
59
+ assert tok.encode("hello world")[0] == 31373
60
+ assert tok.decode([31373, 995]) == "hello world"
61
+
62
+
63
+ def test_roundtrip():
64
+ if not bpt.bpe_available():
65
+ pytest.skip("tokenizers indisponible")
66
+ tok = bpt.Gpt2BpeTokenizer()
67
+ text = "What is the capital of france? ÀÉïö — naïve"
68
+ assert tok.decode(tok.encode(text)) == text
69
+
70
+
71
+ def test_decode_out_of_range_ids_do_not_crash():
72
+ if not bpt.bpe_available():
73
+ pytest.skip("tokenizers indisponible")
74
+ tok = bpt.Gpt2BpeTokenizer()
75
+ out = tok.decode([31373, 99999999, -5, 50256])
76
+ assert isinstance(out, str) # filtre, pas d'exception
77
+ ```
78
+
79
+ - [x] **Step 2: Verify failure** — `.venv-torch/Scripts/python.exe -m pytest tests/test_bpe_tokenizer.py -v` → FAIL (ModuleNotFoundError)
80
+ - [x] **Step 3: Implementation** — lazy `from tokenizers import Tokenizer` DANS les méthodes ; glob HF cache via `Path.home() / ".cache/huggingface/hub/models--gpt2/snapshots"` ; decode : filtrer `0 <= id < vocab_size`, puis `tok.decode(ids, )`... la lib décode elle-même — entourer d'un try/except et en dernier recours joindre `chr` sûrs ; **le test `test_decode_out_of_range_ids_do_not_crash` fait foi**.
81
+ - [x] **Step 4: Verify** — torch venv : 4 passed ; mingw : skipped (importorskip) ; suites complètes vertes (mingw 105/2+skip, torch 115).
82
+ - [x] **Step 5: Commit** — `git add vorax/model/bpe_tokenizer.py tests/test_bpe_tokenizer.py && git commit -m "feat: GPT-2 BPE tokenizer — exact fractus ids, offline HF-cache resolution, safe decode"`
83
+
84
+ ---
85
+
86
+ ### Task 2: Génération consciente d'EOS (`vorax/model/fractus_core.py` — modification)
87
+
88
+ **Files:**
89
+ - Modify: `vorax/model/fractus_core.py`
90
+ - Test: `tests/test_fractus_core.py` (ajout)
91
+
92
+ **Interfaces:**
93
+ - Produces: `generate(token_ids, max_new_tokens=32, temperature=0.0, eos_token_id: int | None = None, seed: int = 0)` — la boucle s'arrête (sans consommer d'autre token) dès que le token émis == `eos_token_id` ; le token EOS **n'est pas appendé** à la séquence retournée.
94
+
95
+ - [x] **Step 1: Failing test**
96
+
97
+ ```python
98
+ def test_generate_stops_at_eos():
99
+ torch.manual_seed(0)
100
+ core = FractusCore(_tiny())
101
+ ids = torch.tensor([[1, 2, 3]])
102
+ # déterminer le token que greedy émettrait au 1er pas, l'utiliser comme EOS
103
+ with torch.no_grad():
104
+ first = int(core(ids)[0, -1, :].argmax().item())
105
+ out = core.generate(ids, max_new_tokens=8, eos_token_id=first)
106
+ assert out.shape == (1, 4) # prompt + 1 token, puis arrêt
107
+ assert int(out[0, -1].item()) == first
108
+ ```
109
+
110
+ - [x] **Step 2: Verify failure** — TypeError (paramètre inconnu) dans `.venv-torch`.
111
+ - [x] **Step 3: Implementation** — dans la boucle de `generate` : après choix du next token, `if eos_token_id is not None and int(next.item()) == eos_token_id: break` AVANT l'append.
112
+ - [x] **Step 4: Verify** — torch venv 5 fractus_core tests pass (non-slow) ; suites complètes vertes.
113
+ - [x] **Step 5: Commit** — `git add vorax/model/fractus_core.py tests/test_fractus_core.py && git commit -m "feat: eos-aware generation — stop token without consuming it"`
114
+
115
+ ---
116
+
117
+ ### Task 3: `:core` parle BPE + `[NOYAU]` robuste (`vorax/agent/repl.py` + `vorax/model/vocal.py` — modifications)
118
+
119
+ **Files:**
120
+ - Modify: `vorax/agent/repl.py`, `vorax/model/vocal.py`
121
+ - Test: `tests/test_repl.py` (ajout), `tests/test_vocal.py` (ajout)
122
+
123
+ **Interfaces:**
124
+ - Consumes: `Gpt2BpeTokenizer`, `bpe_available` (T1), `eos_token_id` (T2)
125
+ - Produces:
126
+ - `vocal.py` : `safe_decode_text(text: str) -> str` — remplace les surrogates/cp non encodables (`.encode("utf-8", errors="replace").decode("utf-8", errors="replace")`) ; `verbalize` l'applique à sa sortie
127
+ - `repl.py` : `:core <ckpt>` charge désormais le tokenizer BPE si `bpe_available()` (fallback CharTokenizer sinon, message honnête `[NOYAU] tokenizer BPE indisponible — CharTokenizer de secours`) ; la verbalization passe `eos_token_id=tok.eos_token_id` ; toutes les lignes `[NOYAU]` passent par `safe_decode_text`
128
+
129
+ - [x] **Step 1: Failing tests**
130
+
131
+ ```python
132
+ # tests/test_vocal.py (ajout)
133
+ def test_safe_decode_text_replaces_surrogates():
134
+ from vorax.model.vocal import safe_decode_text
135
+ bad = "ok\ud800\udfffend"
136
+ out = safe_decode_text(bad)
137
+ assert "\ud800" not in out and out.startswith("ok")
138
+
139
+ # tests/test_repl.py (ajout)
140
+ def test_core_uses_bpe_when_available(tmp_path, capsys):
141
+ from vorax.model.bpe_tokenizer import bpe_available
142
+ r, _ = _repl(tmp_path)
143
+ fake_ckpt = tmp_path / "nope.pt"
144
+ out = r.feed(f":core {fake_ckpt}")
145
+ # sans torch ou fichier absent : dégradation propre déjà testée en P3-T5 ;
146
+ # ici on vérifie juste qu'aucun crash tokenizer ne fuit
147
+ assert all(isinstance(l, str) for l in out)
148
+ ```
149
+
150
+ - [x] **Step 2: Verify failure** (safe_decode absent → ImportError).
151
+ - [x] **Step 3: Implementation** — comme Interfaces ; garder le gate (aucun `import torch` littéral hors vorax/model/ ; repl n'importe que `bpe_available` depuis vorax.model.bpe_tokenizer, module lui-même lazy).
152
+ - [x] **Step 4: Verify** — suites complètes vertes dans les DEUX venvs.
153
+ - [x] **Step 5: Commit** — `git add vorax/agent/repl.py vorax/model/vocal.py tests/test_vocal.py tests/test_repl.py && git commit -m "feat: core speaks BPE — tokenizer wiring, eos stop, surrogate-safe NOYAU lines"`
154
+
155
+ ---
156
+
157
+ ### Task 4: Évaluation honnête `bench/core_speak.py`
158
+
159
+ **Files:**
160
+ - Create: `bench/core_speak.py`
161
+ - Test: `tests/test_core_speak.py`
162
+
163
+ **Interfaces:**
164
+ - Consumes: `FractusCore.from_checkpoint`, `Gpt2BpeTokenizer`, `Brain`, cartes
165
+ - Produces: `run_core_speak(ckpt_path: Path, questions: list[str], brain: Path | None, max_new_tokens: int = 24) -> dict` — pour chaque question : `{"question", "cards_prefix", "no_cards_output", "with_cards_output", "answer_in_output": bool}` ; `answer_in_output` = la réponse attendue (context du top FAIT) apparaît en sous-chaîne dans `with_cards_output`. `main()` : argparse `--ckpt --brain --rows`, imprime chaque génération **verbatim** + taux global + le verdict honnête `word salad` si taux == 0. Sans torch/tokenizers/checkpoint → message propre, exit 1.
166
+
167
+ - [x] **Step 1: Failing test** (skip sans torch)
168
+
169
+ ```python
170
+ # tests/test_core_speak.py
171
+ import pytest
172
+
173
+ torch = pytest.importorskip("torch", reason="core_speak nécessite torch")
174
+
175
+ from bench.core_speak import run_core_speak
176
+
177
+
178
+ def test_core_speak_reports_honestly(tmp_path):
179
+ import pathlib
180
+ ckpt = pathlib.Path(r"C:\Users\PHIL\ZCodeProject\fractus\checkpoints\checkpoints\fractus_1b_latest.pt")
181
+ if not ckpt.exists():
182
+ pytest.skip("checkpoint absent")
183
+ result = run_core_speak(ckpt, ["what is the capital of france"], brain=None, max_new_tokens=6)
184
+ assert len(result["runs"]) == 1
185
+ run = result["runs"][0]
186
+ for key in ("question", "no_cards_output", "with_cards_output", "answer_in_output"):
187
+ assert key in run
188
+ assert isinstance(result["with_cards_answer_rate"], float)
189
+ ```
190
+
191
+ - [x] **Step 2-5** : verify fail → implementation (questions par défaut : les 4 capitales ; brain par défaut : None = cartes vides pour `no_cards`, et si `--brain` fourni, ask_cards pour `with_cards` ; le préfixe cartes est sérialisé via `serialize_cards` puis tronqué BPE à gauche) → suites vertes (test skippé mingw) → UNE exécution réelle dans .venv-torch documentée verbatim dans le rapport (word salad attendu — honnête) → commit `feat: core_speak bench — honest verbatim evaluation of the current checkpoint`.
192
+
193
+ ---
194
+
195
+ ### Task 5: Dettes — idempotence consolidate + gate durci
196
+
197
+ **Files:**
198
+ - Modify: `vorax/brain.py` (consolidate : après promotion, retirer du compteur les paires promues), `tests/test_gates.py` (durcir)
199
+ - Test: `tests/test_brain_v2.py` (ajout), `tests/test_gates.py` (durci)
200
+
201
+ **Interfaces:**
202
+ - `Brain.consolidate` : les paires promues sont retirées de `coact.counts` (nouvelle méthode `CoActivation.forget(pairs)` qui `del` les clés) → consolider 2× ne duplique plus.
203
+ - Gate : détecte aussi `import_module("torch")`, `__import__("torch")`, `importlib` + "torch" combinaison directe.
204
+
205
+ - [x] **Step 1: Failing tests**
206
+
207
+ ```python
208
+ # tests/test_brain_v2.py (ajout)
209
+ def test_consolidate_is_idempotent():
210
+ brain = _brain()
211
+ for _ in range(3):
212
+ brain.ask_cards("what is the capital of france", k=2)
213
+ n1 = brain.consolidate(threshold=3)
214
+ n2 = brain.consolidate(threshold=3)
215
+ assert n1 >= 1 and n2 == 0 # plus de re-promotion
216
+
217
+ # tests/test_gates.py (ajout)
218
+ def test_gate_catches_dynamic_import_patterns(tmp_path):
219
+ fake = tmp_path / "fake.py"
220
+ fake.write_text('import importlib\nimportlib.import_module("torch")\n', encoding="utf-8")
221
+ text = fake.read_text(encoding="utf-8")
222
+ assert ("torch" in text and ("import_module" in text or "__import__" in text))
223
+ ```
224
+
225
+ (le test gate documente le motif ; la détection réelle dans `test_torch_isolated_in_model_dir` est étendue aux mêmes motifs sur les fichiers du package.)
226
+
227
+ - [x] **Step 2-5** : verify fail → implémentation (forget + motifs) → suites vertes → commit `fix: consolidate idempotent + gate detects dynamic torch imports`.
228
+
229
+ ---
230
+
231
+ ## Definition of Done — Plan 4
232
+
233
+ - [x] Suites vertes : mingw (torch/tokenizers skippés) et `.venv-torch` (tout, `-m "not slow"`)
234
+ - [x] `Gpt2BpeTokenizer` : encodages GPT-2 canoniques exacts (31373…), roundtrip, safe decode
235
+ - [x] `generate` s'arrête sur EOS sans le consommer
236
+ - [x] `:core` parle BPE (fallback honnête), lignes `[NOYAU]` sans surrogate crash
237
+ - [x] `core_speak` exécuté une fois sur le vrai checkpoint, sortie verbatim documentée (word salad = honnête)
238
+ - [x] `consolidate` idempotent ; gate durci aux imports dynamiques
239
+ - [x] Gate : torch + tokenizers confinés dans `vorax/model/`
docs/heritage/plans/2026-08-18-vorax-p5-ctecore.md ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Vorax Plan 5 — « CteCore : le 1B branché » Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Implémenter `vorax/model/cte_core.py` — le noyau natif CTE (Continuous Thought Engine, 1.165B params) compatible strict avec `checkpoints/checkpoints/FRACTUS_1B_PHASE2_FROZEN_MERGED.pt` — et le brancher dans vocal/repl/core_speak à côté de FractusCore.
6
+
7
+ **Architecture:** Le checkpoint CTE (sondé : 440 clés, config d_model=1280/n_heads=20/n_levels=2/n_oscillators=16/coupling_rank=8/n_experts=128/top_k=2/expert_d_ff=2048/siren_rank=64/n_layers=16) étend le FractalBlock : attention linéaire avec **états porteurs persistants** (attn_S (2,1280,1280), attn_z (2,1280)), phases Kuramoto persistantes (kuramoto_phases (2,128,16)), MoE **batché** (U1 (128,2048,64), V1, U2, V2, scale1 (128,1,1), b1, b2, expert_phases (128,)), LayerNorms par sous-bloc, tête `observe` (50257→1280) en entrée, têtes `output/confidence/salience`. La source de vérité des sémantiques de forward est `../fractus-cte/fractus/continuous_engine.py` (LECTURE SEULE).
8
+
9
+ **Tech Stack:** torch CPU dans `.venv-torch`, confiné à `vorax/model/` (gate : torch + tokenizers).
10
+
11
+ **Prérequis :** Plans 1-4 livrés (111/6 mingw, 121 torch). Checkpoint local : `checkpoints/checkpoints/FRACTUS_1B_PHASE2_FROZEN_MERGED.pt` (4.66 GB, gitignored).
12
+
13
+ ## Global Constraints
14
+
15
+ - Siblings en LECTURE SEULE (continuous_engine.py lu, jamais modifié ni importé)
16
+ - torch confiné `vorax/model/` (gate), suites mingw/torch vertes à chaque tâche, tests torch skippés sans torch
17
+ - from_checkpoint strict=True sur les 440 clés — jamais strict=False
18
+ - Fidélité numérique d'abord : une implémentation qui charge mais diverge silencieusement est le pire échec (leçons P3 : GELU, x@W)
19
+ - Déterminisme (greedy par défaut) ; chiffres honnêtes (le checkpoint a 15,5M tokens processed — attendre du faible, le rapporter tel quel)
20
+ - Un commit par tâche ; TDD strict
21
+
22
+ ---
23
+
24
+ ### Task 1: Investigation CTE (`docs/cte-architecture.md`)
25
+
26
+ **Files:**
27
+ - Create: `docs/cte-architecture.md`
28
+ - Test: `tests/test_cte_probe.py`
29
+
30
+ **Interfaces:**
31
+ - Produces: le doc de référence complet — (a) arbre des modules et sémantique forward EXACTE tirée de `../fractus-cte/fractus/continuous_engine.py` : rôle de `observe` vs embedding, ordre des sous-blocs (attn → norm? kuramoto → norm? moe → norm? résidus ?), sens des shapes d'états (attn_S (2,1280,1280) : (n_levels, d, d) ? attn_z (2,1280) ; kuramoto_phases (2,128,16) : (n_levels, batch, n_oscillators) ?), mode inference (quels états reset/keep entre forwards, sched sampling absent), tête output (liée à observe.weight ? séparée ?), confiance/salience (lecture seule), format du MoE batché (indexation des experts, si GELU entre U1/V1 et U2/V2 — VÉRIFIER, leçon GELU), orientation x@W des projections (leçon P3), config `boost`/`ss_rate` (training-only ?) ; (b) tableau complet clés→shapes des 440 clés ; (c) recette d'inférence : comment générer token par token avec les états porteurs (reset initial ? update par pas ?) ; (d) toute divergence vs le FractalBlock de fractus_core.py.
32
+
33
+ - [x] **Step 1: Write the failing test** — `tests/test_cte_probe.py::test_cte_doc_exists_and_has_sections` : lit `docs/cte-architecture.md`, assert présence des ancres `## Forward`, `## États porteurs`, `## MoE batché`, `## Recette d'inférence`, `## Table des clés`.
34
+ - [x] **Step 2: Verify failure** (FileNotFoundError) dans mingw.
35
+ - [x] **Step 3: Investigation** — lire `../fractus-cte/fractus/continuous_engine.py` (+ `nn/` du CTE si utilisé), sonder le checkpoint local (440 clés, shapes), rédiger le doc. Toute ambiguïtre du code doit être tranchée et documentée avec la ligne source en référence.
36
+ - [x] **Step 4: Verify** — mingw suite verte (+1).
37
+ - [x] **Step 5: Commit** — `git add docs/cte-architecture.md tests/test_cte_probe.py && git commit -m "docs: CTE architecture spec — forward semantics, carrier states, batched MoE, inference recipe"`
38
+
39
+ ---
40
+
41
+ ### Task 2: `vorax/model/cte_core.py` — le noyau CTE
42
+
43
+ **Files:**
44
+ - Create: `vorax/model/cte_core.py`
45
+ - Test: `tests/test_cte_core.py`
46
+
47
+ **Interfaces:**
48
+ - Consumes: `docs/cte-architecture.md` (source de vérité)
49
+ - Produces (torch lazy, confiné) :
50
+ - `CteCoreConfig` dataclass : champs du checkpoint (d_model=1280, n_heads=20, d_head=64, n_levels=2, n_oscillators=16, coupling_rank=8, n_experts=128, top_k=2, expert_d_ff=2048, siren_rank=64, n_layers=16, vocab_size=50257, batch=2 — les états persistants sont batchés !) + defaults « tiny » pour les tests (d_model=64, n_heads=4, d_head=16, n_experts=4, expert_d_ff=128, siren_rank=16, n_layers=2, batch=1)
51
+ - `CteCore(config)` : nn.Module avec les noms de sous-modules reproduisant EXACTEMENT les 440 clés ; états persistants enregistrés comme buffers (thought_state, attn_S/z, kuramoto_phases) ; `reset_states()` ; `forward(token_ids)` (utilise et met à jour les états selon la recette du doc) ; `generate(token_ids, max_new_tokens, temperature=0.0, eos_token_id=None, seed=0)` (greedy déterministe, append-then-break sur EOS — sémantique ratifiée P4)
52
+ - `CteCore.from_checkpoint(path) -> CteCore` : lit `model_state` + infère la config depuis les shapes, strict=True, model.eval()
53
+ - Le GELU (si confirmé par le doc) entre les deux facteurs du MoE ; orientation x@W partout où le doc le dit
54
+
55
+ - [x] **Step 1: Write the failing tests** (skip sans torch) — tiny config : `test_forward_logits_shape` (B=1, L=8 → (1,8,50257-tiny)), `test_generate_greedy_deterministic`, `test_states_update_and_reset` (attn_S change après forward, reset_states restaure les init), `test_checkpoint_roundtrip` (save {"model_state": sd} → from_checkpoint strict → même génération), `@pytest.mark.slow test_real_checkpoint_strict_load` (le vrai 4.66GB : strict 440/440, forward B=batch finite logits).
56
+ - [x] **Step 2: Verify failure** (torch venv).
57
+ - [x] **Step 3: Implementation** — fidèle au doc T1 ; exécuter le slow réel une fois et documenter.
58
+ - [x] **Step 4: Verify** — torch venv non-slow pass, mingw skips, suites vertes.
59
+ - [x] **Step 5: Commit** — `git add vorax/model/cte_core.py tests/test_cte_core.py && git commit -m "feat: CteCore — native 1B CTE kernel, strict 440-key checkpoint load, carrier states"`
60
+
61
+ ---
62
+
63
+ ### Task 3: Branchement — le 1B parle
64
+
65
+ **Files:**
66
+ - Modify: `vorax/agent/repl.py` (`:core` détecte le type de checkpoint : clés `observe.weight`+`thought_state` → CteCore, sinon FractusCore — duck-typing via une sonde `probe_checkpoint_kind(path) -> str` dans cte_core), `bench/core_speak.py` (idem, affiche le type), `README.md` (section checkpoint CTE 1B)
67
+ - Test: `tests/test_cte_wiring.py`
68
+
69
+ **Interfaces:**
70
+ - Produces: `probe_checkpoint_kind(path) -> str` ("cte" | "fractus" | "unknown") — sonde lazy torch, lit seulement les clés du ckpt (torch.load map_location cpu, weights_only=False, sans matérialiser les tenseurs si possible — mmap=True si supporté) ; repl/core_speak choisissent la classe en conséquence ; test : un fake ckpt CteCore tiny → "cte" ; un fake FractusCore tiny → "fractus" ; fichier absent → erreur propre.
71
+ - [x] **Step 1: Failing tests** (torch venv, skip mingw) — kind probing sur deux fakes + wiring REPL `:core` avec fake CTE tiny attaché sans traceback.
72
+ - [x] **Step 2-5** : verify → implémentation → suites vertes → UNE exécution réelle : `.venv-torch/Scripts/python.exe bench/core_speak.py --ckpt checkpoints/checkpoints/FRACTUS_1B_PHASE2_FROZEN_MERGED.pt` — sortie verbatim dans le rapport (honnête : attendu faible à 15,5M tokens) → commit `feat: CteCore wired — probe, repl, core_speak, README`.
73
+
74
+ ---
75
+
76
+ ## Definition of Done — Plan 5
77
+
78
+ - [x] Suites vertes mingw + torch (`-m "not slow"`) ; gate intact (torch confiné)
79
+ - [x] `test_real_checkpoint_strict_load` : 440/440 clés strictes sur le vrai 1B, logits finis
80
+ - [x] `probe_checkpoint_kind` distingue CTE/Fractal ; `:core` branche le 1B ; core_speak l'évalue
81
+ - [x] Exécution réelle core_speak sur le 1B documentée verbatim (honnête)
82
+ - [x] Siblings intouchés ; checkpoint 4.66GB local gitignored
docs/heritage/specs/2026-08-17-vorax-design.md ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Vorax — Design
2
+
3
+ **Date** : 2026-08-17
4
+ **Statut** : validé en session de brainstorming (sections approuvées oralement)
5
+ **Nom** : vorax (provisoire, renommable — latin : « qui dévore tout »)
6
+
7
+ ---
8
+
9
+ ## 0. One-liner
10
+
11
+ Fractus naît une fois. Ensuite il n'apprend plus jamais par gradient — **il mange**.
12
+ Toute donnée est compilée en connaissance puis ingérée par écriture. Il grandit à
13
+ l'infini, physiquement.
14
+
15
+ ## 1. Décisions fondatrices
16
+
17
+ | # | Décision | Choix |
18
+ |----|-----------------|---------------------------------------------------------------------------------------------------------------------------------------|
19
+ | D1 | Vision | Un Fractus qui grandit à l'infini par ingestion ; jamais un gradient pour apprendre |
20
+ | D2 | Naissance | L'entraînement en cours (8× RTX 5090, fractus-cte) est le **dernier entraînement** de l'histoire du modèle ; après la naissance = 100 % ingestion |
21
+ | D3 | Jalon v1 | Agent généraliste **live** : absorbe fichiers/CSV/web pendant la session, devient expert en temps réel, converse/code/raisonne |
22
+ | D4 | Hardware | CPU-first ; GPU optionnel (accélère le noyau, jamais requis) |
23
+ | D5 | Isolation | Repo autonome. `fractus/`, `palimpseste/`, `ensemble/`, `prism/` ne sont **jamais modifiés** ; le code substrat est vendored avec attribution, zéro import cross-repo |
24
+ | D6 | Noyau natif | **AUCUN GGUF, AUCUN LLM externe, jamais.** Le noyau est fractus : architecture fractus (LazyStructuredSiren + MoE + attention linéaire + oscillateurs) réécrite from scratch dans vorax, qui charge le checkpoint fractus-cte **en lecture seule** dès qu'il est utilisable. Toute nouvelle connaissance = ingestion. Tant que le checkpoint n'a pas diplômé, le noyau tourne à vide et les organes portent les démos |
25
+ | D7 | Stack noyau | Le noyau natif fractus est implémenté en **torch (CPU)** — seule dépendance hors numpy, isolée dans `vorax/model/` — pour charger directement les checkpoints fractus-cte. Le substrat (compiler, organes, agent) reste **numpy + stdlib pur** |
26
+
27
+ ## 2. Architecture d'ensemble
28
+
29
+ ```
30
+ DONNÉES (CSV / JSON / JSONL / TXT / MD / code / web)
31
+
32
+
33
+ ┌──────────────────────┐
34
+ │ COMPILATEUR .kn │ une seule passe, forme close :
35
+ │ (knowledge compiler)│ hash + comptage + SVD — zéro gradient
36
+ └──────────┬───────────┘
37
+ │ ingestion (écriture O(1)/token)
38
+ ├────────────► ORGANE 1 · TRACES hippocampe : mémoire HV
39
+ │ append-only + LSH + consolidation
40
+ ├────────────► ORGANE 2 · HEBBIEN cortex : mémoire associative
41
+ │ en forme close sur facteurs low-rank U·Vᵀ
42
+ └────────────► ORGANE 3 · SPAWN croissance : chaque dataset
43
+ devient un expert routé (MoE)
44
+
45
+ QUESTION ──► organes → cartes de connaissance → NOYAU NATIF FRACTUS
46
+ │ (architecture fractus réécrite dans vorax,
47
+ │ checkpoint fractus-cte chargé en lecture seule)
48
+
49
+ └──► chaque échange écrit O(1) → le système apprend
50
+ de la conversation elle-même, sans gradient
51
+ ```
52
+
53
+ **Séparation sacrée** : compétence linguistique (noyau fractus, né une fois) vs
54
+ connaissance (organes, écrite en continu). Le noyau fournit le *comment parler* ;
55
+ les organes fournissent le *quoi dire*. Aucune couche linguistique n'est jamais écrite.
56
+
57
+ ## 3. Le compilateur `.kn` — la nouvelle méthode de conversion
58
+
59
+ Pipeline en une passe, tout en **calcul, pas optimisation** (hash déterministe,
60
+ comptage PPMI, une décomposition SVD ; aucune loss, aucune époque, aucun label) :
61
+
62
+ 1. **Parse auto** du format : CSV/TSV, JSON/JSONL, TXT, Markdown, code.
63
+ Format inconnu → fallback texte brut + avertissement.
64
+ 2. **Atomisation** : tout devient des atomes canoniques
65
+ `(énoncé, contexte, provenance, confiance)`.
66
+ 3. **Encodage HV** bipolaire packé (bind = XOR, similarité = 1 − 2·hamming/D) —
67
+ code vendored/adapté de la famille palimpseste, réécrit dans le repo.
68
+ 4. **Extraction structurelle** : patterns + slots (ex. `what is the capital of {X}`),
69
+ relations apprises par comptage (`capital_of: france→paris`) → l'analogie
70
+ 3CosAdd/3CosMul avec k-NN local (k=5) et filtre morphologique (préfixe commun
71
+ ≥ 5 chars exclu) généralise sur du **jamais vu**. Slots sémantiques optionnels :
72
+ embedding local PPMI+SVD (comptage + une décomposition, pas un entraînement)
73
+ projeté dans l'espace HV via matrice ±1 préservant le cosinus.
74
+ 5. **Empaquetage `.kn`** : généralisation du `.exp` — portable, compressé
75
+ (cible : plus petit que la source), rechargement **bit-identique**
76
+ (rejeu du flux dans l'encodeur hash-déterministe).
77
+
78
+ ## 4. Les trois organes
79
+
80
+ ### Organe 1 — Traces (hippocampe)
81
+ - Mémoire append-only d'hypervectors `(adresse, valeur, poids)` ; écriture O(1)/token.
82
+ - Retrieval LSH (K bits AND / L tables OR), candidats ~2-3 % de |M|.
83
+ - Consolidation par co-activation décroissée → promotion de concepts composites
84
+ `bind(addr_i, value_j)` : la mémoire **se compresse** avec le temps.
85
+ - Jamais d'oubli catastrophique : on n'écrase pas, on superpose.
86
+
87
+ ### Organe 2 — Hebbien (cortex)
88
+ - Mémoire associative clé→valeur écrite en **forme close** : produit externe et/ou
89
+ ridge `(KᵀK + λI)⁻¹KᵀV` sur des facteurs low-rank `W = scale·U·Vᵀ` dédiés.
90
+ - **v1** : l'organe vit côté Vorax ; son lecteur alimente le fabricant de cartes
91
+ (lookup associatif direct). Le noyau natif fractus étant embarqué dans vorax,
92
+ les mêmes écritures atterrissent dans les couches connaissance de son forward.
93
+ - Garde-fous : porte de Lyapunov (acceptation ssi ΔE ≤ 0), versioning append-only
94
+ des facteurs → rollback à tout moment.
95
+
96
+ ### Organe 3 — Spawn (croissance)
97
+ - Chaque dataset ingéré devient un **expert** (paquets de traces + facteurs + relations).
98
+ - Clé de routage **dérivée de la signature HV** du dataset (phase calculée, pas apprise).
99
+ - Le modèle grossit physiquement : nouveau fichier = nouvel expert = plus de capacité.
100
+ C'est le « grandir à l'infini ».
101
+ - Composition multi-experts : la pertinence d'un expert pour une question fixe le
102
+ couplage (dynamique d'oscillateurs héritée conceptuellement d'ensemble).
103
+
104
+ ## 5. L'agent live (jalon v1)
105
+
106
+ Boucle de session :
107
+
108
+ 1. **Entrée** : question texte OU dépôt de données (fichier, dossier, URL).
109
+ 2. **Dépôt** → file d'attente → compilation `.kn` en tâche de fond → ingestion
110
+ (traces + hebbien + spawn) → événements de statut visibles
111
+ (« +12 340 atomes ingérés, expert `medecine-3` spawné »).
112
+ 3. **Question** → encodage → retrieval tous organes → **cartes de connaissance** :
113
+ ```
114
+ [CARTE] FAIT: … (provenance, confiance)
115
+ [CARTE] ANALOGIE: X est à Y comme A est à ? → B (sim 0.74)
116
+ [CARTE] PATTERN: "what is the capital of {spain}" → slot connu
117
+ [CARTE] LACUNES: aucune trace pour Z (dire je ne sais pas)
118
+ ```
119
+ → le noyau natif fractus verbalise à partir des cartes.
120
+ 4. **Post-échange** : la conversation elle-même est écrite O(1) dans les traces —
121
+ le système apprend de la session en temps réel.
122
+
123
+ Interface : REPL terminal (v1). CPU-first : tout le substrat et le noyau natif
124
+ fractus sont CPU purs (le GPU du pod accélère si dispo, jamais requis).
125
+
126
+ ## 6. Format `.kn`
127
+
128
+ Conteneur (répertoire ou archive) :
129
+ - `manifest.json` — métadonnées, version de format, hash source, stats
130
+ - `atoms.tsv.gz` — atomes canoniques (énoncé, contexte, provenance, confiance)
131
+ - `tokens.bin.gz` — flux de tokens symboliques (rejeu déterministe)
132
+ - `relations.json` — relations apprises par comptage (pour l'analogie)
133
+ - `vocab.json` — vocabulaire de l'encodage
134
+ - `signature.hv` — signature HV du dataset (pour le spawn/routage)
135
+
136
+ Invariant : compilateur déterministe ⇒ même source ⇒ `.kn` bit-identique.
137
+
138
+ ## 7. Structure du repo
139
+
140
+ ```
141
+ vorax/
142
+ README.md
143
+ vorax/
144
+ compiler/
145
+ parse.py # auto-détection CSV/JSON/JSONL/TXT/MD/code + fallback
146
+ atoms.py # atomisation canonique
147
+ encode.py # HV bipolaires packés (vendored/adapté, attribution)
148
+ structural.py # patterns + slots
149
+ analogy.py # 3CosAdd/3CosMul, k-NN k=5, filtre morphologique
150
+ kn.py # écriture/lecture .kn, rejeu bit-identique
151
+ organs/
152
+ traces.py # mémoire append-only + LSH + consolidation
153
+ hebbian.py # écritures forme close sur facteurs low-rank + porte ΔE
154
+ spawn.py # experts + clés de routage dérivées de la signature
155
+ model/
156
+ vocal.py # noyau natif fractus : architecture réécrite dans vorax
157
+ cards.py # fabricant de cartes (consomme tous les organes)
158
+ vorax.py # assemblage complet : organes + noyau fractus
159
+ agent/
160
+ session.py # boucle live, file d'ingestion, statuts
161
+ ingest.py # pipeline d'ingestion en tâche de fond
162
+ cli.py # REPL
163
+ bench/ # benchmarks honnêtes (culture ensemble)
164
+ tests/
165
+ ```
166
+
167
+ ## 8. Gestion d'erreurs
168
+
169
+ | Situation | Comportement |
170
+ |-------------------------|------------------------------------------------------------------------------|
171
+ | Format inconnu | fallback texte brut + avertissement, jamais de crash de session |
172
+ | `.kn` corrompu | rejet + rapport ; la session continue sur l'état antérieur |
173
+ | Écriture hebbienne refusée par la porte ΔE | journalisée ; l'atome reste en trace (double écriture de sécurité) |
174
+ | Crash pendant ingestion | append-only ⇒ session continue sur l'état d'avant ; reprise idempotente |
175
+ | Noyau fractus indisponible (checkpoint pas prêt) | les organes répondent en mode cartes brutes (le substrat vit sans noyau) |
176
+
177
+ ## 9. Tests et benchmarks
178
+
179
+ **Unitaires**
180
+ - Déterminisme de l'encodage ; round-trip `.kn` bit-identique
181
+ - Rappel après N écritures ; test d'interférence (écrire 10k faits, relire 10k)
182
+ - Porte ΔE : une écriture dégradante est refusée ; rollback fonctionnel
183
+ - Consolidation : les concepts promisés sont retrievables, les traces sources aussi
184
+
185
+ **Killer bench**
186
+ - Ingérer un domaine en X minutes sur CPU → précision QA **supérieure à un LLM de
187
+ même taille non fine-tuné**, sans un seul gradient ; comparé aussi au fine-tuné
188
+ (honnêteté absolue sur l'écart restant).
189
+
190
+ **Demo live**
191
+ - Absorption de fichiers pendant une session, mesurée en temps réel.
192
+
193
+ ## 10. Non-goals
194
+
195
+ - Modifier quoi que ce soit dans `fractus/`, `palimpseste/`, `ensemble/`, `prism/`
196
+ (charger le checkpoint fractus-cte **en lecture** est autorisé ; aucun code n'y est importé).
197
+ - **Aucune dépendance à un LLM externe — ni GGUF, ni llama.cpp, ni API distante.
198
+ Le noyau est fractus, de la famille fractus uniquement.**
199
+ - Dépendre d'un GPU (optionnel uniquement).
200
+ - Fine-tuning, sous quelque forme que ce soit : si un apprentissage exige un
201
+ gradient, c'est un bug de design, pas une option.
202
+
203
+ ## 11. Risques de recherche ouverts (assumés honnêtement)
204
+
205
+ 1. **Fluidité sur du non-vu** : les organes interpolent, le noyau fractus fluidifie ;
206
+ la qualité de génération open-ended hors distribution restera inférieure à un
207
+ LLM entraîné dessus. Les benchmarks le diront sans maquillage.
208
+ 2. **Scaling mémoire** : cible ≤ 1× taille source après consolidation+dédup
209
+ (le `.exp` prouve 4-10× plus petit ; à confirmer à l'échelle GB).
210
+ 3. **Interférence hebbienne à grande échelle** : la porte ΔE protège, mais la
211
+ capacité effective des facteurs low-rank à 100k+ associations est un vrai
212
+ sujet de mesure.
docs/superpowers/plans/2026-08-18-fractus-vorax-relais.md ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Fractus-Vorax Plan — « Le Relais » Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Donner naissance à **Fractus-Vorax** : le repo définitif qui consolide tout ce qui a été prouvé dans `vorax` (substrat, organes, noyaux natifs, agent) en un être autonome — né du dernier entraînement de l'histoire (checkpoint PHASE2_FROZEN 4.66GB, le relais), qui n'apprend plus jamais par gradient et grandit uniquement par ingestion.
6
+
7
+ **Architecture:** Consolidation rebrandée du codebase vorax (package `fractus_vorax`, v1.0.0) — hv, compiler (.kn, analogie), organes (traces, hebbien, spawn, consolidation), brain, cards, model (FractusCore + CteCore + vocal + BPE), agent (cli, repl), bench, tests. Le cerveau de naissance vit dans `brain/` (checkpoint copié, gitignored). `vorax/` reste l'archive de recherche — jamais modifié.
8
+
9
+ **Tech Stack:** identique à vorax : numpy+stdlib (substrat), torch CPU + tokenizers (noyaux, confinés `fractus_vorax/model/`), double venv (mingw + natif 3.11).
10
+
11
+ **Prérequis :** vorax v1.2 livré (140/2 torch, 113/8 mingw). Checkpoint final : `C:\Users\PHIL\ZCodeProject\vorax\checkpoints\checkpoints\FRACTUS_1B_PHASE2_FROZEN_MERGED.pt` (le dernier poussé sur HF, 2026-08-18 04:20).
12
+
13
+ ## Global Constraints
14
+
15
+ - `vorax/` et tous les autres siblings : LECTURE SEULE (source de la consolidation, jamais modifiés)
16
+ - Fractus-Vorax est AUTOCONTENU : zéro import cross-repo, tout le code vit ici (attribution dans ATTRIBUTIONS.md)
17
+ - Gate conservé : torch + tokenizers confinés à `fractus_vorax/model/` ; suites vertes dans les DEUX venvs
18
+ - Le checkpoint de naissance est copié dans `brain/` (gitignored), chargé en lecture seule, strict 440/440
19
+ - Déterminisme, append-only, zéro LLM externe — les contrats sacrés de vorax restent sacrés
20
+ - Identité : v1.0.0, tagline « Fractus ne s'entraîne plus : il mange. »
21
+ - Un commit par tâche ; TDD où il y a du comportement nouveau ; prose FR, code EN
22
+
23
+ ---
24
+
25
+ ### Task 1: Migration consolidée — le repoFractus-Vorax
26
+
27
+ **Files:**
28
+ - Create: tout l'arbre `fractus_vorax/` (copie rebrandée de `vorax/vorax/`), `tests/`, `bench/`, `demo/`, `conftest.py`, `pyproject.toml`, `.gitignore`, `ATTRIBUTIONS.md`, `README.md` (manifest de naissance), `docs/` (specs+plans vorax copiés dans `docs/heritage/`)
29
+
30
+ **Interfaces:**
31
+ - Produces: package `fractus_vorax` v1.0.0 installable, TOUTES les interfaces vorax conservées à l'identique (renommage de module uniquement : `vorax.*` → `fractus_vorax.*`), suite complète verte dans un venv mingw neuf + un venv torch neuf (pip cache rendra torch rapide), gate vert, 13+ git commits d'histoire? NON — historique neuf propre : 1 commit initial de consolidation + les tâches suivantes.
32
+
33
+ - [x] **Step 1: Copie et rebrand** — copier `vorax/vorax/*` → `fractus_vorax/`, `tests/`, `bench/`, `demo/`, `conftest.py` depuis vorax ; rebrand : `import vorax`/`from vorax` → `fractus_vorax` partout (imports, tests, bench, demo) ; pyproject name=fractus-vorax, version 1.0.0, script `fractus-vorax = "fractus_vorax.agent.cli:main"` ; .gitignore (+`brain/`, `checkpoints/`, `.venv*/`, `.superpowers/`, `bench/out/`) ; ATTENTION hook : les réécritures de fichiers .py doivent passer par Write/Edit (pas de sed/redirect bash) — un script python exécuté pour le rebrand peut être bloqué : si bloqué, faire les fichiers un par un avec les outils dédiés.
34
+ - [x] **Step 2: Venvs neufs** — `.venv` (mingw : numpy+pytest via pacman déjà présent, pip install -e .[dev] impossible PEP668 → installer pytest via pacman si absent et tester via conftest sys.path) — PLUS SIMPLE : documenter que les tests tournent depuis la racine via conftest (comme vorax : mingw venv utilisé avec sys.path) ; créer `.venv-torch` natif 3.11 (torch cpu + tokenizers + numpy + pytest via pip, wheels cachées).
35
+ - [x] **Step 3: Vérifier** — suites complètes vertes dans les 2 venvs (mêmes comptes que vorax : ~113/8 mingw, 140/2 torch) ; gate vert.
36
+ - [x] **Step 4: ATTRIBUTIONS.md + README manifest** — attribution vorax (et la lignée fractus/palimpseste/ensemble par extension) ; README : identité, quickstart, statut, le manifeste du relais (« l'entraînement s'arrête ici »).
37
+ - [x] **Step 5: Commit** — `git add -A && git commit -m "feat: fractus-vorax v1.0.0 — consolidation du relais (heritage: vorax v1.2)"`
38
+
39
+ ---
40
+
41
+ ### Task 2: Le cerveau de naissance
42
+
43
+ **Files:**
44
+ - Create: `brain/FRACTUS_BIRTH.pt` (copie du checkpoint final, gitignored), `docs/NAISSANCE.md`
45
+ - Modify: `README.md`
46
+
47
+ **Interfaces:**
48
+ - Produces: le checkpoint final copié (4.66GB) à `brain/FRACTUS_BIRTH.pt` ; vérification UNE fois : `probe_checkpoint_kind` → "cte", `CteCore.from_checkpoint` strict 440/440, forward logits finis (documenté verbatim dans NAISSANCE.md avec la date, le sha256 du fichier (long mais OK en appendice), tokens_processed lu du config, et le pacte : « ce fichier ne sera JAMAIS réentraîné — toute connaissance nouvelle arrive par ingestion ») ; README pointe `:core brain/FRACTUS_BIRTH.pt`.
49
+
50
+ - [x] **Step 1: Copier + vérifier** (script jetable .superpowers/scratch, sortie verbatim capturée)
51
+ - [x] **Step 2: NAISSANCE.md** — acte de naissance documenté
52
+ - [x] **Step 3: Commit** — `git add docs/NAISSANCE.md README.md && git commit -m "feat: cerveau de naissance — checkpoint final installé, pacte d'ingestion"` (le .pt est gitignored)
53
+
54
+ ---
55
+
56
+ ### Task 3: L'inauguration — démo du relais
57
+
58
+ **Files:**
59
+ - Create: `demo/inauguration.py`
60
+ - Test: `tests/test_inauguration.py`
61
+ - Modify: `README.md` (section L'inauguration)
62
+
63
+ **Interfaces:**
64
+ - Produces: `inauguration() -> list[str]` — LA démo : (1) statut du cerveau de naissance (chargé, type, params), (2) ingestion d'un corpus domaine, (3) question exacte (FAIT/HEBBIEN), (4) typo (ANALOGIE), (5) session REPL scriptée qui nourrit le brain, (6) si `.venv-torch` + cerveau présent : `[NOYAU]` ligne de génération sur une question (honnête, word salad accepté), (7) ligne finale `[FRACTUS-VORAX] Le relais est pris. L'entraînement est fini. Il mange.` — console-safe ASCII. Test : marqueurs présents (skip torch proprement).
65
+
66
+ - [x] **Step 1: Failing test** → **Step 2: implémentation** → **Step 3: suites vertes** → **Step 4: run réel documenté verbatim** → **Step 5: Commit** `feat: inauguration — the takeover demo`
67
+
68
+ ---
69
+
70
+ ## Definition of Done — Le Relais
71
+
72
+ - [x] Repo `fractus-vorax` autonome : suites vertes double venv, gate vert, zéro import cross-repo
73
+ - [x] Cerveau de naissance installé et vérifié strict (440/440), pacte écrit
74
+ - [x] Inauguration exécutée en réel, sortie verbatim documentée
75
+ - [x] `vorax/` et siblings intacts ; README manifeste + ATTRIBUTIONS
76
+ - [x] Git log propre (3+ commits : consolidation, naissance, inauguration)
docs/superpowers/plans/2026-08-18-p6-mecanique-parole.md ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Fractus-Vorax Plan 6 — « La mécanique de la parole » Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Faire parler le cerveau scellé par la MÉCANIQUE, pas par l'entraînement : une suite de décodage anti-attracteur (z-norm des logits + pénalité de répétition + top-k/p, prouvée empiriquement sur le 1B réel — elle libère le lexique : `philosophy`, `manufactures`, `surveyed`...), l'observabilité à ciel ouvert (experts qui tirent, têtes confiance/salience, phases), et le pilotage par les organes (biais de logits vers les tokens-réponses des cartes).
6
+
7
+ **Architecture:** `fractus_vorax/model/speak.py` — boucle de décodage mécanique sur CteCore/FractusCore (duck-typés), transformant les logits avant softmax : z-normalisation (écrase l'échelle folle des états-attracteurs, std mesurée 26 → calme, centaines → attracteur), pénalité de répétition sur l'ensemble des tokens émis (divise le logit), top-k/top-p optionnels, température, generator seedé. `fractus_vorax/model/diagnostics.py` — lecture ciel ouvert. Steering : `bias_tokens: dict[int, float]` ajouté aux logits (les organes orientent la bouche).
8
+
9
+ **Faits établis (expériences du 2026-08-18, cerveau scellé, verbatim au ledger)** : greedy → ` the`×16 (attracteur) ; t=1.0 sampling → identique (gap de logits trop grand) ; top-8 premier pas quasi-plat (écart 8 pts) ; z-norm×3 + rep_pen 6-12 → lexique réel anglais. Chinchilla ne s'applique pas (MoE structuré) — framing corrigé dans le README.
10
+
11
+ **Tech Stack:** torch confiné `fractus_vorax/model/` ; suites double venv vertes ; gate intact.
12
+
13
+ ## Global Constraints
14
+
15
+ - Aucun gradient, jamais — le pacte : on ne touche PAS aux poids, seulement aux logits/décodage/états de travail
16
+ - torch/tokenizers confinés au package `model/` ; mingw sans torch reste vert (tests skippés)
17
+ - Déterminisme : seeds explicites, mêmes seeds ⇒ mêmes paroles ; diagnostics reproductibles
18
+ - Honnêteté radicale : les sorties du 1B sont rapportées VERBATIM (lexique ≠ syntaxe — le dire)
19
+ - Un commit par tâche ; TDD ; prose FR, code EN
20
+
21
+ ---
22
+
23
+ ### Task 1: `speak.py` — la boucle de décodage mécanique
24
+
25
+ **Files:**
26
+ - Create: `fractus_vorax/model/speak.py`
27
+ - Test: `tests/test_speak.py`
28
+
29
+ **Interfaces:**
30
+ - Produces (torch lazy, dans model/) :
31
+ - `znorm_logits(logits: "torch.Tensor", scale: float = 3.0) -> "torch.Tensor"` — (x−mean)/std·scale, pur
32
+ - `apply_rep_penalty(logits, emitted: set[int], penalty: float = 6.0)` — logits[t] /= penalty pour t ∈ emitted
33
+ - `apply_top_k(logits, k)`, `apply_top_p(logits, p)` (masque à −inf hors sélection)
34
+ - `speak(core, tokenizer, prompt: str, max_new_tokens=16, temperature=1.0, rep_pen=6.0, z_scale=3.0, top_k: int|None=None, top_p: float|None=None, seed=0, eos_token_id: int|None=None, bias_tokens: dict[int, float]|None=None) -> tuple[str, list[dict]]` — texte + diagnostics par pas {logit_std_avant, top3: [(token, logit)], chosen: str} ; boucle : forward → znorm → rep-pen → bias (+δ sur ids) → top-k/p → softmax/temp → multinomial(generator seedé) ; EOS append-then-break strippé au décodage ; reset_states(1) initial
35
+ - Tests (importorskip torch, tiny CteCore réel) : znorm/rep-pen/top-k/top-p unitaires sur tenseurs connus ; speak déterministe (même seed ⇒ même texte) ; speak avec rep_pen élevé ne répète JAMAIS le même token 3× de suite ; bias_tokens fort (δ=+50 sur un id) fait choisir cet id au premier pas de façon écrasante (10 seeds).
36
+
37
+ ### Task 2: `diagnostics.py` — l'observabilité à ciel ouvert
38
+
39
+ **Files:**
40
+ - Create: `fractus_vorax/model/diagnostics.py`
41
+ - Modify: `fractus_vorax/model/cte_core.py` (exposer la lecture du routage : la passe MoE doit pouvoir rapporter les top-k experts — ajouter un mode `return_routing` LÉGER sur le bloc CTE OU recalculer les gates depuis kuramoto_phases/norm_moe en reproduisant la formule — préférer le recalcul extérieur : zéro changement du noyau)
42
+ - Test: `tests/test_diagnostics.py`
43
+
44
+ **Interfaces:**
45
+ - `expert_routing_snapshot(core, token_ids) -> dict` — {layer: [(expert_id, gate_weight), ...top-2]} recalculé depuis les phases (formule du doc §MoE : mean-phase von Mises κ_eff=1.6 → top-2 renormalisé), sans toucher aux poids
46
+ - `head_readout(core, token_ids) -> {"confidence": float, "salience": float}` (les têtes du CTE ; fractus → None)
47
+ - Tests : tiny CteCore → structure valide, 2 experts/layer, poids sommant à 1 ; head_readout borné.
48
+
49
+ ### Task 3: Steering organique — les cartes dirigent la bouche
50
+
51
+ **Files:**
52
+ - Modify: `fractus_vorax/model/speak.py` (déjà `bias_tokens`), `fractus_vorax/brain.py`
53
+ - Test: `tests/test_steering.py`
54
+
55
+ **Interfaces:**
56
+ - `Brain.speak_bias(question: str, k: int = 3, boost: float = 8.0) -> dict[int, float]` — ask_cards → tokens BPE des réponses (contexts + ANALOGIE) → {token_id: boost} (premier token de chaque réponse, boost décroissant par rang)
57
+ - Test : brain avec capitales → speak_bias('what is the capital of france') contient le token BPE de ' paris' avec boost > 0 ; intégration mini : speak(tiny, ..., bias_tokens=brain.speak_bias(...)) s'exécute.
58
+
59
+ ### Task 4: Intégration — `:speak`, core_speak v2, README, réel
60
+
61
+ **Files:**
62
+ - Modify: `fractus_vorax/agent/repl.py` (commande `:speak <texte>` : cartes du brain + steering + speak, lignes `[PAROLE]`), `bench/core_speak.py` (mode mécanique : speak() en plus de greedy, colonnes steered/unsteered + diagnostics), `README.md`, `demo/inauguration.py` (étape 6 passe à speak mécanique)
63
+ - Test: `tests/test_speak_integration.py` (REPL `:speak` produit une ligne `[PAROLE]` sans traceback, skip-proof mingw)
64
+
65
+ - Réel (documenter verbatim) : `:speak` et core_speak v2 sur le 1B scellé — 4 questions × {greedy, mécanique, mécanique+steering} ; rapporter honnêtement (lexique libéré, syntaxe absente — et le taux de tokens-réponse steered vs unsteered).
66
+
67
+ ---
68
+
69
+ ## Definition of Done — Plan 6
70
+
71
+ - [x] Suites vertes double venv, gate intact
72
+ - [x] speak() déterministe, anti-attracteur prouvé sur tiny (pas de triple répétition à rep_pen élevé)
73
+ - [x] Diagnostics ciel ouvert : experts/confiance/salience lisibles sans toucher aux poids
74
+ - [x] Steering : le bias organique augmente la présence des tokens-réponse (mesuré, honnête)
75
+ - [x] `:speak` dans le REPL + core_speak v2 + README verbatim du 1B réel
76
+ - [x] Zéro gradient. Le pacte tient (poids en lecture seule mmap).
docs/superpowers/plans/2026-08-18-p7-parler-ce-soir.md ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Fractus-Vorax Plan 7 — « Parler ce soir » Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Ce soir, le CTE PARLE : réponses articulées aux questions dont les organes connaissent la réponse — le verrou de réponse multi-tokens guide la génération token par token À TRAVERS la distribution du noyau (le lexique et les transitions sortent du 1B, seule la réponse est verrouillée par les organes), plus continuation libre après la réponse.
6
+
7
+ **Architecture:** Extension de speak() : `answer_lock: list[int] | None` + `answer_boost: float` — à l'étape i (< len(answer_lock)), bias +boost sur answer_lock[i] après z-norm/rep-pen. `Brain.speak_answer(question, tokenizer)` → tokens BPE de " "+réponse (top carte FAIT/HEBBIEN). Prompt d'articulation : `"{question} The answer is"` — le noyau produit la phrase, le verrou place la réponse, la suite est libre (mécanique anti-attracteur). REPL `:say <question>` = le combo complet. Démo `demo/ce_soir.py` + réel verbatim + README.
8
+
9
+ **Contraintes** : disque protégé (RIEN de nouveau de gros — 13 GB devront suffire) ; zéro gradient ; suites vertes ; honnêteté (le verrou est dit comme verrou — les organes articulent À TRAVERS le noyau) ; un commit par tâche.
10
+
11
+ ---
12
+
13
+ ### Task 1: Verrou de réponse + :say + démo ce soir
14
+
15
+ **Files:**
16
+ - Modify: `fractus_vorax/model/speak.py` (answer_lock/answer_boost), `fractus_vorax/brain.py` (speak_answer), `fractus_vorax/agent/repl.py` (`:say`), Create: `demo/ce_soir.py`, Modify: `README.md`
17
+ - Test: `tests/test_answer_lock.py`
18
+
19
+ **Interfaces:**
20
+ - `speak(..., answer_lock: list[int] | None = None, answer_boost: float = 12.0)` — à l'étape i, si i < len(answer_lock) : bias answer_lock[i] += answer_boost (après transforms, avant top-k/p). Ids validés vocab.
21
+ - `Brain.speak_answer(question: str, tokenizer, k: int = 3) -> list[int]` — ask_cards ; première carte FAIT/HEBBIEN avec context → tokenizer.encode(" " + context) (liste COMPLÈTE) ; ANALOGIE si aucune FAIT ; LACUNE → [].
22
+ - REPL `:say <question>` : requiert noyau+tokenizer+cereau ; lignes : `[ORGANES] {stmt} => {answer}`, `[PAROLE] {question} The answer is {génération mécanique avec verrou}` — prompt d'articulation = f"{question} The answer is", speak(max_new_tokens=12, answer_lock=brain.speak_answer(...)); dégradations honnêtes sans noyau/cerveau.
23
+ - `demo/ce_soir.py` : `main() -> list[str]` — (1) statut cerveau, (2) pour les 4 capitales : :say complet (organe + parole verrouillée), (3) 2 samples de libre parole mécanique (sans verrou) pour montrer le lexique, (4) ligne finale `[FRACTUS-VORAX] Le noyau parle. Les organes savent. Zéro gradient.` Console-safe, tmpdir.
24
+ - Tests : answer_lock unitaire (étape i bias token i — 10 seeds, boost 12 → le token verrouillé est choisi à chaque étape i sur tiny core) ; speak_answer (capitals → encode(" tokyo") pour japan) ; :say dégradation sans noyau (mingw-safe) ; intégration tiny avec fake tokenizer.
25
+ - Réel (verbatim report+README) : demo/ce_soir.py sur le 1B scellé en .venv-torch. ~6s/token × ~60 tokens ≈ 6-8 min max. Si > 10 min, réduire à 3 capitales + 1 libre.
26
+ - Commit: `feat: answer lock — the organs articulate through the core's mouth` puis `docs: ce soir le noyau parle — real verbatim`.
27
+
28
+ ### Definition of Done
29
+ - [x] Suites vertes double venv ; disque non entamé (vérif df avant/après)
30
+ - [x] Verrou prouvé : les 4 capitales répondues avec le bon mot dans la génération du 1B
31
+ - [x] Libre parole : lexique réel, honnête (syntaxe absente si absente)
32
+ - [x] README verbatim + framing honnête (le verrou est le mécanisme, pas de la triche cachée)
fractus_vorax/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # fractus_vorax/__init__.py
2
+ __version__ = "1.0.0"
fractus_vorax/agent/__init__.py ADDED
File without changes
fractus_vorax/agent/cli.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # fractus_vorax/agent/cli.py
2
+ """CLI Fractus-Vorax : ingest (il mange), ask (il répond), status (il compte)."""
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from ..brain import Brain
8
+ from ..compiler.kn import compile_kn, load_kn, source_sha256
9
+ from ..hv import D_DEFAULT, SEED_DEFAULT
10
+
11
+
12
+ def ingest(
13
+ source: Path,
14
+ brain_path: Path,
15
+ D: int = D_DEFAULT,
16
+ seed: int = SEED_DEFAULT,
17
+ kn_cache: Path | None = None,
18
+ ) -> int:
19
+ cache = kn_cache if kn_cache is not None else brain_path.parent / "kn"
20
+ kn_dir = cache / f"{source.stem}.kn"
21
+ manifest_path = kn_dir / "manifest.json"
22
+ stale = True # pas de manifest → recompiler (cache absent, illisible ou source changée)
23
+ if manifest_path.exists():
24
+ try:
25
+ cached = json.loads(manifest_path.read_text(encoding="utf-8"))
26
+ stale = cached.get("source_sha256") != source_sha256(source)
27
+ except (OSError, ValueError):
28
+ stale = True
29
+ if stale:
30
+ compile_kn(source, kn_dir, D=D, seed=seed)
31
+ atoms = load_kn(kn_dir)
32
+ brain = Brain.load(brain_path) if (brain_path / "meta.json").exists() else Brain(D=D, seed=seed)
33
+ n = brain.ingest_source(source.stem, atoms)
34
+ brain.save(brain_path)
35
+ if n > 0:
36
+ print(f"[VORAX] +{n} atomes ingérés depuis {source.name} → expert '{source.stem}' spawné (total: {len(brain)})")
37
+ else:
38
+ # NB : le message ne contient volontairement pas « spawné » — un batch
39
+ # vide ne déclenche pas ExpertRegistry.spawn (gardé par `if atoms:`).
40
+ print(f"[VORAX] +0 atome ingéré depuis {source.name} (source vide — aucun expert)")
41
+ return n
42
+
43
+
44
+ def ask(question: str, brain_path: Path, k: int = 3) -> list[str]:
45
+ if not (brain_path / "meta.json").exists():
46
+ return ["[CARTE] LACUNES: aucune trace (brain vide ou absent)"]
47
+ brain = Brain.load(brain_path)
48
+ cards = brain.ask_cards(question, k=k)
49
+ if not cards:
50
+ return ["[CARTE] LACUNES: aucune trace pour cette question"]
51
+ return [f"[CARTE] {c.kind}: {c.text} (sim {c.sim:.2f} | src: {c.source})" for c in cards]
52
+
53
+
54
+ def status(brain_path: Path) -> str:
55
+ if not (brain_path / "meta.json").exists():
56
+ return "[VORAX] brain absent"
57
+ brain = Brain.load(brain_path)
58
+ n_relations = sum(len(rel) for rel in brain.relations.values())
59
+ organs = (
60
+ f"[VORAX] organes: {len(brain.experts)} experts, {n_relations} relations, "
61
+ f"{brain.hebbian.n_writes} écritures hebbiennes, {brain.coact.stats()['pairs']} paires co-actives"
62
+ )
63
+ return f"[VORAX] brain: {len(brain)} atomes, D={brain.D}, seed={brain.seed}\n{organs}"
64
+
65
+
66
+ def main(argv: list[str] | None = None) -> int:
67
+ parser = argparse.ArgumentParser(prog="fractus_vorax", description="Il ne s'entraîne plus : il mange.")
68
+ sub = parser.add_subparsers(dest="cmd", required=True)
69
+
70
+ p_ing = sub.add_parser("ingest", help="compiler et ingérer une source")
71
+ p_ing.add_argument("source", type=Path)
72
+ p_ing.add_argument("--brain", type=Path, default=Path("./brain"))
73
+ p_ing.add_argument("--D", type=int, default=D_DEFAULT)
74
+ p_ing.add_argument("--seed", type=int, default=SEED_DEFAULT)
75
+ p_ing.add_argument("--kn-cache", type=Path, default=None)
76
+
77
+ p_ask = sub.add_parser("ask", help="poser une question au brain")
78
+ p_ask.add_argument("question", type=str)
79
+ p_ask.add_argument("--brain", type=Path, default=Path("./brain"))
80
+ p_ask.add_argument("--k", type=int, default=3)
81
+
82
+ p_st = sub.add_parser("status", help="état du brain")
83
+ p_st.add_argument("--brain", type=Path, default=Path("./brain"))
84
+
85
+ args = parser.parse_args(argv)
86
+ try:
87
+ if args.cmd == "ingest":
88
+ ingest(args.source, args.brain, D=args.D, seed=args.seed, kn_cache=args.kn_cache)
89
+ elif args.cmd == "ask":
90
+ for line in ask(args.question, args.brain, k=args.k):
91
+ print(line)
92
+ elif args.cmd == "status":
93
+ print(status(args.brain))
94
+ except (OSError, ValueError, UnicodeDecodeError, json.JSONDecodeError) as exc:
95
+ print(f"[VORAX] ERREUR: {exc}")
96
+ return 1
97
+ return 0
98
+
99
+
100
+ if __name__ == "__main__":
101
+ raise SystemExit(main())
fractus_vorax/agent/repl.py ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # fractus_vorax/agent/repl.py
2
+ """REPL live Fractus-Vorax (Plan 3, tâche 5) : il mange, il répond, il se souvient.
3
+
4
+ Chaque question posée au REPL nourrit le brain en O(1) : l'échange
5
+ (question, meilleure réponse) est écrit comme un atome de provenance
6
+ ``"session"`` puis sauvegardé (spec §5.4 — la conversation EST une source).
7
+
8
+ GATE torch : ce fichier n'importe jamais torch directement — le noyau
9
+ n'est touché que via ``core_available()`` (sonde sûre) et un ``:core``
10
+ optionnel qui charge un checkpoint seulement si torch vit dans ce python.
11
+ ``:core`` charge le tokenizer BPE GPT-2 (ids exacts fractus) quand la lib
12
+ ``tokenizers`` vit ici (``bpe_available``, sonde paresseuse), sinon repli
13
+ CharTokenizer avec message honnête. Toutes les lignes ``[NOYAU]`` passent
14
+ par ``safe_decode_text`` : jamais de surrogate à l'impression.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import io
20
+ from contextlib import redirect_stdout
21
+ from pathlib import Path
22
+
23
+ from ..brain import Brain
24
+ from ..compiler.atoms import Atom
25
+ from ..hv import D_DEFAULT, SEED_DEFAULT
26
+ from ..model.bpe_tokenizer import Gpt2BpeTokenizer, bpe_available # module lazy-safe (gate torch)
27
+ from ..model.vocal import CharTokenizer, core_available, safe_decode_text, verbalize
28
+ from . import cli
29
+
30
+
31
+ class Repl:
32
+ """Agent interactif : feed() retourne des lignes, run() les imprime."""
33
+
34
+ def __init__(self, brain_path: Path, D: int = D_DEFAULT, seed: int = SEED_DEFAULT) -> None:
35
+ self.brain_path = Path(brain_path)
36
+ self.D = D
37
+ self.seed = seed
38
+ if (self.brain_path / "meta.json").exists():
39
+ self.brain = Brain.load(self.brain_path)
40
+ else:
41
+ self.brain = Brain(D=D, seed=seed)
42
+ self.core = None # noyau fractus optionnel (via :core <ckpt>)
43
+ self.tokenizer = None
44
+ self._running = False
45
+
46
+ # ------------------------------------------------------------------
47
+ # Commandes et questions
48
+ # ------------------------------------------------------------------
49
+
50
+ def feed(self, line: str) -> list[str]:
51
+ """Traite une ligne : commande ``:...`` ou question. Jamais de print."""
52
+ line = line.strip()
53
+ if not line:
54
+ return []
55
+ if line == ":quit":
56
+ self._running = False
57
+ return []
58
+ if line.startswith(":"):
59
+ parts = line.split(maxsplit=1)
60
+ cmd = parts[0]
61
+ rest = parts[1].strip() if len(parts) > 1 else ""
62
+ if cmd == ":ingest":
63
+ return self._cmd_ingest(rest)
64
+ if cmd == ":status":
65
+ return self._cmd_status()
66
+ if cmd == ":consolidate":
67
+ return self._cmd_consolidate()
68
+ if cmd == ":core":
69
+ return self._cmd_core(rest)
70
+ if cmd == ":speak":
71
+ return self._cmd_speak(rest)
72
+ if cmd == ":say":
73
+ return self._cmd_say(rest)
74
+ return [f"[VORAX] commande inconnue: {cmd}"]
75
+ return self._answer(line)
76
+
77
+ def _cmd_ingest(self, rest: str) -> list[str]:
78
+ if not rest:
79
+ return ["[VORAX] usage: :ingest <path>"]
80
+ try:
81
+ buf = io.StringIO()
82
+ with redirect_stdout(buf): # cli.ingest imprime — on capture
83
+ cli.ingest(Path(rest), self.brain_path, D=self.D, seed=self.seed)
84
+ lines = [l for l in buf.getvalue().splitlines() if l.strip()]
85
+ self.brain = Brain.load(self.brain_path) # résumer l'état disque
86
+ return lines
87
+ except (OSError, ValueError, UnicodeDecodeError) as exc:
88
+ return [f"[VORAX] ERREUR: {exc}"]
89
+
90
+ def _cmd_status(self) -> list[str]:
91
+ n_relations = sum(len(rel) for rel in self.brain.relations.values())
92
+ return [
93
+ f"[VORAX] brain: {len(self.brain)} atomes, D={self.brain.D}, seed={self.brain.seed}",
94
+ f"[VORAX] organes: {len(self.brain.experts)} experts, {n_relations} relations, "
95
+ f"{self.brain.hebbian.n_writes} écritures hebbiennes",
96
+ ]
97
+
98
+ def _cmd_consolidate(self) -> list[str]:
99
+ n = self.brain.consolidate()
100
+ self.brain.save(self.brain_path)
101
+ return [f"[VORAX] consolidate: {n} promotions"]
102
+
103
+ def _cmd_core(self, rest: str) -> list[str]:
104
+ if not rest:
105
+ return [safe_decode_text("[NOYAU] usage: :core <ckpt_path>")]
106
+ if not core_available():
107
+ return [safe_decode_text("[NOYAU] indisponible: torch absent de ce python")]
108
+ path = Path(rest)
109
+ try:
110
+ # Sonde d'espèce (clés seules, lazy torch) : le REPL ne sait pas
111
+ # ce que contient le checkpoint — il le demande avant d'attacher.
112
+ from ..model.cte_core import probe_checkpoint_kind # tardif : gate torch
113
+
114
+ kind = probe_checkpoint_kind(path)
115
+ except Exception as exc: # fichier absent/corrompu — dégradation honnête
116
+ return [safe_decode_text(f"[NOYAU] indisponible: {exc}")]
117
+ try:
118
+ if kind == "cte":
119
+ from ..model.cte_core import CteCore # import tardif : gate torch
120
+
121
+ core = CteCore.from_checkpoint(path)
122
+ type_line = "[NOYAU] type: cte (1B Continuous Thought Engine)"
123
+ elif kind == "fractus":
124
+ from ..model.fractus_core import FractusCore # tardif : gate torch
125
+
126
+ core = FractusCore.from_checkpoint(path)
127
+ type_line = "[NOYAU] type: fractus"
128
+ else: # inconnu : ligne honnête, AUCUN attach
129
+ return [safe_decode_text(
130
+ f"[NOYAU] type de checkpoint inconnu: {path} "
131
+ "(ni cte ni fractus) — noyau non attaché")]
132
+ except Exception as exc: # load strict échoué — dégradation honnête
133
+ return [safe_decode_text(f"[NOYAU] indisponible: {exc}")]
134
+ # Tokenizer : BPE GPT-2 (ids exacts fractus) si la lib vit dans ce
135
+ # python ; sinon CharTokenizer de secours, message honnête. Aucune
136
+ # étape ne peut faire sortir un traceback de :core.
137
+ notes: list[str] = []
138
+ if bpe_available():
139
+ try:
140
+ self.tokenizer = Gpt2BpeTokenizer()
141
+ except Exception as exc: # cache HF vide + réseau injoignable — repli
142
+ self.tokenizer = CharTokenizer(
143
+ vocab_size=int(getattr(core.config, "vocab_size", 0x110000))
144
+ )
145
+ notes.append(safe_decode_text(
146
+ f"[NOYAU] tokenizer BPE indisponible — CharTokenizer de secours ({exc})"))
147
+ else:
148
+ self.tokenizer = CharTokenizer(
149
+ vocab_size=int(getattr(core.config, "vocab_size", 0x110000))
150
+ )
151
+ notes.append(safe_decode_text(
152
+ "[NOYAU] tokenizer BPE indisponible — CharTokenizer de secours"))
153
+ self.core = core # noyau + tokenizer attachés ensemble ou pas du tout
154
+ return [
155
+ safe_decode_text(type_line),
156
+ safe_decode_text(f"[NOYAU] noyau chargé: {rest}"),
157
+ *notes,
158
+ ]
159
+
160
+ def _cmd_speak(self, rest: str) -> list[str]:
161
+ """:speak <texte> — la parole MÉCANIQUE du noyau piloté par les organes.
162
+
163
+ Trois lignes ``[PAROLE]`` : les cartes du brain sur la question
164
+ (``ask_cards``, k=3), le steering organique (``speak_bias`` : les
165
+ tokens-réponses que les cartes injectent dans la bouche), puis la
166
+ génération ``speak()`` (z-norm ×3, rep-pen 6, t=1.0, seed 7, 8
167
+ tokens, bias organique) sur le préfixe protocolaire des cartes.
168
+
169
+ GATE torch : ``speak`` importe torch au niveau module — import
170
+ TARDIF ici, atteint seulement si un noyau est attaché (``:core`` ne
171
+ l'attache que si torch vit dans ce python). Sans noyau : la ligne
172
+ honnête ``noyau non attaché`` — le substrat mingw reste propre.
173
+ Toute ligne passe par ``safe_decode_text`` (console-safe), et aucun
174
+ échec du noyau ne fait sortir un traceback de :speak.
175
+ """
176
+ if not rest:
177
+ return ["[PAROLE] usage: :speak <texte>"]
178
+ if self.core is None or self.tokenizer is None:
179
+ return ["[PAROLE] noyau non attaché (:core d'abord)"]
180
+ try:
181
+ from ..model.speak import speak # tardif : gate torch (module torch)
182
+ from ..model.vocal import serialize_cards
183
+
184
+ cards = self.brain.ask_cards(rest, k=3)
185
+ lines = [f"[PAROLE] cartes: {len(cards)}"]
186
+ bias = self.brain.speak_bias(rest, self.tokenizer, k=3)
187
+ if bias:
188
+ steering = ", ".join(
189
+ f"{safe_decode_text(self.tokenizer.decode([tid]))!r} x{w:g}"
190
+ for tid, w in sorted(bias.items(), key=lambda kv: -kv[1])
191
+ )
192
+ else:
193
+ steering = "(aucun - pas de carte-réponse)"
194
+ lines.append(safe_decode_text(f"[PAROLE] steering: {steering}"))
195
+ text, _diags = speak(
196
+ self.core,
197
+ self.tokenizer,
198
+ serialize_cards(rest, cards),
199
+ max_new_tokens=8,
200
+ temperature=1.0,
201
+ rep_pen=6.0,
202
+ z_scale=3.0,
203
+ seed=7,
204
+ eos_token_id=getattr(self.tokenizer, "eos_token_id", None),
205
+ bias_tokens=bias or None,
206
+ )
207
+ lines.append(safe_decode_text(f'[PAROLE] "{text}"'))
208
+ return lines
209
+ except Exception as exc: # le noyau peut échouer — ligne honnête, jamais de traceback
210
+ return [safe_decode_text(f"[PAROLE] indisponible: {exc}")]
211
+
212
+ def _cmd_say(self, rest: str) -> list[str]:
213
+ """:say <question> — les organes ARTICULENT à travers la bouche du noyau.
214
+
215
+ Deux lignes :
216
+ - ``[ORGANES] {stmt} => {answer}`` : la carte qui CONNAÎT la réponse
217
+ (première FAIT/HEBBIEN avec contexte — HEBBIEN corrobore en tête —
218
+ sinon ANALOGIE, sinon la lacune, dite comme lacune) ;
219
+ - ``[PAROLE] {question} The answer is{génération}`` : le prompt
220
+ d'articulation ``f"{question} The answer is"`` puis speak() 12
221
+ tokens avec ``answer_lock=brain.speak_answer(...)`` (rep_pen 6,
222
+ z_scale 3, t=1.0, seed 7) — le verrou place la réponse des organes
223
+ token par token À TRAVERS la distribution du noyau, la suite de la
224
+ génération est LIBRE. Le verrou est LE mécanisme, affiché comme
225
+ tel : le lexique et les transitions sortent du noyau, la réponse
226
+ est verrouillée par les organes — rien de caché.
227
+
228
+ Dégradations honnêtes : sans noyau/tokenizer (``:core`` d'abord) →
229
+ UNE ligne ; sans carte-réponse (lacune) → la parole coule SANS
230
+ verrou et le dit — on ne verrouille jamais une bouche sur rien.
231
+
232
+ GATE torch : même contrat que :speak — import tardif de speak,
233
+ atteint seulement si un noyau est attaché. Jamais de traceback.
234
+ """
235
+ if not rest:
236
+ return ["[PAROLE] usage: :say <question>"]
237
+ if self.core is None or self.tokenizer is None:
238
+ return ["[PAROLE] noyau non attaché (:core d'abord)"]
239
+ try:
240
+ from ..model.speak import speak # tardif : gate torch (module torch)
241
+
242
+ cards = self.brain.ask_cards(rest, k=3)
243
+ organes = next(
244
+ (c for c in cards if c.kind in ("FAIT", "HEBBIEN") and " => " in c.text),
245
+ None,
246
+ )
247
+ if organes is None: # pas de fait — l'analogie, sinon la lacune dite
248
+ organes = next((c for c in cards if c.kind == "ANALOGIE"), cards[0])
249
+ lock = self.brain.speak_answer(rest, self.tokenizer, k=3)
250
+ text, _diags = speak(
251
+ self.core,
252
+ self.tokenizer,
253
+ f"{rest} The answer is",
254
+ max_new_tokens=12,
255
+ temperature=1.0,
256
+ rep_pen=6.0,
257
+ z_scale=3.0,
258
+ seed=7,
259
+ eos_token_id=getattr(self.tokenizer, "eos_token_id", None),
260
+ answer_lock=lock or None,
261
+ )
262
+ note = "" if lock else " (sans verrou - aucune carte-réponse, la parole est libre)"
263
+ return [
264
+ safe_decode_text(
265
+ f"[ORGANES] {organes.kind}: {organes.text} (sim {organes.sim:.2f})"
266
+ ),
267
+ safe_decode_text(f"[PAROLE] {rest} The answer is{text}{note}"),
268
+ ]
269
+ except Exception as exc: # le noyau peut échouer — ligne honnête, jamais de traceback
270
+ return [safe_decode_text(f"[PAROLE] indisponible: {exc}")]
271
+
272
+ # ------------------------------------------------------------------
273
+ # Question → cartes → écriture O(1) de l'échange
274
+ # ------------------------------------------------------------------
275
+
276
+ def _answer(self, question: str) -> list[str]:
277
+ cards = self.brain.ask_cards(question)
278
+ lines = [f"[CARTE] {c.kind}: {c.text} (sim {c.sim:.2f} | src: {c.source})" for c in cards]
279
+ # noyau optionnel : seulement s'il a été attaché via :core
280
+ if self.core is not None:
281
+ try:
282
+ text = verbalize(
283
+ self.core,
284
+ question,
285
+ cards,
286
+ self.tokenizer,
287
+ eos_token_id=getattr(self.tokenizer, "eos_token_id", None),
288
+ )
289
+ lines.append(safe_decode_text(f"[NOYAU] {text}"))
290
+ except Exception as exc: # le noyau non-né peut échouer — l'afficher honnêtement
291
+ lines.append(safe_decode_text(f"[NOYAU] indisponible: {exc}"))
292
+ # la conversation nourrit le brain : 1 atome par échange, puis save
293
+ best = ""
294
+ for c in cards:
295
+ if c.kind != "LACUNE":
296
+ best = c.text.split(" => ", 1)[1] if " => " in c.text else c.text
297
+ break
298
+ self.brain.ingest_atom(Atom(question, best, "session"))
299
+ self.brain.save(self.brain_path)
300
+ return lines
301
+
302
+ # ------------------------------------------------------------------
303
+ # Boucle interactive
304
+ # ------------------------------------------------------------------
305
+
306
+ def run(self) -> None:
307
+ """while True: input → feed → print. EOF (Ctrl-D / pipe) sort proprement."""
308
+ self._running = True
309
+ while self._running:
310
+ try:
311
+ line = input("fractus_vorax> ")
312
+ except EOFError:
313
+ break
314
+ except KeyboardInterrupt:
315
+ break
316
+ for out in self.feed(line):
317
+ print(out)
318
+
319
+
320
+ def main(argv: list[str] | None = None) -> int:
321
+ parser = argparse.ArgumentParser(prog="fractus_vorax-repl", description="Il ne s'entraîne plus : il mange.")
322
+ parser.add_argument("--brain", type=Path, default=Path("./brain"))
323
+ parser.add_argument("--D", type=int, default=D_DEFAULT)
324
+ parser.add_argument("--seed", type=int, default=SEED_DEFAULT)
325
+ args = parser.parse_args(argv)
326
+ Repl(args.brain, D=args.D, seed=args.seed).run()
327
+ return 0
328
+
329
+
330
+ if __name__ == "__main__":
331
+ raise SystemExit(main())
fractus_vorax/brain.py ADDED
@@ -0,0 +1,384 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # fractus_vorax/brain.py
2
+ """Le cerveau Fractus-Vorax Plan 1 : organes + atomes, persistant et déterministe."""
3
+ import gzip
4
+ import json
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+
9
+ from .cards import Card
10
+ from .compiler.analogy import Pattern, extract_patterns, match_pattern, solve_analogy
11
+ from .compiler.atoms import Atom
12
+ from .compiler.encode import encode_atom, text_hv
13
+ from .hv import D_DEFAULT, SEED_DEFAULT, bind, hash_hv, sim
14
+ from .organs.consolidation import CoActivation
15
+ from .organs.hebbian import HebbianMemory
16
+ from .organs.spawn import ExpertRegistry
17
+ from .organs.traces import TraceStore
18
+
19
+ # Seuil de déclenchement de l'analogie : un top hit < 0.7 signifie requête
20
+ # approximative (typo/slot inconnu) — les sims typo mesurées sont ~0.63-0.69
21
+ # (bundles 5/6 tokens), les requêtes exactes sont à 1.0. Calibration-sensible :
22
+ # recalibrer si l'encodage change. Contrat des cartes consommé par le Plan 3.
23
+ ANALOGY_TRIGGER_SIM = 0.7
24
+
25
+ # Seuil de match de la lecture hebbienne : la complétion sign(M @ q) doit
26
+ # ressembler (cosinus) à une VALEUR ingérée pour émettre une carte HEBBIEN.
27
+ # Mesuré : question exacte → 1.0 ; typo (5/6 tokens) → ≤ 0.26 (bruit, pas de
28
+ # carte). Calibration-sensible : recalibrer si D_h ou l'encodage change.
29
+ HEBBIAN_CARD_SIM = 0.55
30
+
31
+
32
+ class Brain:
33
+ def __init__(self, D: int = D_DEFAULT, seed: int = SEED_DEFAULT) -> None:
34
+ self.D = D
35
+ self.seed = seed
36
+ self.traces = TraceStore(D=D)
37
+ self.atoms: list[Atom] = []
38
+ self.experts = ExpertRegistry()
39
+ self.hebbian = HebbianMemory()
40
+ self.coact = CoActivation()
41
+ self.relations: dict[Pattern, dict[str, str]] = {}
42
+ # parents des atomes composites : atom_id -> (a_id, b_id) — pour
43
+ # rejouer addr = bind(addr_a, addr_b) au load (le replay ingeste
44
+ # l'atom via text_hv, qui diverge du bind de consolidate()).
45
+ self._composite_parents: dict[int, tuple[int, int]] = {}
46
+ # index hebbien : value.tobytes() -> statement, rempli à l'ingestion
47
+ # pour chaque écriture hebbienne ACCEPTÉE par la porte ΔE — sert à
48
+ # identifier la paire que la complétion matche (les HV ne sont pas
49
+ # inversibles, il faut une table value -> texte).
50
+ self._hebbian_index: dict[bytes, str] = {}
51
+
52
+ def __len__(self) -> int:
53
+ return len(self.atoms)
54
+
55
+ def ingest_atom(self, atom: Atom) -> int:
56
+ atom_id = len(self.atoms)
57
+ self.atoms.append(atom)
58
+ addr, value = encode_atom(atom, self.D, self.seed)
59
+ self.traces.write(atom_id, addr, value, weight=atom.confidence)
60
+ return atom_id
61
+
62
+ def ingest_atoms(self, atoms: list[Atom]) -> int:
63
+ for atom in atoms:
64
+ self.ingest_atom(atom)
65
+ return len(atoms)
66
+
67
+ def ask(self, question: str, k: int = 3) -> list[tuple[Atom, float]]:
68
+ query = text_hv(question, self.D, self.seed)
69
+ return [(self.atoms[i], s) for i, s in self.traces.retrieve(query, k=k)]
70
+
71
+ def ingest_source(self, name: str, atoms: list[Atom]) -> int:
72
+ start = len(self.atoms)
73
+ for atom in atoms:
74
+ self.ingest_atom(atom)
75
+ end = len(self.atoms) - 1
76
+ # relations locales au batch (merge déterministe : setdefault)
77
+ for pattern, rel in extract_patterns(atoms, min_support=3).items():
78
+ merged = self.relations.setdefault(pattern, {})
79
+ for slot, answer in rel.items():
80
+ merged.setdefault(slot, answer)
81
+ # écriture hebbienne (espace D_h=2048, re-hash indépendant)
82
+ for i, atom in enumerate(atoms):
83
+ key = hash_hv(atom.statement, self.hebbian.D_h, self.seed)
84
+ value = hash_hv(atom.context or atom.statement, self.hebbian.D_h, self.seed)
85
+ # sondes = 8 dernières paires DÉJÀ écrites du batch (max(start, i-8):i) ;
86
+ # la fenêtre fixe du brief [max(start, n-9):n-1] sondait des paires non
87
+ # encore écrites → la porte ΔE rejetait TOUT (n_writes=0, vérifié).
88
+ window = self.atoms[max(start, start + i - 8):start + i]
89
+ probes = [
90
+ (hash_hv(a.statement, self.hebbian.D_h, self.seed),
91
+ hash_hv(a.context or a.statement, self.hebbian.D_h, self.seed))
92
+ for a in window
93
+ ]
94
+ if self.hebbian.write_gated(key, value, probes):
95
+ self._hebbian_index[value.tobytes()] = atom.statement
96
+ # spawn : signature = signe majoritaire des addr du batch
97
+ if atoms:
98
+ addrs = [encode_atom(a, self.D, self.seed)[0] for a in atoms]
99
+ total = np.sum(np.stack(addrs).astype(np.int32), axis=0)
100
+ signature = np.where(total >= 0, 1, -1).astype(np.int8)
101
+ self.experts.spawn(name, signature, start, end)
102
+ return len(atoms)
103
+
104
+ def ask_cards(self, question: str, k: int = 3) -> list[Card]:
105
+ query = text_hv(question, self.D, self.seed)
106
+ routed = self.experts.route(query, top=1)
107
+ route_name = routed[0][0] if routed else ""
108
+ hits = self.ask(question, k=k)
109
+ # ask() renvoie (Atom, sim) — les ids viennent du retrieve pour coact
110
+ retrieved = self.traces.retrieve(query, k=k)
111
+ self.coact.record([i for i, _ in retrieved])
112
+ cards: list[Card] = []
113
+ for atom, s in hits:
114
+ text = f"{atom.statement} => {atom.context}" if atom.context else atom.statement
115
+ cards.append(Card("FAIT", text, s, atom.provenance))
116
+ top_sim = hits[0][1] if hits else 0.0
117
+ if top_sim < ANALOGY_TRIGGER_SIM:
118
+ matched = match_pattern(question)
119
+ if matched is not None:
120
+ pattern, _slot = matched
121
+ rel = self.relations.get(pattern)
122
+ if rel:
123
+ for answer, score in solve_analogy(question, rel, D=self.D, seed=self.seed)[:2]:
124
+ cards.append(Card("ANALOGIE", answer, score, route_name or "analogie"))
125
+ # lecture hebbienne : complétion indépendante du retrieval, en tête.
126
+ # Pour une question exacte le match hebbien EST l'atome FAIT top-1
127
+ # (les deux à sim 1.0) : la clause « sans doublon » littérale du plan
128
+ # (sauter la carte) casserait son propre test (n_writes > 0), et
129
+ # remplacer le FAIT top-1 casserait test_ask_cards_fact — la carte
130
+ # HEBBIEN corrobore donc en tête SANS toucher aux cartes FAIT.
131
+ if self._hebbian_index:
132
+ key = hash_hv(question, self.hebbian.D_h, self.seed)
133
+ completed = self.hebbian.complete(key)
134
+ best_bytes: bytes | None = None
135
+ best_sim = -1.0
136
+ for vbytes in sorted(self._hebbian_index): # déterministe : ordre des octets
137
+ s = sim(completed, np.frombuffer(vbytes, dtype=np.int8))
138
+ if s > best_sim:
139
+ best_bytes, best_sim = vbytes, s
140
+ if best_bytes is not None and best_sim >= HEBBIAN_CARD_SIM:
141
+ statement = self._hebbian_index[best_bytes]
142
+ atom = next((a for a in self.atoms if a.statement == statement), None)
143
+ context = atom.context if atom is not None else ""
144
+ text = f"{statement} => {context}" if context else statement
145
+ cards.insert(0, Card("HEBBIEN", text, best_sim, "hebbian"))
146
+ if not cards:
147
+ cards.append(Card("LACUNE", "aucune trace pour cette question", 0.0, route_name))
148
+ return cards
149
+
150
+ def speak_bias(
151
+ self,
152
+ question: str,
153
+ tokenizer,
154
+ k: int = 3,
155
+ boost: float = 8.0,
156
+ ) -> dict[int, float]:
157
+ """Steering organique — les organes dirigent la bouche (Plan 6, T3).
158
+
159
+ ask_cards(question, k) → PREMIER token BPE de la réponse de chaque
160
+ carte → ``{token_id: poids}``, le dictionnaire qui entre dans
161
+ ``speak(..., bias_tokens=...)`` : +δ brut sur les ids-réponses,
162
+ APRÈS z-norm/rep-pen, AVANT top-k/p (contrat de speak).
163
+
164
+ Extraction de la RÉPONSE par nature de carte :
165
+ - FAIT/HEBBIEN : la partie après ``" => "`` (le contexte EST la
166
+ réponse, ex. ``"what is the capital of france => paris"`` →
167
+ ``"paris"``) ; sans ``" => "`` la carte n'a pas de réponse
168
+ (contexte vide) → carte ignorée ;
169
+ - ANALOGIE : le texte nu (solve_analogy rend la réponse seule) ;
170
+ - LACUNE : ignorée — texte placeholder, pas une connaissance ; on
171
+ ne steere jamais la bouche avec une lacune.
172
+
173
+ Convention GPT-2 : on encode ``" " + réponse`` (espace initial
174
+ partie du token — ``encode(" paris")[0] != encode("paris")[0]``) ;
175
+ le PREMIER token de la réponse est la cible : c'est le premier
176
+ geste de la bouche vers cette réponse.
177
+
178
+ Poids = ``boost * 0.5**r`` (rang r dans la liste de cartes : HEBBIEN
179
+ en tête, puis FAIT par similarité décroissante, puis ANALOGIE) —
180
+ la meilleure carte pèse le plus. Accumulation MAX par id, PAS la
181
+ somme : quand plusieurs cartes convergent sur la même réponse
182
+ (HEBBIEN + FAIT top-1, sim 1.0 chacune), le δ injecté reste borné
183
+ par ``boost`` quel que soit l'accord — l'échelle du steering reste
184
+ calibrée contre l'échelle z-norm des logits (std 3 par défaut :
185
+ boost 8 ≈ 2.7σ, dominant mais borné). Une sommation laisserait k
186
+ cartes d'accord empiler un δ non borné et casserait ce calibrage.
187
+
188
+ GATE substrat — DÉVIATION documentée : le plan écrivait
189
+ ``speak_bias(question, k, boost)`` sans le tokenizer ; le vrai BPE
190
+ (Gpt2BpeTokenizer) vit dans model/ (zone torch/tokenizers) alors
191
+ que Brain est substrat numpy-seul (mingw doit rester vert). Le
192
+ tokenizer est donc passé EN PARAMÈTRE par l'appelant torch-aware
193
+ (repl/bench) — le substrat reste propre, même philosophie que
194
+ speak() qui reçoit ses objets. Protocole minimal du tokenizer :
195
+ ``encode(str) -> list[int]`` (celui de vocal/speak).
196
+
197
+ Déterministe : ask_cards est déterministe, l'accumulation max
198
+ aussi (ordre d'insertion = ordre des rangs).
199
+ """
200
+ if k < 1:
201
+ raise ValueError(f"k doit être >= 1 (reçu {k})")
202
+ if boost <= 0.0:
203
+ raise ValueError(f"boost doit être > 0 (reçu {boost})")
204
+
205
+ bias: dict[int, float] = {}
206
+ for rank, card in enumerate(self.ask_cards(question, k=k)):
207
+ if card.kind == "ANALOGIE":
208
+ answer = card.text
209
+ elif card.kind in ("FAIT", "HEBBIEN") and " => " in card.text:
210
+ answer = card.text.split(" => ", 1)[1]
211
+ else: # LACUNE, ou FAIT/HEBBIEN sans réponse (contexte vide)
212
+ continue
213
+ answer = answer.strip()
214
+ if not answer:
215
+ continue
216
+ ids = tokenizer.encode(" " + answer)
217
+ if not ids:
218
+ continue
219
+ tid = int(ids[0])
220
+ weight = boost * (0.5 ** rank)
221
+ if weight > bias.get(tid, 0.0): # max, pas somme (borné)
222
+ bias[tid] = weight
223
+ return bias
224
+
225
+ def speak_answer(
226
+ self,
227
+ question: str,
228
+ tokenizer,
229
+ k: int = 3,
230
+ ) -> list[int]:
231
+ """Le verrou de réponse — les organes livrent la réponse ENTIÈRE (P7).
232
+
233
+ ask_cards(question, k) → la PREMIÈRE carte qui CONNAÎT une réponse,
234
+ puis la liste COMPLÈTE des tokens BPE de ``" " + réponse`` — le
235
+ ``answer_lock`` de ``speak()`` : à l'étape i, le noyau verra
236
+ answer_lock[i] boosté, token après token, jusqu'au dernier fragment
237
+ de la réponse. La suite de la génération est LIBRE (mécanique
238
+ anti-attracteur) : les organes articulent À TRAVERS le noyau.
239
+
240
+ Sélection par nature de carte (même hiérarchie que speak_bias) :
241
+ - FAIT/HEBBIEN avec ``" => "`` : le contexte après la flèche EST la
242
+ réponse (première telle carte — HEBBIEN en tête si elle corrobore) ;
243
+ - ANALOGIE si AUCUNE FAIT/HEBBIEN n'a de réponse : le texte nu
244
+ (solve_analogy rend la réponse seule) ;
245
+ - LACUNE (ou aucune carte-réponse) : ``[]`` — on ne verrouille
246
+ JAMAIS la bouche sur une lacune : sans connaissance, la parole
247
+ reste libre et le dit (repl/demo l'affichent honnêtement).
248
+
249
+ Convention GPT-2 (comme speak_bias) : ``" " + réponse`` — l'espace
250
+ initial fait partie du premier token. Liste COMPLÈTE, pas juste le
251
+ premier geste : c'est la différence avec speak_bias (qui steere le
252
+ PREMIER token de CHAQUE carte) — ici UNE réponse, tous ses tokens.
253
+
254
+ GATE substrat : même déviations documentées que speak_bias — le
255
+ tokenizer est passé EN PARAMÈTRE par l'appelant torch-aware ; ce
256
+ fichier reste numpy-seul. Protocole minimal : ``encode(str) ->
257
+ list[int]``.
258
+
259
+ Déterministe : ask_cards est déterministe, la sélection aussi.
260
+ """
261
+ if k < 1:
262
+ raise ValueError(f"k doit être >= 1 (reçu {k})")
263
+
264
+ analogie = ""
265
+ for card in self.ask_cards(question, k=k):
266
+ if card.kind in ("FAIT", "HEBBIEN") and " => " in card.text:
267
+ answer = card.text.split(" => ", 1)[1].strip()
268
+ if answer:
269
+ return [int(t) for t in tokenizer.encode(" " + answer)]
270
+ elif card.kind == "ANALOGIE" and not analogie:
271
+ analogie = card.text.strip()
272
+ if analogie: # aucune FAIT/HEBBIEN avec réponse — l'analogie connaît
273
+ return [int(t) for t in tokenizer.encode(" " + analogie)]
274
+ return [] # LACUNE / aucune réponse : pas de verrou
275
+
276
+ def consolidate(self, threshold: int = 3) -> int:
277
+ promoted = 0
278
+ pairs = self.coact.promote(threshold=threshold)
279
+ for a_id, b_id in pairs:
280
+ a, b = self.atoms[a_id], self.atoms[b_id]
281
+ composite = Atom(f"({a.statement}) & ({b.statement})",
282
+ a.context or b.context, "consolidated", 0.8)
283
+ atom_id = len(self.atoms)
284
+ self.atoms.append(composite)
285
+ # addr composite = bind des addr STOCKÉES des parents — pas un
286
+ # ré-encodage : un parent composite a pour addr son bind, que
287
+ # encode_atom(statement) ne reproduit pas. Ré-encoder divergeait
288
+ # donc pour les composites imbriqués (profondeur ≥ 2) entre
289
+ # consolidate() et le replay de load(). Les parents ont des ids
290
+ # plus petits : leurs addr stockées sont finales à la promotion.
291
+ addr_a = self.traces.addrs[a_id]
292
+ addr_b = self.traces.addrs[b_id]
293
+ addr = bind(addr_a, addr_b)
294
+ value = encode_atom(composite, self.D, self.seed)[1]
295
+ self.traces.write(atom_id, addr, value, 0.8)
296
+ # mémoriser les parents pour rejouer le bind exact au load()
297
+ self._composite_parents[atom_id] = (a_id, b_id)
298
+ promoted += 1
299
+ # idempotence : les paires promues quittent le compteur — un second
300
+ # consolidate() ne les re-promeut pas (CoActivation.forget).
301
+ self.coact.forget(pairs)
302
+ return promoted
303
+
304
+ def save(self, path: Path) -> None:
305
+ path.mkdir(parents=True, exist_ok=True)
306
+ payload = "\n".join(a.row() for a in self.atoms).encode("utf-8")
307
+ (path / "atoms.jsonl.gz").write_bytes(gzip.compress(payload, mtime=0))
308
+ self.traces.save(path)
309
+ (path / "meta.json").write_text(
310
+ json.dumps({"D": self.D, "seed": self.seed, "n_atoms": len(self.atoms)}, sort_keys=True),
311
+ encoding="utf-8",
312
+ )
313
+ self.experts.save(path / "experts")
314
+ self.hebbian.save(path / "hebbian")
315
+ # index hebbien : clés bytes -> hex (l'ordre des octets est préservé
316
+ # par l'ordre lexicographique hex pour des clés de longueur fixe)
317
+ hebbian_index_payload = {
318
+ vbytes.hex(): statement
319
+ for vbytes, statement in sorted(self._hebbian_index.items())
320
+ }
321
+ (path / "hebbian_index.json").write_text(
322
+ json.dumps(hebbian_index_payload, sort_keys=True), encoding="utf-8"
323
+ )
324
+ self.coact.save(path)
325
+ relations_payload = {"|".join(p): rel for p, rel in sorted(self.relations.items())}
326
+ (path / "relations.json").write_text(
327
+ json.dumps(relations_payload, sort_keys=True), encoding="utf-8"
328
+ )
329
+ composites_payload = {
330
+ str(atom_id): [a_id, b_id]
331
+ for atom_id, (a_id, b_id) in sorted(self._composite_parents.items())
332
+ }
333
+ (path / "composites.json").write_text(
334
+ json.dumps(composites_payload, sort_keys=True), encoding="utf-8"
335
+ )
336
+
337
+ @classmethod
338
+ def load(cls, path: Path) -> "Brain":
339
+ meta = json.loads((path / "meta.json").read_text(encoding="utf-8"))
340
+ brain = cls(D=int(meta["D"]), seed=int(meta["seed"]))
341
+ raw = gzip.decompress((path / "atoms.jsonl.gz").read_bytes()).decode("utf-8")
342
+ for line in raw.splitlines():
343
+ if line.strip():
344
+ brain.ingest_atom(Atom.from_row(line))
345
+ # organes v2 : reconstruits depuis les fichiers si présents (rétro-compatible Plan 1)
346
+ experts_dir = path / "experts"
347
+ if experts_dir.exists():
348
+ brain.experts = ExpertRegistry.load(experts_dir)
349
+ heb_dir = path / "hebbian"
350
+ if heb_dir.exists():
351
+ brain.hebbian = HebbianMemory.load(heb_dir)
352
+ # index hebbien ; absent -> vide (compat Plan 1/2 : pas de HEBBIEN)
353
+ idx_file = path / "hebbian_index.json"
354
+ if idx_file.exists():
355
+ raw_idx = json.loads(idx_file.read_text(encoding="utf-8"))
356
+ brain._hebbian_index = {
357
+ bytes.fromhex(k): v for k, v in sorted(raw_idx.items())
358
+ }
359
+ co_file = path / "coactivation.json"
360
+ if co_file.exists():
361
+ brain.coact = CoActivation.load(path)
362
+ rel_file = path / "relations.json"
363
+ if rel_file.exists():
364
+ raw_rel = json.loads(rel_file.read_text(encoding="utf-8"))
365
+ for key, rel in raw_rel.items():
366
+ pattern = tuple(key.split("|"))
367
+ merged = brain.relations.setdefault(pattern, {})
368
+ for slot, answer in rel.items():
369
+ merged.setdefault(slot, answer)
370
+ # composites : le replay ci-dessus a ré-encodé les atomes composites via
371
+ # text_hv(statement), qui diverge du addr = bind(addr_a, addr_b) écrit
372
+ # par consolidate() → rejouer le bind exact, parents d'abord (ids
373
+ # croissants : un composite parent précède toujours ses enfants).
374
+ comp_file = path / "composites.json"
375
+ if comp_file.exists():
376
+ raw_comp = json.loads(comp_file.read_text(encoding="utf-8"))
377
+ for atom_id_str in sorted(raw_comp, key=int):
378
+ atom_id = int(atom_id_str)
379
+ a_id, b_id = raw_comp[atom_id_str]
380
+ brain.traces.addrs[atom_id] = bind(brain.traces.addrs[a_id], brain.traces.addrs[b_id])
381
+ brain._composite_parents[atom_id] = (int(a_id), int(b_id))
382
+ # addrs mutés directement → invalider la pile paresseuse du retrieve
383
+ brain.traces._stack = None
384
+ return brain
fractus_vorax/cards.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # fractus_vorax/cards.py
2
+ """Carte de connaissance : l'unité que le noyau fractus lira (Plan 3)."""
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class Card:
8
+ kind: str # "FAIT" | "ANALOGIE" | "LACUNE"
9
+ text: str
10
+ sim: float
11
+ source: str
fractus_vorax/compiler/__init__.py ADDED
File without changes
fractus_vorax/compiler/analogy.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # fractus_vorax/compiler/analogy.py
2
+ """Généralisation sans gradient : patterns par préfixe, relations par comptage,
3
+ analogie 3CosAdd/3CosMul sur les slots char-ngram (voisins morphologiques)."""
4
+ import math
5
+
6
+ from ..hv import D_DEFAULT, SEED_DEFAULT, sim
7
+ from .atoms import Atom
8
+ from .encode import slot_hv, text_hv, tokenize
9
+
10
+ Pattern = tuple[str, ...]
11
+ MORPHO_PREFIX = 5
12
+
13
+
14
+ def extract_patterns(atoms: list[Atom], min_support: int = 3) -> dict[Pattern, dict[str, str]]:
15
+ """Groupes d'énoncés partageant le même préfixe tokenique (slot = dernier token)."""
16
+ groups: dict[Pattern, dict[str, str]] = {}
17
+ counts: dict[Pattern, int] = {}
18
+ for atom in atoms:
19
+ tokens = tokenize(atom.statement)
20
+ if len(tokens) < 2 or not atom.context:
21
+ continue
22
+ pattern, slot = tuple(tokens[:-1]), tokens[-1]
23
+ groups.setdefault(pattern, {})[slot] = atom.context
24
+ counts[pattern] = counts.get(pattern, 0) + 1
25
+ return {p: rel for p, rel in groups.items() if counts[p] >= min_support}
26
+
27
+
28
+ def match_pattern(query: str) -> tuple[Pattern, str] | None:
29
+ tokens = tokenize(query)
30
+ if len(tokens) < 2:
31
+ return None
32
+ return tuple(tokens[:-1]), tokens[-1]
33
+
34
+
35
+ def _shares_prefix(a: str, b: str, n: int = MORPHO_PREFIX) -> bool:
36
+ return a[:n] == b[:n] and len(a) >= n and len(b) >= n
37
+
38
+
39
+ def solve_analogy(
40
+ query: str,
41
+ relation: dict[str, str],
42
+ D: int = D_DEFAULT,
43
+ seed: int = SEED_DEFAULT,
44
+ k_neighbors: int = 5,
45
+ method: str = "cosmul",
46
+ min_sim: float = 0.2,
47
+ ) -> list[tuple[str, float]]:
48
+ matched = match_pattern(query)
49
+ if matched is None:
50
+ return []
51
+ _pattern, slot_q = matched
52
+ if slot_q in relation:
53
+ return [] # slot connu : c'est du retrieval, pas de l'analogie
54
+ # voisins morphologiques (char-ngram) les plus proches
55
+ hq = slot_hv(slot_q, D, seed)
56
+ known = sorted(relation) # déterminisme
57
+ scored = sorted(((sim(hq, slot_hv(s, D, seed)), s) for s in known), reverse=True)
58
+ neighbors = [(s, w) for w, s in scored[:k_neighbors] if w >= min_sim]
59
+ if not neighbors:
60
+ return []
61
+ # candidats = réponses des voisins (unicité, ordre trié)
62
+ candidates = sorted({relation[s] for s, _ in neighbors})
63
+ cand_hvs = {c: text_hv(c, D, seed) for c in candidates}
64
+ ans_hvs = {s: text_hv(relation[s], D, seed) for s, _ in neighbors}
65
+ results: list[tuple[str, float]] = []
66
+ for cand, ch in cand_hvs.items():
67
+ if _shares_prefix(cand, slot_q):
68
+ continue # filtre morphologique
69
+ if method == "cosadd":
70
+ score = sum(w * sim(ch, ans_hvs[s]) for s, w in neighbors)
71
+ else: # cosmul : cosinus remappés [0,1], moyenne géométrique pondérée
72
+ log_sum = sum(w * math.log((sim(ch, ans_hvs[s]) + 1.0) / 2.0) for s, w in neighbors)
73
+ total_w = sum(w for _, w in neighbors)
74
+ score = math.exp(log_sum / total_w) if total_w > 0 else 0.0
75
+ if score >= min_sim:
76
+ results.append((cand, float(score)))
77
+ results.sort(key=lambda x: (-x[1], x[0]))
78
+ return results
fractus_vorax/compiler/atoms.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # fractus_vorax/compiler/atoms.py
2
+ """Atome de connaissance : l'unité canonique que tout format devient."""
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class Atom:
8
+ statement: str # l'énoncé retrievable (la question, le fait)
9
+ context: str # la réponse / le contexte associé
10
+ provenance: str # source (fichier:ligne, url...)
11
+ confidence: float = 1.0
12
+
13
+ def row(self) -> str:
14
+ """Sérialisation TSV-safe (4 champs, tabs/retours ligne neutralisés)."""
15
+ f = [self.statement, self.context, self.provenance]
16
+ return (
17
+ "\t".join(
18
+ x.replace("\t", " ").replace("\n", " ").replace("\r", " ") for x in f
19
+ )
20
+ + f"\t{self.confidence}"
21
+ )
22
+
23
+ @staticmethod
24
+ def from_row(row: str) -> "Atom":
25
+ parts = row.rstrip("\r\n").split("\t")
26
+ if len(parts) < 3:
27
+ raise ValueError(f"malformed atom row: {row!r}")
28
+ confidence = float(parts[3]) if len(parts) > 3 and parts[3] != "" else 1.0
29
+ return Atom(parts[0], parts[1], parts[2], confidence)
fractus_vorax/compiler/encode.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # fractus_vorax/compiler/encode.py
2
+ """Texte → HV : le bundle de tokens rend les questions apparentées similaires."""
3
+ import re
4
+
5
+ import numpy as np
6
+
7
+ from ..hv import D_DEFAULT, SEED_DEFAULT, bundle, hash_hv
8
+ from .atoms import Atom
9
+
10
+ _WORD = re.compile(r"[a-z0-9àâäéèêëîïôöùûüç'-]+")
11
+
12
+
13
+ def normalize(text: str) -> str:
14
+ t = text.casefold().strip()
15
+ t = re.sub(r"\s+", " ", t)
16
+ return t.strip(" .;:!?")
17
+
18
+
19
+ def tokenize(text: str) -> list[str]:
20
+ return _WORD.findall(normalize(text))
21
+
22
+
23
+ def text_hv(text: str, D: int = D_DEFAULT, seed: int = SEED_DEFAULT) -> np.ndarray:
24
+ tokens = tokenize(text)
25
+ if not tokens:
26
+ return hash_hv("<empty>", D, seed)
27
+ return bundle([hash_hv(tok, D, seed) for tok in tokens])
28
+
29
+
30
+ def encode_atom(atom: Atom, D: int = D_DEFAULT, seed: int = SEED_DEFAULT) -> tuple[np.ndarray, np.ndarray]:
31
+ addr = text_hv(atom.statement, D, seed)
32
+ if atom.context:
33
+ value = text_hv(f"{atom.statement} => {atom.context}", D, seed)
34
+ else:
35
+ value = addr
36
+ return addr, value
37
+
38
+
39
+ def char_ngrams(text: str, n: int = 3) -> list[str]:
40
+ """Char n-grammes avec padding '#'. Sub-word fuzziness pour les slots."""
41
+ padded = f"#{normalize(text)}#"
42
+ if len(padded) <= n:
43
+ return [padded]
44
+ return [padded[i:i + n] for i in range(len(padded) - n + 1)]
45
+
46
+
47
+ def slot_hv(text: str, D: int = D_DEFAULT, seed: int = SEED_DEFAULT) -> np.ndarray:
48
+ """HV de slot : bundle des char-n-grammes — 'france' ~ 'franca', 'france' ⊥ 'japan'."""
49
+ return bundle([hash_hv(g, D, seed) for g in char_ngrams(text)])
fractus_vorax/compiler/kn.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # fractus_vorax/compiler/kn.py
2
+ """Le compilateur .kn : dataset → artefact de connaissance portable, bit-identique."""
3
+ import gzip
4
+ import hashlib
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+
10
+ from ..hv import D_DEFAULT, SEED_DEFAULT, pack
11
+ from .atoms import Atom
12
+ from .encode import encode_atom
13
+ from .parse import parse_file
14
+
15
+ FORMAT_VERSION = 1
16
+
17
+
18
+ def source_sha256(path: Path) -> str:
19
+ h = hashlib.sha256()
20
+ h.update(path.read_bytes())
21
+ return h.hexdigest()
22
+
23
+
24
+ def compile_kn(
25
+ source: Path, out: Path, D: int = D_DEFAULT, seed: int = SEED_DEFAULT
26
+ ) -> Path:
27
+ atoms = parse_file(source)
28
+ out.mkdir(parents=True, exist_ok=True)
29
+
30
+ payload = "\n".join(a.row() for a in atoms).encode("utf-8")
31
+ (out / "atoms.tsv.gz").write_bytes(gzip.compress(payload, mtime=0))
32
+
33
+ addrs = [encode_atom(a, D, seed)[0] for a in atoms]
34
+ if addrs:
35
+ total = np.sum(np.stack(addrs).astype(np.int32), axis=0)
36
+ signature = np.where(total >= 0, 1, -1).astype(np.int8)
37
+ sig_bits = pack(signature)
38
+ else:
39
+ sig_bits = np.array([], dtype=np.uint8)
40
+ (out / "signature.bin").write_bytes(sig_bits.tobytes())
41
+
42
+ manifest = {
43
+ "D": D,
44
+ "format_version": FORMAT_VERSION,
45
+ "n_atoms": len(atoms),
46
+ "seed": seed,
47
+ "source": str(source.name),
48
+ "source_sha256": source_sha256(source),
49
+ }
50
+ (out / "manifest.json").write_text(
51
+ json.dumps(manifest, sort_keys=True, indent=2), encoding="utf-8"
52
+ )
53
+ return out
54
+
55
+
56
+ def load_kn(kn_dir: Path) -> list[Atom]:
57
+ raw = gzip.decompress((kn_dir / "atoms.tsv.gz").read_bytes()).decode("utf-8")
58
+ return [Atom.from_row(line) for line in raw.splitlines() if line.strip()]
fractus_vorax/compiler/parse.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # fractus_vorax/compiler/parse.py
2
+ """N'importe quelle donnée devient des atomes. Format inconnu → fallback brut."""
3
+ import csv
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from .atoms import Atom
8
+
9
+ _SUFFIXES = {
10
+ ".csv": "csv", ".tsv": "tsv", ".json": "json", ".jsonl": "jsonl",
11
+ ".txt": "txt", ".md": "md",
12
+ }
13
+
14
+
15
+ def detect_format(path: Path) -> str:
16
+ return _SUFFIXES.get(path.suffix.lower(), "raw")
17
+
18
+
19
+ def flatten_record(d: dict, prefix: str = "") -> list[str]:
20
+ """dict (récursif) → paires 'key.subkey: value' plates."""
21
+ pairs: list[str] = []
22
+ for key, value in d.items():
23
+ full = f"{prefix}.{key}" if prefix else str(key)
24
+ if isinstance(value, dict):
25
+ pairs.extend(flatten_record(value, full))
26
+ elif isinstance(value, list):
27
+ joined = "; ".join(str(x) for x in value)
28
+ pairs.append(f"{full}: {joined}")
29
+ else:
30
+ pairs.append(f"{full}: {value}")
31
+ return pairs
32
+
33
+
34
+ def parse_file(path: Path) -> list[Atom]:
35
+ fmt = detect_format(path)
36
+ if fmt in ("csv", "tsv"):
37
+ return _parse_csv(path, delim="," if fmt == "csv" else "\t")
38
+ if fmt == "jsonl":
39
+ return _parse_jsonl(path)
40
+ if fmt == "json":
41
+ return _parse_json(path)
42
+ if fmt in ("txt", "md"):
43
+ return _parse_text(path, md=(fmt == "md"))
44
+ return _parse_raw(path)
45
+
46
+
47
+ def _parse_csv(path: Path, delim: str) -> list[Atom]:
48
+ atoms: list[Atom] = []
49
+ with open(path, newline="", encoding="utf-8", errors="replace") as fh:
50
+ reader = csv.reader(fh, delimiter=delim)
51
+ header = next(reader, None)
52
+ for lineno, row in enumerate(reader, start=2):
53
+ if not row or all(not c.strip() for c in row):
54
+ continue
55
+ cells = [c.strip() for c in row]
56
+ if len(cells) == 2: # QA direct
57
+ atoms.append(Atom(cells[0], cells[1], f"{path.name}:{lineno}"))
58
+ else: # table large → sérialisation key: value
59
+ keys = header if header and len(header) == len(cells) else [f"c{i}" for i in range(len(cells))]
60
+ statement = "; ".join(f"{k}: {v}" for k, v in zip(keys, cells))
61
+ atoms.append(Atom(statement, "", f"{path.name}:{lineno}"))
62
+ return atoms
63
+
64
+
65
+ def _parse_jsonl(path: Path) -> list[Atom]:
66
+ atoms = []
67
+ with open(path, encoding="utf-8", errors="replace") as fh:
68
+ for lineno, line in enumerate(fh, start=1):
69
+ line = line.strip()
70
+ if not line:
71
+ continue
72
+ try:
73
+ record = json.loads(line)
74
+ except json.JSONDecodeError:
75
+ print(f"[VORAX] WARNING: ligne JSON malformée ignorée ({path.name}:{lineno})")
76
+ continue
77
+ if not isinstance(record, dict):
78
+ continue
79
+ atoms.append(Atom("; ".join(flatten_record(record)), "", f"{path.name}:{lineno}"))
80
+ return atoms
81
+
82
+
83
+ def _parse_json(path: Path) -> list[Atom]:
84
+ data = json.loads(path.read_text(encoding="utf-8", errors="replace"))
85
+ records = data if isinstance(data, list) else [data]
86
+ atoms = []
87
+ for i, record in enumerate(records, start=1):
88
+ if isinstance(record, dict):
89
+ atoms.append(Atom("; ".join(flatten_record(record)), "", f"{path.name}:{i}"))
90
+ return atoms
91
+
92
+
93
+ def _parse_text(path: Path, md: bool) -> list[Atom]:
94
+ atoms = []
95
+ context = ""
96
+ buffer: list[str] = []
97
+ for lineno, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), start=1):
98
+ stripped = line.strip()
99
+ if md and stripped.startswith("#"):
100
+ context = stripped.lstrip("#").strip()
101
+ continue
102
+ if not stripped:
103
+ if buffer:
104
+ atoms.append(Atom(" ".join(buffer), context, f"{path.name}:{lineno}"))
105
+ buffer = []
106
+ continue
107
+ buffer.append(stripped)
108
+ if buffer:
109
+ atoms.append(Atom(" ".join(buffer), context, f"{path.name}:EOF"))
110
+ return atoms
111
+
112
+
113
+ def _parse_raw(path: Path) -> list[Atom]:
114
+ atoms = []
115
+ for lineno, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), start=1):
116
+ if line.strip():
117
+ atoms.append(Atom(line.strip(), "", f"{path.name}:{lineno}"))
118
+ return atoms
fractus_vorax/hv.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # fractus_vorax/hv.py
2
+ """Hypervectors bipolaires ±1 : le substrat de calcul de Fractus-Vorax.
3
+
4
+ Tout est déterministe : blake2b en mode compteur, jamais hash() python.
5
+ """
6
+ import hashlib
7
+
8
+ import numpy as np
9
+
10
+ D_DEFAULT = 8192
11
+ SEED_DEFAULT = 0
12
+
13
+
14
+ def hash_hv(text: str, D: int = D_DEFAULT, seed: int = 0) -> np.ndarray:
15
+ """HV bipolaire ±1 déterministe, mode compteur (digest_size max 64 o)."""
16
+ need = (D + 7) // 8
17
+ blocks: list[bytes] = []
18
+ counter = 0
19
+ while sum(len(b) for b in blocks) < need:
20
+ payload = f"{seed}|{counter}|{text}".encode()
21
+ blocks.append(hashlib.blake2b(payload, digest_size=64).digest())
22
+ counter += 1
23
+ bits = np.unpackbits(
24
+ np.frombuffer(b"".join(blocks), dtype=np.uint8), bitorder="little"
25
+ )
26
+ return (bits[:D].astype(np.int16) * 2 - 1).astype(np.int8)
27
+
28
+
29
+ def bind(a: np.ndarray, b: np.ndarray) -> np.ndarray:
30
+ """Association (XOR sur bipolaires) : involution, obscurcit."""
31
+ return a * b
32
+
33
+
34
+ def bundle(hvs: list[np.ndarray]) -> np.ndarray:
35
+ """Superposition : signe majoritaire, égalité → +1 (déterministe)."""
36
+ if not hvs:
37
+ raise ValueError("bundle() needs at least one hv")
38
+ total = np.sum(np.stack(hvs), axis=0, dtype=np.int16)
39
+ return np.where(total >= 0, 1, -1).astype(np.int8)
40
+
41
+
42
+ def sim(a: np.ndarray, b: np.ndarray) -> float:
43
+ """Similarité = cosinus bipolaire = moyenne des produits."""
44
+ return float((a.astype(np.int16) * b.astype(np.int16)).mean())
45
+
46
+
47
+ def pack(hv: np.ndarray) -> np.ndarray:
48
+ """±1 → bits packés (+1 → 1), pour stockage."""
49
+ return np.packbits((hv > 0).astype(np.uint8), bitorder="little")
50
+
51
+
52
+ def unpack(bits: np.ndarray, D: int) -> np.ndarray:
53
+ """Inverse exact de pack()."""
54
+ out = np.unpackbits(bits, bitorder="little")[:D].astype(np.int16)
55
+ return (out * 2 - 1).astype(np.int8)
fractus_vorax/model/__init__.py ADDED
File without changes
fractus_vorax/model/bpe_tokenizer.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """fractus_vorax/model/bpe_tokenizer.py — tokenizer BPE GPT-2, exact fractus (Plan 4, tâche 1).
2
+
3
+ Le vrai tokenizer de fractus : GPT-2 BPE via la lib HF `tokenizers`.
4
+ Mêmes ids que fractus → vocab 50257, eos 50256, "hello world" →
5
+ [31373, 995].
6
+
7
+ Gpt2BpeTokenizer() ──► résolution (dans l'ordre) :
8
+ (1) tokenizer_json explicite ;
9
+ (2) snapshot du cache HF local
10
+ ~/.cache/huggingface/hub/models--gpt2/snapshots/*/tokenizer.json
11
+ (3) Tokenizer.from_pretrained("gpt2") — RÉSEAU (télécharge depuis
12
+ huggingface.co si le cache local est vide) ;
13
+ FileNotFoundError si aucune source ne répond.
14
+ encode(text) -> list[int] ; decode(ids) -> str (sûr : filtre les ids
15
+ hors [0, vocab) et ne lève JAMAIS sur surrogates — le décodeur
16
+ byte-level remplace les séquences UTF-8 invalides par U+FFFD,
17
+ équivalent errors="replace", plus repli id-par-id ceinture+bretelles).
18
+
19
+ GATE torch : ce fichier vit dans fractus_vorax/model/ (seule zone autorisée à
20
+ toucher aux libs lourdes) mais n'importe `tokenizers` QUE paresseusement,
21
+ à l'intérieur des méthodes — importer fractus_vorax.model.bpe_tokenizer dans un
22
+ python sans tokenizers (substrat mingw) ne doit jamais lever.
23
+ `bpe_available()` sonde ce python par try-import.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from pathlib import Path
29
+
30
+ # Constantes GPT-2 canoniques (garde-fou si le json chargé est modifié).
31
+ _GPT2_VOCAB_SIZE = 50257
32
+ _GPT2_EOS_TOKEN = "<|endoftext|>"
33
+ _GPT2_EOS_TOKEN_ID = 50256
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Disponibilité
38
+ # ---------------------------------------------------------------------------
39
+
40
+ def bpe_available() -> bool:
41
+ """True si `tokenizers` est importable dans CE python.
42
+
43
+ Jamais d'import au niveau module : le substrat mingw (sans tokenizers)
44
+ doit pouvoir importer fractus_vorax.model.bpe_tokenizer sans crash et obtenir
45
+ False ici.
46
+ """
47
+ try:
48
+ import tokenizers # noqa: F401
49
+ except Exception: # absent, ou install cassée
50
+ return False
51
+ return True
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Résolution du tokenizer.json
56
+ # ---------------------------------------------------------------------------
57
+
58
+ def _snapshot_candidates() -> list[Path]:
59
+ """tokenizer.json des snapshots gpt2 du cache HF local, plus récent d'abord."""
60
+ snapshots = Path.home() / ".cache" / "huggingface" / "hub" / "models--gpt2" / "snapshots"
61
+ try:
62
+ candidates = list(snapshots.glob("*/tokenizer.json"))
63
+ except OSError:
64
+ return []
65
+ return sorted(
66
+ (p for p in candidates if p.is_file()),
67
+ key=lambda p: p.stat().st_mtime,
68
+ reverse=True,
69
+ )
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Le tokenizer
74
+ # ---------------------------------------------------------------------------
75
+
76
+ class Gpt2BpeTokenizer:
77
+ """Tokenizer BPE GPT-2 — ids exacts de fractus.
78
+
79
+ Parameters
80
+ ----------
81
+ tokenizer_json:
82
+ Chemin explicite d'un tokenizer.json GPT-2. None → résolution
83
+ automatique : snapshot du cache HF local, puis
84
+ ``Tokenizer.from_pretrained("gpt2")`` (réseau).
85
+
86
+ Raises
87
+ ------
88
+ FileNotFoundError
89
+ Source explicite absente, ou aucune source (cache vide + réseau
90
+ injoignable).
91
+ ImportError
92
+ La lib `tokenizers` n'est pas installée dans CE python.
93
+ """
94
+
95
+ def __init__(self, tokenizer_json: Path | None = None) -> None:
96
+ try:
97
+ from tokenizers import Tokenizer
98
+ except Exception as exc: # pragma: no cover - dépend de l'env
99
+ raise ImportError(
100
+ "la lib `tokenizers` est requise (pip install tokenizers)"
101
+ ) from exc
102
+
103
+ if tokenizer_json is not None:
104
+ path = Path(tokenizer_json)
105
+ if not path.is_file():
106
+ raise FileNotFoundError(f"tokenizer.json introuvable : {path}")
107
+ self._tok = Tokenizer.from_file(str(path))
108
+ self.source = str(path)
109
+ else:
110
+ snapshots = _snapshot_candidates()
111
+ if snapshots:
112
+ self._tok = Tokenizer.from_file(str(snapshots[0]))
113
+ self.source = str(snapshots[0])
114
+ else:
115
+ # Dernier recours : réseau (huggingface.co). Peut lever si
116
+ # hors-ligne — remonté comme FileNotFoundError (aucune source).
117
+ try:
118
+ self._tok = Tokenizer.from_pretrained("gpt2")
119
+ self.source = 'from_pretrained("gpt2")'
120
+ except Exception as exc:
121
+ raise FileNotFoundError(
122
+ "aucune source de tokenizer GPT-2 : pas de "
123
+ "tokenizer_json explicite, cache HF local vide, "
124
+ "et from_pretrained('gpt2') a échoué (réseau ?)"
125
+ ) from exc
126
+
127
+ # Attributs publics — reflètent l'artefact réellement chargé.
128
+ self.vocab_size: int = self._tok.get_vocab_size(with_added_tokens=True)
129
+ eos = self._tok.token_to_id(_GPT2_EOS_TOKEN)
130
+ self.eos_token_id: int = _GPT2_EOS_TOKEN_ID if eos is None else int(eos)
131
+
132
+ def encode(self, text: str) -> list[int]:
133
+ """Texte → ids BPE GPT-2 (aucun token spécial ajouté)."""
134
+ return list(self._tok.encode(text).ids)
135
+
136
+ def decode(self, ids: list[int]) -> str:
137
+ """Ids → texte, SANS jamais lever.
138
+
139
+ - Filtre les ids hors [0, vocab_size) et les non-entiers.
140
+ - Le décodeur byte-level remplace déjà les séquences UTF-8
141
+ invalides par U+FFFD (équivalent errors="replace").
142
+ - Ceinture + bretelles : si decode global échoue quand même,
143
+ repli id-par-id avec U+FFFD pour les ids fautifs.
144
+ """
145
+ safe = [
146
+ i for i in ids
147
+ if isinstance(i, int) and not isinstance(i, bool) and 0 <= i < self.vocab_size
148
+ ]
149
+ try:
150
+ return self._tok.decode(safe)
151
+ except Exception:
152
+ pass
153
+ parts: list[str] = []
154
+ for i in safe:
155
+ try:
156
+ parts.append(self._tok.decode([i]))
157
+ except Exception:
158
+ parts.append("\ufffd")
159
+ return "".join(parts)
fractus_vorax/model/cte_core.py ADDED
@@ -0,0 +1,946 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """fractus_vorax/model/cte_core.py — noyau CTE natif (Continuous Thought Engine, 1B).
2
+
3
+ Implémentation fidèle du moteur CTE du checkpoint « FRACTUS_1B_PHASE2_FROZEN_MERGED »
4
+ (4,66 GB, 440 clés). Source de vérité : docs/heritage/cte-architecture.md (T1) — toute
5
+ affirmation ci-dessous cite le doc, qui cite lui-même fractus-cte (LECTURE SEULE,
6
+ jamais importé). Ce fichier vit dans fractus_vorax/model/ (gate torch, tests/test_gates.py).
7
+
8
+ MAPPING sous-module ↔ clés du state_dict (doc §Table des clés : 8 + 16×27 = 440) :
9
+
10
+ CteCore
11
+ ├── observe.weight (vocab, d_model) nn.Embedding, token SEUL
12
+ ├── blocks : ModuleList[n_layers] × CteBlock
13
+ │ ├── attn_S (batch, d, d) BUFFER porteur bloc-diagonal
14
+ │ ├── attn_z (batch, d) BUFFER porteur
15
+ │ ├── kuramoto_phases (batch, SEQ, N) BUFFER write-only
16
+ │ ├── attn : CteLinearAttention
17
+ │ │ ├── w_qkv (3,d,d) / b_qkv (3,d) / w_out (d,d) / b_out (d,) param x@W
18
+ │ │ ├── level_logits (n_levels,) param
19
+ │ │ └── level_offsets (n_levels,) BUFFER
20
+ │ ├── norm_attn.{weight,bias}
21
+ │ ├── kuramoto : KuramotoLayer (repris de fractus_core — sémantique
22
+ │ │ identique vérifiée, doc « Communs confirmés »)
23
+ │ │ └── omega / coupling_u / coupling_lambda
24
+ │ ├── norm_kur.{weight,bias}
25
+ │ ├── moe : PhaseRoutedMoEBatched (low-rang BATCHÉ, dim 0 = expert)
26
+ │ │ ├── U1 (E,F,r) / V1 (E,D,r) / U2 (E,D,r) / V2 (E,F,r)
27
+ │ │ ├── scale1 (E,1,1) / scale2 (E,1,1) / b1 (E,F) / b2 (E,D)
28
+ │ │ └── expert_phases (E,) BUFFER Farey
29
+ │ └── norm_moe.{weight,bias}
30
+ ├── last_lb_loss () BUFFER (métrique training)
31
+ ├── confidence_head.{weight,bias} nn.Linear(d, 1) lecture seule à l'inférence
32
+ ├── output_head.weight (vocab, d) nn.Linear LIÉE à observe
33
+ ├── salience_head.{weight,bias} nn.Linear(d, 1) lecture seule (mémoire absente)
34
+ └── thought_state (batch, 1, d) BUFFER porteur résiduel
35
+
36
+ Décisions tranchées pour T2 (doc §Décisions tranchées) :
37
+ 1. GELU = approximation TANH entre les facteurs U1/V1 et U2/V2 — PAS F.gelu
38
+ (leçon P3 ; le 88M utilisait F.gelu, le CTE non).
39
+ 2. Orientation x@W pour qkv/w_out et le MoE low-rang (h@V puis @Uᵀ) ;
40
+ nn.Linear (x@Wᵀ) UNIQUEMENT pour output_head/confidence/salience.
41
+ 3. output_head.weight = observe.weight (tied, les deux clés existent).
42
+ 4. Forward = chemin tick_chunk_core EXCLUSIF ; génération = chunks de longueur 1 ;
43
+ tick_single INTERDIT (attention mono-niveau, mismatch train/gén connu).
44
+ 5. κ_eff = 4.0/2.5 = 1.6 posé au chargement (temperature est un attribut runtime
45
+ ABSENT du checkpoint ; tous les trainers le fixent à 2.5 post-load).
46
+ 6. États porteurs : thought_state ajouté à la position 0 du chunk, maj = h[:, -1:]
47
+ detaché ; attn_S/attn_z bloc-diagonaux par tête, récurrence INCLUSIVE, nouvel
48
+ état = moyenne sur les niveaux ; kuramoto_phases write-only (sa SHAPE compte
49
+ pour le strict load : (batch, SEQ_train=128, N), sa valeur sans effet).
50
+ 7. Buffers construits en batch=config.batch pour from_checkpoint strict=True,
51
+ re-dimensionnables par reset_states().
52
+ 8. boost/ss_rate/lr/tokens_processed/frozen_merge/n : métadonnées sans effet forward.
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ import math
58
+ import re
59
+ from dataclasses import dataclass
60
+ from pathlib import Path
61
+ from typing import Dict, Optional, Tuple, Union
62
+
63
+ import torch
64
+ import torch.nn as nn
65
+
66
+ # Utilitaires partagés avec le noyau 88M — le doc confirme l'identité bit à bit
67
+ # des sémantiques (elu_plus_one, offsets de niveaux, Farey, Kuramoto RK4
68
+ # dt=0.1/damping=0.01/n_steps=1 : section « Communs confirmés »).
69
+ from fractus_vorax.model.fractus_core import (
70
+ KuramotoLayer,
71
+ elu_plus_one,
72
+ farey_expert_phases,
73
+ mandelbrot_level_offsets,
74
+ )
75
+
76
+ TWO_PI = 2.0 * math.pi
77
+
78
+ # --- Constantes de fidélité (doc §MoE batché, « TEMPÉRATURE = 2.5 — PIÈGE ») ---
79
+ MOE_KAPPA = 4.0
80
+ # temperature est un attribut runtime (défaut 1.0) ABSENT du state_dict et du
81
+ # config : tous les trainers le fixent à 2.5 après chargement
82
+ # (fast4gpu_boost.py:99-102, fast4gpu_stage2_ss.py:32-34). L'oublier change
83
+ # toutes les gates → divergence silencieuse. On le pose en dur.
84
+ GATE_TEMPERATURE = 2.5
85
+ KAPPA_EFF = MOE_KAPPA / GATE_TEMPERATURE # = 1.6 — ne jamais utiliser 4.0 direct
86
+
87
+ # SEQ d'entraînement du stage 2 (fast4gpu_stage2_ss.py:39) : la dimension du
88
+ # milieu du buffer kuramoto_phases du checkpoint (2, 128, 16).
89
+ TRAIN_SEQ_LEN = 128
90
+
91
+
92
+ def _gelu_tanh(x: torch.Tensor) -> torch.Tensor:
93
+ """GELU approximation TANH (moe.py:27-31) : 0.5x(1+tanh(√(2/π)(x+0.044715x³))).
94
+
95
+ PAS torch.nn.functional.gelu (variante exacte du 88M) — écart max ~3e-3 par
96
+ activation, amplifié par 16 blocs (doc §MoE batché, décision 1).
97
+ """
98
+ return 0.5 * x * (1.0 + torch.tanh(
99
+ math.sqrt(2.0 / math.pi) * (x + 0.044715 * x ** 3)
100
+ ))
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Sonde de checkpoint — quelle espèce de noyau dort dans ce fichier ?
105
+ # ---------------------------------------------------------------------------
106
+
107
+ def probe_checkpoint_kind(path: Union[str, Path]) -> str:
108
+ """Sonde le type d'un checkpoint : ``"cte"`` | ``"fractus"`` | ``"unknown"``.
109
+
110
+ - torch PARESSEUX à l'intérieur (philosophie gate), ``map_location="cpu"`` ;
111
+ - ``mmap=True`` d'abord : les tenseurs ne sont PAS matérialisés, seules
112
+ les clés du pickle sont lues (le 1B de 4,66 GB est sondé sans le
113
+ charger) ; repli ``torch.load`` ordinaire si mmap échoue pour CE
114
+ fichier (archive non-zipfile, torch trop vieux…) ;
115
+ - state_dict cherché sous « model_state »/« model »/« state_dict »
116
+ (même ordre que les deux ``from_checkpoint``), sinon dict nu ;
117
+ - marqueurs : ``{"observe.weight", "thought_state"}`` ⊆ clés → ``"cte"`` ;
118
+ ``"embed.tok_embed.weight"`` présente → ``"fractus"`` ; sinon
119
+ ``"unknown"`` (les appelants refusent l'attach, honnêtement) ;
120
+ - chemin absent → ``FileNotFoundError`` : les appelants (repl, core_speak)
121
+ dégradent en message propre, ils ne tracent pas.
122
+ """
123
+ import torch # paresseux (philosophie gate)
124
+
125
+ path = Path(path)
126
+ if not path.is_file():
127
+ raise FileNotFoundError(f"checkpoint introuvable: {path}")
128
+ try:
129
+ ckpt = torch.load(path, map_location="cpu", weights_only=False, mmap=True)
130
+ except Exception: # mmap non supporté pour ce fichier — repli ordinaire
131
+ ckpt = torch.load(path, map_location="cpu", weights_only=False)
132
+
133
+ if not isinstance(ckpt, dict):
134
+ return "unknown"
135
+ sd = None
136
+ for key in ("model_state", "model", "state_dict"):
137
+ cand = ckpt.get(key)
138
+ if isinstance(cand, dict):
139
+ sd = cand
140
+ break
141
+ if sd is None:
142
+ sd = ckpt # dict nu : state_dict sérialisé tel quel
143
+ keys = set(sd.keys())
144
+ if "observe.weight" in keys and "thought_state" in keys:
145
+ return "cte"
146
+ if "embed.tok_embed.weight" in keys:
147
+ return "fractus"
148
+ return "unknown"
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # Config
153
+ # ---------------------------------------------------------------------------
154
+
155
+ @dataclass
156
+ class CteCoreConfig:
157
+ """Config du noyau CTE.
158
+
159
+ Défauts = preset « tiny » pour les tests. Les valeurs du checkpoint réel
160
+ 1B FROZEN_MERGED (doc §0) : d_model=1280, n_heads=20, d_head=64, n_levels=2,
161
+ n_oscillators=16, coupling_rank=8, n_experts=128, top_k=2, expert_d_ff=2048,
162
+ siren_rank=64, n_layers=16, vocab_size=50257, batch=2, train_seq_len=128 —
163
+ accessibles via `CteCoreConfig.real_1b()`.
164
+
165
+ batch : dimension batch des états persistants (le trainer tournait en B=2 ;
166
+ à l'inférence la recette officielle travaille en batch 1 via reset_states(1)).
167
+ """
168
+
169
+ vocab_size: int = 50257
170
+ d_model: int = 64
171
+ n_heads: int = 4
172
+ d_head: int = 16
173
+ n_levels: int = 2
174
+ n_oscillators: int = 4
175
+ coupling_rank: int = 4
176
+ n_experts: int = 4
177
+ top_k: int = 2
178
+ expert_d_ff: int = 128
179
+ siren_rank: int = 16
180
+ n_layers: int = 2
181
+ batch: int = 1
182
+ train_seq_len: int = TRAIN_SEQ_LEN
183
+ # Fenêtre de troncature gauche du prompt (vocal.verbalize, duck-typed avec
184
+ # FractusCoreConfig). Le moteur n'a PAS de pos_embed borné — la position
185
+ # vit dans le carry (S, z) de l'attention linéaire et generate digère un
186
+ # prompt d'une seule pièce : c'est un garde-fou de prompt, pas une
187
+ # contrainte du modèle (256 = défaut FractusCoreConfig).
188
+ max_seq_len: int = 256
189
+
190
+ def __post_init__(self) -> None:
191
+ if self.n_heads * self.d_head != self.d_model:
192
+ raise ValueError(
193
+ f"n_heads·d_head ({self.n_heads * self.d_head}) != "
194
+ f"d_model ({self.d_model})"
195
+ )
196
+ if self.n_levels < 1:
197
+ raise ValueError("n_levels must be >= 1")
198
+ if self.n_experts < 1 or not 1 <= self.top_k <= self.n_experts:
199
+ raise ValueError("need n_experts >= 1 and 1 <= top_k <= n_experts")
200
+ if not 1 <= self.coupling_rank <= self.n_oscillators:
201
+ raise ValueError("need 1 <= coupling_rank <= n_oscillators")
202
+ if self.batch < 1 or self.train_seq_len < 1:
203
+ raise ValueError("need batch >= 1 and train_seq_len >= 1")
204
+
205
+ @classmethod
206
+ def real_1b(cls) -> "CteCoreConfig":
207
+ """Preset du checkpoint FROZEN_MERGED (config sondé, doc §0)."""
208
+ return cls(
209
+ vocab_size=50257,
210
+ d_model=1280,
211
+ n_heads=20,
212
+ d_head=64,
213
+ n_levels=2,
214
+ n_oscillators=16,
215
+ coupling_rank=8,
216
+ n_experts=128,
217
+ top_k=2,
218
+ expert_d_ff=2048,
219
+ siren_rank=64,
220
+ n_layers=16,
221
+ batch=2,
222
+ train_seq_len=TRAIN_SEQ_LEN,
223
+ )
224
+
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # Attention linéaire causale multi-niveaux (chemin chunk, avec carry)
228
+ # ---------------------------------------------------------------------------
229
+
230
+ class CteLinearAttention(nn.Module):
231
+ """Attention linéaire causale multi-niveaux du CTE (attention.py).
232
+
233
+ - Projections en convention x@W : q = einsum("bld,de->ble", h, w_qkv[0])
234
+ (PAS nn.Linear x@Wᵀ — leçon P3, doc §Forward point 1).
235
+ - Feature map φ(x; level) = elu_plus_one(x + ω_level) sur q et k UNIQUEMENT,
236
+ v brut ; offsets ω_level = (φ²)^(−level) (buffer, [1.0, 0.3819660…]).
237
+ - Récurrence causale INCLUSIVE avec carry (S₀, z₀) : S_t = S₀ + Σ_{i≤t} k_i⊗v_i,
238
+ z_t = z₀ + Σ_{i≤t} k_i, y_t = (q_t·S_t)/(q_t·z_t), 0 si |dénom| < 1e-10 ;
239
+ retourne l'état final (S, z) UNIQUEMENT quand un carry est fourni.
240
+
241
+ L'orchestration multi-niveaux + carry vit dans CteBlock.tick_chunk_core
242
+ (port exact de continuous_engine.py:139-217) — le forward autonome du module
243
+ d'origine (sans carry) n'est pas utilisé par le CTE et n'est pas reproduit.
244
+ """
245
+
246
+ def __init__(self, d_model: int, n_heads: int, d_head: int, n_levels: int = 2):
247
+ super().__init__()
248
+ if n_heads * d_head != d_model:
249
+ raise ValueError(f"n_heads·d_head ({n_heads * d_head}) != d_model ({d_model})")
250
+ if n_levels < 1:
251
+ raise ValueError("n_levels must be >= 1")
252
+ self.d_model = d_model
253
+ self.n_heads = n_heads
254
+ self.d_head = d_head
255
+ self.n_levels = n_levels
256
+ d_qkv = n_heads * d_head # = d_model
257
+
258
+ scale = math.sqrt(2.0 / (d_model + d_qkv))
259
+ self.w_qkv = nn.Parameter(torch.empty(3, d_model, d_qkv).uniform_(-scale, scale))
260
+ self.b_qkv = nn.Parameter(torch.zeros(3, d_qkv))
261
+ scale_out = math.sqrt(2.0 / (d_qkv + d_model))
262
+ self.w_out = nn.Parameter(torch.empty(d_qkv, d_model).uniform_(-scale_out, scale_out))
263
+ self.b_out = nn.Parameter(torch.zeros(d_model))
264
+ self.level_logits = nn.Parameter(torch.zeros(n_levels))
265
+ # Buffer persistant (clé du state_dict) — sondé [1.0, 0.3819660544395447].
266
+ self.register_buffer("level_offsets", mandelbrot_level_offsets(n_levels))
267
+
268
+ def linear_attention_causal(
269
+ self,
270
+ q: torch.Tensor,
271
+ k: torch.Tensor,
272
+ v: torch.Tensor,
273
+ carry: Optional[Tuple[torch.Tensor, torch.Tensor]] = None,
274
+ ):
275
+ """q, k : (B, L, D) déjà φ-mappés ; v : (B, L, D) brut.
276
+
277
+ Port exact de attention.py:129-187. Sans carry → y seul ; avec
278
+ carry=(S0, z0) → (y, (S_final, z_final)), l'état final incluant le carry.
279
+ """
280
+ L = q.shape[1]
281
+ outer = torch.einsum("btp,btq->btpq", k, v) # k_i ⊗ v_i
282
+ mask = torch.tril(torch.ones(L, L, dtype=q.dtype, device=q.device))
283
+ S = torch.einsum("tj,bjpq->btpq", mask, outer) # Σ_{i≤t} inclusif
284
+ z = torch.einsum("tj,bjp->btp", mask, k)
285
+ if carry is not None:
286
+ S0, z0 = carry
287
+ S = S + S0.unsqueeze(1) # le carry contribue à chaque position
288
+ z = z + z0.unsqueeze(1)
289
+ num = torch.einsum("btp,btpq->btq", q, S)
290
+ denom = (q * z).sum(dim=-1, keepdim=True)
291
+ safe = denom.abs() > 1e-10
292
+ y = torch.where(safe, num / (denom + 1e-20), torch.zeros_like(num))
293
+ if carry is not None:
294
+ return y, (S[:, -1], z[:, -1])
295
+ return y
296
+
297
+
298
+ # ---------------------------------------------------------------------------
299
+ # MoE batché low-rang routé par phases (moe.py, mode low-rank batché)
300
+ # ---------------------------------------------------------------------------
301
+
302
+ class PhaseRoutedMoEBatched(nn.Module):
303
+ """MoE low-rang BATCHÉ : un seul tenseur par famille, dim 0 = expert E.
304
+
305
+ - Routage von Mises : θ̄ = atan2(Σ sin θ, Σ cos θ) ; gate_e ∝
306
+ exp(κ_eff·cos(θ̄ − φ_e)) normalisées (uniforme si somme < 1e-10) ;
307
+ **κ_eff = KAPPA_EFF = 1.6** (4.0/2.5, décision 5 — constantes module).
308
+ - top_k=2 par valeur, gates retenues RENORMALISÉES sur les k retenus.
309
+ - Expert low-rang (orientation x@W partout, décision 2) :
310
+ h1 = scale1·(h @ V1) @ U1ᵀ + b1 ; h1_act = _gelu_tanh(h1) ;
311
+ out = scale2·(h1_act @ V2) @ U2ᵀ + b2.
312
+ - Dispatch adaptatif exact de l'original : sparse gather-first si
313
+ E > 2·top_k (toujours vrai pour E=128, K=2), dense sinon (tiny E=4, K=2)
314
+ — bit-identiques (moe.py:8-17).
315
+ - Load-balance (métrique) : lb = E·Σ_e (P_e − 1/E)², P = gates.mean((0,1)).
316
+ """
317
+
318
+ def __init__(
319
+ self,
320
+ d_model: int,
321
+ n_experts: int,
322
+ top_k: int,
323
+ d_ff: int,
324
+ expert_rank: int,
325
+ ):
326
+ super().__init__()
327
+ if n_experts < 1:
328
+ raise ValueError("n_experts >= 1")
329
+ if not 1 <= top_k <= n_experts:
330
+ raise ValueError(f"top_k must be in [1, {n_experts}]")
331
+ if expert_rank < 1:
332
+ raise ValueError("expert_rank must be >= 1")
333
+ self.d_model = d_model
334
+ self.n_experts = n_experts
335
+ self.top_k = top_k
336
+ self.d_ff = d_ff
337
+ self.expert_rank = expert_rank
338
+ # κ_eff fixé à la constante de fidélité — PAS configurable pour éviter
339
+ # la divergence silencieuse (l'original expose kappa/temperature en
340
+ # attributs runtime ; les trainers posent 2.5 post-load, doc §MoE).
341
+ self.kappa_eff = KAPPA_EFF
342
+
343
+ # Buffer persistant (clé du state_dict) — régénérable (Farey), écart
344
+ # sondé max 6.4e-07 vs recompute ; la valeur du checkpoint écrase.
345
+ self.register_buffer("expert_phases", farey_expert_phases(n_experts))
346
+
347
+ r = expert_rank
348
+ su1 = math.sqrt(2.0 / (d_ff + r))
349
+ sv1 = math.sqrt(2.0 / (d_model + r))
350
+ su2 = math.sqrt(2.0 / (d_model + r))
351
+ sv2 = math.sqrt(2.0 / (d_ff + r))
352
+ self.U1 = nn.Parameter(torch.empty(n_experts, d_ff, r).uniform_(-su1, su1))
353
+ self.V1 = nn.Parameter(torch.empty(n_experts, d_model, r).uniform_(-sv1, sv1))
354
+ self.U2 = nn.Parameter(torch.empty(n_experts, d_model, r).uniform_(-su2, su2))
355
+ self.V2 = nn.Parameter(torch.empty(n_experts, d_ff, r).uniform_(-sv2, sv2))
356
+ self.scale1 = nn.Parameter(torch.ones(n_experts, 1, 1))
357
+ self.scale2 = nn.Parameter(torch.ones(n_experts, 1, 1))
358
+ self.b1 = nn.Parameter(torch.zeros(n_experts, d_ff))
359
+ self.b2 = nn.Parameter(torch.zeros(n_experts, d_model))
360
+
361
+ def _compute_gates(self, phases: torch.Tensor) -> torch.Tensor:
362
+ """phases (B, L, N) → gates (B, L, E) normalisées (von Mises, κ_eff)."""
363
+ sin_p = torch.sin(phases).sum(dim=-1)
364
+ cos_p = torch.cos(phases).sum(dim=-1)
365
+ theta_bar = torch.atan2(sin_p, cos_p)
366
+ diff = theta_bar.unsqueeze(-1) - self.expert_phases.view(
367
+ *[1] * (phases.dim() - 1), self.n_experts
368
+ )
369
+ gates = torch.exp(self.kappa_eff * torch.cos(diff))
370
+ gates_sum = gates.sum(dim=-1, keepdim=True)
371
+ uniform = torch.full_like(gates, 1.0 / self.n_experts)
372
+ return torch.where(gates_sum > 1e-10, gates / gates_sum, uniform)
373
+
374
+ def _sparse_expert_forward(
375
+ self, h: torch.Tensor, topk_idx: torch.Tensor
376
+ ) -> torch.Tensor:
377
+ """GATHER-FIRST (moe.py:193-238) : ne calcule que les top_k experts.
378
+
379
+ h (B, L, D), topk_idx (B, L, K) → sorties par expert retenu (B, L, K, D).
380
+ """
381
+ B, L, D = h.shape
382
+ K = topk_idx.shape[-1]
383
+ r = self.expert_rank
384
+ flat_idx = topk_idx.reshape(-1)
385
+
386
+ g_U1 = self.U1.index_select(0, flat_idx).reshape(B * L, K, self.d_ff, r)
387
+ g_V1 = self.V1.index_select(0, flat_idx).reshape(B * L, K, D, r)
388
+ g_s1 = self.scale1.index_select(0, flat_idx).reshape(B * L, K, 1, 1)
389
+ g_b1 = self.b1.index_select(0, flat_idx).reshape(B * L, K, self.d_ff)
390
+ g_U2 = self.U2.index_select(0, flat_idx).reshape(B * L, K, D, r)
391
+ g_V2 = self.V2.index_select(0, flat_idx).reshape(B * L, K, self.d_ff, r)
392
+ g_s2 = self.scale2.index_select(0, flat_idx).reshape(B * L, K, 1, 1)
393
+ g_b2 = self.b2.index_select(0, flat_idx).reshape(B * L, K, D)
394
+
395
+ N = B * L
396
+ h_flat = h.reshape(N, D)
397
+ # Couche 1 : h1 = scale1·(h@V1)@U1ᵀ + b1 — x@W, scale AVANT le bias.
398
+ hV1 = torch.einsum("nd,nkdr->nkr", h_flat, g_V1)
399
+ h1 = g_s1.squeeze(-1) * torch.einsum("nkr,nkfr->nkf", hV1, g_U1) + g_b1
400
+ h1_act = _gelu_tanh(h1)
401
+ # Couche 2 : out = scale2·(h1_act@V2)@U2ᵀ + b2.
402
+ hV2 = torch.einsum("nkf,nkfr->nkr", h1_act, g_V2)
403
+ out = g_s2.squeeze(-1) * torch.einsum("nkr,nkdr->nkd", hV2, g_U2) + g_b2
404
+ return out.reshape(B, L, K, D)
405
+
406
+ def _dense_expert_forward(self, h: torch.Tensor) -> torch.Tensor:
407
+ """Dense (moe.py:271-284) : tous les E experts — (B, L, E, D)."""
408
+ hV1 = torch.einsum("bld,edr->bler", h, self.V1)
409
+ h1 = (
410
+ self.scale1.view(1, 1, self.n_experts, 1)
411
+ * torch.einsum("bler,efr->blef", hV1, self.U1)
412
+ + self.b1.view(1, 1, self.n_experts, self.d_ff)
413
+ )
414
+ h1_act = _gelu_tanh(h1)
415
+ hV2 = torch.einsum("blef,efr->bler", h1_act, self.V2)
416
+ out = (
417
+ self.scale2.view(1, 1, self.n_experts, 1)
418
+ * torch.einsum("bler,edr->bled", hV2, self.U2)
419
+ + self.b2.view(1, 1, self.n_experts, self.d_model)
420
+ )
421
+ return out
422
+
423
+ def forward(
424
+ self, h: torch.Tensor, phases: torch.Tensor
425
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
426
+ """h (B, L, D), phases (B, L, N) → (output (B, L, D), lb_loss scalaire)."""
427
+ B, L, D = h.shape
428
+ gates = self._compute_gates(phases)
429
+
430
+ topk_vals, topk_idx = gates.topk(self.top_k, dim=-1)
431
+ topk_sum = topk_vals.sum(dim=-1, keepdim=True)
432
+ uniform_topk = torch.full_like(topk_vals, 1.0 / self.top_k)
433
+ topk_vals_norm = torch.where(
434
+ topk_sum > 1e-10, topk_vals / topk_sum, uniform_topk
435
+ )
436
+
437
+ # Dispatch adaptatif exact de l'original : sparse si E > 2·K
438
+ # (E=128, K=2 → sparse ; tiny E=4, K=2 → dense — bit-identiques).
439
+ if self.n_experts > 2 * self.top_k:
440
+ topk_out = self._sparse_expert_forward(h, topk_idx)
441
+ else:
442
+ all_out = self._dense_expert_forward(h)
443
+ idx_exp = topk_idx.unsqueeze(-1).expand(-1, -1, -1, self.d_model)
444
+ topk_out = torch.gather(all_out, dim=2, index=idx_exp)
445
+
446
+ output = (topk_vals_norm.unsqueeze(-1) * topk_out).sum(dim=2)
447
+
448
+ P = gates.mean(dim=(0, 1))
449
+ lb_loss = self.n_experts * ((P - 1.0 / self.n_experts) ** 2).sum()
450
+ return output, lb_loss
451
+
452
+
453
+ # ---------------------------------------------------------------------------
454
+ # Bloc CTE (port exact de CTEBlock.tick_chunk_core)
455
+ # ---------------------------------------------------------------------------
456
+
457
+ class CteBlock(nn.Module):
458
+ """Un bloc du moteur : norm_attn → attention multi-niveaux avec carry →
459
+ résidu → norm_kur → RK4 (stateless) → norm_moe → MoE (phases de la dernière
460
+ position) → résidu. Propriétaire de ses buffers porteurs attn_S/attn_z et
461
+ du snapshot write-only kuramoto_phases.
462
+ """
463
+
464
+ def __init__(self, config: CteCoreConfig):
465
+ super().__init__()
466
+ self.attn = CteLinearAttention(
467
+ config.d_model, config.n_heads, config.d_head, config.n_levels
468
+ )
469
+ self.norm_attn = nn.LayerNorm(config.d_model)
470
+ # Kuramoto STATELESS : RK4 n_steps=1, dt=0.1, damping=0.01
471
+ # (continuous_engine.py:52-53) — KuramotoLayer de fractus_core est
472
+ # identique (doc « Communs confirmés »).
473
+ self.kuramoto = KuramotoLayer(
474
+ config.d_model,
475
+ n_oscillators=config.n_oscillators,
476
+ rank=config.coupling_rank,
477
+ n_steps=1,
478
+ dt=0.1,
479
+ damping=0.01,
480
+ )
481
+ self.norm_kur = nn.LayerNorm(config.d_model)
482
+ self.moe = PhaseRoutedMoEBatched(
483
+ d_model=config.d_model,
484
+ n_experts=config.n_experts,
485
+ top_k=config.top_k,
486
+ d_ff=config.expert_d_ff,
487
+ expert_rank=config.siren_rank,
488
+ )
489
+ self.norm_moe = nn.LayerNorm(config.d_model)
490
+
491
+ n_h_dh = config.n_heads * config.d_head
492
+ # États porteurs batchés (shapes du checkpoint pour le strict load).
493
+ self.register_buffer("attn_S", torch.zeros(config.batch, n_h_dh, n_h_dh))
494
+ self.register_buffer("attn_z", torch.zeros(config.batch, n_h_dh))
495
+ # write-only : le 128 du milieu = SEQ d'entraînement (exigence strict load)
496
+ self.register_buffer(
497
+ "kuramoto_phases",
498
+ torch.zeros(config.batch, config.train_seq_len, config.n_oscillators),
499
+ )
500
+
501
+ def reset_state(self, batch_size: int = 1) -> None:
502
+ """Zero les états de CE bloc (continuous_engine.py:73-80) —
503
+ kuramoto_phases re-dimensionné en (batch, 1, N)."""
504
+ device = self.attn_S.device
505
+ d = self.attn.n_heads * self.attn.d_head
506
+ self.attn_S = torch.zeros(batch_size, d, d, device=device)
507
+ self.attn_z = torch.zeros(batch_size, d, device=device)
508
+ self.kuramoto_phases = torch.zeros(
509
+ batch_size, 1, self.kuramoto.N, device=device
510
+ )
511
+
512
+ def tick_chunk_core(self, h: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
513
+ """Port exact de continuous_engine.py:139-217 — le SEUL chemin autorisé.
514
+
515
+ h (B, C, d_model) → (h_transformé, lb_loss). Carry (S, z) à travers les
516
+ frontières de chunks (pensée continue).
517
+ """
518
+ B, C, _D_model = h.shape
519
+ attn = self.attn
520
+ nH, dH, nL = attn.n_heads, attn.d_head, attn.n_levels
521
+
522
+ # 1. Attention pré-norm + projections x@W (:149-152).
523
+ h_normed = self.norm_attn(h)
524
+ q_all = torch.einsum("bld,de->ble", h_normed, attn.w_qkv[0]) + attn.b_qkv[0]
525
+ k_all = torch.einsum("bld,de->ble", h_normed, attn.w_qkv[1]) + attn.b_qkv[1]
526
+ v_all = torch.einsum("bld,de->ble", h_normed, attn.w_qkv[2]) + attn.b_qkv[2]
527
+ q_all = q_all.view(B, C, nH, dH)
528
+ k_all = k_all.view(B, C, nH, dH)
529
+ v_all = v_all.view(B, C, nH, dH)
530
+
531
+ # 2. Offsets par niveau sur q/k + feature map (v brut) (:157-162).
532
+ offsets = attn.level_offsets
533
+ q_lev = q_all.unsqueeze(1) + offsets.view(nL, 1, 1, 1)
534
+ k_lev = k_all.unsqueeze(1) + offsets.view(nL, 1, 1, 1)
535
+ q_feat = elu_plus_one(q_lev, alpha=1.0)
536
+ k_feat = elu_plus_one(k_lev, alpha=1.0)
537
+ v_lev = v_all.unsqueeze(1).expand(B, nL, C, nH, dH)
538
+ # Aplat multi-niveaux×têtes : (B·nL·nH, C, dH) (:163-165).
539
+ q_flat = q_feat.permute(0, 1, 3, 2, 4).reshape(B * nL * nH, C, dH)
540
+ k_flat = k_feat.permute(0, 1, 3, 2, 4).reshape(B * nL * nH, C, dH)
541
+ v_flat = v_lev.permute(0, 1, 3, 2, 4).reshape(B * nL * nH, C, dH)
542
+
543
+ # 3. Carry (S, z) : blocs diagonaux par tête, expansion sur les niveaux
544
+ # (:168-179) — les états sont tranchés à B (B ≤ batch des états).
545
+ state_S = self.attn_S[:B]
546
+ state_z = self.attn_z[:B]
547
+ carry_S_per_head = torch.stack(
548
+ [
549
+ state_S[:, hd * dH:(hd + 1) * dH, hd * dH:(hd + 1) * dH]
550
+ for hd in range(nH)
551
+ ],
552
+ dim=1,
553
+ )
554
+ carry_z_per_head = torch.stack(
555
+ [state_z[:, hd * dH:(hd + 1) * dH] for hd in range(nH)], dim=1
556
+ )
557
+ carry_S_flat = carry_S_per_head.unsqueeze(1).expand(
558
+ B, nL, nH, dH, dH
559
+ ).reshape(B * nL * nH, dH, dH)
560
+ carry_z_flat = carry_z_per_head.unsqueeze(1).expand(
561
+ B, nL, nH, dH
562
+ ).reshape(B * nL * nH, dH)
563
+
564
+ # 4. Attention linéaire causale INCLUSIVE avec carry (attention.py:129-187).
565
+ y_flat, (S_final, z_final) = attn.linear_attention_causal(
566
+ q_flat, k_flat, v_flat, carry=(carry_S_flat, carry_z_flat)
567
+ )
568
+
569
+ # 5. Nouvel état : moyenne sur les niveaux + reconstruction
570
+ # bloc-diagonale + detach (:184-193).
571
+ S_reshaped = S_final.reshape(B, nL, nH, dH, dH).mean(dim=1)
572
+ z_reshaped = z_final.reshape(B, nL, nH, dH).mean(dim=1)
573
+ new_S = torch.zeros(B, nH * dH, nH * dH, device=h.device, dtype=h.dtype)
574
+ new_z = torch.zeros(B, nH * dH, device=h.device, dtype=h.dtype)
575
+ for hd in range(nH):
576
+ new_S[:, hd * dH:(hd + 1) * dH, hd * dH:(hd + 1) * dH] = S_reshaped[:, hd]
577
+ new_z[:, hd * dH:(hd + 1) * dH] = z_reshaped[:, hd]
578
+ self.attn_S = new_S.detach()
579
+ self.attn_z = new_z.detach()
580
+
581
+ # 6. Agrégation softmax(level_logits) + projection x@W + résidu (:195-199).
582
+ y = y_flat.reshape(B, nL, nH, C, dH).permute(0, 1, 3, 2, 4).reshape(
583
+ B, nL, C, nH * dH
584
+ )
585
+ level_weights = torch.softmax(attn.level_logits, dim=-1)
586
+ attn_out = (y * level_weights.view(1, nL, 1, 1)).sum(dim=1)
587
+ attn_out = attn_out @ attn.w_out + attn.b_out
588
+ h = h + attn_out
589
+
590
+ # 7. Kuramoto pré-norm, STATELESS par forward (:203-206) : phases
591
+ # initiales dérivées du hidden, RK4, snapshot write-only.
592
+ h_kur = self.norm_kur(h)
593
+ theta = self.kuramoto(h_kur)
594
+ self.kuramoto_phases = theta.detach()
595
+
596
+ # 8. MoE pré-norm : phases de la DERNIÈRE position broadcastées (:211-215).
597
+ h_moe = self.norm_moe(h)
598
+ phases_last = theta[:, -1:, :]
599
+ phases_in = phases_last.expand(-1, C, -1)
600
+ moe_out, lb_loss = self.moe(h_moe, phases_in)
601
+ h = h + moe_out
602
+
603
+ return h, lb_loss
604
+
605
+
606
+ # ---------------------------------------------------------------------------
607
+ # Moteur
608
+ # ---------------------------------------------------------------------------
609
+
610
+ class CteCore(nn.Module):
611
+ """Continuous Thought Engine natif : token_ids (B, L) → logits (B, L, vocab).
612
+
613
+ Arbre des modules EXACTEMENT conforme au doc §Table des clés pour un
614
+ `load_state_dict(strict=True)` sur les 440 clés du FROZEN_MERGED sans
615
+ aucune retouche. output_head liée à observe. Forward = tick_chunk
616
+ EXCLUSIF (décision 4) ; generate suit la recette d'inférence du doc
617
+ (reset complet batch 1 → UN chunk prompt → boucle chunks de longueur 1).
618
+ """
619
+
620
+ def __init__(self, config: CteCoreConfig):
621
+ super().__init__()
622
+ self.config = config
623
+ self.vocab_size = config.vocab_size
624
+ self.d_model = config.d_model
625
+
626
+ # Embedding de token SEUL — pas de position, pas de norm d'embedding :
627
+ # la position vient du carry (S, z) de l'attention linéaire (doc §Forward).
628
+ self.observe = nn.Embedding(config.vocab_size, config.d_model)
629
+
630
+ self.blocks = nn.ModuleList([CteBlock(config) for _ in range(config.n_layers)])
631
+
632
+ # Accumulateur load-balance (métrique training, sans effet forward).
633
+ self.register_buffer("last_lb_loss", torch.tensor(0.0))
634
+
635
+ # Têtes : nn.Linear x@Wᵀ (décision 2) — lectures seules à l'inférence
636
+ # (generate_aligned n'utilise ni confidence ni salience).
637
+ self.confidence_head = nn.Linear(config.d_model, 1)
638
+ self.output_head = nn.Linear(config.d_model, config.vocab_size, bias=False)
639
+ self.output_head.weight = self.observe.weight # LIÉE (décision 3)
640
+ self.salience_head = nn.Linear(config.d_model, 1)
641
+
642
+ # Flux résiduel porteur partagé, continu à travers chunks.
643
+ self.register_buffer(
644
+ "thought_state", torch.zeros(config.batch, 1, config.d_model)
645
+ )
646
+
647
+ # ------------------------------------------------------------------
648
+ # États porteurs
649
+ # ------------------------------------------------------------------
650
+
651
+ def reset_states(self, batch_size: int = 1) -> None:
652
+ """Reset inter-séquences (recette d'inférence, étape 1 ; port exact de
653
+ reset_thought continuous_engine.py:336-341) : thought_state + attn_S/
654
+ attn_z/kuramoto_phases de chaque bloc → zéros en batch `batch_size`.
655
+
656
+ last_lb_loss n'est PAS réinitialisée (métrique training, sans effet
657
+ forward — doc §États porteurs).
658
+ """
659
+ self.thought_state = torch.zeros(
660
+ batch_size, 1, self.d_model, device=self.thought_state.device
661
+ )
662
+ for blk in self.blocks:
663
+ blk.reset_state(batch_size)
664
+
665
+ # ------------------------------------------------------------------
666
+ # Forward — chemin tick_chunk EXCLUSIF (décision 4)
667
+ # ------------------------------------------------------------------
668
+
669
+ def forward(self, token_ids: torch.Tensor) -> torch.Tensor:
670
+ """token_ids (B, L) → logits (B, L, vocab_size).
671
+
672
+ Port exact de tick_chunk (continuous_engine.py:458-475) : le porteur
673
+ thought_state n'est ajouté qu'à la position 0 du chunk ; il devient
674
+ h[:, -1:, :] du dernier bloc. B doit être ≤ le batch des états porteurs
675
+ (les états sont tranchés à B puis deviennent B-sized) — sinon appeler
676
+ reset_states(B) d'abord.
677
+ """
678
+ if token_ids.dim() != 2:
679
+ raise ValueError(f"token_ids attendu (B, L), reçu {tuple(token_ids.shape)}")
680
+ B, C = token_ids.shape
681
+ if C < 1:
682
+ raise ValueError("chunk vide (L=0) interdit")
683
+ if B > self.thought_state.shape[0]:
684
+ raise ValueError(
685
+ f"B={B} > batch des états porteurs ({self.thought_state.shape[0]}) "
686
+ "— appeler reset_states(B) d'abord (les états sont batchés)"
687
+ )
688
+
689
+ obs_vecs = self.observe(token_ids)
690
+ h = obs_vecs.clone()
691
+ # Le flux résiduel porté n'est ajouté QU'à la position 0 du chunk.
692
+ h[:, 0, :] = h[:, 0, :] + self.thought_state[:B, 0, :]
693
+
694
+ total_lb = torch.tensor(0.0, device=h.device)
695
+ for blk in self.blocks:
696
+ h, lb = blk.tick_chunk_core(h)
697
+ total_lb = total_lb + lb.detach()
698
+ self.last_lb_loss = total_lb
699
+
700
+ # Le dernier h devient le nouveau porteur.
701
+ self.thought_state = h[:, -1:, :].detach()
702
+ # PAS de LayerNorm finale — output_head s'applique directement au h.
703
+ return self.output_head(h)
704
+
705
+ # ------------------------------------------------------------------
706
+ # Génération — recette d'inférence officielle (doc §Recette d'inférence)
707
+ # ------------------------------------------------------------------
708
+
709
+ @torch.no_grad()
710
+ def generate(
711
+ self,
712
+ token_ids: torch.Tensor,
713
+ max_new_tokens: int = 32,
714
+ temperature: float = 0.0,
715
+ eos_token_id: Optional[int] = None,
716
+ seed: int = 0,
717
+ ) -> torch.Tensor:
718
+ """Génération auto-régressive batch 1, train-aligned (generate_aligned).
719
+
720
+ Recette : eval + reset_states(1) complet ; UN SEUL chunk pour le prompt
721
+ (logits[0, -1]) ; boucle token par token en chunks de LONGUEUR 1 — les
722
+ états porteurs vivent à travers la boucle, jamais de reset intermédiaire,
723
+ jamais tick_single. temperature=0 (défaut) → greedy déterministe (argmax) ;
724
+ sinon multinomial sur softmax(logits/T) avec torch.Generator seedé.
725
+ eos_token_id : append-then-break (sémantique ratifiée P4) — l'EOS clôt
726
+ la séquence, aucun token n'est consommé après lui.
727
+ """
728
+ if token_ids.dim() != 2 or token_ids.shape[0] != 1:
729
+ raise ValueError(
730
+ "generate travaille en batch 1 (recette d'inférence) — "
731
+ f"token_ids (1, L), reçu {tuple(token_ids.shape)}"
732
+ )
733
+ self.eval()
734
+ self.reset_states(1)
735
+
736
+ ids = token_ids.clone()
737
+ logits = self(ids) # le prompt en UN SEUL chunk
738
+ cur = logits[0, -1]
739
+
740
+ gen = None
741
+ if temperature > 0.0:
742
+ gen = torch.Generator(device=ids.device)
743
+ gen.manual_seed(seed)
744
+
745
+ for _ in range(max_new_tokens):
746
+ if temperature <= 0.0:
747
+ next_tok = cur.argmax(dim=-1) # greedy déterministe
748
+ else:
749
+ probs = torch.softmax(cur / temperature, dim=-1)
750
+ next_tok = torch.multinomial(probs, 1, generator=gen)
751
+ ids = torch.cat([ids, next_tok.view(1, 1)], dim=1) # append-then-break
752
+ if eos_token_id is not None and int(next_tok.item()) == eos_token_id:
753
+ break
754
+ logits = self(next_tok.view(1, 1)) # chunk de longueur 1
755
+ cur = logits[0, -1]
756
+ return ids
757
+
758
+ # ------------------------------------------------------------------
759
+ # Chargement strict de checkpoint
760
+ # ------------------------------------------------------------------
761
+
762
+ @classmethod
763
+ def from_checkpoint(
764
+ cls, path: Union[str, Path], map_location: str = "cpu"
765
+ ) -> "CteCore":
766
+ """Charge un checkpoint CTE en STRICT (jamais strict=False).
767
+
768
+ - torch.load(weights_only=False) — fichier local de confiance. mmap
769
+ d'abord (même pattern que probe_checkpoint_kind) : les tenseurs du
770
+ state_dict restent adossés au FICHIER (zéro commit RAM), puis
771
+ load_state_dict(assign=True) — les POIDS du 1B de 4,66 GB ne sont
772
+ jamais copiés en RAM privée (chargeable sur ~12 GB de RAM) ; repli
773
+ copie ordinaire si mmap échoue pour CE fichier (archive
774
+ non-zipfile, torch trop vieux…).
775
+ - state_dict cherché sous « model_state » (format FROZEN_MERGED), puis
776
+ « model », « state_dict », sinon dict nu — marqueurs CTE :
777
+ observe.weight + thought_state.
778
+ - Config inférée des shapes (règles dans `_infer_config`) ; le dict
779
+ top-level « config » du checkpoint (attributs non encodables dans les
780
+ shapes : n_heads/d_head, top_k) sert d'indice quand il existe.
781
+ - Pas de reset post-chargement : les états du checkpoint (batch 2) sont
782
+ conservés tels quels ; la recette d'inférence appelle reset_states(1).
783
+ """
784
+ mmap_ok = True
785
+ try:
786
+ ckpt = torch.load(
787
+ path, map_location=map_location, weights_only=False, mmap=True
788
+ )
789
+ except Exception: # mmap non supporté pour ce fichier — repli ordinaire
790
+ mmap_ok = False
791
+ ckpt = torch.load(path, map_location=map_location, weights_only=False)
792
+ sd: Optional[Dict[str, torch.Tensor]] = None
793
+ hint: dict = {}
794
+ if isinstance(ckpt, dict):
795
+ for key in ("model_state", "model", "state_dict"):
796
+ cand = ckpt.get(key)
797
+ if isinstance(cand, dict) and "observe.weight" in cand \
798
+ and "thought_state" in cand:
799
+ sd = cand
800
+ break
801
+ if sd is None and "observe.weight" in ckpt and "thought_state" in ckpt:
802
+ sd = ckpt # state_dict nu
803
+ if isinstance(ckpt.get("config"), dict):
804
+ hint = ckpt["config"]
805
+ if sd is None:
806
+ raise ValueError(
807
+ f"{path}: aucune clé 'model_state'/'model'/'state_dict' ni "
808
+ "state_dict CTE détectable au niveau supérieur "
809
+ "(marqueurs : observe.weight + thought_state)"
810
+ )
811
+ config = cls._infer_config(sd, hint)
812
+ if mmap_ok:
813
+ # Construction sur MÉTA (zéro stockage) : les poids réels ne vivent
814
+ # que comme vues mmap du fichier — le pic de RAM privée tombe de
815
+ # ~8,9 Go (copie classique) à ~0,5 Go (buffers clonés + activations).
816
+ # Idiome standard torch (accélérateurs/FSDP) : meta puis assign.
817
+ with torch.device("meta"):
818
+ model = cls(config)
819
+ else:
820
+ model = cls(config)
821
+ # Garde de SHAPES restaurée (revue T2) : ``assign=True`` REMPLACE les
822
+ # tenseurs au lieu d'y copier, ce qui court-circuite la validation des
823
+ # shapes de load_state_dict — un state_dict aux shapes dérivées
824
+ # s'attacherait sans erreur. Chaque clé doit exister des deux côtés
825
+ # avec la même shape, AVANT l'assign : mismatch -> RuntimeError.
826
+ expected = {k: tuple(t.shape) for k, t in model.state_dict().items()}
827
+ bad = [
828
+ f"{k}: absent du checkpoint (modèle attend {shape})"
829
+ if k not in sd else
830
+ f"{k}: checkpoint {tuple(sd[k].shape)} vs modèle {shape}"
831
+ for k, shape in expected.items()
832
+ if k not in sd or tuple(sd[k].shape) != shape
833
+ ]
834
+ bad += [
835
+ f"{k}: clé checkpoint absente du modèle (shape {tuple(sd[k].shape)})"
836
+ for k in sd
837
+ if k not in expected
838
+ ]
839
+ if bad:
840
+ raise RuntimeError(
841
+ "shapes incompatibles checkpoint/modèle (garde restaurée : "
842
+ "assign=True ne valide pas les shapes) — " + "; ".join(bad[:8])
843
+ )
844
+ result = model.load_state_dict(sd, strict=True, assign=mmap_ok)
845
+ missing = list(result.missing_keys)
846
+ unexpected = list(result.unexpected_keys)
847
+ if missing or unexpected:
848
+ raise RuntimeError(
849
+ f"strict load incomplet — manquantes: {missing[:8]} "
850
+ f"inattendues: {unexpected[:8]}"
851
+ )
852
+ if mmap_ok:
853
+ # Poids ASSIGNÉS : les paramètres restent adossés au FICHIER (vues
854
+ # mmap lecture seule) — zéro copie privée (~4,2 Go économisés sur le
855
+ # 1B : le cerveau tient sur une machine à ~12 Go de RAM) et le pacte
856
+ # rendu PHYSIQUE : aucune écriture in-place ne peut toucher les
857
+ # poids du fichier. Seuls les états porteurs (buffers, ~215 Mo sur
858
+ # le 1B) doivent redevenir écrivables : la récurrence forward met à
859
+ # jour attn_S/attn_z/thought_state in-place.
860
+ with torch.no_grad():
861
+ for buf in model.buffers():
862
+ buf.set_(buf.detach().clone())
863
+ # restaurer la tête liée (décision 3 : output_head.weight EST
864
+ # observe.weight) — assign a détaché les deux clés jumelles.
865
+ model.output_head.weight = model.observe.weight
866
+ model.eval()
867
+ return model
868
+
869
+ @staticmethod
870
+ def _default_head_split(d_model: int) -> Tuple[int, int]:
871
+ """Split par défaut quand NI les shapes NI le config ne l'encodent.
872
+
873
+ w_qkv est cubique (3, d, d) : le split n_heads×d_head est invisible dans
874
+ le state_dict. Heuristique : plus grand d_head ∈ {64, 32, 16, 8, 4, 2, 1}
875
+ divisant d_model avec au moins 4 têtes (d_head ≤ d_model/4) — retombe sur
876
+ les presets connus : 1280 → 20×64 (réel CTE), 768 → 12×64 (88M),
877
+ 64 → 4×16 (tiny). Dernier recours : 1 tête.
878
+ """
879
+ for cand in (64, 32, 16, 8, 4, 2, 1):
880
+ if d_model % cand == 0 and cand * 4 <= d_model:
881
+ return d_model // cand, cand
882
+ return d_model, 1
883
+
884
+ @staticmethod
885
+ def _infer_config(sd: Dict[str, torch.Tensor], hint: Optional[dict] = None) -> CteCoreConfig:
886
+ """Règles d'inférence de la config depuis les shapes du state_dict :
887
+
888
+ - vocab_size, d_model : shape de `observe.weight` (vocab, d) ;
889
+ - batch : shape de `thought_state` (batch, 1, d) ;
890
+ - train_seq_len : shape de `blocks.0.kuramoto_phases` (batch, SEQ, N) ;
891
+ - n_layers : 1 + max indice des clés `blocks.(i).…` ;
892
+ - n_levels : shape de `blocks.0.attn.level_logits` ;
893
+ - n_experts : shape de `blocks.0.moe.expert_phases` (E,) ;
894
+ - expert_d_ff, siren_rank : dims de `blocks.0.moe.U1` (E, F, r) ;
895
+ - n_oscillators : shape de `blocks.0.kuramoto.omega` ;
896
+ - coupling_rank : shape de `blocks.0.kuramoto.coupling_lambda` ;
897
+ - n_heads/d_head : NON encodés (w_qkv cubique) → indice `config`
898
+ du checkpoint si cohérent (n_heads·d_head == d_model), sinon
899
+ heuristique `_default_head_split` ;
900
+ - top_k : non encodé → indice `config`, sinon 2
901
+ (jamais surchargé à l'entraînement).
902
+ """
903
+ hint = hint or {}
904
+ vocab_size, d_model = (int(v) for v in sd["observe.weight"].shape)
905
+ batch = int(sd["thought_state"].shape[0])
906
+ block_ids = [
907
+ int(m.group(1)) for k in sd if (m := re.match(r"^blocks\.(\d+)\.", k))
908
+ ]
909
+ if not block_ids:
910
+ raise ValueError("state_dict sans clés 'blocks.(i).…' — pas un CTE")
911
+ n_layers = 1 + max(block_ids)
912
+ train_seq_len = int(sd["blocks.0.kuramoto_phases"].shape[1])
913
+ n_levels = int(sd["blocks.0.attn.level_logits"].shape[0])
914
+ n_experts = int(sd["blocks.0.moe.expert_phases"].shape[0])
915
+ expert_d_ff = int(sd["blocks.0.moe.U1"].shape[1])
916
+ siren_rank = int(sd["blocks.0.moe.U1"].shape[2])
917
+ n_oscillators = int(sd["blocks.0.kuramoto.omega"].shape[0])
918
+ coupling_rank = int(sd["blocks.0.kuramoto.coupling_lambda"].shape[0])
919
+ top_k = hint.get("top_k", 2)
920
+ if not isinstance(top_k, int) or not 1 <= top_k <= n_experts:
921
+ top_k = 2
922
+
923
+ n_heads = hint.get("n_heads")
924
+ d_head = hint.get("d_head")
925
+ if not (
926
+ isinstance(n_heads, int) and isinstance(d_head, int)
927
+ and n_heads * d_head == d_model
928
+ ):
929
+ n_heads, d_head = CteCore._default_head_split(d_model)
930
+
931
+ return CteCoreConfig(
932
+ vocab_size=vocab_size,
933
+ d_model=d_model,
934
+ n_heads=n_heads,
935
+ d_head=d_head,
936
+ n_levels=n_levels,
937
+ n_oscillators=n_oscillators,
938
+ coupling_rank=coupling_rank,
939
+ n_experts=n_experts,
940
+ top_k=top_k,
941
+ expert_d_ff=expert_d_ff,
942
+ siren_rank=siren_rank,
943
+ n_layers=n_layers,
944
+ batch=batch,
945
+ train_seq_len=train_seq_len,
946
+ )
fractus_vorax/model/diagnostics.py ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """fractus_vorax/model/diagnostics.py — l'observabilité à ciel ouvert (Plan 6, tâche 2).
2
+
3
+ Lire le cerveau scellé SANS le toucher : quels experts tirent (routage von
4
+ Mises), que disent les têtes confiance/salience. Zéro gradient, zéro
5
+ modification du noyau — ``cte_core.py`` n'est NI modifié NI patché : la
6
+ contrainte de la tâche (« recalcul extérieur : zéro changement du noyau »)
7
+ est respectée à la lettre — hooks et réplication seulement.
8
+
9
+ APPROCHE (décision de la tâche, notes de fidélité) :
10
+
11
+ - **expert_routing_snapshot** — un forward hook par bloc sur son sous-module
12
+ ``moe`` capture le couple ``(h_moe, phases)`` RÉELLEMENT consommé par la
13
+ passe MoE du forward ; les gates sont recalculées depuis ces phases
14
+ exactes via la propre méthode ``_compute_gates`` du module (appel en
15
+ LECTURE SEULE — bit-exact par construction : même tenseur, même code
16
+ chemin), puis top-k renormalisé comme dans son ``forward``. Le snapshot
17
+ rapporte donc les ids/poids EXACTS du routage qui a produit les logits —
18
+ PAS une approximation. Fidélité :
19
+ * phases CTE = DERNIÈRE position du chunk broadcastée sur tout le chunk
20
+ (doc §Forward point 8) → gates identiques à toutes les positions ; on
21
+ rapporte la position -1 (la pensée courante). FractusCore : phases par
22
+ position → position -1 aussi.
23
+ * gardes dégénérées répliquées à l'identique : gates somme < 1e-10 →
24
+ uniforme 1/E ; top-k somme < 1e-10 → uniforme 1/K.
25
+ * si un moe duck-typé n'expose pas ``_compute_gates``, repli sur la
26
+ réplication inline de la formule von Mises (θ̄ = atan2(Σ sin θ,
27
+ Σ cos θ) ; gate_e ∝ exp(κ·cos(θ̄ − φ_e)) — κ_eff CTE via
28
+ ``module.kappa_eff``, κ direct fractus via ``module.kappa`` ; doc §MoE
29
+ batché et §Divergences).
30
+ * batch élément 0 rapporté (les deux noyaux parlent en batch 1).
31
+ - **head_readout** — les têtes ``confidence_head``/``salience_head`` du CTE
32
+ ne sont PAS invoquées par ``CteCore.forward`` (lectures seules à
33
+ l'inférence, doc §Forward « Rôle de observe ») : un hook posé SUR ELLES
34
+ ne tirerait jamais. On capture donc le hidden final ``h`` via un forward
35
+ hook sur ``output_head`` (son ENTRÉE est exactement le h qui produit les
36
+ logits — aucune approximation), puis on évalue les deux têtes soi-même :
37
+ ``sigmoid(confidence_head(h[0, -1]))`` et ``sigmoid(salience_head(h[0,
38
+ -1]))`` — la formule du tick() original (continuous_engine.py:437-456,
39
+ source heritage LECTURE SEULE : sigmoid sur ``h[:, 0, :]`` d'un chunk de
40
+ longueur 1 — sous la recette officielle d'inférence, position 0 ==
41
+ position -1 ; pour un chunk long on lit -1, la pensée courante qui
42
+ prédit le token suivant, généralisation naturelle du chemin officiel).
43
+ Borné [0, 1) par construction (sigmoid). FractusCore (pas de têtes,
44
+ lm_head seule) → ``{"confidence": None, "salience": None}``, sans lancer
45
+ le moindre forward.
46
+
47
+ CONVENTION D'ÉTATS (même choix que speak(), documenté) : la lecture forward
48
+ MUTE les états porteurs (attn_S/attn_z/thought_state) — c'est inhérent au
49
+ chemin tick_chunk. Pour des diagnostics REPRODUCTIBLES (contrainte du
50
+ plan), ``reset_states(1)`` est appelé au départ quand le noyau l'expose.
51
+ Un noyau mid-génération sera donc réinitialisé : introspecter AVANT de
52
+ parler, ou assumer le reset.
53
+
54
+ GATE torch : ce fichier vit dans fractus_vorax/model/ (zone exemptée par
55
+ tests/test_gates.py) et importe torch au niveau module, comme cte_core/
56
+ speak — jamais importé par le substrat mingw (tests importorskip).
57
+ """
58
+
59
+ from __future__ import annotations
60
+
61
+ from typing import Dict, List, Optional, Sequence, Tuple, Union
62
+
63
+ import torch
64
+
65
+ TokenIds = Union[torch.Tensor, Sequence[int]]
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Aides privées
70
+ # ---------------------------------------------------------------------------
71
+
72
+ def _core_device(core) -> torch.device:
73
+ """Device des paramètres du noyau (cpu si aucun paramètre/attribut)."""
74
+ try:
75
+ return next(core.parameters()).device
76
+ except (StopIteration, AttributeError):
77
+ return torch.device("cpu")
78
+
79
+
80
+ def _as_batch_ids(core, token_ids: TokenIds) -> torch.Tensor:
81
+ """tensor (B, L)|(L,) | list[int] → tensor long (B, L).
82
+
83
+ Liste/tenseur 1-D → batch 1. Vide → ValueError (chunk vide interdit,
84
+ même contrat que CteCore.forward).
85
+ """
86
+ if isinstance(token_ids, torch.Tensor):
87
+ ids = token_ids.to(dtype=torch.long)
88
+ if ids.dim() == 1:
89
+ ids = ids.unsqueeze(0)
90
+ if ids.dim() != 2:
91
+ raise ValueError(
92
+ f"token_ids attendu (B, L) ou (L,), reçu {tuple(token_ids.shape)}"
93
+ )
94
+ else:
95
+ seq = [int(t) for t in token_ids]
96
+ if not seq:
97
+ raise ValueError("token_ids vide : rien à diagnostiquer")
98
+ ids = torch.tensor([seq], dtype=torch.long, device=_core_device(core))
99
+ if ids.shape[1] < 1:
100
+ raise ValueError("chunk vide (L=0) interdit")
101
+ return ids
102
+
103
+
104
+ def _gates_from_phases(moe, phases: torch.Tensor) -> torch.Tensor:
105
+ """Gates von Mises (B, L, E) normalisées.
106
+
107
+ La méthode ``_compute_gates`` du module si elle existe (bit-exact : le
108
+ même code qui a produit le forward — CTE comme fractus la nomment
109
+ ainsi) ; sinon réplication inline de la formule (doc §MoE batché),
110
+ κ lu sur le module : ``kappa_eff`` (CTE, 1.6) sinon ``kappa`` (fractus,
111
+ 4.0 direct).
112
+ """
113
+ compute = getattr(moe, "_compute_gates", None)
114
+ if callable(compute):
115
+ return compute(phases)
116
+ kappa = getattr(moe, "kappa_eff", None)
117
+ if kappa is None:
118
+ kappa = getattr(moe, "kappa", None)
119
+ if kappa is None or not hasattr(moe, "expert_phases"):
120
+ raise TypeError(
121
+ f"{type(moe).__name__} n'expose ni _compute_gates ni "
122
+ "(kappa|kappa_eff)+expert_phases — routage illisible"
123
+ )
124
+ n_experts = int(moe.expert_phases.shape[-1])
125
+ theta_bar = torch.atan2(
126
+ torch.sin(phases).sum(dim=-1), torch.cos(phases).sum(dim=-1)
127
+ )
128
+ diff = theta_bar.unsqueeze(-1) - moe.expert_phases
129
+ gates = torch.exp(kappa * torch.cos(diff))
130
+ gates_sum = gates.sum(dim=-1, keepdim=True)
131
+ uniform = torch.full_like(gates, 1.0 / n_experts)
132
+ return torch.where(gates_sum > 1e-10, gates / gates_sum, uniform)
133
+
134
+
135
+ def _topk_renormalized(gates_1d: torch.Tensor, k: int) -> List[Tuple[int, float]]:
136
+ """Réplique EXACTEMENT le top-k renormalisé du forward du MoE :
137
+
138
+ topk trié par valeur décroissante, renormalisation sur les k retenus,
139
+ garde dégénérée somme < 1e-10 → uniforme 1/k (mêmes seuils que
140
+ PhaseRoutedMoEBatched.forward).
141
+ """
142
+ vals, idx = gates_1d.topk(k, dim=-1)
143
+ total = vals.sum()
144
+ if float(total) > 1e-10:
145
+ norm = vals / total
146
+ else:
147
+ norm = torch.full_like(vals, 1.0 / k)
148
+ return [(int(e), float(w)) for e, w in zip(idx.tolist(), norm.tolist())]
149
+
150
+
151
+ def _reset_and_eval(core) -> None:
152
+ """Convention speak() : états porteurs repartent de zéro (diagnostics
153
+ reproductibles), mode eval si le noyau l'expose (duck-typing)."""
154
+ if hasattr(core, "reset_states"):
155
+ core.reset_states(1)
156
+ if hasattr(core, "eval"):
157
+ core.eval()
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # API publique
162
+ # ---------------------------------------------------------------------------
163
+
164
+ @torch.no_grad() # le pacte : lecture seule, jamais de gradient
165
+ def expert_routing_snapshot(
166
+ core, token_ids: TokenIds
167
+ ) -> Dict[str, List[Tuple[int, float]]]:
168
+ """Photo du routage MoE de CHAQUE couche pour ces token_ids — ciel ouvert.
169
+
170
+ ``{f"layer_{i}": [(expert_id, gate_weight), ...top_k]}`` — les poids
171
+ renormalisés somment à 1, triés par gate décroissante, ids du noyau
172
+ RÉEL (0..n_experts-1). Recalcul extérieur : un forward hook par bloc
173
+ capture les phases consommées par la passe MoE, les gates sont
174
+ recalculées par la propre méthode du module (bit-exact — notes de
175
+ fidélité en tête de module). FractusCore lisible aussi (mêmes
176
+ conventions moe) ; batch élément 0 ; position -1.
177
+
178
+ Les hooks sont TOUJOURS retirés (finally). Le forward de lecture mute
179
+ les états porteurs : reset_states(1) au départ (convention speak).
180
+ """
181
+ ids = _as_batch_ids(core, token_ids)
182
+ captured: Dict[str, List[Tuple[int, float]]] = {}
183
+ handles = []
184
+
185
+ def _make_hook(layer_key: str):
186
+ def hook(module, args, output):
187
+ if len(args) < 2:
188
+ raise RuntimeError(
189
+ f"hook moe ({layer_key}) : phases absentes des arguments "
190
+ "du forward — protocole inattendu"
191
+ )
192
+ phases = args[1].detach()
193
+ # (B, L, E) → batch 0, dernière position : la pensée courante.
194
+ # CTE : broadcast de la dernière position → identique partout.
195
+ gates = _gates_from_phases(module, phases)[0, -1]
196
+ captured[layer_key] = _topk_renormalized(gates, int(module.top_k))
197
+
198
+ return hook
199
+
200
+ try:
201
+ for i, blk in enumerate(core.blocks):
202
+ moe = getattr(blk, "moe", None)
203
+ if moe is not None:
204
+ handles.append(
205
+ moe.register_forward_hook(_make_hook(f"layer_{i}"))
206
+ )
207
+ _reset_and_eval(core)
208
+ core(ids)
209
+ finally:
210
+ for handle in handles:
211
+ handle.remove()
212
+
213
+ missing = [
214
+ f"layer_{i}"
215
+ for i, blk in enumerate(core.blocks)
216
+ if getattr(blk, "moe", None) is not None and f"layer_{i}" not in captured
217
+ ]
218
+ if missing:
219
+ raise RuntimeError(
220
+ f"forward sans passe MoE sur {missing} — routage non observable "
221
+ "pour ce noyau"
222
+ )
223
+ return dict(captured)
224
+
225
+
226
+ @torch.no_grad() # le pacte : lecture seule, jamais de gradient
227
+ def head_readout(core, token_ids: TokenIds) -> Dict[str, Optional[float]]:
228
+ """Lecture des têtes confiance/salience du CTE pour ces token_ids.
229
+
230
+ ``{"confidence": float|None, "salience": float|None}`` — floats Python
231
+ bornés [0, 1) (sigmoid, formule du tick() original) sur CteCore ;
232
+ ``None``/``None`` sur un noyau sans têtes (FractusCore), sans lancer
233
+ le moindre forward.
234
+
235
+ Le hidden final est capté par un forward hook sur ``output_head`` (son
236
+ entrée EST le h des logits — les têtes n'étant pas invoquées par le
237
+ forward, les hooker directement ne tirerait jamais ; notes de fidélité
238
+ en tête de module). Position -1 (la pensée courante). Hook toujours
239
+ retiré ; reset_states(1) au départ (convention speak).
240
+ """
241
+ conf_head = getattr(core, "confidence_head", None)
242
+ sal_head = getattr(core, "salience_head", None)
243
+ if conf_head is None and sal_head is None:
244
+ return {"confidence": None, "salience": None}
245
+
246
+ target = getattr(core, "output_head", None)
247
+ if target is None:
248
+ target = getattr(core, "lm_head", None)
249
+ if target is None:
250
+ raise TypeError(
251
+ f"{type(core).__name__} expose des têtes mais ni output_head ni "
252
+ "lm_head pour capter le hidden final — lecture impossible"
253
+ )
254
+
255
+ hidden: Dict[str, torch.Tensor] = {}
256
+
257
+ def hook(module, args, output):
258
+ if len(args) < 1:
259
+ raise RuntimeError("hook tête de sortie : hidden absent des arguments")
260
+ hidden["h"] = args[0].detach()
261
+
262
+ handle = target.register_forward_hook(hook)
263
+ try:
264
+ _reset_and_eval(core)
265
+ core(_as_batch_ids(core, token_ids))
266
+ finally:
267
+ handle.remove()
268
+
269
+ if "h" not in hidden:
270
+ raise RuntimeError(
271
+ "la tête de sortie n'a pas été invoquée par le forward — "
272
+ "hidden non observable pour ce noyau"
273
+ )
274
+ h_last = hidden["h"][0, -1] # (d_model,) — batch 0, position courante
275
+
276
+ def _read(head) -> Optional[float]:
277
+ if head is None:
278
+ return None
279
+ return float(torch.sigmoid(head(h_last)).item())
280
+
281
+ return {"confidence": _read(conf_head), "salience": _read(sal_head)}
fractus_vorax/model/fractus_core.py ADDED
@@ -0,0 +1,717 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """fractus_vorax/model/fractus_core.py — noyau fractus natif en PyTorch.
2
+
3
+ Implémentation fidèle de l'architecture Fractus1B telle que mesurée sur le
4
+ checkpoint réel (doc de référence : docs/heritage/fractus-cte-architecture.md, LA
5
+ source de vérité — spécialement §4.2 amendement GELU et §4.3 amendement
6
+ orientation x@W). Ce fichier est le SEUL endroit du paquet fractus_vorax autorisé à
7
+ importer torch (gate tests/test_gates.py).
8
+
9
+ MAPPING sous-module ↔ clés du state_dict (doc §5, vérifié strict sur le
10
+ checkpoint 88M `fractus_1b_latest.pt` — 4231 clés) :
11
+
12
+ FractusCore
13
+ ├── embed : BPEEmbedding
14
+ │ ├── tok_embed.weight (vocab, d_model)
15
+ │ ├── pos_embed.weight (max_seq_len, d_model)
16
+ │ └── norm.{weight,bias} (d_model,)
17
+ ├── blocks : ModuleList[n_layers] × FractalBlockSparse
18
+ │ ├── norm1.{weight,bias} LayerNorm
19
+ │ ├── attn : FractalLinearAttention
20
+ │ │ ├── w_qkv (3, d, d) / b_qkv (3, d) param
21
+ │ │ ├── w_out (d, d) / b_out (d,) param
22
+ │ │ ├── level_logits (n_levels,) param
23
+ │ │ └── level_offsets (n_levels,) BUFFER persistant
24
+ │ ├── norm_kur.{weight,bias}
25
+ │ ├── kuramoto : KuramotoLayer
26
+ │ │ ├── omega (N,) / coupling_u (N, r) / coupling_lambda (r,)
27
+ │ ├── norm_moe.{weight,bias}
28
+ │ └── moe : SparseStructuredMoE
29
+ │ ├── expert_phases (E,) BUFFER persistant (Farey)
30
+ │ ├── experts_w1 : ModuleList[E] × LazyStructuredSirenLinear(d→d_ff)
31
+ │ │ └── {U (d_ff,r), V (d,r), scale (), bias (d_ff,)}
32
+ │ └── experts_w2 : ModuleList[E] × LazyStructuredSirenLinear(d_ff→d)
33
+ │ └── {U (d,r), V (d_ff,r), scale (), bias (d,)}
34
+ ├── norm.{weight,bias} LayerNorm final
35
+ └── lm_head.weight (vocab, d_model) LIÉE à embed.tok_embed.weight
36
+
37
+ Écarts documentés (formes les plus proches, doc §4) :
38
+ - §4.1 : le résidu SIREN est ABANDONNÉ dans la variante Lazy du checkpoint
39
+ (facteurs bas-rang uniquement, style LoRA) — `LazyStructuredSirenLinear`
40
+ implémente exactement cette variante : y = scale·(x@V)@Uᵀ + b.
41
+ - §4.2 : activation GELU (torch.nn.functional.gelu) entre w1 et w2 de chaque
42
+ expert — sans elle le checkpoint charge mais produit des logits faux.
43
+ - §4.3 : projections d'attention en convention y = x @ W (einsum "bld,de->ble"),
44
+ PAS la convention nn.Linear x @ Wᵀ. Le state-carry (S₀, z₀) de la CTE n'est
45
+ pas implémenté (spécifique fractus-cte, inutile pour ce checkpoint).
46
+ - Le forward du modèle retourne les logits SEULS (pas (logits, aux_loss)) ;
47
+ les blocs calculent bien la load-balance loss en interne comme l'original.
48
+ - `generate` utilise une fenêtre glissante bornée à max_seq_len (le pos_embed
49
+ du checkpoint réel ne couvre que 16 positions).
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import math
55
+ import re
56
+ from dataclasses import dataclass
57
+ from pathlib import Path
58
+ from typing import Dict, Optional, Tuple, Union
59
+
60
+ import torch
61
+ import torch.nn as nn
62
+ import torch.nn.functional as F
63
+
64
+ TWO_PI = 2.0 * math.pi
65
+ # φ_or² = ((1+√5)/2)² ≈ 2.618 — base des offsets de niveaux (doc §4.3).
66
+ GOLDEN_PHI_SQ = ((1.0 + math.sqrt(5.0)) / 2.0) ** 2
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Utilitaires numériques (doc §4.2, §4.3 — portés de fractus1B/nn/stats.py)
71
+ # ---------------------------------------------------------------------------
72
+
73
+ def elu_plus_one(x: torch.Tensor, alpha: float = 1.0) -> torch.Tensor:
74
+ """φ(x) = x+1 si x>0, sinon α(e^x−1)+1 — strictement positif."""
75
+ pos = x + 1.0
76
+ neg = alpha * (torch.exp(x) - 1.0) + 1.0
77
+ return torch.where(x > 0, pos, neg)
78
+
79
+
80
+ def stable_softmax(logits: torch.Tensor, dim: int = -1) -> torch.Tensor:
81
+ """Softmax stable (soustraction du max) ; uniforme si le dénominateur ~ 0."""
82
+ max_logits, _ = logits.max(dim=dim, keepdim=True)
83
+ exp = torch.exp(logits - max_logits)
84
+ denom = exp.sum(dim=dim, keepdim=True)
85
+ uniform = torch.full_like(exp, 1.0 / exp.shape[dim])
86
+ return torch.where(denom > 1e-10, exp / denom, uniform)
87
+
88
+
89
+ def farey_sequence(n: int) -> list:
90
+ """Suite de Farey F_n : fractions irréductibles p/q ∈ [0,1], q ≤ n,
91
+ générées par la médiane. |F_n| = 1 + Σ φ(q) (totient d'Euler)."""
92
+ if n < 1:
93
+ raise ValueError("n must be >= 1")
94
+ fractions = []
95
+ a, b = 0, 1
96
+ c, d = 1, n
97
+ fractions.append((a, b))
98
+ while c <= n:
99
+ k = (n + b) // d
100
+ c, d, a, b = k * c - a, k * d - b, c, d
101
+ fractions.append((a, b))
102
+ return fractions
103
+
104
+
105
+ def farey_expert_phases(n_experts: int) -> torch.Tensor:
106
+ """E angles 2π·p/q ∈ [0, 2π) sélectionnés uniformément dans F_{2E}.
107
+
108
+ Règle de sélection (farey.py) : idx = min(int(i·|F_{2E}|/E), |F_{2E}|−1).
109
+ Vérifié sur le checkpoint : max |phases_ckpt − phases| = 2.2e-07 (float32).
110
+ """
111
+ if n_experts < 1:
112
+ raise ValueError("n_experts must be >= 1")
113
+ fractions = farey_sequence(2 * n_experts)
114
+ n_frac = len(fractions)
115
+ angles_all = [2.0 * math.pi * p / q for (p, q) in fractions]
116
+ phases = [
117
+ angles_all[min(int(i * n_frac / n_experts), n_frac - 1)]
118
+ for i in range(n_experts)
119
+ ]
120
+ return torch.tensor(phases, dtype=torch.float32)
121
+
122
+
123
+ def mandelbrot_level_offsets(n_levels: int) -> torch.Tensor:
124
+ """Offsets ω_level = (φ²)^(−level), level ∈ [0, n_levels).
125
+
126
+ n_levels=2 → [1.0, 0.3819660…] (égal au buffer du checkpoint, doc §4.3).
127
+ """
128
+ levels = torch.arange(n_levels, dtype=torch.float32)
129
+ return GOLDEN_PHI_SQ ** (-levels)
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Config
134
+ # ---------------------------------------------------------------------------
135
+
136
+ @dataclass
137
+ class FractusCoreConfig:
138
+ """Config du noyau fractus.
139
+
140
+ Les 7 premiers champs (avec leurs défauts « tiny ») suivent le cahier des
141
+ charges ; les champs étendus ci-dessous correspondent aux valeurs du
142
+ modèle réel 88M (doc §3) et sont inférés par `FractusCore.from_checkpoint`.
143
+
144
+ n_heads/d_head ne sont PAS encodés dans le state_dict (w_qkv est cubique) :
145
+ par défaut d_head=64 (modèle réel : 768 = 12×64), sinon le plus grand
146
+ diviseur de d_model parmi {64, 32, 16, 8, 4, 2, 1}.
147
+ """
148
+
149
+ vocab_size: int
150
+ d_model: int = 512
151
+ n_layers: int = 4
152
+ n_experts: int = 8
153
+ top_k: int = 2
154
+ rank: int = 32
155
+ max_seq_len: int = 256
156
+ # Champs étendus (défauts = modèle réel 88M, doc §3) :
157
+ d_ff: int = 1024 # expert_d_ff — inféré de experts_w1.0.U
158
+ n_levels: int = 2 # inféré de attn.level_logits
159
+ n_heads: Optional[int] = None
160
+ d_head: Optional[int] = None
161
+ n_oscillators: int = 16 # inféré de kuramoto.omega
162
+ coupling_rank: int = 8 # inféré de kuramoto.coupling_lambda
163
+ kuramoto_n_steps: int = 1
164
+ kuramoto_dt: float = 0.1
165
+ kuramoto_damping: float = 0.01
166
+ moe_kappa: float = 4.0 # von Mises κ
167
+
168
+ def __post_init__(self) -> None:
169
+ if self.n_heads is None and self.d_head is None:
170
+ for cand in (64, 32, 16, 8, 4, 2, 1):
171
+ if self.d_model % cand == 0:
172
+ self.d_head = cand
173
+ break
174
+ self.n_heads = self.d_model // self.d_head
175
+ elif self.n_heads is None:
176
+ self.n_heads = self.d_model // self.d_head
177
+ elif self.d_head is None:
178
+ self.d_head = self.d_model // self.n_heads
179
+ if self.n_heads * self.d_head != self.d_model:
180
+ raise ValueError(
181
+ f"n_heads·d_head ({self.n_heads * self.d_head}) != d_model ({self.d_model})"
182
+ )
183
+ if self.n_levels < 1:
184
+ raise ValueError("n_levels must be >= 1")
185
+ if self.n_experts < 1 or not 1 <= self.top_k <= self.n_experts:
186
+ raise ValueError("need n_experts >= 1 and 1 <= top_k <= n_experts")
187
+ if self.rank < 1:
188
+ raise ValueError("rank must be >= 1")
189
+ if not 1 <= self.coupling_rank <= self.n_oscillators:
190
+ raise ValueError("need 1 <= coupling_rank <= n_oscillators")
191
+
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # LazyStructuredSirenLinear (doc §4.1)
195
+ # ---------------------------------------------------------------------------
196
+
197
+ class LazyStructuredSirenLinear(nn.Module):
198
+ """Couche linéaire bas-rang style LoRA — SANS matrice pleine.
199
+
200
+ W = scale · U @ Vᵀ avec U (out, r), V (in, r), scale scalaire apprenable.
201
+ Forward : y = scale · (x @ V) @ Uᵀ + b — deux petites matmuls, mémoire
202
+ O((out+in)·r). Le résidu SIREN historique est abandonné dans cette
203
+ variante Lazy (compromis RAM du checkpoint réel, doc §4.1).
204
+ """
205
+
206
+ def __init__(self, in_features: int, out_features: int, rank: int = 16):
207
+ super().__init__()
208
+ self.in_features = in_features
209
+ self.out_features = out_features
210
+ self.rank = rank
211
+ scale_u = math.sqrt(2.0 / (out_features + rank))
212
+ scale_v = math.sqrt(2.0 / (in_features + rank))
213
+ self.U = nn.Parameter(torch.empty(out_features, rank).uniform_(-scale_u, scale_u))
214
+ self.V = nn.Parameter(torch.empty(in_features, rank).uniform_(-scale_v, scale_v))
215
+ self.scale = nn.Parameter(torch.tensor(1.0))
216
+ self.bias = nn.Parameter(torch.zeros(out_features))
217
+
218
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
219
+ """x (..., in) → (..., out) : y = scale·(x @ V) @ Uᵀ + b."""
220
+ h = x @ self.V # (..., rank)
221
+ y = self.scale * (h @ self.U.T) # (..., out)
222
+ return y + self.bias
223
+
224
+ def reconstruct_weight(self) -> torch.Tensor:
225
+ """W complet (inspection/export uniquement)."""
226
+ return self.scale * (self.U @ self.V.T)
227
+
228
+
229
+ # ---------------------------------------------------------------------------
230
+ # Attention linéaire fractale (doc §4.3 — Katharopoulos 2020, causale inclusive)
231
+ # ---------------------------------------------------------------------------
232
+
233
+ class FractalLinearAttention(nn.Module):
234
+ """Attention linéaire causale multi-niveaux.
235
+
236
+ - Feature map φ(x; level) = elu_plus_one(x + ω_level), ω_level = (φ²)^(−level).
237
+ - Récurrence causale INCLUSIVE : S_t = Σ_{i≤t} k_i⊗v_i, z_t = Σ_{i≤t} k_i,
238
+ y_t = (q_t·S_t)/(q_t·z_t), 0 si |dénom| < 1e-10 (masque triangulaire + einsum).
239
+ - Multi-niveaux : sortie = Σ_level softmax(level_logits)_level · attn_level(x).
240
+ - Projections en convention y = x @ W (einsum "bld,de->ble") — AMENDEMENT
241
+ §4.3 : PAS la convention nn.Linear x @ Wᵀ. Q/K/V dans l'ordre d'indexation
242
+ de w_qkv ; têtes découpées par view(B, L, H, D) contigu.
243
+ """
244
+
245
+ def __init__(self, d_model: int, n_heads: int, d_head: int, n_levels: int = 2):
246
+ super().__init__()
247
+ if n_heads * d_head != d_model:
248
+ raise ValueError(f"n_heads·d_head ({n_heads * d_head}) != d_model ({d_model})")
249
+ if n_levels < 1:
250
+ raise ValueError("n_levels must be >= 1")
251
+ self.d_model = d_model
252
+ self.n_heads = n_heads
253
+ self.d_head = d_head
254
+ self.n_levels = n_levels
255
+ d_qkv = n_heads * d_head # = d_model
256
+
257
+ scale = math.sqrt(2.0 / (d_model + d_qkv))
258
+ self.w_qkv = nn.Parameter(torch.empty(3, d_model, d_qkv).uniform_(-scale, scale))
259
+ self.b_qkv = nn.Parameter(torch.zeros(3, d_qkv))
260
+ scale_out = math.sqrt(2.0 / (d_qkv + d_model))
261
+ self.w_out = nn.Parameter(torch.empty(d_qkv, d_model).uniform_(-scale_out, scale_out))
262
+ self.b_out = nn.Parameter(torch.zeros(d_model))
263
+ self.level_logits = nn.Parameter(torch.zeros(n_levels))
264
+ # Buffer PERSISTANT (clé du state_dict, doc §5) : [1.0, 0.3819660…].
265
+ self.register_buffer("level_offsets", mandelbrot_level_offsets(n_levels))
266
+
267
+ def _linear_attention_causal_vectorized(
268
+ self, q: torch.Tensor, k: torch.Tensor, v: torch.Tensor
269
+ ) -> torch.Tensor:
270
+ """q, k : (B, L, D) déjà φ-mappés ; v : (B, L, D) brut. → y (B, L, D)."""
271
+ B, L, D = q.shape
272
+ outer = torch.einsum("btp,btq->btpq", k, v) # k_i ⊗ v_i
273
+ mask = torch.tril(torch.ones(L, L, dtype=q.dtype, device=q.device))
274
+ S = torch.einsum("tj,bjpq->btpq", mask, outer) # Σ_{i≤t}
275
+ z = torch.einsum("tj,bjp->btp", mask, k) # Σ_{i≤t}
276
+ num = torch.einsum("btp,btpq->btq", q, S)
277
+ denom = (q * z).sum(dim=-1, keepdim=True)
278
+ safe = denom.abs() > 1e-10
279
+ return torch.where(safe, num / (denom + 1e-20), torch.zeros_like(num))
280
+
281
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
282
+ """x (B, L, d_model) → (B, L, d_model)."""
283
+ B, L, _ = x.shape
284
+ H, D = self.n_heads, self.d_head
285
+ nlev = self.n_levels
286
+
287
+ # Projections x @ W (amendement §4.3) — Q, K, V dans l'ordre de w_qkv.
288
+ q_all = torch.einsum("bld,de->ble", x, self.w_qkv[0]) + self.b_qkv[0]
289
+ k_all = torch.einsum("bld,de->ble", x, self.w_qkv[1]) + self.b_qkv[1]
290
+ v_all = torch.einsum("bld,de->ble", x, self.w_qkv[2]) + self.b_qkv[2]
291
+ q_all = q_all.view(B, L, H, D) # découpe contiguë des têtes
292
+ k_all = k_all.view(B, L, H, D)
293
+ v_all = v_all.view(B, L, H, D)
294
+
295
+ # Feature map par niveau : elu_plus_one(x + ω_level) sur q et k (pas v).
296
+ offsets = self.level_offsets # (nlev,)
297
+ q_feat = elu_plus_one(q_all.unsqueeze(1) + offsets.view(1, nlev, 1, 1, 1))
298
+ k_feat = elu_plus_one(k_all.unsqueeze(1) + offsets.view(1, nlev, 1, 1, 1))
299
+ v_lev = v_all.unsqueeze(1).expand(B, nlev, L, H, D)
300
+
301
+ # (B, nlev, H) aplat en un seul batch → UN appel vectorisé.
302
+ q_flat = q_feat.permute(0, 1, 3, 2, 4).reshape(B * nlev * H, L, D)
303
+ k_flat = k_feat.permute(0, 1, 3, 2, 4).reshape(B * nlev * H, L, D)
304
+ v_flat = v_lev.permute(0, 1, 3, 2, 4).reshape(B * nlev * H, L, D)
305
+ y_flat = self._linear_attention_causal_vectorized(q_flat, k_flat, v_flat)
306
+ y = y_flat.reshape(B, nlev, H, L, D).permute(0, 1, 3, 2, 4).reshape(B, nlev, L, H * D)
307
+
308
+ level_weights = stable_softmax(self.level_logits, dim=-1)
309
+ attn = (y * level_weights.view(1, nlev, 1, 1)).sum(dim=1)
310
+
311
+ return attn @ self.w_out + self.b_out # x @ W (amendement §4.3)
312
+
313
+
314
+ # ---------------------------------------------------------------------------
315
+ # Oscillateurs de Kuramoto (doc §4.4)
316
+ # ---------------------------------------------------------------------------
317
+
318
+ class KuramotoLayer(nn.Module):
319
+ """Oscillateurs de Kuramoto à couplage bas-rang, STATELESS.
320
+
321
+ - Phases initiales dérivées du hidden : θ_init = 2π·mean(hidden) + 2π·i/N (mod 2π).
322
+ - dθ_i/dt = ω_i − damping·θ_i + Σ_j K_ij sin(θ_j − θ_i), K = UΛUᵀ en O(N·r).
323
+ - Intégration RK4 (4 évals), wrap mod 2π à chaque pas.
324
+ - Sortie : phases (B, L, N) qui alimentent la grille von Mises du MoE.
325
+ """
326
+
327
+ def __init__(
328
+ self,
329
+ d_model: int,
330
+ n_oscillators: int,
331
+ rank: int,
332
+ n_steps: int = 1,
333
+ dt: float = 0.1,
334
+ damping: float = 0.01,
335
+ ):
336
+ super().__init__()
337
+ if n_oscillators < 1 or rank < 1 or rank > n_oscillators:
338
+ raise ValueError("n_oscillators >= 1 and 1 <= rank <= n_oscillators")
339
+ self.d_model = d_model
340
+ self.N = n_oscillators
341
+ self.rank = rank
342
+ self.n_steps = n_steps
343
+ self.dt = dt
344
+ self.damping = damping
345
+
346
+ self.omega = nn.Parameter(torch.empty(n_oscillators).uniform_(-0.05, 0.05))
347
+ self.coupling_u = nn.Parameter(torch.empty(n_oscillators, rank).uniform_(-1.0, 1.0))
348
+ self.coupling_lambda = nn.Parameter(torch.empty(rank).uniform_(0.01, 0.51))
349
+
350
+ def _derivative(self, theta: torch.Tensor) -> torch.Tensor:
351
+ """dθ/dt pour θ (..., N) — couplage bas-rang K = UΛUᵀ en O(N·r)."""
352
+ sin_t = torch.sin(theta)
353
+ cos_t = torch.cos(theta)
354
+ p = torch.einsum("...n,nr->...r", sin_t, self.coupling_u)
355
+ q = torch.einsum("...n,nr->...r", cos_t, self.coupling_u)
356
+ u_p = torch.einsum("...r,nr->...n", self.coupling_lambda * p, self.coupling_u)
357
+ u_q = torch.einsum("...r,nr->...n", self.coupling_lambda * q, self.coupling_u)
358
+ return self.omega - self.damping * theta + cos_t * u_p - sin_t * u_q
359
+
360
+ def _rk4_step(self, theta: torch.Tensor, dt: float) -> torch.Tensor:
361
+ k1 = self._derivative(theta)
362
+ k2 = self._derivative(theta + 0.5 * dt * k1)
363
+ k3 = self._derivative(theta + 0.5 * dt * k2)
364
+ k4 = self._derivative(theta + dt * k3)
365
+ return theta + (dt / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4)
366
+
367
+ def _encode_from_hidden(self, hidden: torch.Tensor) -> torch.Tensor:
368
+ """hidden (B, L, d_model) → phases initiales (B, L, N)."""
369
+ hidden_mean = hidden.mean(dim=-1) * TWO_PI # (B, L)
370
+ offsets = torch.arange(self.N, dtype=hidden.dtype, device=hidden.device)
371
+ offsets = offsets / self.N * TWO_PI
372
+ theta_init = hidden_mean.unsqueeze(-1) + offsets.view(1, 1, self.N)
373
+ return torch.remainder(theta_init, TWO_PI)
374
+
375
+ def forward(self, hidden: torch.Tensor) -> torch.Tensor:
376
+ theta = self._encode_from_hidden(hidden)
377
+ for _ in range(self.n_steps):
378
+ theta = torch.remainder(self._rk4_step(theta, self.dt), TWO_PI)
379
+ return theta
380
+
381
+
382
+ # ---------------------------------------------------------------------------
383
+ # MoE creux routé par phases (doc §4.2)
384
+ # ---------------------------------------------------------------------------
385
+
386
+ class SparseStructuredMoE(nn.Module):
387
+ """MoE creux : E experts × (w1: d→d_ff, w2: d_ff→d) en LazySiren bas-rang.
388
+
389
+ - Phases de Farey (buffer persistant) + grille von Mises κ=4.0 :
390
+ θ̄ = atan2(Σsin θ, Σcos θ), gate_e ∝ exp(κ·cos(θ̄ − phase_e)), normalisées.
391
+ - Top-k creux (k=2) : experts aux gates max, poids renormalisés, somme
392
+ pondérée. Dispatch vectorisé gather-first : on empile les facteurs
393
+ U/V/scale/bias des experts sélectionnés puis bmm groupé — aucune
394
+ matrice W jamais matérialisée.
395
+ - Expert : h1 = w1(x) → GELU (AMENDEMENT §4.2) → y = w2(h1_act).
396
+ - Perte d'équilibrage : lb = E·Σ_e (P_e − 1/E)².
397
+ """
398
+
399
+ def __init__(
400
+ self,
401
+ d_model: int,
402
+ n_experts: int = 64,
403
+ top_k: int = 2,
404
+ d_ff: int = 1024,
405
+ siren_rank: int = 16,
406
+ kappa: float = 4.0,
407
+ ):
408
+ super().__init__()
409
+ self.d_model = d_model
410
+ self.n_experts = n_experts
411
+ self.top_k = top_k
412
+ self.d_ff = d_ff
413
+ self.kappa = kappa
414
+ # Buffer PERSISTANT (clé du state_dict, doc §5) — phases de Farey.
415
+ self.register_buffer("expert_phases", farey_expert_phases(n_experts))
416
+ self.experts_w1 = nn.ModuleList([
417
+ LazyStructuredSirenLinear(d_model, d_ff, rank=siren_rank)
418
+ for _ in range(n_experts)
419
+ ])
420
+ self.experts_w2 = nn.ModuleList([
421
+ LazyStructuredSirenLinear(d_ff, d_model, rank=siren_rank)
422
+ for _ in range(n_experts)
423
+ ])
424
+
425
+ def _compute_gates(self, phases: torch.Tensor) -> torch.Tensor:
426
+ """phases (B, L, N) → gates (B, L, E) normalisées (grille von Mises)."""
427
+ sin_p = torch.sin(phases).sum(dim=-1)
428
+ cos_p = torch.cos(phases).sum(dim=-1)
429
+ theta_bar = torch.atan2(sin_p, cos_p)
430
+ diff = theta_bar.unsqueeze(-1) - self.expert_phases.view(
431
+ *[1] * (phases.dim() - 1), self.n_experts
432
+ )
433
+ gates = torch.exp(self.kappa * torch.cos(diff))
434
+ gates_sum = gates.sum(dim=-1, keepdim=True)
435
+ uniform = torch.full_like(gates, 1.0 / self.n_experts)
436
+ return torch.where(gates_sum > 1e-10, gates / gates_sum, uniform)
437
+
438
+ def forward(
439
+ self, h: torch.Tensor, phases: torch.Tensor
440
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
441
+ """h (B, L, d), phases (B, L, N) → (output (B, L, d), lb_loss scalaire)."""
442
+ B, L, D = h.shape
443
+ K = self.top_k
444
+ gates = self._compute_gates(phases)
445
+ topk_vals, topk_idx = gates.topk(K, dim=-1) # (B, L, K)
446
+ topk_sum = topk_vals.sum(dim=-1, keepdim=True)
447
+ topk_norm = torch.where(
448
+ topk_sum > 1e-10, topk_vals / topk_sum,
449
+ torch.full_like(topk_vals, 1.0 / K),
450
+ )
451
+
452
+ N = B * L
453
+ flat_idx = topk_idx.reshape(-1) # (N·K,) id expert
454
+
455
+ # Empiler les facteurs bas-rang des experts puis gather les K retenus.
456
+ w1_V = torch.stack([e.V for e in self.experts_w1]) # (E, D, R)
457
+ w1_U = torch.stack([e.U for e in self.experts_w1]) # (E, F, R)
458
+ w1_s = torch.stack([e.scale for e in self.experts_w1]) # (E,)
459
+ w1_b = torch.stack([e.bias for e in self.experts_w1]) # (E, F)
460
+ w2_V = torch.stack([e.V for e in self.experts_w2]) # (E, F, R)
461
+ w2_U = torch.stack([e.U for e in self.experts_w2]) # (E, D, R)
462
+ w2_s = torch.stack([e.scale for e in self.experts_w2]) # (E,)
463
+ w2_b = torch.stack([e.bias for e in self.experts_w2]) # (E, D)
464
+
465
+ g1V = w1_V.index_select(0, flat_idx)
466
+ g1U = w1_U.index_select(0, flat_idx)
467
+ g1s = w1_s.index_select(0, flat_idx)
468
+ g1b = w1_b.index_select(0, flat_idx)
469
+ g2V = w2_V.index_select(0, flat_idx)
470
+ g2U = w2_U.index_select(0, flat_idx)
471
+ g2s = w2_s.index_select(0, flat_idx)
472
+ g2b = w2_b.index_select(0, flat_idx)
473
+
474
+ # Chaque token répété K fois : (N, D) → (N·K, D).
475
+ h_rep = h.reshape(N, D).unsqueeze(1).expand(N, K, D).reshape(N * K, D)
476
+
477
+ # LazySiren w1 : y = scale·(x@V)@Uᵀ + b — un bmm groupé.
478
+ proj1 = torch.bmm(h_rep.unsqueeze(1), g1V).squeeze(1) # (N·K, R)
479
+ h1 = torch.bmm(proj1.unsqueeze(1), g1U.transpose(1, 2)).squeeze(1) # (N·K, F)
480
+ h1 = g1s.unsqueeze(-1) * h1 + g1b
481
+ h1_act = F.gelu(h1) # AMENDEMENT §4.2 — GELU entre w1 et w2.
482
+
483
+ # LazySiren w2.
484
+ proj2 = torch.bmm(h1_act.unsqueeze(1), g2V).squeeze(1) # (N·K, R)
485
+ out_nk = torch.bmm(proj2.unsqueeze(1), g2U.transpose(1, 2)).squeeze(1) # (N·K, D)
486
+ out_nk = g2s.unsqueeze(-1) * out_nk + g2b
487
+
488
+ out_k = out_nk.reshape(B, L, K, D)
489
+ output = (topk_norm.unsqueeze(-1) * out_k).sum(dim=2) # (B, L, D)
490
+
491
+ P = gates.mean(dim=(0, 1))
492
+ lb_loss = self.n_experts * ((P - 1.0 / self.n_experts) ** 2).sum()
493
+ return output, lb_loss
494
+
495
+
496
+ # ---------------------------------------------------------------------------
497
+ # Bloc fractal et modèle (doc §4.5)
498
+ # ---------------------------------------------------------------------------
499
+
500
+ class BPEEmbedding(nn.Module):
501
+ """Embedding BPE : table token + positions + LayerNorm."""
502
+
503
+ def __init__(self, vocab_size: int, d_model: int, max_seq_len: int = 512):
504
+ super().__init__()
505
+ self.tok_embed = nn.Embedding(vocab_size, d_model)
506
+ self.pos_embed = nn.Embedding(max_seq_len, d_model)
507
+ self.norm = nn.LayerNorm(d_model)
508
+ nn.init.normal_(self.tok_embed.weight, mean=0.0, std=0.02)
509
+ nn.init.normal_(self.pos_embed.weight, mean=0.0, std=0.02)
510
+
511
+ def forward(self, ids: torch.Tensor) -> torch.Tensor:
512
+ B, L = ids.shape
513
+ pos = torch.arange(L, device=ids.device).unsqueeze(0).expand(B, L)
514
+ return self.norm(self.tok_embed(ids) + self.pos_embed(pos))
515
+
516
+
517
+ class FractalBlockSparse(nn.Module):
518
+ """Bloc pré-norm résiduel : attention + Kuramoto + MoE creux (doc §4.5).
519
+
520
+ x = x + attn(norm1(x)) ; phases = kuramoto(norm_kur(x)) ;
521
+ moe_out, lb = moe(norm_moe(x), phases) ; x = x + moe_out.
522
+ """
523
+
524
+ def __init__(self, config: FractusCoreConfig):
525
+ super().__init__()
526
+ self.norm1 = nn.LayerNorm(config.d_model)
527
+ self.attn = FractalLinearAttention(
528
+ config.d_model, config.n_heads, config.d_head, config.n_levels
529
+ )
530
+ self.norm_kur = nn.LayerNorm(config.d_model)
531
+ self.kuramoto = KuramotoLayer(
532
+ config.d_model,
533
+ n_oscillators=config.n_oscillators,
534
+ rank=config.coupling_rank,
535
+ n_steps=config.kuramoto_n_steps,
536
+ dt=config.kuramoto_dt,
537
+ damping=config.kuramoto_damping,
538
+ )
539
+ self.norm_moe = nn.LayerNorm(config.d_model)
540
+ self.moe = SparseStructuredMoE(
541
+ config.d_model,
542
+ n_experts=config.n_experts,
543
+ top_k=config.top_k,
544
+ d_ff=config.d_ff,
545
+ siren_rank=config.rank,
546
+ kappa=config.moe_kappa,
547
+ )
548
+
549
+ def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
550
+ x = x + self.attn(self.norm1(x))
551
+ phases = self.kuramoto(self.norm_kur(x))
552
+ moe_out, lb_loss = self.moe(self.norm_moe(x), phases)
553
+ x = x + moe_out
554
+ return x, lb_loss
555
+
556
+
557
+ class FractusCore(nn.Module):
558
+ """Fractus1B natif : ids (B, L) → logits (B, L, vocab).
559
+
560
+ Arbre des modules EXACTEMENT conforme au doc §4.5/§5 (noms d'attributs =
561
+ clés du state_dict réel) pour un `load_state_dict(strict=True)` sur le
562
+ checkpoint 88M sans aucune retouches de clés. lm_head liée à tok_embed.
563
+ """
564
+
565
+ def __init__(self, config: FractusCoreConfig):
566
+ super().__init__()
567
+ self.config = config
568
+ self.embed = BPEEmbedding(config.vocab_size, config.d_model, config.max_seq_len)
569
+ self.blocks = nn.ModuleList([
570
+ FractalBlockSparse(config) for _ in range(config.n_layers)
571
+ ])
572
+ self.norm = nn.LayerNorm(config.d_model)
573
+ self.lm_head = nn.Linear(config.d_model, config.vocab_size, bias=False)
574
+ # Tête liée à l'embedding (doc §5 : poids identiques bit à bit).
575
+ self.lm_head.weight = self.embed.tok_embed.weight
576
+
577
+ def forward(self, token_ids: torch.Tensor) -> torch.Tensor:
578
+ """token_ids (B, L ≤ max_seq_len) → logits (B, L, vocab_size)."""
579
+ x = self.embed(token_ids)
580
+ for block in self.blocks:
581
+ x, _lb = block(x) # aux (load-balance) calculée, non retournée
582
+ x = self.norm(x)
583
+ return self.lm_head(x)
584
+
585
+ @torch.no_grad()
586
+ def generate(
587
+ self,
588
+ token_ids: torch.Tensor,
589
+ max_new_tokens: int = 32,
590
+ temperature: float = 0.0,
591
+ eos_token_id: Optional[int] = None,
592
+ seed: int = 0,
593
+ ) -> torch.Tensor:
594
+ """Génération auto-régressive.
595
+
596
+ temperature == 0 → greedy déterministe (argmax). Sinon multinomial
597
+ sur softmax(logits/T) avec un torch.Generator seedé (`seed`).
598
+ Contexte borné par max_seq_len (fenêtre glissante sur les dernières
599
+ positions — le pos_embed du checkpoint réel ne couvre que seq_len).
600
+ eos_token_id : dès que le token émis == eos_token_id, arrêt IMMÉDIAT
601
+ — le token EOS clôt la séquence, aucun autre token n'est consommé.
602
+ (Sémantique du test du plan p4-t2 : out se termine PAR le token EOS.)
603
+ """
604
+ ids = token_ids.clone()
605
+ gen = None
606
+ if temperature > 0.0:
607
+ gen = torch.Generator(device=ids.device)
608
+ gen.manual_seed(seed)
609
+ for _ in range(max_new_tokens):
610
+ ctx = ids[:, -self.config.max_seq_len:]
611
+ logits = self(ctx)
612
+ last = logits[:, -1, :]
613
+ if temperature <= 0.0:
614
+ next_tok = last.argmax(dim=-1, keepdim=True)
615
+ else:
616
+ probs = torch.softmax(last / temperature, dim=-1)
617
+ next_tok = torch.multinomial(probs, 1, generator=gen)
618
+ ids = torch.cat([ids, next_tok], dim=1)
619
+ # EOS-aware : le token d'arrêt clôt la génération — plus AUCUN
620
+ # token consommé après lui. (.all() : batch-safe, == int(next)
621
+ # en B=1 ; s'applique aux DEUX chemins greedy et multinomial.)
622
+ if eos_token_id is not None and bool((next_tok == eos_token_id).all()):
623
+ break
624
+ return ids
625
+
626
+ # ------------------------------------------------------------------
627
+ # Chargement strict de checkpoint
628
+ # ------------------------------------------------------------------
629
+
630
+ @classmethod
631
+ def from_checkpoint(
632
+ cls, path: Union[str, Path], map_location: str = "cpu"
633
+ ) -> "FractusCore":
634
+ """Charge un checkpoint fractus en STRICT (jamais strict=False).
635
+
636
+ - torch.load(weights_only=False) — fichier local de confiance (doc §8).
637
+ - state_dict cherché sous « model_state » (checkpoint réel 88M), puis
638
+ « model » (format des tests), puis « state_dict », sinon dict brut.
639
+ - Config inférée des shapes (règles commentées dans `_infer_config`).
640
+ - Toute clé manquante/inattendue lève une erreur BRUYANTE.
641
+ """
642
+ ckpt = torch.load(path, map_location=map_location, weights_only=False)
643
+ sd: Optional[Dict[str, torch.Tensor]] = None
644
+ if isinstance(ckpt, dict):
645
+ for key in ("model_state", "model", "state_dict"):
646
+ cand = ckpt.get(key)
647
+ if isinstance(cand, dict) and "embed.tok_embed.weight" in cand:
648
+ sd = cand
649
+ break
650
+ if sd is None and "embed.tok_embed.weight" in ckpt:
651
+ sd = ckpt # dict déjà nu (state_dict sérialisé tel quel)
652
+ if sd is None:
653
+ raise ValueError(
654
+ f"{path}: aucune clé 'model_state'/'model'/'state_dict' ni "
655
+ "state_dict fractus détectable au niveau supérieur"
656
+ )
657
+ config = cls._infer_config(sd)
658
+ model = cls(config)
659
+ result = model.load_state_dict(sd, strict=True)
660
+ missing = list(result.missing_keys)
661
+ unexpected = list(result.unexpected_keys)
662
+ if missing or unexpected:
663
+ raise RuntimeError(
664
+ f"strict load incomplet — manquantes: {missing[:8]} "
665
+ f"inattendues: {unexpected[:8]}"
666
+ )
667
+ model.eval()
668
+ return model
669
+
670
+ @staticmethod
671
+ def _infer_config(sd: Dict[str, torch.Tensor]) -> FractusCoreConfig:
672
+ """Règles d'inférence de la config depuis les shapes du state_dict :
673
+
674
+ - vocab_size, d_model : shape de `embed.tok_embed.weight` (vocab, d) ;
675
+ - max_seq_len : shape de `embed.pos_embed.weight` (seq, d) ;
676
+ - n_layers : 1 + max indice des clés `blocks.(i).…` ;
677
+ - n_levels : shape de `blocks.0.attn.level_logits` ;
678
+ - n_experts : shape du buffer `blocks.0.moe.expert_phases` ;
679
+ - rank : dernière dim de `blocks.0.moe.experts_w1.0.V` ;
680
+ - d_ff : shape de `blocks.0.moe.experts_w1.0.U` (d_ff, r) ;
681
+ - n_oscillators : shape de `blocks.0.kuramoto.omega` ;
682
+ - coupling_rank : shape de `blocks.0.kuramoto.coupling_lambda` ;
683
+ - top_k, κ, dt, … : NON encodés dans le state_dict → défauts du doc
684
+ §3 (top_k=2 jamais surchargé à l'entraînement, κ=4.0, RK4 dt=0.1…) ;
685
+ - n_heads×d_head : non encodés (w_qkv cubique) → d_head=64 si
686
+ d_model % 64 == 0 (modèle réel 768=12×64), sinon plus grand diviseur
687
+ de d_model parmi {64, 32, 16, 8, 4, 2, 1}.
688
+ """
689
+ vocab_size, d_model = (int(v) for v in sd["embed.tok_embed.weight"].shape)
690
+ max_seq_len = int(sd["embed.pos_embed.weight"].shape[0])
691
+ block_ids = [
692
+ int(m.group(1))
693
+ for k in sd
694
+ if (m := re.match(r"^blocks\.(\d+)\.", k))
695
+ ]
696
+ if not block_ids:
697
+ raise ValueError("state_dict sans clés 'blocks.(i).…' — pas un fractus")
698
+ n_layers = 1 + max(block_ids)
699
+ n_levels = int(sd["blocks.0.attn.level_logits"].shape[0])
700
+ n_experts = int(sd["blocks.0.moe.expert_phases"].shape[0])
701
+ rank = int(sd["blocks.0.moe.experts_w1.0.V"].shape[1])
702
+ d_ff = int(sd["blocks.0.moe.experts_w1.0.U"].shape[0])
703
+ n_oscillators = int(sd["blocks.0.kuramoto.omega"].shape[0])
704
+ coupling_rank = int(sd["blocks.0.kuramoto.coupling_lambda"].shape[0])
705
+ return FractusCoreConfig(
706
+ vocab_size=vocab_size,
707
+ d_model=d_model,
708
+ n_layers=n_layers,
709
+ n_experts=n_experts,
710
+ top_k=2,
711
+ rank=rank,
712
+ max_seq_len=max_seq_len,
713
+ d_ff=d_ff,
714
+ n_levels=n_levels,
715
+ n_oscillators=n_oscillators,
716
+ coupling_rank=coupling_rank,
717
+ )
fractus_vorax/model/speak.py ADDED
@@ -0,0 +1,350 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """fractus_vorax/model/speak.py — la boucle de décodage mécanique (Plan 6, tâche 1).
2
+
3
+ Faire parler le cerveau scellé par la MÉCANIQUE, pas par l'entraînement. La
4
+ parole libérée n'est pas une question de poids : greedy et t=1.0 ordinaires
5
+ voient le même attracteur (greedy → ``' the'``×16 sur le 1B, écart de logits
6
+ trop grand pour l'échantillonnage). La suite ci-dessous formalise la boucle
7
+ PROUVÉE EMPIRIQUEMENT le 2026-08-18 (ledger, cerveau scellé, verbatim) :
8
+
9
+ forward → z-norm des logits → pénalité de répétition → bias des organes
10
+ → top-k/top-p → softmax/température → multinomial (generator seedé)
11
+
12
+ - **z-norm ×3** : std des logits mesurée ~26 à froid mais des CENTAINES dans
13
+ les états-attracteurs. ``(x−mean)/std·scale`` écrase l'échelle folle et
14
+ remet toute distribution sur la même échelle calme — c'est ELLE qui libère
15
+ le lexique réel (``philosophy``, ``manufactures``, ``surveyed``…).
16
+ - **rep_pen 6-12** : divise le logit de tout token déjà émis (ensemble, pas
17
+ séquence) — l'attracteur de répétition est brisé mécaniquement.
18
+ - **top-k/top-p** : masque à −inf hors sélection (optionnels, None = off).
19
+ - **bias_tokens** : +δ brut sur les ids choisis par les organes — le steering
20
+ organique (Plan 6, tâche 3) ; ajouté APRÈS z-norm/rep-pen, AVANT top-k/p.
21
+ - **answer_lock** (Plan 7) : LE mécanisme d'articulation organique — à
22
+ l'étape i, +answer_boost sur answer_lock[i] (les tokens BPE COMPLETS de la
23
+ réponse des organes, ex. ``" tokyo"``) : la réponse est placée token par
24
+ token À TRAVERS la distribution du noyau (lexique et transitions sortent
25
+ du 1B, seule la réponse est verrouillée), puis la suite est libre. Même
26
+ place dans le contrat que bias_tokens : APRÈS transforms, AVANT top-k/p.
27
+ Boost par défaut 30, CALIBRÉ PAR MESURE sur le 1B (2026-08-18) : le plan
28
+ visait 12 (« ≈ 4σ ») mais la mesure montre z[locked] à −3σ (ex. ' par'
29
+ z=−3.0) quand le top de la distribution z-normée est à +4σ (zmax≈11.9) —
30
+ +12 PERD le tirage pour paris/madrid (2/4 capitales seulement). +30
31
+ domine même z[locked]=−6σ contre zmax 12 ET la masse de queue complète
32
+ (≈ e^15.4 sur 50257 tokens std 3) : échec par pas < 0.1%, borné, jamais
33
+ de sommation.
34
+ - **diagnostics par pas** : std AVANT transforms, top3 (après transforms),
35
+ token choisi — l'observabilité légère de la boucle.
36
+
37
+ ZÉRO GRADIENT, JAMAIS (le pacte) : ``@torch.no_grad()`` sur toute la boucle,
38
+ les poids ne sont jamais touchés — seulement les logits.
39
+
40
+ GATE torch : ce fichier vit dans fractus_vorax/model/ (seule zone exemptée
41
+ par tests/test_gates.py) et importe torch AU NIVEAU MODULE, comme cte_core
42
+ — mais il n'est importé que par du code torch-aware (tests importorskip,
43
+ harnais lazy), jamais par le substrat mingw.
44
+
45
+ CHOIX DOCUMENTÉ — contexte COMPLET à chaque pas : la recette officielle de
46
+ ``CteCore.generate`` nourrit le carry (S, z) par chunks de LONGUEUR 1 sur le
47
+ contexte croissant. La sonde manuelle du 2026-08-18 (celle qui a produit les
48
+ résultats du ledger) appelait ``core(ctx_complet)`` à CHAQUE itération, sans
49
+ reset intermédiaire — le contexte entier re-rentre dans le carry à chaque
50
+ pas (effet d'emphase cumulatif). On suit la sonde : contexte complet par
51
+ pas, reset_states(1) UNE FOIS au départ. La fenêtre reste bornée à gauche
52
+ par ``config.max_seq_len`` (garde-fou prompt, même politique que generate/
53
+ verbalize — FractusCore a un pos_embed réellement borné).
54
+ """
55
+
56
+ from __future__ import annotations
57
+
58
+ from typing import Dict, List, Optional, Set, Tuple
59
+
60
+ import torch
61
+
62
+ from fractus_vorax.model.vocal import safe_decode_text
63
+
64
+ # Fenêtre de repli si le noyau n'expose pas config.max_seq_len
65
+ # (même valeur que vocal.py — défaut FractusCoreConfig).
66
+ _FALLBACK_MAX_SEQ_LEN = 256
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Transforms purs des logits (tenseur 1-D sur le vocab)
71
+ # ---------------------------------------------------------------------------
72
+
73
+ def znorm_logits(logits: torch.Tensor, scale: float = 3.0) -> torch.Tensor:
74
+ """(x − mean)/std · scale — pur, jamais in-place.
75
+
76
+ Écrase l'échelle des états-attracteurs (std mesurée : ~26 calme,
77
+ centaines en attracteur) : la sortie a std == scale quelle que soit
78
+ l'entrée, mean == 0. Garde dégénérée : std ~0 (logits constants) →
79
+ zéros (clamp de la division, aucun NaN). std non biaisé de torch —
80
+ sur 50257 logits la correction est invisible.
81
+ """
82
+ if scale <= 0.0:
83
+ raise ValueError(f"scale doit être > 0 (reçu {scale})")
84
+ std = logits.std().clamp_min(1e-12)
85
+ return (logits - logits.mean()) / std * scale
86
+
87
+
88
+ def apply_rep_penalty(
89
+ logits: torch.Tensor,
90
+ emitted: Set[int],
91
+ penalty: float = 6.0,
92
+ ) -> torch.Tensor:
93
+ """logits[t] /= penalty pour tout t déjà émis — division SIMPLE (spec).
94
+
95
+ ``emitted`` est l'ENSEMBLE des tokens émis (pas la séquence) : un token
96
+ déjà sorti voit son logit (re-calculé puis z-normé à CE pas) divisé par
97
+ penalty, à chaque pas où il reste dans l'ensemble. Note honnête : la
98
+ division d'un logit NÉGATIF le rapproche de 0 (l'agrandit) — sans effet
99
+ pratique, les tokens émis vivent dans le haut positif de la distribution
100
+ post-z-norm. Pur ; ``emitted`` vide → tenseur inchangé.
101
+ """
102
+ if penalty <= 0.0:
103
+ raise ValueError(f"penalty doit être > 0 (reçu {penalty})")
104
+ if not emitted:
105
+ return logits
106
+ idx = torch.tensor(sorted(emitted), dtype=torch.long, device=logits.device)
107
+ out = logits.clone()
108
+ out[idx] = out[idx] / penalty
109
+ return out
110
+
111
+
112
+ def apply_top_k(logits: torch.Tensor, k: int) -> torch.Tensor:
113
+ """Masque à −inf tout ce qui n'est pas dans les k plus grands.
114
+
115
+ k ≥ taille du vocab → identité (tout survit). Égalités au k-ième rang :
116
+ toutes survivent (``logits < seuil``) — standard, jamais de masque vide.
117
+ Pur ; renvoie un nouveau tenseur (masked_fill).
118
+ """
119
+ if k < 1:
120
+ raise ValueError(f"k doit être >= 1 (reçu {k})")
121
+ vocab = logits.shape[-1]
122
+ if k >= vocab:
123
+ return logits
124
+ kth = torch.topk(logits, k, dim=-1).values[..., -1:]
125
+ return logits.masked_fill(logits < kth, float("-inf"))
126
+
127
+
128
+ def apply_top_p(logits: torch.Tensor, p: float) -> torch.Tensor:
129
+ """Nucleus : ne garde que le plus petit préfixe de masse >= p.
130
+
131
+ Convention cumul EXCLUSIF : le rang r survit ssi la masse des rangs < r
132
+ est < p — le top-1 survit donc TOUJOURS (jamais de masque vide, p > 0).
133
+ p >= 1 → identité (aucun masque). Pur ; renvoie un nouveau tenseur.
134
+ """
135
+ if p <= 0.0 or p > 1.0:
136
+ raise ValueError(f"p doit être dans (0, 1] (reçu {p})")
137
+ if p >= 1.0:
138
+ return logits
139
+ sorted_logits, sorted_idx = torch.sort(logits, descending=True, dim=-1)
140
+ probs = torch.softmax(sorted_logits, dim=-1)
141
+ prefix = torch.cumsum(probs, dim=-1) - probs # cumul exclusif du rang
142
+ sorted_masked = sorted_logits.masked_fill(prefix >= p, float("-inf"))
143
+ out = torch.empty_like(logits)
144
+ out.scatter_(-1, sorted_idx, sorted_masked)
145
+ return out
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # La boucle de décodage mécanique
150
+ # ---------------------------------------------------------------------------
151
+
152
+ @torch.no_grad()
153
+ def speak(
154
+ core,
155
+ tokenizer,
156
+ prompt: str,
157
+ max_new_tokens: int = 16,
158
+ temperature: float = 1.0,
159
+ rep_pen: float = 6.0,
160
+ z_scale: float = 3.0,
161
+ top_k: Optional[int] = None,
162
+ top_p: Optional[float] = None,
163
+ seed: int = 0,
164
+ eos_token_id: Optional[int] = None,
165
+ bias_tokens: Optional[Dict[int, float]] = None,
166
+ answer_lock: Optional[List[int]] = None,
167
+ answer_boost: float = 30.0,
168
+ ) -> Tuple[str, List[dict]]:
169
+ """Le cerveau parle par la mécanique — texte + diagnostics par pas.
170
+
171
+ ``core`` est duck-typé (CteCore comme FractusCore) : ``forward(ids)``
172
+ → logits (B, L, vocab), ``reset_states(1)`` s'il existe (CteCore ;
173
+ FractusCore est stateless et n'expose rien de tel). ``tokenizer`` :
174
+ ``encode(str) -> list[int]`` et ``decode(list[int]) -> str`` (protocole
175
+ minimal de vocal.py).
176
+
177
+ Boucle par pas (l'ordre des transforms est le contrat, ratifié P6) :
178
+ 1. ``forward`` sur le contexte COMPLET croissant (choix documenté en
179
+ tête de module — la sonde, pas les chunks de 1) → logits[0, -1] ;
180
+ 2. z-norm ×z_scale ;
181
+ 3. rep-pen sur l'ensemble des tokens émis ;
182
+ 4. bias des organes (+δ brut sur les ids — après z-norm/rep-pen,
183
+ AVANT top-k/p) ;
184
+ 4bis. VERROU DE RÉPONSE (Plan 7) : à l'étape i < len(answer_lock),
185
+ +answer_boost sur answer_lock[i] — même place dans le contrat.
186
+ C'est LE mécanisme d'articulation : les organes (Brain.speak_answer)
187
+ fournissent les tokens BPE COMPLETS de la réponse (" tokyo" = [id
188
+ ' to', id 'ky', id 'o']), et le verrou place chaque token À
189
+ TRAVERS la distribution du noyau, un pas après l'autre — le
190
+ lexique, les transitions et la SUITE de la phrase restent ceux du
191
+ 1B, seule la réponse est verrouillée. Boost 30 par défaut, CALIBRÉ
192
+ PAR MESURE sur le 1B (le 12 du plan perd le tirage quand le token
193
+ verrouillé vit à −1σ et le top à +4σ : z[' par']=−3.0 vs zmax=11.9
194
+ — voir tête de module) : dominance quasi totale et BORNÉE (comme le
195
+ bias_tokens, jamais de sommation non bornée) ;
196
+ 5. top-k puis top-p (optionnels) ;
197
+ 6. softmax/température → multinomial sur un ``torch.Generator`` seedé.
198
+
199
+ EOS : append-then-break (sémantique ratifiée P4) — l'EOS clot la
200
+ séquence puis est STRIPPÉ au décodage. EOS effectif : le paramètre
201
+ l'emporte, sinon ``tokenizer.eos_token_id`` s'il existe (Gpt2BpeTokenizer
202
+ l'expose), sinon aucun arrêt. Divergence DÉLIBÉRÉE d'avec
203
+ ``vocal.verbalize`` (tokenizer d'abord là-bas) : ici l'appelant
204
+ qui passe ``eos_token_id`` fait FORCE de loi — l'override gagne.
205
+
206
+ Paramètres par défaut = la recette empirique du ledger (rep_pen 6,
207
+ z_scale 3 — la fourchette prouvée est 6-12).
208
+
209
+ Renvoie ``(texte, diagnostics)`` où chaque diagnostic est
210
+ ``{"logit_std_avant": float, # std des logits BRUTS (avant transforms)
211
+ "top3": [(str, float)], # top3 décodé, logits après z-norm/rep-pen/bias
212
+ "chosen": str}`` # token émis à ce pas (déjà strippé de rien)
213
+
214
+ Le texte passe par ``safe_decode_text`` (jamais de surrogates à
215
+ l'affichage). Déterminisme : même seed + même états initiaux (reset
216
+ au départ) ⇒ mêmes paroles, mêmes diagnostics.
217
+ """
218
+ if max_new_tokens < 1:
219
+ raise ValueError(f"max_new_tokens doit être >= 1 (reçu {max_new_tokens})")
220
+ if temperature <= 0.0:
221
+ raise ValueError(
222
+ "speak est le chemin ÉCHANTILLONNÉ (temperature > 0) — "
223
+ "le greedy déterministe vit dans core.generate"
224
+ )
225
+ if rep_pen <= 0.0:
226
+ raise ValueError(f"rep_pen doit être > 0 (reçu {rep_pen})")
227
+ if top_k is not None and top_k < 1:
228
+ raise ValueError(f"top_k doit être >= 1 (reçu {top_k})")
229
+ if top_p is not None and not 0.0 < top_p <= 1.0:
230
+ raise ValueError(f"top_p doit être dans (0, 1] (reçu {top_p})")
231
+ if answer_lock is not None and answer_boost <= 0.0:
232
+ raise ValueError(f"answer_boost doit être > 0 (reçu {answer_boost})")
233
+
234
+ ids = list(tokenizer.encode(prompt))
235
+ if not ids:
236
+ raise ValueError("prompt vide : l'encodage n'a produit aucun token")
237
+
238
+ # Fenêtre gauche (garde-fou, même politique que vocal.verbalize) : on
239
+ # garde les DERNIERS tokens — la fin du prompt compte plus que son début.
240
+ max_seq_len = int(
241
+ getattr(getattr(core, "config", None), "max_seq_len", _FALLBACK_MAX_SEQ_LEN)
242
+ )
243
+ if len(ids) > max_seq_len:
244
+ ids = ids[-max_seq_len:]
245
+
246
+ try:
247
+ device = next(core.parameters()).device
248
+ except (StopIteration, AttributeError):
249
+ device = torch.device("cpu")
250
+ ctx = torch.tensor([ids], dtype=torch.long, device=device)
251
+
252
+ # Recette : les états porteurs repartent de zéro (CteCore). FractusCore
253
+ # est stateless et n'expose pas reset_states — duck-typing, pas isinstance.
254
+ if hasattr(core, "reset_states"):
255
+ core.reset_states(1)
256
+ if hasattr(core, "eval"):
257
+ core.eval()
258
+
259
+ gen = torch.Generator(device=device)
260
+ gen.manual_seed(seed)
261
+
262
+ # EOS effectif : paramètre > tokenizer.
263
+ effective_eos: Optional[int] = (
264
+ eos_token_id
265
+ if eos_token_id is not None
266
+ else getattr(tokenizer, "eos_token_id", None)
267
+ )
268
+
269
+ vocab = None # résolu au premier forward (taille des logits)
270
+ emitted: Set[int] = set()
271
+ out_ids: List[int] = []
272
+ diagnostics: List[dict] = []
273
+
274
+ for _step in range(max_new_tokens):
275
+ # Choix documenté : contexte COMPLET à chaque pas (la sonde), borné
276
+ # à gauche par la fenêtre du noyau.
277
+ logits = core(ctx[:, -max_seq_len:])[0, -1].float()
278
+ if vocab is None:
279
+ vocab = logits.shape[-1]
280
+ if bias_tokens:
281
+ for tid in bias_tokens:
282
+ if not 0 <= int(tid) < vocab:
283
+ raise ValueError(
284
+ f"bias_tokens id {tid} hors vocab [0, {vocab})"
285
+ )
286
+ if answer_lock:
287
+ for i, tid in enumerate(answer_lock):
288
+ if not 0 <= int(tid) < vocab:
289
+ raise ValueError(
290
+ f"answer_lock[{i}] id {tid} hors vocab [0, {vocab})"
291
+ )
292
+
293
+ std_before = float(logits.std())
294
+
295
+ # 2-4 : z-norm → rep-pen → bias (l'ordre est le contrat).
296
+ t = znorm_logits(logits, scale=z_scale)
297
+ t = apply_rep_penalty(t, emitted, penalty=rep_pen)
298
+ if bias_tokens:
299
+ delta = torch.zeros_like(t)
300
+ for tid, d in bias_tokens.items():
301
+ delta[int(tid)] = float(d)
302
+ t = t + delta
303
+ # 4bis : le verrou de réponse (Plan 7) — à l'étape i, +answer_boost
304
+ # sur answer_lock[i]. APRÈS transforms, AVANT top-k/p : le token
305
+ # verrouillé survit aux masques (il est top-1 après le boost) et les
306
+ # diagnostics top3 ci-dessous le montrent à sa place. ``t`` est
307
+ # toujours un tenseur frais à ce point (sortie de znorm/rep-pen/bias,
308
+ # jamais les logits du forward) — l'écriture indexée est sûre.
309
+ if answer_lock and _step < len(answer_lock):
310
+ locked = int(answer_lock[_step])
311
+ t[locked] = t[locked] + answer_boost
312
+
313
+ # top3 diagnostics : après z-norm/rep-pen/bias (les masques top-k/p
314
+ # y mettraient des −inf — ici les valeurs sont toujours réelles).
315
+ top3_vals, top3_idx = torch.topk(t, min(3, t.shape[-1]))
316
+ top3 = [
317
+ (safe_decode_text(tokenizer.decode([int(i)])), float(v))
318
+ for i, v in zip(top3_idx.tolist(), top3_vals.tolist())
319
+ ]
320
+
321
+ # 5-6 : masques optionnels puis échantillonné seedé.
322
+ if top_k is not None:
323
+ t = apply_top_k(t, top_k)
324
+ if top_p is not None:
325
+ t = apply_top_p(t, top_p)
326
+ probs = torch.softmax(t / temperature, dim=-1)
327
+ next_tok = torch.multinomial(probs, 1, generator=gen)
328
+ tid = int(next_tok.item())
329
+
330
+ emitted.add(tid)
331
+ out_ids.append(tid)
332
+ diagnostics.append(
333
+ {
334
+ "logit_std_avant": std_before,
335
+ "top3": top3,
336
+ "chosen": safe_decode_text(tokenizer.decode([tid])),
337
+ }
338
+ )
339
+
340
+ ctx = torch.cat([ctx, next_tok.view(1, 1)], dim=1)
341
+
342
+ # Append-then-break : l'EOS clot la séquence, rien après lui.
343
+ if effective_eos is not None and tid == int(effective_eos):
344
+ break
345
+
346
+ # L'EOS ne se verbalise pas (P4) : on retire UN unique EOS final.
347
+ if effective_eos is not None and out_ids and out_ids[-1] == int(effective_eos):
348
+ out_ids = out_ids[:-1]
349
+
350
+ return safe_decode_text(tokenizer.decode(out_ids)), diagnostics
fractus_vorax/model/vocal.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """fractus_vorax/model/vocal.py — le noyau lit les cartes (Plan 3, tâche 3).
2
+
3
+ Pipeline de verbalisation : sérialisation protocolaire déterministe des
4
+ cartes → encodage → génération greedy par FractusCore → décodage.
5
+
6
+ serialize_cards(question, cards) ──► préfixe protocolaire
7
+ "[FAIT] … (sim 0.90)"
8
+ "[ANALOGIE] … (sim 1.00)"
9
+ "Q: {question}"
10
+ verbalize(core, question, cards, tokenizer) ──► texte du noyau
11
+
12
+ GATE torch : ce fichier vit dans fractus_vorax/model/ (seul endroit autorisé à
13
+ toucher torch) mais n'importe torch QUE paresseusement, à l'intérieur des
14
+ fonctions — importer fractus_vorax.model.vocal dans un python sans torch (substrat
15
+ mingw) ne doit jamais lever. `core_available()` sonde ce python par
16
+ try-import.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import List, Sequence
22
+
23
+ from fractus_vorax.cards import Card
24
+
25
+ # Taille de fenêtre de repli si le noyau n'expose pas config.max_seq_len
26
+ # (valeur par défaut de FractusCoreConfig).
27
+ _FALLBACK_MAX_SEQ_LEN = 256
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Sérialisation protocolaire
32
+ # ---------------------------------------------------------------------------
33
+
34
+ def serialize_cards(question: str, cards: Sequence[Card]) -> str:
35
+ """Préfixe protocolaire DÉTERMINISTE : cartes triées puis question.
36
+
37
+ - Une ligne par carte, format ``[KIND] {text} (sim {sim:.2f})``.
38
+ - Tri total par ``(kind, text)`` : ni timestamp, ni aléa, ni ordre
39
+ d'arrivée — même entrée, même octets (le noyau doit revoir exactement
40
+ le même contexte pour la même question).
41
+ - La dernière ligne est toujours ``Q: {question}`` (sans newline final).
42
+ """
43
+ lines = [
44
+ f"[{card.kind}] {card.text} (sim {card.sim:.2f})"
45
+ for card in sorted(cards, key=lambda c: (c.kind, c.text))
46
+ ]
47
+ lines.append(f"Q: {question}")
48
+ return "\n".join(lines)
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Tokenizer de test/démo
53
+ # ---------------------------------------------------------------------------
54
+
55
+ class CharTokenizer:
56
+ """Tokenizer char-level factice (test/demo) — encode = codepoints mod vocab.
57
+
58
+ Le tokenizer réel (BPE du checkpoint) arrivera avec le checkpoint ;
59
+ tout objet exposant ``encode(str) -> list[int]`` et
60
+ ``decode(list[int]) -> str`` est accepté par ``verbalize``.
61
+
62
+ ``vocab_size=1114112`` (0x110000, tout Unicode) rend l'encodage
63
+ injectif : le round-trip est exact.
64
+ """
65
+
66
+ def __init__(self, vocab_size: int = 0x110000):
67
+ if vocab_size < 1:
68
+ raise ValueError("vocab_size must be >= 1")
69
+ self.vocab_size = vocab_size
70
+
71
+ def encode(self, text: str) -> List[int]:
72
+ """Chaque caractère → son codepoint modulo vocab_size."""
73
+ return [ord(ch) % self.vocab_size for ch in text]
74
+
75
+ def decode(self, ids: Sequence[int]) -> str:
76
+ """Chaque id → chr(id) — inverse exact de encode si vocab complet."""
77
+ return "".join(chr(int(i)) for i in ids)
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # Disponibilité du noyau
82
+ # ---------------------------------------------------------------------------
83
+
84
+ def core_available() -> bool:
85
+ """True si torch ET FractusCore sont importables dans CE python.
86
+
87
+ Jamais d'import au niveau module : le substrat mingw (sans torch) doit
88
+ pouvoir importer fractus_vorax.model.vocal sans crash et obtenir False ici.
89
+ """
90
+ try:
91
+ import torch # noqa: F401
92
+ from fractus_vorax.model.fractus_core import FractusCore # noqa: F401
93
+ except Exception: # torch absent, fractus_core absent, ou dépendance cassée
94
+ return False
95
+ return True
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # Texte sûr pour l'affichage
100
+ # ---------------------------------------------------------------------------
101
+
102
+ def safe_decode_text(text: str) -> str:
103
+ """str → str ne contenant JAMAIS de surrogates/codepoints non encodables.
104
+
105
+ Chaque codepoint non encodable en UTF-8 (surrogates isolés U+D800..U+DFFF,
106
+ produits par un décodage de tokens sauvages) devient U+FFFD via
107
+ ``.encode("utf-8", errors="replace")`` ; le second ``replace`` est une
108
+ ceinture de plus (no-op en pratique). Toutes les lignes ``[NOYAU]`` du
109
+ REPL passent par ici : jamais d'UnicodeEncodeError à l'impression.
110
+ """
111
+ return text.encode("utf-8", errors="replace").decode("utf-8", errors="replace")
112
+
113
+
114
+ def _strip_eos(ids: List[int], eos: int | None) -> List[int]:
115
+ """Retire UN unique token EOS FINAL (directive de la revue Plan 4/T2).
116
+
117
+ ``FractusCore.generate`` inclut l'EOS dans la séquence retournée
118
+ (append-then-break) : sans ce retrait, "<|endoftext|>" fuiterait dans
119
+ le texte verbalisé. Contrat : ``eos is None`` ou dernier id != eos →
120
+ liste inchangée ; sinon la liste moins SON dernier élément (un seul
121
+ EOS retiré, jamais plus).
122
+ """
123
+ if eos is not None and len(ids) > 0 and ids[-1] == eos:
124
+ return ids[:-1]
125
+ return ids
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Verbalisation
130
+ # ---------------------------------------------------------------------------
131
+
132
+ def verbalize(
133
+ core,
134
+ question: str,
135
+ cards: Sequence[Card],
136
+ tokenizer,
137
+ max_new_tokens: int = 48,
138
+ eos_token_id: int | None = None,
139
+ ) -> str:
140
+ """Le noyau lit les cartes et répond : pipeline complet, greedy déterministe.
141
+
142
+ 1. Prompt = ``serialize_cards(question, cards)`` (inclut la ligne ``Q:``).
143
+ 2. Encodage via ``tokenizer.encode(str) -> list[int]`` (protocole minimal).
144
+ 3. Troncature PAR LA GAUCHE à ``core.config.max_seq_len`` (repli 256) :
145
+ on garde les DERNIERS tokens — la fin de la question compte plus que
146
+ les préfixes de cartes (le pos_embed du checkpoint réel ne couvre que
147
+ 16 positions).
148
+ 4. ``core.generate`` greedy (temperature=0, déterministe), stoppé par
149
+ l'EOS effectif : ``tokenizer.eos_token_id`` si le tokenizer l'expose,
150
+ sinon le paramètre ``eos_token_id`` (None = pas d'arrêt EOS).
151
+ 5. Décodage de la CONTINUATION (tokens nouveaux uniquement — pas l'écho
152
+ du prompt) via ``tokenizer.decode(list[int]) -> str``, APRÈS retrait
153
+ de l'éventuel EOS final émis (sémantique append-then-break de
154
+ generate : l'EOS clôt la séquence mais ne se verbalise pas).
155
+ 6. ``safe_decode_text`` sur la sortie : jamais de surrogates.
156
+ """
157
+ import torch # paresseux : gate — jamais au niveau module
158
+
159
+ prompt = serialize_cards(question, cards)
160
+ ids = list(tokenizer.encode(prompt))
161
+
162
+ # Fenêtre du noyau : garder les derniers max_seq_len tokens.
163
+ max_seq_len = getattr(getattr(core, "config", None), "max_seq_len", _FALLBACK_MAX_SEQ_LEN)
164
+ if len(ids) > max_seq_len:
165
+ ids = ids[-max_seq_len:]
166
+
167
+ try:
168
+ device = next(core.parameters()).device
169
+ except (StopIteration, AttributeError):
170
+ device = torch.device("cpu")
171
+ input_ids = torch.tensor([ids], dtype=torch.long, device=device)
172
+
173
+ # EOS effectif : celui du tokenizer s'il l'expose, sinon le paramètre.
174
+ tok_eos = getattr(tokenizer, "eos_token_id", None)
175
+ effective_eos = tok_eos if tok_eos is not None else eos_token_id
176
+
177
+ output_ids = core.generate(
178
+ input_ids,
179
+ max_new_tokens=max_new_tokens,
180
+ temperature=0.0,
181
+ eos_token_id=effective_eos,
182
+ )
183
+
184
+ continuation = output_ids[0, len(ids):].tolist()
185
+ continuation = _strip_eos(continuation, effective_eos)
186
+ return safe_decode_text(tokenizer.decode(continuation))
fractus_vorax/organs/__init__.py ADDED
File without changes
fractus_vorax/organs/consolidation.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # fractus_vorax/organs/consolidation.py
2
+ """Consolidation : les atomes co-retrouvés souvent se lient en concepts composites.
3
+ Comptage append-only, paires triées, déterminisme total."""
4
+ import json
5
+ from itertools import combinations
6
+ from pathlib import Path
7
+
8
+
9
+ class CoActivation:
10
+ def __init__(self) -> None:
11
+ self.counts: dict[tuple[int, int], int] = {}
12
+
13
+ def record(self, atom_ids: list[int]) -> None:
14
+ ids = sorted(set(atom_ids))
15
+ for a, b in combinations(ids, 2):
16
+ self.counts[(a, b)] = self.counts.get((a, b), 0) + 1
17
+
18
+ def promote(self, threshold: int = 3) -> list[tuple[int, int]]:
19
+ pairs = [(pair, c) for pair, c in self.counts.items() if c >= threshold]
20
+ pairs.sort(key=lambda x: (-x[1], x[0]))
21
+ return [pair for pair, _ in pairs]
22
+
23
+ def forget(self, pairs: list[tuple[int, int]]) -> None:
24
+ """Retire des paires du compteur (ignore les absentes).
25
+
26
+ Appelé par Brain.consolidate après promotion : sans cela, les paires
27
+ restent comptées et un second consolidate() les re-promeut (doublon).
28
+ """
29
+ for pair in pairs:
30
+ self.counts.pop(pair, None)
31
+
32
+ def stats(self) -> dict:
33
+ max_count = max(self.counts.values(), default=0)
34
+ return {"pairs": len(self.counts), "max_count": max_count}
35
+
36
+ def save(self, path: Path) -> None:
37
+ path.mkdir(parents=True, exist_ok=True)
38
+ payload = {f"{a}|{b}": c for (a, b), c in sorted(self.counts.items())}
39
+ (path / "coactivation.json").write_text(
40
+ json.dumps(payload, sort_keys=True), encoding="utf-8"
41
+ )
42
+
43
+ @classmethod
44
+ def load(cls, path: Path) -> "CoActivation":
45
+ co = cls()
46
+ for key, count in json.loads((path / "coactivation.json").read_text(encoding="utf-8")).items():
47
+ a, b = key.split("|")
48
+ co.counts[(int(a), int(b))] = int(count)
49
+ return co
fractus_vorax/organs/hebbian.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # fractus_vorax/organs/hebbian.py
2
+ """Organe 2 — cortex hebbien : mémoire associative en forme close.
3
+ Écriture = produit externe additionné (jamais un gradient), complétion = sign(M @ q).
4
+ Porte ΔE : une écriture qui casse une sonde existante est refusée et annulée."""
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+
10
+ from ..hv import pack, unpack
11
+
12
+ D_H_DEFAULT = 2048
13
+
14
+
15
+ class HebbianMemory:
16
+ def __init__(self, D_h: int = D_H_DEFAULT) -> None:
17
+ self.D_h = D_h
18
+ self.M = np.zeros((D_h, D_h), dtype=np.int32)
19
+ self._ledger: list[tuple[np.ndarray, np.ndarray]] = []
20
+
21
+ @property
22
+ def n_writes(self) -> int:
23
+ return len(self._ledger)
24
+
25
+ def __len__(self) -> int:
26
+ return len(self._ledger)
27
+
28
+ def write(self, key: np.ndarray, value: np.ndarray) -> int:
29
+ # M = Σ v kᵀ (hétéro-associatif) pour que sign(M @ k_j) ≈ v_j ;
30
+ # l'orientation k vᵀ du brief rendait M @ q = k·(v·q), i.e. ±k, jamais v.
31
+ self.M += np.outer(value.astype(np.int32), key.astype(np.int32))
32
+ self._ledger.append((key, value))
33
+ return len(self._ledger) - 1
34
+
35
+ def write_gated(
36
+ self, key: np.ndarray, value: np.ndarray,
37
+ probes: list[tuple[np.ndarray, np.ndarray]],
38
+ ) -> bool:
39
+ self.write(key, value)
40
+ if all(np.array_equal(self.complete(pk), pv) for pk, pv in probes):
41
+ return True
42
+ self.rollback()
43
+ return False
44
+
45
+ def complete(self, query: np.ndarray) -> np.ndarray:
46
+ acc = self.M @ query.astype(np.int32)
47
+ return np.where(acc >= 0, 1, -1).astype(np.int8)
48
+
49
+ def rollback(self) -> bool:
50
+ if not self._ledger:
51
+ return False
52
+ key, value = self._ledger.pop()
53
+ self.M -= np.outer(value.astype(np.int32), key.astype(np.int32))
54
+ return True
55
+
56
+ def save(self, path: Path) -> None:
57
+ path.mkdir(parents=True, exist_ok=True)
58
+ n = len(self._ledger)
59
+ n_bytes = self.D_h // 8
60
+ if n:
61
+ keys = np.stack([pack(k) for k, _ in self._ledger])
62
+ values = np.stack([pack(v) for _, v in self._ledger])
63
+ else:
64
+ keys = np.zeros((0, n_bytes), dtype=np.uint8)
65
+ values = keys.copy()
66
+ np.savez_compressed(path / "hebbian.npz", M=self.M, keys=keys, values=values)
67
+ (path / "hebbian_meta.json").write_text(
68
+ json.dumps({"D_h": self.D_h, "n_writes": n}, sort_keys=True), encoding="utf-8"
69
+ )
70
+
71
+ @classmethod
72
+ def load(cls, path: Path) -> "HebbianMemory":
73
+ meta = json.loads((path / "hebbian_meta.json").read_text(encoding="utf-8"))
74
+ mem = cls(D_h=int(meta["D_h"]))
75
+ data = np.load(path / "hebbian.npz")
76
+ mem.M = data["M"].astype(np.int32)
77
+ for i in range(int(meta["n_writes"])):
78
+ key = unpack(data["keys"][i], mem.D_h)
79
+ value = unpack(data["values"][i], mem.D_h)
80
+ mem._ledger.append((key, value))
81
+ return mem
fractus_vorax/organs/spawn.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # fractus_vorax/organs/spawn.py
2
+ """Organe 3 — croissance : chaque source ingérée devient un expert routé par
3
+ sa signature HV. Le modèle grossit physiquement, sans entraînement conjoint."""
4
+ import json
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+
10
+ from ..hv import pack, sim, unpack
11
+
12
+ D_SIG = 8192 # les signatures vivent dans l'espace D du brain
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Expert:
17
+ name: str
18
+ atom_start: int
19
+ atom_end: int
20
+ n_atoms: int
21
+
22
+
23
+ class ExpertRegistry:
24
+ def __init__(self) -> None:
25
+ self.experts: dict[str, Expert] = {}
26
+ self.signatures: dict[str, np.ndarray] = {}
27
+
28
+ def __len__(self) -> int:
29
+ return len(self.experts)
30
+
31
+ def spawn(self, name: str, signature: np.ndarray, atom_start: int, atom_end: int) -> Expert:
32
+ expert = Expert(name, atom_start, atom_end, atom_end - atom_start + 1)
33
+ self.experts[name] = expert
34
+ self.signatures[name] = signature
35
+ return expert
36
+
37
+ def get(self, name: str) -> Expert | None:
38
+ return self.experts.get(name)
39
+
40
+ def route(self, query_hv: np.ndarray, top: int = 2) -> list[tuple[str, float]]:
41
+ if not self.experts:
42
+ return []
43
+ scored = [(name, sim(query_hv, sig)) for name, sig in self.signatures.items()]
44
+ scored.sort(key=lambda x: (-x[1], x[0]))
45
+ return scored[:top]
46
+
47
+ def save(self, path: Path) -> None:
48
+ path.mkdir(parents=True, exist_ok=True)
49
+ payload = [
50
+ {"name": e.name, "atom_start": e.atom_start, "atom_end": e.atom_end, "n_atoms": e.n_atoms}
51
+ for e in sorted(self.experts.values(), key=lambda e: e.name)
52
+ ]
53
+ (path / "experts.json").write_text(
54
+ json.dumps(payload, sort_keys=True, indent=2), encoding="utf-8"
55
+ )
56
+ names = sorted(self.signatures)
57
+ bits = [pack(self.signatures[n]) for n in names]
58
+ np.savez_compressed(
59
+ path / "signatures.npz",
60
+ names=np.array(names),
61
+ bits=np.stack(bits) if bits else np.array([], dtype=np.uint8),
62
+ )
63
+
64
+ @classmethod
65
+ def load(cls, path: Path) -> "ExpertRegistry":
66
+ reg = cls()
67
+ for item in json.loads((path / "experts.json").read_text(encoding="utf-8")):
68
+ reg.experts[item["name"]] = Expert(
69
+ item["name"], item["atom_start"], item["atom_end"], item["n_atoms"]
70
+ )
71
+ data = np.load(path / "signatures.npz", allow_pickle=False)
72
+ names = [str(n) for n in data["names"]]
73
+ if names:
74
+ n_bytes = data["bits"].shape[1]
75
+ D = n_bytes * 8
76
+ for i, name in enumerate(names):
77
+ reg.signatures[name] = unpack(data["bits"][i], D)
78
+ return reg
fractus_vorax/organs/traces.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # fractus_vorax/organs/traces.py
2
+ """Organe 1 — hippocampe : mémoire append-only, on n'écrase jamais, on superpose."""
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+
8
+ from ..hv import D_DEFAULT, pack, unpack
9
+
10
+
11
+ class TraceStore:
12
+ def __init__(self, D: int = D_DEFAULT) -> None:
13
+ self.D = D
14
+ self.addrs: list[np.ndarray] = []
15
+ self.values: list[np.ndarray] = []
16
+ self.weights: list[float] = []
17
+ self.ids: list[int] = []
18
+ self._stack: np.ndarray | None = None
19
+
20
+ def __len__(self) -> int:
21
+ return len(self.ids)
22
+
23
+ def write(self, atom_id: int, addr: np.ndarray, value: np.ndarray, weight: float = 1.0) -> int:
24
+ self.addrs.append(addr)
25
+ self.values.append(value)
26
+ self.weights.append(float(weight))
27
+ self.ids.append(int(atom_id))
28
+ self._stack = None
29
+ return len(self.ids) - 1
30
+
31
+ def _mat(self) -> np.ndarray:
32
+ if self._stack is None:
33
+ self._stack = np.stack(self.addrs).astype(np.int16)
34
+ return self._stack
35
+
36
+ def retrieve(self, query: np.ndarray, k: int = 8) -> list[tuple[int, float]]:
37
+ if not self.ids:
38
+ return []
39
+ sims = (self._mat() @ query.astype(np.int16)) / self.D
40
+ sims = sims * np.asarray(self.weights, dtype=np.float64)
41
+ order = np.argsort(-sims)[:k]
42
+ return [(self.ids[i], float(sims[i])) for i in order]
43
+
44
+ def signature(self) -> np.ndarray:
45
+ """Bundle de toutes les addresses (clé de routage future — spawn)."""
46
+ if not self.addrs:
47
+ raise ValueError("empty store has no signature")
48
+ total = np.sum(self._mat(), axis=0, dtype=np.int32)
49
+ return np.where(total >= 0, 1, -1).astype(np.int8)
50
+
51
+ def save(self, path: Path) -> None:
52
+ path.mkdir(parents=True, exist_ok=True)
53
+ packed_a = np.concatenate([pack(a) for a in self.addrs]) if self.addrs else np.array([], dtype=np.uint8)
54
+ packed_v = np.concatenate([pack(v) for v in self.values]) if self.values else np.array([], dtype=np.uint8)
55
+ np.savez_compressed(
56
+ path / "traces.npz",
57
+ a=packed_a,
58
+ v=packed_v,
59
+ w=np.asarray(self.weights, dtype=np.float32),
60
+ ids=np.asarray(self.ids, dtype=np.int64),
61
+ )
62
+ (path / "meta.json").write_text(
63
+ json.dumps({"D": self.D, "n": len(self.ids)}, sort_keys=True), encoding="utf-8"
64
+ )
65
+
66
+ @classmethod
67
+ def load(cls, path: Path) -> "TraceStore":
68
+ meta = json.loads((path / "meta.json").read_text(encoding="utf-8"))
69
+ store = cls(D=int(meta["D"]))
70
+ data = np.load(path / "traces.npz")
71
+ for i in range(int(meta["n"])):
72
+ a = unpack(data["a"][i * (store.D // 8):(i + 1) * (store.D // 8)], store.D)
73
+ v = unpack(data["v"][i * (store.D // 8):(i + 1) * (store.D // 8)], store.D)
74
+ store.addrs.append(a)
75
+ store.values.append(v)
76
+ store.weights = [float(x) for x in data["w"]]
77
+ store.ids = [int(x) for x in data["ids"]]
78
+ return store
pyproject.toml ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # pyproject.toml
2
+ [build-system]
3
+ requires = ["setuptools>=68"]
4
+ build-backend = "setuptools.build_meta"
5
+
6
+ [project]
7
+ name = "fractus-vorax"
8
+ version = "1.0.0"
9
+ description = "Fractus ne s'entraine plus : il mange. Knowledge ingestion without training."
10
+ requires-python = ">=3.10"
11
+ dependencies = ["numpy>=1.24"]
12
+
13
+ [project.optional-dependencies]
14
+ dev = ["pytest>=8"]
15
+
16
+ [project.scripts]
17
+ fractus-vorax = "fractus_vorax.agent.cli:main"
18
+
19
+ [tool.setuptools.packages.find]
20
+ include = ["fractus_vorax*"]
21
+
22
+ [tool.pytest.ini_options]
23
+ testpaths = ["tests"]
24
+ markers = ["slow: tests lourds exclus par défaut"]
tests/test_analogy.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # tests/test_analogy.py
2
+ from fractus_vorax.compiler.analogy import extract_patterns, match_pattern, solve_analogy
3
+ from fractus_vorax.compiler.atoms import Atom
4
+
5
+ CAPITALS = [
6
+ Atom("what is the capital of france", "paris", "cap.csv:2"),
7
+ Atom("what is the capital of spain", "madrid", "cap.csv:3"),
8
+ Atom("what is the capital of japan", "tokyo", "cap.csv:4"),
9
+ Atom("what is the capital of italy", "rome", "cap.csv:5"),
10
+ Atom("who wrote hamlet", "william shakespeare", "books.csv:2"),
11
+ ]
12
+
13
+
14
+ def test_extract_patterns_min_support():
15
+ patterns = extract_patterns(CAPITALS, min_support=3)
16
+ assert ("what", "is", "the", "capital", "of") in patterns
17
+ assert ("who", "wrote") not in patterns # support 1 < 3
18
+ rel = patterns[("what", "is", "the", "capital", "of")]
19
+ assert rel["france"] == "paris" and rel["italy"] == "rome"
20
+
21
+
22
+ def test_match_pattern():
23
+ pattern, slot = match_pattern("What is the capital of Korea?")
24
+ assert pattern == ("what", "is", "the", "capital", "of")
25
+ assert slot == "korea"
26
+ assert match_pattern("hello") is None
27
+
28
+
29
+ def test_solve_analogy_typo_slot():
30
+ """Slot jamais vu mais morphologiquement proche d'un slot connu."""
31
+ patterns = extract_patterns(CAPITALS)
32
+ rel = patterns[("what", "is", "the", "capital", "of")]
33
+ hits = solve_analogy("what is the capital of franc", rel, D=4096)
34
+ assert hits and hits[0][0] == "paris"
35
+ assert hits[0][1] > 0.2
36
+
37
+
38
+ def test_solve_analogy_known_slot_returns_empty():
39
+ """Slot déjà connu = retrieval (job des traces), pas analogie."""
40
+ patterns = extract_patterns(CAPITALS)
41
+ rel = patterns[("what", "is", "the", "capital", "of")]
42
+ assert solve_analogy("what is the capital of france", rel, D=4096) == []
43
+
44
+
45
+ def test_solve_analogy_unknown_pattern_returns_empty():
46
+ assert solve_analogy("qui est le capitaine", {}, D=4096) == []
47
+
48
+
49
+ def test_morphological_filter():
50
+ """Candidat partageant un préfixe >= 5 chars avec le slot requête est exclu."""
51
+ rel = {"australia": "australian", "austria": "austrian", "bulgaria": "bulgarian",
52
+ "croatia": "croatian", "romania": "romanian"}
53
+ hits = solve_analogy("demonym of australian", rel, D=4096) # slot requete = australian
54
+ answers = [a for a, _ in hits]
55
+ assert "australian" not in answers # préfixe partagé avec le slot requête
56
+
57
+
58
+ def test_both_methods_agree_on_typo_case():
59
+ patterns = extract_patterns(CAPITALS)
60
+ rel = patterns[("what", "is", "the", "capital", "of")]
61
+ for method in ("cosadd", "cosmul"):
62
+ hits = solve_analogy("what is the capital of spane", rel, D=4096, method=method)
63
+ assert hits and hits[0][0] == "madrid"
tests/test_answer_lock.py ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # tests/test_answer_lock.py
2
+ """Plan 7 — le verrou de réponse : les organes articulent à travers le noyau.
3
+
4
+ Quatre angles :
5
+ - ``speak(..., answer_lock, answer_boost)`` : à l'étape i, +boost sur
6
+ answer_lock[i] (APRÈS z-norm/rep-pen, AVANT top-k/p) — boost par défaut
7
+ 30, CALIBRÉ PAR MESURE (le 12 du plan perd le tirage quand le token
8
+ verrouillé vit à −1σ et le top à +4σ : z[' par']=−3.0 vs zmax=11.9 sur
9
+ le 1B — 2/4 capitales seulement ; le tiny core non-entraîné porte les
10
+ mêmes outliers froids, zmax 17-20) : le token verrouillé est choisi à
11
+ CHAQUE étape i, 10 seeds sur 10 ;
12
+ - ``Brain.speak_answer`` : la liste COMPLÈTE des tokens BPE de " "+réponse
13
+ (tokyo pour japan), ANALOGIE en repli, LACUNE -> [] ;
14
+ - ``:say`` sans noyau : la dégradation honnête (mingw-safe, jamais torch) ;
15
+ - intégration tiny : REPL + tiny CteCore -> les lignes [ORGANES]/[PAROLE],
16
+ la réponse verrouillée VISIBLE dans la génération.
17
+ """
18
+ import pytest
19
+
20
+ from fractus_vorax.agent.repl import Repl
21
+ from fractus_vorax.brain import Brain
22
+ from fractus_vorax.compiler.atoms import Atom
23
+
24
+ CAPITALS = [
25
+ Atom("what is the capital of france", "paris", "cap.csv:2"),
26
+ Atom("what is the capital of spain", "madrid", "cap.csv:3"),
27
+ Atom("what is the capital of japan", "tokyo", "cap.csv:4"),
28
+ Atom("what is the capital of italy", "rome", "cap.csv:5"),
29
+ ]
30
+
31
+
32
+ def _brain() -> Brain:
33
+ brain = Brain(D=2048)
34
+ brain.ingest_source("capitals", CAPITALS)
35
+ return brain
36
+
37
+
38
+ class FakeAnswerTok:
39
+ """Tokenizer fake du protocole minimal (même table que test_steering) :
40
+ « paris » -> [7, 8] comme un vrai BPE qui fusionnerait — la liste
41
+ COMPLÈTE est observable, pas juste le premier geste."""
42
+
43
+ vocab_size = 128
44
+ eos_token_id = 127
45
+ _WORDS = {
46
+ " paris": [7, 8],
47
+ " madrid": [9],
48
+ " tokyo": [10],
49
+ " rome": [11],
50
+ }
51
+
52
+ def encode(self, text: str) -> list[int]:
53
+ return list(self._WORDS.get(text, [1]))
54
+
55
+ def decode(self, ids: list[int]) -> str:
56
+ rev = {v[0]: k.strip() for k, v in self._WORDS.items()}
57
+ return "".join(rev.get(int(i), "?") for i in ids)
58
+
59
+
60
+ class _TinyCharTok:
61
+ """Tokenizer char-level sur le vocab tiny (128) — injectif sur les ids < 128."""
62
+
63
+ vocab_size = 128
64
+
65
+ def encode(self, text: str) -> list[int]:
66
+ return [ord(c) % 128 for c in text]
67
+
68
+ def decode(self, ids: list[int]) -> str:
69
+ return "".join(chr(int(i) % 128) for i in ids)
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # speak(answer_lock=...) — le verrou domine chaque pas (torch, skip en mingw)
74
+ # ---------------------------------------------------------------------------
75
+ def _torch_tiny():
76
+ torch = pytest.importorskip(
77
+ "torch", reason="speak nécessite torch (substrat sans torch)"
78
+ )
79
+ from fractus_vorax.model.cte_core import CteCore, CteCoreConfig
80
+
81
+ torch.manual_seed(0)
82
+ return torch, CteCore(CteCoreConfig(vocab_size=128))
83
+
84
+
85
+ def test_answer_lock_locks_every_step_ten_seeds():
86
+ """Boost par défaut (30, calibré par mesure sur le 1B — le 12 du plan
87
+ perdait contre les outliers zmax≈12-20 alors que z[locked] peut vivre à
88
+ −3σ) : à CHAQUE étape i le token émis EST answer_lock[i], 10 seeds sur
89
+ 10 — la réponse des organes passe À TRAVERS la distribution du noyau,
90
+ pas à côté."""
91
+ _, core = _torch_tiny()
92
+ from fractus_vorax.model.speak import speak
93
+
94
+ tok = _TinyCharTok()
95
+ lock = [42, 84, 7] # ids distincts : le rep-pen ne touche jamais le verrou
96
+ for seed in range(10):
97
+ _, diags = speak(
98
+ core, tok, "hello", max_new_tokens=3,
99
+ answer_lock=lock, seed=seed,
100
+ )
101
+ assert [d["chosen"] for d in diags] == [tok.decode([i]) for i in lock], (
102
+ f"seed {seed}: le verrou n'a pas tenu à chaque pas"
103
+ )
104
+
105
+
106
+ def test_answer_lock_boost_twelve_is_honestly_weak():
107
+ """Le 12 du plan, gardé comme MESURE honnête : sur le tiny core non
108
+ entraîné (outliers zmax≈18 froids), +12 sur un token à −1σ NE domine
109
+ PAS — c'est la mesure qui a motivé le défaut 30 (voir speak.py). Ce
110
+ test fige la frontière : le boost place le token, il ne le force pas
111
+ magiquement."""
112
+ _, core = _torch_tiny()
113
+ from fractus_vorax.model.speak import speak
114
+
115
+ tok = _TinyCharTok()
116
+ text12, _ = speak(
117
+ core, tok, "hello", max_new_tokens=1,
118
+ answer_lock=[42], answer_boost=12.0, seed=0,
119
+ )
120
+ text30, _ = speak(
121
+ core, tok, "hello", max_new_tokens=1,
122
+ answer_lock=[42], answer_boost=30.0, seed=0,
123
+ )
124
+ assert text30 == tok.decode([42]) # 30 : le verrou tient (même seed)
125
+ assert text12 != tok.decode([42]) # 12 : z[42]−1.2+12 < zmax 18.4 — perdu
126
+
127
+
128
+ def test_answer_lock_survives_top_k_mask():
129
+ """Le verrou vit AVANT top-k/p : le token boosté est top-1, il survit au
130
+ masque — top_k=5 ne casse pas l'articulation."""
131
+ _, core = _torch_tiny()
132
+ from fractus_vorax.model.speak import speak
133
+
134
+ tok = _TinyCharTok()
135
+ for seed in range(5):
136
+ _, diags = speak(
137
+ core, tok, "hello", max_new_tokens=2,
138
+ answer_lock=[42, 84], top_k=5, seed=seed,
139
+ )
140
+ assert [d["chosen"] for d in diags] == [tok.decode([42]), tok.decode([84])]
141
+
142
+
143
+ def test_answer_lock_longer_than_generation_is_fine():
144
+ """len(answer_lock) > max_new_tokens : le verrou s'arrête avec la boucle,
145
+ aucune erreur, les pas émis sont verrouillés."""
146
+ _, core = _torch_tiny()
147
+ from fractus_vorax.model.speak import speak
148
+
149
+ tok = _TinyCharTok()
150
+ text, diags = speak(
151
+ core, tok, "hello", max_new_tokens=2,
152
+ answer_lock=[42, 84, 7, 90, 55], seed=0,
153
+ )
154
+ assert len(diags) == 2
155
+ assert [d["chosen"] for d in diags] == [tok.decode([42]), tok.decode([84])]
156
+ assert isinstance(text, str)
157
+
158
+
159
+ def test_answer_lock_rejects_bad_arguments():
160
+ _, core = _torch_tiny()
161
+ from fractus_vorax.model.speak import speak
162
+
163
+ tok = _TinyCharTok()
164
+ with pytest.raises(ValueError): # id hors vocab
165
+ speak(core, tok, "hello", max_new_tokens=2, answer_lock=[999])
166
+ with pytest.raises(ValueError): # boost nul ou négatif
167
+ speak(core, tok, "hello", max_new_tokens=2, answer_lock=[42], answer_boost=0.0)
168
+
169
+
170
+ def test_answer_lock_visible_in_top3_diagnostics():
171
+ """Le boost est posé AVANT les diagnostics top3 : le token verrouillé
172
+ y apparaît (observabilité du mécanisme — il est affiché, pas caché)."""
173
+ _, core = _torch_tiny()
174
+ from fractus_vorax.model.speak import speak
175
+
176
+ tok = _TinyCharTok()
177
+ _, diags = speak(core, tok, "hello", max_new_tokens=1, answer_lock=[42], seed=0)
178
+ top3_tokens = [s for s, _v in diags[0]["top3"]]
179
+ assert tok.decode([42]) in top3_tokens
180
+ assert top3_tokens[0] == tok.decode([42]) # top-1 après le boost
181
+
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # Brain.speak_answer — substrat (les deux venvs)
185
+ # ---------------------------------------------------------------------------
186
+ def test_speak_answer_japan_is_full_tokyo_list():
187
+ """japan -> la liste COMPLÈTE de " tokyo" (pas juste le premier token —
188
+ c'est la différence avec speak_bias : UN verrou, TOUS ses tokens)."""
189
+ assert _brain().speak_answer("what is the capital of japan", FakeAnswerTok()) == [10]
190
+
191
+
192
+ def test_speak_answer_france_is_full_paris_list():
193
+ """france -> [7, 8] : les DEUX tokens de " paris" — le verrou couvre la
194
+ réponse entière, fragment après fragment."""
195
+ assert _brain().speak_answer("what is the capital of france", FakeAnswerTok()) == [7, 8]
196
+
197
+
198
+ def test_speak_answer_lacune_is_empty():
199
+ """Cerveau vide -> seule carte LACUNE -> [] : on ne verrouille JAMAIS la
200
+ bouche sur une lacune."""
201
+ assert Brain(D=2048).speak_answer("anything", FakeAnswerTok()) == []
202
+
203
+
204
+ def test_speak_answer_rejects_bad_k():
205
+ with pytest.raises(ValueError):
206
+ _brain().speak_answer("q", FakeAnswerTok(), k=0)
207
+
208
+
209
+ def test_speak_answer_deterministic():
210
+ brain = _brain()
211
+ a = brain.speak_answer("what is the capital of japan", FakeAnswerTok())
212
+ b = brain.speak_answer("what is the capital of japan", FakeAnswerTok())
213
+ assert a == b == [10]
214
+
215
+
216
+ def test_speak_answer_with_real_gpt2_bpe():
217
+ """Le VRAI BPE GPT-2 : japan -> exactement encode(" tokyo"), liste
218
+ complète (se skipe en substrat mingw sans lib tokenizers)."""
219
+ bpt = pytest.importorskip("fractus_vorax.model.bpe_tokenizer")
220
+ if not bpt.bpe_available():
221
+ pytest.skip("tokenizers indisponible")
222
+ tok = bpt.Gpt2BpeTokenizer()
223
+ assert _brain().speak_answer("what is the capital of japan", tok) == tok.encode(" tokyo")
224
+ assert _brain().speak_answer("what is the capital of france", tok) == tok.encode(" paris")
225
+
226
+
227
+ # ---------------------------------------------------------------------------
228
+ # :say — dégradation honnête (substrat, les deux venvs)
229
+ # ---------------------------------------------------------------------------
230
+ def _repl(tmp_path) -> Repl:
231
+ brain = Brain(D=2048)
232
+ brain.ingest_source("capitals", CAPITALS)
233
+ brain.save(tmp_path / "brain")
234
+ return Repl(tmp_path / "brain", D=2048)
235
+
236
+
237
+ def test_say_without_core_degrades_honestly(tmp_path):
238
+ """Sans :core : UNE ligne honnête, jamais de traceback — le contrat
239
+ mingw (ce test ne touche jamais torch)."""
240
+ r = _repl(tmp_path)
241
+ assert r.core is None and r.tokenizer is None
242
+ assert r.feed(":say what is the capital of japan") == [
243
+ "[PAROLE] noyau non attaché (:core d'abord)"
244
+ ]
245
+ # le REPL reste vivant après la dégradation
246
+ assert any("[CARTE]" in l for l in r.feed("what is the capital of japan"))
247
+
248
+
249
+ def test_say_usage_line_without_question(tmp_path):
250
+ r = _repl(tmp_path)
251
+ assert r.feed(":say") == ["[PAROLE] usage: :say <question>"]
252
+
253
+
254
+ # ---------------------------------------------------------------------------
255
+ # Intégration tiny — :say complet sur un tiny CteCore (torch)
256
+ # ---------------------------------------------------------------------------
257
+ def test_say_tiny_core_organes_and_locked_parole(tmp_path):
258
+ """Noeud torch : tiny CteCore + tokenizer char -> les DEUX lignes.
259
+ [ORGANES] porte la carte (paris), [PAROLE] porte le prompt
260
+ d'articulation ET la génération où la réponse verrouillée est VISIBLE :
261
+ les 6 tokens char de " paris" passent À TRAVERS la boucle du noyau."""
262
+ torch = pytest.importorskip(
263
+ "torch", reason=":say mécanique nécessite torch (substrat sans torch)"
264
+ )
265
+ from fractus_vorax.model.cte_core import CteCore, CteCoreConfig
266
+
267
+ r = _repl(tmp_path)
268
+ torch.manual_seed(0)
269
+ r.core = CteCore(CteCoreConfig(vocab_size=128))
270
+ r.tokenizer = _TinyCharTok()
271
+ out = r.feed(":say what is the capital of france")
272
+ organes = [l for l in out if l.startswith("[ORGANES]")]
273
+ parole = [l for l in out if l.startswith("[PAROLE]")]
274
+ assert len(organes) == 1 and len(parole) == 1, f"lignes manquantes: {out}"
275
+ assert "paris" in organes[0] # la carte qui connaît : stmt => paris
276
+ assert parole[0].startswith(
277
+ "[PAROLE] what is the capital of france The answer is"
278
+ )
279
+ # LE point du Plan 7 : la réponse des organes apparaît dans la parole
280
+ # du noyau (verrou = " paris" char par char, puis suite libre)
281
+ assert " paris" in parole[0]
282
+ # déterminisme : même seed interne (7) -> même articulation
283
+ again = r.feed(":say what is the capital of france")
284
+ assert [l for l in again if l.startswith("[PAROLE]")] == parole
285
+
286
+
287
+ def test_say_tiny_core_lacune_says_no_lock(tmp_path):
288
+ """Cerveau SANS connaissance (aucun atome -> carte LACUNE) : la parole
289
+ coule SANS verrou et le DIT — honnête, jamais de verrou sur rien.
290
+ (Un brain non vide répond toujours des FAIT bas-sim : la lacune vraie
291
+ est le cerveau vide.)"""
292
+ torch = pytest.importorskip(
293
+ "torch", reason=":say mécanique nécessite torch (substrat sans torch)"
294
+ )
295
+ from fractus_vorax.model.cte_core import CteCore, CteCoreConfig
296
+
297
+ r = Repl(tmp_path / "brain-vide", D=2048) # pas de meta.json -> brain vide
298
+ torch.manual_seed(0)
299
+ r.core = CteCore(CteCoreConfig(vocab_size=128))
300
+ r.tokenizer = _TinyCharTok()
301
+ out = r.feed(":say what is the capital of france")
302
+ parole = [l for l in out if l.startswith("[PAROLE]")]
303
+ assert parole and "sans verrou" in parole[0]
304
+ assert any("LACUNE" in l for l in out) # la lacune est dite comme lacune
305
+
306
+
307
+ def test_say_tiny_core_failure_is_honest(tmp_path):
308
+ """Un noyau qui échoue (tokenizer incohérent avec le vocab) -> ligne
309
+ ``[PAROLE] indisponible: ...``, jamais de traceback."""
310
+ torch = pytest.importorskip(
311
+ "torch", reason=":say mécanique nécessite torch (substrat sans torch)"
312
+ )
313
+ from fractus_vorax.model.cte_core import CteCore, CteCoreConfig
314
+
315
+ r = _repl(tmp_path)
316
+ torch.manual_seed(0)
317
+ r.core = CteCore(CteCoreConfig(vocab_size=8)) # vocab minuscule
318
+ r.tokenizer = _TinyCharTok() # ids jusqu'à 127 -> hors vocab
319
+ out = r.feed(":say what is the capital of france")
320
+ assert any(l.startswith("[PAROLE] indisponible:") for l in out)