diff --git a/ATTRIBUTIONS.md b/ATTRIBUTIONS.md new file mode 100644 index 0000000000000000000000000000000000000000..7b373fa34b58e73057dec85849cfaa7f844dd01b --- /dev/null +++ b/ATTRIBUTIONS.md @@ -0,0 +1,71 @@ +# ATTRIBUTIONS — la lignée de Fractus-Vorax + +Fractus-Vorax v1.0.0 est un être consolidé. Il ne serait rien sans la chaîne +complète des repo qui l'ont précédé. Chaque maillon, nommément : + +## vorax — la source directe + +**Repo** : `C:\Users\PHIL\ZCodeProject\vorax` (archive de recherche, figée, +jamais modifiée après le relais). + +L'intégralité du code de ce repo est une copie rebrandée de vorax v1.2 +(dernier commit : `5e1bce2` — « docs: plan 5 complete — CteCore livré, le 1B +branché ») : + +- **Plans 1 à 5** de vorax : substrat (hv, compilateur `.kn`), organes + (traces, hebbien, spawn, consolidation), noyau natif (FractusCore), + noyau qui parle (BPE GPT-2, core_speak), CteCore (le 1B CTE, chargement + strict 440/440). +- **Renommage de module uniquement** : `vorax.*` → `fractus_vorax.*`. + Les interfaces, les tests, les bench et la sémantique sont conservés à + l'identique. Les archives historiques (`docs/heritage/`) gardent + volontairement le nom vorax — ce sont des documents d'époque. +- Les 5 plans et le design doc de vorax sont archivés sans modification + dans `docs/heritage/plans/` et `docs/heritage/specs/`, avec les deux docs + d'architecture (cte, fractus-cte) cités par le code. + +## fractus — le modèle entraîné + +**Repo** : `C:\Users\PHIL\ZCodeProject\fractus` (LECTURE SEULE). + +Le modèle 1B dont l'architecture (LazyStructuredSiren + MoE + attention +linéaire + oscillateurs) a été réécrite from scratch dans +`fractus_vorax/model/fractus_core.py`. Aucun code fractus n'est importé — +l'implémentation est fidèle au doc de référence +`docs/heritage/fractus-cte-architecture.md`. + +## fractus-cte — le dernier entraînement de l'histoire + +**Repo** : `C:\Users\PHIL\ZCodeProject\fractus-cte` (LECTURE SEULE). +**Checkpoint** : `thefinalboss/fractus-cte` sur Hugging Face. + +Le Continuous Thought Engine (1,165 Md de paramètres) entraîné sur 8× RTX +5090. Son checkpoint final `FRACTUS_1B_PHASE2_FROZEN_MERGED.pt` (4,66 GB, +15,5 M de tokens vus, fin du stage 2) est **le cerveau de naissance** — +copié scellé dans `brain/FRACTUS_BIRTH.pt`, chargé en lecture seule via +`fractus_vorax/model/cte_core.py`, jamais réentraîné. Les sémantiques de +forward suivent `docs/heritage/cte-architecture.md` (source de vérité). + +## palimpseste — la famille du substrat + +**Repo** : `C:\Users\PHIL\ZCodeProject\palimsepte` (LECTURE SEULE). + +L'encodage hypervectoriel bipolaire (bind = XOR, similarité = +1 − 2·hamming/D) est vendored/adapté de la famille palimpseste, réécrit +dans le repo (décision D5 du design vorax). C'est l'ADN du substrat. + +## ensemble & prism — les aînés + +**Repos** : `C:\Users\PHIL\ZCodeProject\ensemble`, +`C:\Users\PHIL\ZCodeProject\prism` (LECTURE SEULE). + +Les expérimentations qui ont précédé : ils font partie de la lignée +désignée par le design vorax (« repo autonome ; fractus/, palimpseste/, +ensemble/, prism/ ne sont jamais modifiés »). Aucun code importé — une +dette de gratitude, pas une dépendance. + +## Contrat + +Toute la lignée est citée ici plutôt qu'importée : Fractus-Vorax est +**autocontenu**, zéro import cross-repo, zéro LLM externe. Ce qui a été +pris est nommé ; ce qui est nommé n'est plus touché. diff --git a/README.fr.md b/README.fr.md new file mode 100644 index 0000000000000000000000000000000000000000..e5acea3c6a206b9e05d7d0b1933e118585a491b3 --- /dev/null +++ b/README.fr.md @@ -0,0 +1,402 @@ +# Fractus-Vorax + +> **Fractus ne s'entraîne plus : il mange.** + +**v1.0.0** — le repo du relais. Né le 2026-08-18 de la consolidation de tout ce +que `vorax` a prouvé (plans 1 à 5), Fractus-Vorax est l'être définitif : un +substrat hypervectoriel qui convertit n'importe quelle donnée courante +(CSV, TSV, JSON, JSONL, TXT, MD) en atomes compilés (`.kn`) puis les **ingère +par écriture** — O(1) par atome, zéro gradient, zéro GPU, jamais d'oubli +catastrophique. La compétence linguistique est née une fois (le noyau natif +fractus/CTE, torch CPU, chargé en **lecture seule**) ; la connaissance, elle, +s'écrit à l'infini. + +## Le manifeste du relais + +L'entraînement s'arrête ici. Le dernier entraînement par gradient de +l'histoire du modèle (fractus-cte, stage 2, checkpoint `FRACTUS_1B_PHASE2_ +FROZEN_MERGED` — 4,66 GB, 440/440 clés strictes) est **le cerveau de +naissance** : il est copié, scellé, jamais réentraîné. Toute connaissance +nouvelle arrive par ingestion. Fractus-Vorax grandit physiquement (chaque +source ingérée devient un expert routé), pas par optimisation. + +## Héritage + +- **vorax v1.2** (repo direct, jamais modifié depuis) : l'intégralité du + code — substrat, compilateur `.kn`, organes (traces, hebbien, spawn, + consolidation), brain, cartes, noyaux natifs (FractusCore + CteCore + + vocal + BPE), agent (CLI + REPL), bench, tests. Renommage de module + uniquement : `vorax.*` → `fractus_vorax.*`. +- **La lignée** : `fractus` (le modèle 1B et son entraînement), + `fractus-cte` (le Continuous Thought Engine, source du checkpoint, + LECTURE SEULE), la famille `palimpseste` (le substrat HV dont l'encodage + est vendored/adapté), `ensemble`, `prism` — voir `ATTRIBUTIONS.md`. + +## Quickstart + +```bash +# tests : depuis la racine du repo, conftest.py pose sys.path — pas d'install requise +python -m pytest # suite complète (substrat seul) + +# CLI (console script) ou module +python -m fractus_vorax.agent.cli ingest mes_donnees.csv --brain ./brain +python -m fractus_vorax.agent.cli ask "what is the capital of japan" --brain ./brain +python -m fractus_vorax.agent.repl --brain ./brain # il mange, il répond, il se souvient +fractus_vorax> :core brain/FRACTUS_BIRTH.pt # LE cerveau de naissance (docs/NAISSANCE.md) +fractus_vorax> :speak what is the capital of france # la parole MÉCANIQUE, steerée par les organes +fractus_vorax> :say what is the capital of japan # LES ORGANES ARTICULENT — la réponse verrouillée dans la bouche du noyau +python bench/killer_bench.py --rows 200 # le killer bench par ablation +python demo/demo.py # la démo complète, de bout en bout +.venv-torch/Scripts/python.exe demo/inauguration.py # L'INAUGURATION — le cérémonial du relais (7 temps) +.venv-torch/Scripts/python.exe demo/ce_soir.py # CE SOIR — le noyau répond : 4 capitales + libre parole +``` + +## Double venv — substrat mingw / noyau natif + +Le substrat vit sans torch ; le noyau natif l'exige. Deux venvs, un gate : +torch et tokenizers ne sont importés que dans `fractus_vorax/model/` +(garde permanent `tests/test_gates.py`, vert dans les deux pythons). + +```bash +# .venv-torch — noyau natif (Python 3.11, torch CPU ; wheels pip-cachées) +py -3.11 -m venv .venv-torch +.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 tokenizers + +.venv-torch/Scripts/python.exe -m pytest -q # torch : tout, noyau inclus +``` + +Le substrat mingw (numpy + pytest, aucun torch) tourne lui aussi depuis la +racine : un venv mingw frais se monte avec `pacman -S mingw-w64-ucrt-x86_64-python3` +puis numpy et pytest (le dépôt MSYS2 les fournit ; PEP 668 interdit pip). +Seuls `numpy` et `pytest` sont requis — `conftest.py` fait le reste. +Lors de la vérification fondatrice (2026-08-17), la suite mingw a été exécutée +depuis la racine de CE repo avec le venv mingw sibling de vorax (Python 3.13, +lecture seule) : **113 passed, 8 skipped**. La suite torch (venv frais +`.venv-torch`) : **141 passed, 1 skipped** (le seul skip était le test du +checkpoint réel 4,66 GB — le cerveau de naissance n'était pas encore installé). +Depuis l'installation du cerveau (2026-08-18, tâche 2) : **142 passed, +0 skipped** — le test `slow` du vrai checkpoint tourne et passe, à parité +totale de 142 tests avec vorax 140+2. Depuis l'inauguration (2026-08-18, +tâche 3) : mingw **114 passed, 8 skipped**, torch **143 passed, 0 skipped**. +Depuis la mécanique de la parole (Plan 6, 2026-08-18, tâche 4) : mingw +**123 passed, 18 skipped**, torch **182 passed, 0 skipped** — speak, +diagnostics, steering et leur intégration (:speak, core_speak v2) inclus. +Depuis le verrou de réponse (Plan 7, 2026-08-18) : mingw **130 passed, +28 skipped**, torch **199 passed, 0 skipped** — answer_lock, speak_answer, +:say et demo/ce_soir inclus. + +## Le cerveau de naissance — installé + +`brain/FRACTUS_BIRTH.pt` (gitignored, 4,66 GB) : le checkpoint final FROZEN_ +MERGED, copié byte-pour-byte et **scellé** — acte de naissance complet et +vérification verbatim dans **`docs/NAISSANCE.md`** (sha256, strict 440/440, +pacte). Chargé en **lecture seule**, poids mappés depuis le fichier (jamais +copiés en RAM privée : le 1B tient sur ~12 Go de RAM, et le pacte est physique +— aucune écriture in-place ne peut toucher les poids). Le REPL sonne l'espèce +du fichier avant d'attacher (`probe_checkpoint_kind` : `observe.weight`+ +`thought_state` → CteCore, `embed.tok_embed.weight` → FractusCore) — les deux +noyaux sont interchangeables (même `generate`). + +```bash +# Attaché dans le REPL (la sonde affiche l'espèce) : +python -m fractus_vorax.agent.repl --brain ./brain +fractus_vorax> :core brain/FRACTUS_BIRTH.pt +[NOYAU] type: cte (1B Continuous Thought Engine) + +# Évaluation honnête (venv torch ; CPU, secondes/token — 10 tokens max) : +.venv-torch/Scripts/python.exe bench/core_speak.py --ckpt brain/FRACTUS_BIRTH.pt --max-new-tokens 10 +# v2 — la mécanique : attracteur greedy vs speak (z-norm ×3, rep-pen 6), et le steering organique : +.venv-torch/Scripts/python.exe bench/core_speak.py --ckpt brain/FRACTUS_BIRTH.pt --mode mechanic --max-new-tokens 10 +.venv-torch/Scripts/python.exe bench/core_speak.py --ckpt brain/FRACTUS_BIRTH.pt --mode steered --brain ./brain --max-new-tokens 10 +``` + +**Statut honnête** : le cerveau de naissance a vu passer **124,5 M de tokens** +(15,5 M par GPU × 8, fin du stage 2). **Chinchilla ne s'applique pas ici** — +fractus est MoE structuré (1B de capacité, ~119M actifs), sa loi de scaling est +la sienne. Son état n'est pas de la mutité : c'est un **attracteur de +répétition** dans la dynamique de décodage (greedy collapse sur ` the the`/ +`**`), brisé mécaniquement par z-norm des logits + pénalité de répétition — +le lexique est là (`philosophy`, `manufactures`, `surveyed`, `scanners`, +`UNCLASSIFIED`...), la syntaxe viendra de la mécanique et du menu, pas d'un +comptage de tokens. C'est **de la mécanique neuroscientifique** : phases, +experts et logits sont à ciel ouvert — et les organes peuvent désormais +steerer la bouche (mesuré : **2/4 tokens-réponse steerés vs 0/4 non-steerés**, +voir « La mécanique de la parole »). + +## L'inauguration + +Le cérémonial du takeover, en 7 temps : (1) statut du cerveau de naissance, +(2) ingestion d'un corpus domaine, (3) question exacte -> carte FAIT, +(4) typo -> ANALOGIE, (5) session REPL scriptée où **la conversation nourrit +le brain** (le compteur d'atomes croît, écriture O(1)), (6) la parole du +noyau — une génération MÉCANIQUE RÉELLE du 1B (speak : z-norm ×3, rep-pen 6, +t=1.0, seed 7, 8 tokens BPE) avec steering organique (les cartes dirigent la +bouche), rapportée verbatim, (7) la ligne du relais pris. Autonome : aucun +argument, artefacts dans un tmpdir jetable ; honnête dans chaque python — +sans torch (substrat mingw), le cerveau se dégrade en une ligne d'absence et +les organes portent toute la démo. + +```bash +.venv-torch/Scripts/python.exe demo/inauguration.py +``` + +Run réel du 2026-08-18, mécanique de la parole (venv torch, CPU, ~70 s) — +sortie verbatim : + +```text +[FRACTUS-VORAX] 1/7 noyau de naissance +[FRACTUS-VORAX] cerveau: cte 1B, strict 440/440, tokens natus 15.5M x8 +[FRACTUS-VORAX] 2/7 ingestion de capitals.csv (5 capitales) +[VORAX] +5 atomes ingérés depuis capitals.csv -> expert 'capitals' spawné (total: 5) +[FRACTUS-VORAX] 3/7 question exacte: what is the capital of japan +[CARTE] HEBBIEN: what is the capital of japan => tokyo (sim 1.00 | src: hebbian) +[CARTE] FAIT: what is the capital of japan => tokyo (sim 1.00 | src: capitals.csv:4) +[CARTE] FAIT: what is the capital of spain => madrid (sim 0.71 | src: capitals.csv:3) +[CARTE] FAIT: what is the capital of portugal => lisbon (sim 0.70 | src: capitals.csv:6) +[FRACTUS-VORAX] 4/7 typo volontaire: what is the capital of franc +[CARTE] FAIT: what is the capital of italy => rome (sim 0.69 | src: capitals.csv:5) +[CARTE] FAIT: what is the capital of spain => madrid (sim 0.68 | src: capitals.csv:3) +[CARTE] FAIT: what is the capital of japan => tokyo (sim 0.68 | src: capitals.csv:4) +[CARTE] ANALOGIE: paris (sim 1.00 | src: capitals) +[FRACTUS-VORAX] 5/7 session REPL scriptée (:ingest, :status, question, :status) +[VORAX] +5 atomes ingérés depuis capitals.csv -> expert 'capitals' spawné (total: 5) +[VORAX] brain: 5 atomes, D=2048, seed=0 +[VORAX] organes: 1 experts, 5 relations, 5 écritures hebbiennes +[CARTE] HEBBIEN: what is the capital of spain => madrid (sim 1.00 | src: hebbian) +[CARTE] FAIT: what is the capital of spain => madrid (sim 1.00 | src: capitals.csv:3) +[CARTE] FAIT: what is the capital of japan => tokyo (sim 0.71 | src: capitals.csv:4) +[CARTE] FAIT: what is the capital of italy => rome (sim 0.68 | src: capitals.csv:5) +[VORAX] brain: 6 atomes, D=2048, seed=0 +[VORAX] organes: 1 experts, 5 relations, 5 écritures hebbiennes +[FRACTUS-VORAX] session: 5 -> 6 atomes - l'échange a nourri le brain (écriture O(1), zéro gradient) +[FRACTUS-VORAX] 6/7 la parole du noyau (BPE, 8 tokens, mécanique) +[FRACTUS-VORAX] noyau: " to distributed mistakesporary "ALS currentAvg" +[FRACTUS-VORAX] Le relais est pris. L'entraînement est fini. Il mange. +``` + +**La première parole du noyau de naissance fut seize astérisques** (greedy, +2026-08-18, tenue au registre) : l'attracteur `**`×8. Depuis la mécanique de +la parole, le cérémonial fait parler le 1B autrement : `speak` (z-norm ×3 + +rep-pen 6) **steeré par les organes** — et le PREMIER token émis est `' to'`, +le premier geste BPE vers « tokyo » que la carte FAIT vient d'injecter dans +la bouche. Le reste est de la salade assumée (`mistakesporary`, `ALS`) : +lexique libéré, syntaxe absente — le relais n'en est que plus net : la +compétence linguistique est née une fois et scellée ; la connaissance, elle, +s'écrit — tokyo par FAIT, paris par ANALOGIE, un atome de session par échange. + +## La mécanique de la parole + +Le cerveau est scellé — la parole, elle, est MÉCANIQUE (Plan 6) : +`speak` (`fractus_vorax/model/speak.py`) transforme les logits AVANT +l'échantillonnage : **z-norm ×3** (écrase l'échelle folle des +états-attracteurs, std mesurée ~26 calme / des centaines en attracteur), +**pénalité de répétition 6** (divise le logit de tout token déjà émis), +**bias organique** (les cartes injectent leurs tokens-réponses, +δ après +z-norm), top-k/top-p optionnels, generator seedé. Le ciel ouvert accompagne +la bouche : `diagnostics.py` lit le routage des experts (top-2 par couche, +recalculé depuis les phases von Mises — zéro poids touché) et les têtes +confiance/salience. Dans le REPL : `:speak ` (après `:core`). + +**Run réel du 2026-08-18 — REPL `:speak` sur le 1B scellé** (session +scriptée `Repl`, brain 5 capitales, ~51 s de génération) : + +```text +[VORAX] +5 atomes ingérés depuis p6capitals.csv -> expert 'p6capitals' spawné (total: 5) +[NOYAU] type: cte (1B Continuous Thought Engine) +[NOYAU] noyau chargé: brain\FRACTUS_BIRTH.pt +[PAROLE] cartes: 4 +[PAROLE] steering: ' par' x8, ' r' x2, ' mad' x1 +[PAROLE] "imeo sqor beginnerporary " textbook Goku**" +``` + +Lecture honnête : `beginner`, `textbook` — du LEXIQUE RÉEL anglais là où +greedy produit `**`×8 ; `sqor`, `Goku**` — la syntaxe est absente. Le +steering montre l'échelle organique : GPT-2 coupe « paris » en `' par'`+`'is'` +et « rome » en `' r'`+`'ome'` — ce sont ces PREMIERS gestes que les cartes +injectent (boost 8, décroissant par rang). + +**Run réel — `core_speak --mode mechanic`** (4 capitales, 10 tokens, ~62 s, +sortie verbatim, extrait) : + +```text +[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) +[CORE-SPEAK] ciel ouvert (question 1): + routage experts (top-2/couche, gates renormalisées): + 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 + 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 + 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 + 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 + têtes: confiance 0.978, saillance 0.002 +--- question 1/4: what is the capital of france + greedy (attracteur) -> has + mécanique non-steerée -> has Imgie oneaw Platinumaney** + iTunes +--- question 2/4: what is the capital of spain + greedy (attracteur) -> AlternAlternAlternAlternAlternAlternAlternAlternAlternAltern + mécanique non-steerée -> scanners examplesthroughORD UNCLASSIFIED),aney** Formernaut +--- question 3/4: what is the capital of japan + greedy (attracteur) -> apan + mécanique non-steerée -> ELECT examples Months advanced**ALSJoAvg + iTunes +--- question 4/4: what is the capital of italy + greedy (attracteur) -> one one one one one one one one one one + mécanique non-steerée -> scanners examples archaeologicalfooted dir decayinganey** Form like +``` + +Le contraste est le résultat : attracteurs (`Altern`×10, ` one`×10) vs +`scanners`, `examples`, `UNCLASSIFIED`, `archaeological`, `decaying`, +`Months advanced` — le lexique est là, la syntaxe non. Ciel ouvert honnête : +à κ_eff = 1.6 les gates von Mises du 1B sont quasi-plates, le top-2 +renormalisé sort **0.50/0.50 à chaque couche** (paires d'experts adjacents) — +le routage réel de CE checkpoint est un quasi-tie, c'est la mesure, pas un +artefact du lecteur. Têtes : confiance 0.978, saillance 0.002. + +**Run réel — `core_speak --mode steered --brain` (le money shot)** (4 +capitales, 10 tokens, même seed 7 pour la paire steerée/non-steerée, ~11 min, +sortie verbatim, extrait) : + +```text +--- question 1/4: what is the capital of france + attendu (contexte du top FAIT): paris (token BPE 1582 ' par') + greedy (attracteur) -> is is is is is is is is is is + mécanique non-steerée -> imeo sqor beginnerporary " textbook Goku** Mer cp + steering organique: ' par' x8, ' r' x2, ' mad' x1 + mécanique steerée -> imeo sqor beginnerporary " textbook Goku** Mer cp + token-réponse ' par' émis: non-steerée NON | steerée NON +--- question 3/4: what is the capital of japan + attendu (contexte du top FAIT): tokyo (token BPE 284 ' to') + greedy (attracteur) -> ******************** + mécanique non-steerée -> ** development Months:awALS currentAvg + feedback + steering organique: ' to' x8, ' mad' x2, ' l' x1 + mécanique steerée -> to distributed mistakesporary "ALS currentAvg + feedback + token-réponse ' to' émis: non-steerée NON | steerée OUI +--- question 4/4: what is the capital of italy + attendu (contexte du top FAIT): rome (token BPE 374 ' r') + steering organique: ' r' x8, ' l' x2, ' mad' x1 + mécanique steerée -> ** Humans Months: Senatorsclerosis None + r cp + token-réponse ' r' émis: non-steerée NON | steerée OUI +[CORE-SPEAK] token-réponse mécanique non-steerée: 0/4 = 0.00 +[CORE-SPEAK] token-réponse mécanique steerée: 2/4 = 0.50 +[CORE-SPEAK] verdict honnête: answers present +``` + +**Le steering organique est MESURÉ : 2/4 vs 0/4.** Sans biais, aucune +question ne voit son token-réponse émis ; avec le biais des cartes (boost 8), +japon émet `' to'` (le premier geste vers tokyo, dès le premier token) et +italie émet `' r'` (vers rome) — le même flux aléatoire, seul le biais +organique change (comparaison appariée, même seed). Lecture honnête des deux +autres : france et spain produisent une sortie IDENTIQUE à la non-steerée — +un boost de 8 (≈ 2.7σ contre la z-norm) déplace la distribution mais ne +garantit pas le tirage ; et le token placé n'est que le PREMIER fragment BPE +de la réponse (`' to'` ≠ « tokyo »). Le verdict « answers present » dit +exactement cela : les organes dirigent le premier geste de la bouche, la +syntaxe reste à venir — par la mécanique, jamais par le gradient. + +## Ce soir, le noyau parle + +Le verrou de réponse (Plan 7) : **les organes articulent à travers la bouche +du noyau.** `Brain.speak_answer` livre la réponse ENTIERE en tokens BPE +(`" tokyo"` = `' to'`+`'ky'`+`'o'`), et `speak(..., answer_lock=...)` pose ++boost sur `answer_lock[i]` à l'étape i — APRÈS z-norm/rep-pen, AVANT +top-k/p, même place dans le contrat que le bias organique. La réponse est +placée token par token À TRAVERS la distribution du noyau ; le lexique, les +transitions et la SUITE de la phrase restent ceux du 1B. Dans le REPL : +`:say ` — deux lignes, `[ORGANES]` (la carte qui connaît) puis +`[PAROLE]` (le prompt d'articulation `"{question} The answer is"` + 12 +tokens générés, rep-pen 6, z-norm ×3, t=1.0, seed 7). + +**Le verrou est LE mécanisme, affiché comme tel — pas de la triche cachée.** +Calibrage honnête par mesure : le plan visait un boost de 12 (« ≈ 4σ »), +mais la mesure sur le 1B montre le token verrouillé vivre à −1σ (`z[' par']` += −3.0) quand le top de la distribution z-normée est à +4σ (zmax ≈ 11.9) — ++12 perd le tirage pour paris/madrid (2/4 capitales). **Boost 30** (défaut, +mesuré dans `speak.py`) : dominance même à z[locked] = −6σ contre zmax 12 ET +la masse de queue complète, échec par pas < 0.1%, borné, jamais de sommation. +Le test `test_answer_lock_boost_twelve_is_honestly_weak` fige cette frontière. + +**Run réel du 2026-08-18 — `demo/ce_soir.py` sur le 1B scellé** (venv torch, +CPU, 4 capitales verrouillées + 2 libres, 72 tokens générés, ~1 min 16 s +horloge) — sortie verbatim : + +```text +[FRACTUS-VORAX] 1/4 statut - organes et noyau +[VORAX] +5 atomes ingérés depuis capitals.csv -> expert 'capitals' spawné (total: 5) +[VORAX] brain: 5 atomes, D=2048, seed=0 +[VORAX] organes: 1 experts, 5 relations, 5 écritures hebbiennes +[NOYAU] type: cte (1B Continuous Thought Engine) +[NOYAU] noyau chargé: C:\Users\PHIL\ZCodeProject\fractus-vorax\brain\FRACTUS_BIRTH.pt +[FRACTUS-VORAX] 2/4 les quatre capitales - :say (verrou de réponse, 12 tokens) +[ORGANES] HEBBIEN: what is the capital of france => paris (sim 1.00) +[PAROLE] what is the capital of france The answer is paris Months; +), attributes** FormorporDEC Go +[ORGANES] HEBBIEN: what is the capital of spain => madrid (sim 1.00) +[PAROLE] what is the capital of spain The answer is madridcre construction broom mart attributes** +130 constructed of +[ORGANES] HEBBIEN: what is the capital of japan => tokyo (sim 1.00) +[PAROLE] what is the capital of japan The answer is tokyo Documentation ofALS** playable +orporDEC Concern +[ORGANES] HEBBIEN: what is the capital of italy => rome (sim 1.00) +[PAROLE] what is the capital of italy The answer is romegie Industrialendo Reaper None** FormorporDEC mar +[FRACTUS-VORAX] 3/4 libre parole mécanique - 2 échantillons sans verrou +[FRACTUS-VORAX] "The meaning of life is�etr): Ré Works single slicing point Devin、conn" +[FRACTUS-VORAX] "In the beginning there was- distinguish) MontgomeryEB, Permanent encourages + many、 Her" +[FRACTUS-VORAX] Le noyau parle. Les organes savent. Zéro gradient. +``` + +Lecture honnête : **les 4 capitales sont répondues dans la génération du +1B** — paris et tokyo propres (` paris Months`, ` tokyo Documentation`), +madrid et rome verrouillés jusqu'au dernier fragment puis COLLÉS au token +libre suivant (`madridcre`, `romegie` : `' mad'`+`'rid'`+`'cre'`, +`' r'`+`'ome'`+`'gie'` — le BPE n'exige pas d'espace, la suite est libre et +ne sait pas qu'un mot vient de finir). Chaque token verrouillé a été émis à +son pas, 100 % du verrou. La carte `[ORGANES]` devant chaque parole dit QUI +savait (HEBBIEN corrobore, sim 1.00) : le noyau fournit la bouche et le +lexique, les organes fournissent la réponse — le mécanisme est la phrase +elle-même, rien n'est caché. Et la libre parole sans verrou, juste après, +montre l'autre face : `Works single slicing point`, `distinguish)`, +`MontgomeryEB` — le lexique réel, la syntaxe absente. Le noyau ne savait +rien : les organes savaient, la bouche était mécanique. Zéro gradient. + +## Killer bench — chiffres honnêtes (`--rows 200`, D=8192) + +| mesure | valeur | +|---|---| +| précision held-out — paraphrases + typos (jamais ingérées verbatim) | **0.99** (1.00 / 0.98) | +| contrôle — faits jamais ingérés | **0.00** (il se souvient, il ne devine pas) | +| ablation CARTES OFF (sans organes) | **0.00** (le plancher honnête) | + +Sans ses organes, le substrat ne répond rien : cartes ON 0.99 vs cartes OFF +0.00 — c'est le contraste killer. + +## Le contrat sacré + +1. Aucun gradient, jamais, pour apprendre quoi que ce soit. +2. Les artefacts sont déterministes et bit-identiques (même source → même `.kn`). +3. La mémoire est append-only : on superpose, on n'écrase pas. +4. CPU d'abord ; le GPU est une option, pas une exigence. +5. Zéro LLM externe, zéro GGUF — le noyau est fractus ou rien. + +## Architecture du repo + +``` +fractus_vorax/ + hv.py compiler/ (parse, atoms, encode, kn, analogy) substrat numpy pur + organs/ (traces, hebbian, spawn, consolidation) écriture O(1), jamais gradient + brain.py cards.py l'être persistant et ses cartes + model/ (fractus_core, cte_core, bpe_tokenizer, vocal, + speak, diagnostics) SEULE zone torch+tokenizers (gate) + agent/ (cli, repl — :core, :speak, :say) il mange, il répond, il parle +bench/ (killer_bench, ingest_bench, core_speak) demo/ les preuves +docs/heritage/ specs + plans vorax, archives historiques +ATTRIBUTIONS.md la lignée, nommément +``` + +Design hérité : `docs/heritage/specs/2026-08-17-vorax-design.md` +Plans hérités : `docs/heritage/plans/` (P1-P5) +Plan du relais : `docs/superpowers/plans/2026-08-18-fractus-vorax-relais.md` +Plan 6 (la mécanique de la parole) : `docs/superpowers/plans/2026-08-18-p6-mecanique-parole.md` diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..85aef7c4ddfaceaaaad9d8e150972a40374f68f3 --- /dev/null +++ b/README.md @@ -0,0 +1,152 @@ +# Fractus-Vorax + +**The Fractus that never trains again. It eats.** + +**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. + +> Le manifeste complet (français) : [`README.fr.md`](README.fr.md) — the founding takeover document. + +--- + +## The Pact + +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). + +``` +This brain will NEVER be retrained. +No gradient will ever touch its weights. +All new knowledge arrives by ingestion. +Training stops here. +``` + +## Quick Start + +```bash +git clone https://huggingface.co/thefinalboss/fractus-vorax # or local copy +cd fractus-vorax + +# Substrate venv (numpy-only, CPU, no torch needed for the organs): +# any Python ≥3.10 with numpy + pytest — the full substrate suite runs. + +# Full-stack venv (adds the native CTE/Fractal kernels — torch CPU): +py -3.11 -m venv .venv-torch +.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 tokenizers numpy pytest + +# Fetch the sealed birth brain (4.66 GB — lives on the fractus-cte repo): +.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')" +mv checkpoints/checkpoints/FRACTUS_1B_PHASE2_FROZEN_MERGED.pt brain/FRACTUS_BIRTH.pt # (mkdir brain first) + +# Tests (both environments, honestly counted): +.venv-torch/Scripts/python.exe -m pytest -q # 199 passed (full stack) + +# Feed it something, then talk to it: +.venv-torch/Scripts/python.exe -m fractus_vorax.agent.repl --brain ./brain +fractus_vorax> :ingest my_data.csv +fractus_vorax> :core brain/FRACTUS_BIRTH.pt +fractus_vorax> :say what is the capital of japan # the 1B answers, out of its own mouth +``` + +## What is Fractus-Vorax? + +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: + +- **Fractus-cte** trains the brain (8 GPUs, mean-merged hourly, sealed at the end). +- **Fractus-Vorax** refuses to ever train it again — and makes it *know things anyway*. + +### What makes it different from GPT/RAG? + +| | GPT-style | Fractus-Vorax | +|---|---|---| +| New knowledge | retrain / fine-tune / context window | **compiled to `.kn` and written** into organs, O(1) per atom, permanent | +| Forgetting | catastrophic | append-only memory: it cannot forget | +| Unseen data | hallucinates confidently | **answers 0.00 on facts it never ate** (measured floor) | +| Generalization | emergent from gradients | analogy (3CosAdd/3CosMul over char-ngram slots) — morphological, measured | +| Growth | bigger training run | each dataset **spawns a routed expert** — physical growth, no joint training | +| Speaking | the model speaks | **mechanics speak**: anti-attractor decoding + organ steering on a sealed brain | +| Hardware | datacenter | laptop CPU (kernels optional, torch CPU) | + +## Architecture + +``` +DATA (csv/json/jsonl/txt/md/anything) + │ one pass, closed forms (hash, counting, SVD) — compilation, not optimization + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ KNOWLEDGE COMPILER (.kn) — deterministic, bit-identical │ +└──────────────┬───────────────┬───────────────┬───────────────┘ + ▼ ▼ ▼ + ORGAN 1 · TRACES ORGAN 2 · HEBBIAN ORGAN 3 · SPAWN + hippocampus: cortex: closed- growth: one expert + append-only HV form outer-product per dataset, routed + memory + LSH-style writes, ΔE gate by HV signature + retrieval refuses degradation (physical MoE growth) + │ │ │ + ▼ ▼ ▼ + CARDS (FACT / HEBBIAN / ANALOGY / GAP) — the organ output + │ + ▼ + SEALED CTE BRAIN (1.165B params, 440/440 strict, read-only mmap) + + SPEAK: z-norm anti-attractor decoding, repetition penalty, + answer-lock steering (the organs articulate THROUGH the core) + │ + ▼ + The conversation itself is written back O(1) — it learns as you talk. +``` + +**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. + +## The Mechanics of Speech (honest) + +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: + +1. **Z-normalization of logits** — crushes the attractor's runaway scale (measured std ~26 calm, hundreds in-loop). +2. **Repetition penalty** — breaks self-reinforcement; vocabulary is liberated (`philosophy`, `manufactures`, `archaeological`, `UNCLASSIFIED`… verbatim in the README.fr / reports). +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. + +**Measured (real 1B, verbatim, paired seeds):** +- 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). +- First-token steering (soft bias, no lock): 2/4 vs 0/4 unsteered. +- Free speech: real English vocabulary, **syntax absent** at this training depth. That gap belongs to the brain's nascence, not to the mechanics. +- 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. + +## Benchmarks (honest floors included) + +| Measure | Result | +|---|---| +| Held-out paraphrases (never-seen queries of eaten facts) | **1.00** | +| Held-out typos (morphologically novel slots) | **0.98** | +| Control: facts never ingested | **0.00** — it does not guess | +| Floor: cards disabled | **0.00** — the organs are the entire effect | +| Ingestion | one pass, ~1.1k atoms/s compile, CPU | +| Query latency | ~7 ms (organs), CPU | +| Gradients used, total, since birth | **0** | + +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}`. + +## Research Results (Honest) + +**Validated:** +- Training-free expertise: ingest → 0.99 held-out accuracy, zero gradient (killer bench, floors included). +- Morphological generalization: typo→answer via 3CosMul over char-ngram slots (ANALOGY cards, sim 1.00 on real typos). +- Hebbian closed-form writes with a ΔE gate: degrading writes refused and rolled back (measured), corroboration cards at sim 1.00. +- Physical growth: per-dataset expert spawn + signature routing, no joint training. +- 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). +- Mechanical speech unlock: anti-attractor decoding liberates the sealed brain's vocabulary; answer-lock yields 4/4 articulated answers. +- Determinism as an invariant: same source → bit-identical `.kn`; same seeds → same words. + +**Honest limits:** +- 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. +- 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. +- Answer-lock articulates what the organs know; it is displayed as a mechanism (`[ORGANES]` line before every `[PAROLE]` line), never hidden in the output. +- Steering boosts shift distributions; they do not guarantee the draw (2/4 vs 0/4 first-token, measured with paired seeds). + +## Lineage + +`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). + +**No corporation can control it.** CPU-first, no external LLM, no API, weights read-only, knowledge portable as `.kn` files. + +--- + +*Fractus was born once. Fractus-Vorax never lets it train again — it only eats.* diff --git a/bench/core_speak.py b/bench/core_speak.py new file mode 100644 index 0000000000000000000000000000000000000000..b8015e666bdc13261e7acf6b50a277d359779485 --- /dev/null +++ b/bench/core_speak.py @@ -0,0 +1,585 @@ +# bench/core_speak.py +"""L'évaluation honnête du noyau réel : que dit VRAIMENT le checkpoint ? + +``run_core_speak`` charge le vrai checkpoint (chargement STRICT) — l'espèce +est sondée par ``probe_checkpoint_kind`` : CteCore (CTE 1B « Continuous +Thought Engine », clés ``observe.weight``+``thought_state``) ou FractusCore +(``embed.tok_embed.weight``) — puis évalue selon le ``--mode`` (v2) : + + - ``greedy`` (v1, inchangé) : pour chaque question, DEUX générations + greedy avec le vrai tokenizer BPE GPT-2 (vocab 50257, eos 50256) : + « sans cartes » (prompt nu ``Q: {question}``) et « avec cartes » + (préfixe protocolaire ``serialize_cards(question, cards)`` — cartes du + brain si ``brain`` est fourni, cartes vides sinon) ; + - ``mechanic`` : la parole MÉCANIQUE (``speak`` : z-norm ×3, rep-pen 6, + t=1.0, seed ``--seed``) sur le préfixe cartes, CÔTE À CÔTE avec la + baseline greedy avec cartes (l'attracteur, pour le contraste verbatim) ; + - ``steered`` : ``mechanic`` + steering organique (``brain.speak_bias`` : + les cartes injectent les tokens-réponses dans la bouche) — les TROIS + générations côte à côte (greedy attracteur / mécanique non-steerée / + mécanique steerée, MÊME seed) + le taux de tokens-réponse steerée vs + non-steerée. Exige ``--brain`` (pas d'organes, pas de steering). + +En modes ``mechanic``/``steered``, le ciel ouvert accompagne la parole : +routage des experts (top-2 par couche, renormalisé — +``expert_routing_snapshot``) et lecture des têtes confiance/salience +(``head_readout``) sur la PREMIÈRE question. + +HONNÊTETÉ EST LE PRODUIT : + - chaque génération est rapportée VERBATIM (aucun nettoyage, aucun + cherry-picking, aucun lissage) ; + - la réponse attendue = le contexte de la PREMIÈRE carte FAIT (rien + d'autre) ; sans brain ou sans carte FAIT -> aucun attendu -> taux 0 + PAR CONSTRUCTION ; + - le token-réponse attendu = le PREMIER token BPE de ``" {réponse}"`` + (même convention GPT-2 que ``Brain.speak_bias``) ; le taux compte les + questions où CE token exact apparaît dans les tokens émis — et si le + taux reste 0 le verdict est ``word salad`` : le checkpoint actuel + produit de la salade de mots, c'est la mesure honnête du harnais, pas + un échec du harnais. + +FENÊTRE max_seq_len = 16 (ce checkpoint) : en BPE, préfixe cartes + +question sont tronqués PAR LA GAUCHE (seuls les 16 derniers tokens du +prompt sont vus par le noyau) — la vraie contrainte de CE checkpoint, +imprimée dans l'en-tête du rapport. + +GATE torch : bench/ est hors du scan de tests/test_gates.py, mais les +imports lourds (torch, tokenizers) restent PARESSEUX, à l'intérieur des +fonctions — importer bench.core_speak ne lève jamais dans un python nu. + +Sans torch / sans tokenizers / checkpoint absent ou corrompu / brain +invalide / mode steered sans brain -> message propre et exit 1 (jamais +de traceback). +""" +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +# exécution directe ``python bench/core_speak.py`` : racine du repo sur +# sys.path (conftest.py fait de même pour pytest ; le venv torch n'a pas +# fractus_vorax pip-installé et c'est LE python où le checkpoint est pertinent). +_REPO_ROOT = str(Path(__file__).resolve().parent.parent) +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) + +from fractus_vorax.model.vocal import safe_decode_text, serialize_cards # noqa: E402 (lazy-safe, sans torch) + +# Les 4 capitales canoniques du projet (demo/demo.py) — les questions par +# défaut de l'évaluation. Le pool est volontairement minuscule : on ne +# mesure pas une moyenne statistique, on montre VERBATIM ce que dit le +# noyau sur des questions élémentaires. +DEFAULT_QUESTIONS = [ + "what is the capital of france", + "what is the capital of spain", + "what is the capital of japan", + "what is the capital of italy", +] + +# Étiquettes honnêtes par espèce sondée (repl :core affiche les mêmes). +CORE_TYPE_LABELS = { + "cte": "cte (1B Continuous Thought Engine)", + "fractus": "fractus", +} + +MODES = ("greedy", "mechanic", "steered") + +# La recette mécanique du ledger 2026-08-18 (ratifiée P6) — les mêmes +# constantes vivent dans speak() par défaut ; ici elles sont explicites. +MECHANIC_REP_PEN = 6.0 +MECHANIC_Z_SCALE = 3.0 +MECHANIC_TEMPERATURE = 1.0 +DEFAULT_SEED = 7 + + +# --------------------------------------------------------------------------- +# Réponse attendue : contexte du top FAIT +# --------------------------------------------------------------------------- +def _top_fact_context(cards: list) -> str | None: + """Contexte de la PREMIÈRE carte FAIT (la réponse attendue), sinon None. + + ``Brain.ask_cards`` formate les cartes FAIT ``{énoncé} => {contexte}`` : + le contexte est la partie après « => ». Une carte FAIT sans contexte ne + porte aucune réponse -> None (honnête : on ne devine pas, on ne dérive + pas l'attendu de la question). Les cartes HEBBIEN/ANALOGIE/LACUNE ne + comptent pas : le contrat est le top FAIT, rien d'autre. + """ + for card in cards: + if card.kind == "FAIT": + if " => " in card.text: + return card.text.split(" => ", 1)[1].strip() + return None + return None + + +def _answer_token_hit(diags: list[dict], target_text: str | None) -> bool: + """True si le token-réponse attendu apparaît PARMI LES TOKENS ÉMIS. + + Comparaison exacte token-à-token : ``diags[i]["chosen"]`` est le token + émis au pas i (décodé seul) — pas une recherche de sous-chaîne dans le + texte final (le décodage multi-tokens pourrait fusionner/mangler) : + c'est la mesure TOKEN honnête, alignée sur ce que ``speak_bias`` injecte. + ``target_text None`` (aucun attendu FAIT) -> False par construction. + """ + if target_text is None: + return False + return any(d["chosen"] == target_text for d in diags) + + +# --------------------------------------------------------------------------- +# Le harnais +# --------------------------------------------------------------------------- +def run_core_speak( + ckpt_path: Path, + questions: list[str], + brain: Path | None, + max_new_tokens: int = 24, + mode: str = "greedy", + seed: int = DEFAULT_SEED, +) -> dict: + """Évalue le noyau réel — sorties verbatim, taux honnête, verdict. + + ``mode`` (v2) : + - ``"greedy"`` (v1 inchangé) : sans cartes + avec cartes (greedy). + - ``"mechanic"`` : baseline greedy avec cartes + parole mécanique + ``speak`` (recette du ledger : rep_pen 6, z_scale 3, t=1.0, seed). + - ``"steered"`` (exige ``brain``) : mécanique non-steerée + mécanique + steerée (``speak_bias``), même seed — la comparaison APPARIÉE (même + flux aléatoire, seul le biais organique change), + le taux de + tokens-réponse des deux, + la baseline greedy. + + Pour chaque question, ``cards`` = ``brain.ask_cards(question)`` si + ``brain`` est fourni, cartes vides sinon. Le taux greedy (v1) compte + l'attendu en SOUS-CHAÎNE de la sortie avec cartes ; les taux mécaniques + (v2) comptent le PREMIER token BPE de ``" {attendu}"`` parmi les tokens + émis (mesure token, cf. ``_answer_token_hit``). + + Retourne ``{"ckpt", "core_type", "mode", "seed", "brain", "n_brain_atoms", + "max_new_tokens", "max_seq_len", "tokenizer_source", "vocab_size", + "eos_token_id", "runs": [...], "n_with_expected", "routing", "routing_ids", + "head_readout", "with_cards_answer_rate", "answer_token_rate_mechanic", + "answer_token_rate_steered", "verdict"}`` avec, par run, les clés du mode + (``no_cards_output``/``with_cards_output`` en greedy ; ``greedy_output``, + ``mechanic_output``, ``mechanic_diags`` ; ``steered_output``, + ``steered_diags``, ``bias_tokens``, ``answer_token``, et les hits booléens). + """ + if mode not in MODES: + raise ValueError(f"mode inconnu: {mode!r} (modes: {', '.join(MODES)})") + + # Imports lourds PARESSEUX (philosophie gate : jamais au niveau module). + from fractus_vorax.brain import Brain + from fractus_vorax.model.bpe_tokenizer import Gpt2BpeTokenizer + from fractus_vorax.model.cte_core import CteCore, probe_checkpoint_kind + from fractus_vorax.model.fractus_core import FractusCore + from fractus_vorax.model.vocal import verbalize + + ckpt_path = Path(ckpt_path) + loaded = Brain.load(Path(brain)) if brain is not None else None + if mode == "steered" and loaded is None: + raise ValueError("--mode steered exige --brain (steering organique)") + + # Sonde d'espèce puis classe adéquate — les noyaux partagent la + # signature generate(token_ids, max_new_tokens, temperature, + # eos_token_id, seed) : tout le reste du harnais est duck-typé. + kind = probe_checkpoint_kind(ckpt_path) + if kind == "cte": + core = CteCore.from_checkpoint(ckpt_path) # strict, jamais False + elif kind == "fractus": + core = FractusCore.from_checkpoint(ckpt_path) # strict, jamais False + else: + raise ValueError( + f"type de checkpoint inconnu: {ckpt_path} (ni cte ni fractus)" + ) + tokenizer = Gpt2BpeTokenizer() # vrai BPE : vocab 50257, eos 50256 + max_seq_len = int(getattr(core.config, "max_seq_len", 256)) + eos = int(tokenizer.eos_token_id) + + def _speak(prompt: str, bias_tokens=None): + from fractus_vorax.model.speak import speak # tardif : gate torch + + return speak( + core, + tokenizer, + prompt, + max_new_tokens=max_new_tokens, + temperature=MECHANIC_TEMPERATURE, + rep_pen=MECHANIC_REP_PEN, + z_scale=MECHANIC_Z_SCALE, + seed=seed, + eos_token_id=eos, + bias_tokens=bias_tokens, + ) + + runs = [] + n_with_expected = 0 + for question in questions: + cards = loaded.ask_cards(question) if loaded is not None else [] + prefix = serialize_cards(question, cards) + expected = _top_fact_context(cards) + if expected is not None: + n_with_expected += 1 + # Token-réponse attendu (convention GPT-2 de speak_bias : espace + # initial partie du token) — None sans attendu FAIT. + answer_token = ( + int(tokenizer.encode(" " + expected)[0]) + if expected is not None and tokenizer.encode(" " + expected) + else None + ) + answer_token_text = ( + safe_decode_text(tokenizer.decode([answer_token])) + if answer_token is not None + else None + ) + run = { + "question": question, + "cards_prefix": prefix, + "expected_answer": expected, + "answer_token": answer_token, + "answer_token_text": answer_token_text, + # honnêteté : le préfixe sérialisé dépasse-t-il la fenêtre ? + "prompt_truncated": len(tokenizer.encode(prefix)) > max_seq_len, + } + if mode == "greedy": + run["no_cards_output"] = verbalize( + core, question, [], tokenizer, max_new_tokens=max_new_tokens + ) + run["with_cards_output"] = verbalize( + core, question, cards, tokenizer, max_new_tokens=max_new_tokens + ) + run["answer_in_output"] = bool( + expected is not None and expected in run["with_cards_output"] + ) + else: # mechanic | steered — la mécanique, avec sa baseline greedy + run["greedy_output"] = verbalize( + core, question, cards, tokenizer, max_new_tokens=max_new_tokens + ) + mech_text, mech_diags = _speak(prefix) + run["mechanic_output"] = mech_text + run["mechanic_diags"] = mech_diags + run["mechanic_answer_token"] = _answer_token_hit( + mech_diags, answer_token_text + ) + if mode == "steered": + bias = loaded.speak_bias(question, tokenizer, k=3) + steered_text, steered_diags = _speak(prefix, bias_tokens=bias) + run["bias_tokens"] = bias + # représentation lisible du steering (décodée, VERBATIM) : + # ce que les organes ont injecté dans la bouche, rang par rang. + run["bias_decoded"] = [ + (safe_decode_text(tokenizer.decode([tid])), float(w)) + for tid, w in sorted(bias.items(), key=lambda kv: -kv[1]) + ] + run["steered_output"] = steered_text + run["steered_diags"] = steered_diags + run["steered_answer_token"] = _answer_token_hit( + steered_diags, answer_token_text + ) + runs.append(run) + + # Ciel ouvert sur la PREMIÈRE question (modes mécaniques) : les ids du + # prompt tels que speak les voit (fenêtre gauche), routage + têtes. + routing: dict | None = None + readout: dict | None = None + if mode in ("mechanic", "steered") and runs: + from fractus_vorax.model.diagnostics import ( # tardif : gate torch + expert_routing_snapshot, + head_readout, + ) + + first_ids = tokenizer.encode(runs[0]["cards_prefix"]) + if len(first_ids) > max_seq_len: + first_ids = first_ids[-max_seq_len:] + routing = expert_routing_snapshot(core, first_ids) + readout = head_readout(core, first_ids) + + def _rate(key: str) -> float: + hits = [r for r in runs if r.get(key)] + return len(hits) / len(runs) if runs else 0.0 + + with_cards_rate = _rate("answer_in_output") + mechanic_rate = ( + _rate("mechanic_answer_token") if mode in ("mechanic", "steered") else None + ) + steered_rate = _rate("steered_answer_token") if mode == "steered" else None + if mode == "greedy": + verdict_rate, verdict = with_cards_rate, ( + "word salad" if with_cards_rate == 0.0 else "answers present" + ) + elif mode == "steered": + verdict_rate = steered_rate + verdict = "word salad" if steered_rate == 0.0 else "answers present" + else: # mechanic : la mécanique libère le lexique, pas (encore) les + # réponses — le taux token est rapporté tel quel, sans verdict + # réponses (l'attente honnête du mode mechanic est le LEXIQUE). + verdict_rate, verdict = mechanic_rate, "lexical run (no answer verdict)" + + return { + "ckpt": str(ckpt_path), + "core_type": kind, + "mode": mode, + "seed": int(seed), + "brain": str(brain) if brain is not None else None, + "n_brain_atoms": len(loaded) if loaded is not None else 0, + "max_new_tokens": int(max_new_tokens), + "max_seq_len": max_seq_len, + "tokenizer_source": tokenizer.source, + "vocab_size": int(tokenizer.vocab_size), + "eos_token_id": eos, + "runs": runs, + "n_with_expected": n_with_expected, + "routing": routing, + "head_readout": readout, + "with_cards_answer_rate": float(with_cards_rate), + "answer_token_rate_mechanic": ( + None if mechanic_rate is None else float(mechanic_rate) + ), + "answer_token_rate_steered": ( + None if steered_rate is None else float(steered_rate) + ), + "verdict_rate": float(verdict_rate), + "verdict": verdict, + } + + +# --------------------------------------------------------------------------- +# Impression console-safe +# --------------------------------------------------------------------------- +def _console_safe(line: str) -> str: + """« -> » au lieu de la flèche Unicode, « - » au lieu de l'em-dash (cp1252-hostiles).""" + return line.replace("\u2192", "->").replace("\u2014", "-") + + +def _print_safe(line: str) -> None: + """Print qui ne lève JAMAIS (repli errors=replace sur console exotique).""" + safe = _console_safe(line) + try: + print(safe) + except UnicodeEncodeError: # pragma: no cover - console au codepage hostile + enc = getattr(sys.stdout, "encoding", None) or "utf-8" + print(safe.encode(enc, errors="replace").decode(enc, errors="replace")) + + +def _print_prefix(prefix: str) -> None: + _print_safe(" préfixe cartes (sérialisé, avant troncature BPE):") + for line in prefix.splitlines() or [""]: + _print_safe(f" {line}") + + +def _print_expected(run: dict) -> None: + expected = run["expected_answer"] + if expected is not None: + tok = run.get("answer_token_text") + tok_line = f" (token BPE {run['answer_token']:>5} {tok!r})" if tok else "" + _print_safe(f" attendu (contexte du top FAIT): {expected}{tok_line}") + else: + _print_safe(" attendu (contexte du top FAIT): (aucun - pas de carte FAIT)") + + +def _print_routing(routing: dict | None, readout: dict | None) -> None: + """Le ciel ouvert, compact : top-2 experts par couche + têtes.""" + if routing is None and readout is None: + return + _print_safe("[CORE-SPEAK] ciel ouvert (question 1):") + if routing: + layers = sorted(routing.keys(), key=lambda k: int(k.split("_")[1])) + chunks = [layers[i : i + 4] for i in range(0, len(layers), 4)] + _print_safe(" routage experts (top-2/couche, gates renormalisées):") + for chunk in chunks: + cells = [] + for key in chunk: + entries = " ".join(f"e{e} {w:.2f}" for e, w in routing[key]) + cells.append(f"{key.replace('layer_', 'L')}: {entries}") + _print_safe(" " + " | ".join(cells)) + if readout is not None: + conf, sal = readout.get("confidence"), readout.get("salience") + if conf is None and sal is None: + _print_safe(" têtes: (aucune - noyau sans têtes confiance/salience)") + else: + _print_safe(f" têtes: confiance {conf:.3f}, saillance {sal:.3f}") + + +def _print_report(result: dict) -> None: + """Le rapport honnête : en-tête + générations VERBATIM + verdict.""" + runs = result["runs"] + n = len(runs) + mode = result["mode"] + _print_safe(f"[CORE-SPEAK] checkpoint: {result['ckpt']}") + _print_safe( + f"[CORE-SPEAK] type de noyau: " + f"{CORE_TYPE_LABELS.get(result['core_type'], result['core_type'])}" + ) + _print_safe( + f"[CORE-SPEAK] tokenizer BPE: vocab {result['vocab_size']}, " + f"eos {result['eos_token_id']} ({result['tokenizer_source']})" + ) + _print_safe( + f"[CORE-SPEAK] fenêtre: {result['max_seq_len']} tokens BPE - " + "préfixe cartes tronqué à gauche (seuls les derniers tokens du " + "prompt sont vus par le noyau)" + ) + if result["brain"] is None: + _print_safe("[CORE-SPEAK] brain: aucun - cartes vides pour les deux runs") + else: + _print_safe( + f"[CORE-SPEAK] brain: {result['brain']} ({result['n_brain_atoms']} atomes)" + ) + if mode == "greedy": + _print_safe( + f"[CORE-SPEAK] mode: greedy - max_new_tokens {result['max_new_tokens']}, " + "greedy déterministe (temperature 0)" + ) + else: + steer_note = ( + " + steering organique (speak_bias, boost 8)" if mode == "steered" else "" + ) + _print_safe( + f"[CORE-SPEAK] mode: {mode} - speak mécanique (z-norm x{MECHANIC_Z_SCALE:g}, " + f"rep-pen {MECHANIC_REP_PEN:g}, t={MECHANIC_TEMPERATURE:g}, seed {result['seed']})" + f"{steer_note}, max_new_tokens {result['max_new_tokens']}, " + "+ baseline greedy avec cartes (l'attracteur, pour le contraste)" + ) + n_trunc = sum(1 for r in runs if r["prompt_truncated"]) + _print_safe(f"[CORE-SPEAK] prompts tronqués par la fenêtre: {n_trunc}/{n}") + + _print_routing(result.get("routing"), result.get("head_readout")) + + for i, run in enumerate(runs, 1): + _print_safe(f"--- question {i}/{n}: {run['question']}") + _print_prefix(run["cards_prefix"]) + _print_expected(run) + # VERBATIM : la sortie est imprimée telle quelle, multi-lignes comprises. + if mode == "greedy": + _print_safe(f" sans cartes -> {run['no_cards_output']}") + _print_safe(f" avec cartes -> {run['with_cards_output']}") + ok = "OUI" if run["answer_in_output"] else "NON" + _print_safe(f" réponse attendue dans la sortie avec cartes: {ok}") + else: + _print_safe(f" greedy (attracteur) -> {run['greedy_output']}") + _print_safe(f" mécanique non-steerée -> {run['mechanic_output']}") + if mode == "steered": + pairs = run.get("bias_decoded") or [] + if pairs: + steering = ", ".join(f"{txt!r} x{w:g}" for txt, w in pairs) + _print_safe(f" steering organique: {steering}") + else: + _print_safe(" steering organique: (vide - pas de carte-réponse)") + _print_safe(f" mécanique steerée -> {run['steered_output']}") + target = run.get("answer_token_text") + m_hit = "OUI" if run.get("mechanic_answer_token") else "NON" + s_hit = "OUI" if run.get("steered_answer_token") else "NON" + if target is not None: + _print_safe( + f" token-réponse {target!r} émis: non-steerée {m_hit} | steerée {s_hit}" + ) + else: + _print_safe( + " token-réponse: (aucun attendu FAIT - pas de mesure)" + ) + + if mode == "greedy": + n_ok = sum(1 for r in runs if r["answer_in_output"]) + rate = result["with_cards_answer_rate"] + _print_safe(f"[CORE-SPEAK] taux avec cartes: {n_ok}/{n} = {rate:.2f}") + else: + m_rate = result["answer_token_rate_mechanic"] + n_m = sum(1 for r in runs if r.get("mechanic_answer_token")) + _print_safe(f"[CORE-SPEAK] token-réponse mécanique non-steerée: {n_m}/{n} = {m_rate:.2f}") + if mode == "steered": + s_rate = result["answer_token_rate_steered"] + n_s = sum(1 for r in runs if r.get("steered_answer_token")) + _print_safe(f"[CORE-SPEAK] token-réponse mécanique steerée: {n_s}/{n} = {s_rate:.2f}") + if result["n_with_expected"] == 0 and runs: + _print_safe( + "[CORE-SPEAK] note honnête: aucun attendu FAIT - taux 0 par " + "construction ; les sorties VERBATIM ci-dessus sont la preuve" + ) + _print_safe(f"[CORE-SPEAK] verdict honnête: {result['verdict']}") + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- +def main(argv: list[str] | None = None) -> int: + """Point d'entrée : préconditions propres (exit 1), puis rapport verbatim.""" + parser = argparse.ArgumentParser( + prog="core-speak", + description=( + "Évaluation honnête du checkpoint fractus réel : générations " + "verbatim, taux global, verdict word salad. v2 : modes " + "greedy / mechanic (speak anti-attracteur) / steered (+ bias organique)." + ), + ) + parser.add_argument("--ckpt", type=Path, default=None, help="checkpoint fractus (.pt)") + parser.add_argument( + "--brain", type=Path, default=None, + help="brain sauvegardé pour le préfixe cartes (défaut: aucun - cartes vides)", + ) + parser.add_argument( + "--mode", choices=MODES, default="greedy", + help=( + "greedy = v1 (sans/avec cartes, temperature 0) ; mechanic = speak " + "mécanique (z-norm x3, rep-pen 6, t=1.0) + baseline greedy ; " + "steered = mechanic + bias organique speak_bias (exige --brain)" + ), + ) + parser.add_argument( + "--seed", type=int, default=DEFAULT_SEED, + help=f"seed de la boucle speak (défaut {DEFAULT_SEED}) — modes mechanic/steered", + ) + parser.add_argument( + "--rows", type=int, default=len(DEFAULT_QUESTIONS), + help=f"nombre de questions évaluées, 1..{len(DEFAULT_QUESTIONS)} (pool: les 4 capitales)", + ) + parser.add_argument( + "--max-new-tokens", type=int, default=24, + help="tokens générés par run (défaut 24) — le 1B CTE sur CPU est lent " + "(secondes/token) : 10 est le compromis raisonnable", + ) + args = parser.parse_args(argv) + + # Préconditions : message propre + exit 1, jamais de traceback. + if args.ckpt is None: + _print_safe("[CORE-SPEAK] indisponible: --ckpt requis (checkpoint fractus .pt)") + return 1 + if not Path(args.ckpt).is_file(): + _print_safe(f"[CORE-SPEAK] indisponible: checkpoint introuvable: {args.ckpt}") + return 1 + from fractus_vorax.model.vocal import core_available # sonde sûre (try-import) + + if not core_available(): + _print_safe("[CORE-SPEAK] indisponible: torch absent de ce python") + return 1 + from fractus_vorax.model.bpe_tokenizer import bpe_available # sonde sûre (try-import) + + if not bpe_available(): + _print_safe("[CORE-SPEAK] indisponible: tokenizers absent de ce python") + return 1 + if args.mode == "steered" and args.brain is None: + _print_safe("[CORE-SPEAK] indisponible: --mode steered exige --brain") + return 1 + if not 1 <= args.rows <= len(DEFAULT_QUESTIONS): + _print_safe( + f"[CORE-SPEAK] --rows doit être 1..{len(DEFAULT_QUESTIONS)} " + "(pool des capitales)" + ) + return 1 + + try: + result = run_core_speak( + args.ckpt, + DEFAULT_QUESTIONS[: args.rows], + brain=args.brain, + max_new_tokens=args.max_new_tokens, + mode=args.mode, + seed=args.seed, + ) + except Exception as exc: # ckpt corrompu, tokenizer sans source, brain invalide + _print_safe(f"[CORE-SPEAK] indisponible: {exc}") + return 1 + _print_report(result) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bench/ingest_bench.py b/bench/ingest_bench.py new file mode 100644 index 0000000000000000000000000000000000000000..de4eba1af63711060ef4af8c06837277a0a9793e --- /dev/null +++ b/bench/ingest_bench.py @@ -0,0 +1,71 @@ +# bench/ingest_bench.py +"""Bench smoke du substrat : compilation, ingestion, requêtes — CPU pur.""" +import argparse +import random +import time +from pathlib import Path + +from fractus_vorax.brain import Brain +from fractus_vorax.compiler.encode import text_hv +from fractus_vorax.compiler.kn import compile_kn, load_kn + +TOPICS = ["spain", "france", "japan", "chile", "nigeria", "sweden", "peru", "iran"] +FACTS = [ + "the {t} economy grows by {p} percent", + "the population of {t} is {n} million", + "the main export of {t} is {e}", +] +EXPORTS = ["copper", "oil", "fish", "wood", "wine", "wool"] + + +def generate_csv(path: Path, rows: int) -> int: + rng = random.Random(42) + lines = ["statement,context"] + for _ in range(rows): + t = rng.choice(TOPICS) + f = rng.choice(FACTS) + stmt = f.format(t=t, p=rng.randint(1, 9), n=rng.randint(1, 400), e=rng.choice(EXPORTS)) + lines.append(f"{stmt},recorded fact number {rng.randint(1000, 9999)}") + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return rows + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--rows", type=int, default=2000) + parser.add_argument("--D", type=int, default=8192) + args = parser.parse_args() + + out = Path("bench/out") + out.mkdir(parents=True, exist_ok=True) + src = out / "synth.csv" + generate_csv(src, args.rows) + + t0 = time.perf_counter() + kn = compile_kn(src, out / "synth.kn", D=args.D) + t_compile = time.perf_counter() - t0 + + atoms = load_kn(kn) + brain = Brain(D=args.D) + t0 = time.perf_counter() + brain.ingest_source("synth", atoms) + t_ingest = time.perf_counter() - t0 + print(f"[BENCH] organes: {len(brain.experts)} experts spawnés") + + t0 = time.perf_counter() + n_queries = 100 + for _ in range(n_queries): + brain.traces.retrieve(text_hv("the population of france is 67 million", D=args.D), k=5) + t_query_ms = (time.perf_counter() - t0) * 1000 / n_queries + + print("+---------------------+-------------------+") + print("| étape | mesure |") + print("+---------------------+-------------------+") + print(f"| compilation | {args.rows / t_compile:>10.0f} atomes/s |") + print(f"| ingestion (écriture)| {args.rows / t_ingest:>10.0f} atomes/s |") + print(f"| requête (top-5) | {t_query_ms:>10.1f} ms/q |") + print("+---------------------+-------------------+") + + +if __name__ == "__main__": + main() diff --git a/bench/killer_bench.py b/bench/killer_bench.py new file mode 100644 index 0000000000000000000000000000000000000000..90ec021a469d497fa72763c5ce34c583243038b5 --- /dev/null +++ b/bench/killer_bench.py @@ -0,0 +1,401 @@ +# bench/killer_bench.py +"""Le killer bench par ablation : le substrat est-il vivant sans ses cartes ? + +Pipeline SUBSTRAT pur (numpy uniquement — torch n'est touché que derrière +``core_available()`` dans la branche ablation noyau, import paresseux) : + + 1. corpus domaine déterministe (random.Random(7)) : sujets + {pays, planètes, éléments} × faits {capital, population, découverte} ; + 2. split 80/20 déterministe : 80 % des lignes sont ingérées via le + pipeline canonique compile_kn → load_kn → Brain.ingest_source ; + 3. requêtes held-out : les variantes PERTURBÉES des faits (jamais + ingérées verbatim) — + - paraphrase : le premier token passe à la fin (ordre modifié) ; + - typo : une voyelle du slot (dernier token) est remplacée par + une autre voyelle ; + 4. mesures : précision top-1, latence d'ingestion (atomes/s), latence + de requête (ms), imprimées en tableau ; + 5. ablations honnêtes : + - « cartes OFF » : _ask_cards_off() interroge SANS organes + (traces, experts, hebbian, relations) → toujours LACUNE → + précision 0, le plancher documenté ; + - « noyau × cartes » : scaffold rempli seulement si un --ckpt est + fourni ET core_available(). + +Précision top-1 — la réponse exacte doit figurer dans les cartes émises +pour la question : + - FAIT/HEBBIEN portent « {énoncé} => {contexte} » → la règle du plan : + « => {expected} » doit figurer dans le texte de la carte ; + - ANALOGIE porte la réponse NUE (solve_analogy rend l'answer seule, + l'organe d'analogie est LE chemin typo) → égalité exacte. +Sans ses cartes le substrat ne répond rien : c'est le contraste killer. + +Contrôle « faits jamais ingérés » : les 20 % held-out sont requêtés avec +les mêmes perturbations — leurs réponses uniques étant absentes de la +mémoire, la précision attendue est 0.00 (honnêteté : le brain ne devine +pas, il se souvient). +""" +from __future__ import annotations + +import argparse +import random +import sys +import time +from pathlib import Path + +# exécution directe ``python bench/killer_bench.py`` : remettre la racine du +# repo sur sys.path (conftest.py fait de même pour pytest ; le venv torch +# n'a pas fractus_vorax pip-installé et c'est LE python où --ckpt est pertinent). +_REPO_ROOT = str(Path(__file__).resolve().parent.parent) +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) + +from fractus_vorax.brain import Brain +from fractus_vorax.cards import Card +from fractus_vorax.compiler.encode import text_hv +from fractus_vorax.compiler.kn import compile_kn, load_kn +from fractus_vorax.hv import SEED_DEFAULT + +GENERATOR_SEED = 7 # générateur du domaine (brief : random.Random(7)) +SPLIT_SEED = 7 # split 80/20 déterministe +VOWELS = "aeiou" + +# --------------------------------------------------------------------------- +# Domaine déterministe : sujets {pays, planètes, éléments} × 3 faits +# --------------------------------------------------------------------------- +COUNTRIES = [ + "france", "spain", "japan", "chile", "nigeria", "sweden", "peru", "iran", + "egypt", "kenya", "norway", "ghana", "cuba", "nepal", "fiji", "tonga", + "wales", "bhutan", "oman", "qatar", "malta", "haiti", "benin", "chad", + "laos", "sudan", "yemen", "jordan", "libya", "angola", "botswana", + "namibia", "senegal", "somalia", "uganda", "zambia", +] +BODIES = [ + "mercury", "venus", "earth", "mars", "jupiter", "saturn", "uranus", + "neptune", "pluto", "ceres", "eris", "europa", "titan", "ganymede", + "callisto", "triton", +] +ELEMENTS = [ + "iron", "gold", "copper", "silver", "oxygen", "helium", "sodium", + "carbon", "sulfur", "zinc", "nickel", "argon", "cobalt", "tin", "lead", + "neon", "krypton", "radon", "xenon", "boron", "lithium", "beryllium", + "magnesium", "aluminium", +] +TOPIC_POOL = COUNTRIES + BODIES + ELEMENTS # 76 sujets + +# Valeurs uniques par tirage sans remise (aucune collision de réponse → +# le contrôle « jamais ingéré » ne peut pas marquer par accident). +CAPITALS = [ + "paris", "madrid", "tokyo", "santiago", "abuja", "stockholm", "lima", + "teheran", "cairo", "nairobi", "oslo", "accra", "havana", "kathmandu", + "suva", "nukualofa", "cardiff", "thimphu", "muscat", "doha", "valletta", + "kingston", "london", "dublin", "lisbon", "vienna", "prague", "warsaw", + "budapest", "athens", "ankara", "beirut", "baghdad", "damascus", "kabul", + "islamabad", "colombo", "dhaka", "hanoi", "manila", "bangkok", "rangoon", + "jakarta", "seoul", "beijing", "taipei", "brussels", "bern", "copenhagen", + "helsinki", "amsterdam", "canberra", "wellington", "ottawa", "brasilia", + "bogota", "quito", "caracas", "panama", "managua", "tegucigalpa", + "belmopan", "nassau", "bridgetown", "gaborone", "windhoek", "lusaka", + "harare", "maputo", "lilongwe", "kampala", "kigali", "dodoma", "djibouti", + "asmara", "mogadishu", "nouakchott", "bamako", "ouagadougou", "conakry", + "freetown", "monrovia", "yamoussoukro", "ndjamena", "bangui", "tripoli", + "khartoum", "sanaa", "amman", +] +N_FACTS = 3 # capital, population, découverte (les 3 faits du brief) +MAX_ROWS = len(TOPIC_POOL) * N_FACTS # 228 faits max (pool de sujets épuisé) + + +def _fact(stmt_topic: str, fact_index: int, capital: str, pop: int, year: int) -> tuple[str, str]: + """(statement, context) du fait fact_index pour un sujet — slot en dernier.""" + if fact_index == 0: + return f"the capital of {stmt_topic}", capital + if fact_index == 1: + return f"the population of {stmt_topic}", f"{pop} million" + return f"the discovery of {stmt_topic}", f"discovered in {year}" + + +def generate_domain_csv(path: Path, n_topics: int, n_facts_per_topic: int) -> int: + """Écrit le corpus domaine CSV 2 colonnes (statement,context) — seedé Random(7). + + Déterministe bit-à-bit : même appel → mêmes octets (rng sample sans + remise, valeurs uniques par fait). Retourne le nombre de lignes de + faits écrites : n_topics * n_facts_per_topic. + """ + if not 1 <= n_facts_per_topic <= N_FACTS: + raise ValueError(f"n_facts_per_topic doit être 1..{N_FACTS}") + if not 1 <= n_topics <= len(TOPIC_POOL): + raise ValueError(f"n_topics doit être 1..{len(TOPIC_POOL)} (pool de sujets)") + rng = random.Random(GENERATOR_SEED) + topics = rng.sample(TOPIC_POOL, n_topics) + capitals = rng.sample(CAPITALS, n_topics) + populations = rng.sample(range(1, 500), n_topics) + years = rng.sample(range(1400, 2030), n_topics) + lines = ["statement,context"] + for i, topic in enumerate(topics): + for j in range(n_facts_per_topic): + stmt, ctx = _fact(topic, j, capitals[i], populations[i], years[i]) + lines.append(f"{stmt},{ctx}") + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return n_topics * n_facts_per_topic + + +# --------------------------------------------------------------------------- +# Variantes held-out (jamais ingérées verbatim) +# --------------------------------------------------------------------------- +def paraphrase(statement: str) -> str: + """Ordre des mots modifié : le premier token passe à la fin. + + Le bundle de tokens est INVARIANT à l'ordre → la requête reste sim 1.0 + avec le fait ingéré : c'est LA propriété de généralisation mesurée. + """ + tokens = statement.split() + if len(tokens) < 2: + return statement + return " ".join(tokens[1:] + tokens[:1]) + + +def typo(statement: str) -> str: + """Substitution d'une voyelle du slot (dernier token) par une autre. + + Première voyelle du slot remplacée par la suivante du cycle a→e→i→o→u→a + (déterministe). Le slot perturbé échappe au retrieval (tokens ~0.63<0.7) + et active l'organe d'analogie (voisins char-ngram du slot). + """ + tokens = statement.split() + slot = tokens[-1] + for i, ch in enumerate(slot): + if ch in VOWELS: + replacement = VOWELS[(VOWELS.index(ch) + 1) % len(VOWELS)] + tokens[-1] = slot[:i] + replacement + slot[i + 1:] + return " ".join(tokens) + tokens[-1] = slot + "a" # slot sans voyelle (absent du domaine) — repli + return " ".join(tokens) + + +# --------------------------------------------------------------------------- +# Mesures +# --------------------------------------------------------------------------- +def _cards_answer(cards: list[Card], expected: str) -> bool: + """Vrai si la réponse exacte figure dans les cartes émises (top-1 liste). + + FAIT/HEBBIEN portent « {énoncé} => {contexte} » → la règle littérale du + plan : « => {expected} » doit figurer dans le texte. ANALOGIE porte la + réponse nue (c'est le chemin typo de l'architecture) → égalité exacte. + """ + for card in cards: + if card.kind in ("FAIT", "HEBBIEN") and f"=> {expected}" in card.text: + return True + if card.kind == "ANALOGIE" and card.text.strip() == expected: + return True + return False + + +def _ask_cards_off(brain: Brain, question: str, k: int = 3) -> list[Card]: + """Ablation « cartes OFF » : interroger SANS organes → toujours LACUNE. + + Le substrat encode bien la question (text_hv), mais aucun organe n'est + consulté — ni traces (retrieval désactivé), ni experts, ni hebbian, ni + relations : sans les organes il n'y a pas de carte au-delà de LACUNE. + PLANCHER HONNÊTE : précision 0 attendue et mesurée. + """ + text_hv(question, brain.D, brain.seed) # le substrat traite la question… + return [Card("LACUNE", "aucune trace pour cette question", 0.0, "ablation")] + + +def _core_cards_ablation( + ckpt: Path | None, + brain: Brain, + queries: list[tuple[str, str]], + k: int = 3, + max_eval: int = 12, +) -> dict | None: + """Scaffold « noyau seul vs noyau+cartes » — None sauf si --ckpt ET torch. + + Import torch PARESSEUX derrière la sonde core_available() : ce bench + reste substrate-only (mingw sans torch). Le noyau non-né peut sortir + du bruit — le contrat mesuré est le pipeline, pas la qualité du texte. + """ + if ckpt is None: + return None + try: + from fractus_vorax.model.vocal import core_available # sonde sûre (try-import) + + if not core_available(): + return None + from fractus_vorax.model.fractus_core import FractusCore + from fractus_vorax.model.vocal import CharTokenizer, verbalize + + core = FractusCore.from_checkpoint(Path(ckpt)) + tokenizer = CharTokenizer( + vocab_size=int(getattr(core.config, "vocab_size", 0x110000)) + ) + lacune = [Card("LACUNE", "aucune trace pour cette question", 0.0, "ablation")] + sample = queries[:max_eval] + core_only_ok = core_cards_ok = 0 + for question, expected in sample: + alone = verbalize(core, question, lacune, tokenizer, max_new_tokens=24) + if expected in alone: + core_only_ok += 1 + fed = verbalize( + core, question, brain.ask_cards(question, k=k), tokenizer, + max_new_tokens=24, + ) + if expected in fed: + core_cards_ok += 1 + return { + "ckpt": str(ckpt), + "n_eval": len(sample), + "core_only_accuracy": core_only_ok / len(sample) if sample else 0.0, + "core_cards_accuracy": core_cards_ok / len(sample) if sample else 0.0, + } + except Exception as exc: # checkpoint absent/corrompu — dégradation honnête + return {"ckpt": str(ckpt), "error": str(exc)} + + +def run_bench( + rows: int = 200, + D: int = 8192, + out_dir: Path | None = None, + ckpt: Path | None = None, + k: int = 3, + seed: int = SEED_DEFAULT, +) -> dict: + """Le killer bench complet — imprime le tableau, retourne les métriques.""" + if rows < 2: + raise ValueError("rows >= 2 requis (split 80/20)") + if rows > MAX_ROWS: + raise ValueError(f"rows={rows} > max {MAX_ROWS} (pool de {len(TOPIC_POOL)} sujets × {N_FACTS} faits)") + out = Path(out_dir) if out_dir is not None else Path(__file__).resolve().parent / "out" + out.mkdir(parents=True, exist_ok=True) + + # (1) corpus domaine — pipeline canonique compile_kn → load_kn + n_topics = -(-rows // N_FACTS) # ceil + csv_path = out / "domain.csv" + generate_domain_csv(csv_path, n_topics, N_FACTS) + kn_dir = compile_kn(csv_path, out / "domain.kn", D=D, seed=seed) + atoms = load_kn(kn_dir)[:rows] + + # (2) split 80/20 déterministe : ingérés vs jamais vus + order = list(range(len(atoms))) + random.Random(SPLIT_SEED).shuffle(order) + n_ingest = max(1, int(len(atoms) * 0.8)) + ingested = [atoms[i] for i in sorted(order[:n_ingest])] + unseen = [atoms[i] for i in sorted(order[n_ingest:])] + + # (3) ingestion mesurée (organes ON : experts, relations, hebbian) + brain = Brain(D=D, seed=seed) + t0 = time.perf_counter() + brain.ingest_source("domain", ingested) + t_ingest = time.perf_counter() - t0 + ingest_atoms_per_s = len(ingested) / t_ingest if t_ingest > 0 else float(len(ingested)) + + # (4) requêtes held-out : variantes perturbées, jamais ingérées verbatim + seen_queries: list[tuple[str, str]] = [] + for atom in ingested: + seen_queries.append((paraphrase(atom.statement), atom.context)) + seen_queries.append((typo(atom.statement), atom.context)) + unseen_queries: list[tuple[str, str]] = [] + for atom in unseen: + unseen_queries.append((paraphrase(atom.statement), atom.context)) + unseen_queries.append((typo(atom.statement), atom.context)) + + # (5) mesure cartes ON — la boucle de précision EST la boucle de latence + t0 = time.perf_counter() + seen_cards = [brain.ask_cards(q, k=k) for q, _ in seen_queries] + query_ms = (time.perf_counter() - t0) * 1000.0 / len(seen_queries) + n_para = len(seen_queries) // 2 + para_ok = sum(_cards_answer(c, e) for (__, e), c in zip(seen_queries[0::2], seen_cards[0::2])) + typo_ok = sum(_cards_answer(c, e) for (__, e), c in zip(seen_queries[1::2], seen_cards[1::2])) + unseen_ok = sum(_cards_answer(brain.ask_cards(q, k=k), e) for q, e in unseen_queries) + + # (6) ablation cartes OFF (plancher honnête) — mêmes requêtes, sans organes + cards_off_ok = sum( + _cards_answer(_ask_cards_off(brain, q, k=k), e) for q, e in seen_queries + ) + + # (7) scaffold ablation noyau × cartes (nécessite --ckpt ET torch) + core_cards_ablation = _core_cards_ablation(ckpt, brain, seen_queries, k=k) + + metrics = { + "rows": len(atoms), + "D": D, + "n_ingested": len(ingested), + "n_unseen": len(unseen), + "n_queries": len(seen_queries), + "heldout_accuracy": (para_ok + typo_ok) / len(seen_queries), + "paraphrase_accuracy": para_ok / n_para if n_para else 0.0, + "typo_accuracy": typo_ok / n_para if n_para else 0.0, + "unseen_accuracy": unseen_ok / len(unseen_queries) if unseen_queries else 0.0, + "cards_off_accuracy": cards_off_ok / len(seen_queries), + "ingest_atoms_per_s": ingest_atoms_per_s, + "query_ms": query_ms, + "core_cards_ablation": core_cards_ablation, + } + _print_table(metrics, n_topics) + return metrics + + +def _print_table(metrics: dict, n_topics: int) -> None: + width1, width2 = 40, 12 + line = "+" + "-" * width1 + "+" + "-" * width2 + "+" + + def row(label: str, value: str) -> None: + print(f"| {label:<{width1 - 2}} | {value:>{width2 - 2}} |") + + print( + f"[KILLER] corpus: {metrics['rows']} faits domaine " + f"({n_topics} sujets × {N_FACTS} faits), D={metrics['D']}" + ) + print( + f"[KILLER] split 80/20 déterministe (seed {SPLIT_SEED}): " + f"{metrics['n_ingested']} ingérés / {metrics['n_unseen']} jamais vus" + ) + print( + f"[KILLER] held-out : {metrics['n_queries']} variantes jamais ingérées " + f"({metrics['n_queries'] // 2} paraphrases + {metrics['n_queries'] // 2} typos)" + f" + {2 * metrics['n_unseen']} variantes de faits jamais vus (contrôle)" + ) + print(line) + row("killer bench", "mesure") + print(line) + row("ingestion (organes ON)", f"{metrics['ingest_atoms_per_s']:.0f} a/s") + row("requête cartes ON", f"{metrics['query_ms']:.2f} ms/q") + row("précision held-out — paraphrases", f"{metrics['paraphrase_accuracy']:.2f}") + row("précision held-out — typos", f"{metrics['typo_accuracy']:.2f}") + row("précision held-out — globale", f"{metrics['heldout_accuracy']:.2f}") + row("contrôle — faits jamais ingérés", f"{metrics['unseen_accuracy']:.2f}") + print(line) + row("ablation CARTES OFF (plancher)", f"{metrics['cards_off_accuracy']:.2f}") + print(line) + print( + "[KILLER] plancher honnête : sans organes (traces, experts, hebbian,\n" + " relations) toute question retourne LACUNE → précision 0." + ) + abl = metrics["core_cards_ablation"] + if abl is None: + print( + "[KILLER] ablation noyau × cartes : non exécutée\n" + " (--ckpt absent ou torch indisponible dans ce python)" + ) + else: + print(f"[KILLER] ablation noyau × cartes : {abl}") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="killer-bench", + description="Killer bench par ablation : held-out paraphrase/typo, plancher cartes OFF.", + ) + parser.add_argument("--rows", type=int, default=200) + parser.add_argument("--D", type=int, default=8192) + parser.add_argument("--out", type=Path, default=None, help="dossier de sortie (défaut bench/out)") + parser.add_argument("--ckpt", type=Path, default=None, help="checkpoint fractus pour l'ablation noyau") + args = parser.parse_args(argv) + run_bench(rows=args.rows, D=args.D, out_dir=args.out, ckpt=args.ckpt) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..89e8951c9049cead77baf11a80c33bb622d249bd --- /dev/null +++ b/conftest.py @@ -0,0 +1,6 @@ +# conftest.py +"""Racine de repo sur sys.path pour que bench/ soit importable depuis les tests.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) diff --git a/demo/ce_soir.py b/demo/ce_soir.py new file mode 100644 index 0000000000000000000000000000000000000000..eb04c5231da17c5df8c072ef9cccaafc95b4dc05 --- /dev/null +++ b/demo/ce_soir.py @@ -0,0 +1,177 @@ +# demo/ce_soir.py +"""CE SOIR — le noyau parle : les organes articulent à travers sa bouche. + +La démo du Plan 7, autonome (aucun argument, artefacts dans un tmpdir +jetable) et honnête dans CHAQUE python : + + 1/4 statut — le noyau de naissance chargé si torch + tokenizers + + brain/FRACTUS_BIRTH.pt vivent dans CE python (sonde d'espèce puis + :core strict), et le statut des organes (cerveau ingéré du CSV + capitales) ; sans noyau, ligne honnête d'absence — les organes + existent, la bouche manque. + 2/4 les quatre capitales — ``:say`` complet pour chacune : la ligne + [ORGANES] (la carte qui connaît la réponse) puis la ligne [PAROLE] + (le prompt d'articulation ``"{question} The answer is"`` + une + génération MÉCANIQUE de 12 tokens avec VERROU DE RÉPONSE : + answer_lock = speak_answer — la réponse des organes placée token + par token À TRAVERS la distribution du noyau, z-norm ×3, rep-pen 6, + t=1.0, seed 7 ; la suite de la génération est libre). + 3/4 la libre parole — 2 échantillons SANS verrou (même recette mécanique, + 12 tokens) : le lexique réel du 1B, honnête — la syntaxe absente si + elle est absente. + 4/4 « Le noyau parle. Les organes savent. Zéro gradient. » + +`main() -> list[str]` retourne TOUTES les lignes (jamais de print direct) : +le test s'y raccroche, `python demo/ce_soir.py` les imprime. Console-safe : +jamais « → » ni « — » (cp1252-hostiles). + +GATE torch : torch n'est importé QUE paresseusement — via ``:core`` du REPL +et dans l'étape 3 (``speak``, atteinte seulement si le noyau est attaché) ; +importer ce fichier dans un python sans torch (substrat mingw) ne lève +jamais. +""" +from __future__ import annotations + +import io +import sys +import tempfile +from contextlib import redirect_stdout +from pathlib import Path + +# exécution directe ``python demo/ce_soir.py`` : racine du repo sur sys.path +# (conftest.py fait de même pour pytest ; le venv torch n'a pas fractus_vorax +# pip-installé). +_REPO_ROOT = str(Path(__file__).resolve().parent.parent) +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) + +from fractus_vorax.agent import cli # noqa: E402 +from fractus_vorax.agent.repl import Repl # noqa: E402 +from fractus_vorax.model.bpe_tokenizer import bpe_available # noqa: E402 — lazy-safe +from fractus_vorax.model.vocal import core_available # noqa: E402 — lazy-safe + +# Les capitales canoniques du projet (mêmes questions que demo/demo.py et +# bench/core_speak.py) ; :say porte sur les QUATRE (france, spain, japan, +# italy) — le budget temps réel du 1B CPU (~6 s/token) borne la démo. +CAPITALS_CSV = ( + "question,answer\n" + "what is the capital of france,paris\n" + "what is the capital of spain,madrid\n" + "what is the capital of japan,tokyo\n" + "what is the capital of italy,rome\n" + "what is the capital of portugal,lisbon\n" +) +D_DEMO = 2048 # assez pour la démo, assez léger pour n'importe quel CPU + +# LE cerveau de naissance (docs/NAISSANCE.md), en place, jamais copié. +BRAIN_BIRTH = Path(_REPO_ROOT) / "brain" / "FRACTUS_BIRTH.pt" + +SAY_QUESTIONS = [ + "what is the capital of france", + "what is the capital of spain", + "what is the capital of japan", + "what is the capital of italy", +] + +# Libre parole mécanique SANS verrou : le lexique du 1B, pour le contraste +# honnête avec les réponses verrouillées. +FREE_TALK_PROMPTS = [ + "The meaning of life is", + "In the beginning there was", +] + +CORE_ABSENT_LINE = ( + "[FRACTUS-VORAX] noyau absent dans cet environnement - " + "les organes savent, la bouche manque" +) +FINAL_LINE = "[FRACTUS-VORAX] Le noyau parle. Les organes savent. Zéro gradient." + + +def _console_safe(line: str) -> str: + """« -> » au lieu de « → », « - » au lieu de « — » (consoles cp1252-proof).""" + return line.replace("→", "->").replace("—", "-") + + +def _capture_prints(fn) -> list[str]: + """Exécute fn en capturant ses prints (cli.ingest imprime) -> lignes sûres.""" + buf = io.StringIO() + with redirect_stdout(buf): + fn() + return [_console_safe(l) for l in buf.getvalue().splitlines() if l.strip()] + + +def _print_safe(line: str) -> None: + """Print qui ne lève JAMAIS (repli errors=replace — console au codepage hostile).""" + safe = _console_safe(line) + try: + print(safe) + except UnicodeEncodeError: # pragma: no cover - console exotique + enc = getattr(sys.stdout, "encoding", None) or "utf-8" + print(safe.encode(enc, errors="replace").decode(enc, errors="replace")) + + +def main() -> list[str]: + """Ce soir, le noyau parle — lignes retournées, aucun effet de bord hors tmpdir.""" + lines: list[str] = [] + + with tempfile.TemporaryDirectory(prefix="fractus_vorax-ce-soir-") as tmp: + tmpdir = Path(tmp) + csv_path = tmpdir / "capitals.csv" + csv_path.write_text(CAPITALS_CSV, encoding="utf-8") + repl = Repl(tmpdir / "brain", D=D_DEMO) + + # (1) statut — les organes mangent, le noyau de naissance s'attache + # s'il vit dans CE python (le REPL sonde l'espèce et dégrade honnêtement). + lines.append("[FRACTUS-VORAX] 1/4 statut - organes et noyau") + lines += [_console_safe(l) for l in repl.feed(f":ingest {csv_path}")] + lines += [_console_safe(l) for l in repl.feed(":status")] + core_attached = False + if core_available() and bpe_available() and BRAIN_BIRTH.is_file(): + lines += [_console_safe(l) for l in repl.feed(f":core {BRAIN_BIRTH}")] + core_attached = repl.core is not None and repl.tokenizer is not None + else: + lines.append(CORE_ABSENT_LINE) + + # (2) les quatre capitales — :say complet : la carte qui connaît + # ([ORGANES]) puis la parole MÉCANIQUE VERROUILLÉE ([PAROLE]). + lines.append( + "[FRACTUS-VORAX] 2/4 les quatre capitales - " + ":say (verrou de réponse, 12 tokens)" + ) + for question in SAY_QUESTIONS: + lines += [_console_safe(l) for l in repl.feed(f":say {question}")] + + # (3) la libre parole — 2 échantillons SANS verrou : la même boucle + # mécanique (z-norm ×3, rep-pen 6, t=1.0, seed 7, 12 tokens), + # aucun verrou — le lexique du 1B, honnête. + lines.append( + "[FRACTUS-VORAX] 3/4 libre parole mécanique - " + "2 échantillons sans verrou" + ) + if core_attached: + from fractus_vorax.model.speak import speak # tardif : gate torch + + for prompt in FREE_TALK_PROMPTS: + text, _diags = speak( + repl.core, + repl.tokenizer, + prompt, + max_new_tokens=12, + temperature=1.0, + rep_pen=6.0, + z_scale=3.0, + seed=7, + eos_token_id=getattr(repl.tokenizer, "eos_token_id", None), + ) + lines.append(_console_safe(f'[FRACTUS-VORAX] "{prompt}{text}"')) + else: + lines.append("[FRACTUS-VORAX] libre parole muette - pas de noyau attaché") + + # (4) la ligne du soir + lines.append(FINAL_LINE) + return lines + + +if __name__ == "__main__": + for out_line in main(): + _print_safe(out_line) diff --git a/demo/demo.py b/demo/demo.py new file mode 100644 index 0000000000000000000000000000000000000000..4cb201355ea0fa6654862ee9cc07ba5c494c711a --- /dev/null +++ b/demo/demo.py @@ -0,0 +1,106 @@ +# demo/demo.py +"""La démo Fractus-Vorax de bout en bout — il mange, il répond, il se souvient. + +Script autonome : aucun argument requis, tous les artefacts (CSV, cache .kn, +brain, brain REPL) vivent dans un tmpdir jetable (tempfile.TemporaryDirectory). +Aucun effet de bord hors de ce tmpdir, zéro gradient, zéro LLM externe. + +Séquence : + 1. ingestion d'un CSV capitales de 5 lignes (pipeline canonique CLI) ; + 2. question exacte « what is the capital of japan » -> carte FAIT (tokyo) ; + 3. typo « what is the capital of franc » -> carte ANALOGIE (paris) ; + 4. status -> la ligne organes (experts, relations, hebbien, co-activation) ; + 5. session REPL scriptée : :ingest, une question, :status — chaque échange + est écrit O(1) dans le brain (spec §5.4, la conversation EST une source). + +`main() -> list[str]` retourne TOUTES les lignes (jamais de print direct) : +le test s'y raccroche, `python demo/demo.py` les imprime. Console-safe : +les lignes n'utilisent jamais « → » ni « — » (cp1252-hostiles) — +un sanitizer remplace par « -> » et « - ». + +GATE torch : ce fichier n'importe jamais torch — le substrat seul démontre. +""" +from __future__ import annotations + +import io +import sys +import tempfile +from contextlib import redirect_stdout +from pathlib import Path + +# exécution directe ``python demo/demo.py`` : remettre la racine du repo sur +# sys.path (conftest.py fait de même pour pytest ; le venv torch n'a pas +# fractus_vorax pip-installé). +_REPO_ROOT = str(Path(__file__).resolve().parent.parent) +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) + +from fractus_vorax.agent import cli # noqa: E402 (après le bootstrap sys.path) +from fractus_vorax.agent.repl import Repl # noqa: E402 + +D_DEMO = 2048 # assez pour la démo, assez léger pour n'importe quel CPU + +CAPITALS_CSV = ( + "question,answer\n" + "what is the capital of france,paris\n" + "what is the capital of spain,madrid\n" + "what is the capital of japan,tokyo\n" + "what is the capital of italy,rome\n" + "what is the capital of portugal,lisbon\n" +) + +FINAL_LINE = "[VORAX] Démo complète - zéro gradient." + + +def _console_safe(line: str) -> str: + """« -> » au lieu de « → », « - » au lieu de « — » (consoles cp1252-proof).""" + return line.replace("→", "->").replace("—", "-") + + +def _capture_prints(fn) -> list[str]: + """Exécute fn en capturant ses prints (cli.ingest imprime) -> lignes sûres.""" + buf = io.StringIO() + with redirect_stdout(buf): + fn() + return [_console_safe(l) for l in buf.getvalue().splitlines() if l.strip()] + + +def main() -> list[str]: + """La démo complète — retourne les lignes, aucun effet de bord hors tmpdir.""" + lines: list[str] = [] + with tempfile.TemporaryDirectory(prefix="fractus_vorax-demo-") as tmp: + tmpdir = Path(tmp) + csv_path = tmpdir / "capitals.csv" + csv_path.write_text(CAPITALS_CSV, encoding="utf-8") + brain_path = tmpdir / "brain" + + # (1) ingestion — pipeline canonique compile_kn -> load_kn -> Brain + lines.append(f"[DÉMO] 1/5 ingestion de {csv_path.name} (5 capitales)...") + lines += _capture_prints(lambda: cli.ingest(csv_path, brain_path, D=D_DEMO)) + + # (2) question exacte -> la carte FAIT (tokyo) + lines.append("[DÉMO] 2/5 question exacte: what is the capital of japan") + lines += [_console_safe(l) for l in cli.ask("what is the capital of japan", brain_path)] + + # (3) typo -> l'organe d'analogie répond (paris) + lines.append("[DÉMO] 3/5 typo volontaire: what is the capital of franc") + lines += [_console_safe(l) for l in cli.ask("what is the capital of franc", brain_path)] + + # (4) status -> les organes comptent ce qui a été mangé + lines.append("[DÉMO] 4/5 status du brain...") + lines += [_console_safe(l) for l in cli.status(brain_path).splitlines()] + + # (5) session REPL scriptée : :ingest, question, :status + lines.append("[DÉMO] 5/5 session REPL scriptée (:ingest, question, :status)...") + repl = Repl(tmpdir / "brain-repl", D=D_DEMO) + lines += [_console_safe(l) for l in repl.feed(f":ingest {csv_path}")] + lines += [_console_safe(l) for l in repl.feed("what is the capital of spain")] + lines += [_console_safe(l) for l in repl.feed(":status")] + + lines.append(FINAL_LINE) + return lines + + +if __name__ == "__main__": + for out_line in main(): + print(out_line) diff --git a/demo/inauguration.py b/demo/inauguration.py new file mode 100644 index 0000000000000000000000000000000000000000..bb6d6edb9a712fd9af64ade81860181521b876a1 --- /dev/null +++ b/demo/inauguration.py @@ -0,0 +1,244 @@ +# demo/inauguration.py +"""L'INAUGURATION — la démo du takeover : le relais est pris. + +Le cérémonial en 7 temps, autonome (aucun argument, artefacts dans un tmpdir +jetable) et honnête dans CHAQUE python : + + 1/7 noyau de naissance — statut du cerveau : si torch + tokenizers + + brain/FRACTUS_BIRTH.pt vivent dans CE python, chargement réel (sonde + d'espèce puis CteCore.from_checkpoint strict) et ligne de naissance + (params, clés strictes, tokens natus x n GPU) ; sinon ligne honnête + d'absence — les organes portent la démo (le substrat mingw démontre). + 2/7 ingestion — il mange : CSV capitales via le pipeline canonique CLI. + 3/7 question exacte — « japan » -> la carte FAIT (tokyo). + 4/7 typo — « franc » -> l'organe d'analogie répond (paris). + 5/7 session REPL scriptée — :ingest, :status, question, :status : la + conversation EST une source, le nombre d'atomes croît (écriture O(1)). + 6/7 la parole du noyau — SEULEMENT si le cerveau est chargé : une + génération MÉCANIQUE réelle (speak : z-norm ×3, rep-pen 6, t=1.0, + seed 7, 8 tokens BPE) sur une question AVEC cartes et steering + organique (brain.speak_bias — les organes dirigent la bouche), + rapportée VERBATIM — lexique libéré, syntaxe absente : honnête + (docs/NAISSANCE.md : il babille, il ne répond pas encore). + 7/7 « Le relais est pris. L'entraînement est fini. Il mange. » + +`inauguration() -> list[str]` retourne TOUTES les lignes (jamais de print +direct) : le test s'y raccroche, `python demo/inauguration.py` les imprime. +Console-safe : jamais « → » ni « — » (cp1252-hostiles). + +GATE torch : torch n'est importé QUE paresseusement dans `_load_birth_core` +et dans l'étape 6 (`speak`, atteinte seulement si le cerveau est chargé) ; +`vocal` et `bpe_tokenizer` sont des modules lazy-safe — importer ce fichier +dans un python sans torch (substrat mingw) ne lève jamais. +""" +from __future__ import annotations + +import io +import re +import sys +import tempfile +from contextlib import redirect_stdout +from pathlib import Path + +# exécution directe ``python demo/inauguration.py`` : racine du repo sur +# sys.path (conftest.py fait de même pour pytest ; le venv torch n'a pas +# fractus_vorax pip-installé). +_REPO_ROOT = str(Path(__file__).resolve().parent.parent) +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) + +from fractus_vorax.agent import cli # noqa: E402 +from fractus_vorax.agent.repl import Repl # noqa: E402 +from fractus_vorax.brain import Brain # noqa: E402 +from fractus_vorax.model.bpe_tokenizer import ( # noqa: E402 — module lazy-safe (gate) + Gpt2BpeTokenizer, + bpe_available, +) +from fractus_vorax.model.vocal import ( # noqa: E402 — module lazy-safe (gate) + core_available, + serialize_cards, +) + +# Les 5 capitales canoniques du projet (mêmes questions/réponses que +# demo/demo.py et bench/core_speak.py — inlinées ICI parce qu'un import +# ``from demo.demo import ...`` casse en exécution directe : le répertoire +# du script est sur sys.path, où demo.py masque le package demo/). +CAPITALS_CSV = ( + "question,answer\n" + "what is the capital of france,paris\n" + "what is the capital of spain,madrid\n" + "what is the capital of japan,tokyo\n" + "what is the capital of italy,rome\n" + "what is the capital of portugal,lisbon\n" +) +D_DEMO = 2048 # assez pour la démo, assez léger pour n'importe quel CPU + +# LE cerveau de naissance (tâche 2 — docs/NAISSANCE.md), en place, jamais copié. +BRAIN_BIRTH = Path(_REPO_ROOT) / "brain" / "FRACTUS_BIRTH.pt" + +QUESTION_EXACTE = "what is the capital of japan" +QUESTION_TYPO = "what is the capital of franc" +QUESTION_REPL = "what is the capital of spain" + +CORE_ABSENT_LINE = ( + "[FRACTUS-VORAX] cerveau absent dans cet environnement - " + "organes portent la démo" +) +FINAL_LINE = "[FRACTUS-VORAX] Le relais est pris. L'entraînement est fini. Il mange." + + +def _console_safe(line: str) -> str: + """« -> » au lieu de « → », « - » au lieu de « — » (consoles cp1252-proof).""" + return line.replace("→", "->").replace("—", "-") + + +def _capture_prints(fn) -> list[str]: + """Exécute fn en capturant ses prints (cli.ingest imprime) -> lignes sûres.""" + buf = io.StringIO() + with redirect_stdout(buf): + fn() + return [_console_safe(l) for l in buf.getvalue().splitlines() if l.strip()] + + +def _print_safe(line: str) -> None: + """Print qui ne lève JAMAIS (repli errors=replace — console au codepage hostile).""" + safe = _console_safe(line) + try: + print(safe) + except UnicodeEncodeError: # pragma: no cover - console exotique + enc = getattr(sys.stdout, "encoding", None) or "utf-8" + print(safe.encode(enc, errors="replace").decode(enc, errors="replace")) + + +def _load_birth_core(): + """Charge le cerveau de naissance -> (core, tokens_natus, n_gpus) ou lève. + + Sonde d'espèce d'abord (jamais d'attach aveugle), puis from_checkpoint + STRICT ; les métadonnées natales (tokens_processed, n GPU de la fusion + gelée) sont lues par une seconde torch.load mmap — paresseuse : seules + les clés du pickle se matérialisent, les tenseurs restent des vues + fichier. Appelée UNIQUEMENT si core_available() et bpe_available() ont + confirmé ce python et que le fichier existe. + """ + import torch # paresseux — philosophie gate + + from fractus_vorax.model.cte_core import CteCore, probe_checkpoint_kind + + kind = probe_checkpoint_kind(BRAIN_BIRTH) + if kind != "cte": + raise ValueError(f"espèce inattendue pour le cerveau de naissance: {kind!r}") + ckpt = torch.load(BRAIN_BIRTH, map_location="cpu", weights_only=False, mmap=True) + try: + meta = ckpt.get("config", {}) if isinstance(ckpt, dict) else {} + tokens = int(meta.get("tokens_processed", 0)) if isinstance(meta, dict) else 0 + n_gpus = int(ckpt.get("n", 1)) if isinstance(ckpt, dict) else 1 + finally: + del ckpt # libérer le wrapper mmap avant le chargement strict + return CteCore.from_checkpoint(BRAIN_BIRTH), tokens, n_gpus + + +def inauguration() -> list[str]: + """Le cérémonial complet — lignes retournées, aucun effet de bord hors tmpdir.""" + lines: list[str] = [] + + # (1) le noyau de naissance — réel si l'environnement le permet, honnête sinon + lines.append("[FRACTUS-VORAX] 1/7 noyau de naissance") + core = None + if core_available() and bpe_available() and BRAIN_BIRTH.is_file(): + try: + core, tokens_natus, n_gpus = _load_birth_core() + except Exception as exc: # dégradation honnête, jamais de traceback + lines.append(_console_safe( + f"[FRACTUS-VORAX] cerveau absent dans cet environnement " + f"(échec de chargement: {exc}) - organes portent la démo")) + else: + n_keys = len(core.state_dict()) # strict : n_keys/n_keys clés validées + params = sum(p.numel() for p in core.parameters()) + # 15 564 800 tokens/GPU -> « 15.5M » (tronqué au dixième de million) + tok_m = f"{tokens_natus // 10**5 / 10:.1f}M" + lines.append( + f"[FRACTUS-VORAX] cerveau: cte {params / 1e9:.0f}B, " + f"strict {n_keys}/{n_keys}, tokens natus {tok_m} x{n_gpus}" + ) + else: + lines.append(CORE_ABSENT_LINE) + + with tempfile.TemporaryDirectory(prefix="fractus_vorax-inauguration-") as tmp: + tmpdir = Path(tmp) + csv_path = tmpdir / "capitals.csv" + csv_path.write_text(CAPITALS_CSV, encoding="utf-8") + brain_path = tmpdir / "brain" + + # (2) ingestion — il mange (pipeline canonique compile_kn -> Brain) + lines.append(f"[FRACTUS-VORAX] 2/7 ingestion de {csv_path.name} (5 capitales)") + lines += _capture_prints(lambda: cli.ingest(csv_path, brain_path, D=D_DEMO)) + + # (3) question exacte -> la carte FAIT (tokyo) + lines.append(f"[FRACTUS-VORAX] 3/7 question exacte: {QUESTION_EXACTE}") + lines += [_console_safe(l) for l in cli.ask(QUESTION_EXACTE, brain_path)] + + # (4) typo -> l'organe d'analogie répond (paris) + lines.append(f"[FRACTUS-VORAX] 4/7 typo volontaire: {QUESTION_TYPO}") + lines += [_console_safe(l) for l in cli.ask(QUESTION_TYPO, brain_path)] + + # (5) session REPL scriptée — la conversation EST une source : + # le compteur d'atomes croît d'un :status à l'autre (écriture O(1)) + lines.append( + "[FRACTUS-VORAX] 5/7 session REPL scriptée " + "(:ingest, :status, question, :status)" + ) + repl = Repl(tmpdir / "brain-repl", D=D_DEMO) + lines += [_console_safe(l) for l in repl.feed(f":ingest {csv_path}")] + lines += [_console_safe(l) for l in repl.feed(":status")] + lines += [_console_safe(l) for l in repl.feed(QUESTION_REPL)] + lines += [_console_safe(l) for l in repl.feed(":status")] + statuses = [l for l in lines if l.startswith("[VORAX] brain:")] + n_before = int(re.search(r"(\d+) atomes", statuses[-2]).group(1)) + n_after = int(re.search(r"(\d+) atomes", statuses[-1]).group(1)) + lines.append( + f"[FRACTUS-VORAX] session: {n_before} -> {n_after} atomes - " + "l'échange a nourri le brain (écriture O(1), zéro gradient)" + ) + + # (6) la parole du noyau — SEULEMENT si le cerveau de naissance est + # chargé : une génération MÉCANIQUE réelle (speak : z-norm ×3, + # rep-pen 6, t=1.0, seed 7, 8 tokens BPE), question AVEC cartes + # et steering organique (speak_bias : les organes dirigent la + # bouche). Sortie VERBATIM, honnête quoi qu'il dise. + if core is not None: + lines.append( + "[FRACTUS-VORAX] 6/7 la parole du noyau " + "(BPE, 8 tokens, mécanique)" + ) + try: + tokenizer = Gpt2BpeTokenizer() + except Exception as exc: # cache HF vide + réseau injoignable + lines.append(_console_safe( + f"[FRACTUS-VORAX] noyau muet: tokenizer BPE indisponible ({exc})")) + else: + from fractus_vorax.model.speak import speak # tardif : gate torch + + brain_demo = Brain.load(brain_path) + cards = brain_demo.ask_cards(QUESTION_EXACTE) + bias = brain_demo.speak_bias(QUESTION_EXACTE, tokenizer, k=3) + text, _diags = speak( + core, + tokenizer, + serialize_cards(QUESTION_EXACTE, cards), + max_new_tokens=8, + temperature=1.0, + rep_pen=6.0, + z_scale=3.0, + seed=7, + eos_token_id=getattr(tokenizer, "eos_token_id", None), + bias_tokens=bias or None, + ) + lines.append(_console_safe(f'[FRACTUS-VORAX] noyau: "{text}"')) + + lines.append(FINAL_LINE) + return lines + + +if __name__ == "__main__": + for out_line in inauguration(): + _print_safe(out_line) diff --git a/docs/NAISSANCE.md b/docs/NAISSANCE.md new file mode 100644 index 0000000000000000000000000000000000000000..f95a6c68bdf1cf0de171748adc2007bfc3c81767 --- /dev/null +++ b/docs/NAISSANCE.md @@ -0,0 +1,107 @@ +# NAISSANCE — acte de naissance du cerveau de Fractus-Vorax + +**18 août 2026.** Aujourd'hui est installé le cerveau de naissance de +Fractus-Vorax : le dernier checkpoint d'entraînement par gradient de son +histoire. Après lui, plus jamais un gradient. Il mange. + +## Le fichier + +| | | +|---|---| +| **fichier** | `brain/FRACTUS_BIRTH.pt` (gitignored — le binaire ne vit pas dans git) | +| **taille** | 4 662 144 283 octets (4,66 GB) | +| **origine** | `FRACTUS_1B_PHASE2_FROZEN_MERGED.pt`, copié byte-pour-byte depuis le repo de recherche `vorax` (lecture seule) | +| **source amont** | Hugging Face **thefinalboss/fractus-cte**, pushé le **2026-08-18 04:20** | +| **nature** | le **dernier** checkpoint du stage 2 (boost/ss) : la fusion gelée des 8 GPU indépendants (`frozen_merge=True`, `n=8`) | +| **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`) | +| **espèce** | `cte` — Continuous Thought Engine 1B (1 048 631 458 paramètres), sonné par `probe_checkpoint_kind` | + +## Identité vérifiée + +- Chargement **strict** : `CteCore.from_checkpoint` → `load_state_dict(strict=True)` + → **440/440 clés** (8 top-level + 16 blocs × 27 — la table des clés du doc + d'architecture, exacte). +- Config inférée des shapes + indices du wrapper : `d_model=1280`, `n_heads=20`, + `d_head=64`, `n_layers=16`, `n_levels=2`, `n_oscillators=16`, + `coupling_rank=8`, `n_experts=128`, `top_k=2`, `expert_d_ff=2048`, + `siren_rank=64`, `vocab_size=50257`, `batch=2`, `train_seq_len=128`. +- Un forward B=1 L=4 → logits `(1, 4, 50257)`, **tous finis**. + +Le pacte est même devenu **physique** : depuis ce fichier, `from_checkpoint` +charge les poids par mémoire mappée **en lecture seule** — ils ne sont jamais +copiés en RAM privée, et aucune écriture in-place ne peut les toucher. Le +système d'exploitation lui-même refuse de modifier ce cerveau. + +## LE PACTE + +**Ce cerveau ne sera JAMAIS réentraîné. Aucun gradient ne modifiera ses poids. +Toute connaissance nouvelle arrive par ingestion (traces, hebbien, spawn). +L'entraînement s'arrête ici.** + +## Vérification — sortie verbatim + +Script jetable `.superpowers/scratch/verify_birth.py`, exécuté UNE fois le +2026-08-18 depuis la racine du repo avec `.venv-torch` (Python 3.11.9, +torch 2.13.0+cpu) : + +```text +fichier : C:\Users\PHIL\ZCodeProject\fractus-vorax\brain\FRACTUS_BIRTH.pt +taille : 4,662,144,283 octets +sha256 : 15cb8abe88809c4ad3793d1fd7829d24fd9702150002c323f368cf13f7be96fa (4.8s) +sonde : probe_checkpoint_kind -> 'cte' +clés wrapper (top-level) : ['config', 'frozen_merge', 'model_state', 'n'] + config.batch = 2 + config.boost = True + config.coupling_rank = 8 + config.d_head = 64 + config.d_model = 1280 + config.expert_d_ff = 2048 + config.gpu = 0 + config.lr = 0.0007 + config.n_experts = 128 + config.n_heads = 20 + config.n_layers = 16 + config.n_levels = 2 + config.n_oscillators = 16 + config.siren_rank = 64 + config.ss_rate = 0.25 + config.tokens_processed = 15564800 + config.top_k = 2 + frozen_merge = True + n = 8 +strict : from_checkpoint (load_state_dict strict=True) -> 440 cles +config inféré des shapes (+indices wrapper) : + d_model=1280 n_heads=20 d_head=64 n_layers=16 + n_levels=2 n_oscillators=16 coupling_rank=8 + n_experts=128 top_k=2 expert_d_ff=2048 siren_rank=64 + vocab_size=50257 batch=2 train_seq_len=128 +forward : B=1 L=4 -> logits (1, 4, 50257) finis=True min=-265.9127 max=264.5801 +VERDICT : cerveau de naissance INSTALLÉ et VÉRIFIÉ (strict 440/440, logits finis) +exit_code=0 +``` + +Le même jour, la suite torch complète (checkpoint réel inclus) a passé : +**142 passed, 0 skipped** — le test du vrai checkpoint (`slow`), skippé à la +naissance du repo, tourne et passe désormais (il lit `brain/FRACTUS_BIRTH.pt` +d'abord, repli sur l'héritage `checkpoints/checkpoints/`). La suite mingw +(substrat sans torch) reste **113 passed, 8 skipped** — inchangée. + +## Statut honnête + +Ce cerveau a vu passer 124,5 M de tokens — fin du stage 2, loss encore haute : +il babille, il ne répond pas encore. Le word salad est attendu et documenté +(`bench/core_speak.py` rapporte chaque génération verbatim). En attendant que +l'espèce s'affine un jour par nouvelle lignée, ce sont **les organes qui +portent les démos** (cartes FAIT/ANALOGIE, killer bench) — et c'est le point : +la compétence linguistique est née une fois ; la connaissance, elle, s'écrit à +l'infini par ingestion. + +## Appendice — empreinte complète + +``` +sha256( brain/FRACTUS_BIRTH.pt ) += 15cb8abe88809c4ad3793d1fd7829d24fd9702150002c323f368cf13f7be96fa +``` + +Toute copie future du cerveau de naissance DOIT retrouver cette empreinte, +octet pour octet. Si elle diffère, ce n'est pas ce cerveau. diff --git a/docs/heritage/cte-architecture.md b/docs/heritage/cte-architecture.md new file mode 100644 index 0000000000000000000000000000000000000000..13bf8646db4f1772b76d67d6ef09dc2416852246 --- /dev/null +++ b/docs/heritage/cte-architecture.md @@ -0,0 +1,771 @@ +# Architecture CTE — spec du checkpoint 1B « FROZEN_MERGED » + +> Source de vérité pour `vorax/model/cte_core.py` (plan 5, T2). Toute affirmation +> cite `fichier:ligne` du dépôt fractus-cte (LECTURE SEULE, jamais importé) ou du +> sondage du checkpoint local. Leçons P3 appliquées : chasse aux divergences +> silencieuses (GELU, orientation x@W) en priorité. + +## 0. Sources et méthode + +- Code : `C:\Users\PHIL\ZCodeProject\fractus-cte\fractus\continuous_engine.py` (moteur), + `fractus/nn/attention.py`, `fractus/nn/moe.py`, `fractus/nn/phase_ode.py`, + `fractus/nn/stats.py`, `fractus/nn/farey.py`. +- Entraînement (producteur exact du checkpoint) : `fractus-cte\scripts\fast4gpu_boost.py` + (la config sauvée lignes 207-215 correspond CLÉ POUR CLÉ au config sondé) ; + `scripts\fast4gpu_stage2_ss.py` (phase 2, B=2/SEQ=128, lignes 39) ; + `scripts\smoke_load_merged.py` (validation tick_chunk). +- Génération officielle : `fractus-cte\fractus\generate_aligned.py`. +- Checkpoint : `checkpoints/checkpoints/FRACTUS_1B_PHASE2_FROZEN_MERGED.pt` + (4,66 GB, gitignored), sondé avec `.venv-torch` (float32 intégral, 440 clés). + +Checkpoint top-level : `['model_state', 'config', 'frozen_merge', 'n']`, +`frozen_merge=True`, `n=8` (fusion de 8 checkpoints per-GPU ; l'outil de fusion +n'est PAS dans le dépôt fractus-cte — grep « frozen_merge » sans résultat ; +métadonnée sans effet sur le forward). + +Config sondé (identique à `fast4gpu_boost.py:47-59` + clés trainer lignes 207-215) : + +``` +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 (inféré de observe.weight), +gpu=0, boost=True, batch=2, lr=7e-4, ss_rate=0.25, tokens_processed=15_564_800 +``` + +`tokens_processed = 15 564 800 = batch(2) × SEQ(128) × 60 800 pas` — ~15,5 M +tokens par shard (chiffre honnête : attendre des productions faibles). + +## Forward + +### Rôle de `observe` vs embedding, et liaison de la tête de sortie + +- `observe` = `nn.Embedding(vocab_size, d_model)` (continuous_engine.py:262) : + embedding de token SEUL. **Pas de positional embedding, pas de norm + d'embedding** (contrairement au BPEEmbedding 88M, fractus_core.py:500-514) — + la position vient du carry (S, z) de l'attention linéaire. +- `output_head` = `nn.Linear(d_model, vocab_size, bias=False)` + (continuous_engine.py:280), **LIÉE** : `self.output_head.weight = + self.observe.weight` (continuous_engine.py:281). Sondage : `torch.equal( + observe.weight, output_head.weight) == True` (bit-identiques dans le + checkpoint ; les deux clés existent séparément dans le state_dict). + Convention nn.Linear : `logits = h @ output_head.weight.T` (les deux poids + (50257, 1280)). +- `confidence_head` = `nn.Linear(d_model, 1)` (continuous_engine.py:279) ; + `salience_head` = `nn.Linear(d_model, 1)` (continuous_engine.py:282). À + l'inférence ce sont des **lectures seules** : `confidence = + sigmoid(confidence_head(h[:, 0, :]))` est retournée par `tick()` + (continuous_engine.py:454) mais n'entre pas dans les logits ; + `salience` n'est utilisée QUE si une mémoire est attachée + (continuous_engine.py:437-439 ; `self.memory = None` par défaut, :285). + `generate_aligned.generate_chunk` n'utilise ni l'une ni l'autre. +- **Pas de LayerNorm final** avant la tête (contrairement à + fractus_core.py:572/582-583) : `output_head` s'applique directement au h + sorti du dernier bloc (continuous_engine.py:474). + +### DEUX chemins de forward — celui du training/generation est le CHUNK + +Le moteur a deux familles de forward, et elles NE SONT PAS équivalentes : + +1. `tick()` → `CTEBlock.tick_single` (continuous_engine.py:405-456, 82-137) : + un token à la fois. **Attention mono-niveau** : `q_feat = elu_plus_one(q + + attn.level_offsets[0])` (continuous_engine.py:93-94) — offset du niveau 0 + uniquement, PAS d'agrégation `softmax(level_logits)`. Boucle Python par + tête pour la mise à jour (S, z) (continuous_engine.py:96-106). +2. `tick_chunk()` / `tick_chunk_train()` → `CTEBlock.tick_chunk_core` + (continuous_engine.py:458-497, 139-217) : chunk (B, C, d), attention + multi-niveaux complète avec carry, agrégation `softmax(level_logits)` + (continuous_engine.py:196-197). + +**Décision tranchée** : le training a utilisé `tick_chunk_train` +(fast4gpu_boost.py:152, fast4gpu_stage2_ss.py:49) et la génération officielle +utilise `tick_chunk` SEULE — « Uses tick_chunk only (same path as stage2 +training), never tick_single » (generate_aligned.py:1-4, appels :34 et :47). +`tick_single` contient un reste de mismatch train/gén connu (commentaire +continuous_engine.py:123-124 : le Kuramoto a été réaligné sur RK4, mais +l'attention y reste mono-niveau). **CteCore DOIT implémenter le chemin +`tick_chunk_core` ; la génération token par token = chunks de longueur 1.** +Ne jamais reproduire `tick_single`. + +### Sémantique exacte de `tick_chunk_core` (continuous_engine.py:139-217) + +Entrée `h` : (B, C, d_model). Pour chaque bloc, dans l'ordre : + +1. **Attention pré-norm** : `h_normed = self.norm_attn(h)` (:149). Projections + Q/K/V en convention **x@W** : `q = einsum("bld,de->ble", h_normed, + attn.w_qkv[0]) + attn.b_qkv[0]` (idem k avec l'indice 1, v avec 2) + (:150-152 ; attention.py:204-206). `w_qkv` (3, d, d) est indexé [q, k, v], + chaque tranche (in=d_model, out=n_heads·d_head) — **PAS nn.Linear x@Wᵀ** + (leçon P3, encore vraie ici). +2. **Têtes et niveaux** : reshape (B, C, nH, dH) (:153-155) ; offsets par + niveau `ω_level = (φ²)^(−level)` ajoutés à q et k (:157-159 ; + attention.py:21-31 ; sondé `[1.0, 0.3819660544395447]` = 2.618^0, 2.618^−1) ; + feature map `elu_plus_one(x, α=1)` sur q et k UNIQUEMENT — v reste brut + (:160-162 ; stats.py:18-31 : φ(x) = x+1 si x>0 sinon α(e^x−1)+1). +3. **Aplat multi-niveaux×têtes** : (B·n_levels·nH, C, dH) via + permute(0,1,3,2,4) (:163-165). +4. **Carry (S, z)** : extraction des blocs diagonaux par tête de `attn_S` / + `attn_z` (:168-175), expansion identique sur les n_levels (:176-179). +5. **Attention linéaire causale INCLUSIVE avec carry** + (attention.py:129-187) : `S_t = S0 + Σ_{i≤t} k_i⊗v_i`, `z_t = z0 + Σ_{i≤t} + k_i`, `y_t = (q_t·S_t)/(q_t·z_t)`, sortie 0 si |dénom| < 1e-10 + (attention.py:155-179). Retourne y ET l'état final (S_final, z_final) + uniquement quand un carry est fourni (attention.py:181-186). +6. **Nouvel état** : `S_final` (B·nL·nH, dH, dH) → reshape (B, nL, nH, dH, + dH) → **MOYENNE sur les niveaux** (dim=1) → reconstruction + bloc-diagonale (B, nH·dH, nH·dH) avec zéros hors blocs (:184-193). Les + états sont `.detach()` (:192-193). +7. **Agrégation de niveaux + sortie** : y → (B, nL, C, nH·dH) (:195) ; + `level_weights = torch.softmax(attn.level_logits, dim=-1)` (:196) ; + `attn_out = Σ_level w_level · y_level` (:197) ; projection **x@W** : + `attn_out @ attn.w_out + attn.b_out` (:198) ; **résidu** `h = h + + attn_out` (:199). (torch.softmax ici, stable_softmax dans + attention.py:232 — équivalentes numériquement, softmax torch étant + stabilisé par soustraction du max.) +8. **Kuramoto pré-norm** : `h_kur = self.norm_kur(h)` (:203). Phases initiales + depuis le hidden : `θ_init = 2π·mean_d(h_kur) + 2π·i/N (mod 2π)` + (phase_ode.py:113-119). Intégration **RK4, n_steps=1, dt=0.1** + (continuous_engine.py:52-53 ; phase_ode.py:91-111), dérivée + `dθ_i/dt = ω_i − 0.01·θ_i + Σ_j K_ij sin(θ_j − θ_i)` avec + `K = UΛUᵀ` bas-rang (phase_ode.py:55-69 ; damping=0.01 par défaut + :37). Wrap mod 2π après chaque pas (phase_ode.py:103-110). Buffer + `self.kuramoto_phases = theta.detach()` (:206). +9. **MoE pré-norm** : `h_moe = self.norm_moe(h)` (:211) ; **phases de la + DERNIÈRE position du chunk broadcastées sur tout le chunk** : + `phases_last = theta[:, -1:, :]` puis `expand(-1, C, -1)` (:212-213) ; + `moe_out, lb_loss = self.moe(h_moe, phases_in)` (:214) ; **résidu** + `h = h + moe_out` (:215). Retour (h, lb_loss). + +### Niveau moteur (tick_chunk, continuous_engine.py:458-475) + +1. `obs_vecs = self.observe(observations)` (:463) — embedding token seul. +2. `h[:, 0, :] += self.thought_state[:, 0, :]` (:465) — le flux résiduel + porté n'est ajouté **qu'à la position 0** du chunk. +3. Pile des blocs : `h, lb = blk.tick_chunk_core(h)` (:468-470) ; `lb` + cumulée détachée dans `last_lb_loss` (:470-471). +4. `self.thought_state = h[:, -1:, :].detach()` (:473) — le dernier h devient + le nouveau porteur. +5. `output_logits = self.output_head(h)` (:474) — logits (B, C, vocab) sur + TOUTES les positions (dense CE au training, tick_chunk_train :477-497 + identique + lb non détachée). + +## États porteurs + +Trois familles d'états persistants, TOUS enregistrés en buffers donc présents +dans le state_dict (d'où leurs clés) : + +| État (clé) | Shape checkpoint | Sémantique | Mise à jour | Reset | +|---|---|---|---|---| +| `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) | +| `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) | +| `blocks.N.attn_z` | (2, 1280) | (batch, n_heads·d_head), segments par tête | idem S (continuous_engine.py:184-193) | zeros | +| `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) | +| `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) | + +Points critiques pour T2 : + +- **`attn_S`/`attn_z` sont VRAIMENT porteurs** (lues en carry au forward + suivant, continuous_engine.py:168-182) ; `kuramoto_phases` est un snapshot + d'observabilité écrit-seul : sa SHAPE compte pour le strict load (2, 128, + 16), sa VALEUR est sans effet. `reset_state` re-dimensionne ces buffers + dynamiquement (continuous_engine.py:73-80) — construire en (2, …) pour le + strict load puis `reset_states()` en batch 1 à l'inférence est fidèle. +- Le checkpoint contient ces états NON NULS (sondage : absmax thought_state + 20.6, attn_S 1.0e8, attn_z 3.4e7 — résidus du dernier chunk d'entraînement, + S ayant accumulé depuis le `reset_thought` de début d'époque, + fast4gpu_boost.py:105). Sans effet : la génération officielle reset tout + avant de commencer (generate_aligned.py:22-27). +- La première dimension 2 partout = `batch` du trainer (B=2, + fast4gpu_stage2_ss.py:39 ; `batch: 2` dans le config). À l'inférence la + génération officielle travaille en batch 1 (`reset_thought(1)`, + generate_aligned.py:22). +- `reset_thought` (continuous_engine.py:336-341) remet TOUT à zéro : + thought_state + attn_S/attn_z/kuramoto_phases de chaque bloc. C'est le reset + inter-séquences à l'inférence. + +## MoE batché + +`PhaseRoutedMoE` (moe.py:34-326), construit avec `kappa=4.0`, +`d_ff=expert_d_ff`, `expert_rank=siren_rank` (continuous_engine.py:60-64) — +mode **low-rank batché** (moe.py:91-107) : un seul tenseur par famille +d'experts, dim 0 = index d'expert E=128. + +- `expert_phases` (E,) : buffer Farey (moe.py:79-80 ; farey.py:40-55 : + E angles 2π·p/q tirés uniformément de F_{2E}). Sondage : max|ckpt − + recompute| = 6.4e-07 (arrondi float32) — régénérable, mais la clé existe → + buffer pour le strict load. +- **Routage von Mises** (moe.py:109-125) : depuis les phases (B, L, N=16) : + `θ̄ = atan2(Σ_n sin θ_n, Σ_n cos θ_n)` (:114-116) ; **`κ_eff = + kappa / temperature`** (:117) ; `gate_e ∝ exp(κ_eff·cos(θ̄ − φ_e))`, + normalisées (uniforme si somme < 1e-10) (:118-125). `top_k=2` par valeur + (moe.py:304), gates retenues **renormalisées** sur les k retenus (:305-309). +- **TEMPÉRATURE = 2.5 À L'ENTRAÎNEMENT — PIÈGE** : `temperature` est un + attribut runtime, défaut 1.0 (moe.py:59, 73-74), **absent du state_dict et + du config**. Tous les trainers la fixent à 2.5 après chargement + (fast4gpu_boost.py:39 et 99-102 ; fast4gpu_stage2_ss.py:9 et 32-34 ; + fast4gpu_surgery.py:53). Pour une inférence fidèle aux poids : + **κ_eff = 4.0 / 2.5 = 1.6**. L'oublier change toutes les gates → divergence + silencieuse (même famille que la leçon GELU de P3). +- **Dispatch** : sparse gather-first si `E > 2·top_k` (moe.py:312) — avec + E=128, K=2 toujours vérifié → chemin `_sparse_expert_forward` + (moe.py:193-238). Bit-identique au dense (moe.py:8-17, 311-318). +- **Calcul par expert (low-rank, moe.py:213-238)** — orientation x@W partout : + 1. Couche 1 : `hV1 = einsum('nd,nkdr->nkr', h, V1)` = **h @ V1** avec V1 + (E, D=1280, r=64) (:230) ; `h1 = scale1 · (hV1 @ U1ᵀ) + b1` avec U1 + (E, F=2048, r=64) (:232) — soit `W1_eff = scale1·U1@V1ᵀ` de forme + (F, D), appliqué **x@W1** (PAS nn.Linear x@Wᵀ). scale1 (E, 1, 1) + multiplie AVANT le bias : `g_s1.squeeze(-1) * (...) + g_b1`. + 2. **Activation : GELU — CONFIRMÉE** : `h1_act = _gelu(h1)` (moe.py:233, + aussi :248, :268, :278 sur tous les chemins). **Approximation TANH** + (moe.py:27-31) : `0.5·x·(1 + tanh(√(2/π)·(x + 0.044715·x³)))` — + PAS `F.gelu` exact (qui est la variante du 88M, fractus_core.py:481). + Différence max ~3e-3 par activation, amplifiée par 16 blocs. + 3. Couche 2 : `hV2 = h1_act @ V2` avec V2 (E, F=2048, r) (:236) ; + `out = scale2 · (hV2 @ U2ᵀ) + b2` avec U2 (E, D=1280, r) (:237) — + `W2_eff = scale2·U2@V2ᵀ` (D, F), appliqué x@W2. + 4. Mélange : `output = Σ_k gate_norm_k · out_k` (moe.py:319). +- **Load-balance** (training uniquement) : `P = gates.mean(dim=(0,1))`, + `lb = E·Σ_e (P_e − 1/E)²` (moe.py:323-324). Le poids du MoE retourne + `(output, lb_loss)` (moe.py:286-326) ; à l'inférence lb est ignorée. +- `add_expert` (moe.py:127-191, self-modification) : hors scope inférence, + jamais appelé par les scripts de génération. + +## Recette d'inférence + +Recette officielle = `generate_aligned.generate_chunk` +(generate_aligned.py:9-50), « train-aligned » : + +1. `engine.eval()` ; **reset complet** : `engine.reset_thought(1)` — + thought_state=0, attn_S/attn_z/kuramoto_phases de chaque bloc=0 en batch 1 + (continuous_engine.py:336-341, 73-80) ; le script re-zero attn_S/attn_z + (:23-27, redondant). +2. **Fixer `moe.temperature = 2.5` sur chaque bloc** (κ_eff=1.6) — fait par + les trainers au chargement (fast4gpu_boost.py:99-102), à répliquer au + chargement du checkpoint côté CteCore. +3. Prompt encodé, tronqué (context_limit=128 dans le script d'origine, valeur + de sampling sans importance pour le noyau) : **UN SEUL chunk** `logits = + tick_chunk(prompt)` ; prendre `logits[0, -1]` (generate_aligned.py:34-35). +4. Boucle token par token : **chunk de longueur 1** `logits = + tick_chunk([[next]])` (generate_aligned.py:47) ; `cur = logits[0, -1]` ; + greedy = `argmax(cur)` (déterministe ; le script d'origine utilise + température/top-k/repetition-ban — options de décodage, pas du noyau). +5. Les états porteurs vivent À TRAVERS la boucle : jamais de reset entre les + tokens, jamais d'appel à `tick()`/`tick_single` (mismatch mono-niveau, + cf. §Forward). +6. `boost`, `ss_rate`, `lr` : **métadonnées training uniquement**. Scheduled + sampling (fast4gpu_boost.py:167-188) : avec proba ss_rate=0.25 par pas, une + SECONDE passe d'entraînement sur un chunk mixte (20 % des tokens remplacés + par les propres échantillons du modèle, position 0 protégée). Zéro effet + sur le forward d'inférence. `batch=2` : dimension batch des états du + checkpoint (cf. §États porteurs). +7. Fin de séquence : sémantique ratifiée P4 « append-then-break » sur EOS + (à implémenter côté CteCore.generate ; l'original n'a pas d'EOS). + +Pour T2 : `CteCore.forward(token_ids)` = cette recette étape 3 pour un chunk +quelconque (B, L) ; `CteCore.generate` = étapes 1-4 en greedy avec reset +initial. Le contexte est non borné (l'attention linéaire condense tout +l'historique dans (S, z)) mais les magnitudes de S croissent sans reset — +le reset inter-séquences est obligatoire. + +## Table des clés + +440 clés au total = 8 top-level + 16 blocs × 27 (sondage direct du +checkpoint, trié ; toutes float32 ; `blocks.{0..15}` suivent le MÊME motif — +tableau généré mécaniquement depuis le checkpoint) : + +| Clé | Shape | +|---|---| +| `blocks.0.attn.b_out` | (1280,) | +| `blocks.0.attn.b_qkv` | (3, 1280) | +| `blocks.0.attn.level_logits` | (2,) | +| `blocks.0.attn.level_offsets` | (2,) | +| `blocks.0.attn.w_out` | (1280, 1280) | +| `blocks.0.attn.w_qkv` | (3, 1280, 1280) | +| `blocks.0.attn_S` | (2, 1280, 1280) | +| `blocks.0.attn_z` | (2, 1280) | +| `blocks.0.kuramoto.coupling_lambda` | (8,) | +| `blocks.0.kuramoto.coupling_u` | (16, 8) | +| `blocks.0.kuramoto.omega` | (16,) | +| `blocks.0.kuramoto_phases` | (2, 128, 16) | +| `blocks.0.moe.U1` | (128, 2048, 64) | +| `blocks.0.moe.U2` | (128, 1280, 64) | +| `blocks.0.moe.V1` | (128, 1280, 64) | +| `blocks.0.moe.V2` | (128, 2048, 64) | +| `blocks.0.moe.b1` | (128, 2048) | +| `blocks.0.moe.b2` | (128, 1280) | +| `blocks.0.moe.expert_phases` | (128,) | +| `blocks.0.moe.scale1` | (128, 1, 1) | +| `blocks.0.moe.scale2` | (128, 1, 1) | +| `blocks.0.norm_attn.bias` | (1280,) | +| `blocks.0.norm_attn.weight` | (1280,) | +| `blocks.0.norm_kur.bias` | (1280,) | +| `blocks.0.norm_kur.weight` | (1280,) | +| `blocks.0.norm_moe.bias` | (1280,) | +| `blocks.0.norm_moe.weight` | (1280,) | +| `blocks.1.attn.b_out` | (1280,) | +| `blocks.1.attn.b_qkv` | (3, 1280) | +| `blocks.1.attn.level_logits` | (2,) | +| `blocks.1.attn.level_offsets` | (2,) | +| `blocks.1.attn.w_out` | (1280, 1280) | +| `blocks.1.attn.w_qkv` | (3, 1280, 1280) | +| `blocks.1.attn_S` | (2, 1280, 1280) | +| `blocks.1.attn_z` | (2, 1280) | +| `blocks.1.kuramoto.coupling_lambda` | (8,) | +| `blocks.1.kuramoto.coupling_u` | (16, 8) | +| `blocks.1.kuramoto.omega` | (16,) | +| `blocks.1.kuramoto_phases` | (2, 128, 16) | +| `blocks.1.moe.U1` | (128, 2048, 64) | +| `blocks.1.moe.U2` | (128, 1280, 64) | +| `blocks.1.moe.V1` | (128, 1280, 64) | +| `blocks.1.moe.V2` | (128, 2048, 64) | +| `blocks.1.moe.b1` | (128, 2048) | +| `blocks.1.moe.b2` | (128, 1280) | +| `blocks.1.moe.expert_phases` | (128,) | +| `blocks.1.moe.scale1` | (128, 1, 1) | +| `blocks.1.moe.scale2` | (128, 1, 1) | +| `blocks.1.norm_attn.bias` | (1280,) | +| `blocks.1.norm_attn.weight` | (1280,) | +| `blocks.1.norm_kur.bias` | (1280,) | +| `blocks.1.norm_kur.weight` | (1280,) | +| `blocks.1.norm_moe.bias` | (1280,) | +| `blocks.1.norm_moe.weight` | (1280,) | +| `blocks.10.attn.b_out` | (1280,) | +| `blocks.10.attn.b_qkv` | (3, 1280) | +| `blocks.10.attn.level_logits` | (2,) | +| `blocks.10.attn.level_offsets` | (2,) | +| `blocks.10.attn.w_out` | (1280, 1280) | +| `blocks.10.attn.w_qkv` | (3, 1280, 1280) | +| `blocks.10.attn_S` | (2, 1280, 1280) | +| `blocks.10.attn_z` | (2, 1280) | +| `blocks.10.kuramoto.coupling_lambda` | (8,) | +| `blocks.10.kuramoto.coupling_u` | (16, 8) | +| `blocks.10.kuramoto.omega` | (16,) | +| `blocks.10.kuramoto_phases` | (2, 128, 16) | +| `blocks.10.moe.U1` | (128, 2048, 64) | +| `blocks.10.moe.U2` | (128, 1280, 64) | +| `blocks.10.moe.V1` | (128, 1280, 64) | +| `blocks.10.moe.V2` | (128, 2048, 64) | +| `blocks.10.moe.b1` | (128, 2048) | +| `blocks.10.moe.b2` | (128, 1280) | +| `blocks.10.moe.expert_phases` | (128,) | +| `blocks.10.moe.scale1` | (128, 1, 1) | +| `blocks.10.moe.scale2` | (128, 1, 1) | +| `blocks.10.norm_attn.bias` | (1280,) | +| `blocks.10.norm_attn.weight` | (1280,) | +| `blocks.10.norm_kur.bias` | (1280,) | +| `blocks.10.norm_kur.weight` | (1280,) | +| `blocks.10.norm_moe.bias` | (1280,) | +| `blocks.10.norm_moe.weight` | (1280,) | +| `blocks.11.attn.b_out` | (1280,) | +| `blocks.11.attn.b_qkv` | (3, 1280) | +| `blocks.11.attn.level_logits` | (2,) | +| `blocks.11.attn.level_offsets` | (2,) | +| `blocks.11.attn.w_out` | (1280, 1280) | +| `blocks.11.attn.w_qkv` | (3, 1280, 1280) | +| `blocks.11.attn_S` | (2, 1280, 1280) | +| `blocks.11.attn_z` | (2, 1280) | +| `blocks.11.kuramoto.coupling_lambda` | (8,) | +| `blocks.11.kuramoto.coupling_u` | (16, 8) | +| `blocks.11.kuramoto.omega` | (16,) | +| `blocks.11.kuramoto_phases` | (2, 128, 16) | +| `blocks.11.moe.U1` | (128, 2048, 64) | +| `blocks.11.moe.U2` | (128, 1280, 64) | +| `blocks.11.moe.V1` | (128, 1280, 64) | +| `blocks.11.moe.V2` | (128, 2048, 64) | +| `blocks.11.moe.b1` | (128, 2048) | +| `blocks.11.moe.b2` | (128, 1280) | +| `blocks.11.moe.expert_phases` | (128,) | +| `blocks.11.moe.scale1` | (128, 1, 1) | +| `blocks.11.moe.scale2` | (128, 1, 1) | +| `blocks.11.norm_attn.bias` | (1280,) | +| `blocks.11.norm_attn.weight` | (1280,) | +| `blocks.11.norm_kur.bias` | (1280,) | +| `blocks.11.norm_kur.weight` | (1280,) | +| `blocks.11.norm_moe.bias` | (1280,) | +| `blocks.11.norm_moe.weight` | (1280,) | +| `blocks.12.attn.b_out` | (1280,) | +| `blocks.12.attn.b_qkv` | (3, 1280) | +| `blocks.12.attn.level_logits` | (2,) | +| `blocks.12.attn.level_offsets` | (2,) | +| `blocks.12.attn.w_out` | (1280, 1280) | +| `blocks.12.attn.w_qkv` | (3, 1280, 1280) | +| `blocks.12.attn_S` | (2, 1280, 1280) | +| `blocks.12.attn_z` | (2, 1280) | +| `blocks.12.kuramoto.coupling_lambda` | (8,) | +| `blocks.12.kuramoto.coupling_u` | (16, 8) | +| `blocks.12.kuramoto.omega` | (16,) | +| `blocks.12.kuramoto_phases` | (2, 128, 16) | +| `blocks.12.moe.U1` | (128, 2048, 64) | +| `blocks.12.moe.U2` | (128, 1280, 64) | +| `blocks.12.moe.V1` | (128, 1280, 64) | +| `blocks.12.moe.V2` | (128, 2048, 64) | +| `blocks.12.moe.b1` | (128, 2048) | +| `blocks.12.moe.b2` | (128, 1280) | +| `blocks.12.moe.expert_phases` | (128,) | +| `blocks.12.moe.scale1` | (128, 1, 1) | +| `blocks.12.moe.scale2` | (128, 1, 1) | +| `blocks.12.norm_attn.bias` | (1280,) | +| `blocks.12.norm_attn.weight` | (1280,) | +| `blocks.12.norm_kur.bias` | (1280,) | +| `blocks.12.norm_kur.weight` | (1280,) | +| `blocks.12.norm_moe.bias` | (1280,) | +| `blocks.12.norm_moe.weight` | (1280,) | +| `blocks.13.attn.b_out` | (1280,) | +| `blocks.13.attn.b_qkv` | (3, 1280) | +| `blocks.13.attn.level_logits` | (2,) | +| `blocks.13.attn.level_offsets` | (2,) | +| `blocks.13.attn.w_out` | (1280, 1280) | +| `blocks.13.attn.w_qkv` | (3, 1280, 1280) | +| `blocks.13.attn_S` | (2, 1280, 1280) | +| `blocks.13.attn_z` | (2, 1280) | +| `blocks.13.kuramoto.coupling_lambda` | (8,) | +| `blocks.13.kuramoto.coupling_u` | (16, 8) | +| `blocks.13.kuramoto.omega` | (16,) | +| `blocks.13.kuramoto_phases` | (2, 128, 16) | +| `blocks.13.moe.U1` | (128, 2048, 64) | +| `blocks.13.moe.U2` | (128, 1280, 64) | +| `blocks.13.moe.V1` | (128, 1280, 64) | +| `blocks.13.moe.V2` | (128, 2048, 64) | +| `blocks.13.moe.b1` | (128, 2048) | +| `blocks.13.moe.b2` | (128, 1280) | +| `blocks.13.moe.expert_phases` | (128,) | +| `blocks.13.moe.scale1` | (128, 1, 1) | +| `blocks.13.moe.scale2` | (128, 1, 1) | +| `blocks.13.norm_attn.bias` | (1280,) | +| `blocks.13.norm_attn.weight` | (1280,) | +| `blocks.13.norm_kur.bias` | (1280,) | +| `blocks.13.norm_kur.weight` | (1280,) | +| `blocks.13.norm_moe.bias` | (1280,) | +| `blocks.13.norm_moe.weight` | (1280,) | +| `blocks.14.attn.b_out` | (1280,) | +| `blocks.14.attn.b_qkv` | (3, 1280) | +| `blocks.14.attn.level_logits` | (2,) | +| `blocks.14.attn.level_offsets` | (2,) | +| `blocks.14.attn.w_out` | (1280, 1280) | +| `blocks.14.attn.w_qkv` | (3, 1280, 1280) | +| `blocks.14.attn_S` | (2, 1280, 1280) | +| `blocks.14.attn_z` | (2, 1280) | +| `blocks.14.kuramoto.coupling_lambda` | (8,) | +| `blocks.14.kuramoto.coupling_u` | (16, 8) | +| `blocks.14.kuramoto.omega` | (16,) | +| `blocks.14.kuramoto_phases` | (2, 128, 16) | +| `blocks.14.moe.U1` | (128, 2048, 64) | +| `blocks.14.moe.U2` | (128, 1280, 64) | +| `blocks.14.moe.V1` | (128, 1280, 64) | +| `blocks.14.moe.V2` | (128, 2048, 64) | +| `blocks.14.moe.b1` | (128, 2048) | +| `blocks.14.moe.b2` | (128, 1280) | +| `blocks.14.moe.expert_phases` | (128,) | +| `blocks.14.moe.scale1` | (128, 1, 1) | +| `blocks.14.moe.scale2` | (128, 1, 1) | +| `blocks.14.norm_attn.bias` | (1280,) | +| `blocks.14.norm_attn.weight` | (1280,) | +| `blocks.14.norm_kur.bias` | (1280,) | +| `blocks.14.norm_kur.weight` | (1280,) | +| `blocks.14.norm_moe.bias` | (1280,) | +| `blocks.14.norm_moe.weight` | (1280,) | +| `blocks.15.attn.b_out` | (1280,) | +| `blocks.15.attn.b_qkv` | (3, 1280) | +| `blocks.15.attn.level_logits` | (2,) | +| `blocks.15.attn.level_offsets` | (2,) | +| `blocks.15.attn.w_out` | (1280, 1280) | +| `blocks.15.attn.w_qkv` | (3, 1280, 1280) | +| `blocks.15.attn_S` | (2, 1280, 1280) | +| `blocks.15.attn_z` | (2, 1280) | +| `blocks.15.kuramoto.coupling_lambda` | (8,) | +| `blocks.15.kuramoto.coupling_u` | (16, 8) | +| `blocks.15.kuramoto.omega` | (16,) | +| `blocks.15.kuramoto_phases` | (2, 128, 16) | +| `blocks.15.moe.U1` | (128, 2048, 64) | +| `blocks.15.moe.U2` | (128, 1280, 64) | +| `blocks.15.moe.V1` | (128, 1280, 64) | +| `blocks.15.moe.V2` | (128, 2048, 64) | +| `blocks.15.moe.b1` | (128, 2048) | +| `blocks.15.moe.b2` | (128, 1280) | +| `blocks.15.moe.expert_phases` | (128,) | +| `blocks.15.moe.scale1` | (128, 1, 1) | +| `blocks.15.moe.scale2` | (128, 1, 1) | +| `blocks.15.norm_attn.bias` | (1280,) | +| `blocks.15.norm_attn.weight` | (1280,) | +| `blocks.15.norm_kur.bias` | (1280,) | +| `blocks.15.norm_kur.weight` | (1280,) | +| `blocks.15.norm_moe.bias` | (1280,) | +| `blocks.15.norm_moe.weight` | (1280,) | +| `blocks.2.attn.b_out` | (1280,) | +| `blocks.2.attn.b_qkv` | (3, 1280) | +| `blocks.2.attn.level_logits` | (2,) | +| `blocks.2.attn.level_offsets` | (2,) | +| `blocks.2.attn.w_out` | (1280, 1280) | +| `blocks.2.attn.w_qkv` | (3, 1280, 1280) | +| `blocks.2.attn_S` | (2, 1280, 1280) | +| `blocks.2.attn_z` | (2, 1280) | +| `blocks.2.kuramoto.coupling_lambda` | (8,) | +| `blocks.2.kuramoto.coupling_u` | (16, 8) | +| `blocks.2.kuramoto.omega` | (16,) | +| `blocks.2.kuramoto_phases` | (2, 128, 16) | +| `blocks.2.moe.U1` | (128, 2048, 64) | +| `blocks.2.moe.U2` | (128, 1280, 64) | +| `blocks.2.moe.V1` | (128, 1280, 64) | +| `blocks.2.moe.V2` | (128, 2048, 64) | +| `blocks.2.moe.b1` | (128, 2048) | +| `blocks.2.moe.b2` | (128, 1280) | +| `blocks.2.moe.expert_phases` | (128,) | +| `blocks.2.moe.scale1` | (128, 1, 1) | +| `blocks.2.moe.scale2` | (128, 1, 1) | +| `blocks.2.norm_attn.bias` | (1280,) | +| `blocks.2.norm_attn.weight` | (1280,) | +| `blocks.2.norm_kur.bias` | (1280,) | +| `blocks.2.norm_kur.weight` | (1280,) | +| `blocks.2.norm_moe.bias` | (1280,) | +| `blocks.2.norm_moe.weight` | (1280,) | +| `blocks.3.attn.b_out` | (1280,) | +| `blocks.3.attn.b_qkv` | (3, 1280) | +| `blocks.3.attn.level_logits` | (2,) | +| `blocks.3.attn.level_offsets` | (2,) | +| `blocks.3.attn.w_out` | (1280, 1280) | +| `blocks.3.attn.w_qkv` | (3, 1280, 1280) | +| `blocks.3.attn_S` | (2, 1280, 1280) | +| `blocks.3.attn_z` | (2, 1280) | +| `blocks.3.kuramoto.coupling_lambda` | (8,) | +| `blocks.3.kuramoto.coupling_u` | (16, 8) | +| `blocks.3.kuramoto.omega` | (16,) | +| `blocks.3.kuramoto_phases` | (2, 128, 16) | +| `blocks.3.moe.U1` | (128, 2048, 64) | +| `blocks.3.moe.U2` | (128, 1280, 64) | +| `blocks.3.moe.V1` | (128, 1280, 64) | +| `blocks.3.moe.V2` | (128, 2048, 64) | +| `blocks.3.moe.b1` | (128, 2048) | +| `blocks.3.moe.b2` | (128, 1280) | +| `blocks.3.moe.expert_phases` | (128,) | +| `blocks.3.moe.scale1` | (128, 1, 1) | +| `blocks.3.moe.scale2` | (128, 1, 1) | +| `blocks.3.norm_attn.bias` | (1280,) | +| `blocks.3.norm_attn.weight` | (1280,) | +| `blocks.3.norm_kur.bias` | (1280,) | +| `blocks.3.norm_kur.weight` | (1280,) | +| `blocks.3.norm_moe.bias` | (1280,) | +| `blocks.3.norm_moe.weight` | (1280,) | +| `blocks.4.attn.b_out` | (1280,) | +| `blocks.4.attn.b_qkv` | (3, 1280) | +| `blocks.4.attn.level_logits` | (2,) | +| `blocks.4.attn.level_offsets` | (2,) | +| `blocks.4.attn.w_out` | (1280, 1280) | +| `blocks.4.attn.w_qkv` | (3, 1280, 1280) | +| `blocks.4.attn_S` | (2, 1280, 1280) | +| `blocks.4.attn_z` | (2, 1280) | +| `blocks.4.kuramoto.coupling_lambda` | (8,) | +| `blocks.4.kuramoto.coupling_u` | (16, 8) | +| `blocks.4.kuramoto.omega` | (16,) | +| `blocks.4.kuramoto_phases` | (2, 128, 16) | +| `blocks.4.moe.U1` | (128, 2048, 64) | +| `blocks.4.moe.U2` | (128, 1280, 64) | +| `blocks.4.moe.V1` | (128, 1280, 64) | +| `blocks.4.moe.V2` | (128, 2048, 64) | +| `blocks.4.moe.b1` | (128, 2048) | +| `blocks.4.moe.b2` | (128, 1280) | +| `blocks.4.moe.expert_phases` | (128,) | +| `blocks.4.moe.scale1` | (128, 1, 1) | +| `blocks.4.moe.scale2` | (128, 1, 1) | +| `blocks.4.norm_attn.bias` | (1280,) | +| `blocks.4.norm_attn.weight` | (1280,) | +| `blocks.4.norm_kur.bias` | (1280,) | +| `blocks.4.norm_kur.weight` | (1280,) | +| `blocks.4.norm_moe.bias` | (1280,) | +| `blocks.4.norm_moe.weight` | (1280,) | +| `blocks.5.attn.b_out` | (1280,) | +| `blocks.5.attn.b_qkv` | (3, 1280) | +| `blocks.5.attn.level_logits` | (2,) | +| `blocks.5.attn.level_offsets` | (2,) | +| `blocks.5.attn.w_out` | (1280, 1280) | +| `blocks.5.attn.w_qkv` | (3, 1280, 1280) | +| `blocks.5.attn_S` | (2, 1280, 1280) | +| `blocks.5.attn_z` | (2, 1280) | +| `blocks.5.kuramoto.coupling_lambda` | (8,) | +| `blocks.5.kuramoto.coupling_u` | (16, 8) | +| `blocks.5.kuramoto.omega` | (16,) | +| `blocks.5.kuramoto_phases` | (2, 128, 16) | +| `blocks.5.moe.U1` | (128, 2048, 64) | +| `blocks.5.moe.U2` | (128, 1280, 64) | +| `blocks.5.moe.V1` | (128, 1280, 64) | +| `blocks.5.moe.V2` | (128, 2048, 64) | +| `blocks.5.moe.b1` | (128, 2048) | +| `blocks.5.moe.b2` | (128, 1280) | +| `blocks.5.moe.expert_phases` | (128,) | +| `blocks.5.moe.scale1` | (128, 1, 1) | +| `blocks.5.moe.scale2` | (128, 1, 1) | +| `blocks.5.norm_attn.bias` | (1280,) | +| `blocks.5.norm_attn.weight` | (1280,) | +| `blocks.5.norm_kur.bias` | (1280,) | +| `blocks.5.norm_kur.weight` | (1280,) | +| `blocks.5.norm_moe.bias` | (1280,) | +| `blocks.5.norm_moe.weight` | (1280,) | +| `blocks.6.attn.b_out` | (1280,) | +| `blocks.6.attn.b_qkv` | (3, 1280) | +| `blocks.6.attn.level_logits` | (2,) | +| `blocks.6.attn.level_offsets` | (2,) | +| `blocks.6.attn.w_out` | (1280, 1280) | +| `blocks.6.attn.w_qkv` | (3, 1280, 1280) | +| `blocks.6.attn_S` | (2, 1280, 1280) | +| `blocks.6.attn_z` | (2, 1280) | +| `blocks.6.kuramoto.coupling_lambda` | (8,) | +| `blocks.6.kuramoto.coupling_u` | (16, 8) | +| `blocks.6.kuramoto.omega` | (16,) | +| `blocks.6.kuramoto_phases` | (2, 128, 16) | +| `blocks.6.moe.U1` | (128, 2048, 64) | +| `blocks.6.moe.U2` | (128, 1280, 64) | +| `blocks.6.moe.V1` | (128, 1280, 64) | +| `blocks.6.moe.V2` | (128, 2048, 64) | +| `blocks.6.moe.b1` | (128, 2048) | +| `blocks.6.moe.b2` | (128, 1280) | +| `blocks.6.moe.expert_phases` | (128,) | +| `blocks.6.moe.scale1` | (128, 1, 1) | +| `blocks.6.moe.scale2` | (128, 1, 1) | +| `blocks.6.norm_attn.bias` | (1280,) | +| `blocks.6.norm_attn.weight` | (1280,) | +| `blocks.6.norm_kur.bias` | (1280,) | +| `blocks.6.norm_kur.weight` | (1280,) | +| `blocks.6.norm_moe.bias` | (1280,) | +| `blocks.6.norm_moe.weight` | (1280,) | +| `blocks.7.attn.b_out` | (1280,) | +| `blocks.7.attn.b_qkv` | (3, 1280) | +| `blocks.7.attn.level_logits` | (2,) | +| `blocks.7.attn.level_offsets` | (2,) | +| `blocks.7.attn.w_out` | (1280, 1280) | +| `blocks.7.attn.w_qkv` | (3, 1280, 1280) | +| `blocks.7.attn_S` | (2, 1280, 1280) | +| `blocks.7.attn_z` | (2, 1280) | +| `blocks.7.kuramoto.coupling_lambda` | (8,) | +| `blocks.7.kuramoto.coupling_u` | (16, 8) | +| `blocks.7.kuramoto.omega` | (16,) | +| `blocks.7.kuramoto_phases` | (2, 128, 16) | +| `blocks.7.moe.U1` | (128, 2048, 64) | +| `blocks.7.moe.U2` | (128, 1280, 64) | +| `blocks.7.moe.V1` | (128, 1280, 64) | +| `blocks.7.moe.V2` | (128, 2048, 64) | +| `blocks.7.moe.b1` | (128, 2048) | +| `blocks.7.moe.b2` | (128, 1280) | +| `blocks.7.moe.expert_phases` | (128,) | +| `blocks.7.moe.scale1` | (128, 1, 1) | +| `blocks.7.moe.scale2` | (128, 1, 1) | +| `blocks.7.norm_attn.bias` | (1280,) | +| `blocks.7.norm_attn.weight` | (1280,) | +| `blocks.7.norm_kur.bias` | (1280,) | +| `blocks.7.norm_kur.weight` | (1280,) | +| `blocks.7.norm_moe.bias` | (1280,) | +| `blocks.7.norm_moe.weight` | (1280,) | +| `blocks.8.attn.b_out` | (1280,) | +| `blocks.8.attn.b_qkv` | (3, 1280) | +| `blocks.8.attn.level_logits` | (2,) | +| `blocks.8.attn.level_offsets` | (2,) | +| `blocks.8.attn.w_out` | (1280, 1280) | +| `blocks.8.attn.w_qkv` | (3, 1280, 1280) | +| `blocks.8.attn_S` | (2, 1280, 1280) | +| `blocks.8.attn_z` | (2, 1280) | +| `blocks.8.kuramoto.coupling_lambda` | (8,) | +| `blocks.8.kuramoto.coupling_u` | (16, 8) | +| `blocks.8.kuramoto.omega` | (16,) | +| `blocks.8.kuramoto_phases` | (2, 128, 16) | +| `blocks.8.moe.U1` | (128, 2048, 64) | +| `blocks.8.moe.U2` | (128, 1280, 64) | +| `blocks.8.moe.V1` | (128, 1280, 64) | +| `blocks.8.moe.V2` | (128, 2048, 64) | +| `blocks.8.moe.b1` | (128, 2048) | +| `blocks.8.moe.b2` | (128, 1280) | +| `blocks.8.moe.expert_phases` | (128,) | +| `blocks.8.moe.scale1` | (128, 1, 1) | +| `blocks.8.moe.scale2` | (128, 1, 1) | +| `blocks.8.norm_attn.bias` | (1280,) | +| `blocks.8.norm_attn.weight` | (1280,) | +| `blocks.8.norm_kur.bias` | (1280,) | +| `blocks.8.norm_kur.weight` | (1280,) | +| `blocks.8.norm_moe.bias` | (1280,) | +| `blocks.8.norm_moe.weight` | (1280,) | +| `blocks.9.attn.b_out` | (1280,) | +| `blocks.9.attn.b_qkv` | (3, 1280) | +| `blocks.9.attn.level_logits` | (2,) | +| `blocks.9.attn.level_offsets` | (2,) | +| `blocks.9.attn.w_out` | (1280, 1280) | +| `blocks.9.attn.w_qkv` | (3, 1280, 1280) | +| `blocks.9.attn_S` | (2, 1280, 1280) | +| `blocks.9.attn_z` | (2, 1280) | +| `blocks.9.kuramoto.coupling_lambda` | (8,) | +| `blocks.9.kuramoto.coupling_u` | (16, 8) | +| `blocks.9.kuramoto.omega` | (16,) | +| `blocks.9.kuramoto_phases` | (2, 128, 16) | +| `blocks.9.moe.U1` | (128, 2048, 64) | +| `blocks.9.moe.U2` | (128, 1280, 64) | +| `blocks.9.moe.V1` | (128, 1280, 64) | +| `blocks.9.moe.V2` | (128, 2048, 64) | +| `blocks.9.moe.b1` | (128, 2048) | +| `blocks.9.moe.b2` | (128, 1280) | +| `blocks.9.moe.expert_phases` | (128,) | +| `blocks.9.moe.scale1` | (128, 1, 1) | +| `blocks.9.moe.scale2` | (128, 1, 1) | +| `blocks.9.norm_attn.bias` | (1280,) | +| `blocks.9.norm_attn.weight` | (1280,) | +| `blocks.9.norm_kur.bias` | (1280,) | +| `blocks.9.norm_kur.weight` | (1280,) | +| `blocks.9.norm_moe.bias` | (1280,) | +| `blocks.9.norm_moe.weight` | (1280,) | +| `confidence_head.bias` | (1,) | +| `confidence_head.weight` | (1, 1280) | +| `last_lb_loss` | () | +| `observe.weight` | (50257, 1280) | +| `output_head.weight` | (50257, 1280) | +| `salience_head.bias` | (1,) | +| `salience_head.weight` | (1, 1280) | +| `thought_state` | (2, 1, 1280) |@ + +Décompte de contrôle : 8 + 16×27 = 440. Suffixes par bloc (27) : +`attn_S`, `attn_z`, `kuramoto_phases`, `attn.{w_qkv, b_qkv, w_out, b_out, +level_logits, level_offsets}`, `norm_attn.{weight, bias}`, +`kuramoto.{omega, coupling_u, coupling_lambda}`, `norm_kur.{weight, bias}`, +`moe.{U1, V1, U2, V2, scale1, scale2, b1, b2, expert_phases}`, +`norm_moe.{weight, bias}`. + +## Divergences vs FractalBlock (vorax/model/fractus_core.py) + +| Aspect | FractalBlock 88M (fractus_core.py) | CTEBlock 1B (ce doc) | +|---|---|---| +| 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) | +| GELU experts | `F.gelu` exact (fractus_core.py:481) | **approximation tanh** (moe.py:27-31, 233) | +| Gate von Mises | κ=4.0 direct (fractus_core.py:433) | **κ_eff = 4.0/temperature avec temperature=2.5** (moe.py:117 + trainers) | +| Embedding | tok_embed + pos_embed + norm (fractus_core.py:500-514) | `observe` token seul, rien d'autre (continuous_engine.py:262) | +| Norm finale | LayerNorm avant lm_head (fractus_core.py:572) | **aucune** (continuous_engine.py:474) | +| Noms des norms de bloc | `norm1`/`norm_kur`/`norm_moe` | `norm_attn`/`norm_kur`/`norm_moe` (continuous_engine.py:49-65) | +| 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) | +| É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) | +| Têtes | lm_head seule (liée) | output_head liée + confidence_head + salience_head + last_lb_loss (continuous_engine.py:276-282) | +| 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 | +| Orientation x@W | oui (fractus_core.py:288-290, 311) | **oui, identique** (continuous_engine.py:150-152, 198) | +| 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) | + +Communs confirmés : elu_plus_one (stats.py:18-31 ↔ fractus_core.py:73-77), +offsets (φ²)^(−level) (attention.py:21-31 ↔ fractus_core.py:123-129, valeurs +sondées identiques), Kuramoto RK4 dt=0.1/damping=0.01/n_steps=1 +(phase_ode.py ↔ fractus_core.py:318-379), récurrence causale inclusive avec +garde 1e-10 (attention.py:94-127 ↔ fractus_core.py:267-279), phases Farey +(farey.py:40-55 ↔ fractus_core.py:89-120, écart sondé 6.4e-07), top-k +renormalisé (moe.py:304-309 ↔ fractus_core.py:445-450), lb loss +(moe.py:323-324 ↔ fractus_core.py:491-492). + +## Décisions tranchées pour T2 (synthèse) + +1. **GELU : OUI, approximation tanh** entre les facteurs U1/V1 et U2/V2 + (moe.py:233 avec :27-31). Diffère du 88M (F.gelu). +2. **Orientation x@W** pour qkv/w_out du MoE low-rang (h@V puis @Uᵀ) ; + nn.Linear standard (h@Wᵀ) UNIQUEMENT pour output_head/confidence/salience. +3. **Tête LIÉE** : output_head.weight = observe.weight, bit-identiques ; les + deux clés doivent exister dans le state_dict. +4. **Chemain forward : tick_chunk_core exclusivement** ; génération = chunks + de longueur 1 ; tick_single interdit (mono-niveau). +5. **moe.temperature = 2.5** (κ_eff=1.6) à poser au chargement — pas dans le + checkpoint. +6. **États porteurs** : thought_state (B,1,d) ajouté à la position 0 du + chunk, maj = dernier h ; attn_S/attn_z bloc-diagonaux par tête, carry + inclusif, nouvel état = **moyenne sur les niveaux** ; kuramoto_phases + write-only (shape (batch, 128, n_osc) pour le strict load, valeur sans + effet) ; reset complet avant chaque séquence. +7. **Buffers de construction en batch=2** (shapes du checkpoint) pour + `from_checkpoint` strict=True, re-dimensionnables par reset_states(). +8. **boost/ss_rate/lr/tokens_processed/frozen_merge/n** : métadonnées + training/fusion, aucun effet forward. diff --git a/docs/heritage/fractus-cte-architecture.md b/docs/heritage/fractus-cte-architecture.md new file mode 100644 index 0000000000000000000000000000000000000000..55aaf5ec96100f34e8d9481d4a3dd74c15537ef8 --- /dev/null +++ b/docs/heritage/fractus-cte-architecture.md @@ -0,0 +1,320 @@ +# Architecture Fractus / Fractus-CTE — spec extraite du code et du checkpoint réel + +> Document source pour la Task 2 (noyau natif fractus dans `vorax/model/`). +> Tout ce qui suit est **mesuré ou lu** : lecture seule de `C:\Users\PHIL\ZCodeProject\fractus` +> et `C:\Users\PHIL\ZCodeProject\fractus-cte` (règle D5 : jamais écrits), plus un sondage +> réel du checkpoint via `torch.load`. Aucune valeur n'est inventée. + +## 1. Environnement torch (vorax) + +| Composant | Valeur | +|---|---| +| venv | `.venv-torch/` (natif Windows, `py -3.11 -m venv`, **non committé** — gitignoré) | +| Python | 3.11.9 (MSC v.1938 64 bit, AMD64) | +| torch | **2.13.0+cpu** (wheel officielle CPU `https://download.pytorch.org/whl/cpu`) | +| numpy | 2.4.6 | +| pytest | 9.1.1 | +| venv mingw | `.venv` (Python 3.13, numpy+pytest, **sans torch** — PEP 668, pas de wheel mingw) | + +Gate structurel : `tests/test_gates.py::test_torch_isolated_in_model_dir` interdit +`import torch` / `from torch` partout dans `vorax/` sauf dans `vorax/model/`. +Vérifié PASS dans les deux venvs. + +## 2. Checkpoint sondé (source de vérité) + +- Fichier : `C:\Users\PHIL\ZCodeProject\fractus\checkpoints\checkpoints\fractus_1b_latest.pt` + (1 059 107 682 octets ≈ 1,06 Go, lecture seule). +- Chargé avec : `torch.load(path, map_location="cpu", weights_only=False)` — réussi, + ~1 min CPU, aucun OOM. Script jetable : `vorax/.superpowers/scratch/probe_ckpt.py` (gitignoré). + +Clés top-level du fichier `.pt` : + +| Clé | Valeur | +|---|---| +| `model_state` | state_dict, **4231 entrées** (tableau §5) | +| `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** | +| `config` | `{'seq_len': 16, 'batch_size': 256, 'lr': 0.0001, 'corpus': 'data/fractus_corpus.pt'}` | +| `step` | 140000 | +| `epoch` | 0 | +| `loss` | 2.910271406173706 | +| `timestamp` | `2026-07-20 23:04:47` | + +Le checkpoint a été produit par `fractus/scripts/train_1b_cloud.py` +(`save_step_checkpoint`, uploadé sous le nom `checkpoints/fractus_1b_latest.pt`), +qui construit explicitement : + +```python +Fractus1B(vocab_size=50257, d_model=768, n_layers=8, n_heads=12, d_head=64, + n_levels=2, n_experts=64, top_k=2, expert_d_ff=1024, siren_rank=16, + max_seq_len=args.seq_len) +``` + +## 3. Config effective du modèle (croisement shapes réelles × code) + +| Paramètre | Valeur | Preuve | +|---|---|---| +| `vocab_size` | 50257 | `embed.tok_embed.weight (50257, 768)` | +| `d_model` | 768 | toutes les shapes | +| `n_layers` | 8 | `blocks.0.` … `blocks.7.` | +| `n_heads × d_head` | 12 × 64 | contrainte code `n_heads·d_head == d_model` ; 12·64=768 | +| `n_levels` | 2 | `level_logits (2,)`, `level_offsets (2,)` | +| `n_experts` | 64 | `expert_phases (64,)`, experts 0..63 | +| `top_k` | 2 | défaut code (`top_k: int = 2`, jamais surchargé) | +| `expert_d_ff` | 1024 | `experts_w1.e.U (1024, 16)` | +| `siren_rank` | 16 | dernière dim de tous les U/V | +| `max_seq_len` | 16 | `embed.pos_embed.weight (16, 768)` + `config['seq_len']=16` | +| Kuramoto `n_oscillators` | 16 | `omega (16,)` | +| Kuramoto `coupling_rank` | 8 | `coupling_lambda (8,)` | +| Kuramoto `n_steps`, `dt`, `damping` | 1, 0.1, 0.01 | code `FractalBlockSparse.__init__` / `KuramotoLayer` | +| MoE `kappa` (von Mises) | 4.0 | code `SparseStructuredMoE.__init__` | +| Optimizer | AdamW lr 1e-4, wd 0.01, clip grad 1.0 | `optimizer_state` + trainer | + +**Comptage de paramètres mesuré** : 87 828 432 paramètres uniques (tête liée dédupliquée) ++ 528 éléments de buffers (`level_offsets` 2×8, `expert_phases` 64×8) ; le total brut du +state_dict est 126 426 336 car `lm_head.weight` est stockée en double (liée). +C'est le modèle dit « 88M » — pas le 1B « config K » (d=1280/L=16/E=128) qui est le +défaut de `fractus1B/model_1b.py` mais un autre palier. +Capacité dense équivalente (formule `n_effective_capacity`) : ≈ 0,86B. + +## 4. Principes architecturaux (tels qu'implémentés) + +### 4.1 LazyStructuredSirenLinear (`fractus1B/nn/lazy_siren.py`) +Stockage des poids des experts : `W = scale · U · Vᵀ` avec `U (out, r)`, `V (in, r)`, +`scale` scalaire apprenable (style LoRA), `b` bias. +Forward : `y = scale · (x @ V) @ Uᵀ + b` — deux petites matmuls, **aucune matrice +pleine jamais matérialisée**, mémoire O((out+in)·r) par expert. +Le « SIREN » historique (`StructuredSirenLinear` : `W ≈ U@Vᵀ + SIREN(Résidu)`, +sinusoïdes ω0=30, init Sitzmann) est **abandonné dans la variante Lazy** : le résidu +SIREN est remplacé par le seul facteur bas-rang (compromis RAM/vitesse pour l'entraînement CPU/GPU). + +### 4.2 MoE creux routé par phases (`SparseStructuredSirenMoE` dans `model_1b.py`) +- Chaque bloc possède E=64 experts × 2 matrices (w1 : d→d_ff, w2 : d_ff→d), chacune en + LazySiren rang 16. +- **ACTIVATION (critique pour la réimplémentation)** : le forward de l'expert est + `h1 = w1(x)` → **`h1_act = GELU(h1)`** (`torch.nn.functional.gelu`, + `model_1b.py` L149) → `y = w2(h1_act)`. Sans ce GELU entre w1 et w2, le + checkpoint charge sans erreur mais produit des logits faux (échec silencieux). +- **Phases de Farey** (`nn/farey.py`) : on construit la suite de Farey F_{2E} (fractions + irréductibles p/q ∈ [0,1], q ≤ 2E, générées par la médiane), on convertit en angles + 2π·p/q et on sélectionne E angles uniformément. Vérifié sur le checkpoint : + `max |expert_phases_ckpt − farey.expert_phases(64)| = 2,2e-07` (arrondi float32). +- **Grille von Mises** : à partir des phases de Kuramoto θ (B, L, N), + `θ̄ = atan2(Σsin θ, Σcos θ)` puis `gate_e = exp(κ·cos(θ̄ − phase_e))` normalisé (κ=4.0). +- **Top-2 creux** : on sélectionne les 2 experts aux gates max, on renormalise les poids + top-k, somme pondérée des sorties. Dispatch vectorisé « gather-first » : on empile les + facteurs U/V/scale/bias des experts sélectionnés puis un bmm groupé — seul 2/64 des + experts est calculé, et ce sans jamais matérialiser W. +- **Perte d'équilibrage** : `lb = E · Σ_e (P_e − 1/E)²` avec P = moyenne des gates. + Dans le trainer, `aux_clamped = clamp(aux, max=1.0)` et `loss = CE + 0.001·aux`. + +### 4.3 Attention linéaire fractale (`nn/attention.py`) +Forme causale normalisée (Katharopoulos 2020) : +- Feature map `φ(x ; level) = elu_plus_one(x + ω_level)` avec `ω_level = (φ²)^(−level)`, + `φ² = φ_or² ≈ 2.618` (φ_or = nombre d'or). Pour n_levels=2 : offsets `[1.0, 0.3819660…]` + — vérifiés égaux dans le checkpoint. + `elu_plus_one(x) = x+1 si x>0 sinon (e^x −1)+1` (strictement positif → dénominateur sain). +- Récurrence causale INCLUSIVE : `S_t = Σ_{i≤t} k_i⊗v_i`, `z_t = Σ_{i≤t} k_i`, + `y_t = (q_t·S_t)/(q_t·z_t)` (0 si |dénom| < 1e-10). Version vectorisée par masque + triangulaire + einsum, avec option **state-carry** `(S₀, z₀)` pour continuer l'attention + au-delà d'une frontière de chunk (utilisée par la CTE). +- Multi-niveaux : sortie = Σ_level softmax(level_logits)_level · attn_level(x). +- Projections : un seul tenseur `w_qkv (3, d, d)` + `b_qkv (3, d)` ; sortie `w_out (d, d)` + `b_out`. + **Orientation (critique)** : les projections sont en convention `y = x @ W` (einsum + `"bld,de->ble"`, cf. `attention.py` L204-206, L237) — PAS la convention nn.Linear + `x @ Wᵀ`. Les deux étant carrées (768×768), une réimplémentation transposée chargerait + sans erreur de shape mais produirait des sorties fausses. Q/K/V dans l'ordre d'indexation + de `w_qkv` ; split des têtes en `view(B, L, 12, 64)` contigu. +- Contrainte : `n_heads·d_head == d_model` (12·64 = 768). Complexité O(L·d_head²) par tête/niveau. + +### 4.4 Oscillateurs de Kuramoto (`nn/phase_ode.py`) +- État **stateless** entre forwards : phases initiales dérivées du hidden — + `θ_init = 2π·mean(hidden) + 2π·i/N` (mod 2π). +- Dynamique : `dθ_i/dt = ω_i − damping·θ_i + Σ_j K_ij sin(θ_j − θ_i)` avec couplage + bas-rang `K = UΛUᵀ` (`coupling_u (N, r)`, `coupling_lambda (r,)`) évalué en O(N·r). +- Intégration **RK4** (4 évals), `n_steps=1`, `dt=0.1`, `damping=0.01`, wrap mod 2π à chaque pas. +- Sortie : phases (B, L, 16) qui alimentent la grille von Mises du MoE. + `omega` init U(−0.05, 0.05) ; mesuré ckpt : ω ∈ [−0.0586, 0.0706]. + +### 4.5 Bloc et modèle (arbre des modules, `fractus1B/model_1b.py`) + +``` +Fractus1B # ids (B, L) → (logits (B, L, V), aux_loss scalaire) +├── embed : BPEEmbedding +│ ├── tok_embed Embedding(50257, 768) +│ ├── pos_embed Embedding(16, 768) +│ └── norm LayerNorm(768) +├── blocks : ModuleList[8] × FractalBlockSparse +│ ├── norm1 LayerNorm(768) +│ ├── attn FractalLinearAttention(768, n_heads=12, d_head=64, n_levels=2) +│ ├── norm_kur LayerNorm(768) +│ ├── kuramoto KuramotoLayer(768, n_oscillators=16, rank=8, n_steps=1, dt=0.1) +│ ├── norm_moe LayerNorm(768) +│ └── moe SparseStructuredMoE(768, n_experts=64, top_k=2, d_ff=1024, rank=16) +│ ├── expert_phases (buffer, 64) # Farey +│ ├── experts_w1 : ModuleList[64] × LazyStructuredSirenLinear(768→1024, r=16) +│ └── experts_w2 : ModuleList[64] × LazyStructuredSirenLinear(1024→768, r=16) +├── norm : LayerNorm(768) +└── lm_head : Linear(768, 50257, bias=False) # poids LIÉS à embed.tok_embed.weight +``` + +Forward d'un bloc (pré-norm, résiduel) : +``` +x = x + attn(norm1(x)) +phases = kuramoto(norm_kur(x)) +moe_out, lb = moe(norm_moe(x), phases) +x = x + moe_out # retourne (x, lb) +``` +Forward modèle : `embed → 8 blocs (aux = Σ lb) → norm → lm_head`. +Pas de gradient checkpointing dans cette version (commentaire L9 du code). + +## 5. Table complète clés → shapes du state_dict RÉEL (4231 clés) + +Les 8 blocs sont **structurellement identiques** (vérifié : même ensemble de clés par +bloc). Le tableau ci-dessous est donc la forme canonique exacte : top-level (7 clés) + +motif par bloc (528 clés = 16 hors experts + 2×64×4 experts). Total : +8 × 528 + 7 = **4231** = compté dans le checkpoint. Toutes les valeurs sont en +`torch.float32`. + +**Top-level (7)** + +| Clé | Shape | Rôle | +|---|---|---| +| `embed.tok_embed.weight` | (50257, 768) | embedding BPE | +| `embed.pos_embed.weight` | (16, 768) | positions (max_seq_len=16) | +| `embed.norm.weight` | (768,) | LayerNorm embedding | +| `embed.norm.bias` | (768,) | idem | +| `norm.weight` | (768,) | LayerNorm final | +| `norm.bias` | (768,) | idem | +| `lm_head.weight` | (50257, 768) | **tied** : `torch.equal(lm_head, tok_embed) == True` | + +**Par bloc `blocks.i.`, i ∈ 0..7 (528 clés chacun) — 16 clés hors experts** + +| Clé | Shape | Param/buffer | +|---|---|---| +| `blocks.i.norm1.weight` | (768,) | param | +| `blocks.i.norm1.bias` | (768,) | param | +| `blocks.i.attn.w_qkv` | (3, 768, 768) | param (Q,K,V concaténés) | +| `blocks.i.attn.b_qkv` | (3, 768) | param | +| `blocks.i.attn.w_out` | (768, 768) | param | +| `blocks.i.attn.b_out` | (768,) | param | +| `blocks.i.attn.level_logits` | (2,) | param (poids des niveaux) | +| `blocks.i.attn.level_offsets` | (2,) | **buffer** persistant = [1.0, 0.3819660] | +| `blocks.i.norm_kur.weight` | (768,) | param | +| `blocks.i.norm_kur.bias` | (768,) | param | +| `blocks.i.kuramoto.omega` | (16,) | param | +| `blocks.i.kuramoto.coupling_u` | (16, 8) | param | +| `blocks.i.kuramoto.coupling_lambda` | (8,) | param | +| `blocks.i.norm_moe.weight` | (768,) | param | +| `blocks.i.norm_moe.bias` | (768,) | param | +| `blocks.i.moe.expert_phases` | (64,) | **buffer** persistant (Farey, cf. §4.2) | + +**Experts (512 clés par bloc)** — pour chaque expert `e ∈ 0..63` : + +| Clé | Shape | Signification | +|---|---|---| +| `blocks.i.moe.experts_w1.e.U` | (1024, 16) | U de w1 (d_ff×r) — W1 = scale·U·Vᵀ | +| `blocks.i.moe.experts_w1.e.V` | (768, 16) | V de w1 (d×r) | +| `blocks.i.moe.experts_w1.e.scale` | () | scalaire apprenable | +| `blocks.i.moe.experts_w1.e.bias` | (1024,) | bias w1 | +| `blocks.i.moe.experts_w2.e.U` | (768, 16) | U de w2 (d×r) | +| `blocks.i.moe.experts_w2.e.V` | (1024, 16) | V de w2 (d_ff×r) | +| `blocks.i.moe.experts_w2.e.scale` | () | scalaire apprenable | +| `blocks.i.moe.experts_w2.e.bias` | (768,) | bias w2 | + +Arithmétique des clés : 16 + 64·4 + 64·4 = 528/bloc ; 8·528 + 7 = 4231. ✓ + +## 6. Tokenizer (`fractus1B/tokenizer.py`, identique dans `fractus` et `fractus-cte`) + +- **Byte-level BPE, compatible GPT-2**, via la lib HF `tokenizers` (`tokenizers.Tokenizer`, + modèle `BPE`, pre-tokenizer `ByteLevel`, decoder `ByteLevel`). +- `FractusTokenizer.gpt2_compatible()` : `Tokenizer.from_pretrained("gpt2")` — + **vocab = 50257** (`DEFAULT_VOCAB_SIZE = 50257` ; 50256 merges BPE + 1 `<|endoftext|>` + d'id 50256, utilisé comme token d'arrêt dans la génération du trainer). +- `build_or_load(path)` : charge `data/tokenizer/fractus_bpe.json` s'il existe, sinon GPT-2. +- API : `encode(text) -> List[int]` (ids), `encode_batch`, `decode(ids) -> str`, `save(path)`. +- Encodage/décodage entièrement délégués à la lib `tokenizers` — aucun vocab maison embarqué + dans le checkpoint ; pour vorax, un tokenizer GPT-2 (HF `tokenizers` ou équivalent BPE + byte-level 50257) reproduit exactement les ids. + +## 7. La CTE (fractus-cte) — architecture cible + +`fractus-cte/fractus/continuous_engine.py` définit le **ContinuousThoughtEngine**, variante +« pensée continue » de la même architecture. C'est la cible de `scripts/train_1b_gpu.py` +(config `TARGET_1B` : d=1280, n_layers=16, 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 — 1 048 631 458 params d'après le commentaire du script). + +``` +ContinuousThoughtEngine(vocab=50257, d_model, n_layers, n_heads=4, d_head=64, + n_levels=2, n_oscillators=16, coupling_rank=8, + n_experts=8, top_k=2, expert_d_ff=256, siren_rank=32) +├── observe : Embedding(vocab, d_model) +├── blocks : ModuleList[n_layers] × CTEBlock +│ ├── attn FractalLinearAttention (mêmes params que §4.3) +│ ├── norm_attn / norm_kur / norm_moe : LayerNorm +│ ├── kuramoto KuramotoLayer (n_steps=1, dt=0.1) +│ ├── moe PhaseRoutedMoE (kappa=4.0) +│ └── buffers d'état persistants : attn_S (1, nH·dH, nH·dH), attn_z (1, nH·dH), +│ kuramoto_phases (1, 1, N) +├── last_lb_loss (buffer scalaire) +├── confidence_head : Linear(d_model, 1) # sigmoid → confiance +├── output_head : Linear(d_model, vocab, bias=False) # LIÉE à observe.weight +├── salience_head : Linear(d_model, 1) # gate mémoire (salience sigmoid) +└── thought_state (buffer 1, 1, d_model) # flux résiduel porté de tick en tick +``` + +Différences clés avec le modèle d'entraînement (source : `nn/moe.py` de fractus-cte) : + +- **`PhaseRoutedMoE` empile les experts** dans des tenseurs uniques (pas de ModuleList) : + `U1 (E, d_ff, r)`, `V1 (E, d, r)`, `U2 (E, d, r)`, `V2 (E, d_ff, r)`, + `scale1/scale2 (E, 1, 1)`, `b1 (E, d_ff)`, `b2 (E, d)`, buffer `expert_phases (E,)`. + Même mathématique W = scale·U·Vᵀ, même grille von Mises/Farey, même lb_loss ; + dispatch adaptatif : dense si `E ≤ 2·top_k`, sinon gather-first creux. +- **Attention avec état porté** : `(S, z)` par bloc, initialisés à zéro, mis à jour à chaque + chunk (moyenne des niveaux, bloc-diagonalisés par tête, `.detach()` pour ne pas + backpropager à travers le temps). +- **Modes d'exécution** : `tick()` (1 token, boucle par tête), `tick_vec()` (obs vectorisé), + `tick_chunk()` / `tick_chunk_train()` (chunk C tokens, attention vectorisée avec carry, + CE dense sur toutes les positions pour la variante `_train`), `think()` (profondeur + adaptative selon confiance > seuil), `reset_thought()`, `maybe_grow()` (auto-ajout + d'expert zéro-initialisé près de l'expert dominant si déséquilibre > 0.8). +- **`from_pretrained(ckpt)`** : reconstruit depuis `ckpt['config']` (n_layers inféré en + comptant les préfixes `blocks.` à défaut), charge par correspondance de shapes (sous-ensemble). +- **`grow.py :: grow_cte`** : croissance par zero-padding — largeur (d_model), profondeur + (n_layers), experts, rang ; les anciens poids sont copiés en haut-gauche, le neuf est neutre. +- Boucle d'entraînement GPU (`train_1b_gpu.py`) : chunks `seq_len`, CE sur le dernier token + (`tick_chunk_train` retourne tous les logits), accumulation, bf16 autocast, clip 1.0. + +**Mapping des clés modèle d'entraînement → CTE** (extrait de +`fractus/scripts/transfer_to_cte.py`, assemblage du checkpoint en CTE d=768/E=64) : + +| Clé CTE (mono-bloc) | Clé modèle d'entraînement | +|---|---| +| `observe.weight` | `embed.tok_embed.weight` | +| `attn.w_qkv/b_qkv/w_out/b_out/level_logits` | `blocks.0.attn.*` | +| `norm_attn.*` | `blocks.0.norm1.*` | +| `norm_kur.*` / `norm_moe.*` | `blocks.0.norm_kur.*` / `blocks.0.norm_moe.*` | +| `kuramoto.omega/coupling_u/coupling_lambda` | `blocks.0.kuramoto.*` | +| `output_head.weight` | `embed.tok_embed.weight` (liée) | + +(NB : ce script cible une vielle CTE à experts `experts_w1.{i}.*` en CachedSiren ; la CTE +actuelle de fractus-cte utilise les tenseurs empilés U1/V1/… — le mapping pertinent pour +Task 2 est la table §5 + les shapes §7.) + +## 8. Recette de chargement pour Task 2 + +```python +import torch +ckpt = torch.load(r"C:\Users\PHIL\ZCodeProject\fractus\checkpoints\checkpoints\fractus_1b_latest.pt", + map_location="cpu", weights_only=False) # fichier local de confiance +sd = ckpt["model_state"] # 4231 clés, cf. §5 +``` + +- `optimizer_state` : ignorer pour l'inférence. +- `lm_head.weight` dupliquée et **égale bit à bit** à `embed.tok_embed.weight` → charger une seule fois et lier. +- Les buffers `level_offsets` et `expert_phases` sont recalculables + (offsets = φ²^(−level) ; phases = `farey.expert_phases(E)`) — vérifié égal au checkpoint à + 2,2e-07 près (float32). +- Règle D5 : le checkpoint et les repos fractus / fractus-cte sont en **lecture seule + stricte** (aucune écriture, y compris `__pycache__` ; scripts de sondage dans + `vorax/.superpowers/scratch/`, gitignoré). diff --git a/docs/heritage/plans/2026-08-17-vorax-p1-substrat.md b/docs/heritage/plans/2026-08-17-vorax-p1-substrat.md new file mode 100644 index 0000000000000000000000000000000000000000..d1a9952681501db4ca2cd2e21d7c0a302ddfb1f2 --- /dev/null +++ b/docs/heritage/plans/2026-08-17-vorax-p1-substrat.md @@ -0,0 +1,1611 @@ +# Vorax Plan 1 — « Le substrat qui mange » Implementation Plan + +> **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. + +**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. + +**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. + +**Tech Stack:** Python ≥ 3.10, numpy (seule dépendance runtime), pytest (dev). CPU pur, Windows/Git Bash friendly. + +**Roadmap Vorax** (contexte, pas dans ce plan) : +- **Plan 1 (ce document)** : substrat — compiler `.kn`, traces, brain, CLI ingest/ask +- Plan 2 : généralisation (patterns/slots, relations, analogie 3CosMul k-NN), organe hebbien, spawn d'experts, consolidation, LSH +- 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 + +**Spec source :** `docs/superpowers/specs/2026-08-17-vorax-design.md` + +## Global Constraints + +- Python `>= 3.10` ; dépendances runtime : `numpy >= 1.24` + stdlib UNIQUEMENT (pas de torch, pas de llama.cpp dans le Plan 1) +- Déterminisme absolu des artefacts : jamais `hash()` python (salé), jamais de timestamp dans `.kn`/brain, clés JSON triées, gzip `mtime=0` +- Les repos `../fractus`, `../palimpseste`, `../ensemble`, `../prism` ne sont JAMAIS lus par le code ni modifiés (code substrat réécrit from scratch dans vorax) +- CPU-only ; D par défaut `8192`, seed par défaut `0` (constantes `D_DEFAULT`, `SEED_DEFAULT`) +- Tout code/identifiants en anglais, prose des docs en français (convention maison) +- Chaque tâche : TDD strict (test d'abord, échec vérifié, implémentation minimale, passage, commit) + +--- + +### Task 1: Scaffolding du repo + +**Files:** +- Create: `pyproject.toml` +- Create: `vorax/__init__.py` +- Create: `vorax/compiler/__init__.py` +- Create: `vorax/organs/__init__.py` +- Create: `vorax/agent/__init__.py` +- Create: `.gitignore` +- Test: `tests/test_scaffold.py` + +**Interfaces:** +- Consumes: rien +- Produces: package installable `vorax` (editable) ; `vorax.__version__ = "0.1.0"` + +- [x] **Step 1: Write the failing test** + +```python +# tests/test_scaffold.py +def test_package_importable(): + import vorax + assert vorax.__version__ == "0.1.0" +``` + +- [x] **Step 2: Run test to verify it fails** + +Run: `cd /c/Users/PHIL/ZCodeProject/vorax && python -m pytest tests/test_scaffold.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'vorax'` (ou collection error) + +- [x] **Step 3: Write minimal implementation** + +```toml +# pyproject.toml +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "vorax" +version = "0.1.0" +description = "Fractus ne s'entraine plus : il mange. Knowledge ingestion without training." +requires-python = ">=3.10" +dependencies = ["numpy>=1.24"] + +[project.optional-dependencies] +dev = ["pytest>=8"] + +[project.scripts] +vorax = "vorax.agent.cli:main" + +[tool.setuptools.packages.find] +include = ["vorax*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +``` + +```python +# vorax/__init__.py +__version__ = "0.1.0" +``` + +```python +# vorax/compiler/__init__.py +``` + +```python +# vorax/organs/__init__.py +``` + +```python +# vorax/agent/__init__.py +``` + +```gitignore +# .gitignore +__pycache__/ +*.pyc +.pytest_cache/ +dist/ +build/ +*.egg-info/ +brains/ +*.kn/ +bench/out/ +``` + +- [x] **Step 4: Install editable + run test to verify it passes** + +Run: `cd /c/Users/PHIL/ZCodeProject/vorax && python -m pip install -e ".[dev]" && python -m pytest tests/test_scaffold.py -v` +Expected: PASS (1 passed) + +- [x] **Step 5: Commit** + +```bash +cd /c/Users/PHIL/ZCodeProject/vorax +git add pyproject.toml vorax .gitignore tests/test_scaffold.py +git commit -m "feat: scaffold vorax package (plan 1, task 1)" +``` + +--- + +### Task 2: Hypervectors bipolaires (`vorax/hv.py`) + +**Files:** +- Create: `vorax/hv.py` +- Test: `tests/test_hv.py` + +**Interfaces:** +- Consumes: rien +- Produces: + - `hash_hv(text: str, D: int = D_DEFAULT, seed: int = 0) -> np.ndarray` (int8, shape `(D,)`, valeurs ±1) + - `bind(a: np.ndarray, b: np.ndarray) -> np.ndarray` + - `bundle(hvs: list[np.ndarray]) -> np.ndarray` + - `sim(a: np.ndarray, b: np.ndarray) -> float` + - `pack(hv: np.ndarray) -> np.ndarray` (uint8 packé, bitorder little, +1→bit 1) + - `unpack(bits: np.ndarray, D: int) -> np.ndarray` + - constantes `D_DEFAULT = 8192`, `SEED_DEFAULT = 0` + +- [x] **Step 1: Write the failing tests** + +```python +# tests/test_hv.py +import numpy as np + +from vorax.hv import D_DEFAULT, SEED_DEFAULT, bind, bundle, hash_hv, pack, sim, unpack + + +def test_hash_hv_deterministic(): + a = hash_hv("what is the capital of france") + b = hash_hv("what is the capital of france") + assert np.array_equal(a, b) + + +def test_hash_hv_shape_and_values(): + hv = hash_hv("hello", D=2048) + assert hv.shape == (2048,) + assert set(np.unique(hv)) <= {-1, 1} + + +def test_hash_hv_seed_changes_result(): + a = hash_hv("hello", seed=0) + b = hash_hv("hello", seed=1) + assert not np.array_equal(a, b) + + +def test_near_orthogonality(): + a = hash_hv("chat", D=8192) + b = hash_hv("chien", D=8192) + assert abs(sim(a, b)) < 0.2 + + +def test_self_similarity_is_one(): + a = hash_hv("n'importe quoi") + assert sim(a, a) == 1.0 + + +def test_bind_is_involution_and_orthogonal(): + a, b = hash_hv("cle"), hash_hv("valeur") + c = bind(a, b) + assert np.array_equal(bind(c, b), a) # XOR-like involution + assert abs(sim(c, a)) < 0.2 # bind obscurcit + + +def test_bundle_majority_with_tie_plus_one(): + a, b, c = hash_hv("x"), hash_hv("y"), hash_hv("z") + m = bundle([a, b, c]) + assert set(np.unique(m)) <= {-1, 1} + assert np.array_equal(bundle([a, a, b]), a) # majorité écrasante + + +def test_bundle_empty_raises(): + try: + bundle([]) + assert False, "should raise" + except ValueError: + pass + + +def test_pack_unpack_roundtrip(): + a = hash_hv("roundtrip", D=2048) + bits = pack(a) + assert bits.dtype == np.uint8 + assert np.array_equal(unpack(bits, 2048), a) + + +def test_defaults(): + assert D_DEFAULT == 8192 + assert SEED_DEFAULT == 0 +``` + +- [x] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_hv.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'vorax.hv'` + +- [x] **Step 3: Write minimal implementation** + +```python +# vorax/hv.py +"""Hypervectors bipolaires ±1 : le substrat de calcul de Vorax. + +Tout est déterministe : blake2b en mode compteur, jamais hash() python. +""" +import hashlib + +import numpy as np + +D_DEFAULT = 8192 +SEED_DEFAULT = 0 + + +def hash_hv(text: str, D: int = D_DEFAULT, seed: int = 0) -> np.ndarray: + """HV bipolaire ±1 déterministe, mode compteur (digest_size max 64 o).""" + need = (D + 7) // 8 + blocks: list[bytes] = [] + counter = 0 + while sum(len(b) for b in blocks) < need: + payload = f"{seed}|{counter}|{text}".encode() + blocks.append(hashlib.blake2b(payload, digest_size=64).digest()) + counter += 1 + bits = np.unpackbits( + np.frombuffer(b"".join(blocks), dtype=np.uint8), bitorder="little" + ) + return (bits[:D].astype(np.int16) * 2 - 1).astype(np.int8) + + +def bind(a: np.ndarray, b: np.ndarray) -> np.ndarray: + """Association (XOR sur bipolaires) : involution, obscurcit.""" + return a * b + + +def bundle(hvs: list[np.ndarray]) -> np.ndarray: + """Superposition : signe majoritaire, égalité → +1 (déterministe).""" + if not hvs: + raise ValueError("bundle() needs at least one hv") + total = np.sum(np.stack(hvs), axis=0, dtype=np.int16) + return np.where(total >= 0, 1, -1).astype(np.int8) + + +def sim(a: np.ndarray, b: np.ndarray) -> float: + """Similarité = cosinus bipolaire = moyenne des produits.""" + return float((a.astype(np.int16) * b.astype(np.int16)).mean()) + + +def pack(hv: np.ndarray) -> np.ndarray: + """±1 → bits packés (+1 → 1), pour stockage.""" + return np.packbits((hv > 0).astype(np.uint8), bitorder="little") + + +def unpack(bits: np.ndarray, D: int) -> np.ndarray: + """Inverse exact de pack().""" + out = np.unpackbits(bits, bitorder="little")[:D].astype(np.int16) + return (out * 2 - 1).astype(np.int8) +``` + +- [x] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_hv.py -v` +Expected: PASS (10 passed) + +- [x] **Step 5: Commit** + +```bash +git add vorax/hv.py tests/test_hv.py +git commit -m "feat: bipolar hypervector core (deterministic blake2b counter-mode)" +``` + +--- + +### Task 3: Atomes canoniques (`vorax/compiler/atoms.py`) + +**Files:** +- Create: `vorax/compiler/atoms.py` +- Test: `tests/test_atoms.py` + +**Interfaces:** +- Consumes: rien +- Produces: + - `Atom` dataclass frozen : champs `statement: str`, `context: str`, `provenance: str`, `confidence: float = 1.0` + - `Atom.row() -> str` (TSV-safe, 4 champs tabulés, tabs remplacés par espace) + - `Atom.from_row(row: str) -> Atom` (inverse exact ; tolère 3 champs → confidence 1.0) + +- [x] **Step 1: Write the failing tests** + +```python +# tests/test_atoms.py +from vorax.compiler.atoms import Atom + + +def test_row_roundtrip(): + a = Atom("what is the capital of france", "paris", "capitals.csv:2", 1.0) + assert Atom.from_row(a.row()) == a + + +def test_row_roundtrip_default_confidence(): + a = Atom("q", "r", "src:1") + assert Atom.from_row(a.row()) == a + + +def test_row_is_tsv_safe(): + a = Atom("a\tb", "c\td", "s", 0.5) + parts = a.row().split("\t") + assert len(parts) == 4 + assert "\t" not in parts[0] + + +def test_frozen(): + a = Atom("q", "r", "s") + try: + a.statement = "x" # type: ignore[misc] + assert False, "should be frozen" + except AttributeError: + pass +``` + +- [x] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_atoms.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'vorax.compiler.atoms'` + +- [x] **Step 3: Write minimal implementation** + +```python +# vorax/compiler/atoms.py +"""Atome de connaissance : l'unité canonique que tout format devient.""" +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Atom: + statement: str # l'énoncé retrievable (la question, le fait) + context: str # la réponse / le contexte associé + provenance: str # source (fichier:ligne, url...) + confidence: float = 1.0 + + def row(self) -> str: + """Sérialisation TSV-safe (4 champs, tabs neutralisés).""" + f = [self.statement, self.context, self.provenance] + return "\t".join(x.replace("\t", " ") for x in f) + f"\t{self.confidence}" + + @staticmethod + def from_row(row: str) -> "Atom": + parts = row.rstrip("\n").split("\t") + if len(parts) < 3: + raise ValueError(f"malformed atom row: {row!r}") + confidence = float(parts[3]) if len(parts) > 3 and parts[3] != "" else 1.0 + return Atom(parts[0], parts[1], parts[2], confidence) +``` + +- [x] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_atoms.py -v` +Expected: PASS (4 passed) + +- [x] **Step 5: Commit** + +```bash +git add vorax/compiler/atoms.py tests/test_atoms.py +git commit -m "feat: canonical knowledge Atom with TSV-safe roundtrip" +``` + +--- + +### Task 4: Parseur multi-format (`vorax/compiler/parse.py`) + +**Files:** +- Create: `vorax/compiler/parse.py` +- Test: `tests/test_parse.py` + +**Interfaces:** +- Consumes: `Atom` (Task 3) +- Produces: + - `detect_format(path: Path) -> str` ∈ `{"csv", "tsv", "json", "jsonl", "txt", "md", "raw"}` + - `parse_file(path: Path) -> list[Atom]` + - `flatten_record(d: dict, prefix: str = "") -> list[str]` (paires `"key.subkey: value"`) + +- [x] **Step 1: Write the failing tests** + +```python +# tests/test_parse.py +import json +from pathlib import Path + +from vorax.compiler.parse import detect_format, flatten_record, parse_file + + +def _write(tmp_path, name, content): + p = tmp_path / name + p.write_text(content, encoding="utf-8") + return p + + +def test_detect_format(tmp_path): + assert detect_format(_write(tmp_path, "a.csv", "x\n1")) == "csv" + assert detect_format(_write(tmp_path, "a.tsv", "x\n1")) == "tsv" + assert detect_format(_write(tmp_path, "a.jsonl", "{}")) == "jsonl" + assert detect_format(_write(tmp_path, "a.json", "[]")) == "json" + assert detect_format(_write(tmp_path, "a.md", "# t")) == "md" + assert detect_format(_write(tmp_path, "a.txt", "hi")) == "txt" + assert detect_format(_write(tmp_path, "a.xyz", "hi")) == "raw" + + +def test_parse_csv_two_columns_is_qa(tmp_path): + p = _write(tmp_path, "cap.csv", "question,answer\nwhat is the capital of france,paris\nwho wrote hamlet,william shakespeare\n") + atoms = parse_file(p) + assert len(atoms) == 2 + assert atoms[0].statement == "what is the capital of france" + assert atoms[0].context == "paris" + assert atoms[0].provenance.startswith("cap.csv") + assert atoms[0].confidence == 1.0 + + +def test_parse_csv_wide_table_serializes_pairs(tmp_path): + p = _write(tmp_path, "rows.csv", "name,city,height\neiffel,paris,330\n") + atoms = parse_file(p) + assert len(atoms) == 1 + assert "name: eiffel" in atoms[0].statement + assert "city: paris" in atoms[0].statement + + +def test_parse_jsonl(tmp_path): + line = json.dumps({"name": "eiffel tower", "city": "paris", "height_m": 330}) + p = _write(tmp_path, "d.jsonl", line + "\n") + atoms = parse_file(p) + assert len(atoms) == 1 + assert "name: eiffel tower" in atoms[0].statement + assert "height_m: 330" in atoms[0].statement + + +def test_parse_json_list(tmp_path): + p = _write(tmp_path, "d.json", json.dumps([{"a": 1}, {"b": 2}])) + atoms = parse_file(p) + assert len(atoms) == 2 + + +def test_flatten_record_nested(): + pairs = flatten_record({"x": {"y": "z"}, "l": [1, 2]}) + assert "x.y: z" in pairs + assert "l: 1; 2" in pairs + + +def test_parse_txt_paragraphs(tmp_path): + p = _write(tmp_path, "doc.txt", "Premier paragraphe sur paris.\n\nDeuxieme paragraphe.\n") + atoms = parse_file(p) + assert len(atoms) == 2 + assert atoms[0].statement == "Premier paragraphe sur paris." + + +def test_parse_md_headings_become_context(tmp_path): + p = _write(tmp_path, "doc.md", "# Capitales\n\nparis est la capitale de la france\n") + atoms = parse_file(p) + assert atoms[0].context == "Capitales" + assert "paris" in atoms[0].statement + + +def test_parse_raw_fallback_reads_lines(tmp_path): + p = _write(tmp_path, "weird.xyz", "ligne une\nligne deux\n") + atoms = parse_file(p) + assert len(atoms) == 2 +``` + +- [x] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_parse.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'vorax.compiler.parse'` + +- [x] **Step 3: Write minimal implementation** + +```python +# vorax/compiler/parse.py +"""N'importe quelle donnée devient des atomes. Format inconnu → fallback brut.""" +import csv +import json +from pathlib import Path + +from .atoms import Atom + +_SUFFIXES = { + ".csv": "csv", ".tsv": "tsv", ".json": "json", ".jsonl": "jsonl", + ".txt": "txt", ".md": "md", +} + + +def detect_format(path: Path) -> str: + return _SUFFIXES.get(path.suffix.lower(), "raw") + + +def flatten_record(d: dict, prefix: str = "") -> list[str]: + """dict (récursif) → paires 'key.subkey: value' plates.""" + pairs: list[str] = [] + for key, value in d.items(): + full = f"{prefix}.{key}" if prefix else str(key) + if isinstance(value, dict): + pairs.extend(flatten_record(value, full)) + elif isinstance(value, list): + joined = "; ".join(str(x) for x in value) + pairs.append(f"{full}: {joined}") + else: + pairs.append(f"{full}: {value}") + return pairs + + +def parse_file(path: Path) -> list[Atom]: + fmt = detect_format(path) + if fmt in ("csv", "tsv"): + return _parse_csv(path, delim="," if fmt == "csv" else "\t") + if fmt == "jsonl": + return _parse_jsonl(path) + if fmt == "json": + return _parse_json(path) + if fmt in ("txt", "md"): + return _parse_text(path, md=(fmt == "md")) + return _parse_raw(path) + + +def _parse_csv(path: Path, delim: str) -> list[Atom]: + atoms: list[Atom] = [] + with open(path, newline="", encoding="utf-8") as fh: + reader = csv.reader(fh, delimiter=delim) + header = next(reader, None) + for lineno, row in enumerate(reader, start=2): + if not row or all(not c.strip() for c in row): + continue + cells = [c.strip() for c in row] + if len(cells) == 2: # QA direct + atoms.append(Atom(cells[0], cells[1], f"{path.name}:{lineno}")) + else: # table large → sérialisation key: value + keys = header if header and len(header) == len(cells) else [f"c{i}" for i in range(len(cells))] + statement = "; ".join(f"{k}: {v}" for k, v in zip(keys, cells)) + atoms.append(Atom(statement, "", f"{path.name}:{lineno}")) + return atoms + + +def _parse_jsonl(path: Path) -> list[Atom]: + atoms = [] + with open(path, encoding="utf-8") as fh: + for lineno, line in enumerate(fh, start=1): + line = line.strip() + if not line: + continue + record = json.loads(line) + if not isinstance(record, dict): + continue + atoms.append(Atom("; ".join(flatten_record(record)), "", f"{path.name}:{lineno}")) + return atoms + + +def _parse_json(path: Path) -> list[Atom]: + data = json.loads(path.read_text(encoding="utf-8")) + records = data if isinstance(data, list) else [data] + atoms = [] + for i, record in enumerate(records, start=1): + if isinstance(record, dict): + atoms.append(Atom("; ".join(flatten_record(record)), "", f"{path.name}:{i}")) + return atoms + + +def _parse_text(path: Path, md: bool) -> list[Atom]: + atoms = [] + context = "" + buffer: list[str] = [] + for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + stripped = line.strip() + if md and stripped.startswith("#"): + context = stripped.lstrip("#").strip() + continue + if not stripped: + if buffer: + atoms.append(Atom(" ".join(buffer), context, f"{path.name}:{lineno}")) + buffer = [] + continue + buffer.append(stripped) + if buffer: + atoms.append(Atom(" ".join(buffer), context, f"{path.name}:EOF")) + return atoms + + +def _parse_raw(path: Path) -> list[Atom]: + atoms = [] + for lineno, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), start=1): + if line.strip(): + atoms.append(Atom(line.strip(), "", f"{path.name}:{lineno}")) + return atoms +``` + +- [x] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_parse.py -v` +Expected: PASS (9 passed) + +- [x] **Step 5: Commit** + +```bash +git add vorax/compiler/parse.py tests/test_parse.py +git commit -m "feat: multi-format parser — any data becomes atoms" +``` + +--- + +### Task 5: Encodage (`vorax/compiler/encode.py`) + +**Files:** +- Create: `vorax/compiler/encode.py` +- Test: `tests/test_encode.py` + +**Interfaces:** +- Consumes: `hash_hv, bundle, D_DEFAULT, SEED_DEFAULT` (Task 2) ; `Atom` (Task 3) +- Produces: + - `normalize(text: str) -> str` + - `tokenize(text: str) -> list[str]` + - `text_hv(text: str, D: int = D_DEFAULT, seed: int = 0) -> np.ndarray` (bundle des HV de tokens) + - `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` + +- [x] **Step 1: Write the failing tests** + +```python +# tests/test_encode.py +import numpy as np + +from vorax.compiler.atoms import Atom +from vorax.compiler.encode import encode_atom, normalize, text_hv, tokenize +from vorax.hv import sim + + +def test_normalize(): + assert normalize(" What IS The Capital? ") == "what is the capital" + + +def test_tokenize(): + assert tokenize("What is the capital of France?") == ["what", "is", "the", "capital", "of", "france"] + + +def test_text_hv_identical_sentences(): + a = text_hv("what is the capital of france") + b = text_hv("What IS the capital of France?") + assert np.array_equal(a, b) + + +def test_related_more_similar_than_unrelated(): + q1 = text_hv("what is the capital of france") + q2 = text_hv("what is the capital of spain") + far = text_hv("quantum entanglement violates locality") + assert sim(q1, q2) > 0.3 # 5 tokens partagés sur 6 + assert sim(q1, far) < 0.2 # quasi-orthogonal + assert sim(q1, q2) > sim(q1, far) + + +def test_encode_atom_deterministic(): + atom = Atom("what is the capital of france", "paris", "cap.csv:2") + a1, v1 = encode_atom(atom) + a2, v2 = encode_atom(atom) + assert np.array_equal(a1, a2) and np.array_equal(v1, v2) + + +def test_encode_atom_addr_is_statement(): + atom = Atom("who wrote hamlet", "william shakespeare", "s:1") + addr, value = encode_atom(atom) + assert np.array_equal(addr, text_hv("who wrote hamlet")) + assert not np.array_equal(value, addr) # le contexte change la valeur + + +def test_encode_atom_no_context_value_equals_addr(): + atom = Atom("un fait nu", "", "s:1") + addr, value = encode_atom(atom) + assert np.array_equal(value, addr) +``` + +- [x] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_encode.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'vorax.compiler.encode'` + +- [x] **Step 3: Write minimal implementation** + +```python +# vorax/compiler/encode.py +"""Texte → HV : le bundle de tokens rend les questions apparentées similaires.""" +import re + +import numpy as np + +from ..hv import D_DEFAULT, SEED_DEFAULT, bundle, hash_hv +from .atoms import Atom + +_WORD = re.compile(r"[a-z0-9àâäéèêëîïôöùûüç'-]+") + + +def normalize(text: str) -> str: + t = text.casefold().strip() + t = re.sub(r"\s+", " ", t) + return t.strip(" .;:!?") + + +def tokenize(text: str) -> list[str]: + return _WORD.findall(normalize(text)) + + +def text_hv(text: str, D: int = D_DEFAULT, seed: int = SEED_DEFAULT) -> np.ndarray: + tokens = tokenize(text) + if not tokens: + return hash_hv("", D, seed) + return bundle([hash_hv(tok, D, seed) for tok in tokens]) + + +def encode_atom(atom: Atom, D: int = D_DEFAULT, seed: int = SEED_DEFAULT) -> tuple[np.ndarray, np.ndarray]: + addr = text_hv(atom.statement, D, seed) + if atom.context: + value = text_hv(f"{atom.statement} => {atom.context}", D, seed) + else: + value = addr + return addr, value +``` + +- [x] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_encode.py -v` +Expected: PASS (7 passed) + +- [x] **Step 5: Commit** + +```bash +git add vorax/compiler/encode.py tests/test_encode.py +git commit -m "feat: token-bundle encoding — related questions become similar HVs" +``` + +--- + +### Task 6: Organe Traces (`vorax/organs/traces.py`) + +**Files:** +- Create: `vorax/organs/traces.py` +- Test: `tests/test_traces.py` + +**Interfaces:** +- Consumes: `pack, unpack` (Task 2) +- Produces: + - `TraceStore(D: int = D_DEFAULT)` avec : + - `write(atom_id: int, addr: np.ndarray, value: np.ndarray, weight: float = 1.0) -> int` (index de trace) + - `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 + - `__len__() -> int` + - `save(path: Path) -> None` (crée `path/` : `traces.npz` + `meta.json`) + - `TraceStore.load(path: Path) -> TraceStore` (classmethod) + +- [x] **Step 1: Write the failing tests** + +```python +# tests/test_traces.py +from pathlib import Path + +import numpy as np + +from vorax.compiler.encode import text_hv +from vorax.hv import D_DEFAULT, sim +from vorax.organs.traces import TraceStore + + +def _fill(store: TraceStore) -> None: + for i, q in enumerate(["what is the capital of france", "who wrote hamlet", "quantum stuff"]): + addr, value = text_hv(q, D=2048), text_hv(q, D=2048) + store.write(i, addr, value) + + +def test_write_and_retrieve_top1(tmp_path): + store = TraceStore(D=2048) + _fill(store) + hits = store.retrieve(text_hv("what is the capital of france", D=2048), k=2) + assert len(hits) == 2 + assert hits[0][0] == 0 # le bon atom_id en tête + assert hits[0][1] > 0.5 # similarité élevée + assert hits[0][1] >= hits[1][1] # tri décroissant + + +def test_empty_retrieve_returns_empty(): + store = TraceStore(D=2048) + assert store.retrieve(text_hv("rien", D=2048)) == [] + assert len(store) == 0 + + +def test_weight_boosts_ranking(): + store = TraceStore(D=2048) + q1, q2 = "what is the capital of france", "what is the capital of spain" + store.write(0, text_hv(q1, D=2048), text_hv(q1, D=2048), weight=0.1) + store.write(1, text_hv(q2, D=2048), text_hv(q2, D=2048), weight=2.0) + hits = store.retrieve(text_hv("what is the capital of france", D=2048), k=2) + assert hits[0][0] == 1 # poids fort domine malgré des tokens partagés + + +def test_save_load_roundtrip(tmp_path): + store = TraceStore(D=2048) + _fill(store) + store.save(tmp_path / "brain") + loaded = TraceStore.load(tmp_path / "brain") + assert len(loaded) == 3 + q = text_hv("who wrote hamlet", D=2048) + assert [i for i, _ in loaded.retrieve(q, k=3)] == [i for i, _ in store.retrieve(q, k=3)] +``` + +- [x] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_traces.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'vorax.organs.traces'` + +- [x] **Step 3: Write minimal implementation** + +```python +# vorax/organs/traces.py +"""Organe 1 — hippocampe : mémoire append-only, on n'écrase jamais, on superpose.""" +import json +from pathlib import Path + +import numpy as np + +from ..hv import D_DEFAULT, pack, unpack + + +class TraceStore: + def __init__(self, D: int = D_DEFAULT) -> None: + self.D = D + self.addrs: list[np.ndarray] = [] + self.values: list[np.ndarray] = [] + self.weights: list[float] = [] + self.ids: list[int] = [] + self._stack: np.ndarray | None = None + + def __len__(self) -> int: + return len(self.ids) + + def write(self, atom_id: int, addr: np.ndarray, value: np.ndarray, weight: float = 1.0) -> int: + self.addrs.append(addr) + self.values.append(value) + self.weights.append(float(weight)) + self.ids.append(int(atom_id)) + self._stack = None + return len(self.ids) - 1 + + def _mat(self) -> np.ndarray: + if self._stack is None: + self._stack = np.stack(self.addrs).astype(np.int16) + return self._stack + + def retrieve(self, query: np.ndarray, k: int = 8) -> list[tuple[int, float]]: + if not self.ids: + return [] + sims = (self._mat() @ query.astype(np.int16)) / self.D + sims = sims * np.asarray(self.weights, dtype=np.float64) + order = np.argsort(-sims)[:k] + return [(self.ids[i], float(sims[i])) for i in order] + + def signature(self) -> np.ndarray: + """Bundle de toutes les addresses (clé de routage future — spawn).""" + if not self.addrs: + raise ValueError("empty store has no signature") + total = np.sum(self._mat(), axis=0, dtype=np.int32) + return np.where(total >= 0, 1, -1).astype(np.int8) + + def save(self, path: Path) -> None: + path.mkdir(parents=True, exist_ok=True) + packed_a = np.concatenate([pack(a) for a in self.addrs]) if self.addrs else np.array([], dtype=np.uint8) + packed_v = np.concatenate([pack(v) for v in self.values]) if self.values else np.array([], dtype=np.uint8) + np.savez_compressed( + path / "traces.npz", + a=packed_a, + v=packed_v, + w=np.asarray(self.weights, dtype=np.float32), + ids=np.asarray(self.ids, dtype=np.int64), + ) + (path / "meta.json").write_text( + json.dumps({"D": self.D, "n": len(self.ids)}, sort_keys=True), encoding="utf-8" + ) + + @classmethod + def load(cls, path: Path) -> "TraceStore": + meta = json.loads((path / "meta.json").read_text(encoding="utf-8")) + store = cls(D=int(meta["D"])) + data = np.load(path / "traces.npz") + for i in range(int(meta["n"])): + a = unpack(data["a"][i * (store.D // 8):(i + 1) * (store.D // 8)], store.D) + v = unpack(data["v"][i * (store.D // 8):(i + 1) * (store.D // 8)], store.D) + store.addrs.append(a) + store.values.append(v) + store.weights = [float(x) for x in data["w"]] + store.ids = [int(x) for x in data["ids"]] + return store +``` + +- [x] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_traces.py -v` +Expected: PASS (4 passed) + +- [x] **Step 5: Commit** + +```bash +git add vorax/organs/traces.py tests/test_traces.py +git commit -m "feat: trace organ — append-only HV memory with weighted retrieval" +``` + +--- + +### Task 7: Brain — assemblage persistant (`vorax/brain.py`) + +**Files:** +- Create: `vorax/brain.py` +- Test: `tests/test_brain.py` + +**Interfaces:** +- Consumes: `TraceStore` (Task 6), `encode_atom, text_hv` (Task 5), `Atom` (Task 3) +- Produces: + - `Brain(D: int = D_DEFAULT, seed: int = SEED_DEFAULT)` avec : + - `ingest_atom(atom: Atom) -> int` (atom_id) + - `ingest_atoms(atoms: list[Atom]) -> int` (nombre ingéré) + - `ask(question: str, k: int = 3) -> list[tuple[Atom, float]]` + - `__len__() -> int` + - `save(path: Path) -> None` (répertoire : `meta.json`, `atoms.jsonl.gz`, `traces.npz`) + - `Brain.load(path: Path) -> Brain` (classmethod) + +- [x] **Step 1: Write the failing tests** + +```python +# tests/test_brain.py +import gzip +import json +from pathlib import Path + +from vorax.brain import Brain +from vorax.compiler.atoms import Atom + +QA = [ + Atom("what is the capital of france", "paris", "cap.csv:2"), + Atom("what is the capital of spain", "madrid", "cap.csv:3"), + Atom("what is the capital of japan", "tokyo", "cap.csv:4"), + Atom("who wrote hamlet", "william shakespeare", "books.csv:2"), +] + + +def test_ingest_and_ask_top1(): + brain = Brain(D=2048) + brain.ingest_atoms(QA) + assert len(brain) == 4 + hits = brain.ask("what is the capital of france", k=2) + assert hits[0][0].context == "paris" + assert hits[0][1] > 0.3 + + +def test_ask_generalizes_token_overlap(): + """Jamais vu mot à mot, mais les tokens partagés doivent hisser le bon domaine.""" + brain = Brain(D=2048) + brain.ingest_atoms(QA) + hits = brain.ask("what is the capital of germany", k=1) + assert hits[0][0].statement.startswith("what is the capital of") + + +def test_save_load_roundtrip(tmp_path): + brain = Brain(D=2048) + brain.ingest_atoms(QA) + brain.save(tmp_path / "brain") + loaded = Brain.load(tmp_path / "brain") + assert len(loaded) == 4 + assert loaded.ask("who wrote hamlet", k=1)[0][0].context == "william shakespeare" + + +def test_save_is_deterministic(tmp_path): + b1, b2 = Brain(D=2048), Brain(D=2048) + b1.ingest_atoms(QA) + b2.ingest_atoms(QA) + b1.save(tmp_path / "b1") + b2.save(tmp_path / "b2") + assert (tmp_path / "b1" / "meta.json").read_bytes() == (tmp_path / "b2" / "meta.json").read_bytes() + assert (tmp_path / "b1" / "atoms.jsonl.gz").read_bytes() == (tmp_path / "b2" / "atoms.jsonl.gz").read_bytes() +``` + +- [x] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_brain.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'vorax.brain'` + +- [x] **Step 3: Write minimal implementation** + +```python +# vorax/brain.py +"""Le cerveau Vorax Plan 1 : organes + atomes, persistant et déterministe.""" +import gzip +import json +from pathlib import Path + +from .compiler.atoms import Atom +from .compiler.encode import encode_atom, text_hv +from .hv import D_DEFAULT, SEED_DEFAULT +from .organs.traces import TraceStore + + +class Brain: + def __init__(self, D: int = D_DEFAULT, seed: int = SEED_DEFAULT) -> None: + self.D = D + self.seed = seed + self.traces = TraceStore(D=D) + self.atoms: list[Atom] = [] + + def __len__(self) -> int: + return len(self.atoms) + + def ingest_atom(self, atom: Atom) -> int: + atom_id = len(self.atoms) + self.atoms.append(atom) + addr, value = encode_atom(atom, self.D, self.seed) + self.traces.write(atom_id, addr, value, weight=atom.confidence) + return atom_id + + def ingest_atoms(self, atoms: list[Atom]) -> int: + for atom in atoms: + self.ingest_atom(atom) + return len(atoms) + + def ask(self, question: str, k: int = 3) -> list[tuple[Atom, float]]: + query = text_hv(question, self.D, self.seed) + return [(self.atoms[i], s) for i, s in self.traces.retrieve(query, k=k)] + + def save(self, path: Path) -> None: + path.mkdir(parents=True, exist_ok=True) + payload = "\n".join(a.row() for a in self.atoms).encode("utf-8") + (path / "atoms.jsonl.gz").write_bytes(gzip.compress(payload, mtime=0)) + self.traces.save(path) + (path / "meta.json").write_text( + json.dumps({"D": self.D, "seed": self.seed, "n_atoms": len(self.atoms)}, sort_keys=True), + encoding="utf-8", + ) + + @classmethod + def load(cls, path: Path) -> "Brain": + meta = json.loads((path / "meta.json").read_text(encoding="utf-8")) + brain = cls(D=int(meta["D"]), seed=int(meta["seed"])) + raw = gzip.decompress((path / "atoms.jsonl.gz").read_bytes()).decode("utf-8") + for line in raw.splitlines(): + if line.strip(): + brain.ingest_atom(Atom.from_row(line)) + return brain +``` + +- [x] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_brain.py -v` +Expected: PASS (4 passed) + +- [x] **Step 5: Commit** + +```bash +git add vorax/brain.py tests/test_brain.py +git commit -m "feat: Brain — persistent organ+atom assembly, ingest and ask" +``` + +--- + +### Task 8: Compilateur `.kn` (`vorax/compiler/kn.py`) + +**Files:** +- Create: `vorax/compiler/kn.py` +- Test: `tests/test_kn.py` + +**Interfaces:** +- Consumes: `parse_file` (Task 4), `Atom.row/from_row` (Task 3), `pack` (Task 2) +- Produces: + - `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` + - `load_kn(kn_dir: Path) -> list[Atom]` + - `source_sha256(path: Path) -> str` + - manifest (clés triées, SANS timestamp) : `{"D", "format_version", "n_atoms", "seed", "source", "source_sha256"}` avec `format_version = 1` + +- [x] **Step 1: Write the failing tests** + +```python +# tests/test_kn.py +import gzip +import json +from pathlib import Path + +from vorax.compiler.kn import compile_kn, load_kn, source_sha256 + + +def _csv(tmp_path): + p = tmp_path / "cap.csv" + p.write_text( + "question,answer\nwhat is the capital of france,paris\nwho wrote hamlet,william shakespeare\n", + encoding="utf-8", + ) + return p + + +def test_compile_produces_kn_layout(tmp_path): + kn = compile_kn(_csv(tmp_path), tmp_path / "cap.kn") + assert (kn / "manifest.json").exists() + assert (kn / "atoms.tsv.gz").exists() + assert (kn / "signature.bin").exists() + manifest = json.loads((kn / "manifest.json").read_text(encoding="utf-8")) + assert manifest["n_atoms"] == 2 + assert manifest["format_version"] == 1 + assert "created" not in manifest # jamais de timestamp + + +def test_load_kn_roundtrip(tmp_path): + src = _csv(tmp_path) + kn = compile_kn(src, tmp_path / "cap.kn") + atoms = load_kn(kn) + assert atoms[0].statement == "what is the capital of france" + assert atoms[0].context == "paris" + assert atoms[0].confidence == 1.0 + + +def test_compile_is_bit_identical(tmp_path): + src = _csv(tmp_path) + k1 = compile_kn(src, tmp_path / "a.kn") + k2 = compile_kn(src, tmp_path / "b.kn") + for name in ("manifest.json", "atoms.tsv.gz", "signature.bin"): + assert (k1 / name).read_bytes() == (k2 / name).read_bytes(), name + + +def test_source_sha256_stable(tmp_path): + src = _csv(tmp_path) + assert source_sha256(src) == source_sha256(src) + assert len(source_sha256(src)) == 64 + + +def test_manifest_records_source_hash(tmp_path): + src = _csv(tmp_path) + kn = compile_kn(src, tmp_path / "cap.kn") + manifest = json.loads((kn / "manifest.json").read_text(encoding="utf-8")) + assert manifest["source_sha256"] == source_sha256(src) +``` + +- [x] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_kn.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'vorax.compiler.kn'` + +- [x] **Step 3: Write minimal implementation** + +```python +# vorax/compiler/kn.py +"""Le compilateur .kn : dataset → artefact de connaissance portable, bit-identique.""" +import gzip +import hashlib +import json +from pathlib import Path + +import numpy as np + +from ..hv import D_DEFAULT, SEED_DEFAULT, pack +from .atoms import Atom +from .encode import encode_atom +from .parse import parse_file + +FORMAT_VERSION = 1 + + +def source_sha256(path: Path) -> str: + h = hashlib.sha256() + h.update(path.read_bytes()) + return h.hexdigest() + + +def compile_kn( + source: Path, out: Path, D: int = D_DEFAULT, seed: int = SEED_DEFAULT +) -> Path: + atoms = parse_file(source) + out.mkdir(parents=True, exist_ok=True) + + payload = "\n".join(a.row() for a in atoms).encode("utf-8") + (out / "atoms.tsv.gz").write_bytes(gzip.compress(payload, mtime=0)) + + addrs = [encode_atom(a, D, seed)[0] for a in atoms] + if addrs: + total = np.sum(np.stack(addrs).astype(np.int32), axis=0) + signature = np.where(total >= 0, 1, -1).astype(np.int8) + sig_bits = pack(signature) + else: + sig_bits = np.array([], dtype=np.uint8) + (out / "signature.bin").write_bytes(sig_bits.tobytes()) + + manifest = { + "D": D, + "format_version": FORMAT_VERSION, + "n_atoms": len(atoms), + "seed": seed, + "source": str(source.name), + "source_sha256": source_sha256(source), + } + (out / "manifest.json").write_text( + json.dumps(manifest, sort_keys=True, indent=2), encoding="utf-8" + ) + return out + + +def load_kn(kn_dir: Path) -> list[Atom]: + raw = gzip.decompress((kn_dir / "atoms.tsv.gz").read_bytes()).decode("utf-8") + return [Atom.from_row(line) for line in raw.splitlines() if line.strip()] +``` + +- [x] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_kn.py -v` +Expected: PASS (5 passed) + +- [x] **Step 5: Commit** + +```bash +git add vorax/compiler/kn.py tests/test_kn.py +git commit -m "feat: .kn knowledge compiler — deterministic, portable, bit-identical" +``` + +--- + +### Task 9: CLI `ingest` / `ask` / `status` (`vorax/agent/cli.py`) + +**Files:** +- Create: `vorax/agent/cli.py` +- Test: `tests/test_cli.py` + +**Interfaces:** +- Consumes: `Brain` (Task 7), `compile_kn, load_kn` (Task 8) +- Produces: + - `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) + - `ask(question: str, brain_path: Path, k: int) -> list[str]` — lignes de cartes formatées + - `status(brain_path: Path) -> str` + - `main(argv: list[str] | None = None) -> int` (exit code 0) — subcommands `ingest/ask/status`, options `--brain` (défaut `./brain`), `--D`, `--seed`, `--k`, `--kn-cache` + - Format carte : `[CARTE] FAIT: {statement} (sim {sim:.2f} | src: {provenance})` puis ligne ` RÉP: {context}` si context non vide + +- [x] **Step 1: Write the failing tests** + +```python +# tests/test_cli.py +from pathlib import Path + +from vorax.agent import cli + +CSV_CONTENT = ( + "question,answer\n" + "what is the capital of france,paris\n" + "what is the capital of spain,madrid\n" + "what is the capital of japan,tokyo\n" + "who wrote hamlet,william shakespeare\n" +) + + +def _make_csv(tmp_path): + p = tmp_path / "cap.csv" + p.write_text(CSV_CONTENT, encoding="utf-8") + return p + + +def test_ingest_then_ask_end_to_end(tmp_path, capsys): + csv = _make_csv(tmp_path) + brain_dir = tmp_path / "brain" + n = cli.ingest(csv, brain_dir, D=2048, seed=0, kn_cache=tmp_path / "kn") + assert n == 4 + assert (brain_dir / "meta.json").exists() + assert (tmp_path / "kn" / "cap.kn" / "manifest.json").exists() # l'artefact .kn existe + + lines = cli.ask("what is the capital of japan", brain_dir, k=1) + out = "\n".join(lines) + assert "tokyo" in out + assert "[CARTE]" in out + assert "RÉP:" in out + print(out) + captured = capsys.readouterr() + assert "tokyo" in captured.out + + +def test_ask_empty_brain(tmp_path): + lines = cli.ask("anything", tmp_path / "nope", k=1) + assert lines == ["[CARTE] LACUNES: aucune trace (brain vide ou absent)"] + + +def test_status_reports_counts(tmp_path, capsys): + csv = _make_csv(tmp_path) + cli.ingest(csv, tmp_path / "brain", D=2048, seed=0, kn_cache=tmp_path / "kn") + s = cli.status(tmp_path / "brain") + assert "4" in s + + +def test_main_subcommands(tmp_path, capsys): + csv = _make_csv(tmp_path) + brain = tmp_path / "brain" + assert cli.main(["ingest", str(csv), "--brain", str(brain), "--D", "2048", "--kn-cache", str(tmp_path / "kn")]) == 0 + assert cli.main(["ask", "who wrote hamlet", "--brain", str(brain), "--k", "1"]) == 0 + assert "william shakespeare" in capsys.readouterr().out + assert cli.main(["status", "--brain", str(brain)]) == 0 +``` + +- [x] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_cli.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'vorax.agent.cli'` + +- [x] **Step 3: Write minimal implementation** + +```python +# vorax/agent/cli.py +"""CLI Vorax : ingest (il mange), ask (il répond), status (il compte).""" +import argparse +from pathlib import Path + +from ..brain import Brain +from ..compiler.kn import compile_kn, load_kn +from ..hv import D_DEFAULT, SEED_DEFAULT + + +def ingest( + source: Path, + brain_path: Path, + D: int = D_DEFAULT, + seed: int = SEED_DEFAULT, + kn_cache: Path | None = None, +) -> int: + cache = kn_cache if kn_cache is not None else brain_path.parent / "kn" + kn_dir = cache / f"{source.stem}.kn" + if not kn_dir.exists(): + compile_kn(source, kn_dir, D=D, seed=seed) + atoms = load_kn(kn_dir) + brain = Brain.load(brain_path) if (brain_path / "meta.json").exists() else Brain(D=D, seed=seed) + n = brain.ingest_atoms(atoms) + brain.save(brain_path) + print(f"[VORAX] +{n} atomes ingérés depuis {source.name} → {brain_path} (total: {len(brain)})") + return n + + +def ask(question: str, brain_path: Path, k: int = 3) -> list[str]: + if not (brain_path / "meta.json").exists(): + return ["[CARTE] LACUNES: aucune trace (brain vide ou absent)"] + brain = Brain.load(brain_path) + hits = brain.ask(question, k=k) + if not hits: + return ["[CARTE] LACUNES: aucune trace pour cette question"] + lines: list[str] = [] + for atom, s in hits: + lines.append(f"[CARTE] FAIT: {atom.statement} (sim {s:.2f} | src: {atom.provenance})") + if atom.context: + lines.append(f" RÉP: {atom.context}") + return lines + + +def status(brain_path: Path) -> str: + if not (brain_path / "meta.json").exists(): + return "[VORAX] brain absent" + brain = Brain.load(brain_path) + return f"[VORAX] brain: {len(brain)} atomes, D={brain.D}, seed={brain.seed}" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="vorax", description="Il ne s'entraîne plus : il mange.") + sub = parser.add_subparsers(dest="cmd", required=True) + + p_ing = sub.add_parser("ingest", help="compiler et ingérer une source") + p_ing.add_argument("source", type=Path) + p_ing.add_argument("--brain", type=Path, default=Path("./brain")) + p_ing.add_argument("--D", type=int, default=D_DEFAULT) + p_ing.add_argument("--seed", type=int, default=SEED_DEFAULT) + p_ing.add_argument("--kn-cache", type=Path, default=None) + + p_ask = sub.add_parser("ask", help="poser une question au brain") + p_ask.add_argument("question", type=str) + p_ask.add_argument("--brain", type=Path, default=Path("./brain")) + p_ask.add_argument("--k", type=int, default=3) + + p_st = sub.add_parser("status", help="état du brain") + p_st.add_argument("--brain", type=Path, default=Path("./brain")) + + args = parser.parse_args(argv) + if args.cmd == "ingest": + ingest(args.source, args.brain, D=args.D, seed=args.seed, kn_cache=args.kn_cache) + elif args.cmd == "ask": + for line in ask(args.question, args.brain, k=args.k): + print(line) + elif args.cmd == "status": + print(status(args.brain)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) +``` + +- [x] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_cli.py -v` +Expected: PASS (4 passed) + +- [x] **Step 5: Full suite + manual smoke, then commit** + +Run: `python -m pytest -v` +Expected: PASS (48 passed — suite complète à ce stade) + +Run: `python -m vorax.agent.cli ingest /nonexistent 2>&1 || true` (sanity: argparse errors gracefully, no traceback panic) +Then: + +```bash +git add vorax/agent/cli.py tests/test_cli.py +git commit -m "feat: CLI ingest/ask/status — end-to-end training-free expertise" +``` + +--- + +### Task 10: Bench smoke + README + +**Files:** +- Create: `bench/ingest_bench.py` +- Create: `README.md` +- Test: `tests/test_bench_smoke.py` + +**Interfaces:** +- Consumes: `compile_kn, load_kn` (Task 8), `Brain` (Task 7), `text_hv` (Task 5) +- Produces: + - `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 + - `generate_csv(path: Path, rows: int) -> int` (fonction réutilisable, rng seedée) + +- [x] **Step 1: Write the failing test** + +```python +# tests/test_bench_smoke.py +import csv +from pathlib import Path + +from bench.ingest_bench import generate_csv + + +def test_generate_csv_deterministic(tmp_path): + p1, p2 = tmp_path / "a.csv", tmp_path / "b.csv" + n1, n2 = generate_csv(p1, 50), generate_csv(p2, 50) + assert n1 == n2 == 50 + assert p1.read_bytes() == p2.read_bytes() + with open(p1, newline="", encoding="utf-8") as fh: + assert sum(1 for _ in csv.reader(fh)) == 51 # header + 50 +``` + +- [x] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_bench_smoke.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'bench'` + +- [x] **Step 3: Write minimal implementation** + +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). + +```python +# conftest.py +"""Racine de repo sur sys.path pour que bench/ soit importable depuis les tests.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +``` + +```python +# bench/ingest_bench.py +"""Bench smoke du substrat : compilation, ingestion, requêtes — CPU pur.""" +import argparse +import random +import time +from pathlib import Path + +from vorax.brain import Brain +from vorax.compiler.encode import text_hv +from vorax.compiler.kn import compile_kn, load_kn + +TOPICS = ["spain", "france", "japan", "chile", "nigeria", "sweden", "peru", "iran"] +FACTS = [ + "the {t} economy grows by {p} percent", + "the population of {t} is {n} million", + "the main export of {t} is {e}", +] +EXPORTS = ["copper", "oil", "fish", "wood", "wine", "wool"] + + +def generate_csv(path: Path, rows: int) -> int: + rng = random.Random(42) + lines = ["statement,context"] + for _ in range(rows): + t = rng.choice(TOPICS) + f = rng.choice(FACTS) + stmt = f.format(t=t, p=rng.randint(1, 9), n=rng.randint(1, 400), e=rng.choice(EXPORTS)) + lines.append(f"{stmt},recorded fact number {rng.randint(1000, 9999)}") + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return rows + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--rows", type=int, default=2000) + parser.add_argument("--D", type=int, default=8192) + args = parser.parse_args() + + out = Path("bench/out") + out.mkdir(parents=True, exist_ok=True) + src = out / "synth.csv" + generate_csv(src, args.rows) + + t0 = time.perf_counter() + kn = compile_kn(src, out / "synth.kn", D=args.D) + t_compile = time.perf_counter() - t0 + + atoms = load_kn(kn) + brain = Brain(D=args.D) + t0 = time.perf_counter() + brain.ingest_atoms(atoms) + t_ingest = time.perf_counter() - t0 + + t0 = time.perf_counter() + n_queries = 100 + for _ in range(n_queries): + brain.traces.retrieve(text_hv("the population of france is 67 million", D=args.D), k=5) + t_query_ms = (time.perf_counter() - t0) * 1000 / n_queries + + print("+---------------------+-------------------+") + print("| étape | mesure |") + print("+---------------------+-------------------+") + print(f"| compilation | {args.rows / t_compile:>10.0f} atomes/s |") + print(f"| ingestion (écriture)| {args.rows / t_ingest:>10.0f} atomes/s |") + print(f"| requête (top-5) | {t_query_ms:>10.1f} ms/q |") + print("+---------------------+-------------------+") + + +if __name__ == "__main__": + main() +``` + +```markdown +# README.md + +# Vorax + +> Fractus ne s'entraîne plus : **il mange**. + +Vorax convertit n'importe quel dataset courant (CSV, TSV, JSON, JSONL, TXT, MD) +en atomes de connaissance compilés (`.kn`), puis les **ingère par écriture** dans +une mémoire de traces hypervectorielles — O(1) par atome, zéro gradient, zéro +GPU, jamais d'oubli catastrophique. La compétence linguistique naît une fois +(frustes cordes vocales branchées au Plan 3) ; la connaissance, elle, s'écrit à +l'infini. + +**Statut** : Plan 1 — le substrat qui mange (compilateur `.kn`, organe traces, +brain persistant, CLI). + +## Quickstart + +```bash +python -m pip install -e ".[dev]" +python -m pytest # suite complète +python -m vorax.agent.cli ingest mes_donnees.csv --brain ./brain +python -m vorax.agent.cli ask "what is the capital of japan" --brain ./brain +python bench/ingest_bench.py # débit compilation/ingestion/requêtes +``` + +## Le contrat sacré + +1. Aucun gradient, jamais, pour apprendre quoi que ce soit. +2. Les artefacts sont déterministes et bit-identiques (même source → même `.kn`). +3. La mémoire est append-only : on superpose, on n'écrase pas. +4. CPU d'abord ; le GPU est une option, pas une exigence. + +Design : `docs/superpowers/specs/2026-08-17-vorax-design.md` +Plan : `docs/superpowers/plans/2026-08-17-vorax-p1-substrat.md` +``` + +- [x] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_bench_smoke.py -v` → PASS +Run: `python -m pytest -v` → PASS (suite complète) +Run: `python bench/ingest_bench.py --rows 500 --D 2048` → le tableau s'imprime avec des débits finis + +- [x] **Step 5: Commit** + +```bash +git add bench/ingest_bench.py tests/test_bench_smoke.py conftest.py README.md +git commit -m "feat: ingest bench smoke + README (plan 1 complete)" +``` + +--- + +## Definition of Done — Plan 1 + +- [x] `python -m pytest -v` : toute la suite passe (49 tests) +- [x] `python bench/ingest_bench.py` : débits mesurés et imprimés +- [x] Démonstration manuelle : `ingest cap.csv` puis `ask` répond `tokyo` — **expertise instantanée, zéro gradient** +- [x] Aucune modification hors de `C:\Users\PHIL\ZCodeProject\vorax\` +- [x] `git log` : un commit par tâche, messages préfixés `feat:` diff --git a/docs/heritage/plans/2026-08-18-vorax-p2-organes.md b/docs/heritage/plans/2026-08-18-vorax-p2-organes.md new file mode 100644 index 0000000000000000000000000000000000000000..e95f423ff0d031b0f8133f7ab3e69388bb041937 --- /dev/null +++ b/docs/heritage/plans/2026-08-18-vorax-p2-organes.md @@ -0,0 +1,1133 @@ +# Vorax Plan 2 — « Les organes et la généralisation » Implementation Plan + +> **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. + +**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. + +**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. + +**Tech Stack:** Python ≥ 3.10, numpy + stdlib UNIQUEMENT (torch est réservé au Plan 3, isolé dans `vorax/model/`). + +**Spec source :** `docs/superpowers/specs/2026-08-17-vorax-design.md` (D1-D7) + +## Global Constraints + +- Python `>= 3.10` ; runtime : `numpy >= 1.24` + stdlib UNIQUEMENT — torch interdit dans ce plan +- 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 +- Append-only : on n'écrase jamais une trace ; le rollback hebbien est une soustraction de la dernière écriture, journalisée +- CPU-only ; D par défaut `8192` (D_h hebbien : `2048`), seed par défaut `0` +- 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`) +- Tout code/identifiants en anglais, prose des docs en français +- TDD strict par tâche, un commit par tâche + +--- + +### Task 1: Slots char-ngram (`vorax/compiler/encode.py` — modification) + +**Files:** +- Modify: `vorax/compiler/encode.py` +- Test: `tests/test_encode.py` (ajouts) + +**Interfaces:** +- Consumes: `hash_hv, bundle, D_DEFAULT, SEED_DEFAULT` (existants) +- Produces: + - `char_ngrams(text: str, n: int = 3) -> list[str]` — n-grammes de caractères sur `#{normalize(text)}#` + - `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) + +- [x] **Step 1: Write the failing tests** (ajouter à `tests/test_encode.py`) + +```python +def test_char_ngrams_padded(): + assert char_ngrams("ab") == ["#ab", "ab#"] + +def test_char_ngrams_length_three(): + assert char_ngrams("abcd") == ["#ab", "abc", "bcd", "cd#"] + +def test_slot_hv_typo_similarity(): + a = slot_hv("france", D=4096) + b = slot_hv("franca", D=4096) + c = slot_hv("japan", D=4096) + assert sim(a, b) > 0.4 # 3-grammes partagés + assert sim(a, c) < 0.2 # quasi-orthogonal + +def test_slot_hv_deterministic(): + assert np.array_equal(slot_hv("spain", D=2048), slot_hv("spain", D=2048)) +``` + +(importer `slot_hv, char_ngrams` depuis `vorax.compiler.encode` et `sim` depuis `vorax.hv` en tête de fichier de test.) + +- [x] **Step 2: Run tests to verify they fail** + +Run: `.venv/bin/python.exe -m pytest tests/test_encode.py -v` +Expected: FAIL — `ImportError: cannot import name 'char_ngrams'` + +- [x] **Step 3: Write minimal implementation** (ajouter à `vorax/compiler/encode.py`) + +```python +def char_ngrams(text: str, n: int = 3) -> list[str]: + """Char n-grammes avec padding '#'. Sub-word fuzziness pour les slots.""" + padded = f"#{normalize(text)}#" + if len(padded) <= n: + return [padded] + return [padded[i:i + n] for i in range(len(padded) - n + 1)] + + +def slot_hv(text: str, D: int = D_DEFAULT, seed: int = SEED_DEFAULT) -> np.ndarray: + """HV de slot : bundle des char-n-grammes — 'france' ~ 'franca', 'france' ⊥ 'japan'.""" + return bundle([hash_hv(g, D, seed) for g in char_ngrams(text)]) +``` + +- [x] **Step 4: Run tests to verify they pass** + +Run: `.venv/bin/python.exe -m pytest tests/test_encode.py -v` +Expected: PASS (11 passed : 7 anciennes + 4 nouvelles) + +- [x] **Step 5: Commit** + +```bash +git add vorax/compiler/encode.py tests/test_encode.py +git commit -m "feat: char-ngram slot encoding — morphological slot similarity" +``` + +--- + +### Task 2: Patterns, relations et analogie (`vorax/compiler/analogy.py`) + +**Files:** +- Create: `vorax/compiler/analogy.py` +- Test: `tests/test_analogy.py` + +**Interfaces:** +- Consumes: `tokenize, text_hv, slot_hv` (Task 1 / existant), `Atom`, `hash_hv, bundle, bind, sim` +- Produces: + - `Pattern = tuple[str, ...]` (tokens du préfixe) + - `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 + - `match_pattern(query: str) -> tuple[Pattern, str] | None` — pattern = tous les tokens sauf le dernier, slot = dernier token (None si < 2 tokens) + - `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` + +- [x] **Step 1: Write the failing tests** + +```python +# tests/test_analogy.py +from vorax.compiler.analogy import extract_patterns, match_pattern, solve_analogy +from vorax.compiler.atoms import Atom + +CAPITALS = [ + Atom("what is the capital of france", "paris", "cap.csv:2"), + Atom("what is the capital of spain", "madrid", "cap.csv:3"), + Atom("what is the capital of japan", "tokyo", "cap.csv:4"), + Atom("what is the capital of italy", "rome", "cap.csv:5"), + Atom("who wrote hamlet", "william shakespeare", "books.csv:2"), +] + + +def test_extract_patterns_min_support(): + patterns = extract_patterns(CAPITALS, min_support=3) + assert ("what", "is", "the", "capital", "of") in patterns + assert ("who", "wrote") not in patterns # support 1 < 3 + rel = patterns[("what", "is", "the", "capital", "of")] + assert rel["france"] == "paris" and rel["italy"] == "rome" + + +def test_match_pattern(): + pattern, slot = match_pattern("What is the capital of Korea?") + assert pattern == ("what", "is", "the", "capital", "of") + assert slot == "korea" + assert match_pattern("hello") is None + + +def test_solve_analogy_typo_slot(): + """Slot jamais vu mais morphologiquement proche d'un slot connu.""" + patterns = extract_patterns(CAPITALS) + rel = patterns[("what", "is", "the", "capital", "of")] + hits = solve_analogy("what is the capital of franc", rel, D=4096) + assert hits and hits[0][0] == "paris" + assert hits[0][1] > 0.2 + + +def test_solve_analogy_known_slot_returns_empty(): + """Slot déjà connu = retrieval (job des traces), pas analogie.""" + patterns = extract_patterns(CAPITALS) + rel = patterns[("what", "is", "the", "capital", "of")] + assert solve_analogy("what is the capital of france", rel, D=4096) == [] + + +def test_solve_analogy_unknown_pattern_returns_empty(): + assert solve_analogy("qui est le capitaine", {}, D=4096) == [] + + +def test_morphological_filter(): + """Candidat partageant un préfixe >= 5 chars avec le slot requête est exclu.""" + rel = {"australia": "australian", "austria": "austrian", "bulgaria": "bulgarian", + "croatia": "croatian", "romania": "romanian"} + hits = solve_analogy("demonym of australian", rel, D=4096) # slot requete = australian + answers = [a for a, _ in hits] + assert "australian" not in answers # préfixe partagé avec le slot requête + + +def test_both_methods_agree_on_typo_case(): + patterns = extract_patterns(CAPITALS) + rel = patterns[("what", "is", "the", "capital", "of")] + for method in ("cosadd", "cosmul"): + hits = solve_analogy("what is the capital of spane", rel, D=4096, method=method) + assert hits and hits[0][0] == "madrid" +``` + +- [x] **Step 2: Run tests to verify they fail** + +Run: `.venv/bin/python.exe -m pytest tests/test_analogy.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'vorax.compiler.analogy'` + +- [x] **Step 3: Write minimal implementation** + +```python +# vorax/compiler/analogy.py +"""Généralisation sans gradient : patterns par préfixe, relations par comptage, +analogie 3CosAdd/3CosMul sur les slots char-ngram (voisins morphologiques).""" +import math + +import numpy as np + +from ..hv import D_DEFAULT, SEED_DEFAULT, sim +from .atoms import Atom +from .encode import slot_hv, text_hv, tokenize + +Pattern = tuple[str, ...] +MORPHO_PREFIX = 5 + + +def extract_patterns(atoms: list[Atom], min_support: int = 3) -> dict[Pattern, dict[str, str]]: + """Groupes d'énoncés partageant le même préfixe tokenique (slot = dernier token).""" + groups: dict[Pattern, dict[str, str]] = {} + counts: dict[Pattern, int] = {} + for atom in atoms: + tokens = tokenize(atom.statement) + if len(tokens) < 2 or not atom.context: + continue + pattern, slot = tuple(tokens[:-1]), tokens[-1] + groups.setdefault(pattern, {})[slot] = atom.context + counts[pattern] = counts.get(pattern, 0) + 1 + return {p: rel for p, rel in groups.items() if counts[p] >= min_support} + + +def match_pattern(query: str) -> tuple[Pattern, str] | None: + tokens = tokenize(query) + if len(tokens) < 2: + return None + return tuple(tokens[:-1]), tokens[-1] + + +def _shares_prefix(a: str, b: str, n: int = MORPHO_PREFIX) -> bool: + return a[:n] == b[:n] and len(a) >= n and len(b) >= n + + +def 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]]: + matched = match_pattern(query) + if matched is None: + return [] + _pattern, slot_q = matched + if slot_q in relation: + return [] # slot connu : c'est du retrieval, pas de l'analogie + # voisins morphologiques (char-ngram) les plus proches + hq = slot_hv(slot_q, D, seed) + known = sorted(relation) # déterminisme + scored = sorted(((sim(hq, slot_hv(s, D, seed)), s) for s in known), reverse=True) + neighbors = [(s, w) for w, s in scored[:k_neighbors] if w >= min_sim] + if not neighbors: + return [] + # candidats = réponses des voisins (unicité, ordre trié) + candidates = sorted({relation[s] for s, _ in neighbors}) + cand_hvs = {c: text_hv(c, D, seed) for c in candidates} + ans_hvs = {s: text_hv(relation[s], D, seed) for s, _ in neighbors} + results: list[tuple[str, float]] = [] + for cand, ch in cand_hvs.items(): + if _shares_prefix(cand, slot_q): + continue # filtre morphologique + if method == "cosadd": + score = sum(w * sim(ch, ans_hvs[s]) for s, w in neighbors) + else: # cosmul : cosinus remappés [0,1], moyenne géométrique pondérée + log_sum = sum(w * math.log((sim(ch, ans_hvs[s]) + 1.0) / 2.0) for s, w in neighbors) + total_w = sum(w for _, w in neighbors) + score = math.exp(log_sum / total_w) if total_w > 0 else 0.0 + if score >= min_sim: + results.append((cand, float(score))) + results.sort(key=lambda x: (-x[1], x[0])) + return results +``` + +- [x] **Step 4: Run tests to verify they pass** + +Run: `.venv/bin/python.exe -m pytest tests/test_analogy.py -v` +Expected: PASS (7 passed) puis `.venv/bin/python.exe -m pytest -q` → 62 passed (aucune régression) + +- [x] **Step 5: Commit** + +```bash +git add vorax/compiler/analogy.py tests/test_analogy.py +git commit -m "feat: pattern extraction, relations and 3CosAdd/3CosMul analogy with morphological filter" +``` + +--- + +### Task 3: Organe hebbien (`vorax/organs/hebbian.py`) + +**Files:** +- Create: `vorax/organs/hebbian.py` +- Test: `tests/test_hebbian.py` + +**Interfaces:** +- Consumes: `pack, unpack` +- Produces: + - `HebbianMemory(D_h: int = 2048)` avec : + - `write(key: np.ndarray, value: np.ndarray) -> int` (index d'écriture, M += outer(key, value)) + - `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` + - `complete(query: np.ndarray) -> np.ndarray` — `sign(M @ query)`, égalités → +1 + - `rollback() -> bool` (annule la dernière écriture) + - `n_writes -> int`, `__len__` = n_writes + - `save(path: Path)` / `HebbianMemory.load(path: Path)` (npz int32 + meta.json, clé `n_writes`, `D_h`) + +- [x] **Step 1: Write the failing tests** + +```python +# tests/test_hebbian.py +from pathlib import Path + +import numpy as np + +from vorax.hv import hash_hv, sim, unpack, pack +from vorax.organs.hebbian import HebbianMemory + + +def _pair(text: str, D: int = 2048): + return hash_hv(f"key:{text}", D), hash_hv(f"val:{text}", D) + + +def test_write_and_complete(): + mem = HebbianMemory(D_h=2048) + pairs = [_pair(t) for t in ("france", "spain", "japan")] + for k, v in pairs: + mem.write(k, v) + for k, v in pairs: + assert np.array_equal(mem.complete(k), v) + + +def test_complete_noise_robustness(): + mem = HebbianMemory(D_h=2048) + k, v = _pair("attractor") + mem.write(k, v) + rng = np.random.default_rng(42) + flip = rng.choice(2048, size=100, replace=False) # ~5% de bruit + noisy = k.copy() + noisy[flip] *= -1 + assert np.array_equal(mem.complete(noisy), v) + + +def test_gate_rejects_degrading_write(): + mem = HebbianMemory(D_h=2048) + k1, v1 = _pair("un") + k2, v2 = _pair("deux") + assert mem.write_gated(k1, v1, probes=[]) is True + # écrire (k1, -v1) dégraderait la sonde (k1 -> v1) + assert mem.write_gated(k1, -v1, probes=[(k1, v1)]) is False + assert mem.n_writes == 1 + assert np.array_equal(mem.complete(k1), v1) # rien n'a bougé + + +def test_rollback(): + mem = HebbianMemory(D_h=2048) + k, v = _pair("garder") + k2, v2 = _pair("jeter") + mem.write(k, v) + mem.write(k2, v2) + assert mem.rollback() is True + assert mem.n_writes == 1 + assert np.array_equal(mem.complete(k), v) + + +def test_save_load_roundtrip(tmp_path): + mem = HebbianMemory(D_h=2048) + for t in ("a", "b", "c"): + mem.write(*_pair(t)) + mem.save(tmp_path / "heb") + loaded = HebbianMemory.load(tmp_path / "heb") + assert loaded.n_writes == 3 + k, v = _pair("b") + assert np.array_equal(loaded.complete(k), v) +``` + +- [x] **Step 2: Run tests to verify they fail** + +Run: `.venv/bin/python.exe -m pytest tests/test_hebbian.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'vorax.organs.hebbian'` + +- [x] **Step 3: Write minimal implementation** + +```python +# vorax/organs/hebbian.py +"""Organe 2 — cortex hebbien : mémoire associative en forme close. +Écriture = produit externe additionné (jamais un gradient), complétion = sign(M @ q). +Porte ΔE : une écriture qui casse une sonde existante est refusée et annulée.""" +import json +from pathlib import Path + +import numpy as np + +from ..hv import pack, unpack + +D_H_DEFAULT = 2048 + + +class HebbianMemory: + def __init__(self, D_h: int = D_H_DEFAULT) -> None: + self.D_h = D_h + self.M = np.zeros((D_h, D_h), dtype=np.int32) + self._ledger: list[tuple[np.ndarray, np.ndarray]] = [] + + @property + def n_writes(self) -> int: + return len(self._ledger) + + def __len__(self) -> int: + return len(self._ledger) + + def write(self, key: np.ndarray, value: np.ndarray) -> int: + self.M += np.outer(key.astype(np.int32), value.astype(np.int32)) + self._ledger.append((key, value)) + return len(self._ledger) - 1 + + def write_gated( + self, key: np.ndarray, value: np.ndarray, + probes: list[tuple[np.ndarray, np.ndarray]], + ) -> bool: + self.write(key, value) + if all(np.array_equal(self.complete(pk), pv) for pk, pv in probes): + return True + self.rollback() + return False + + def complete(self, query: np.ndarray) -> np.ndarray: + acc = self.M @ query.astype(np.int32) + return np.where(acc >= 0, 1, -1).astype(np.int8) + + def rollback(self) -> bool: + if not self._ledger: + return False + key, value = self._ledger.pop() + self.M -= np.outer(key.astype(np.int32), value.astype(np.int32)) + return True + + def save(self, path: Path) -> None: + path.mkdir(parents=True, exist_ok=True) + n = len(self._ledger) + n_bytes = self.D_h // 8 + if n: + keys = np.stack([pack(k) for k, _ in self._ledger]) + values = np.stack([pack(v) for _, v in self._ledger]) + else: + keys = np.zeros((0, n_bytes), dtype=np.uint8) + values = keys.copy() + np.savez_compressed(path / "hebbian.npz", M=self.M, keys=keys, values=values) + (path / "hebbian_meta.json").write_text( + json.dumps({"D_h": self.D_h, "n_writes": n}, sort_keys=True), encoding="utf-8" + ) + + @classmethod + def load(cls, path: Path) -> "HebbianMemory": + meta = json.loads((path / "hebbian_meta.json").read_text(encoding="utf-8")) + mem = cls(D_h=int(meta["D_h"])) + data = np.load(path / "hebbian.npz") + mem.M = data["M"].astype(np.int32) + for i in range(int(meta["n_writes"])): + key = unpack(data["keys"][i], mem.D_h) + value = unpack(data["values"][i], mem.D_h) + mem._ledger.append((key, value)) + return mem +``` + +- [x] **Step 4: Run tests to verify they pass** + +Run: `.venv/bin/python.exe -m pytest tests/test_hebbian.py -v` +Expected: PASS (5 passed) puis `.venv/bin/python.exe -m pytest -q` → 67 passed + +- [x] **Step 5: Commit** + +```bash +git add vorax/organs/hebbian.py tests/test_hebbian.py +git commit -m "feat: hebbian organ — closed-form outer-product memory with delta-E gate and rollback" +``` + +--- + +### Task 4: Spawn d'experts (`vorax/organs/spawn.py`) + +**Files:** +- Create: `vorax/organs/spawn.py` +- Test: `tests/test_spawn.py` + +**Interfaces:** +- Consumes: `pack, unpack, sim` +- Produces: + - `Expert` dataclass frozen : `name: str`, `atom_start: int`, `atom_end: int`, `n_atoms: int` (`signature` stockée à part dans le registre) + - `ExpertRegistry()` avec : + - `spawn(name: str, signature: np.ndarray, atom_start: int, atom_end: int) -> Expert` + - `route(query_hv: np.ndarray, top: int = 2) -> list[tuple[str, float]]` — sims aux signatures, tri décroissant puis nom + - `get(name: str) -> Expert | None`, `__len__` + - `save(path: Path)` / `ExpertRegistry.load(path: Path)` (`experts.json` trié + `signatures.npz` packées) + +- [x] **Step 1: Write the failing tests** + +```python +# tests/test_spawn.py +from vorax.hv import bundle, hash_hv +from vorax.organs.spawn import ExpertRegistry + +CAP_TOKENS = ["what", "is", "the", "capital", "of"] +BOOK_TOKENS = ["who", "wrote"] + + +def _sig(tokens, D=2048): + return bundle([hash_hv(t, D) for t in tokens]) + + +def test_spawn_and_route(): + reg = ExpertRegistry() + reg.spawn("capitals", _sig(CAP_TOKENS), 0, 4) + reg.spawn("books", _sig(BOOK_TOKENS), 5, 9) + assert len(reg) == 2 + hits = reg.route(_sig(["what", "is", "the", "capital", "of", "germany"])) + assert hits[0][0] == "capitals" + assert hits[0][1] > 0.5 + + +def test_route_empty(): + assert ExpertRegistry().route(_sig(CAP_TOKENS)) == [] + + +def test_get(): + reg = ExpertRegistry() + e = reg.spawn("capitals", _sig(CAP_TOKENS), 0, 4) + assert reg.get("capitals") == e + assert reg.get("nope") is None + + +def test_save_load_roundtrip(tmp_path): + reg = ExpertRegistry() + reg.spawn("capitals", _sig(CAP_TOKENS), 0, 4) + reg.spawn("books", _sig(BOOK_TOKENS), 5, 9) + reg.save(tmp_path / "experts") + loaded = ExpertRegistry.load(tmp_path / "experts") + assert len(loaded) == 2 + hits = loaded.route(_sig(["who", "wrote", "hamlet"])) + assert hits[0][0] == "books" +``` + +- [x] **Step 2: Run tests to verify they fail** + +Run: `.venv/bin/python.exe -m pytest tests/test_spawn.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'vorax.organs.spawn'` + +- [x] **Step 3: Write minimal implementation** + +```python +# vorax/organs/spawn.py +"""Organe 3 — croissance : chaque source ingérée devient un expert routé par +sa signature HV. Le modèle grossit physiquement, sans entraînement conjoint.""" +import json +from dataclasses import dataclass +from pathlib import Path + +import numpy as np + +from ..hv import pack, sim, unpack + +D_SIG = 8192 # les signatures vivent dans l'espace D du brain + + +@dataclass(frozen=True) +class Expert: + name: str + atom_start: int + atom_end: int + n_atoms: int + + +class ExpertRegistry: + def __init__(self) -> None: + self.experts: dict[str, Expert] = {} + self.signatures: dict[str, np.ndarray] = {} + + def __len__(self) -> int: + return len(self.experts) + + def spawn(self, name: str, signature: np.ndarray, atom_start: int, atom_end: int) -> Expert: + expert = Expert(name, atom_start, atom_end, atom_end - atom_start + 1) + self.experts[name] = expert + self.signatures[name] = signature + return expert + + def get(self, name: str) -> Expert | None: + return self.experts.get(name) + + def route(self, query_hv: np.ndarray, top: int = 2) -> list[tuple[str, float]]: + if not self.experts: + return [] + scored = [(name, sim(query_hv, sig)) for name, sig in self.signatures.items()] + scored.sort(key=lambda x: (-x[1], x[0])) + return scored[:top] + + def save(self, path: Path) -> None: + path.mkdir(parents=True, exist_ok=True) + payload = [ + {"name": e.name, "atom_start": e.atom_start, "atom_end": e.atom_end, "n_atoms": e.n_atoms} + for e in sorted(self.experts.values(), key=lambda e: e.name) + ] + (path / "experts.json").write_text( + json.dumps(payload, sort_keys=True, indent=2), encoding="utf-8" + ) + names = sorted(self.signatures) + bits = [pack(self.signatures[n]) for n in names] + np.savez_compressed( + path / "signatures.npz", + names=np.array(names), + bits=np.stack(bits) if bits else np.array([], dtype=np.uint8), + ) + + @classmethod + def load(cls, path: Path) -> "ExpertRegistry": + reg = cls() + for item in json.loads((path / "experts.json").read_text(encoding="utf-8")): + reg.experts[item["name"]] = Expert( + item["name"], item["atom_start"], item["atom_end"], item["n_atoms"] + ) + data = np.load(path / "signatures.npz", allow_pickle=False) + names = [str(n) for n in data["names"]] + if names: + n_bytes = data["bits"].shape[1] + D = n_bytes * 8 + for i, name in enumerate(names): + reg.signatures[name] = unpack(data["bits"][i], D) + return reg +``` + +- [x] **Step 4: Run tests to verify they pass** + +Run: `.venv/bin/python.exe -m pytest tests/test_spawn.py -v` +Expected: PASS (4 passed) puis `.venv/bin/python.exe -m pytest -q` → 71 passed + +- [x] **Step 5: Commit** + +```bash +git add vorax/organs/spawn.py tests/test_spawn.py +git commit -m "feat: expert spawn organ — signature-routed growth, no joint training" +``` + +--- + +### Task 5: Consolidation (`vorax/organs/consolidation.py`) + +**Files:** +- Create: `vorax/organs/consolidation.py` +- Test: `tests/test_consolidation.py` + +**Interfaces:** +- Consumes: rien (numpy/stdlib) +- Produces: + - `CoActivation()` avec : + - `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) + - `promote(threshold: int = 3) -> list[tuple[int, int]]` — paires atteignant le seuil, triées (count décroissant, puis paire) + - `stats() -> dict` (`{"pairs": n, "max_count": c}`) + - `save(path: Path)` / `CoActivation.load(path: Path)` (`coactivation.json` : paires `"i|j": count`, clés triées) + +- [x] **Step 1: Write the failing tests** + +```python +# tests/test_consolidation.py +from vorax.organs.consolidation import CoActivation + + +def test_record_and_promote(): + co = CoActivation() + for _ in range(3): + co.record([0, 1, 2]) # paires (0,1) (0,2) (1,2) x3 + co.record([7, 8]) # x1 + pairs = co.promote(threshold=3) + assert set(pairs) == {(0, 1), (0, 2), (1, 2)} + assert (7, 8) not in pairs + + +def test_order_independent(): + co = CoActivation() + co.record([2, 0]) + co.record([0, 2]) + assert co.promote(threshold=2) == [(0, 2)] + + +def test_stats_and_roundtrip(tmp_path): + co = CoActivation() + for _ in range(4): + co.record([3, 4]) + co.save(tmp_path / "co") + loaded = CoActivation.load(tmp_path / "co") + assert loaded.promote(threshold=4) == [(3, 4)] + assert loaded.stats()["pairs"] == 1 +``` + +- [x] **Step 2: Run tests to verify they fail** + +Run: `.venv/bin/python.exe -m pytest tests/test_consolidation.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'vorax.organs.consolidation'` + +- [x] **Step 3: Write minimal implementation** + +```python +# vorax/organs/consolidation.py +"""Consolidation : les atomes co-retrouvés souvent se lient en concepts composites. +Comptage append-only, paires triées, déterminisme total.""" +import json +from itertools import combinations +from pathlib import Path + + +class CoActivation: + def __init__(self) -> None: + self.counts: dict[tuple[int, int], int] = {} + + def record(self, atom_ids: list[int]) -> None: + ids = sorted(set(atom_ids)) + for a, b in combinations(ids, 2): + self.counts[(a, b)] = self.counts.get((a, b), 0) + 1 + + def promote(self, threshold: int = 3) -> list[tuple[int, int]]: + pairs = [(pair, c) for pair, c in self.counts.items() if c >= threshold] + pairs.sort(key=lambda x: (-x[1], x[0])) + return [pair for pair, _ in pairs] + + def stats(self) -> dict: + max_count = max(self.counts.values(), default=0) + return {"pairs": len(self.counts), "max_count": max_count} + + def save(self, path: Path) -> None: + path.mkdir(parents=True, exist_ok=True) + payload = {f"{a}|{b}": c for (a, b), c in sorted(self.counts.items())} + (path / "coactivation.json").write_text( + json.dumps(payload, sort_keys=True), encoding="utf-8" + ) + + @classmethod + def load(cls, path: Path) -> "CoActivation": + co = cls() + for key, count in json.loads((path / "coactivation.json").read_text(encoding="utf-8")).items(): + a, b = key.split("|") + co.counts[(int(a), int(b))] = int(count) + return co +``` + +- [x] **Step 4: Run tests to verify they pass** + +Run: `.venv/bin/python.exe -m pytest tests/test_consolidation.py -v` +Expected: PASS (3 passed) puis `.venv/bin/python.exe -m pytest -q` → 74 passed + +- [x] **Step 5: Commit** + +```bash +git add vorax/organs/consolidation.py tests/test_consolidation.py +git commit -m "feat: co-activation consolidation — concepts from co-retrieval, no gradients" +``` + +--- + +### Task 6: Brain v2 — intégration des organes (`vorax/brain.py` — modification) + +**Files:** +- Modify: `vorax/brain.py` +- Create: `vorax/cards.py` +- Test: `tests/test_brain_v2.py` + +**Interfaces:** +- Consumes: tous les organes (Tasks 1-5), `bind` de hv +- Produces: + - `Card` dataclass frozen (`vorax/cards.py`) : `kind: str` ∈ `{"FAIT", "ANALOGIE", "LACUNE"}`, `text: str`, `sim: float`, `source: str` + - Brain étend (compatibilité Plan 1 **strictement préservée** — `ask()`, `ingest_atom`, `save/load` existants gardent leur signature et comportement) : + - attributs nouveaux : `experts: ExpertRegistry`, `hebbian: HebbianMemory`, `coact: CoActivation`, `relations: dict[Pattern, dict[str, str]]` + - `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 + - `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 + - `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 + - `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 + +- [x] **Step 1: Write the failing tests** + +```python +# tests/test_brain_v2.py +from vorax.brain import Brain +from vorax.cards import Card +from vorax.compiler.atoms import Atom + +CAPITALS = [ + Atom("what is the capital of france", "paris", "cap.csv:2"), + Atom("what is the capital of spain", "madrid", "cap.csv:3"), + Atom("what is the capital of japan", "tokyo", "cap.csv:4"), + Atom("what is the capital of italy", "rome", "cap.csv:5"), +] +BOOKS = [Atom("who wrote hamlet", "william shakespeare", "books.csv:2")] + + +def _brain(): + brain = Brain(D=2048) + brain.ingest_source("capitals", CAPITALS) + brain.ingest_source("books", BOOKS) + return brain + + +def test_ingest_source_spawns_expert_and_relations(): + brain = _brain() + assert len(brain.experts) == 2 + assert brain.experts.get("capitals") is not None + assert any("capital" in " ".join(p) for p in brain.relations) + assert brain.hebbian.n_writes >= 5 + + +def test_ask_cards_fact(): + brain = _brain() + cards = brain.ask_cards("what is the capital of japan", k=2) + facts = [c for c in cards if c.kind == "FAIT"] + assert facts and "tokyo" in facts[0].text + assert facts[0].sim > 0.5 + + +def test_ask_cards_analogy_on_typo(): + brain = _brain() + cards = brain.ask_cards("what is the capital of franc", k=3) + ana = [c for c in cards if c.kind == "ANALOGIE"] + assert ana and "paris" in ana[0].text + + +def test_consolidate_creates_composite(): + brain = _brain() + for _ in range(3): + brain.ask_cards("what is the capital of france", k=2) + n0 = len(brain) + promoted = brain.consolidate(threshold=3) + assert promoted >= 1 + assert len(brain) == n0 + promoted + comp = brain.atoms[-1] + assert comp.provenance == "consolidated" + + +def test_plan1_compat_ask_still_works(): + brain = _brain() + hits = brain.ask("what is the capital of spain", k=1) + assert hits[0][0].context == "madrid" + + +def test_save_load_roundtrip_v2(tmp_path): + brain = _brain() + brain.save(tmp_path / "brain") + loaded = Brain.load(tmp_path / "brain") + assert len(loaded.experts) == 2 + assert loaded.hebbian.n_writes == brain.hebbian.n_writes + assert loaded.ask("who wrote hamlet", k=1)[0][0].context == "william shakespeare" + + +def test_load_plan1_brain_still_works(tmp_path): + """Un brain Plan 1 (sans organes persistés) doit se charger.""" + brain = Brain(D=2048) + brain.ingest_atoms(CAPITALS) + brain.save(tmp_path / "old") + # simuler l'absence des fichiers v2 + import shutil + for sub in ("experts", "hebbian"): + shutil.rmtree(tmp_path / "old" / sub, ignore_errors=True) + (tmp_path / "old" / "coactivation.json").unlink(missing_ok=True) + (tmp_path / "old" / "relations.json").unlink(missing_ok=True) + loaded = Brain.load(tmp_path / "old") + assert len(loaded) == 4 + assert len(loaded.experts) == 0 +``` + +- [x] **Step 2: Run tests to verify they fail** + +Run: `.venv/bin/python.exe -m pytest tests/test_brain_v2.py -v` +Expected: FAIL — `ImportError: cannot import name 'Card'` (et `ingest_source` manquant) + +- [x] **Step 3: Write minimal implementation** + +```python +# vorax/cards.py +"""Carte de connaissance : l'unité que le noyau fractus lira (Plan 3).""" +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Card: + kind: str # "FAIT" | "ANALOGIE" | "LACUNE" + text: str + sim: float + source: str +``` + +Modification de `vorax/brain.py` (ajouts — ne rien casser) : + +```python +# --- nouveaux imports en tête --- +from .cards import Card +from .compiler.analogy import Pattern, extract_patterns, match_pattern, solve_analogy +from .hv import bind, hash_hv +from .organs.consolidation import CoActivation +from .organs.hebbian import HebbianMemory +from .organs.spawn import ExpertRegistry + +# --- __init__ étend (après l'existant) --- + self.experts = ExpertRegistry() + self.hebbian = HebbianMemory() + self.coact = CoActivation() + self.relations: dict[Pattern, dict[str, str]] = {} + +# --- méthodes nouvelles --- + def ingest_source(self, name: str, atoms: list[Atom]) -> int: + start = len(self.atoms) + for atom in atoms: + self.ingest_atom(atom) + end = len(self.atoms) - 1 + # relations locales au batch (merge déterministe : setdefault) + for pattern, rel in extract_patterns(atoms, min_support=3).items(): + merged = self.relations.setdefault(pattern, {}) + for slot, answer in rel.items(): + merged.setdefault(slot, answer) + # écriture hebbienne (espace D_h=2048, re-hash indépendant) + for atom in atoms: + key = hash_hv(atom.statement, self.hebbian.D_h, self.seed) + value = hash_hv(atom.context or atom.statement, self.hebbian.D_h, self.seed) + window = self.atoms[max(start, len(self.atoms) - 9):len(self.atoms) - 1] + probes = [ + (hash_hv(a.statement, self.hebbian.D_h, self.seed), + hash_hv(a.context or a.statement, self.hebbian.D_h, self.seed)) + for a in window + ] + self.hebbian.write_gated(key, value, probes) + # spawn : signature = signe majoritaire des addr du batch + if atoms: + addrs = [encode_atom(a, self.D, self.seed)[0] for a in atoms] + total = np.sum(np.stack(addrs).astype(np.int32), axis=0) + signature = np.where(total >= 0, 1, -1).astype(np.int8) + self.experts.spawn(name, signature, start, end) + return len(atoms) + + def ask_cards(self, question: str, k: int = 3) -> list[Card]: + query = text_hv(question, self.D, self.seed) + routed = self.experts.route(query, top=1) + route_name = routed[0][0] if routed else "" + hits = self.ask(question, k=k) + self.coact.record([i for i, _ in hits]) + cards: list[Card] = [] + for atom, s in hits: + text = f"{atom.statement} => {atom.context}" if atom.context else atom.statement + cards.append(Card("FAIT", text, s, atom.provenance)) + top_sim = hits[0][1] if hits else 0.0 + if top_sim < 0.5: + matched = match_pattern(question) + if matched is not None: + pattern, _slot = matched + rel = self.relations.get(pattern) + if rel: + for answer, score in solve_analogy(question, rel, D=self.D, seed=self.seed)[:2]: + cards.append(Card("ANALOGIE", answer, score, route_name or "analogie")) + if not cards: + cards.append(Card("LACUNE", "aucune trace pour cette question", 0.0, route_name)) + return cards + + def consolidate(self, threshold: int = 3) -> int: + promoted = 0 + for a_id, b_id in self.coact.promote(threshold=threshold): + a, b = self.atoms[a_id], self.atoms[b_id] + composite = Atom(f"({a.statement}) & ({b.statement})", + a.context or b.context, "consolidated", 0.8) + atom_id = len(self.atoms) + self.atoms.append(composite) + # addr composite = bind des deux addr — write() stocke les HV tels quels + addr_a = encode_atom(a, self.D, self.seed)[0] + addr_b = encode_atom(b, self.D, self.seed)[0] + addr = bind(addr_a, addr_b) + value = encode_atom(composite, self.D, self.seed)[1] + self.traces.write(atom_id, addr, value, 0.8) + promoted += 1 + return promoted +``` + +(`TraceStore.write` stocke les HV passés tels quels — l'encodage n'a lieu que dans +`ingest_atom`. Pas besoin de `write_raw` : `consolidate` appelle `write` directement.) + +`save`/`load` étendus — à la fin du `save` existant : + +```python + self.experts.save(path / "experts") + self.hebbian.save(path / "hebbian") + self.coact.save(path) + relations_payload = {"|".join(p): rel for p, rel in sorted(self.relations.items())} + (path / "relations.json").write_text( + json.dumps(relations_payload, sort_keys=True), encoding="utf-8" + ) +``` + +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) : + +```python + experts_dir = path / "experts" + if experts_dir.exists(): + brain.experts = ExpertRegistry.load(experts_dir) + heb_dir = path / "hebbian" + if heb_dir.exists(): + brain.hebbian = HebbianMemory.load(heb_dir) + co_file = path / "coactivation.json" + if co_file.exists(): + brain.coact = CoActivation.load(path) + rel_file = path / "relations.json" + if rel_file.exists(): + raw = json.loads(rel_file.read_text(encoding="utf-8")) + for key, rel in raw.items(): + pattern = tuple(key.split("|")) + merged = brain.relations.setdefault(pattern, {}) + for slot, answer in rel.items(): + merged.setdefault(slot, answer) + return brain +``` + +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. + +- [x] **Step 4: Run tests to verify they pass** + +Run: `.venv/bin/python.exe -m pytest tests/test_brain_v2.py -v` +Expected: PASS (7 passed) puis `.venv/bin/python.exe -m pytest -q` → 81 passed (74 + 7, zéro régression Plan 1) + +- [x] **Step 5: Commit** + +```bash +git add vorax/brain.py vorax/cards.py vorax/organs/traces.py tests/test_brain_v2.py +git commit -m "feat: brain v2 — organs wired: routing, analogy cards, gated hebbian writes, consolidation" +``` + +--- + +### Task 7: CLI v2 — cartes riches et experts visibles (`vorax/agent/cli.py` — modification) + +**Files:** +- Modify: `vorax/agent/cli.py` +- Modify: `bench/ingest_bench.py` (stat experts) +- Test: `tests/test_cli_v2.py` + +**Interfaces:** +- Consumes: `Brain.ask_cards`, `Brain.experts`, `Brain.relations`, `Brain.hebbian.n_writes` +- Produces: + - `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)})` + - `ask()` utilise `ask_cards` et imprime : `[CARTE] {kind}: {text} (sim {sim:.2f} | src: {source})` (et pour ANALOGIE, `src` = expert routé ou `analogie`) + - `status()` ajoute : `[VORAX] organes: {len(experts)} experts, {n_relations} relations, {hebbian.n_writes} écritures hebbiennes, {coact.stats()['pairs']} paires co-actives` + - bench : après ingestion, imprime une ligne `[BENCH] organes: {len(brain.experts)} experts spawnés` + +- [x] **Step 1: Write the failing tests** + +```python +# tests/test_cli_v2.py +from pathlib import Path + +from vorax.agent import cli + +CSV_CONTENT = ( + "question,answer\n" + "what is the capital of france,paris\n" + "what is the capital of spain,madrid\n" + "what is the capital of japan,tokyo\n" + "what is the capital of italy,rome\n" + "who wrote hamlet,william shakespeare\n" +) + + +def _setup(tmp_path): + p = tmp_path / "cap.csv" + p.write_text(CSV_CONTENT, encoding="utf-8") + brain = tmp_path / "brain" + cli.ingest(p, brain, D=2048, seed=0, kn_cache=tmp_path / "kn") + return p, brain + + +def test_ingest_announces_expert(tmp_path, capsys): + _setup(tmp_path) + out = capsys.readouterr().out + assert "expert 'cap' spawné" in out + + +def test_ask_prints_fact_cards(tmp_path, capsys): + _, brain = _setup(tmp_path) + cli.main(["ask", "what is the capital of japan", "--brain", str(brain), "--k", "2"]) + out = capsys.readouterr().out + assert "[CARTE] FAIT:" in out and "tokyo" in out + + +def test_ask_typo_prints_analogy_card(tmp_path, capsys): + _, brain = _setup(tmp_path) + cli.main(["ask", "what is the capital of franc", "--brain", str(brain), "--k", "2"]) + out = capsys.readouterr().out + assert "[CARTE] ANALOGIE:" in out and "paris" in out + + +def test_status_lists_organs(tmp_path, capsys): + _, brain = _setup(tmp_path) + cli.main(["status", "--brain", str(brain)]) + out = capsys.readouterr().out + assert "organes:" in out and "expert" in out and "relation" in out +``` + +- [x] **Step 2: Run tests to verify they fail** + +Run: `.venv/bin/python.exe -m pytest tests/test_cli_v2.py -v` +Expected: FAIL — `expert 'cap' spawné` absent (ingest utilise encore ingest_atoms) + +- [x] **Step 3: Write minimal implementation** + +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 : + +```python + brain = Brain.load(brain_path) + cards = brain.ask_cards(question, k=k) + if not cards: + return ["[CARTE] LACUNES: aucune trace pour cette question"] + return [f"[CARTE] {c.kind}: {c.text} (sim {c.sim:.2f} | src: {c.source})" for c in cards] +``` + +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)`) : + +```python + print(f"[BENCH] organes: {len(brain.experts)} experts spawnés") +``` + +- [x] **Step 4: Run tests to verify they pass** + +Run: `.venv/bin/python.exe -m pytest tests/test_cli_v2.py -v` +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. + +- [x] **Step 5: Full suite, bench smoke, commit** + +Run: `.venv/bin/python.exe -m pytest -q` → 85 passed +Run: `.venv/bin/python.exe bench/ingest_bench.py --rows 300 --D 2048` → tableau + `[BENCH] organes: 1 experts spawnés` + +```bash +git add vorax/agent/cli.py bench/ingest_bench.py tests/test_cli_v2.py tests/test_cli.py +git commit -m "feat: cli v2 — rich cards, expert spawn announcements, organ stats" +``` + +--- + +## Definition of Done — Plan 2 + +- [x] `python -m pytest -q` : 85 passed (55 Plan 1 + 30 Plan 2), zéro régression +- [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** +- [x] `status` montre les organes (experts, relations, écritures hebbiennes, co-activation) +- [x] Hebbien : porte ΔE refuse une écriture dégradante, rollback OK, save/load OK +- [x] Aucune dépendance nouvelle (numpy + stdlib seulement), déterminisme préservé +- [x] `git log` : un commit par tâche diff --git a/docs/heritage/plans/2026-08-18-vorax-p3-noyau.md b/docs/heritage/plans/2026-08-18-vorax-p3-noyau.md new file mode 100644 index 0000000000000000000000000000000000000000..d869204982396d1b8a3d2518a75ef32506ec7e98 --- /dev/null +++ b/docs/heritage/plans/2026-08-18-vorax-p3-noyau.md @@ -0,0 +1,488 @@ +# Vorax Plan 3 — « Le noyau natif fractus et l'agent live » Implementation Plan + +> **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. + +**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. + +**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. + +**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/`. + +**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). + +## Global Constraints + +- **Torch strictement isolé dans `vorax/model/`** : aucun autre module de `vorax/` n'importe torch (même paresseusement) — testé par gate +- La suite complète doit rester verte dans le venv mingw **sans torch** (tests torch → `pytest.importorskip`) +- Aucun LLM externe, aucun GGUF, aucune API distante — le noyau est fractus, point +- Le checkpoint fractus-cte est chargé **en lecture seule** (jamais réécrit, jamais modifié — les repos siblings ne sont jamais écrits) +- Déterminisme : génération greedy par défaut (temperature=0), seeds fixées, artefacts triés +- Les 93 tests des Plans 1-2 restent verts à chaque tâche ; un commit par tâche +- CPU-only ; prose docs en français, code en anglais +- TDD strict + +--- + +### Task 1: Venv torch + investigation de l'architecture fractus-cte + +**Files:** +- Create: `.venv-torch/` (venv Python 3.11 natif, PAS committé — gitignore) +- Create: `docs/fractus-cte-architecture.md` +- Create: `vorax/model/__init__.py` (vide) +- Create: `tests/test_gates.py` +- Modify: `.gitignore` + +**Interfaces:** +- Produces: + - `.venv-torch` avec torch CPU + numpy + pytest fonctionnels (importables, versions documentées dans le doc) + - `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) + - `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` + +- [x] **Step 1: Write the failing test** + +```python +# tests/test_gates.py +"""Gates structurels : torch ne vit que dans vorax/model/.""" +from pathlib import Path + +REPO = Path(__file__).parent.parent + + +def test_torch_isolated_in_model_dir(): + package = REPO / "vorax" + offenders = [] + for py in package.rglob("*.py"): + if py.parent == package / "model": + continue # autorisé ici et ici seulement + text = py.read_text(encoding="utf-8") + if "import torch" in text or "from torch" in text: + offenders.append(str(py.relative_to(REPO))) + assert offenders == [] +``` + +- [x] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/python.exe -m pytest tests/test_gates.py -v` +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`. + +- [x] **Step 3: Implementation** + +1. Créer `vorax/model/__init__.py` (vide) et ajouter `.venv-torch/` à `.gitignore`. +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` +3. Vérifier : `.venv-torch/Scripts/python.exe -c "import torch, numpy; print(torch.__version__, numpy.__version__)"`. +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) : +```python +import torch +ckpt = torch.load(r"C:\Users\PHIL\ZCodeProject\fractus\checkpoints\checkpoints\fractus_1b_latest.pt", map_location="cpu", weights_only=False) +sd = ckpt.get("model", ckpt.get("state_dict", ckpt)) +for k, v in sd.items(): + print(k, tuple(v.shape) if hasattr(v, "shape") else type(v)) +``` +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. + +- [x] **Step 4: Verify** + +Run: `.venv/bin/python.exe -m pytest tests/test_gates.py -v` → PASS +Run: `.venv-torch/Scripts/python.exe -m pytest tests/test_gates.py -v` → PASS +Run: `.venv/bin/python.exe -m pytest -q` → 94 passed (93 + gate) + +- [x] **Step 5: Commit** + +```bash +git add .gitignore vorax/model/__init__.py tests/test_gates.py docs/fractus-cte-architecture.md +git commit -m "feat: torch venv + fractus-cte architecture spec + torch isolation gate" +``` + +--- + +### Task 2: Noyau natif — `vorax/model/fractus_core.py` + +**Files:** +- Create: `vorax/model/fractus_core.py` +- Test: `tests/test_fractus_core.py` + +**Interfaces:** +- Consumes: `docs/fractus-cte-architecture.md` (Task 1 — c'est LA source de vérité des couches/clés/shapes) +- Produces (imports torch paresseusement, uniquement dans ce fichier) : + - `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) + - `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) + - `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)` + - `forward(token_ids: "torch.Tensor") -> "torch.Tensor"` (logits) + - `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é + +- [x] **Step 1: Write the failing tests** (skip sans torch) + +```python +# tests/test_fractus_core.py +import pytest + +torch = pytest.importorskip("torch", reason="noyau torch optionnel (substrat sans torch)") + +from vorax.model.fractus_core import FractusCore, FractusCoreConfig + + +def _tiny(): + return FractusCoreConfig(vocab_size=128, d_model=64, n_layers=2, n_experts=4, top_k=2, rank=16, max_seq_len=64) + + +def test_forward_logits_shape(): + torch.manual_seed(0) + core = FractusCore(_tiny()) + ids = torch.randint(0, 128, (1, 10)) + logits = core(ids) + assert logits.shape == (1, 10, 128) + + +def test_generate_greedy_deterministic(): + torch.manual_seed(0) + core = FractusCore(_tiny()) + ids = torch.tensor([[1, 2, 3]]) + a = core.generate(ids, max_new_tokens=8) + b = core.generate(ids, max_new_tokens=8) + assert torch.equal(a, b) + assert a.shape == (1, 11) + + +def test_checkpoint_roundtrip(tmp_path): + """Sauvegarde/charge au format fractus (clé 'model') — strict load.""" + torch.manual_seed(0) + core = FractusCore(_tiny()) + torch.save({"model": core.state_dict()}, tmp_path / "tiny.pt") + loaded = FractusCore.from_checkpoint(tmp_path / "tiny.pt") + assert torch.equal(loaded.generate(torch.tensor([[1, 2, 3]]), 4), + core.generate(torch.tensor([[1, 2, 3]]), 4)) + + +def test_real_checkpoint_strict_load(): + """Marqué slow : charge le VRAI checkpoint fractus (lecture seule). + Skip si absent. Ne s'exécute que dans .venv-torch.""" + import pathlib + ckpt = pathlib.Path(r"C:\Users\PHIL\ZCodeProject\fractus\checkpoints\checkpoints\fractus_1b_latest.pt") + if not ckpt.exists(): + pytest.skip("checkpoint fractus non présent") + core = FractusCore.from_checkpoint(ckpt) + ids = torch.zeros(1, 4, dtype=torch.long) + with torch.no_grad(): + logits = core(ids) + assert logits.ndim == 3 +``` + +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"]`). + +- [x] **Step 2: Run tests to verify they fail** + +Run (mingw, sans torch) : `.venv/bin/python.exe -m pytest tests/test_fractus_core.py -v` → SKIP (importorskip) +Run (torch venv) : `.venv-torch/Scripts/python.exe -m pytest tests/test_fractus_core.py -v` → FAIL (ModuleNotFoundError vorax.model.fractus_core) + +- [x] **Step 3: Implementation** + +Implémenter `vorax/model/fractus_core.py` **fidèlement au doc de la Task 1** : +- `import torch` et `import torch.nn as nn` en tête (ce fichier est le seul endroit autorisé). +- `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). +- 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. +- `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). +- `generate` : boucle greedy/multinomial, pas de sampling si temperature == 0. +- 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. + +- [x] **Step 4: Verify** + +Run: `.venv-torch/Scripts/python.exe -m pytest tests/test_fractus_core.py -v -m "not slow"` → PASS (4 tests) +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) + +- [x] **Step 5: Commit** + +```bash +git add vorax/model/fractus_core.py tests/test_fractus_core.py pyproject.toml +git commit -m "feat: native fractus core in torch — architecture per fractus-cte spec, strict checkpoint load" +``` + +--- + +### Task 3: Le noyau lit les cartes — `vorax/model/vocal.py` + +**Files:** +- Create: `vorax/model/vocal.py` +- Test: `tests/test_vocal.py` + +**Interfaces:** +- Consumes: `FractusCore` (Task 2), `Card` (`vorax/cards.py`) +- Produces: + - `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) + - `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) + - `CharTokenizer` (classe de test/demo : `encode` = codepoints modulo vocab, `decode` = chr) avec `vocab_size` + - `core_available() -> bool` : True si torch ET FractusCore importables dans CE python (le substrat mingw répond False) + +- [x] **Step 1: Write the failing tests** + +```python +# tests/test_vocal.py +from vorax.cards import Card +from vorax.model.vocal import CharTokenizer, core_available, serialize_cards, verbalize + +CARDS = [ + Card("FAIT", "what is the capital of japan => tokyo", 0.9, "cap.csv:4"), + Card("ANALOGIE", "paris", 1.0, "cap"), +] + + +def test_serialize_cards_deterministic_and_sorted(): + s1 = serialize_cards("q?", CARDS) + s2 = serialize_cards("q?", CARDS) + assert s1 == s2 + assert s1.startswith("[FAIT]") or "[ANALOGIE]" in s1 + assert s1.endswith("Q: q?") + lines = [l for l in s1.splitlines() if l] + assert lines == sorted(lines, key=lambda l: (not l.startswith("["), l)) # cartes avant Q + + +def test_char_tokenizer_roundtrip(): + tok = CharTokenizer(vocab_size=1114112) + assert tok.decode(tok.encode("héllo wörld")) == "héllo wörld" + + +def test_verbalize_returns_text_when_core_available(): + if not core_available(): + import pytest + pytest.skip("torch indisponible dans ce python") + from vorax.model.fractus_core import FractusCore, FractusCoreConfig + import torch + torch.manual_seed(0) + core = FractusCore(FractusCoreConfig(vocab_size=1114112, d_model=64, n_layers=1, n_experts=2, rank=8, max_seq_len=512)) + out = verbalize(core, "what is the capital of japan?", CARDS[:1], CharTokenizer(vocab_size=1114112), max_new_tokens=8) + assert isinstance(out, str) and len(out) >= 0 # le noyau non-né peut sortir du bruit — le contrat est le pipeline +``` + +- [x] **Step 2: Run tests to verify they fail** + +Run mingw : 2 PASS / 1 SKIP attendus APRÈS implémentation ; avant : FAIL (ModuleNotFoundError). +Run torch venv : les 3 doivent passer après implémentation. + +- [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). + +- [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. + +- [x] **Step 5: Commit** + +```bash +git add vorax/model/vocal.py tests/test_vocal.py +git commit -m "feat: core reads cards — protocol serialization + verbalization pipeline" +``` + +--- + +### Task 4: Lectures hebbiennes → cartes (`vorax/brain.py` — modification) + +**Files:** +- Modify: `vorax/brain.py` +- Test: `tests/test_brain_hebbian.py` + +**Interfaces:** +- Consumes: `HebbianMemory.complete` (Plan 2) +- 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. + - `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). + +- [x] **Step 1: Write the failing tests** + +```python +# tests/test_brain_hebbian.py +from vorax.brain import Brain +from vorax.compiler.atoms import Atom + +CAPITALS = [ + Atom("what is the capital of france", "paris", "cap.csv:2"), + Atom("what is the capital of spain", "madrid", "cap.csv:3"), + Atom("what is the capital of japan", "tokyo", "cap.csv:4"), + Atom("what is the capital of italy", "rome", "cap.csv:5"), +] + + +def test_hebbian_card_on_question(): + brain = Brain(D=2048) + brain.ingest_source("capitals", CAPITALS) + cards = brain.ask_cards("what is the capital of spain", k=1) + kinds = [c.kind for c in cards] + 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 + heb = [c for c in cards if c.kind == "HEBBIEN"] + if heb: + assert "madrid" in heb[0].text + assert kinds.index("HEBBIEN") == 0 # en tête + + +def test_hebbian_index_roundtrip(tmp_path): + brain = Brain(D=2048) + brain.ingest_source("capitals", CAPITALS) + brain.save(tmp_path / "brain") + loaded = Brain.load(tmp_path / "brain") + assert len(loaded._hebbian_index) == len(brain._hebbian_index) + cards = loaded.ask_cards("what is the capital of japan", k=1) + assert any(c.kind in ("HEBBIEN", "FAIT") for c in cards) +``` + +- [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). + +- [x] **Step 3: Implementation** — comme décrit dans Interfaces ; la sim de match = `sim(completed, value_hv)` ; pas de torch ici (substrat pur). + +- [x] **Step 4: Verify** — mingw full suite : 93 + gate + 2 = vert (torch tests skipped selon venv) ; aucun changement des cartes existantes sans hebbian match. + +- [x] **Step 5: Commit** + +```bash +git add vorax/brain.py tests/test_brain_hebbian.py +git commit -m "feat: hebbian reads feed the card maker — HEBBIEN cards, persisted index" +``` + +--- + +### Task 5: Agent REPL live — `vorax/agent/repl.py` + +**Files:** +- Create: `vorax/agent/repl.py` +- Test: `tests/test_repl.py` + +**Interfaces:** +- Consumes: `cli.ingest`, `Brain`, `ask_cards`, `consolidate`, `core_available`/`verbalize` (optionnels) +- Produces: + - `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 + - Commandes : `:ingest ` (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) + - `main(argv=None)` : argparse (`--brain`, `--D`, `--seed`), lance `run()`, exit 0 + - 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) + +- [x] **Step 1: Write the failing tests** + +```python +# tests/test_repl.py +from pathlib import Path + +from vorax.agent.repl import Repl + +CSV = ("question,answer\n" + "what is the capital of france,paris\n" + "what is the capital of spain,madrid\n" + "what is the capital of japan,tokyo\n" + "what is the capital of italy,rome\n" + "who wrote hamlet,william shakespeare\n") + + +def _repl(tmp_path): + csv = tmp_path / "cap.csv" + csv.write_text(CSV, encoding="utf-8") + r = Repl(tmp_path / "brain", D=2048) + return r, csv + + +def test_ingest_and_ask(tmp_path): + r, csv = _repl(tmp_path) + out = r.feed(f":ingest {csv}") + assert any("expert" in l for l in out) + out = r.feed("what is the capital of japan") + assert any("[CARTE]" in l and "tokyo" in l for l in out) + + +def test_conversation_feeds_brain(tmp_path): + r, csv = _repl(tmp_path) + r.feed(f":ingest {csv}") + n0 = len(r.brain) + r.feed("who wrote hamlet") + assert len(r.brain) == n0 + 1 # échange écrit O(1) + assert r.brain.atoms[-1].provenance == "session" + out = r.feed(":status") + assert any("atomes" in l for l in out) + + +def test_quit_and_unknown(tmp_path): + r, _ = _repl(tmp_path) + assert r.feed(":quit") == [] + out = r.feed("zzz nothing here") + assert any("LACUNE" in l or "[CARTE]" in l for l in out) +``` + +- [x] **Step 2: Verify failure** — ModuleNotFoundError: vorax.agent.repl. + +- [x] **Step 3: Implementation** — Repl comme décrit (feed retourne les lignes ; run() = while True: input → feed → print). Attention Windows : `input()` avec prompt `vorax> `. + +- [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. + +- [x] **Step 5: Commit** + +```bash +git add vorax/agent/repl.py tests/test_repl.py +git commit -m "feat: live REPL agent — ingest/status/consolidate, conversation written O(1)" +``` + +--- + +### Task 6: Killer bench par ablation — `bench/killer_bench.py` + +**Files:** +- Create: `bench/killer_bench.py` +- Test: `tests/test_killer_bench.py` + +**Interfaces:** +- 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é) +- 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`). + +- [x] **Step 1: Write the failing test** + +```python +# tests/test_killer_bench.py +from pathlib import Path + +from bench.killer_bench import generate_domain_csv, run_bench + + +def test_generate_domain_csv_deterministic(tmp_path): + a, b = tmp_path / "a.csv", tmp_path / "b.csv" + n1, n2 = generate_domain_csv(a, 6, 3), generate_domain_csv(b, 6, 3) + assert n1 == n2 == 18 and a.read_bytes() == b.read_bytes() + + +def test_run_bench_small(tmp_path): + metrics = run_bench(rows=24, D=2048, out_dir=tmp_path) + assert 0.0 <= metrics["heldout_accuracy"] <= 1.0 + assert metrics["ingest_atoms_per_s"] > 0 + assert metrics["query_ms"] > 0 + assert metrics["core_cards_ablation"] is None or isinstance(metrics["core_cards_ablation"], dict) +``` + +- [x] **Step 2: Verify failure** — ModuleNotFoundError: bench.killer_bench. + +- [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. + +- [x] **Step 4: Verify** — mingw suite verte (+2) ; smoke : `.venv/bin/python.exe bench/killer_bench.py --rows 40 --D 2048` imprime le tableau. + +- [x] **Step 5: Commit** + +```bash +git add bench/killer_bench.py tests/test_killer_bench.py +git commit -m "feat: killer bench — held-out paraphrase/typo accuracy, cards-off floor, core ablation scaffold" +``` + +--- + +### Task 7: README final + démo script + +**Files:** +- Create: `demo/demo.py` +- Modify: `README.md` +- Test: `tests/test_demo.py` + +**Interfaces:** +- 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é. + +- [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 : +```bash +git add demo/demo.py tests/test_demo.py README.md +git commit -m "feat: demo script + final README — the complete training-free fractus" +``` + +--- + +## Definition of Done — Plan 3 + +- [x] Suite verte dans le venv mingw SANS torch (torch tests skipped) ET dans `.venv-torch` (tout passe, `-m "not slow"`) +- [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) +- [x] Gate : torch présent uniquement dans `vorax/model/` +- [x] REPL : ingest → question → échange écrit O(1) dans le brain → status +- [x] Killer bench : tableau avec précision held-out, plancher cartes-OFF, latences +- [x] Démo complète : tokyo + ANALOGIE paris + organes + `zéro gradient` +- [x] Aucun LLM externe nulle part ; checkpoint chargé en lecture seule ; repos siblings intouchés diff --git a/docs/heritage/plans/2026-08-18-vorax-p4-noyau-parle.md b/docs/heritage/plans/2026-08-18-vorax-p4-noyau-parle.md new file mode 100644 index 0000000000000000000000000000000000000000..8614c460306146813c57bfc4d8a61f8a21b81d61 --- /dev/null +++ b/docs/heritage/plans/2026-08-18-vorax-p4-noyau-parle.md @@ -0,0 +1,239 @@ +# Vorax Plan 4 — « Le noyau parle » Implementation Plan + +> **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. + +**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). + +**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). + +**Tech Stack:** substrat numpy+stdlib inchangé ; `.venv-torch` gagne `tokenizers` (wheel natif, sans torch). Aucune nouvelle dépendance côté mingw. + +**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). + +## Global Constraints + +- Torch ET tokenizers strictement confinés à `vorax/model/` (imports paresseux ; le substrat mingw n'installe rien de nouveau) +- Le cache HF et les fichiers siblings sont lus **en lecture seule** ; aucun écriture hors repo (sauf tmpdir/bench-out gitignorés) +- Déterminisme : encodage BPE déterministe, génération greedy par défaut, artefacts triés +- Chiffres honnêtes : core_speak rapporte ce que le checkpoint produit **verbatim** — word salad si word salad +- Les suites restent vertes : mingw (torch/tokenizers tests skippés) et `.venv-torch` +- Un commit par tâche ; TDD strict ; prose docs FR, code EN + +--- + +### Task 1: Tokenizer BPE GPT-2 (`vorax/model/bpe_tokenizer.py`) + +**Files:** +- Create: `vorax/model/bpe_tokenizer.py` +- Test: `tests/test_bpe_tokenizer.py` + +**Interfaces:** +- Produces: + - `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"`) + - 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. + - `bpe_available() -> bool` : sonde try-import (lazy), True si `tokenizers` importable dans CE python + +- [x] **Step 1: Install + write the failing tests** + +Installer : `.venv-torch/Scripts/python.exe -m pip install tokenizers` + +```python +# tests/test_bpe_tokenizer.py +import pytest + +bpt = pytest.importorskip("vorax.model.bpe_tokenizer") + + +def test_bpe_available_probe(): + from vorax.model.bpe_tokenizer import bpe_available + assert isinstance(bpe_available(), bool) + + +def test_gpt2_known_encodings(): + if not bpt.bpe_available(): + pytest.skip("tokenizers indisponible") + tok = bpt.Gpt2BpeTokenizer() + assert tok.vocab_size == 50257 + assert tok.eos_token_id == 50256 + # encodages GPT-2 canoniques + assert tok.encode("hello world")[0] == 31373 + assert tok.decode([31373, 995]) == "hello world" + + +def test_roundtrip(): + if not bpt.bpe_available(): + pytest.skip("tokenizers indisponible") + tok = bpt.Gpt2BpeTokenizer() + text = "What is the capital of france? ÀÉïö — naïve" + assert tok.decode(tok.encode(text)) == text + + +def test_decode_out_of_range_ids_do_not_crash(): + if not bpt.bpe_available(): + pytest.skip("tokenizers indisponible") + tok = bpt.Gpt2BpeTokenizer() + out = tok.decode([31373, 99999999, -5, 50256]) + assert isinstance(out, str) # filtre, pas d'exception +``` + +- [x] **Step 2: Verify failure** — `.venv-torch/Scripts/python.exe -m pytest tests/test_bpe_tokenizer.py -v` → FAIL (ModuleNotFoundError) +- [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**. +- [x] **Step 4: Verify** — torch venv : 4 passed ; mingw : skipped (importorskip) ; suites complètes vertes (mingw 105/2+skip, torch 115). +- [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"` + +--- + +### Task 2: Génération consciente d'EOS (`vorax/model/fractus_core.py` — modification) + +**Files:** +- Modify: `vorax/model/fractus_core.py` +- Test: `tests/test_fractus_core.py` (ajout) + +**Interfaces:** +- 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. + +- [x] **Step 1: Failing test** + +```python +def test_generate_stops_at_eos(): + torch.manual_seed(0) + core = FractusCore(_tiny()) + ids = torch.tensor([[1, 2, 3]]) + # déterminer le token que greedy émettrait au 1er pas, l'utiliser comme EOS + with torch.no_grad(): + first = int(core(ids)[0, -1, :].argmax().item()) + out = core.generate(ids, max_new_tokens=8, eos_token_id=first) + assert out.shape == (1, 4) # prompt + 1 token, puis arrêt + assert int(out[0, -1].item()) == first +``` + +- [x] **Step 2: Verify failure** — TypeError (paramètre inconnu) dans `.venv-torch`. +- [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. +- [x] **Step 4: Verify** — torch venv 5 fractus_core tests pass (non-slow) ; suites complètes vertes. +- [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"` + +--- + +### Task 3: `:core` parle BPE + `[NOYAU]` robuste (`vorax/agent/repl.py` + `vorax/model/vocal.py` — modifications) + +**Files:** +- Modify: `vorax/agent/repl.py`, `vorax/model/vocal.py` +- Test: `tests/test_repl.py` (ajout), `tests/test_vocal.py` (ajout) + +**Interfaces:** +- Consumes: `Gpt2BpeTokenizer`, `bpe_available` (T1), `eos_token_id` (T2) +- Produces: + - `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 + - `repl.py` : `:core ` 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` + +- [x] **Step 1: Failing tests** + +```python +# tests/test_vocal.py (ajout) +def test_safe_decode_text_replaces_surrogates(): + from vorax.model.vocal import safe_decode_text + bad = "ok\ud800\udfffend" + out = safe_decode_text(bad) + assert "\ud800" not in out and out.startswith("ok") + +# tests/test_repl.py (ajout) +def test_core_uses_bpe_when_available(tmp_path, capsys): + from vorax.model.bpe_tokenizer import bpe_available + r, _ = _repl(tmp_path) + fake_ckpt = tmp_path / "nope.pt" + out = r.feed(f":core {fake_ckpt}") + # sans torch ou fichier absent : dégradation propre déjà testée en P3-T5 ; + # ici on vérifie juste qu'aucun crash tokenizer ne fuit + assert all(isinstance(l, str) for l in out) +``` + +- [x] **Step 2: Verify failure** (safe_decode absent → ImportError). +- [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). +- [x] **Step 4: Verify** — suites complètes vertes dans les DEUX venvs. +- [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"` + +--- + +### Task 4: Évaluation honnête `bench/core_speak.py` + +**Files:** +- Create: `bench/core_speak.py` +- Test: `tests/test_core_speak.py` + +**Interfaces:** +- Consumes: `FractusCore.from_checkpoint`, `Gpt2BpeTokenizer`, `Brain`, cartes +- 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. + +- [x] **Step 1: Failing test** (skip sans torch) + +```python +# tests/test_core_speak.py +import pytest + +torch = pytest.importorskip("torch", reason="core_speak nécessite torch") + +from bench.core_speak import run_core_speak + + +def test_core_speak_reports_honestly(tmp_path): + import pathlib + ckpt = pathlib.Path(r"C:\Users\PHIL\ZCodeProject\fractus\checkpoints\checkpoints\fractus_1b_latest.pt") + if not ckpt.exists(): + pytest.skip("checkpoint absent") + result = run_core_speak(ckpt, ["what is the capital of france"], brain=None, max_new_tokens=6) + assert len(result["runs"]) == 1 + run = result["runs"][0] + for key in ("question", "no_cards_output", "with_cards_output", "answer_in_output"): + assert key in run + assert isinstance(result["with_cards_answer_rate"], float) +``` + +- [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`. + +--- + +### Task 5: Dettes — idempotence consolidate + gate durci + +**Files:** +- Modify: `vorax/brain.py` (consolidate : après promotion, retirer du compteur les paires promues), `tests/test_gates.py` (durcir) +- Test: `tests/test_brain_v2.py` (ajout), `tests/test_gates.py` (durci) + +**Interfaces:** +- `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. +- Gate : détecte aussi `import_module("torch")`, `__import__("torch")`, `importlib` + "torch" combinaison directe. + +- [x] **Step 1: Failing tests** + +```python +# tests/test_brain_v2.py (ajout) +def test_consolidate_is_idempotent(): + brain = _brain() + for _ in range(3): + brain.ask_cards("what is the capital of france", k=2) + n1 = brain.consolidate(threshold=3) + n2 = brain.consolidate(threshold=3) + assert n1 >= 1 and n2 == 0 # plus de re-promotion + +# tests/test_gates.py (ajout) +def test_gate_catches_dynamic_import_patterns(tmp_path): + fake = tmp_path / "fake.py" + fake.write_text('import importlib\nimportlib.import_module("torch")\n', encoding="utf-8") + text = fake.read_text(encoding="utf-8") + assert ("torch" in text and ("import_module" in text or "__import__" in text)) +``` + +(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.) + +- [x] **Step 2-5** : verify fail → implémentation (forget + motifs) → suites vertes → commit `fix: consolidate idempotent + gate detects dynamic torch imports`. + +--- + +## Definition of Done — Plan 4 + +- [x] Suites vertes : mingw (torch/tokenizers skippés) et `.venv-torch` (tout, `-m "not slow"`) +- [x] `Gpt2BpeTokenizer` : encodages GPT-2 canoniques exacts (31373…), roundtrip, safe decode +- [x] `generate` s'arrête sur EOS sans le consommer +- [x] `:core` parle BPE (fallback honnête), lignes `[NOYAU]` sans surrogate crash +- [x] `core_speak` exécuté une fois sur le vrai checkpoint, sortie verbatim documentée (word salad = honnête) +- [x] `consolidate` idempotent ; gate durci aux imports dynamiques +- [x] Gate : torch + tokenizers confinés dans `vorax/model/` diff --git a/docs/heritage/plans/2026-08-18-vorax-p5-ctecore.md b/docs/heritage/plans/2026-08-18-vorax-p5-ctecore.md new file mode 100644 index 0000000000000000000000000000000000000000..a0689e15412fc5659d46f44d83eaef6bb1d29573 --- /dev/null +++ b/docs/heritage/plans/2026-08-18-vorax-p5-ctecore.md @@ -0,0 +1,82 @@ +# Vorax Plan 5 — « CteCore : le 1B branché » Implementation Plan + +> **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. + +**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. + +**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). + +**Tech Stack:** torch CPU dans `.venv-torch`, confiné à `vorax/model/` (gate : torch + tokenizers). + +**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). + +## Global Constraints + +- Siblings en LECTURE SEULE (continuous_engine.py lu, jamais modifié ni importé) +- torch confiné `vorax/model/` (gate), suites mingw/torch vertes à chaque tâche, tests torch skippés sans torch +- from_checkpoint strict=True sur les 440 clés — jamais strict=False +- Fidélité numérique d'abord : une implémentation qui charge mais diverge silencieusement est le pire échec (leçons P3 : GELU, x@W) +- Déterminisme (greedy par défaut) ; chiffres honnêtes (le checkpoint a 15,5M tokens processed — attendre du faible, le rapporter tel quel) +- Un commit par tâche ; TDD strict + +--- + +### Task 1: Investigation CTE (`docs/cte-architecture.md`) + +**Files:** +- Create: `docs/cte-architecture.md` +- Test: `tests/test_cte_probe.py` + +**Interfaces:** +- 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. + +- [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`. +- [x] **Step 2: Verify failure** (FileNotFoundError) dans mingw. +- [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. +- [x] **Step 4: Verify** — mingw suite verte (+1). +- [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"` + +--- + +### Task 2: `vorax/model/cte_core.py` — le noyau CTE + +**Files:** +- Create: `vorax/model/cte_core.py` +- Test: `tests/test_cte_core.py` + +**Interfaces:** +- Consumes: `docs/cte-architecture.md` (source de vérité) +- Produces (torch lazy, confiné) : + - `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) + - `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) + - `CteCore.from_checkpoint(path) -> CteCore` : lit `model_state` + infère la config depuis les shapes, strict=True, model.eval() + - Le GELU (si confirmé par le doc) entre les deux facteurs du MoE ; orientation x@W partout où le doc le dit + +- [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). +- [x] **Step 2: Verify failure** (torch venv). +- [x] **Step 3: Implementation** — fidèle au doc T1 ; exécuter le slow réel une fois et documenter. +- [x] **Step 4: Verify** — torch venv non-slow pass, mingw skips, suites vertes. +- [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"` + +--- + +### Task 3: Branchement — le 1B parle + +**Files:** +- 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) +- Test: `tests/test_cte_wiring.py` + +**Interfaces:** +- 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. +- [x] **Step 1: Failing tests** (torch venv, skip mingw) — kind probing sur deux fakes + wiring REPL `:core` avec fake CTE tiny attaché sans traceback. +- [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`. + +--- + +## Definition of Done — Plan 5 + +- [x] Suites vertes mingw + torch (`-m "not slow"`) ; gate intact (torch confiné) +- [x] `test_real_checkpoint_strict_load` : 440/440 clés strictes sur le vrai 1B, logits finis +- [x] `probe_checkpoint_kind` distingue CTE/Fractal ; `:core` branche le 1B ; core_speak l'évalue +- [x] Exécution réelle core_speak sur le 1B documentée verbatim (honnête) +- [x] Siblings intouchés ; checkpoint 4.66GB local gitignored diff --git a/docs/heritage/specs/2026-08-17-vorax-design.md b/docs/heritage/specs/2026-08-17-vorax-design.md new file mode 100644 index 0000000000000000000000000000000000000000..fecec4eaf7fa66423b36056c4ad3e3ec918db4c7 --- /dev/null +++ b/docs/heritage/specs/2026-08-17-vorax-design.md @@ -0,0 +1,212 @@ +# Vorax — Design + +**Date** : 2026-08-17 +**Statut** : validé en session de brainstorming (sections approuvées oralement) +**Nom** : vorax (provisoire, renommable — latin : « qui dévore tout ») + +--- + +## 0. One-liner + +Fractus naît une fois. Ensuite il n'apprend plus jamais par gradient — **il mange**. +Toute donnée est compilée en connaissance puis ingérée par écriture. Il grandit à +l'infini, physiquement. + +## 1. Décisions fondatrices + +| # | Décision | Choix | +|----|-----------------|---------------------------------------------------------------------------------------------------------------------------------------| +| D1 | Vision | Un Fractus qui grandit à l'infini par ingestion ; jamais un gradient pour apprendre | +| 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 | +| D3 | Jalon v1 | Agent généraliste **live** : absorbe fichiers/CSV/web pendant la session, devient expert en temps réel, converse/code/raisonne | +| D4 | Hardware | CPU-first ; GPU optionnel (accélère le noyau, jamais requis) | +| 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 | +| 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 | +| 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** | + +## 2. Architecture d'ensemble + +``` +DONNÉES (CSV / JSON / JSONL / TXT / MD / code / web) + │ + ▼ +┌──────────────────────┐ +│ COMPILATEUR .kn │ une seule passe, forme close : +│ (knowledge compiler)│ hash + comptage + SVD — zéro gradient +└──────────┬───────────┘ + │ ingestion (écriture O(1)/token) + ├────────────► ORGANE 1 · TRACES hippocampe : mémoire HV + │ append-only + LSH + consolidation + ├────────────► ORGANE 2 · HEBBIEN cortex : mémoire associative + │ en forme close sur facteurs low-rank U·Vᵀ + └────────────► ORGANE 3 · SPAWN croissance : chaque dataset + devient un expert routé (MoE) + +QUESTION ──► organes → cartes de connaissance → NOYAU NATIF FRACTUS + │ (architecture fractus réécrite dans vorax, + │ checkpoint fractus-cte chargé en lecture seule) + │ + └──► chaque échange écrit O(1) → le système apprend + de la conversation elle-même, sans gradient +``` + +**Séparation sacrée** : compétence linguistique (noyau fractus, né une fois) vs +connaissance (organes, écrite en continu). Le noyau fournit le *comment parler* ; +les organes fournissent le *quoi dire*. Aucune couche linguistique n'est jamais écrite. + +## 3. Le compilateur `.kn` — la nouvelle méthode de conversion + +Pipeline en une passe, tout en **calcul, pas optimisation** (hash déterministe, +comptage PPMI, une décomposition SVD ; aucune loss, aucune époque, aucun label) : + +1. **Parse auto** du format : CSV/TSV, JSON/JSONL, TXT, Markdown, code. + Format inconnu → fallback texte brut + avertissement. +2. **Atomisation** : tout devient des atomes canoniques + `(énoncé, contexte, provenance, confiance)`. +3. **Encodage HV** bipolaire packé (bind = XOR, similarité = 1 − 2·hamming/D) — + code vendored/adapté de la famille palimpseste, réécrit dans le repo. +4. **Extraction structurelle** : patterns + slots (ex. `what is the capital of {X}`), + relations apprises par comptage (`capital_of: france→paris`) → l'analogie + 3CosAdd/3CosMul avec k-NN local (k=5) et filtre morphologique (préfixe commun + ≥ 5 chars exclu) généralise sur du **jamais vu**. Slots sémantiques optionnels : + embedding local PPMI+SVD (comptage + une décomposition, pas un entraînement) + projeté dans l'espace HV via matrice ±1 préservant le cosinus. +5. **Empaquetage `.kn`** : généralisation du `.exp` — portable, compressé + (cible : plus petit que la source), rechargement **bit-identique** + (rejeu du flux dans l'encodeur hash-déterministe). + +## 4. Les trois organes + +### Organe 1 — Traces (hippocampe) +- Mémoire append-only d'hypervectors `(adresse, valeur, poids)` ; écriture O(1)/token. +- Retrieval LSH (K bits AND / L tables OR), candidats ~2-3 % de |M|. +- Consolidation par co-activation décroissée → promotion de concepts composites + `bind(addr_i, value_j)` : la mémoire **se compresse** avec le temps. +- Jamais d'oubli catastrophique : on n'écrase pas, on superpose. + +### Organe 2 — Hebbien (cortex) +- Mémoire associative clé→valeur écrite en **forme close** : produit externe et/ou + ridge `(KᵀK + λI)⁻¹KᵀV` sur des facteurs low-rank `W = scale·U·Vᵀ` dédiés. +- **v1** : l'organe vit côté Vorax ; son lecteur alimente le fabricant de cartes + (lookup associatif direct). Le noyau natif fractus étant embarqué dans vorax, + les mêmes écritures atterrissent dans les couches connaissance de son forward. +- Garde-fous : porte de Lyapunov (acceptation ssi ΔE ≤ 0), versioning append-only + des facteurs → rollback à tout moment. + +### Organe 3 — Spawn (croissance) +- Chaque dataset ingéré devient un **expert** (paquets de traces + facteurs + relations). +- Clé de routage **dérivée de la signature HV** du dataset (phase calculée, pas apprise). +- Le modèle grossit physiquement : nouveau fichier = nouvel expert = plus de capacité. + C'est le « grandir à l'infini ». +- Composition multi-experts : la pertinence d'un expert pour une question fixe le + couplage (dynamique d'oscillateurs héritée conceptuellement d'ensemble). + +## 5. L'agent live (jalon v1) + +Boucle de session : + +1. **Entrée** : question texte OU dépôt de données (fichier, dossier, URL). +2. **Dépôt** → file d'attente → compilation `.kn` en tâche de fond → ingestion + (traces + hebbien + spawn) → événements de statut visibles + (« +12 340 atomes ingérés, expert `medecine-3` spawné »). +3. **Question** → encodage → retrieval tous organes → **cartes de connaissance** : + ``` + [CARTE] FAIT: … (provenance, confiance) + [CARTE] ANALOGIE: X est à Y comme A est à ? → B (sim 0.74) + [CARTE] PATTERN: "what is the capital of {spain}" → slot connu + [CARTE] LACUNES: aucune trace pour Z (dire je ne sais pas) + ``` + → le noyau natif fractus verbalise à partir des cartes. +4. **Post-échange** : la conversation elle-même est écrite O(1) dans les traces — + le système apprend de la session en temps réel. + +Interface : REPL terminal (v1). CPU-first : tout le substrat et le noyau natif +fractus sont CPU purs (le GPU du pod accélère si dispo, jamais requis). + +## 6. Format `.kn` + +Conteneur (répertoire ou archive) : +- `manifest.json` — métadonnées, version de format, hash source, stats +- `atoms.tsv.gz` — atomes canoniques (énoncé, contexte, provenance, confiance) +- `tokens.bin.gz` — flux de tokens symboliques (rejeu déterministe) +- `relations.json` — relations apprises par comptage (pour l'analogie) +- `vocab.json` — vocabulaire de l'encodage +- `signature.hv` — signature HV du dataset (pour le spawn/routage) + +Invariant : compilateur déterministe ⇒ même source ⇒ `.kn` bit-identique. + +## 7. Structure du repo + +``` +vorax/ + README.md + vorax/ + compiler/ + parse.py # auto-détection CSV/JSON/JSONL/TXT/MD/code + fallback + atoms.py # atomisation canonique + encode.py # HV bipolaires packés (vendored/adapté, attribution) + structural.py # patterns + slots + analogy.py # 3CosAdd/3CosMul, k-NN k=5, filtre morphologique + kn.py # écriture/lecture .kn, rejeu bit-identique + organs/ + traces.py # mémoire append-only + LSH + consolidation + hebbian.py # écritures forme close sur facteurs low-rank + porte ΔE + spawn.py # experts + clés de routage dérivées de la signature + model/ + vocal.py # noyau natif fractus : architecture réécrite dans vorax + cards.py # fabricant de cartes (consomme tous les organes) + vorax.py # assemblage complet : organes + noyau fractus + agent/ + session.py # boucle live, file d'ingestion, statuts + ingest.py # pipeline d'ingestion en tâche de fond + cli.py # REPL + bench/ # benchmarks honnêtes (culture ensemble) + tests/ +``` + +## 8. Gestion d'erreurs + +| Situation | Comportement | +|-------------------------|------------------------------------------------------------------------------| +| Format inconnu | fallback texte brut + avertissement, jamais de crash de session | +| `.kn` corrompu | rejet + rapport ; la session continue sur l'état antérieur | +| Écriture hebbienne refusée par la porte ΔE | journalisée ; l'atome reste en trace (double écriture de sécurité) | +| Crash pendant ingestion | append-only ⇒ session continue sur l'état d'avant ; reprise idempotente | +| Noyau fractus indisponible (checkpoint pas prêt) | les organes répondent en mode cartes brutes (le substrat vit sans noyau) | + +## 9. Tests et benchmarks + +**Unitaires** +- Déterminisme de l'encodage ; round-trip `.kn` bit-identique +- Rappel après N écritures ; test d'interférence (écrire 10k faits, relire 10k) +- Porte ΔE : une écriture dégradante est refusée ; rollback fonctionnel +- Consolidation : les concepts promisés sont retrievables, les traces sources aussi + +**Killer bench** +- Ingérer un domaine en X minutes sur CPU → précision QA **supérieure à un LLM de + même taille non fine-tuné**, sans un seul gradient ; comparé aussi au fine-tuné + (honnêteté absolue sur l'écart restant). + +**Demo live** +- Absorption de fichiers pendant une session, mesurée en temps réel. + +## 10. Non-goals + +- Modifier quoi que ce soit dans `fractus/`, `palimpseste/`, `ensemble/`, `prism/` + (charger le checkpoint fractus-cte **en lecture** est autorisé ; aucun code n'y est importé). +- **Aucune dépendance à un LLM externe — ni GGUF, ni llama.cpp, ni API distante. + Le noyau est fractus, de la famille fractus uniquement.** +- Dépendre d'un GPU (optionnel uniquement). +- Fine-tuning, sous quelque forme que ce soit : si un apprentissage exige un + gradient, c'est un bug de design, pas une option. + +## 11. Risques de recherche ouverts (assumés honnêtement) + +1. **Fluidité sur du non-vu** : les organes interpolent, le noyau fractus fluidifie ; + la qualité de génération open-ended hors distribution restera inférieure à un + LLM entraîné dessus. Les benchmarks le diront sans maquillage. +2. **Scaling mémoire** : cible ≤ 1× taille source après consolidation+dédup + (le `.exp` prouve 4-10× plus petit ; à confirmer à l'échelle GB). +3. **Interférence hebbienne à grande échelle** : la porte ΔE protège, mais la + capacité effective des facteurs low-rank à 100k+ associations est un vrai + sujet de mesure. diff --git a/docs/superpowers/plans/2026-08-18-fractus-vorax-relais.md b/docs/superpowers/plans/2026-08-18-fractus-vorax-relais.md new file mode 100644 index 0000000000000000000000000000000000000000..18f5170de114b66556bdcb5e921a4638aefb13bc --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-fractus-vorax-relais.md @@ -0,0 +1,76 @@ +# Fractus-Vorax Plan — « Le Relais » Implementation Plan + +> **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. + +**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. + +**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é. + +**Tech Stack:** identique à vorax : numpy+stdlib (substrat), torch CPU + tokenizers (noyaux, confinés `fractus_vorax/model/`), double venv (mingw + natif 3.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). + +## Global Constraints + +- `vorax/` et tous les autres siblings : LECTURE SEULE (source de la consolidation, jamais modifiés) +- Fractus-Vorax est AUTOCONTENU : zéro import cross-repo, tout le code vit ici (attribution dans ATTRIBUTIONS.md) +- Gate conservé : torch + tokenizers confinés à `fractus_vorax/model/` ; suites vertes dans les DEUX venvs +- Le checkpoint de naissance est copié dans `brain/` (gitignored), chargé en lecture seule, strict 440/440 +- Déterminisme, append-only, zéro LLM externe — les contrats sacrés de vorax restent sacrés +- Identité : v1.0.0, tagline « Fractus ne s'entraîne plus : il mange. » +- Un commit par tâche ; TDD où il y a du comportement nouveau ; prose FR, code EN + +--- + +### Task 1: Migration consolidée — le repoFractus-Vorax + +**Files:** +- 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/`) + +**Interfaces:** +- 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. + +- [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. +- [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). +- [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. +- [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 »). +- [x] **Step 5: Commit** — `git add -A && git commit -m "feat: fractus-vorax v1.0.0 — consolidation du relais (heritage: vorax v1.2)"` + +--- + +### Task 2: Le cerveau de naissance + +**Files:** +- Create: `brain/FRACTUS_BIRTH.pt` (copie du checkpoint final, gitignored), `docs/NAISSANCE.md` +- Modify: `README.md` + +**Interfaces:** +- 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`. + +- [x] **Step 1: Copier + vérifier** (script jetable .superpowers/scratch, sortie verbatim capturée) +- [x] **Step 2: NAISSANCE.md** — acte de naissance documenté +- [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) + +--- + +### Task 3: L'inauguration — démo du relais + +**Files:** +- Create: `demo/inauguration.py` +- Test: `tests/test_inauguration.py` +- Modify: `README.md` (section L'inauguration) + +**Interfaces:** +- 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). + +- [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` + +--- + +## Definition of Done — Le Relais + +- [x] Repo `fractus-vorax` autonome : suites vertes double venv, gate vert, zéro import cross-repo +- [x] Cerveau de naissance installé et vérifié strict (440/440), pacte écrit +- [x] Inauguration exécutée en réel, sortie verbatim documentée +- [x] `vorax/` et siblings intacts ; README manifeste + ATTRIBUTIONS +- [x] Git log propre (3+ commits : consolidation, naissance, inauguration) diff --git a/docs/superpowers/plans/2026-08-18-p6-mecanique-parole.md b/docs/superpowers/plans/2026-08-18-p6-mecanique-parole.md new file mode 100644 index 0000000000000000000000000000000000000000..4873242384ad67194642faf7083270e2d14f85c6 --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-p6-mecanique-parole.md @@ -0,0 +1,76 @@ +# Fractus-Vorax Plan 6 — « La mécanique de la parole » Implementation Plan + +> **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. + +**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). + +**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). + +**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. + +**Tech Stack:** torch confiné `fractus_vorax/model/` ; suites double venv vertes ; gate intact. + +## Global Constraints + +- Aucun gradient, jamais — le pacte : on ne touche PAS aux poids, seulement aux logits/décodage/états de travail +- torch/tokenizers confinés au package `model/` ; mingw sans torch reste vert (tests skippés) +- Déterminisme : seeds explicites, mêmes seeds ⇒ mêmes paroles ; diagnostics reproductibles +- Honnêteté radicale : les sorties du 1B sont rapportées VERBATIM (lexique ≠ syntaxe — le dire) +- Un commit par tâche ; TDD ; prose FR, code EN + +--- + +### Task 1: `speak.py` — la boucle de décodage mécanique + +**Files:** +- Create: `fractus_vorax/model/speak.py` +- Test: `tests/test_speak.py` + +**Interfaces:** +- Produces (torch lazy, dans model/) : + - `znorm_logits(logits: "torch.Tensor", scale: float = 3.0) -> "torch.Tensor"` — (x−mean)/std·scale, pur + - `apply_rep_penalty(logits, emitted: set[int], penalty: float = 6.0)` — logits[t] /= penalty pour t ∈ emitted + - `apply_top_k(logits, k)`, `apply_top_p(logits, p)` (masque à −inf hors sélection) + - `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 +- 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). + +### Task 2: `diagnostics.py` — l'observabilité à ciel ouvert + +**Files:** +- Create: `fractus_vorax/model/diagnostics.py` +- 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) +- Test: `tests/test_diagnostics.py` + +**Interfaces:** +- `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 +- `head_readout(core, token_ids) -> {"confidence": float, "salience": float}` (les têtes du CTE ; fractus → None) +- Tests : tiny CteCore → structure valide, 2 experts/layer, poids sommant à 1 ; head_readout borné. + +### Task 3: Steering organique — les cartes dirigent la bouche + +**Files:** +- Modify: `fractus_vorax/model/speak.py` (déjà `bias_tokens`), `fractus_vorax/brain.py` +- Test: `tests/test_steering.py` + +**Interfaces:** +- `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) +- 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. + +### Task 4: Intégration — `:speak`, core_speak v2, README, réel + +**Files:** +- Modify: `fractus_vorax/agent/repl.py` (commande `:speak ` : 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) +- Test: `tests/test_speak_integration.py` (REPL `:speak` produit une ligne `[PAROLE]` sans traceback, skip-proof mingw) + +- 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). + +--- + +## Definition of Done — Plan 6 + +- [x] Suites vertes double venv, gate intact +- [x] speak() déterministe, anti-attracteur prouvé sur tiny (pas de triple répétition à rep_pen élevé) +- [x] Diagnostics ciel ouvert : experts/confiance/salience lisibles sans toucher aux poids +- [x] Steering : le bias organique augmente la présence des tokens-réponse (mesuré, honnête) +- [x] `:speak` dans le REPL + core_speak v2 + README verbatim du 1B réel +- [x] Zéro gradient. Le pacte tient (poids en lecture seule mmap). diff --git a/docs/superpowers/plans/2026-08-18-p7-parler-ce-soir.md b/docs/superpowers/plans/2026-08-18-p7-parler-ce-soir.md new file mode 100644 index 0000000000000000000000000000000000000000..7c88ba4108a6bf7ce2d29c7453113a2e55c6692f --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-p7-parler-ce-soir.md @@ -0,0 +1,32 @@ +# Fractus-Vorax Plan 7 — « Parler ce soir » Implementation Plan + +> **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. + +**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. + +**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 ` = le combo complet. Démo `demo/ce_soir.py` + réel verbatim + README. + +**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. + +--- + +### Task 1: Verrou de réponse + :say + démo ce soir + +**Files:** +- 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` +- Test: `tests/test_answer_lock.py` + +**Interfaces:** +- `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. +- `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 → []. +- REPL `:say ` : 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. +- `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. +- 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. +- 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. +- Commit: `feat: answer lock — the organs articulate through the core's mouth` puis `docs: ce soir le noyau parle — real verbatim`. + +### Definition of Done +- [x] Suites vertes double venv ; disque non entamé (vérif df avant/après) +- [x] Verrou prouvé : les 4 capitales répondues avec le bon mot dans la génération du 1B +- [x] Libre parole : lexique réel, honnête (syntaxe absente si absente) +- [x] README verbatim + framing honnête (le verrou est le mécanisme, pas de la triche cachée) diff --git a/fractus_vorax/__init__.py b/fractus_vorax/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..77f95462558a38160b0644b0d21a8cd7a530370e --- /dev/null +++ b/fractus_vorax/__init__.py @@ -0,0 +1,2 @@ +# fractus_vorax/__init__.py +__version__ = "1.0.0" diff --git a/fractus_vorax/agent/__init__.py b/fractus_vorax/agent/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/fractus_vorax/agent/cli.py b/fractus_vorax/agent/cli.py new file mode 100644 index 0000000000000000000000000000000000000000..545288525d09cc19aab96dcaaea2bdc5c114d71a --- /dev/null +++ b/fractus_vorax/agent/cli.py @@ -0,0 +1,101 @@ +# fractus_vorax/agent/cli.py +"""CLI Fractus-Vorax : ingest (il mange), ask (il répond), status (il compte).""" +import argparse +import json +from pathlib import Path + +from ..brain import Brain +from ..compiler.kn import compile_kn, load_kn, source_sha256 +from ..hv import D_DEFAULT, SEED_DEFAULT + + +def ingest( + source: Path, + brain_path: Path, + D: int = D_DEFAULT, + seed: int = SEED_DEFAULT, + kn_cache: Path | None = None, +) -> int: + cache = kn_cache if kn_cache is not None else brain_path.parent / "kn" + kn_dir = cache / f"{source.stem}.kn" + manifest_path = kn_dir / "manifest.json" + stale = True # pas de manifest → recompiler (cache absent, illisible ou source changée) + if manifest_path.exists(): + try: + cached = json.loads(manifest_path.read_text(encoding="utf-8")) + stale = cached.get("source_sha256") != source_sha256(source) + except (OSError, ValueError): + stale = True + if stale: + compile_kn(source, kn_dir, D=D, seed=seed) + atoms = load_kn(kn_dir) + brain = Brain.load(brain_path) if (brain_path / "meta.json").exists() else Brain(D=D, seed=seed) + n = brain.ingest_source(source.stem, atoms) + brain.save(brain_path) + if n > 0: + print(f"[VORAX] +{n} atomes ingérés depuis {source.name} → expert '{source.stem}' spawné (total: {len(brain)})") + else: + # NB : le message ne contient volontairement pas « spawné » — un batch + # vide ne déclenche pas ExpertRegistry.spawn (gardé par `if atoms:`). + print(f"[VORAX] +0 atome ingéré depuis {source.name} (source vide — aucun expert)") + return n + + +def ask(question: str, brain_path: Path, k: int = 3) -> list[str]: + if not (brain_path / "meta.json").exists(): + return ["[CARTE] LACUNES: aucune trace (brain vide ou absent)"] + brain = Brain.load(brain_path) + cards = brain.ask_cards(question, k=k) + if not cards: + return ["[CARTE] LACUNES: aucune trace pour cette question"] + return [f"[CARTE] {c.kind}: {c.text} (sim {c.sim:.2f} | src: {c.source})" for c in cards] + + +def status(brain_path: Path) -> str: + if not (brain_path / "meta.json").exists(): + return "[VORAX] brain absent" + brain = Brain.load(brain_path) + n_relations = sum(len(rel) for rel in brain.relations.values()) + organs = ( + f"[VORAX] organes: {len(brain.experts)} experts, {n_relations} relations, " + f"{brain.hebbian.n_writes} écritures hebbiennes, {brain.coact.stats()['pairs']} paires co-actives" + ) + return f"[VORAX] brain: {len(brain)} atomes, D={brain.D}, seed={brain.seed}\n{organs}" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="fractus_vorax", description="Il ne s'entraîne plus : il mange.") + sub = parser.add_subparsers(dest="cmd", required=True) + + p_ing = sub.add_parser("ingest", help="compiler et ingérer une source") + p_ing.add_argument("source", type=Path) + p_ing.add_argument("--brain", type=Path, default=Path("./brain")) + p_ing.add_argument("--D", type=int, default=D_DEFAULT) + p_ing.add_argument("--seed", type=int, default=SEED_DEFAULT) + p_ing.add_argument("--kn-cache", type=Path, default=None) + + p_ask = sub.add_parser("ask", help="poser une question au brain") + p_ask.add_argument("question", type=str) + p_ask.add_argument("--brain", type=Path, default=Path("./brain")) + p_ask.add_argument("--k", type=int, default=3) + + p_st = sub.add_parser("status", help="état du brain") + p_st.add_argument("--brain", type=Path, default=Path("./brain")) + + args = parser.parse_args(argv) + try: + if args.cmd == "ingest": + ingest(args.source, args.brain, D=args.D, seed=args.seed, kn_cache=args.kn_cache) + elif args.cmd == "ask": + for line in ask(args.question, args.brain, k=args.k): + print(line) + elif args.cmd == "status": + print(status(args.brain)) + except (OSError, ValueError, UnicodeDecodeError, json.JSONDecodeError) as exc: + print(f"[VORAX] ERREUR: {exc}") + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/fractus_vorax/agent/repl.py b/fractus_vorax/agent/repl.py new file mode 100644 index 0000000000000000000000000000000000000000..3c523f937ed781c56f493aebc9735ba3484607c6 --- /dev/null +++ b/fractus_vorax/agent/repl.py @@ -0,0 +1,331 @@ +# fractus_vorax/agent/repl.py +"""REPL live Fractus-Vorax (Plan 3, tâche 5) : il mange, il répond, il se souvient. + +Chaque question posée au REPL nourrit le brain en O(1) : l'échange +(question, meilleure réponse) est écrit comme un atome de provenance +``"session"`` puis sauvegardé (spec §5.4 — la conversation EST une source). + +GATE torch : ce fichier n'importe jamais torch directement — le noyau +n'est touché que via ``core_available()`` (sonde sûre) et un ``:core`` +optionnel qui charge un checkpoint seulement si torch vit dans ce python. +``:core`` charge le tokenizer BPE GPT-2 (ids exacts fractus) quand la lib +``tokenizers`` vit ici (``bpe_available``, sonde paresseuse), sinon repli +CharTokenizer avec message honnête. Toutes les lignes ``[NOYAU]`` passent +par ``safe_decode_text`` : jamais de surrogate à l'impression. +""" +from __future__ import annotations + +import argparse +import io +from contextlib import redirect_stdout +from pathlib import Path + +from ..brain import Brain +from ..compiler.atoms import Atom +from ..hv import D_DEFAULT, SEED_DEFAULT +from ..model.bpe_tokenizer import Gpt2BpeTokenizer, bpe_available # module lazy-safe (gate torch) +from ..model.vocal import CharTokenizer, core_available, safe_decode_text, verbalize +from . import cli + + +class Repl: + """Agent interactif : feed() retourne des lignes, run() les imprime.""" + + def __init__(self, brain_path: Path, D: int = D_DEFAULT, seed: int = SEED_DEFAULT) -> None: + self.brain_path = Path(brain_path) + self.D = D + self.seed = seed + if (self.brain_path / "meta.json").exists(): + self.brain = Brain.load(self.brain_path) + else: + self.brain = Brain(D=D, seed=seed) + self.core = None # noyau fractus optionnel (via :core ) + self.tokenizer = None + self._running = False + + # ------------------------------------------------------------------ + # Commandes et questions + # ------------------------------------------------------------------ + + def feed(self, line: str) -> list[str]: + """Traite une ligne : commande ``:...`` ou question. Jamais de print.""" + line = line.strip() + if not line: + return [] + if line == ":quit": + self._running = False + return [] + if line.startswith(":"): + parts = line.split(maxsplit=1) + cmd = parts[0] + rest = parts[1].strip() if len(parts) > 1 else "" + if cmd == ":ingest": + return self._cmd_ingest(rest) + if cmd == ":status": + return self._cmd_status() + if cmd == ":consolidate": + return self._cmd_consolidate() + if cmd == ":core": + return self._cmd_core(rest) + if cmd == ":speak": + return self._cmd_speak(rest) + if cmd == ":say": + return self._cmd_say(rest) + return [f"[VORAX] commande inconnue: {cmd}"] + return self._answer(line) + + def _cmd_ingest(self, rest: str) -> list[str]: + if not rest: + return ["[VORAX] usage: :ingest "] + try: + buf = io.StringIO() + with redirect_stdout(buf): # cli.ingest imprime — on capture + cli.ingest(Path(rest), self.brain_path, D=self.D, seed=self.seed) + lines = [l for l in buf.getvalue().splitlines() if l.strip()] + self.brain = Brain.load(self.brain_path) # résumer l'état disque + return lines + except (OSError, ValueError, UnicodeDecodeError) as exc: + return [f"[VORAX] ERREUR: {exc}"] + + def _cmd_status(self) -> list[str]: + n_relations = sum(len(rel) for rel in self.brain.relations.values()) + return [ + f"[VORAX] brain: {len(self.brain)} atomes, D={self.brain.D}, seed={self.brain.seed}", + f"[VORAX] organes: {len(self.brain.experts)} experts, {n_relations} relations, " + f"{self.brain.hebbian.n_writes} écritures hebbiennes", + ] + + def _cmd_consolidate(self) -> list[str]: + n = self.brain.consolidate() + self.brain.save(self.brain_path) + return [f"[VORAX] consolidate: {n} promotions"] + + def _cmd_core(self, rest: str) -> list[str]: + if not rest: + return [safe_decode_text("[NOYAU] usage: :core ")] + if not core_available(): + return [safe_decode_text("[NOYAU] indisponible: torch absent de ce python")] + path = Path(rest) + try: + # Sonde d'espèce (clés seules, lazy torch) : le REPL ne sait pas + # ce que contient le checkpoint — il le demande avant d'attacher. + from ..model.cte_core import probe_checkpoint_kind # tardif : gate torch + + kind = probe_checkpoint_kind(path) + except Exception as exc: # fichier absent/corrompu — dégradation honnête + return [safe_decode_text(f"[NOYAU] indisponible: {exc}")] + try: + if kind == "cte": + from ..model.cte_core import CteCore # import tardif : gate torch + + core = CteCore.from_checkpoint(path) + type_line = "[NOYAU] type: cte (1B Continuous Thought Engine)" + elif kind == "fractus": + from ..model.fractus_core import FractusCore # tardif : gate torch + + core = FractusCore.from_checkpoint(path) + type_line = "[NOYAU] type: fractus" + else: # inconnu : ligne honnête, AUCUN attach + return [safe_decode_text( + f"[NOYAU] type de checkpoint inconnu: {path} " + "(ni cte ni fractus) — noyau non attaché")] + except Exception as exc: # load strict échoué — dégradation honnête + return [safe_decode_text(f"[NOYAU] indisponible: {exc}")] + # Tokenizer : BPE GPT-2 (ids exacts fractus) si la lib vit dans ce + # python ; sinon CharTokenizer de secours, message honnête. Aucune + # étape ne peut faire sortir un traceback de :core. + notes: list[str] = [] + if bpe_available(): + try: + self.tokenizer = Gpt2BpeTokenizer() + except Exception as exc: # cache HF vide + réseau injoignable — repli + self.tokenizer = CharTokenizer( + vocab_size=int(getattr(core.config, "vocab_size", 0x110000)) + ) + notes.append(safe_decode_text( + f"[NOYAU] tokenizer BPE indisponible — CharTokenizer de secours ({exc})")) + else: + self.tokenizer = CharTokenizer( + vocab_size=int(getattr(core.config, "vocab_size", 0x110000)) + ) + notes.append(safe_decode_text( + "[NOYAU] tokenizer BPE indisponible — CharTokenizer de secours")) + self.core = core # noyau + tokenizer attachés ensemble ou pas du tout + return [ + safe_decode_text(type_line), + safe_decode_text(f"[NOYAU] noyau chargé: {rest}"), + *notes, + ] + + def _cmd_speak(self, rest: str) -> list[str]: + """:speak — la parole MÉCANIQUE du noyau piloté par les organes. + + Trois lignes ``[PAROLE]`` : les cartes du brain sur la question + (``ask_cards``, k=3), le steering organique (``speak_bias`` : les + tokens-réponses que les cartes injectent dans la bouche), puis la + génération ``speak()`` (z-norm ×3, rep-pen 6, t=1.0, seed 7, 8 + tokens, bias organique) sur le préfixe protocolaire des cartes. + + GATE torch : ``speak`` importe torch au niveau module — import + TARDIF ici, atteint seulement si un noyau est attaché (``:core`` ne + l'attache que si torch vit dans ce python). Sans noyau : la ligne + honnête ``noyau non attaché`` — le substrat mingw reste propre. + Toute ligne passe par ``safe_decode_text`` (console-safe), et aucun + échec du noyau ne fait sortir un traceback de :speak. + """ + if not rest: + return ["[PAROLE] usage: :speak "] + if self.core is None or self.tokenizer is None: + return ["[PAROLE] noyau non attaché (:core d'abord)"] + try: + from ..model.speak import speak # tardif : gate torch (module torch) + from ..model.vocal import serialize_cards + + cards = self.brain.ask_cards(rest, k=3) + lines = [f"[PAROLE] cartes: {len(cards)}"] + bias = self.brain.speak_bias(rest, self.tokenizer, k=3) + if bias: + steering = ", ".join( + f"{safe_decode_text(self.tokenizer.decode([tid]))!r} x{w:g}" + for tid, w in sorted(bias.items(), key=lambda kv: -kv[1]) + ) + else: + steering = "(aucun - pas de carte-réponse)" + lines.append(safe_decode_text(f"[PAROLE] steering: {steering}")) + text, _diags = speak( + self.core, + self.tokenizer, + serialize_cards(rest, cards), + max_new_tokens=8, + temperature=1.0, + rep_pen=6.0, + z_scale=3.0, + seed=7, + eos_token_id=getattr(self.tokenizer, "eos_token_id", None), + bias_tokens=bias or None, + ) + lines.append(safe_decode_text(f'[PAROLE] "{text}"')) + return lines + except Exception as exc: # le noyau peut échouer — ligne honnête, jamais de traceback + return [safe_decode_text(f"[PAROLE] indisponible: {exc}")] + + def _cmd_say(self, rest: str) -> list[str]: + """:say — les organes ARTICULENT à travers la bouche du noyau. + + Deux lignes : + - ``[ORGANES] {stmt} => {answer}`` : la carte qui CONNAÎT la réponse + (première FAIT/HEBBIEN avec contexte — HEBBIEN corrobore en tête — + sinon ANALOGIE, sinon la lacune, dite comme lacune) ; + - ``[PAROLE] {question} The answer is{génération}`` : le prompt + d'articulation ``f"{question} The answer is"`` puis speak() 12 + tokens avec ``answer_lock=brain.speak_answer(...)`` (rep_pen 6, + z_scale 3, t=1.0, seed 7) — le verrou place la réponse des organes + token par token À TRAVERS la distribution du noyau, la suite de la + génération est LIBRE. Le verrou est LE mécanisme, affiché comme + tel : le lexique et les transitions sortent du noyau, la réponse + est verrouillée par les organes — rien de caché. + + Dégradations honnêtes : sans noyau/tokenizer (``:core`` d'abord) → + UNE ligne ; sans carte-réponse (lacune) → la parole coule SANS + verrou et le dit — on ne verrouille jamais une bouche sur rien. + + GATE torch : même contrat que :speak — import tardif de speak, + atteint seulement si un noyau est attaché. Jamais de traceback. + """ + if not rest: + return ["[PAROLE] usage: :say "] + if self.core is None or self.tokenizer is None: + return ["[PAROLE] noyau non attaché (:core d'abord)"] + try: + from ..model.speak import speak # tardif : gate torch (module torch) + + cards = self.brain.ask_cards(rest, k=3) + organes = next( + (c for c in cards if c.kind in ("FAIT", "HEBBIEN") and " => " in c.text), + None, + ) + if organes is None: # pas de fait — l'analogie, sinon la lacune dite + organes = next((c for c in cards if c.kind == "ANALOGIE"), cards[0]) + lock = self.brain.speak_answer(rest, self.tokenizer, k=3) + text, _diags = speak( + self.core, + self.tokenizer, + f"{rest} The answer is", + max_new_tokens=12, + temperature=1.0, + rep_pen=6.0, + z_scale=3.0, + seed=7, + eos_token_id=getattr(self.tokenizer, "eos_token_id", None), + answer_lock=lock or None, + ) + note = "" if lock else " (sans verrou - aucune carte-réponse, la parole est libre)" + return [ + safe_decode_text( + f"[ORGANES] {organes.kind}: {organes.text} (sim {organes.sim:.2f})" + ), + safe_decode_text(f"[PAROLE] {rest} The answer is{text}{note}"), + ] + except Exception as exc: # le noyau peut échouer — ligne honnête, jamais de traceback + return [safe_decode_text(f"[PAROLE] indisponible: {exc}")] + + # ------------------------------------------------------------------ + # Question → cartes → écriture O(1) de l'échange + # ------------------------------------------------------------------ + + def _answer(self, question: str) -> list[str]: + cards = self.brain.ask_cards(question) + lines = [f"[CARTE] {c.kind}: {c.text} (sim {c.sim:.2f} | src: {c.source})" for c in cards] + # noyau optionnel : seulement s'il a été attaché via :core + if self.core is not None: + try: + text = verbalize( + self.core, + question, + cards, + self.tokenizer, + eos_token_id=getattr(self.tokenizer, "eos_token_id", None), + ) + lines.append(safe_decode_text(f"[NOYAU] {text}")) + except Exception as exc: # le noyau non-né peut échouer — l'afficher honnêtement + lines.append(safe_decode_text(f"[NOYAU] indisponible: {exc}")) + # la conversation nourrit le brain : 1 atome par échange, puis save + best = "" + for c in cards: + if c.kind != "LACUNE": + best = c.text.split(" => ", 1)[1] if " => " in c.text else c.text + break + self.brain.ingest_atom(Atom(question, best, "session")) + self.brain.save(self.brain_path) + return lines + + # ------------------------------------------------------------------ + # Boucle interactive + # ------------------------------------------------------------------ + + def run(self) -> None: + """while True: input → feed → print. EOF (Ctrl-D / pipe) sort proprement.""" + self._running = True + while self._running: + try: + line = input("fractus_vorax> ") + except EOFError: + break + except KeyboardInterrupt: + break + for out in self.feed(line): + print(out) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="fractus_vorax-repl", description="Il ne s'entraîne plus : il mange.") + parser.add_argument("--brain", type=Path, default=Path("./brain")) + parser.add_argument("--D", type=int, default=D_DEFAULT) + parser.add_argument("--seed", type=int, default=SEED_DEFAULT) + args = parser.parse_args(argv) + Repl(args.brain, D=args.D, seed=args.seed).run() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/fractus_vorax/brain.py b/fractus_vorax/brain.py new file mode 100644 index 0000000000000000000000000000000000000000..55040de4cd10671ba1d2eac3715cf71edbbc87ce --- /dev/null +++ b/fractus_vorax/brain.py @@ -0,0 +1,384 @@ +# fractus_vorax/brain.py +"""Le cerveau Fractus-Vorax Plan 1 : organes + atomes, persistant et déterministe.""" +import gzip +import json +from pathlib import Path + +import numpy as np + +from .cards import Card +from .compiler.analogy import Pattern, extract_patterns, match_pattern, solve_analogy +from .compiler.atoms import Atom +from .compiler.encode import encode_atom, text_hv +from .hv import D_DEFAULT, SEED_DEFAULT, bind, hash_hv, sim +from .organs.consolidation import CoActivation +from .organs.hebbian import HebbianMemory +from .organs.spawn import ExpertRegistry +from .organs.traces import TraceStore + +# Seuil de déclenchement de l'analogie : un top hit < 0.7 signifie requête +# approximative (typo/slot inconnu) — les sims typo mesurées sont ~0.63-0.69 +# (bundles 5/6 tokens), les requêtes exactes sont à 1.0. Calibration-sensible : +# recalibrer si l'encodage change. Contrat des cartes consommé par le Plan 3. +ANALOGY_TRIGGER_SIM = 0.7 + +# Seuil de match de la lecture hebbienne : la complétion sign(M @ q) doit +# ressembler (cosinus) à une VALEUR ingérée pour émettre une carte HEBBIEN. +# Mesuré : question exacte → 1.0 ; typo (5/6 tokens) → ≤ 0.26 (bruit, pas de +# carte). Calibration-sensible : recalibrer si D_h ou l'encodage change. +HEBBIAN_CARD_SIM = 0.55 + + +class Brain: + def __init__(self, D: int = D_DEFAULT, seed: int = SEED_DEFAULT) -> None: + self.D = D + self.seed = seed + self.traces = TraceStore(D=D) + self.atoms: list[Atom] = [] + self.experts = ExpertRegistry() + self.hebbian = HebbianMemory() + self.coact = CoActivation() + self.relations: dict[Pattern, dict[str, str]] = {} + # parents des atomes composites : atom_id -> (a_id, b_id) — pour + # rejouer addr = bind(addr_a, addr_b) au load (le replay ingeste + # l'atom via text_hv, qui diverge du bind de consolidate()). + self._composite_parents: dict[int, tuple[int, int]] = {} + # index hebbien : value.tobytes() -> statement, rempli à l'ingestion + # pour chaque écriture hebbienne ACCEPTÉE par la porte ΔE — sert à + # identifier la paire que la complétion matche (les HV ne sont pas + # inversibles, il faut une table value -> texte). + self._hebbian_index: dict[bytes, str] = {} + + def __len__(self) -> int: + return len(self.atoms) + + def ingest_atom(self, atom: Atom) -> int: + atom_id = len(self.atoms) + self.atoms.append(atom) + addr, value = encode_atom(atom, self.D, self.seed) + self.traces.write(atom_id, addr, value, weight=atom.confidence) + return atom_id + + def ingest_atoms(self, atoms: list[Atom]) -> int: + for atom in atoms: + self.ingest_atom(atom) + return len(atoms) + + def ask(self, question: str, k: int = 3) -> list[tuple[Atom, float]]: + query = text_hv(question, self.D, self.seed) + return [(self.atoms[i], s) for i, s in self.traces.retrieve(query, k=k)] + + def ingest_source(self, name: str, atoms: list[Atom]) -> int: + start = len(self.atoms) + for atom in atoms: + self.ingest_atom(atom) + end = len(self.atoms) - 1 + # relations locales au batch (merge déterministe : setdefault) + for pattern, rel in extract_patterns(atoms, min_support=3).items(): + merged = self.relations.setdefault(pattern, {}) + for slot, answer in rel.items(): + merged.setdefault(slot, answer) + # écriture hebbienne (espace D_h=2048, re-hash indépendant) + for i, atom in enumerate(atoms): + key = hash_hv(atom.statement, self.hebbian.D_h, self.seed) + value = hash_hv(atom.context or atom.statement, self.hebbian.D_h, self.seed) + # sondes = 8 dernières paires DÉJÀ écrites du batch (max(start, i-8):i) ; + # la fenêtre fixe du brief [max(start, n-9):n-1] sondait des paires non + # encore écrites → la porte ΔE rejetait TOUT (n_writes=0, vérifié). + window = self.atoms[max(start, start + i - 8):start + i] + probes = [ + (hash_hv(a.statement, self.hebbian.D_h, self.seed), + hash_hv(a.context or a.statement, self.hebbian.D_h, self.seed)) + for a in window + ] + if self.hebbian.write_gated(key, value, probes): + self._hebbian_index[value.tobytes()] = atom.statement + # spawn : signature = signe majoritaire des addr du batch + if atoms: + addrs = [encode_atom(a, self.D, self.seed)[0] for a in atoms] + total = np.sum(np.stack(addrs).astype(np.int32), axis=0) + signature = np.where(total >= 0, 1, -1).astype(np.int8) + self.experts.spawn(name, signature, start, end) + return len(atoms) + + def ask_cards(self, question: str, k: int = 3) -> list[Card]: + query = text_hv(question, self.D, self.seed) + routed = self.experts.route(query, top=1) + route_name = routed[0][0] if routed else "" + hits = self.ask(question, k=k) + # ask() renvoie (Atom, sim) — les ids viennent du retrieve pour coact + retrieved = self.traces.retrieve(query, k=k) + self.coact.record([i for i, _ in retrieved]) + cards: list[Card] = [] + for atom, s in hits: + text = f"{atom.statement} => {atom.context}" if atom.context else atom.statement + cards.append(Card("FAIT", text, s, atom.provenance)) + top_sim = hits[0][1] if hits else 0.0 + if top_sim < ANALOGY_TRIGGER_SIM: + matched = match_pattern(question) + if matched is not None: + pattern, _slot = matched + rel = self.relations.get(pattern) + if rel: + for answer, score in solve_analogy(question, rel, D=self.D, seed=self.seed)[:2]: + cards.append(Card("ANALOGIE", answer, score, route_name or "analogie")) + # lecture hebbienne : complétion indépendante du retrieval, en tête. + # Pour une question exacte le match hebbien EST l'atome FAIT top-1 + # (les deux à sim 1.0) : la clause « sans doublon » littérale du plan + # (sauter la carte) casserait son propre test (n_writes > 0), et + # remplacer le FAIT top-1 casserait test_ask_cards_fact — la carte + # HEBBIEN corrobore donc en tête SANS toucher aux cartes FAIT. + if self._hebbian_index: + key = hash_hv(question, self.hebbian.D_h, self.seed) + completed = self.hebbian.complete(key) + best_bytes: bytes | None = None + best_sim = -1.0 + for vbytes in sorted(self._hebbian_index): # déterministe : ordre des octets + s = sim(completed, np.frombuffer(vbytes, dtype=np.int8)) + if s > best_sim: + best_bytes, best_sim = vbytes, s + if best_bytes is not None and best_sim >= HEBBIAN_CARD_SIM: + statement = self._hebbian_index[best_bytes] + atom = next((a for a in self.atoms if a.statement == statement), None) + context = atom.context if atom is not None else "" + text = f"{statement} => {context}" if context else statement + cards.insert(0, Card("HEBBIEN", text, best_sim, "hebbian")) + if not cards: + cards.append(Card("LACUNE", "aucune trace pour cette question", 0.0, route_name)) + return cards + + def speak_bias( + self, + question: str, + tokenizer, + k: int = 3, + boost: float = 8.0, + ) -> dict[int, float]: + """Steering organique — les organes dirigent la bouche (Plan 6, T3). + + ask_cards(question, k) → PREMIER token BPE de la réponse de chaque + carte → ``{token_id: poids}``, le dictionnaire qui entre dans + ``speak(..., bias_tokens=...)`` : +δ brut sur les ids-réponses, + APRÈS z-norm/rep-pen, AVANT top-k/p (contrat de speak). + + Extraction de la RÉPONSE par nature de carte : + - FAIT/HEBBIEN : la partie après ``" => "`` (le contexte EST la + réponse, ex. ``"what is the capital of france => paris"`` → + ``"paris"``) ; sans ``" => "`` la carte n'a pas de réponse + (contexte vide) → carte ignorée ; + - ANALOGIE : le texte nu (solve_analogy rend la réponse seule) ; + - LACUNE : ignorée — texte placeholder, pas une connaissance ; on + ne steere jamais la bouche avec une lacune. + + Convention GPT-2 : on encode ``" " + réponse`` (espace initial + partie du token — ``encode(" paris")[0] != encode("paris")[0]``) ; + le PREMIER token de la réponse est la cible : c'est le premier + geste de la bouche vers cette réponse. + + Poids = ``boost * 0.5**r`` (rang r dans la liste de cartes : HEBBIEN + en tête, puis FAIT par similarité décroissante, puis ANALOGIE) — + la meilleure carte pèse le plus. Accumulation MAX par id, PAS la + somme : quand plusieurs cartes convergent sur la même réponse + (HEBBIEN + FAIT top-1, sim 1.0 chacune), le δ injecté reste borné + par ``boost`` quel que soit l'accord — l'échelle du steering reste + calibrée contre l'échelle z-norm des logits (std 3 par défaut : + boost 8 ≈ 2.7σ, dominant mais borné). Une sommation laisserait k + cartes d'accord empiler un δ non borné et casserait ce calibrage. + + GATE substrat — DÉVIATION documentée : le plan écrivait + ``speak_bias(question, k, boost)`` sans le tokenizer ; le vrai BPE + (Gpt2BpeTokenizer) vit dans model/ (zone torch/tokenizers) alors + que Brain est substrat numpy-seul (mingw doit rester vert). Le + tokenizer est donc passé EN PARAMÈTRE par l'appelant torch-aware + (repl/bench) — le substrat reste propre, même philosophie que + speak() qui reçoit ses objets. Protocole minimal du tokenizer : + ``encode(str) -> list[int]`` (celui de vocal/speak). + + Déterministe : ask_cards est déterministe, l'accumulation max + aussi (ordre d'insertion = ordre des rangs). + """ + if k < 1: + raise ValueError(f"k doit être >= 1 (reçu {k})") + if boost <= 0.0: + raise ValueError(f"boost doit être > 0 (reçu {boost})") + + bias: dict[int, float] = {} + for rank, card in enumerate(self.ask_cards(question, k=k)): + if card.kind == "ANALOGIE": + answer = card.text + elif card.kind in ("FAIT", "HEBBIEN") and " => " in card.text: + answer = card.text.split(" => ", 1)[1] + else: # LACUNE, ou FAIT/HEBBIEN sans réponse (contexte vide) + continue + answer = answer.strip() + if not answer: + continue + ids = tokenizer.encode(" " + answer) + if not ids: + continue + tid = int(ids[0]) + weight = boost * (0.5 ** rank) + if weight > bias.get(tid, 0.0): # max, pas somme (borné) + bias[tid] = weight + return bias + + def speak_answer( + self, + question: str, + tokenizer, + k: int = 3, + ) -> list[int]: + """Le verrou de réponse — les organes livrent la réponse ENTIÈRE (P7). + + ask_cards(question, k) → la PREMIÈRE carte qui CONNAÎT une réponse, + puis la liste COMPLÈTE des tokens BPE de ``" " + réponse`` — le + ``answer_lock`` de ``speak()`` : à l'étape i, le noyau verra + answer_lock[i] boosté, token après token, jusqu'au dernier fragment + de la réponse. La suite de la génération est LIBRE (mécanique + anti-attracteur) : les organes articulent À TRAVERS le noyau. + + Sélection par nature de carte (même hiérarchie que speak_bias) : + - FAIT/HEBBIEN avec ``" => "`` : le contexte après la flèche EST la + réponse (première telle carte — HEBBIEN en tête si elle corrobore) ; + - ANALOGIE si AUCUNE FAIT/HEBBIEN n'a de réponse : le texte nu + (solve_analogy rend la réponse seule) ; + - LACUNE (ou aucune carte-réponse) : ``[]`` — on ne verrouille + JAMAIS la bouche sur une lacune : sans connaissance, la parole + reste libre et le dit (repl/demo l'affichent honnêtement). + + Convention GPT-2 (comme speak_bias) : ``" " + réponse`` — l'espace + initial fait partie du premier token. Liste COMPLÈTE, pas juste le + premier geste : c'est la différence avec speak_bias (qui steere le + PREMIER token de CHAQUE carte) — ici UNE réponse, tous ses tokens. + + GATE substrat : même déviations documentées que speak_bias — le + tokenizer est passé EN PARAMÈTRE par l'appelant torch-aware ; ce + fichier reste numpy-seul. Protocole minimal : ``encode(str) -> + list[int]``. + + Déterministe : ask_cards est déterministe, la sélection aussi. + """ + if k < 1: + raise ValueError(f"k doit être >= 1 (reçu {k})") + + analogie = "" + for card in self.ask_cards(question, k=k): + if card.kind in ("FAIT", "HEBBIEN") and " => " in card.text: + answer = card.text.split(" => ", 1)[1].strip() + if answer: + return [int(t) for t in tokenizer.encode(" " + answer)] + elif card.kind == "ANALOGIE" and not analogie: + analogie = card.text.strip() + if analogie: # aucune FAIT/HEBBIEN avec réponse — l'analogie connaît + return [int(t) for t in tokenizer.encode(" " + analogie)] + return [] # LACUNE / aucune réponse : pas de verrou + + def consolidate(self, threshold: int = 3) -> int: + promoted = 0 + pairs = self.coact.promote(threshold=threshold) + for a_id, b_id in pairs: + a, b = self.atoms[a_id], self.atoms[b_id] + composite = Atom(f"({a.statement}) & ({b.statement})", + a.context or b.context, "consolidated", 0.8) + atom_id = len(self.atoms) + self.atoms.append(composite) + # addr composite = bind des addr STOCKÉES des parents — pas un + # ré-encodage : un parent composite a pour addr son bind, que + # encode_atom(statement) ne reproduit pas. Ré-encoder divergeait + # donc pour les composites imbriqués (profondeur ≥ 2) entre + # consolidate() et le replay de load(). Les parents ont des ids + # plus petits : leurs addr stockées sont finales à la promotion. + addr_a = self.traces.addrs[a_id] + addr_b = self.traces.addrs[b_id] + addr = bind(addr_a, addr_b) + value = encode_atom(composite, self.D, self.seed)[1] + self.traces.write(atom_id, addr, value, 0.8) + # mémoriser les parents pour rejouer le bind exact au load() + self._composite_parents[atom_id] = (a_id, b_id) + promoted += 1 + # idempotence : les paires promues quittent le compteur — un second + # consolidate() ne les re-promeut pas (CoActivation.forget). + self.coact.forget(pairs) + return promoted + + def save(self, path: Path) -> None: + path.mkdir(parents=True, exist_ok=True) + payload = "\n".join(a.row() for a in self.atoms).encode("utf-8") + (path / "atoms.jsonl.gz").write_bytes(gzip.compress(payload, mtime=0)) + self.traces.save(path) + (path / "meta.json").write_text( + json.dumps({"D": self.D, "seed": self.seed, "n_atoms": len(self.atoms)}, sort_keys=True), + encoding="utf-8", + ) + self.experts.save(path / "experts") + self.hebbian.save(path / "hebbian") + # index hebbien : clés bytes -> hex (l'ordre des octets est préservé + # par l'ordre lexicographique hex pour des clés de longueur fixe) + hebbian_index_payload = { + vbytes.hex(): statement + for vbytes, statement in sorted(self._hebbian_index.items()) + } + (path / "hebbian_index.json").write_text( + json.dumps(hebbian_index_payload, sort_keys=True), encoding="utf-8" + ) + self.coact.save(path) + relations_payload = {"|".join(p): rel for p, rel in sorted(self.relations.items())} + (path / "relations.json").write_text( + json.dumps(relations_payload, sort_keys=True), encoding="utf-8" + ) + composites_payload = { + str(atom_id): [a_id, b_id] + for atom_id, (a_id, b_id) in sorted(self._composite_parents.items()) + } + (path / "composites.json").write_text( + json.dumps(composites_payload, sort_keys=True), encoding="utf-8" + ) + + @classmethod + def load(cls, path: Path) -> "Brain": + meta = json.loads((path / "meta.json").read_text(encoding="utf-8")) + brain = cls(D=int(meta["D"]), seed=int(meta["seed"])) + raw = gzip.decompress((path / "atoms.jsonl.gz").read_bytes()).decode("utf-8") + for line in raw.splitlines(): + if line.strip(): + brain.ingest_atom(Atom.from_row(line)) + # organes v2 : reconstruits depuis les fichiers si présents (rétro-compatible Plan 1) + experts_dir = path / "experts" + if experts_dir.exists(): + brain.experts = ExpertRegistry.load(experts_dir) + heb_dir = path / "hebbian" + if heb_dir.exists(): + brain.hebbian = HebbianMemory.load(heb_dir) + # index hebbien ; absent -> vide (compat Plan 1/2 : pas de HEBBIEN) + idx_file = path / "hebbian_index.json" + if idx_file.exists(): + raw_idx = json.loads(idx_file.read_text(encoding="utf-8")) + brain._hebbian_index = { + bytes.fromhex(k): v for k, v in sorted(raw_idx.items()) + } + co_file = path / "coactivation.json" + if co_file.exists(): + brain.coact = CoActivation.load(path) + rel_file = path / "relations.json" + if rel_file.exists(): + raw_rel = json.loads(rel_file.read_text(encoding="utf-8")) + for key, rel in raw_rel.items(): + pattern = tuple(key.split("|")) + merged = brain.relations.setdefault(pattern, {}) + for slot, answer in rel.items(): + merged.setdefault(slot, answer) + # composites : le replay ci-dessus a ré-encodé les atomes composites via + # text_hv(statement), qui diverge du addr = bind(addr_a, addr_b) écrit + # par consolidate() → rejouer le bind exact, parents d'abord (ids + # croissants : un composite parent précède toujours ses enfants). + comp_file = path / "composites.json" + if comp_file.exists(): + raw_comp = json.loads(comp_file.read_text(encoding="utf-8")) + for atom_id_str in sorted(raw_comp, key=int): + atom_id = int(atom_id_str) + a_id, b_id = raw_comp[atom_id_str] + brain.traces.addrs[atom_id] = bind(brain.traces.addrs[a_id], brain.traces.addrs[b_id]) + brain._composite_parents[atom_id] = (int(a_id), int(b_id)) + # addrs mutés directement → invalider la pile paresseuse du retrieve + brain.traces._stack = None + return brain diff --git a/fractus_vorax/cards.py b/fractus_vorax/cards.py new file mode 100644 index 0000000000000000000000000000000000000000..3407aea115bdd3dafb8a2681c0c554c4f9135701 --- /dev/null +++ b/fractus_vorax/cards.py @@ -0,0 +1,11 @@ +# fractus_vorax/cards.py +"""Carte de connaissance : l'unité que le noyau fractus lira (Plan 3).""" +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Card: + kind: str # "FAIT" | "ANALOGIE" | "LACUNE" + text: str + sim: float + source: str diff --git a/fractus_vorax/compiler/__init__.py b/fractus_vorax/compiler/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/fractus_vorax/compiler/analogy.py b/fractus_vorax/compiler/analogy.py new file mode 100644 index 0000000000000000000000000000000000000000..5914e7c2972d0f10b644a286d54f64588b8c1552 --- /dev/null +++ b/fractus_vorax/compiler/analogy.py @@ -0,0 +1,78 @@ +# fractus_vorax/compiler/analogy.py +"""Généralisation sans gradient : patterns par préfixe, relations par comptage, +analogie 3CosAdd/3CosMul sur les slots char-ngram (voisins morphologiques).""" +import math + +from ..hv import D_DEFAULT, SEED_DEFAULT, sim +from .atoms import Atom +from .encode import slot_hv, text_hv, tokenize + +Pattern = tuple[str, ...] +MORPHO_PREFIX = 5 + + +def extract_patterns(atoms: list[Atom], min_support: int = 3) -> dict[Pattern, dict[str, str]]: + """Groupes d'énoncés partageant le même préfixe tokenique (slot = dernier token).""" + groups: dict[Pattern, dict[str, str]] = {} + counts: dict[Pattern, int] = {} + for atom in atoms: + tokens = tokenize(atom.statement) + if len(tokens) < 2 or not atom.context: + continue + pattern, slot = tuple(tokens[:-1]), tokens[-1] + groups.setdefault(pattern, {})[slot] = atom.context + counts[pattern] = counts.get(pattern, 0) + 1 + return {p: rel for p, rel in groups.items() if counts[p] >= min_support} + + +def match_pattern(query: str) -> tuple[Pattern, str] | None: + tokens = tokenize(query) + if len(tokens) < 2: + return None + return tuple(tokens[:-1]), tokens[-1] + + +def _shares_prefix(a: str, b: str, n: int = MORPHO_PREFIX) -> bool: + return a[:n] == b[:n] and len(a) >= n and len(b) >= n + + +def 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]]: + matched = match_pattern(query) + if matched is None: + return [] + _pattern, slot_q = matched + if slot_q in relation: + return [] # slot connu : c'est du retrieval, pas de l'analogie + # voisins morphologiques (char-ngram) les plus proches + hq = slot_hv(slot_q, D, seed) + known = sorted(relation) # déterminisme + scored = sorted(((sim(hq, slot_hv(s, D, seed)), s) for s in known), reverse=True) + neighbors = [(s, w) for w, s in scored[:k_neighbors] if w >= min_sim] + if not neighbors: + return [] + # candidats = réponses des voisins (unicité, ordre trié) + candidates = sorted({relation[s] for s, _ in neighbors}) + cand_hvs = {c: text_hv(c, D, seed) for c in candidates} + ans_hvs = {s: text_hv(relation[s], D, seed) for s, _ in neighbors} + results: list[tuple[str, float]] = [] + for cand, ch in cand_hvs.items(): + if _shares_prefix(cand, slot_q): + continue # filtre morphologique + if method == "cosadd": + score = sum(w * sim(ch, ans_hvs[s]) for s, w in neighbors) + else: # cosmul : cosinus remappés [0,1], moyenne géométrique pondérée + log_sum = sum(w * math.log((sim(ch, ans_hvs[s]) + 1.0) / 2.0) for s, w in neighbors) + total_w = sum(w for _, w in neighbors) + score = math.exp(log_sum / total_w) if total_w > 0 else 0.0 + if score >= min_sim: + results.append((cand, float(score))) + results.sort(key=lambda x: (-x[1], x[0])) + return results diff --git a/fractus_vorax/compiler/atoms.py b/fractus_vorax/compiler/atoms.py new file mode 100644 index 0000000000000000000000000000000000000000..27acca8aa770313030bc1d5c35d70f1f60c44ff4 --- /dev/null +++ b/fractus_vorax/compiler/atoms.py @@ -0,0 +1,29 @@ +# fractus_vorax/compiler/atoms.py +"""Atome de connaissance : l'unité canonique que tout format devient.""" +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Atom: + statement: str # l'énoncé retrievable (la question, le fait) + context: str # la réponse / le contexte associé + provenance: str # source (fichier:ligne, url...) + confidence: float = 1.0 + + def row(self) -> str: + """Sérialisation TSV-safe (4 champs, tabs/retours ligne neutralisés).""" + f = [self.statement, self.context, self.provenance] + return ( + "\t".join( + x.replace("\t", " ").replace("\n", " ").replace("\r", " ") for x in f + ) + + f"\t{self.confidence}" + ) + + @staticmethod + def from_row(row: str) -> "Atom": + parts = row.rstrip("\r\n").split("\t") + if len(parts) < 3: + raise ValueError(f"malformed atom row: {row!r}") + confidence = float(parts[3]) if len(parts) > 3 and parts[3] != "" else 1.0 + return Atom(parts[0], parts[1], parts[2], confidence) diff --git a/fractus_vorax/compiler/encode.py b/fractus_vorax/compiler/encode.py new file mode 100644 index 0000000000000000000000000000000000000000..8964516af94913d1fbf227edf0aa41ee46eb0542 --- /dev/null +++ b/fractus_vorax/compiler/encode.py @@ -0,0 +1,49 @@ +# fractus_vorax/compiler/encode.py +"""Texte → HV : le bundle de tokens rend les questions apparentées similaires.""" +import re + +import numpy as np + +from ..hv import D_DEFAULT, SEED_DEFAULT, bundle, hash_hv +from .atoms import Atom + +_WORD = re.compile(r"[a-z0-9àâäéèêëîïôöùûüç'-]+") + + +def normalize(text: str) -> str: + t = text.casefold().strip() + t = re.sub(r"\s+", " ", t) + return t.strip(" .;:!?") + + +def tokenize(text: str) -> list[str]: + return _WORD.findall(normalize(text)) + + +def text_hv(text: str, D: int = D_DEFAULT, seed: int = SEED_DEFAULT) -> np.ndarray: + tokens = tokenize(text) + if not tokens: + return hash_hv("", D, seed) + return bundle([hash_hv(tok, D, seed) for tok in tokens]) + + +def encode_atom(atom: Atom, D: int = D_DEFAULT, seed: int = SEED_DEFAULT) -> tuple[np.ndarray, np.ndarray]: + addr = text_hv(atom.statement, D, seed) + if atom.context: + value = text_hv(f"{atom.statement} => {atom.context}", D, seed) + else: + value = addr + return addr, value + + +def char_ngrams(text: str, n: int = 3) -> list[str]: + """Char n-grammes avec padding '#'. Sub-word fuzziness pour les slots.""" + padded = f"#{normalize(text)}#" + if len(padded) <= n: + return [padded] + return [padded[i:i + n] for i in range(len(padded) - n + 1)] + + +def slot_hv(text: str, D: int = D_DEFAULT, seed: int = SEED_DEFAULT) -> np.ndarray: + """HV de slot : bundle des char-n-grammes — 'france' ~ 'franca', 'france' ⊥ 'japan'.""" + return bundle([hash_hv(g, D, seed) for g in char_ngrams(text)]) diff --git a/fractus_vorax/compiler/kn.py b/fractus_vorax/compiler/kn.py new file mode 100644 index 0000000000000000000000000000000000000000..51bdcb67c598f34715d8fcf68730c306753bf86e --- /dev/null +++ b/fractus_vorax/compiler/kn.py @@ -0,0 +1,58 @@ +# fractus_vorax/compiler/kn.py +"""Le compilateur .kn : dataset → artefact de connaissance portable, bit-identique.""" +import gzip +import hashlib +import json +from pathlib import Path + +import numpy as np + +from ..hv import D_DEFAULT, SEED_DEFAULT, pack +from .atoms import Atom +from .encode import encode_atom +from .parse import parse_file + +FORMAT_VERSION = 1 + + +def source_sha256(path: Path) -> str: + h = hashlib.sha256() + h.update(path.read_bytes()) + return h.hexdigest() + + +def compile_kn( + source: Path, out: Path, D: int = D_DEFAULT, seed: int = SEED_DEFAULT +) -> Path: + atoms = parse_file(source) + out.mkdir(parents=True, exist_ok=True) + + payload = "\n".join(a.row() for a in atoms).encode("utf-8") + (out / "atoms.tsv.gz").write_bytes(gzip.compress(payload, mtime=0)) + + addrs = [encode_atom(a, D, seed)[0] for a in atoms] + if addrs: + total = np.sum(np.stack(addrs).astype(np.int32), axis=0) + signature = np.where(total >= 0, 1, -1).astype(np.int8) + sig_bits = pack(signature) + else: + sig_bits = np.array([], dtype=np.uint8) + (out / "signature.bin").write_bytes(sig_bits.tobytes()) + + manifest = { + "D": D, + "format_version": FORMAT_VERSION, + "n_atoms": len(atoms), + "seed": seed, + "source": str(source.name), + "source_sha256": source_sha256(source), + } + (out / "manifest.json").write_text( + json.dumps(manifest, sort_keys=True, indent=2), encoding="utf-8" + ) + return out + + +def load_kn(kn_dir: Path) -> list[Atom]: + raw = gzip.decompress((kn_dir / "atoms.tsv.gz").read_bytes()).decode("utf-8") + return [Atom.from_row(line) for line in raw.splitlines() if line.strip()] diff --git a/fractus_vorax/compiler/parse.py b/fractus_vorax/compiler/parse.py new file mode 100644 index 0000000000000000000000000000000000000000..5ab9bd97b645ccbc149eae3a580e781fb21a47d4 --- /dev/null +++ b/fractus_vorax/compiler/parse.py @@ -0,0 +1,118 @@ +# fractus_vorax/compiler/parse.py +"""N'importe quelle donnée devient des atomes. Format inconnu → fallback brut.""" +import csv +import json +from pathlib import Path + +from .atoms import Atom + +_SUFFIXES = { + ".csv": "csv", ".tsv": "tsv", ".json": "json", ".jsonl": "jsonl", + ".txt": "txt", ".md": "md", +} + + +def detect_format(path: Path) -> str: + return _SUFFIXES.get(path.suffix.lower(), "raw") + + +def flatten_record(d: dict, prefix: str = "") -> list[str]: + """dict (récursif) → paires 'key.subkey: value' plates.""" + pairs: list[str] = [] + for key, value in d.items(): + full = f"{prefix}.{key}" if prefix else str(key) + if isinstance(value, dict): + pairs.extend(flatten_record(value, full)) + elif isinstance(value, list): + joined = "; ".join(str(x) for x in value) + pairs.append(f"{full}: {joined}") + else: + pairs.append(f"{full}: {value}") + return pairs + + +def parse_file(path: Path) -> list[Atom]: + fmt = detect_format(path) + if fmt in ("csv", "tsv"): + return _parse_csv(path, delim="," if fmt == "csv" else "\t") + if fmt == "jsonl": + return _parse_jsonl(path) + if fmt == "json": + return _parse_json(path) + if fmt in ("txt", "md"): + return _parse_text(path, md=(fmt == "md")) + return _parse_raw(path) + + +def _parse_csv(path: Path, delim: str) -> list[Atom]: + atoms: list[Atom] = [] + with open(path, newline="", encoding="utf-8", errors="replace") as fh: + reader = csv.reader(fh, delimiter=delim) + header = next(reader, None) + for lineno, row in enumerate(reader, start=2): + if not row or all(not c.strip() for c in row): + continue + cells = [c.strip() for c in row] + if len(cells) == 2: # QA direct + atoms.append(Atom(cells[0], cells[1], f"{path.name}:{lineno}")) + else: # table large → sérialisation key: value + keys = header if header and len(header) == len(cells) else [f"c{i}" for i in range(len(cells))] + statement = "; ".join(f"{k}: {v}" for k, v in zip(keys, cells)) + atoms.append(Atom(statement, "", f"{path.name}:{lineno}")) + return atoms + + +def _parse_jsonl(path: Path) -> list[Atom]: + atoms = [] + with open(path, encoding="utf-8", errors="replace") as fh: + for lineno, line in enumerate(fh, start=1): + line = line.strip() + if not line: + continue + try: + record = json.loads(line) + except json.JSONDecodeError: + print(f"[VORAX] WARNING: ligne JSON malformée ignorée ({path.name}:{lineno})") + continue + if not isinstance(record, dict): + continue + atoms.append(Atom("; ".join(flatten_record(record)), "", f"{path.name}:{lineno}")) + return atoms + + +def _parse_json(path: Path) -> list[Atom]: + data = json.loads(path.read_text(encoding="utf-8", errors="replace")) + records = data if isinstance(data, list) else [data] + atoms = [] + for i, record in enumerate(records, start=1): + if isinstance(record, dict): + atoms.append(Atom("; ".join(flatten_record(record)), "", f"{path.name}:{i}")) + return atoms + + +def _parse_text(path: Path, md: bool) -> list[Atom]: + atoms = [] + context = "" + buffer: list[str] = [] + for lineno, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), start=1): + stripped = line.strip() + if md and stripped.startswith("#"): + context = stripped.lstrip("#").strip() + continue + if not stripped: + if buffer: + atoms.append(Atom(" ".join(buffer), context, f"{path.name}:{lineno}")) + buffer = [] + continue + buffer.append(stripped) + if buffer: + atoms.append(Atom(" ".join(buffer), context, f"{path.name}:EOF")) + return atoms + + +def _parse_raw(path: Path) -> list[Atom]: + atoms = [] + for lineno, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), start=1): + if line.strip(): + atoms.append(Atom(line.strip(), "", f"{path.name}:{lineno}")) + return atoms diff --git a/fractus_vorax/hv.py b/fractus_vorax/hv.py new file mode 100644 index 0000000000000000000000000000000000000000..985f5dc938d9d25b38db0ae9cbe167b5dd2cb74e --- /dev/null +++ b/fractus_vorax/hv.py @@ -0,0 +1,55 @@ +# fractus_vorax/hv.py +"""Hypervectors bipolaires ±1 : le substrat de calcul de Fractus-Vorax. + +Tout est déterministe : blake2b en mode compteur, jamais hash() python. +""" +import hashlib + +import numpy as np + +D_DEFAULT = 8192 +SEED_DEFAULT = 0 + + +def hash_hv(text: str, D: int = D_DEFAULT, seed: int = 0) -> np.ndarray: + """HV bipolaire ±1 déterministe, mode compteur (digest_size max 64 o).""" + need = (D + 7) // 8 + blocks: list[bytes] = [] + counter = 0 + while sum(len(b) for b in blocks) < need: + payload = f"{seed}|{counter}|{text}".encode() + blocks.append(hashlib.blake2b(payload, digest_size=64).digest()) + counter += 1 + bits = np.unpackbits( + np.frombuffer(b"".join(blocks), dtype=np.uint8), bitorder="little" + ) + return (bits[:D].astype(np.int16) * 2 - 1).astype(np.int8) + + +def bind(a: np.ndarray, b: np.ndarray) -> np.ndarray: + """Association (XOR sur bipolaires) : involution, obscurcit.""" + return a * b + + +def bundle(hvs: list[np.ndarray]) -> np.ndarray: + """Superposition : signe majoritaire, égalité → +1 (déterministe).""" + if not hvs: + raise ValueError("bundle() needs at least one hv") + total = np.sum(np.stack(hvs), axis=0, dtype=np.int16) + return np.where(total >= 0, 1, -1).astype(np.int8) + + +def sim(a: np.ndarray, b: np.ndarray) -> float: + """Similarité = cosinus bipolaire = moyenne des produits.""" + return float((a.astype(np.int16) * b.astype(np.int16)).mean()) + + +def pack(hv: np.ndarray) -> np.ndarray: + """±1 → bits packés (+1 → 1), pour stockage.""" + return np.packbits((hv > 0).astype(np.uint8), bitorder="little") + + +def unpack(bits: np.ndarray, D: int) -> np.ndarray: + """Inverse exact de pack().""" + out = np.unpackbits(bits, bitorder="little")[:D].astype(np.int16) + return (out * 2 - 1).astype(np.int8) diff --git a/fractus_vorax/model/__init__.py b/fractus_vorax/model/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/fractus_vorax/model/bpe_tokenizer.py b/fractus_vorax/model/bpe_tokenizer.py new file mode 100644 index 0000000000000000000000000000000000000000..d55f83a9857bcb382400b65fdaeea2852bf0a799 --- /dev/null +++ b/fractus_vorax/model/bpe_tokenizer.py @@ -0,0 +1,159 @@ +"""fractus_vorax/model/bpe_tokenizer.py — tokenizer BPE GPT-2, exact fractus (Plan 4, tâche 1). + + Le vrai tokenizer de fractus : GPT-2 BPE via la lib HF `tokenizers`. + Mêmes ids que fractus → vocab 50257, eos 50256, "hello world" → + [31373, 995]. + + Gpt2BpeTokenizer() ──► résolution (dans l'ordre) : + (1) tokenizer_json explicite ; + (2) snapshot du cache HF local + ~/.cache/huggingface/hub/models--gpt2/snapshots/*/tokenizer.json + (3) Tokenizer.from_pretrained("gpt2") — RÉSEAU (télécharge depuis + huggingface.co si le cache local est vide) ; + FileNotFoundError si aucune source ne répond. + encode(text) -> list[int] ; decode(ids) -> str (sûr : filtre les ids + hors [0, vocab) et ne lève JAMAIS sur surrogates — le décodeur + byte-level remplace les séquences UTF-8 invalides par U+FFFD, + équivalent errors="replace", plus repli id-par-id ceinture+bretelles). + + GATE torch : ce fichier vit dans fractus_vorax/model/ (seule zone autorisée à + toucher aux libs lourdes) mais n'importe `tokenizers` QUE paresseusement, + à l'intérieur des méthodes — importer fractus_vorax.model.bpe_tokenizer dans un + python sans tokenizers (substrat mingw) ne doit jamais lever. + `bpe_available()` sonde ce python par try-import. +""" + +from __future__ import annotations + +from pathlib import Path + +# Constantes GPT-2 canoniques (garde-fou si le json chargé est modifié). +_GPT2_VOCAB_SIZE = 50257 +_GPT2_EOS_TOKEN = "<|endoftext|>" +_GPT2_EOS_TOKEN_ID = 50256 + + +# --------------------------------------------------------------------------- +# Disponibilité +# --------------------------------------------------------------------------- + +def bpe_available() -> bool: + """True si `tokenizers` est importable dans CE python. + + Jamais d'import au niveau module : le substrat mingw (sans tokenizers) + doit pouvoir importer fractus_vorax.model.bpe_tokenizer sans crash et obtenir + False ici. + """ + try: + import tokenizers # noqa: F401 + except Exception: # absent, ou install cassée + return False + return True + + +# --------------------------------------------------------------------------- +# Résolution du tokenizer.json +# --------------------------------------------------------------------------- + +def _snapshot_candidates() -> list[Path]: + """tokenizer.json des snapshots gpt2 du cache HF local, plus récent d'abord.""" + snapshots = Path.home() / ".cache" / "huggingface" / "hub" / "models--gpt2" / "snapshots" + try: + candidates = list(snapshots.glob("*/tokenizer.json")) + except OSError: + return [] + return sorted( + (p for p in candidates if p.is_file()), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + + +# --------------------------------------------------------------------------- +# Le tokenizer +# --------------------------------------------------------------------------- + +class Gpt2BpeTokenizer: + """Tokenizer BPE GPT-2 — ids exacts de fractus. + + Parameters + ---------- + tokenizer_json: + Chemin explicite d'un tokenizer.json GPT-2. None → résolution + automatique : snapshot du cache HF local, puis + ``Tokenizer.from_pretrained("gpt2")`` (réseau). + + Raises + ------ + FileNotFoundError + Source explicite absente, ou aucune source (cache vide + réseau + injoignable). + ImportError + La lib `tokenizers` n'est pas installée dans CE python. + """ + + def __init__(self, tokenizer_json: Path | None = None) -> None: + try: + from tokenizers import Tokenizer + except Exception as exc: # pragma: no cover - dépend de l'env + raise ImportError( + "la lib `tokenizers` est requise (pip install tokenizers)" + ) from exc + + if tokenizer_json is not None: + path = Path(tokenizer_json) + if not path.is_file(): + raise FileNotFoundError(f"tokenizer.json introuvable : {path}") + self._tok = Tokenizer.from_file(str(path)) + self.source = str(path) + else: + snapshots = _snapshot_candidates() + if snapshots: + self._tok = Tokenizer.from_file(str(snapshots[0])) + self.source = str(snapshots[0]) + else: + # Dernier recours : réseau (huggingface.co). Peut lever si + # hors-ligne — remonté comme FileNotFoundError (aucune source). + try: + self._tok = Tokenizer.from_pretrained("gpt2") + self.source = 'from_pretrained("gpt2")' + except Exception as exc: + raise FileNotFoundError( + "aucune source de tokenizer GPT-2 : pas de " + "tokenizer_json explicite, cache HF local vide, " + "et from_pretrained('gpt2') a échoué (réseau ?)" + ) from exc + + # Attributs publics — reflètent l'artefact réellement chargé. + self.vocab_size: int = self._tok.get_vocab_size(with_added_tokens=True) + eos = self._tok.token_to_id(_GPT2_EOS_TOKEN) + self.eos_token_id: int = _GPT2_EOS_TOKEN_ID if eos is None else int(eos) + + def encode(self, text: str) -> list[int]: + """Texte → ids BPE GPT-2 (aucun token spécial ajouté).""" + return list(self._tok.encode(text).ids) + + def decode(self, ids: list[int]) -> str: + """Ids → texte, SANS jamais lever. + + - Filtre les ids hors [0, vocab_size) et les non-entiers. + - Le décodeur byte-level remplace déjà les séquences UTF-8 + invalides par U+FFFD (équivalent errors="replace"). + - Ceinture + bretelles : si decode global échoue quand même, + repli id-par-id avec U+FFFD pour les ids fautifs. + """ + safe = [ + i for i in ids + if isinstance(i, int) and not isinstance(i, bool) and 0 <= i < self.vocab_size + ] + try: + return self._tok.decode(safe) + except Exception: + pass + parts: list[str] = [] + for i in safe: + try: + parts.append(self._tok.decode([i])) + except Exception: + parts.append("\ufffd") + return "".join(parts) diff --git a/fractus_vorax/model/cte_core.py b/fractus_vorax/model/cte_core.py new file mode 100644 index 0000000000000000000000000000000000000000..984ef1d8c19fd2b330f0b6b6f8987c914f37885a --- /dev/null +++ b/fractus_vorax/model/cte_core.py @@ -0,0 +1,946 @@ +"""fractus_vorax/model/cte_core.py — noyau CTE natif (Continuous Thought Engine, 1B). + +Implémentation fidèle du moteur CTE du checkpoint « FRACTUS_1B_PHASE2_FROZEN_MERGED » +(4,66 GB, 440 clés). Source de vérité : docs/heritage/cte-architecture.md (T1) — toute +affirmation ci-dessous cite le doc, qui cite lui-même fractus-cte (LECTURE SEULE, +jamais importé). Ce fichier vit dans fractus_vorax/model/ (gate torch, tests/test_gates.py). + +MAPPING sous-module ↔ clés du state_dict (doc §Table des clés : 8 + 16×27 = 440) : + + CteCore + ├── observe.weight (vocab, d_model) nn.Embedding, token SEUL + ├── blocks : ModuleList[n_layers] × CteBlock + │ ├── attn_S (batch, d, d) BUFFER porteur bloc-diagonal + │ ├── attn_z (batch, d) BUFFER porteur + │ ├── kuramoto_phases (batch, SEQ, N) BUFFER write-only + │ ├── attn : CteLinearAttention + │ │ ├── w_qkv (3,d,d) / b_qkv (3,d) / w_out (d,d) / b_out (d,) param x@W + │ │ ├── level_logits (n_levels,) param + │ │ └── level_offsets (n_levels,) BUFFER + │ ├── norm_attn.{weight,bias} + │ ├── kuramoto : KuramotoLayer (repris de fractus_core — sémantique + │ │ identique vérifiée, doc « Communs confirmés ») + │ │ └── omega / coupling_u / coupling_lambda + │ ├── norm_kur.{weight,bias} + │ ├── moe : PhaseRoutedMoEBatched (low-rang BATCHÉ, dim 0 = expert) + │ │ ├── U1 (E,F,r) / V1 (E,D,r) / U2 (E,D,r) / V2 (E,F,r) + │ │ ├── scale1 (E,1,1) / scale2 (E,1,1) / b1 (E,F) / b2 (E,D) + │ │ └── expert_phases (E,) BUFFER Farey + │ └── norm_moe.{weight,bias} + ├── last_lb_loss () BUFFER (métrique training) + ├── confidence_head.{weight,bias} nn.Linear(d, 1) lecture seule à l'inférence + ├── output_head.weight (vocab, d) nn.Linear LIÉE à observe + ├── salience_head.{weight,bias} nn.Linear(d, 1) lecture seule (mémoire absente) + └── thought_state (batch, 1, d) BUFFER porteur résiduel + +Décisions tranchées pour T2 (doc §Décisions tranchées) : +1. GELU = approximation TANH entre les facteurs U1/V1 et U2/V2 — PAS F.gelu + (leçon P3 ; le 88M utilisait F.gelu, le CTE non). +2. Orientation x@W pour qkv/w_out et le MoE low-rang (h@V puis @Uᵀ) ; + nn.Linear (x@Wᵀ) UNIQUEMENT pour output_head/confidence/salience. +3. output_head.weight = observe.weight (tied, les deux clés existent). +4. Forward = chemin tick_chunk_core EXCLUSIF ; génération = chunks de longueur 1 ; + tick_single INTERDIT (attention mono-niveau, mismatch train/gén connu). +5. κ_eff = 4.0/2.5 = 1.6 posé au chargement (temperature est un attribut runtime + ABSENT du checkpoint ; tous les trainers le fixent à 2.5 post-load). +6. États porteurs : thought_state ajouté à la position 0 du chunk, maj = h[:, -1:] + detaché ; attn_S/attn_z bloc-diagonaux par tête, récurrence INCLUSIVE, nouvel + état = moyenne sur les niveaux ; kuramoto_phases write-only (sa SHAPE compte + pour le strict load : (batch, SEQ_train=128, N), sa valeur sans effet). +7. Buffers construits en batch=config.batch pour from_checkpoint strict=True, + re-dimensionnables par reset_states(). +8. boost/ss_rate/lr/tokens_processed/frozen_merge/n : métadonnées sans effet forward. +""" + +from __future__ import annotations + +import math +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Optional, Tuple, Union + +import torch +import torch.nn as nn + +# Utilitaires partagés avec le noyau 88M — le doc confirme l'identité bit à bit +# des sémantiques (elu_plus_one, offsets de niveaux, Farey, Kuramoto RK4 +# dt=0.1/damping=0.01/n_steps=1 : section « Communs confirmés »). +from fractus_vorax.model.fractus_core import ( + KuramotoLayer, + elu_plus_one, + farey_expert_phases, + mandelbrot_level_offsets, +) + +TWO_PI = 2.0 * math.pi + +# --- Constantes de fidélité (doc §MoE batché, « TEMPÉRATURE = 2.5 — PIÈGE ») --- +MOE_KAPPA = 4.0 +# temperature est un attribut runtime (défaut 1.0) ABSENT du state_dict et du +# config : tous les trainers le fixent à 2.5 après chargement +# (fast4gpu_boost.py:99-102, fast4gpu_stage2_ss.py:32-34). L'oublier change +# toutes les gates → divergence silencieuse. On le pose en dur. +GATE_TEMPERATURE = 2.5 +KAPPA_EFF = MOE_KAPPA / GATE_TEMPERATURE # = 1.6 — ne jamais utiliser 4.0 direct + +# SEQ d'entraînement du stage 2 (fast4gpu_stage2_ss.py:39) : la dimension du +# milieu du buffer kuramoto_phases du checkpoint (2, 128, 16). +TRAIN_SEQ_LEN = 128 + + +def _gelu_tanh(x: torch.Tensor) -> torch.Tensor: + """GELU approximation TANH (moe.py:27-31) : 0.5x(1+tanh(√(2/π)(x+0.044715x³))). + + PAS torch.nn.functional.gelu (variante exacte du 88M) — écart max ~3e-3 par + activation, amplifié par 16 blocs (doc §MoE batché, décision 1). + """ + return 0.5 * x * (1.0 + torch.tanh( + math.sqrt(2.0 / math.pi) * (x + 0.044715 * x ** 3) + )) + + +# --------------------------------------------------------------------------- +# Sonde de checkpoint — quelle espèce de noyau dort dans ce fichier ? +# --------------------------------------------------------------------------- + +def probe_checkpoint_kind(path: Union[str, Path]) -> str: + """Sonde le type d'un checkpoint : ``"cte"`` | ``"fractus"`` | ``"unknown"``. + + - torch PARESSEUX à l'intérieur (philosophie gate), ``map_location="cpu"`` ; + - ``mmap=True`` d'abord : les tenseurs ne sont PAS matérialisés, seules + les clés du pickle sont lues (le 1B de 4,66 GB est sondé sans le + charger) ; repli ``torch.load`` ordinaire si mmap échoue pour CE + fichier (archive non-zipfile, torch trop vieux…) ; + - state_dict cherché sous « model_state »/« model »/« state_dict » + (même ordre que les deux ``from_checkpoint``), sinon dict nu ; + - marqueurs : ``{"observe.weight", "thought_state"}`` ⊆ clés → ``"cte"`` ; + ``"embed.tok_embed.weight"`` présente → ``"fractus"`` ; sinon + ``"unknown"`` (les appelants refusent l'attach, honnêtement) ; + - chemin absent → ``FileNotFoundError`` : les appelants (repl, core_speak) + dégradent en message propre, ils ne tracent pas. + """ + import torch # paresseux (philosophie gate) + + path = Path(path) + if not path.is_file(): + raise FileNotFoundError(f"checkpoint introuvable: {path}") + try: + ckpt = torch.load(path, map_location="cpu", weights_only=False, mmap=True) + except Exception: # mmap non supporté pour ce fichier — repli ordinaire + ckpt = torch.load(path, map_location="cpu", weights_only=False) + + if not isinstance(ckpt, dict): + return "unknown" + sd = None + for key in ("model_state", "model", "state_dict"): + cand = ckpt.get(key) + if isinstance(cand, dict): + sd = cand + break + if sd is None: + sd = ckpt # dict nu : state_dict sérialisé tel quel + keys = set(sd.keys()) + if "observe.weight" in keys and "thought_state" in keys: + return "cte" + if "embed.tok_embed.weight" in keys: + return "fractus" + return "unknown" + + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +@dataclass +class CteCoreConfig: + """Config du noyau CTE. + + Défauts = preset « tiny » pour les tests. Les valeurs du checkpoint réel + 1B FROZEN_MERGED (doc §0) : 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, train_seq_len=128 — + accessibles via `CteCoreConfig.real_1b()`. + + batch : dimension batch des états persistants (le trainer tournait en B=2 ; + à l'inférence la recette officielle travaille en batch 1 via reset_states(1)). + """ + + vocab_size: int = 50257 + d_model: int = 64 + n_heads: int = 4 + d_head: int = 16 + n_levels: int = 2 + n_oscillators: int = 4 + coupling_rank: int = 4 + n_experts: int = 4 + top_k: int = 2 + expert_d_ff: int = 128 + siren_rank: int = 16 + n_layers: int = 2 + batch: int = 1 + train_seq_len: int = TRAIN_SEQ_LEN + # Fenêtre de troncature gauche du prompt (vocal.verbalize, duck-typed avec + # FractusCoreConfig). Le moteur n'a PAS de pos_embed borné — la position + # vit dans le carry (S, z) de l'attention linéaire et generate digère un + # prompt d'une seule pièce : c'est un garde-fou de prompt, pas une + # contrainte du modèle (256 = défaut FractusCoreConfig). + max_seq_len: int = 256 + + def __post_init__(self) -> None: + if self.n_heads * self.d_head != self.d_model: + raise ValueError( + f"n_heads·d_head ({self.n_heads * self.d_head}) != " + f"d_model ({self.d_model})" + ) + if self.n_levels < 1: + raise ValueError("n_levels must be >= 1") + if self.n_experts < 1 or not 1 <= self.top_k <= self.n_experts: + raise ValueError("need n_experts >= 1 and 1 <= top_k <= n_experts") + if not 1 <= self.coupling_rank <= self.n_oscillators: + raise ValueError("need 1 <= coupling_rank <= n_oscillators") + if self.batch < 1 or self.train_seq_len < 1: + raise ValueError("need batch >= 1 and train_seq_len >= 1") + + @classmethod + def real_1b(cls) -> "CteCoreConfig": + """Preset du checkpoint FROZEN_MERGED (config sondé, doc §0).""" + return cls( + vocab_size=50257, + 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, + batch=2, + train_seq_len=TRAIN_SEQ_LEN, + ) + + +# --------------------------------------------------------------------------- +# Attention linéaire causale multi-niveaux (chemin chunk, avec carry) +# --------------------------------------------------------------------------- + +class CteLinearAttention(nn.Module): + """Attention linéaire causale multi-niveaux du CTE (attention.py). + + - Projections en convention x@W : q = einsum("bld,de->ble", h, w_qkv[0]) + (PAS nn.Linear x@Wᵀ — leçon P3, doc §Forward point 1). + - Feature map φ(x; level) = elu_plus_one(x + ω_level) sur q et k UNIQUEMENT, + v brut ; offsets ω_level = (φ²)^(−level) (buffer, [1.0, 0.3819660…]). + - Récurrence causale INCLUSIVE avec carry (S₀, z₀) : S_t = S₀ + Σ_{i≤t} k_i⊗v_i, + z_t = z₀ + Σ_{i≤t} k_i, y_t = (q_t·S_t)/(q_t·z_t), 0 si |dénom| < 1e-10 ; + retourne l'état final (S, z) UNIQUEMENT quand un carry est fourni. + + L'orchestration multi-niveaux + carry vit dans CteBlock.tick_chunk_core + (port exact de continuous_engine.py:139-217) — le forward autonome du module + d'origine (sans carry) n'est pas utilisé par le CTE et n'est pas reproduit. + """ + + def __init__(self, d_model: int, n_heads: int, d_head: int, n_levels: int = 2): + super().__init__() + if n_heads * d_head != d_model: + raise ValueError(f"n_heads·d_head ({n_heads * d_head}) != d_model ({d_model})") + if n_levels < 1: + raise ValueError("n_levels must be >= 1") + self.d_model = d_model + self.n_heads = n_heads + self.d_head = d_head + self.n_levels = n_levels + d_qkv = n_heads * d_head # = d_model + + scale = math.sqrt(2.0 / (d_model + d_qkv)) + self.w_qkv = nn.Parameter(torch.empty(3, d_model, d_qkv).uniform_(-scale, scale)) + self.b_qkv = nn.Parameter(torch.zeros(3, d_qkv)) + scale_out = math.sqrt(2.0 / (d_qkv + d_model)) + self.w_out = nn.Parameter(torch.empty(d_qkv, d_model).uniform_(-scale_out, scale_out)) + self.b_out = nn.Parameter(torch.zeros(d_model)) + self.level_logits = nn.Parameter(torch.zeros(n_levels)) + # Buffer persistant (clé du state_dict) — sondé [1.0, 0.3819660544395447]. + self.register_buffer("level_offsets", mandelbrot_level_offsets(n_levels)) + + def linear_attention_causal( + self, + q: torch.Tensor, + k: torch.Tensor, + v: torch.Tensor, + carry: Optional[Tuple[torch.Tensor, torch.Tensor]] = None, + ): + """q, k : (B, L, D) déjà φ-mappés ; v : (B, L, D) brut. + + Port exact de attention.py:129-187. Sans carry → y seul ; avec + carry=(S0, z0) → (y, (S_final, z_final)), l'état final incluant le carry. + """ + L = q.shape[1] + outer = torch.einsum("btp,btq->btpq", k, v) # k_i ⊗ v_i + mask = torch.tril(torch.ones(L, L, dtype=q.dtype, device=q.device)) + S = torch.einsum("tj,bjpq->btpq", mask, outer) # Σ_{i≤t} inclusif + z = torch.einsum("tj,bjp->btp", mask, k) + if carry is not None: + S0, z0 = carry + S = S + S0.unsqueeze(1) # le carry contribue à chaque position + z = z + z0.unsqueeze(1) + num = torch.einsum("btp,btpq->btq", q, S) + denom = (q * z).sum(dim=-1, keepdim=True) + safe = denom.abs() > 1e-10 + y = torch.where(safe, num / (denom + 1e-20), torch.zeros_like(num)) + if carry is not None: + return y, (S[:, -1], z[:, -1]) + return y + + +# --------------------------------------------------------------------------- +# MoE batché low-rang routé par phases (moe.py, mode low-rank batché) +# --------------------------------------------------------------------------- + +class PhaseRoutedMoEBatched(nn.Module): + """MoE low-rang BATCHÉ : un seul tenseur par famille, dim 0 = expert E. + + - Routage von Mises : θ̄ = atan2(Σ sin θ, Σ cos θ) ; gate_e ∝ + exp(κ_eff·cos(θ̄ − φ_e)) normalisées (uniforme si somme < 1e-10) ; + **κ_eff = KAPPA_EFF = 1.6** (4.0/2.5, décision 5 — constantes module). + - top_k=2 par valeur, gates retenues RENORMALISÉES sur les k retenus. + - Expert low-rang (orientation x@W partout, décision 2) : + h1 = scale1·(h @ V1) @ U1ᵀ + b1 ; h1_act = _gelu_tanh(h1) ; + out = scale2·(h1_act @ V2) @ U2ᵀ + b2. + - Dispatch adaptatif exact de l'original : sparse gather-first si + E > 2·top_k (toujours vrai pour E=128, K=2), dense sinon (tiny E=4, K=2) + — bit-identiques (moe.py:8-17). + - Load-balance (métrique) : lb = E·Σ_e (P_e − 1/E)², P = gates.mean((0,1)). + """ + + def __init__( + self, + d_model: int, + n_experts: int, + top_k: int, + d_ff: int, + expert_rank: int, + ): + super().__init__() + if n_experts < 1: + raise ValueError("n_experts >= 1") + if not 1 <= top_k <= n_experts: + raise ValueError(f"top_k must be in [1, {n_experts}]") + if expert_rank < 1: + raise ValueError("expert_rank must be >= 1") + self.d_model = d_model + self.n_experts = n_experts + self.top_k = top_k + self.d_ff = d_ff + self.expert_rank = expert_rank + # κ_eff fixé à la constante de fidélité — PAS configurable pour éviter + # la divergence silencieuse (l'original expose kappa/temperature en + # attributs runtime ; les trainers posent 2.5 post-load, doc §MoE). + self.kappa_eff = KAPPA_EFF + + # Buffer persistant (clé du state_dict) — régénérable (Farey), écart + # sondé max 6.4e-07 vs recompute ; la valeur du checkpoint écrase. + self.register_buffer("expert_phases", farey_expert_phases(n_experts)) + + r = expert_rank + su1 = math.sqrt(2.0 / (d_ff + r)) + sv1 = math.sqrt(2.0 / (d_model + r)) + su2 = math.sqrt(2.0 / (d_model + r)) + sv2 = math.sqrt(2.0 / (d_ff + r)) + self.U1 = nn.Parameter(torch.empty(n_experts, d_ff, r).uniform_(-su1, su1)) + self.V1 = nn.Parameter(torch.empty(n_experts, d_model, r).uniform_(-sv1, sv1)) + self.U2 = nn.Parameter(torch.empty(n_experts, d_model, r).uniform_(-su2, su2)) + self.V2 = nn.Parameter(torch.empty(n_experts, d_ff, r).uniform_(-sv2, sv2)) + self.scale1 = nn.Parameter(torch.ones(n_experts, 1, 1)) + self.scale2 = nn.Parameter(torch.ones(n_experts, 1, 1)) + self.b1 = nn.Parameter(torch.zeros(n_experts, d_ff)) + self.b2 = nn.Parameter(torch.zeros(n_experts, d_model)) + + def _compute_gates(self, phases: torch.Tensor) -> torch.Tensor: + """phases (B, L, N) → gates (B, L, E) normalisées (von Mises, κ_eff).""" + sin_p = torch.sin(phases).sum(dim=-1) + cos_p = torch.cos(phases).sum(dim=-1) + theta_bar = torch.atan2(sin_p, cos_p) + diff = theta_bar.unsqueeze(-1) - self.expert_phases.view( + *[1] * (phases.dim() - 1), self.n_experts + ) + gates = torch.exp(self.kappa_eff * torch.cos(diff)) + gates_sum = gates.sum(dim=-1, keepdim=True) + uniform = torch.full_like(gates, 1.0 / self.n_experts) + return torch.where(gates_sum > 1e-10, gates / gates_sum, uniform) + + def _sparse_expert_forward( + self, h: torch.Tensor, topk_idx: torch.Tensor + ) -> torch.Tensor: + """GATHER-FIRST (moe.py:193-238) : ne calcule que les top_k experts. + + h (B, L, D), topk_idx (B, L, K) → sorties par expert retenu (B, L, K, D). + """ + B, L, D = h.shape + K = topk_idx.shape[-1] + r = self.expert_rank + flat_idx = topk_idx.reshape(-1) + + g_U1 = self.U1.index_select(0, flat_idx).reshape(B * L, K, self.d_ff, r) + g_V1 = self.V1.index_select(0, flat_idx).reshape(B * L, K, D, r) + g_s1 = self.scale1.index_select(0, flat_idx).reshape(B * L, K, 1, 1) + g_b1 = self.b1.index_select(0, flat_idx).reshape(B * L, K, self.d_ff) + g_U2 = self.U2.index_select(0, flat_idx).reshape(B * L, K, D, r) + g_V2 = self.V2.index_select(0, flat_idx).reshape(B * L, K, self.d_ff, r) + g_s2 = self.scale2.index_select(0, flat_idx).reshape(B * L, K, 1, 1) + g_b2 = self.b2.index_select(0, flat_idx).reshape(B * L, K, D) + + N = B * L + h_flat = h.reshape(N, D) + # Couche 1 : h1 = scale1·(h@V1)@U1ᵀ + b1 — x@W, scale AVANT le bias. + hV1 = torch.einsum("nd,nkdr->nkr", h_flat, g_V1) + h1 = g_s1.squeeze(-1) * torch.einsum("nkr,nkfr->nkf", hV1, g_U1) + g_b1 + h1_act = _gelu_tanh(h1) + # Couche 2 : out = scale2·(h1_act@V2)@U2ᵀ + b2. + hV2 = torch.einsum("nkf,nkfr->nkr", h1_act, g_V2) + out = g_s2.squeeze(-1) * torch.einsum("nkr,nkdr->nkd", hV2, g_U2) + g_b2 + return out.reshape(B, L, K, D) + + def _dense_expert_forward(self, h: torch.Tensor) -> torch.Tensor: + """Dense (moe.py:271-284) : tous les E experts — (B, L, E, D).""" + hV1 = torch.einsum("bld,edr->bler", h, self.V1) + h1 = ( + self.scale1.view(1, 1, self.n_experts, 1) + * torch.einsum("bler,efr->blef", hV1, self.U1) + + self.b1.view(1, 1, self.n_experts, self.d_ff) + ) + h1_act = _gelu_tanh(h1) + hV2 = torch.einsum("blef,efr->bler", h1_act, self.V2) + out = ( + self.scale2.view(1, 1, self.n_experts, 1) + * torch.einsum("bler,edr->bled", hV2, self.U2) + + self.b2.view(1, 1, self.n_experts, self.d_model) + ) + return out + + def forward( + self, h: torch.Tensor, phases: torch.Tensor + ) -> Tuple[torch.Tensor, torch.Tensor]: + """h (B, L, D), phases (B, L, N) → (output (B, L, D), lb_loss scalaire).""" + B, L, D = h.shape + gates = self._compute_gates(phases) + + topk_vals, topk_idx = gates.topk(self.top_k, dim=-1) + topk_sum = topk_vals.sum(dim=-1, keepdim=True) + uniform_topk = torch.full_like(topk_vals, 1.0 / self.top_k) + topk_vals_norm = torch.where( + topk_sum > 1e-10, topk_vals / topk_sum, uniform_topk + ) + + # Dispatch adaptatif exact de l'original : sparse si E > 2·K + # (E=128, K=2 → sparse ; tiny E=4, K=2 → dense — bit-identiques). + if self.n_experts > 2 * self.top_k: + topk_out = self._sparse_expert_forward(h, topk_idx) + else: + all_out = self._dense_expert_forward(h) + idx_exp = topk_idx.unsqueeze(-1).expand(-1, -1, -1, self.d_model) + topk_out = torch.gather(all_out, dim=2, index=idx_exp) + + output = (topk_vals_norm.unsqueeze(-1) * topk_out).sum(dim=2) + + P = gates.mean(dim=(0, 1)) + lb_loss = self.n_experts * ((P - 1.0 / self.n_experts) ** 2).sum() + return output, lb_loss + + +# --------------------------------------------------------------------------- +# Bloc CTE (port exact de CTEBlock.tick_chunk_core) +# --------------------------------------------------------------------------- + +class CteBlock(nn.Module): + """Un bloc du moteur : norm_attn → attention multi-niveaux avec carry → + résidu → norm_kur → RK4 (stateless) → norm_moe → MoE (phases de la dernière + position) → résidu. Propriétaire de ses buffers porteurs attn_S/attn_z et + du snapshot write-only kuramoto_phases. + """ + + def __init__(self, config: CteCoreConfig): + super().__init__() + self.attn = CteLinearAttention( + config.d_model, config.n_heads, config.d_head, config.n_levels + ) + self.norm_attn = nn.LayerNorm(config.d_model) + # Kuramoto STATELESS : RK4 n_steps=1, dt=0.1, damping=0.01 + # (continuous_engine.py:52-53) — KuramotoLayer de fractus_core est + # identique (doc « Communs confirmés »). + self.kuramoto = KuramotoLayer( + config.d_model, + n_oscillators=config.n_oscillators, + rank=config.coupling_rank, + n_steps=1, + dt=0.1, + damping=0.01, + ) + self.norm_kur = nn.LayerNorm(config.d_model) + self.moe = PhaseRoutedMoEBatched( + d_model=config.d_model, + n_experts=config.n_experts, + top_k=config.top_k, + d_ff=config.expert_d_ff, + expert_rank=config.siren_rank, + ) + self.norm_moe = nn.LayerNorm(config.d_model) + + n_h_dh = config.n_heads * config.d_head + # États porteurs batchés (shapes du checkpoint pour le strict load). + self.register_buffer("attn_S", torch.zeros(config.batch, n_h_dh, n_h_dh)) + self.register_buffer("attn_z", torch.zeros(config.batch, n_h_dh)) + # write-only : le 128 du milieu = SEQ d'entraînement (exigence strict load) + self.register_buffer( + "kuramoto_phases", + torch.zeros(config.batch, config.train_seq_len, config.n_oscillators), + ) + + def reset_state(self, batch_size: int = 1) -> None: + """Zero les états de CE bloc (continuous_engine.py:73-80) — + kuramoto_phases re-dimensionné en (batch, 1, N).""" + device = self.attn_S.device + d = self.attn.n_heads * self.attn.d_head + self.attn_S = torch.zeros(batch_size, d, d, device=device) + self.attn_z = torch.zeros(batch_size, d, device=device) + self.kuramoto_phases = torch.zeros( + batch_size, 1, self.kuramoto.N, device=device + ) + + def tick_chunk_core(self, h: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: + """Port exact de continuous_engine.py:139-217 — le SEUL chemin autorisé. + + h (B, C, d_model) → (h_transformé, lb_loss). Carry (S, z) à travers les + frontières de chunks (pensée continue). + """ + B, C, _D_model = h.shape + attn = self.attn + nH, dH, nL = attn.n_heads, attn.d_head, attn.n_levels + + # 1. Attention pré-norm + projections x@W (:149-152). + h_normed = self.norm_attn(h) + q_all = torch.einsum("bld,de->ble", h_normed, attn.w_qkv[0]) + attn.b_qkv[0] + k_all = torch.einsum("bld,de->ble", h_normed, attn.w_qkv[1]) + attn.b_qkv[1] + v_all = torch.einsum("bld,de->ble", h_normed, attn.w_qkv[2]) + attn.b_qkv[2] + q_all = q_all.view(B, C, nH, dH) + k_all = k_all.view(B, C, nH, dH) + v_all = v_all.view(B, C, nH, dH) + + # 2. Offsets par niveau sur q/k + feature map (v brut) (:157-162). + offsets = attn.level_offsets + q_lev = q_all.unsqueeze(1) + offsets.view(nL, 1, 1, 1) + k_lev = k_all.unsqueeze(1) + offsets.view(nL, 1, 1, 1) + q_feat = elu_plus_one(q_lev, alpha=1.0) + k_feat = elu_plus_one(k_lev, alpha=1.0) + v_lev = v_all.unsqueeze(1).expand(B, nL, C, nH, dH) + # Aplat multi-niveaux×têtes : (B·nL·nH, C, dH) (:163-165). + q_flat = q_feat.permute(0, 1, 3, 2, 4).reshape(B * nL * nH, C, dH) + k_flat = k_feat.permute(0, 1, 3, 2, 4).reshape(B * nL * nH, C, dH) + v_flat = v_lev.permute(0, 1, 3, 2, 4).reshape(B * nL * nH, C, dH) + + # 3. Carry (S, z) : blocs diagonaux par tête, expansion sur les niveaux + # (:168-179) — les états sont tranchés à B (B ≤ batch des états). + state_S = self.attn_S[:B] + state_z = self.attn_z[:B] + carry_S_per_head = torch.stack( + [ + state_S[:, hd * dH:(hd + 1) * dH, hd * dH:(hd + 1) * dH] + for hd in range(nH) + ], + dim=1, + ) + carry_z_per_head = torch.stack( + [state_z[:, hd * dH:(hd + 1) * dH] for hd in range(nH)], dim=1 + ) + carry_S_flat = carry_S_per_head.unsqueeze(1).expand( + B, nL, nH, dH, dH + ).reshape(B * nL * nH, dH, dH) + carry_z_flat = carry_z_per_head.unsqueeze(1).expand( + B, nL, nH, dH + ).reshape(B * nL * nH, dH) + + # 4. Attention linéaire causale INCLUSIVE avec carry (attention.py:129-187). + y_flat, (S_final, z_final) = attn.linear_attention_causal( + q_flat, k_flat, v_flat, carry=(carry_S_flat, carry_z_flat) + ) + + # 5. Nouvel état : moyenne sur les niveaux + reconstruction + # bloc-diagonale + detach (:184-193). + S_reshaped = S_final.reshape(B, nL, nH, dH, dH).mean(dim=1) + z_reshaped = z_final.reshape(B, nL, nH, dH).mean(dim=1) + new_S = torch.zeros(B, nH * dH, nH * dH, device=h.device, dtype=h.dtype) + new_z = torch.zeros(B, nH * dH, device=h.device, dtype=h.dtype) + for hd in range(nH): + new_S[:, hd * dH:(hd + 1) * dH, hd * dH:(hd + 1) * dH] = S_reshaped[:, hd] + new_z[:, hd * dH:(hd + 1) * dH] = z_reshaped[:, hd] + self.attn_S = new_S.detach() + self.attn_z = new_z.detach() + + # 6. Agrégation softmax(level_logits) + projection x@W + résidu (:195-199). + y = y_flat.reshape(B, nL, nH, C, dH).permute(0, 1, 3, 2, 4).reshape( + B, nL, C, nH * dH + ) + level_weights = torch.softmax(attn.level_logits, dim=-1) + attn_out = (y * level_weights.view(1, nL, 1, 1)).sum(dim=1) + attn_out = attn_out @ attn.w_out + attn.b_out + h = h + attn_out + + # 7. Kuramoto pré-norm, STATELESS par forward (:203-206) : phases + # initiales dérivées du hidden, RK4, snapshot write-only. + h_kur = self.norm_kur(h) + theta = self.kuramoto(h_kur) + self.kuramoto_phases = theta.detach() + + # 8. MoE pré-norm : phases de la DERNIÈRE position broadcastées (:211-215). + h_moe = self.norm_moe(h) + phases_last = theta[:, -1:, :] + phases_in = phases_last.expand(-1, C, -1) + moe_out, lb_loss = self.moe(h_moe, phases_in) + h = h + moe_out + + return h, lb_loss + + +# --------------------------------------------------------------------------- +# Moteur +# --------------------------------------------------------------------------- + +class CteCore(nn.Module): + """Continuous Thought Engine natif : token_ids (B, L) → logits (B, L, vocab). + + Arbre des modules EXACTEMENT conforme au doc §Table des clés pour un + `load_state_dict(strict=True)` sur les 440 clés du FROZEN_MERGED sans + aucune retouche. output_head liée à observe. Forward = tick_chunk + EXCLUSIF (décision 4) ; generate suit la recette d'inférence du doc + (reset complet batch 1 → UN chunk prompt → boucle chunks de longueur 1). + """ + + def __init__(self, config: CteCoreConfig): + super().__init__() + self.config = config + self.vocab_size = config.vocab_size + self.d_model = config.d_model + + # Embedding de token SEUL — pas de position, pas de norm d'embedding : + # la position vient du carry (S, z) de l'attention linéaire (doc §Forward). + self.observe = nn.Embedding(config.vocab_size, config.d_model) + + self.blocks = nn.ModuleList([CteBlock(config) for _ in range(config.n_layers)]) + + # Accumulateur load-balance (métrique training, sans effet forward). + self.register_buffer("last_lb_loss", torch.tensor(0.0)) + + # Têtes : nn.Linear x@Wᵀ (décision 2) — lectures seules à l'inférence + # (generate_aligned n'utilise ni confidence ni salience). + self.confidence_head = nn.Linear(config.d_model, 1) + self.output_head = nn.Linear(config.d_model, config.vocab_size, bias=False) + self.output_head.weight = self.observe.weight # LIÉE (décision 3) + self.salience_head = nn.Linear(config.d_model, 1) + + # Flux résiduel porteur partagé, continu à travers chunks. + self.register_buffer( + "thought_state", torch.zeros(config.batch, 1, config.d_model) + ) + + # ------------------------------------------------------------------ + # États porteurs + # ------------------------------------------------------------------ + + def reset_states(self, batch_size: int = 1) -> None: + """Reset inter-séquences (recette d'inférence, étape 1 ; port exact de + reset_thought continuous_engine.py:336-341) : thought_state + attn_S/ + attn_z/kuramoto_phases de chaque bloc → zéros en batch `batch_size`. + + last_lb_loss n'est PAS réinitialisée (métrique training, sans effet + forward — doc §États porteurs). + """ + self.thought_state = torch.zeros( + batch_size, 1, self.d_model, device=self.thought_state.device + ) + for blk in self.blocks: + blk.reset_state(batch_size) + + # ------------------------------------------------------------------ + # Forward — chemin tick_chunk EXCLUSIF (décision 4) + # ------------------------------------------------------------------ + + def forward(self, token_ids: torch.Tensor) -> torch.Tensor: + """token_ids (B, L) → logits (B, L, vocab_size). + + Port exact de tick_chunk (continuous_engine.py:458-475) : le porteur + thought_state n'est ajouté qu'à la position 0 du chunk ; il devient + h[:, -1:, :] du dernier bloc. B doit être ≤ le batch des états porteurs + (les états sont tranchés à B puis deviennent B-sized) — sinon appeler + reset_states(B) d'abord. + """ + if token_ids.dim() != 2: + raise ValueError(f"token_ids attendu (B, L), reçu {tuple(token_ids.shape)}") + B, C = token_ids.shape + if C < 1: + raise ValueError("chunk vide (L=0) interdit") + if B > self.thought_state.shape[0]: + raise ValueError( + f"B={B} > batch des états porteurs ({self.thought_state.shape[0]}) " + "— appeler reset_states(B) d'abord (les états sont batchés)" + ) + + obs_vecs = self.observe(token_ids) + h = obs_vecs.clone() + # Le flux résiduel porté n'est ajouté QU'à la position 0 du chunk. + h[:, 0, :] = h[:, 0, :] + self.thought_state[:B, 0, :] + + total_lb = torch.tensor(0.0, device=h.device) + for blk in self.blocks: + h, lb = blk.tick_chunk_core(h) + total_lb = total_lb + lb.detach() + self.last_lb_loss = total_lb + + # Le dernier h devient le nouveau porteur. + self.thought_state = h[:, -1:, :].detach() + # PAS de LayerNorm finale — output_head s'applique directement au h. + return self.output_head(h) + + # ------------------------------------------------------------------ + # Génération — recette d'inférence officielle (doc §Recette d'inférence) + # ------------------------------------------------------------------ + + @torch.no_grad() + def generate( + self, + token_ids: torch.Tensor, + max_new_tokens: int = 32, + temperature: float = 0.0, + eos_token_id: Optional[int] = None, + seed: int = 0, + ) -> torch.Tensor: + """Génération auto-régressive batch 1, train-aligned (generate_aligned). + + Recette : eval + reset_states(1) complet ; UN SEUL chunk pour le prompt + (logits[0, -1]) ; boucle token par token en chunks de LONGUEUR 1 — les + états porteurs vivent à travers la boucle, jamais de reset intermédiaire, + jamais tick_single. temperature=0 (défaut) → greedy déterministe (argmax) ; + sinon multinomial sur softmax(logits/T) avec torch.Generator seedé. + eos_token_id : append-then-break (sémantique ratifiée P4) — l'EOS clôt + la séquence, aucun token n'est consommé après lui. + """ + if token_ids.dim() != 2 or token_ids.shape[0] != 1: + raise ValueError( + "generate travaille en batch 1 (recette d'inférence) — " + f"token_ids (1, L), reçu {tuple(token_ids.shape)}" + ) + self.eval() + self.reset_states(1) + + ids = token_ids.clone() + logits = self(ids) # le prompt en UN SEUL chunk + cur = logits[0, -1] + + gen = None + if temperature > 0.0: + gen = torch.Generator(device=ids.device) + gen.manual_seed(seed) + + for _ in range(max_new_tokens): + if temperature <= 0.0: + next_tok = cur.argmax(dim=-1) # greedy déterministe + else: + probs = torch.softmax(cur / temperature, dim=-1) + next_tok = torch.multinomial(probs, 1, generator=gen) + ids = torch.cat([ids, next_tok.view(1, 1)], dim=1) # append-then-break + if eos_token_id is not None and int(next_tok.item()) == eos_token_id: + break + logits = self(next_tok.view(1, 1)) # chunk de longueur 1 + cur = logits[0, -1] + return ids + + # ------------------------------------------------------------------ + # Chargement strict de checkpoint + # ------------------------------------------------------------------ + + @classmethod + def from_checkpoint( + cls, path: Union[str, Path], map_location: str = "cpu" + ) -> "CteCore": + """Charge un checkpoint CTE en STRICT (jamais strict=False). + + - torch.load(weights_only=False) — fichier local de confiance. mmap + d'abord (même pattern que probe_checkpoint_kind) : les tenseurs du + state_dict restent adossés au FICHIER (zéro commit RAM), puis + load_state_dict(assign=True) — les POIDS du 1B de 4,66 GB ne sont + jamais copiés en RAM privée (chargeable sur ~12 GB de RAM) ; repli + copie ordinaire si mmap échoue pour CE fichier (archive + non-zipfile, torch trop vieux…). + - state_dict cherché sous « model_state » (format FROZEN_MERGED), puis + « model », « state_dict », sinon dict nu — marqueurs CTE : + observe.weight + thought_state. + - Config inférée des shapes (règles dans `_infer_config`) ; le dict + top-level « config » du checkpoint (attributs non encodables dans les + shapes : n_heads/d_head, top_k) sert d'indice quand il existe. + - Pas de reset post-chargement : les états du checkpoint (batch 2) sont + conservés tels quels ; la recette d'inférence appelle reset_states(1). + """ + mmap_ok = True + try: + ckpt = torch.load( + path, map_location=map_location, weights_only=False, mmap=True + ) + except Exception: # mmap non supporté pour ce fichier — repli ordinaire + mmap_ok = False + ckpt = torch.load(path, map_location=map_location, weights_only=False) + sd: Optional[Dict[str, torch.Tensor]] = None + hint: dict = {} + if isinstance(ckpt, dict): + for key in ("model_state", "model", "state_dict"): + cand = ckpt.get(key) + if isinstance(cand, dict) and "observe.weight" in cand \ + and "thought_state" in cand: + sd = cand + break + if sd is None and "observe.weight" in ckpt and "thought_state" in ckpt: + sd = ckpt # state_dict nu + if isinstance(ckpt.get("config"), dict): + hint = ckpt["config"] + if sd is None: + raise ValueError( + f"{path}: aucune clé 'model_state'/'model'/'state_dict' ni " + "state_dict CTE détectable au niveau supérieur " + "(marqueurs : observe.weight + thought_state)" + ) + config = cls._infer_config(sd, hint) + if mmap_ok: + # Construction sur MÉTA (zéro stockage) : les poids réels ne vivent + # que comme vues mmap du fichier — le pic de RAM privée tombe de + # ~8,9 Go (copie classique) à ~0,5 Go (buffers clonés + activations). + # Idiome standard torch (accélérateurs/FSDP) : meta puis assign. + with torch.device("meta"): + model = cls(config) + else: + model = cls(config) + # Garde de SHAPES restaurée (revue T2) : ``assign=True`` REMPLACE les + # tenseurs au lieu d'y copier, ce qui court-circuite la validation des + # shapes de load_state_dict — un state_dict aux shapes dérivées + # s'attacherait sans erreur. Chaque clé doit exister des deux côtés + # avec la même shape, AVANT l'assign : mismatch -> RuntimeError. + expected = {k: tuple(t.shape) for k, t in model.state_dict().items()} + bad = [ + f"{k}: absent du checkpoint (modèle attend {shape})" + if k not in sd else + f"{k}: checkpoint {tuple(sd[k].shape)} vs modèle {shape}" + for k, shape in expected.items() + if k not in sd or tuple(sd[k].shape) != shape + ] + bad += [ + f"{k}: clé checkpoint absente du modèle (shape {tuple(sd[k].shape)})" + for k in sd + if k not in expected + ] + if bad: + raise RuntimeError( + "shapes incompatibles checkpoint/modèle (garde restaurée : " + "assign=True ne valide pas les shapes) — " + "; ".join(bad[:8]) + ) + result = model.load_state_dict(sd, strict=True, assign=mmap_ok) + missing = list(result.missing_keys) + unexpected = list(result.unexpected_keys) + if missing or unexpected: + raise RuntimeError( + f"strict load incomplet — manquantes: {missing[:8]} " + f"inattendues: {unexpected[:8]}" + ) + if mmap_ok: + # Poids ASSIGNÉS : les paramètres restent adossés au FICHIER (vues + # mmap lecture seule) — zéro copie privée (~4,2 Go économisés sur le + # 1B : le cerveau tient sur une machine à ~12 Go de RAM) et le pacte + # rendu PHYSIQUE : aucune écriture in-place ne peut toucher les + # poids du fichier. Seuls les états porteurs (buffers, ~215 Mo sur + # le 1B) doivent redevenir écrivables : la récurrence forward met à + # jour attn_S/attn_z/thought_state in-place. + with torch.no_grad(): + for buf in model.buffers(): + buf.set_(buf.detach().clone()) + # restaurer la tête liée (décision 3 : output_head.weight EST + # observe.weight) — assign a détaché les deux clés jumelles. + model.output_head.weight = model.observe.weight + model.eval() + return model + + @staticmethod + def _default_head_split(d_model: int) -> Tuple[int, int]: + """Split par défaut quand NI les shapes NI le config ne l'encodent. + + w_qkv est cubique (3, d, d) : le split n_heads×d_head est invisible dans + le state_dict. Heuristique : plus grand d_head ∈ {64, 32, 16, 8, 4, 2, 1} + divisant d_model avec au moins 4 têtes (d_head ≤ d_model/4) — retombe sur + les presets connus : 1280 → 20×64 (réel CTE), 768 → 12×64 (88M), + 64 → 4×16 (tiny). Dernier recours : 1 tête. + """ + for cand in (64, 32, 16, 8, 4, 2, 1): + if d_model % cand == 0 and cand * 4 <= d_model: + return d_model // cand, cand + return d_model, 1 + + @staticmethod + def _infer_config(sd: Dict[str, torch.Tensor], hint: Optional[dict] = None) -> CteCoreConfig: + """Règles d'inférence de la config depuis les shapes du state_dict : + + - vocab_size, d_model : shape de `observe.weight` (vocab, d) ; + - batch : shape de `thought_state` (batch, 1, d) ; + - train_seq_len : shape de `blocks.0.kuramoto_phases` (batch, SEQ, N) ; + - n_layers : 1 + max indice des clés `blocks.(i).…` ; + - n_levels : shape de `blocks.0.attn.level_logits` ; + - n_experts : shape de `blocks.0.moe.expert_phases` (E,) ; + - expert_d_ff, siren_rank : dims de `blocks.0.moe.U1` (E, F, r) ; + - n_oscillators : shape de `blocks.0.kuramoto.omega` ; + - coupling_rank : shape de `blocks.0.kuramoto.coupling_lambda` ; + - n_heads/d_head : NON encodés (w_qkv cubique) → indice `config` + du checkpoint si cohérent (n_heads·d_head == d_model), sinon + heuristique `_default_head_split` ; + - top_k : non encodé → indice `config`, sinon 2 + (jamais surchargé à l'entraînement). + """ + hint = hint or {} + vocab_size, d_model = (int(v) for v in sd["observe.weight"].shape) + batch = int(sd["thought_state"].shape[0]) + block_ids = [ + int(m.group(1)) for k in sd if (m := re.match(r"^blocks\.(\d+)\.", k)) + ] + if not block_ids: + raise ValueError("state_dict sans clés 'blocks.(i).…' — pas un CTE") + n_layers = 1 + max(block_ids) + train_seq_len = int(sd["blocks.0.kuramoto_phases"].shape[1]) + n_levels = int(sd["blocks.0.attn.level_logits"].shape[0]) + n_experts = int(sd["blocks.0.moe.expert_phases"].shape[0]) + expert_d_ff = int(sd["blocks.0.moe.U1"].shape[1]) + siren_rank = int(sd["blocks.0.moe.U1"].shape[2]) + n_oscillators = int(sd["blocks.0.kuramoto.omega"].shape[0]) + coupling_rank = int(sd["blocks.0.kuramoto.coupling_lambda"].shape[0]) + top_k = hint.get("top_k", 2) + if not isinstance(top_k, int) or not 1 <= top_k <= n_experts: + top_k = 2 + + n_heads = hint.get("n_heads") + d_head = hint.get("d_head") + if not ( + isinstance(n_heads, int) and isinstance(d_head, int) + and n_heads * d_head == d_model + ): + n_heads, d_head = CteCore._default_head_split(d_model) + + return CteCoreConfig( + vocab_size=vocab_size, + d_model=d_model, + n_heads=n_heads, + d_head=d_head, + n_levels=n_levels, + n_oscillators=n_oscillators, + coupling_rank=coupling_rank, + n_experts=n_experts, + top_k=top_k, + expert_d_ff=expert_d_ff, + siren_rank=siren_rank, + n_layers=n_layers, + batch=batch, + train_seq_len=train_seq_len, + ) diff --git a/fractus_vorax/model/diagnostics.py b/fractus_vorax/model/diagnostics.py new file mode 100644 index 0000000000000000000000000000000000000000..690b05d9eb10d4149c3c8c9a211d775b6aaf1c85 --- /dev/null +++ b/fractus_vorax/model/diagnostics.py @@ -0,0 +1,281 @@ +"""fractus_vorax/model/diagnostics.py — l'observabilité à ciel ouvert (Plan 6, tâche 2). + +Lire le cerveau scellé SANS le toucher : quels experts tirent (routage von +Mises), que disent les têtes confiance/salience. Zéro gradient, zéro +modification du noyau — ``cte_core.py`` n'est NI modifié NI patché : la +contrainte de la tâche (« recalcul extérieur : zéro changement du noyau ») +est respectée à la lettre — hooks et réplication seulement. + +APPROCHE (décision de la tâche, notes de fidélité) : + +- **expert_routing_snapshot** — un forward hook par bloc sur son sous-module + ``moe`` capture le couple ``(h_moe, phases)`` RÉELLEMENT consommé par la + passe MoE du forward ; les gates sont recalculées depuis ces phases + exactes via la propre méthode ``_compute_gates`` du module (appel en + LECTURE SEULE — bit-exact par construction : même tenseur, même code + chemin), puis top-k renormalisé comme dans son ``forward``. Le snapshot + rapporte donc les ids/poids EXACTS du routage qui a produit les logits — + PAS une approximation. Fidélité : + * phases CTE = DERNIÈRE position du chunk broadcastée sur tout le chunk + (doc §Forward point 8) → gates identiques à toutes les positions ; on + rapporte la position -1 (la pensée courante). FractusCore : phases par + position → position -1 aussi. + * gardes dégénérées répliquées à l'identique : gates somme < 1e-10 → + uniforme 1/E ; top-k somme < 1e-10 → uniforme 1/K. + * si un moe duck-typé n'expose pas ``_compute_gates``, repli sur la + réplication inline de la formule von Mises (θ̄ = atan2(Σ sin θ, + Σ cos θ) ; gate_e ∝ exp(κ·cos(θ̄ − φ_e)) — κ_eff CTE via + ``module.kappa_eff``, κ direct fractus via ``module.kappa`` ; doc §MoE + batché et §Divergences). + * batch élément 0 rapporté (les deux noyaux parlent en batch 1). +- **head_readout** — les têtes ``confidence_head``/``salience_head`` du CTE + ne sont PAS invoquées par ``CteCore.forward`` (lectures seules à + l'inférence, doc §Forward « Rôle de observe ») : un hook posé SUR ELLES + ne tirerait jamais. On capture donc le hidden final ``h`` via un forward + hook sur ``output_head`` (son ENTRÉE est exactement le h qui produit les + logits — aucune approximation), puis on évalue les deux têtes soi-même : + ``sigmoid(confidence_head(h[0, -1]))`` et ``sigmoid(salience_head(h[0, + -1]))`` — la formule du tick() original (continuous_engine.py:437-456, + source heritage LECTURE SEULE : sigmoid sur ``h[:, 0, :]`` d'un chunk de + longueur 1 — sous la recette officielle d'inférence, position 0 == + position -1 ; pour un chunk long on lit -1, la pensée courante qui + prédit le token suivant, généralisation naturelle du chemin officiel). + Borné [0, 1) par construction (sigmoid). FractusCore (pas de têtes, + lm_head seule) → ``{"confidence": None, "salience": None}``, sans lancer + le moindre forward. + +CONVENTION D'ÉTATS (même choix que speak(), documenté) : la lecture forward +MUTE les états porteurs (attn_S/attn_z/thought_state) — c'est inhérent au +chemin tick_chunk. Pour des diagnostics REPRODUCTIBLES (contrainte du +plan), ``reset_states(1)`` est appelé au départ quand le noyau l'expose. +Un noyau mid-génération sera donc réinitialisé : introspecter AVANT de +parler, ou assumer le reset. + +GATE torch : ce fichier vit dans fractus_vorax/model/ (zone exemptée par +tests/test_gates.py) et importe torch au niveau module, comme cte_core/ +speak — jamais importé par le substrat mingw (tests importorskip). +""" + +from __future__ import annotations + +from typing import Dict, List, Optional, Sequence, Tuple, Union + +import torch + +TokenIds = Union[torch.Tensor, Sequence[int]] + + +# --------------------------------------------------------------------------- +# Aides privées +# --------------------------------------------------------------------------- + +def _core_device(core) -> torch.device: + """Device des paramètres du noyau (cpu si aucun paramètre/attribut).""" + try: + return next(core.parameters()).device + except (StopIteration, AttributeError): + return torch.device("cpu") + + +def _as_batch_ids(core, token_ids: TokenIds) -> torch.Tensor: + """tensor (B, L)|(L,) | list[int] → tensor long (B, L). + + Liste/tenseur 1-D → batch 1. Vide → ValueError (chunk vide interdit, + même contrat que CteCore.forward). + """ + if isinstance(token_ids, torch.Tensor): + ids = token_ids.to(dtype=torch.long) + if ids.dim() == 1: + ids = ids.unsqueeze(0) + if ids.dim() != 2: + raise ValueError( + f"token_ids attendu (B, L) ou (L,), reçu {tuple(token_ids.shape)}" + ) + else: + seq = [int(t) for t in token_ids] + if not seq: + raise ValueError("token_ids vide : rien à diagnostiquer") + ids = torch.tensor([seq], dtype=torch.long, device=_core_device(core)) + if ids.shape[1] < 1: + raise ValueError("chunk vide (L=0) interdit") + return ids + + +def _gates_from_phases(moe, phases: torch.Tensor) -> torch.Tensor: + """Gates von Mises (B, L, E) normalisées. + + La méthode ``_compute_gates`` du module si elle existe (bit-exact : le + même code qui a produit le forward — CTE comme fractus la nomment + ainsi) ; sinon réplication inline de la formule (doc §MoE batché), + κ lu sur le module : ``kappa_eff`` (CTE, 1.6) sinon ``kappa`` (fractus, + 4.0 direct). + """ + compute = getattr(moe, "_compute_gates", None) + if callable(compute): + return compute(phases) + kappa = getattr(moe, "kappa_eff", None) + if kappa is None: + kappa = getattr(moe, "kappa", None) + if kappa is None or not hasattr(moe, "expert_phases"): + raise TypeError( + f"{type(moe).__name__} n'expose ni _compute_gates ni " + "(kappa|kappa_eff)+expert_phases — routage illisible" + ) + n_experts = int(moe.expert_phases.shape[-1]) + theta_bar = torch.atan2( + torch.sin(phases).sum(dim=-1), torch.cos(phases).sum(dim=-1) + ) + diff = theta_bar.unsqueeze(-1) - moe.expert_phases + gates = torch.exp(kappa * torch.cos(diff)) + gates_sum = gates.sum(dim=-1, keepdim=True) + uniform = torch.full_like(gates, 1.0 / n_experts) + return torch.where(gates_sum > 1e-10, gates / gates_sum, uniform) + + +def _topk_renormalized(gates_1d: torch.Tensor, k: int) -> List[Tuple[int, float]]: + """Réplique EXACTEMENT le top-k renormalisé du forward du MoE : + + topk trié par valeur décroissante, renormalisation sur les k retenus, + garde dégénérée somme < 1e-10 → uniforme 1/k (mêmes seuils que + PhaseRoutedMoEBatched.forward). + """ + vals, idx = gates_1d.topk(k, dim=-1) + total = vals.sum() + if float(total) > 1e-10: + norm = vals / total + else: + norm = torch.full_like(vals, 1.0 / k) + return [(int(e), float(w)) for e, w in zip(idx.tolist(), norm.tolist())] + + +def _reset_and_eval(core) -> None: + """Convention speak() : états porteurs repartent de zéro (diagnostics + reproductibles), mode eval si le noyau l'expose (duck-typing).""" + if hasattr(core, "reset_states"): + core.reset_states(1) + if hasattr(core, "eval"): + core.eval() + + +# --------------------------------------------------------------------------- +# API publique +# --------------------------------------------------------------------------- + +@torch.no_grad() # le pacte : lecture seule, jamais de gradient +def expert_routing_snapshot( + core, token_ids: TokenIds +) -> Dict[str, List[Tuple[int, float]]]: + """Photo du routage MoE de CHAQUE couche pour ces token_ids — ciel ouvert. + + ``{f"layer_{i}": [(expert_id, gate_weight), ...top_k]}`` — les poids + renormalisés somment à 1, triés par gate décroissante, ids du noyau + RÉEL (0..n_experts-1). Recalcul extérieur : un forward hook par bloc + capture les phases consommées par la passe MoE, les gates sont + recalculées par la propre méthode du module (bit-exact — notes de + fidélité en tête de module). FractusCore lisible aussi (mêmes + conventions moe) ; batch élément 0 ; position -1. + + Les hooks sont TOUJOURS retirés (finally). Le forward de lecture mute + les états porteurs : reset_states(1) au départ (convention speak). + """ + ids = _as_batch_ids(core, token_ids) + captured: Dict[str, List[Tuple[int, float]]] = {} + handles = [] + + def _make_hook(layer_key: str): + def hook(module, args, output): + if len(args) < 2: + raise RuntimeError( + f"hook moe ({layer_key}) : phases absentes des arguments " + "du forward — protocole inattendu" + ) + phases = args[1].detach() + # (B, L, E) → batch 0, dernière position : la pensée courante. + # CTE : broadcast de la dernière position → identique partout. + gates = _gates_from_phases(module, phases)[0, -1] + captured[layer_key] = _topk_renormalized(gates, int(module.top_k)) + + return hook + + try: + for i, blk in enumerate(core.blocks): + moe = getattr(blk, "moe", None) + if moe is not None: + handles.append( + moe.register_forward_hook(_make_hook(f"layer_{i}")) + ) + _reset_and_eval(core) + core(ids) + finally: + for handle in handles: + handle.remove() + + missing = [ + f"layer_{i}" + for i, blk in enumerate(core.blocks) + if getattr(blk, "moe", None) is not None and f"layer_{i}" not in captured + ] + if missing: + raise RuntimeError( + f"forward sans passe MoE sur {missing} — routage non observable " + "pour ce noyau" + ) + return dict(captured) + + +@torch.no_grad() # le pacte : lecture seule, jamais de gradient +def head_readout(core, token_ids: TokenIds) -> Dict[str, Optional[float]]: + """Lecture des têtes confiance/salience du CTE pour ces token_ids. + + ``{"confidence": float|None, "salience": float|None}`` — floats Python + bornés [0, 1) (sigmoid, formule du tick() original) sur CteCore ; + ``None``/``None`` sur un noyau sans têtes (FractusCore), sans lancer + le moindre forward. + + Le hidden final est capté par un forward hook sur ``output_head`` (son + entrée EST le h des logits — les têtes n'étant pas invoquées par le + forward, les hooker directement ne tirerait jamais ; notes de fidélité + en tête de module). Position -1 (la pensée courante). Hook toujours + retiré ; reset_states(1) au départ (convention speak). + """ + conf_head = getattr(core, "confidence_head", None) + sal_head = getattr(core, "salience_head", None) + if conf_head is None and sal_head is None: + return {"confidence": None, "salience": None} + + target = getattr(core, "output_head", None) + if target is None: + target = getattr(core, "lm_head", None) + if target is None: + raise TypeError( + f"{type(core).__name__} expose des têtes mais ni output_head ni " + "lm_head pour capter le hidden final — lecture impossible" + ) + + hidden: Dict[str, torch.Tensor] = {} + + def hook(module, args, output): + if len(args) < 1: + raise RuntimeError("hook tête de sortie : hidden absent des arguments") + hidden["h"] = args[0].detach() + + handle = target.register_forward_hook(hook) + try: + _reset_and_eval(core) + core(_as_batch_ids(core, token_ids)) + finally: + handle.remove() + + if "h" not in hidden: + raise RuntimeError( + "la tête de sortie n'a pas été invoquée par le forward — " + "hidden non observable pour ce noyau" + ) + h_last = hidden["h"][0, -1] # (d_model,) — batch 0, position courante + + def _read(head) -> Optional[float]: + if head is None: + return None + return float(torch.sigmoid(head(h_last)).item()) + + return {"confidence": _read(conf_head), "salience": _read(sal_head)} diff --git a/fractus_vorax/model/fractus_core.py b/fractus_vorax/model/fractus_core.py new file mode 100644 index 0000000000000000000000000000000000000000..410134b9ca34157365e3ce4e9e152a00324c7f87 --- /dev/null +++ b/fractus_vorax/model/fractus_core.py @@ -0,0 +1,717 @@ +"""fractus_vorax/model/fractus_core.py — noyau fractus natif en PyTorch. + + Implémentation fidèle de l'architecture Fractus1B telle que mesurée sur le + checkpoint réel (doc de référence : docs/heritage/fractus-cte-architecture.md, LA + source de vérité — spécialement §4.2 amendement GELU et §4.3 amendement + orientation x@W). Ce fichier est le SEUL endroit du paquet fractus_vorax autorisé à + importer torch (gate tests/test_gates.py). + + MAPPING sous-module ↔ clés du state_dict (doc §5, vérifié strict sur le + checkpoint 88M `fractus_1b_latest.pt` — 4231 clés) : + + FractusCore + ├── embed : BPEEmbedding + │ ├── tok_embed.weight (vocab, d_model) + │ ├── pos_embed.weight (max_seq_len, d_model) + │ └── norm.{weight,bias} (d_model,) + ├── blocks : ModuleList[n_layers] × FractalBlockSparse + │ ├── norm1.{weight,bias} LayerNorm + │ ├── attn : FractalLinearAttention + │ │ ├── w_qkv (3, d, d) / b_qkv (3, d) param + │ │ ├── w_out (d, d) / b_out (d,) param + │ │ ├── level_logits (n_levels,) param + │ │ └── level_offsets (n_levels,) BUFFER persistant + │ ├── norm_kur.{weight,bias} + │ ├── kuramoto : KuramotoLayer + │ │ ├── omega (N,) / coupling_u (N, r) / coupling_lambda (r,) + │ ├── norm_moe.{weight,bias} + │ └── moe : SparseStructuredMoE + │ ├── expert_phases (E,) BUFFER persistant (Farey) + │ ├── experts_w1 : ModuleList[E] × LazyStructuredSirenLinear(d→d_ff) + │ │ └── {U (d_ff,r), V (d,r), scale (), bias (d_ff,)} + │ └── experts_w2 : ModuleList[E] × LazyStructuredSirenLinear(d_ff→d) + │ └── {U (d,r), V (d_ff,r), scale (), bias (d,)} + ├── norm.{weight,bias} LayerNorm final + └── lm_head.weight (vocab, d_model) LIÉE à embed.tok_embed.weight + + Écarts documentés (formes les plus proches, doc §4) : + - §4.1 : le résidu SIREN est ABANDONNÉ dans la variante Lazy du checkpoint + (facteurs bas-rang uniquement, style LoRA) — `LazyStructuredSirenLinear` + implémente exactement cette variante : y = scale·(x@V)@Uᵀ + b. + - §4.2 : activation GELU (torch.nn.functional.gelu) entre w1 et w2 de chaque + expert — sans elle le checkpoint charge mais produit des logits faux. + - §4.3 : projections d'attention en convention y = x @ W (einsum "bld,de->ble"), + PAS la convention nn.Linear x @ Wᵀ. Le state-carry (S₀, z₀) de la CTE n'est + pas implémenté (spécifique fractus-cte, inutile pour ce checkpoint). + - Le forward du modèle retourne les logits SEULS (pas (logits, aux_loss)) ; + les blocs calculent bien la load-balance loss en interne comme l'original. + - `generate` utilise une fenêtre glissante bornée à max_seq_len (le pos_embed + du checkpoint réel ne couvre que 16 positions). +""" + +from __future__ import annotations + +import math +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Optional, Tuple, Union + +import torch +import torch.nn as nn +import torch.nn.functional as F + +TWO_PI = 2.0 * math.pi +# φ_or² = ((1+√5)/2)² ≈ 2.618 — base des offsets de niveaux (doc §4.3). +GOLDEN_PHI_SQ = ((1.0 + math.sqrt(5.0)) / 2.0) ** 2 + + +# --------------------------------------------------------------------------- +# Utilitaires numériques (doc §4.2, §4.3 — portés de fractus1B/nn/stats.py) +# --------------------------------------------------------------------------- + +def elu_plus_one(x: torch.Tensor, alpha: float = 1.0) -> torch.Tensor: + """φ(x) = x+1 si x>0, sinon α(e^x−1)+1 — strictement positif.""" + pos = x + 1.0 + neg = alpha * (torch.exp(x) - 1.0) + 1.0 + return torch.where(x > 0, pos, neg) + + +def stable_softmax(logits: torch.Tensor, dim: int = -1) -> torch.Tensor: + """Softmax stable (soustraction du max) ; uniforme si le dénominateur ~ 0.""" + max_logits, _ = logits.max(dim=dim, keepdim=True) + exp = torch.exp(logits - max_logits) + denom = exp.sum(dim=dim, keepdim=True) + uniform = torch.full_like(exp, 1.0 / exp.shape[dim]) + return torch.where(denom > 1e-10, exp / denom, uniform) + + +def farey_sequence(n: int) -> list: + """Suite de Farey F_n : fractions irréductibles p/q ∈ [0,1], q ≤ n, + générées par la médiane. |F_n| = 1 + Σ φ(q) (totient d'Euler).""" + if n < 1: + raise ValueError("n must be >= 1") + fractions = [] + a, b = 0, 1 + c, d = 1, n + fractions.append((a, b)) + while c <= n: + k = (n + b) // d + c, d, a, b = k * c - a, k * d - b, c, d + fractions.append((a, b)) + return fractions + + +def farey_expert_phases(n_experts: int) -> torch.Tensor: + """E angles 2π·p/q ∈ [0, 2π) sélectionnés uniformément dans F_{2E}. + + Règle de sélection (farey.py) : idx = min(int(i·|F_{2E}|/E), |F_{2E}|−1). + Vérifié sur le checkpoint : max |phases_ckpt − phases| = 2.2e-07 (float32). + """ + if n_experts < 1: + raise ValueError("n_experts must be >= 1") + fractions = farey_sequence(2 * n_experts) + n_frac = len(fractions) + angles_all = [2.0 * math.pi * p / q for (p, q) in fractions] + phases = [ + angles_all[min(int(i * n_frac / n_experts), n_frac - 1)] + for i in range(n_experts) + ] + return torch.tensor(phases, dtype=torch.float32) + + +def mandelbrot_level_offsets(n_levels: int) -> torch.Tensor: + """Offsets ω_level = (φ²)^(−level), level ∈ [0, n_levels). + + n_levels=2 → [1.0, 0.3819660…] (égal au buffer du checkpoint, doc §4.3). + """ + levels = torch.arange(n_levels, dtype=torch.float32) + return GOLDEN_PHI_SQ ** (-levels) + + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +@dataclass +class FractusCoreConfig: + """Config du noyau fractus. + + Les 7 premiers champs (avec leurs défauts « tiny ») suivent le cahier des + charges ; les champs étendus ci-dessous correspondent aux valeurs du + modèle réel 88M (doc §3) et sont inférés par `FractusCore.from_checkpoint`. + + n_heads/d_head ne sont PAS encodés dans le state_dict (w_qkv est cubique) : + par défaut d_head=64 (modèle réel : 768 = 12×64), sinon le plus grand + diviseur de d_model parmi {64, 32, 16, 8, 4, 2, 1}. + """ + + 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 + # Champs étendus (défauts = modèle réel 88M, doc §3) : + d_ff: int = 1024 # expert_d_ff — inféré de experts_w1.0.U + n_levels: int = 2 # inféré de attn.level_logits + n_heads: Optional[int] = None + d_head: Optional[int] = None + n_oscillators: int = 16 # inféré de kuramoto.omega + coupling_rank: int = 8 # inféré de kuramoto.coupling_lambda + kuramoto_n_steps: int = 1 + kuramoto_dt: float = 0.1 + kuramoto_damping: float = 0.01 + moe_kappa: float = 4.0 # von Mises κ + + def __post_init__(self) -> None: + if self.n_heads is None and self.d_head is None: + for cand in (64, 32, 16, 8, 4, 2, 1): + if self.d_model % cand == 0: + self.d_head = cand + break + self.n_heads = self.d_model // self.d_head + elif self.n_heads is None: + self.n_heads = self.d_model // self.d_head + elif self.d_head is None: + self.d_head = self.d_model // self.n_heads + if self.n_heads * self.d_head != self.d_model: + raise ValueError( + f"n_heads·d_head ({self.n_heads * self.d_head}) != d_model ({self.d_model})" + ) + if self.n_levels < 1: + raise ValueError("n_levels must be >= 1") + if self.n_experts < 1 or not 1 <= self.top_k <= self.n_experts: + raise ValueError("need n_experts >= 1 and 1 <= top_k <= n_experts") + if self.rank < 1: + raise ValueError("rank must be >= 1") + if not 1 <= self.coupling_rank <= self.n_oscillators: + raise ValueError("need 1 <= coupling_rank <= n_oscillators") + + +# --------------------------------------------------------------------------- +# LazyStructuredSirenLinear (doc §4.1) +# --------------------------------------------------------------------------- + +class LazyStructuredSirenLinear(nn.Module): + """Couche linéaire bas-rang style LoRA — SANS matrice pleine. + + W = scale · U @ Vᵀ avec U (out, r), V (in, r), scale scalaire apprenable. + Forward : y = scale · (x @ V) @ Uᵀ + b — deux petites matmuls, mémoire + O((out+in)·r). Le résidu SIREN historique est abandonné dans cette + variante Lazy (compromis RAM du checkpoint réel, doc §4.1). + """ + + def __init__(self, in_features: int, out_features: int, rank: int = 16): + super().__init__() + self.in_features = in_features + self.out_features = out_features + self.rank = rank + scale_u = math.sqrt(2.0 / (out_features + rank)) + scale_v = math.sqrt(2.0 / (in_features + rank)) + self.U = nn.Parameter(torch.empty(out_features, rank).uniform_(-scale_u, scale_u)) + self.V = nn.Parameter(torch.empty(in_features, rank).uniform_(-scale_v, scale_v)) + self.scale = nn.Parameter(torch.tensor(1.0)) + self.bias = nn.Parameter(torch.zeros(out_features)) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """x (..., in) → (..., out) : y = scale·(x @ V) @ Uᵀ + b.""" + h = x @ self.V # (..., rank) + y = self.scale * (h @ self.U.T) # (..., out) + return y + self.bias + + def reconstruct_weight(self) -> torch.Tensor: + """W complet (inspection/export uniquement).""" + return self.scale * (self.U @ self.V.T) + + +# --------------------------------------------------------------------------- +# Attention linéaire fractale (doc §4.3 — Katharopoulos 2020, causale inclusive) +# --------------------------------------------------------------------------- + +class FractalLinearAttention(nn.Module): + """Attention linéaire causale multi-niveaux. + + - Feature map φ(x; level) = elu_plus_one(x + ω_level), ω_level = (φ²)^(−level). + - Récurrence causale INCLUSIVE : S_t = Σ_{i≤t} k_i⊗v_i, z_t = Σ_{i≤t} k_i, + y_t = (q_t·S_t)/(q_t·z_t), 0 si |dénom| < 1e-10 (masque triangulaire + einsum). + - Multi-niveaux : sortie = Σ_level softmax(level_logits)_level · attn_level(x). + - Projections en convention y = x @ W (einsum "bld,de->ble") — AMENDEMENT + §4.3 : PAS la convention nn.Linear x @ Wᵀ. Q/K/V dans l'ordre d'indexation + de w_qkv ; têtes découpées par view(B, L, H, D) contigu. + """ + + def __init__(self, d_model: int, n_heads: int, d_head: int, n_levels: int = 2): + super().__init__() + if n_heads * d_head != d_model: + raise ValueError(f"n_heads·d_head ({n_heads * d_head}) != d_model ({d_model})") + if n_levels < 1: + raise ValueError("n_levels must be >= 1") + self.d_model = d_model + self.n_heads = n_heads + self.d_head = d_head + self.n_levels = n_levels + d_qkv = n_heads * d_head # = d_model + + scale = math.sqrt(2.0 / (d_model + d_qkv)) + self.w_qkv = nn.Parameter(torch.empty(3, d_model, d_qkv).uniform_(-scale, scale)) + self.b_qkv = nn.Parameter(torch.zeros(3, d_qkv)) + scale_out = math.sqrt(2.0 / (d_qkv + d_model)) + self.w_out = nn.Parameter(torch.empty(d_qkv, d_model).uniform_(-scale_out, scale_out)) + self.b_out = nn.Parameter(torch.zeros(d_model)) + self.level_logits = nn.Parameter(torch.zeros(n_levels)) + # Buffer PERSISTANT (clé du state_dict, doc §5) : [1.0, 0.3819660…]. + self.register_buffer("level_offsets", mandelbrot_level_offsets(n_levels)) + + def _linear_attention_causal_vectorized( + self, q: torch.Tensor, k: torch.Tensor, v: torch.Tensor + ) -> torch.Tensor: + """q, k : (B, L, D) déjà φ-mappés ; v : (B, L, D) brut. → y (B, L, D).""" + B, L, D = q.shape + outer = torch.einsum("btp,btq->btpq", k, v) # k_i ⊗ v_i + mask = torch.tril(torch.ones(L, L, dtype=q.dtype, device=q.device)) + S = torch.einsum("tj,bjpq->btpq", mask, outer) # Σ_{i≤t} + z = torch.einsum("tj,bjp->btp", mask, k) # Σ_{i≤t} + num = torch.einsum("btp,btpq->btq", q, S) + denom = (q * z).sum(dim=-1, keepdim=True) + safe = denom.abs() > 1e-10 + return torch.where(safe, num / (denom + 1e-20), torch.zeros_like(num)) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """x (B, L, d_model) → (B, L, d_model).""" + B, L, _ = x.shape + H, D = self.n_heads, self.d_head + nlev = self.n_levels + + # Projections x @ W (amendement §4.3) — Q, K, V dans l'ordre de w_qkv. + q_all = torch.einsum("bld,de->ble", x, self.w_qkv[0]) + self.b_qkv[0] + k_all = torch.einsum("bld,de->ble", x, self.w_qkv[1]) + self.b_qkv[1] + v_all = torch.einsum("bld,de->ble", x, self.w_qkv[2]) + self.b_qkv[2] + q_all = q_all.view(B, L, H, D) # découpe contiguë des têtes + k_all = k_all.view(B, L, H, D) + v_all = v_all.view(B, L, H, D) + + # Feature map par niveau : elu_plus_one(x + ω_level) sur q et k (pas v). + offsets = self.level_offsets # (nlev,) + q_feat = elu_plus_one(q_all.unsqueeze(1) + offsets.view(1, nlev, 1, 1, 1)) + k_feat = elu_plus_one(k_all.unsqueeze(1) + offsets.view(1, nlev, 1, 1, 1)) + v_lev = v_all.unsqueeze(1).expand(B, nlev, L, H, D) + + # (B, nlev, H) aplat en un seul batch → UN appel vectorisé. + q_flat = q_feat.permute(0, 1, 3, 2, 4).reshape(B * nlev * H, L, D) + k_flat = k_feat.permute(0, 1, 3, 2, 4).reshape(B * nlev * H, L, D) + v_flat = v_lev.permute(0, 1, 3, 2, 4).reshape(B * nlev * H, L, D) + y_flat = self._linear_attention_causal_vectorized(q_flat, k_flat, v_flat) + y = y_flat.reshape(B, nlev, H, L, D).permute(0, 1, 3, 2, 4).reshape(B, nlev, L, H * D) + + level_weights = stable_softmax(self.level_logits, dim=-1) + attn = (y * level_weights.view(1, nlev, 1, 1)).sum(dim=1) + + return attn @ self.w_out + self.b_out # x @ W (amendement §4.3) + + +# --------------------------------------------------------------------------- +# Oscillateurs de Kuramoto (doc §4.4) +# --------------------------------------------------------------------------- + +class KuramotoLayer(nn.Module): + """Oscillateurs de Kuramoto à couplage bas-rang, STATELESS. + + - Phases initiales dérivées du hidden : θ_init = 2π·mean(hidden) + 2π·i/N (mod 2π). + - dθ_i/dt = ω_i − damping·θ_i + Σ_j K_ij sin(θ_j − θ_i), K = UΛUᵀ en O(N·r). + - Intégration RK4 (4 évals), wrap mod 2π à chaque pas. + - Sortie : phases (B, L, N) qui alimentent la grille von Mises du MoE. + """ + + def __init__( + self, + d_model: int, + n_oscillators: int, + rank: int, + n_steps: int = 1, + dt: float = 0.1, + damping: float = 0.01, + ): + super().__init__() + if n_oscillators < 1 or rank < 1 or rank > n_oscillators: + raise ValueError("n_oscillators >= 1 and 1 <= rank <= n_oscillators") + self.d_model = d_model + self.N = n_oscillators + self.rank = rank + self.n_steps = n_steps + self.dt = dt + self.damping = damping + + self.omega = nn.Parameter(torch.empty(n_oscillators).uniform_(-0.05, 0.05)) + self.coupling_u = nn.Parameter(torch.empty(n_oscillators, rank).uniform_(-1.0, 1.0)) + self.coupling_lambda = nn.Parameter(torch.empty(rank).uniform_(0.01, 0.51)) + + def _derivative(self, theta: torch.Tensor) -> torch.Tensor: + """dθ/dt pour θ (..., N) — couplage bas-rang K = UΛUᵀ en O(N·r).""" + sin_t = torch.sin(theta) + cos_t = torch.cos(theta) + p = torch.einsum("...n,nr->...r", sin_t, self.coupling_u) + q = torch.einsum("...n,nr->...r", cos_t, self.coupling_u) + u_p = torch.einsum("...r,nr->...n", self.coupling_lambda * p, self.coupling_u) + u_q = torch.einsum("...r,nr->...n", self.coupling_lambda * q, self.coupling_u) + return self.omega - self.damping * theta + cos_t * u_p - sin_t * u_q + + def _rk4_step(self, theta: torch.Tensor, dt: float) -> torch.Tensor: + k1 = self._derivative(theta) + k2 = self._derivative(theta + 0.5 * dt * k1) + k3 = self._derivative(theta + 0.5 * dt * k2) + k4 = self._derivative(theta + dt * k3) + return theta + (dt / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4) + + def _encode_from_hidden(self, hidden: torch.Tensor) -> torch.Tensor: + """hidden (B, L, d_model) → phases initiales (B, L, N).""" + hidden_mean = hidden.mean(dim=-1) * TWO_PI # (B, L) + offsets = torch.arange(self.N, dtype=hidden.dtype, device=hidden.device) + offsets = offsets / self.N * TWO_PI + theta_init = hidden_mean.unsqueeze(-1) + offsets.view(1, 1, self.N) + return torch.remainder(theta_init, TWO_PI) + + def forward(self, hidden: torch.Tensor) -> torch.Tensor: + theta = self._encode_from_hidden(hidden) + for _ in range(self.n_steps): + theta = torch.remainder(self._rk4_step(theta, self.dt), TWO_PI) + return theta + + +# --------------------------------------------------------------------------- +# MoE creux routé par phases (doc §4.2) +# --------------------------------------------------------------------------- + +class SparseStructuredMoE(nn.Module): + """MoE creux : E experts × (w1: d→d_ff, w2: d_ff→d) en LazySiren bas-rang. + + - Phases de Farey (buffer persistant) + grille von Mises κ=4.0 : + θ̄ = atan2(Σsin θ, Σcos θ), gate_e ∝ exp(κ·cos(θ̄ − phase_e)), normalisées. + - Top-k creux (k=2) : experts aux gates max, poids renormalisés, somme + pondérée. Dispatch vectorisé gather-first : on empile les facteurs + U/V/scale/bias des experts sélectionnés puis bmm groupé — aucune + matrice W jamais matérialisée. + - Expert : h1 = w1(x) → GELU (AMENDEMENT §4.2) → y = w2(h1_act). + - Perte d'équilibrage : lb = E·Σ_e (P_e − 1/E)². + """ + + def __init__( + self, + d_model: int, + n_experts: int = 64, + top_k: int = 2, + d_ff: int = 1024, + siren_rank: int = 16, + kappa: float = 4.0, + ): + super().__init__() + self.d_model = d_model + self.n_experts = n_experts + self.top_k = top_k + self.d_ff = d_ff + self.kappa = kappa + # Buffer PERSISTANT (clé du state_dict, doc §5) — phases de Farey. + self.register_buffer("expert_phases", farey_expert_phases(n_experts)) + self.experts_w1 = nn.ModuleList([ + LazyStructuredSirenLinear(d_model, d_ff, rank=siren_rank) + for _ in range(n_experts) + ]) + self.experts_w2 = nn.ModuleList([ + LazyStructuredSirenLinear(d_ff, d_model, rank=siren_rank) + for _ in range(n_experts) + ]) + + def _compute_gates(self, phases: torch.Tensor) -> torch.Tensor: + """phases (B, L, N) → gates (B, L, E) normalisées (grille von Mises).""" + sin_p = torch.sin(phases).sum(dim=-1) + cos_p = torch.cos(phases).sum(dim=-1) + theta_bar = torch.atan2(sin_p, cos_p) + diff = theta_bar.unsqueeze(-1) - self.expert_phases.view( + *[1] * (phases.dim() - 1), self.n_experts + ) + gates = torch.exp(self.kappa * torch.cos(diff)) + gates_sum = gates.sum(dim=-1, keepdim=True) + uniform = torch.full_like(gates, 1.0 / self.n_experts) + return torch.where(gates_sum > 1e-10, gates / gates_sum, uniform) + + def forward( + self, h: torch.Tensor, phases: torch.Tensor + ) -> Tuple[torch.Tensor, torch.Tensor]: + """h (B, L, d), phases (B, L, N) → (output (B, L, d), lb_loss scalaire).""" + B, L, D = h.shape + K = self.top_k + gates = self._compute_gates(phases) + topk_vals, topk_idx = gates.topk(K, dim=-1) # (B, L, K) + topk_sum = topk_vals.sum(dim=-1, keepdim=True) + topk_norm = torch.where( + topk_sum > 1e-10, topk_vals / topk_sum, + torch.full_like(topk_vals, 1.0 / K), + ) + + N = B * L + flat_idx = topk_idx.reshape(-1) # (N·K,) id expert + + # Empiler les facteurs bas-rang des experts puis gather les K retenus. + w1_V = torch.stack([e.V for e in self.experts_w1]) # (E, D, R) + w1_U = torch.stack([e.U for e in self.experts_w1]) # (E, F, R) + w1_s = torch.stack([e.scale for e in self.experts_w1]) # (E,) + w1_b = torch.stack([e.bias for e in self.experts_w1]) # (E, F) + w2_V = torch.stack([e.V for e in self.experts_w2]) # (E, F, R) + w2_U = torch.stack([e.U for e in self.experts_w2]) # (E, D, R) + w2_s = torch.stack([e.scale for e in self.experts_w2]) # (E,) + w2_b = torch.stack([e.bias for e in self.experts_w2]) # (E, D) + + g1V = w1_V.index_select(0, flat_idx) + g1U = w1_U.index_select(0, flat_idx) + g1s = w1_s.index_select(0, flat_idx) + g1b = w1_b.index_select(0, flat_idx) + g2V = w2_V.index_select(0, flat_idx) + g2U = w2_U.index_select(0, flat_idx) + g2s = w2_s.index_select(0, flat_idx) + g2b = w2_b.index_select(0, flat_idx) + + # Chaque token répété K fois : (N, D) → (N·K, D). + h_rep = h.reshape(N, D).unsqueeze(1).expand(N, K, D).reshape(N * K, D) + + # LazySiren w1 : y = scale·(x@V)@Uᵀ + b — un bmm groupé. + proj1 = torch.bmm(h_rep.unsqueeze(1), g1V).squeeze(1) # (N·K, R) + h1 = torch.bmm(proj1.unsqueeze(1), g1U.transpose(1, 2)).squeeze(1) # (N·K, F) + h1 = g1s.unsqueeze(-1) * h1 + g1b + h1_act = F.gelu(h1) # AMENDEMENT §4.2 — GELU entre w1 et w2. + + # LazySiren w2. + proj2 = torch.bmm(h1_act.unsqueeze(1), g2V).squeeze(1) # (N·K, R) + out_nk = torch.bmm(proj2.unsqueeze(1), g2U.transpose(1, 2)).squeeze(1) # (N·K, D) + out_nk = g2s.unsqueeze(-1) * out_nk + g2b + + out_k = out_nk.reshape(B, L, K, D) + output = (topk_norm.unsqueeze(-1) * out_k).sum(dim=2) # (B, L, D) + + P = gates.mean(dim=(0, 1)) + lb_loss = self.n_experts * ((P - 1.0 / self.n_experts) ** 2).sum() + return output, lb_loss + + +# --------------------------------------------------------------------------- +# Bloc fractal et modèle (doc §4.5) +# --------------------------------------------------------------------------- + +class BPEEmbedding(nn.Module): + """Embedding BPE : table token + positions + LayerNorm.""" + + def __init__(self, vocab_size: int, d_model: int, max_seq_len: int = 512): + super().__init__() + self.tok_embed = nn.Embedding(vocab_size, d_model) + self.pos_embed = nn.Embedding(max_seq_len, d_model) + self.norm = nn.LayerNorm(d_model) + nn.init.normal_(self.tok_embed.weight, mean=0.0, std=0.02) + nn.init.normal_(self.pos_embed.weight, mean=0.0, std=0.02) + + def forward(self, ids: torch.Tensor) -> torch.Tensor: + B, L = ids.shape + pos = torch.arange(L, device=ids.device).unsqueeze(0).expand(B, L) + return self.norm(self.tok_embed(ids) + self.pos_embed(pos)) + + +class FractalBlockSparse(nn.Module): + """Bloc pré-norm résiduel : attention + Kuramoto + MoE creux (doc §4.5). + + x = x + attn(norm1(x)) ; phases = kuramoto(norm_kur(x)) ; + moe_out, lb = moe(norm_moe(x), phases) ; x = x + moe_out. + """ + + def __init__(self, config: FractusCoreConfig): + super().__init__() + self.norm1 = nn.LayerNorm(config.d_model) + self.attn = FractalLinearAttention( + config.d_model, config.n_heads, config.d_head, config.n_levels + ) + self.norm_kur = nn.LayerNorm(config.d_model) + self.kuramoto = KuramotoLayer( + config.d_model, + n_oscillators=config.n_oscillators, + rank=config.coupling_rank, + n_steps=config.kuramoto_n_steps, + dt=config.kuramoto_dt, + damping=config.kuramoto_damping, + ) + self.norm_moe = nn.LayerNorm(config.d_model) + self.moe = SparseStructuredMoE( + config.d_model, + n_experts=config.n_experts, + top_k=config.top_k, + d_ff=config.d_ff, + siren_rank=config.rank, + kappa=config.moe_kappa, + ) + + def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: + x = x + self.attn(self.norm1(x)) + phases = self.kuramoto(self.norm_kur(x)) + moe_out, lb_loss = self.moe(self.norm_moe(x), phases) + x = x + moe_out + return x, lb_loss + + +class FractusCore(nn.Module): + """Fractus1B natif : ids (B, L) → logits (B, L, vocab). + + Arbre des modules EXACTEMENT conforme au doc §4.5/§5 (noms d'attributs = + clés du state_dict réel) pour un `load_state_dict(strict=True)` sur le + checkpoint 88M sans aucune retouches de clés. lm_head liée à tok_embed. + """ + + def __init__(self, config: FractusCoreConfig): + super().__init__() + self.config = config + self.embed = BPEEmbedding(config.vocab_size, config.d_model, config.max_seq_len) + self.blocks = nn.ModuleList([ + FractalBlockSparse(config) for _ in range(config.n_layers) + ]) + self.norm = nn.LayerNorm(config.d_model) + self.lm_head = nn.Linear(config.d_model, config.vocab_size, bias=False) + # Tête liée à l'embedding (doc §5 : poids identiques bit à bit). + self.lm_head.weight = self.embed.tok_embed.weight + + def forward(self, token_ids: torch.Tensor) -> torch.Tensor: + """token_ids (B, L ≤ max_seq_len) → logits (B, L, vocab_size).""" + x = self.embed(token_ids) + for block in self.blocks: + x, _lb = block(x) # aux (load-balance) calculée, non retournée + x = self.norm(x) + return self.lm_head(x) + + @torch.no_grad() + def generate( + self, + token_ids: torch.Tensor, + max_new_tokens: int = 32, + temperature: float = 0.0, + eos_token_id: Optional[int] = None, + seed: int = 0, + ) -> torch.Tensor: + """Génération auto-régressive. + + temperature == 0 → greedy déterministe (argmax). Sinon multinomial + sur softmax(logits/T) avec un torch.Generator seedé (`seed`). + Contexte borné par max_seq_len (fenêtre glissante sur les dernières + positions — le pos_embed du checkpoint réel ne couvre que seq_len). + eos_token_id : dès que le token émis == eos_token_id, arrêt IMMÉDIAT + — le token EOS clôt la séquence, aucun autre token n'est consommé. + (Sémantique du test du plan p4-t2 : out se termine PAR le token EOS.) + """ + ids = token_ids.clone() + gen = None + if temperature > 0.0: + gen = torch.Generator(device=ids.device) + gen.manual_seed(seed) + for _ in range(max_new_tokens): + ctx = ids[:, -self.config.max_seq_len:] + logits = self(ctx) + last = logits[:, -1, :] + if temperature <= 0.0: + next_tok = last.argmax(dim=-1, keepdim=True) + else: + probs = torch.softmax(last / temperature, dim=-1) + next_tok = torch.multinomial(probs, 1, generator=gen) + ids = torch.cat([ids, next_tok], dim=1) + # EOS-aware : le token d'arrêt clôt la génération — plus AUCUN + # token consommé après lui. (.all() : batch-safe, == int(next) + # en B=1 ; s'applique aux DEUX chemins greedy et multinomial.) + if eos_token_id is not None and bool((next_tok == eos_token_id).all()): + break + return ids + + # ------------------------------------------------------------------ + # Chargement strict de checkpoint + # ------------------------------------------------------------------ + + @classmethod + def from_checkpoint( + cls, path: Union[str, Path], map_location: str = "cpu" + ) -> "FractusCore": + """Charge un checkpoint fractus en STRICT (jamais strict=False). + + - torch.load(weights_only=False) — fichier local de confiance (doc §8). + - state_dict cherché sous « model_state » (checkpoint réel 88M), puis + « model » (format des tests), puis « state_dict », sinon dict brut. + - Config inférée des shapes (règles commentées dans `_infer_config`). + - Toute clé manquante/inattendue lève une erreur BRUYANTE. + """ + ckpt = torch.load(path, map_location=map_location, weights_only=False) + sd: Optional[Dict[str, torch.Tensor]] = None + if isinstance(ckpt, dict): + for key in ("model_state", "model", "state_dict"): + cand = ckpt.get(key) + if isinstance(cand, dict) and "embed.tok_embed.weight" in cand: + sd = cand + break + if sd is None and "embed.tok_embed.weight" in ckpt: + sd = ckpt # dict déjà nu (state_dict sérialisé tel quel) + if sd is None: + raise ValueError( + f"{path}: aucune clé 'model_state'/'model'/'state_dict' ni " + "state_dict fractus détectable au niveau supérieur" + ) + config = cls._infer_config(sd) + model = cls(config) + result = model.load_state_dict(sd, strict=True) + missing = list(result.missing_keys) + unexpected = list(result.unexpected_keys) + if missing or unexpected: + raise RuntimeError( + f"strict load incomplet — manquantes: {missing[:8]} " + f"inattendues: {unexpected[:8]}" + ) + model.eval() + return model + + @staticmethod + def _infer_config(sd: Dict[str, torch.Tensor]) -> FractusCoreConfig: + """Règles d'inférence de la config depuis les shapes du state_dict : + + - vocab_size, d_model : shape de `embed.tok_embed.weight` (vocab, d) ; + - max_seq_len : shape de `embed.pos_embed.weight` (seq, d) ; + - n_layers : 1 + max indice des clés `blocks.(i).…` ; + - n_levels : shape de `blocks.0.attn.level_logits` ; + - n_experts : shape du buffer `blocks.0.moe.expert_phases` ; + - rank : dernière dim de `blocks.0.moe.experts_w1.0.V` ; + - d_ff : shape de `blocks.0.moe.experts_w1.0.U` (d_ff, r) ; + - n_oscillators : shape de `blocks.0.kuramoto.omega` ; + - coupling_rank : shape de `blocks.0.kuramoto.coupling_lambda` ; + - top_k, κ, dt, … : NON encodés dans le state_dict → défauts du doc + §3 (top_k=2 jamais surchargé à l'entraînement, κ=4.0, RK4 dt=0.1…) ; + - n_heads×d_head : non encodés (w_qkv cubique) → d_head=64 si + d_model % 64 == 0 (modèle réel 768=12×64), sinon plus grand diviseur + de d_model parmi {64, 32, 16, 8, 4, 2, 1}. + """ + vocab_size, d_model = (int(v) for v in sd["embed.tok_embed.weight"].shape) + max_seq_len = int(sd["embed.pos_embed.weight"].shape[0]) + block_ids = [ + int(m.group(1)) + for k in sd + if (m := re.match(r"^blocks\.(\d+)\.", k)) + ] + if not block_ids: + raise ValueError("state_dict sans clés 'blocks.(i).…' — pas un fractus") + n_layers = 1 + max(block_ids) + n_levels = int(sd["blocks.0.attn.level_logits"].shape[0]) + n_experts = int(sd["blocks.0.moe.expert_phases"].shape[0]) + rank = int(sd["blocks.0.moe.experts_w1.0.V"].shape[1]) + d_ff = int(sd["blocks.0.moe.experts_w1.0.U"].shape[0]) + n_oscillators = int(sd["blocks.0.kuramoto.omega"].shape[0]) + coupling_rank = int(sd["blocks.0.kuramoto.coupling_lambda"].shape[0]) + return FractusCoreConfig( + vocab_size=vocab_size, + d_model=d_model, + n_layers=n_layers, + n_experts=n_experts, + top_k=2, + rank=rank, + max_seq_len=max_seq_len, + d_ff=d_ff, + n_levels=n_levels, + n_oscillators=n_oscillators, + coupling_rank=coupling_rank, + ) diff --git a/fractus_vorax/model/speak.py b/fractus_vorax/model/speak.py new file mode 100644 index 0000000000000000000000000000000000000000..3fbe02e3076f16ca1bbada4ad5dcd0a9d50225ba --- /dev/null +++ b/fractus_vorax/model/speak.py @@ -0,0 +1,350 @@ +"""fractus_vorax/model/speak.py — la boucle de décodage mécanique (Plan 6, tâche 1). + +Faire parler le cerveau scellé par la MÉCANIQUE, pas par l'entraînement. La +parole libérée n'est pas une question de poids : greedy et t=1.0 ordinaires +voient le même attracteur (greedy → ``' the'``×16 sur le 1B, écart de logits +trop grand pour l'échantillonnage). La suite ci-dessous formalise la boucle +PROUVÉE EMPIRIQUEMENT le 2026-08-18 (ledger, cerveau scellé, verbatim) : + + forward → z-norm des logits → pénalité de répétition → bias des organes + → top-k/top-p → softmax/température → multinomial (generator seedé) + +- **z-norm ×3** : std des logits mesurée ~26 à froid mais des CENTAINES dans + les états-attracteurs. ``(x−mean)/std·scale`` écrase l'échelle folle et + remet toute distribution sur la même échelle calme — c'est ELLE qui libère + le lexique réel (``philosophy``, ``manufactures``, ``surveyed``…). +- **rep_pen 6-12** : divise le logit de tout token déjà émis (ensemble, pas + séquence) — l'attracteur de répétition est brisé mécaniquement. +- **top-k/top-p** : masque à −inf hors sélection (optionnels, None = off). +- **bias_tokens** : +δ brut sur les ids choisis par les organes — le steering + organique (Plan 6, tâche 3) ; ajouté APRÈS z-norm/rep-pen, AVANT top-k/p. +- **answer_lock** (Plan 7) : LE mécanisme d'articulation organique — à + l'étape i, +answer_boost sur answer_lock[i] (les tokens BPE COMPLETS de la + réponse des organes, ex. ``" tokyo"``) : la réponse est placée token par + token À TRAVERS la distribution du noyau (lexique et transitions sortent + du 1B, seule la réponse est verrouillée), puis la suite est libre. Même + place dans le contrat que bias_tokens : APRÈS transforms, AVANT top-k/p. + Boost par défaut 30, CALIBRÉ PAR MESURE sur le 1B (2026-08-18) : le plan + visait 12 (« ≈ 4σ ») mais la mesure montre z[locked] à −3σ (ex. ' par' + z=−3.0) quand le top de la distribution z-normée est à +4σ (zmax≈11.9) — + +12 PERD le tirage pour paris/madrid (2/4 capitales seulement). +30 + domine même z[locked]=−6σ contre zmax 12 ET la masse de queue complète + (≈ e^15.4 sur 50257 tokens std 3) : échec par pas < 0.1%, borné, jamais + de sommation. +- **diagnostics par pas** : std AVANT transforms, top3 (après transforms), + token choisi — l'observabilité légère de la boucle. + +ZÉRO GRADIENT, JAMAIS (le pacte) : ``@torch.no_grad()`` sur toute la boucle, +les poids ne sont jamais touchés — seulement les logits. + +GATE torch : ce fichier vit dans fractus_vorax/model/ (seule zone exemptée +par tests/test_gates.py) et importe torch AU NIVEAU MODULE, comme cte_core +— mais il n'est importé que par du code torch-aware (tests importorskip, +harnais lazy), jamais par le substrat mingw. + +CHOIX DOCUMENTÉ — contexte COMPLET à chaque pas : la recette officielle de +``CteCore.generate`` nourrit le carry (S, z) par chunks de LONGUEUR 1 sur le +contexte croissant. La sonde manuelle du 2026-08-18 (celle qui a produit les +résultats du ledger) appelait ``core(ctx_complet)`` à CHAQUE itération, sans +reset intermédiaire — le contexte entier re-rentre dans le carry à chaque +pas (effet d'emphase cumulatif). On suit la sonde : contexte complet par +pas, reset_states(1) UNE FOIS au départ. La fenêtre reste bornée à gauche +par ``config.max_seq_len`` (garde-fou prompt, même politique que generate/ +verbalize — FractusCore a un pos_embed réellement borné). +""" + +from __future__ import annotations + +from typing import Dict, List, Optional, Set, Tuple + +import torch + +from fractus_vorax.model.vocal import safe_decode_text + +# Fenêtre de repli si le noyau n'expose pas config.max_seq_len +# (même valeur que vocal.py — défaut FractusCoreConfig). +_FALLBACK_MAX_SEQ_LEN = 256 + + +# --------------------------------------------------------------------------- +# Transforms purs des logits (tenseur 1-D sur le vocab) +# --------------------------------------------------------------------------- + +def znorm_logits(logits: torch.Tensor, scale: float = 3.0) -> torch.Tensor: + """(x − mean)/std · scale — pur, jamais in-place. + + Écrase l'échelle des états-attracteurs (std mesurée : ~26 calme, + centaines en attracteur) : la sortie a std == scale quelle que soit + l'entrée, mean == 0. Garde dégénérée : std ~0 (logits constants) → + zéros (clamp de la division, aucun NaN). std non biaisé de torch — + sur 50257 logits la correction est invisible. + """ + if scale <= 0.0: + raise ValueError(f"scale doit être > 0 (reçu {scale})") + std = logits.std().clamp_min(1e-12) + return (logits - logits.mean()) / std * scale + + +def apply_rep_penalty( + logits: torch.Tensor, + emitted: Set[int], + penalty: float = 6.0, +) -> torch.Tensor: + """logits[t] /= penalty pour tout t déjà émis — division SIMPLE (spec). + + ``emitted`` est l'ENSEMBLE des tokens émis (pas la séquence) : un token + déjà sorti voit son logit (re-calculé puis z-normé à CE pas) divisé par + penalty, à chaque pas où il reste dans l'ensemble. Note honnête : la + division d'un logit NÉGATIF le rapproche de 0 (l'agrandit) — sans effet + pratique, les tokens émis vivent dans le haut positif de la distribution + post-z-norm. Pur ; ``emitted`` vide → tenseur inchangé. + """ + if penalty <= 0.0: + raise ValueError(f"penalty doit être > 0 (reçu {penalty})") + if not emitted: + return logits + idx = torch.tensor(sorted(emitted), dtype=torch.long, device=logits.device) + out = logits.clone() + out[idx] = out[idx] / penalty + return out + + +def apply_top_k(logits: torch.Tensor, k: int) -> torch.Tensor: + """Masque à −inf tout ce qui n'est pas dans les k plus grands. + + k ≥ taille du vocab → identité (tout survit). Égalités au k-ième rang : + toutes survivent (``logits < seuil``) — standard, jamais de masque vide. + Pur ; renvoie un nouveau tenseur (masked_fill). + """ + if k < 1: + raise ValueError(f"k doit être >= 1 (reçu {k})") + vocab = logits.shape[-1] + if k >= vocab: + return logits + kth = torch.topk(logits, k, dim=-1).values[..., -1:] + return logits.masked_fill(logits < kth, float("-inf")) + + +def apply_top_p(logits: torch.Tensor, p: float) -> torch.Tensor: + """Nucleus : ne garde que le plus petit préfixe de masse >= p. + + Convention cumul EXCLUSIF : le rang r survit ssi la masse des rangs < r + est < p — le top-1 survit donc TOUJOURS (jamais de masque vide, p > 0). + p >= 1 → identité (aucun masque). Pur ; renvoie un nouveau tenseur. + """ + if p <= 0.0 or p > 1.0: + raise ValueError(f"p doit être dans (0, 1] (reçu {p})") + if p >= 1.0: + return logits + sorted_logits, sorted_idx = torch.sort(logits, descending=True, dim=-1) + probs = torch.softmax(sorted_logits, dim=-1) + prefix = torch.cumsum(probs, dim=-1) - probs # cumul exclusif du rang + sorted_masked = sorted_logits.masked_fill(prefix >= p, float("-inf")) + out = torch.empty_like(logits) + out.scatter_(-1, sorted_idx, sorted_masked) + return out + + +# --------------------------------------------------------------------------- +# La boucle de décodage mécanique +# --------------------------------------------------------------------------- + +@torch.no_grad() +def speak( + core, + tokenizer, + prompt: str, + max_new_tokens: int = 16, + temperature: float = 1.0, + rep_pen: float = 6.0, + z_scale: float = 3.0, + top_k: Optional[int] = None, + top_p: Optional[float] = None, + seed: int = 0, + eos_token_id: Optional[int] = None, + bias_tokens: Optional[Dict[int, float]] = None, + answer_lock: Optional[List[int]] = None, + answer_boost: float = 30.0, +) -> Tuple[str, List[dict]]: + """Le cerveau parle par la mécanique — texte + diagnostics par pas. + + ``core`` est duck-typé (CteCore comme FractusCore) : ``forward(ids)`` + → logits (B, L, vocab), ``reset_states(1)`` s'il existe (CteCore ; + FractusCore est stateless et n'expose rien de tel). ``tokenizer`` : + ``encode(str) -> list[int]`` et ``decode(list[int]) -> str`` (protocole + minimal de vocal.py). + + Boucle par pas (l'ordre des transforms est le contrat, ratifié P6) : + 1. ``forward`` sur le contexte COMPLET croissant (choix documenté en + tête de module — la sonde, pas les chunks de 1) → logits[0, -1] ; + 2. z-norm ×z_scale ; + 3. rep-pen sur l'ensemble des tokens émis ; + 4. bias des organes (+δ brut sur les ids — après z-norm/rep-pen, + AVANT top-k/p) ; + 4bis. VERROU DE RÉPONSE (Plan 7) : à l'étape i < len(answer_lock), + +answer_boost sur answer_lock[i] — même place dans le contrat. + C'est LE mécanisme d'articulation : les organes (Brain.speak_answer) + fournissent les tokens BPE COMPLETS de la réponse (" tokyo" = [id + ' to', id 'ky', id 'o']), et le verrou place chaque token À + TRAVERS la distribution du noyau, un pas après l'autre — le + lexique, les transitions et la SUITE de la phrase restent ceux du + 1B, seule la réponse est verrouillée. Boost 30 par défaut, CALIBRÉ + PAR MESURE sur le 1B (le 12 du plan perd le tirage quand le token + verrouillé vit à −1σ et le top à +4σ : z[' par']=−3.0 vs zmax=11.9 + — voir tête de module) : dominance quasi totale et BORNÉE (comme le + bias_tokens, jamais de sommation non bornée) ; + 5. top-k puis top-p (optionnels) ; + 6. softmax/température → multinomial sur un ``torch.Generator`` seedé. + + EOS : append-then-break (sémantique ratifiée P4) — l'EOS clot la + séquence puis est STRIPPÉ au décodage. EOS effectif : le paramètre + l'emporte, sinon ``tokenizer.eos_token_id`` s'il existe (Gpt2BpeTokenizer + l'expose), sinon aucun arrêt. Divergence DÉLIBÉRÉE d'avec + ``vocal.verbalize`` (tokenizer d'abord là-bas) : ici l'appelant + qui passe ``eos_token_id`` fait FORCE de loi — l'override gagne. + + Paramètres par défaut = la recette empirique du ledger (rep_pen 6, + z_scale 3 — la fourchette prouvée est 6-12). + + Renvoie ``(texte, diagnostics)`` où chaque diagnostic est + ``{"logit_std_avant": float, # std des logits BRUTS (avant transforms) + "top3": [(str, float)], # top3 décodé, logits après z-norm/rep-pen/bias + "chosen": str}`` # token émis à ce pas (déjà strippé de rien) + + Le texte passe par ``safe_decode_text`` (jamais de surrogates à + l'affichage). Déterminisme : même seed + même états initiaux (reset + au départ) ⇒ mêmes paroles, mêmes diagnostics. + """ + if max_new_tokens < 1: + raise ValueError(f"max_new_tokens doit être >= 1 (reçu {max_new_tokens})") + if temperature <= 0.0: + raise ValueError( + "speak est le chemin ÉCHANTILLONNÉ (temperature > 0) — " + "le greedy déterministe vit dans core.generate" + ) + if rep_pen <= 0.0: + raise ValueError(f"rep_pen doit être > 0 (reçu {rep_pen})") + if top_k is not None and top_k < 1: + raise ValueError(f"top_k doit être >= 1 (reçu {top_k})") + if top_p is not None and not 0.0 < top_p <= 1.0: + raise ValueError(f"top_p doit être dans (0, 1] (reçu {top_p})") + if answer_lock is not None and answer_boost <= 0.0: + raise ValueError(f"answer_boost doit être > 0 (reçu {answer_boost})") + + ids = list(tokenizer.encode(prompt)) + if not ids: + raise ValueError("prompt vide : l'encodage n'a produit aucun token") + + # Fenêtre gauche (garde-fou, même politique que vocal.verbalize) : on + # garde les DERNIERS tokens — la fin du prompt compte plus que son début. + max_seq_len = int( + getattr(getattr(core, "config", None), "max_seq_len", _FALLBACK_MAX_SEQ_LEN) + ) + if len(ids) > max_seq_len: + ids = ids[-max_seq_len:] + + try: + device = next(core.parameters()).device + except (StopIteration, AttributeError): + device = torch.device("cpu") + ctx = torch.tensor([ids], dtype=torch.long, device=device) + + # Recette : les états porteurs repartent de zéro (CteCore). FractusCore + # est stateless et n'expose pas reset_states — duck-typing, pas isinstance. + if hasattr(core, "reset_states"): + core.reset_states(1) + if hasattr(core, "eval"): + core.eval() + + gen = torch.Generator(device=device) + gen.manual_seed(seed) + + # EOS effectif : paramètre > tokenizer. + effective_eos: Optional[int] = ( + eos_token_id + if eos_token_id is not None + else getattr(tokenizer, "eos_token_id", None) + ) + + vocab = None # résolu au premier forward (taille des logits) + emitted: Set[int] = set() + out_ids: List[int] = [] + diagnostics: List[dict] = [] + + for _step in range(max_new_tokens): + # Choix documenté : contexte COMPLET à chaque pas (la sonde), borné + # à gauche par la fenêtre du noyau. + logits = core(ctx[:, -max_seq_len:])[0, -1].float() + if vocab is None: + vocab = logits.shape[-1] + if bias_tokens: + for tid in bias_tokens: + if not 0 <= int(tid) < vocab: + raise ValueError( + f"bias_tokens id {tid} hors vocab [0, {vocab})" + ) + if answer_lock: + for i, tid in enumerate(answer_lock): + if not 0 <= int(tid) < vocab: + raise ValueError( + f"answer_lock[{i}] id {tid} hors vocab [0, {vocab})" + ) + + std_before = float(logits.std()) + + # 2-4 : z-norm → rep-pen → bias (l'ordre est le contrat). + t = znorm_logits(logits, scale=z_scale) + t = apply_rep_penalty(t, emitted, penalty=rep_pen) + if bias_tokens: + delta = torch.zeros_like(t) + for tid, d in bias_tokens.items(): + delta[int(tid)] = float(d) + t = t + delta + # 4bis : le verrou de réponse (Plan 7) — à l'étape i, +answer_boost + # sur answer_lock[i]. APRÈS transforms, AVANT top-k/p : le token + # verrouillé survit aux masques (il est top-1 après le boost) et les + # diagnostics top3 ci-dessous le montrent à sa place. ``t`` est + # toujours un tenseur frais à ce point (sortie de znorm/rep-pen/bias, + # jamais les logits du forward) — l'écriture indexée est sûre. + if answer_lock and _step < len(answer_lock): + locked = int(answer_lock[_step]) + t[locked] = t[locked] + answer_boost + + # top3 diagnostics : après z-norm/rep-pen/bias (les masques top-k/p + # y mettraient des −inf — ici les valeurs sont toujours réelles). + top3_vals, top3_idx = torch.topk(t, min(3, t.shape[-1])) + top3 = [ + (safe_decode_text(tokenizer.decode([int(i)])), float(v)) + for i, v in zip(top3_idx.tolist(), top3_vals.tolist()) + ] + + # 5-6 : masques optionnels puis échantillonné seedé. + if top_k is not None: + t = apply_top_k(t, top_k) + if top_p is not None: + t = apply_top_p(t, top_p) + probs = torch.softmax(t / temperature, dim=-1) + next_tok = torch.multinomial(probs, 1, generator=gen) + tid = int(next_tok.item()) + + emitted.add(tid) + out_ids.append(tid) + diagnostics.append( + { + "logit_std_avant": std_before, + "top3": top3, + "chosen": safe_decode_text(tokenizer.decode([tid])), + } + ) + + ctx = torch.cat([ctx, next_tok.view(1, 1)], dim=1) + + # Append-then-break : l'EOS clot la séquence, rien après lui. + if effective_eos is not None and tid == int(effective_eos): + break + + # L'EOS ne se verbalise pas (P4) : on retire UN unique EOS final. + if effective_eos is not None and out_ids and out_ids[-1] == int(effective_eos): + out_ids = out_ids[:-1] + + return safe_decode_text(tokenizer.decode(out_ids)), diagnostics diff --git a/fractus_vorax/model/vocal.py b/fractus_vorax/model/vocal.py new file mode 100644 index 0000000000000000000000000000000000000000..7318c67c2852ead783b4a91619ade01fe6a902b1 --- /dev/null +++ b/fractus_vorax/model/vocal.py @@ -0,0 +1,186 @@ +"""fractus_vorax/model/vocal.py — le noyau lit les cartes (Plan 3, tâche 3). + + Pipeline de verbalisation : sérialisation protocolaire déterministe des + cartes → encodage → génération greedy par FractusCore → décodage. + + serialize_cards(question, cards) ──► préfixe protocolaire + "[FAIT] … (sim 0.90)" + "[ANALOGIE] … (sim 1.00)" + "Q: {question}" + verbalize(core, question, cards, tokenizer) ──► texte du noyau + + GATE torch : ce fichier vit dans fractus_vorax/model/ (seul endroit autorisé à + toucher torch) mais n'importe torch QUE paresseusement, à l'intérieur des + fonctions — importer fractus_vorax.model.vocal dans un python sans torch (substrat + mingw) ne doit jamais lever. `core_available()` sonde ce python par + try-import. +""" + +from __future__ import annotations + +from typing import List, Sequence + +from fractus_vorax.cards import Card + +# Taille de fenêtre de repli si le noyau n'expose pas config.max_seq_len +# (valeur par défaut de FractusCoreConfig). +_FALLBACK_MAX_SEQ_LEN = 256 + + +# --------------------------------------------------------------------------- +# Sérialisation protocolaire +# --------------------------------------------------------------------------- + +def serialize_cards(question: str, cards: Sequence[Card]) -> str: + """Préfixe protocolaire DÉTERMINISTE : cartes triées puis question. + + - Une ligne par carte, format ``[KIND] {text} (sim {sim:.2f})``. + - Tri total par ``(kind, text)`` : ni timestamp, ni aléa, ni ordre + d'arrivée — même entrée, même octets (le noyau doit revoir exactement + le même contexte pour la même question). + - La dernière ligne est toujours ``Q: {question}`` (sans newline final). + """ + lines = [ + f"[{card.kind}] {card.text} (sim {card.sim:.2f})" + for card in sorted(cards, key=lambda c: (c.kind, c.text)) + ] + lines.append(f"Q: {question}") + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Tokenizer de test/démo +# --------------------------------------------------------------------------- + +class CharTokenizer: + """Tokenizer char-level factice (test/demo) — encode = codepoints mod vocab. + + Le tokenizer réel (BPE du checkpoint) arrivera avec le checkpoint ; + tout objet exposant ``encode(str) -> list[int]`` et + ``decode(list[int]) -> str`` est accepté par ``verbalize``. + + ``vocab_size=1114112`` (0x110000, tout Unicode) rend l'encodage + injectif : le round-trip est exact. + """ + + def __init__(self, vocab_size: int = 0x110000): + if vocab_size < 1: + raise ValueError("vocab_size must be >= 1") + self.vocab_size = vocab_size + + def encode(self, text: str) -> List[int]: + """Chaque caractère → son codepoint modulo vocab_size.""" + return [ord(ch) % self.vocab_size for ch in text] + + def decode(self, ids: Sequence[int]) -> str: + """Chaque id → chr(id) — inverse exact de encode si vocab complet.""" + return "".join(chr(int(i)) for i in ids) + + +# --------------------------------------------------------------------------- +# Disponibilité du noyau +# --------------------------------------------------------------------------- + +def core_available() -> bool: + """True si torch ET FractusCore sont importables dans CE python. + + Jamais d'import au niveau module : le substrat mingw (sans torch) doit + pouvoir importer fractus_vorax.model.vocal sans crash et obtenir False ici. + """ + try: + import torch # noqa: F401 + from fractus_vorax.model.fractus_core import FractusCore # noqa: F401 + except Exception: # torch absent, fractus_core absent, ou dépendance cassée + return False + return True + + +# --------------------------------------------------------------------------- +# Texte sûr pour l'affichage +# --------------------------------------------------------------------------- + +def safe_decode_text(text: str) -> str: + """str → str ne contenant JAMAIS de surrogates/codepoints non encodables. + + Chaque codepoint non encodable en UTF-8 (surrogates isolés U+D800..U+DFFF, + produits par un décodage de tokens sauvages) devient U+FFFD via + ``.encode("utf-8", errors="replace")`` ; le second ``replace`` est une + ceinture de plus (no-op en pratique). Toutes les lignes ``[NOYAU]`` du + REPL passent par ici : jamais d'UnicodeEncodeError à l'impression. + """ + return text.encode("utf-8", errors="replace").decode("utf-8", errors="replace") + + +def _strip_eos(ids: List[int], eos: int | None) -> List[int]: + """Retire UN unique token EOS FINAL (directive de la revue Plan 4/T2). + + ``FractusCore.generate`` inclut l'EOS dans la séquence retournée + (append-then-break) : sans ce retrait, "<|endoftext|>" fuiterait dans + le texte verbalisé. Contrat : ``eos is None`` ou dernier id != eos → + liste inchangée ; sinon la liste moins SON dernier élément (un seul + EOS retiré, jamais plus). + """ + if eos is not None and len(ids) > 0 and ids[-1] == eos: + return ids[:-1] + return ids + + +# --------------------------------------------------------------------------- +# Verbalisation +# --------------------------------------------------------------------------- + +def verbalize( + core, + question: str, + cards: Sequence[Card], + tokenizer, + max_new_tokens: int = 48, + eos_token_id: int | None = None, +) -> str: + """Le noyau lit les cartes et répond : pipeline complet, greedy déterministe. + + 1. Prompt = ``serialize_cards(question, cards)`` (inclut la ligne ``Q:``). + 2. Encodage via ``tokenizer.encode(str) -> list[int]`` (protocole minimal). + 3. Troncature PAR LA GAUCHE à ``core.config.max_seq_len`` (repli 256) : + on garde les DERNIERS tokens — la fin de la question compte plus que + les préfixes de cartes (le pos_embed du checkpoint réel ne couvre que + 16 positions). + 4. ``core.generate`` greedy (temperature=0, déterministe), stoppé par + l'EOS effectif : ``tokenizer.eos_token_id`` si le tokenizer l'expose, + sinon le paramètre ``eos_token_id`` (None = pas d'arrêt EOS). + 5. Décodage de la CONTINUATION (tokens nouveaux uniquement — pas l'écho + du prompt) via ``tokenizer.decode(list[int]) -> str``, APRÈS retrait + de l'éventuel EOS final émis (sémantique append-then-break de + generate : l'EOS clôt la séquence mais ne se verbalise pas). + 6. ``safe_decode_text`` sur la sortie : jamais de surrogates. + """ + import torch # paresseux : gate — jamais au niveau module + + prompt = serialize_cards(question, cards) + ids = list(tokenizer.encode(prompt)) + + # Fenêtre du noyau : garder les derniers max_seq_len tokens. + max_seq_len = getattr(getattr(core, "config", None), "max_seq_len", _FALLBACK_MAX_SEQ_LEN) + if len(ids) > max_seq_len: + ids = ids[-max_seq_len:] + + try: + device = next(core.parameters()).device + except (StopIteration, AttributeError): + device = torch.device("cpu") + input_ids = torch.tensor([ids], dtype=torch.long, device=device) + + # EOS effectif : celui du tokenizer s'il l'expose, sinon le paramètre. + tok_eos = getattr(tokenizer, "eos_token_id", None) + effective_eos = tok_eos if tok_eos is not None else eos_token_id + + output_ids = core.generate( + input_ids, + max_new_tokens=max_new_tokens, + temperature=0.0, + eos_token_id=effective_eos, + ) + + continuation = output_ids[0, len(ids):].tolist() + continuation = _strip_eos(continuation, effective_eos) + return safe_decode_text(tokenizer.decode(continuation)) diff --git a/fractus_vorax/organs/__init__.py b/fractus_vorax/organs/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/fractus_vorax/organs/consolidation.py b/fractus_vorax/organs/consolidation.py new file mode 100644 index 0000000000000000000000000000000000000000..c2c4673a7e7902e07a4a885f03eadc40b2578ccb --- /dev/null +++ b/fractus_vorax/organs/consolidation.py @@ -0,0 +1,49 @@ +# fractus_vorax/organs/consolidation.py +"""Consolidation : les atomes co-retrouvés souvent se lient en concepts composites. +Comptage append-only, paires triées, déterminisme total.""" +import json +from itertools import combinations +from pathlib import Path + + +class CoActivation: + def __init__(self) -> None: + self.counts: dict[tuple[int, int], int] = {} + + def record(self, atom_ids: list[int]) -> None: + ids = sorted(set(atom_ids)) + for a, b in combinations(ids, 2): + self.counts[(a, b)] = self.counts.get((a, b), 0) + 1 + + def promote(self, threshold: int = 3) -> list[tuple[int, int]]: + pairs = [(pair, c) for pair, c in self.counts.items() if c >= threshold] + pairs.sort(key=lambda x: (-x[1], x[0])) + return [pair for pair, _ in pairs] + + def forget(self, pairs: list[tuple[int, int]]) -> None: + """Retire des paires du compteur (ignore les absentes). + + Appelé par Brain.consolidate après promotion : sans cela, les paires + restent comptées et un second consolidate() les re-promeut (doublon). + """ + for pair in pairs: + self.counts.pop(pair, None) + + def stats(self) -> dict: + max_count = max(self.counts.values(), default=0) + return {"pairs": len(self.counts), "max_count": max_count} + + def save(self, path: Path) -> None: + path.mkdir(parents=True, exist_ok=True) + payload = {f"{a}|{b}": c for (a, b), c in sorted(self.counts.items())} + (path / "coactivation.json").write_text( + json.dumps(payload, sort_keys=True), encoding="utf-8" + ) + + @classmethod + def load(cls, path: Path) -> "CoActivation": + co = cls() + for key, count in json.loads((path / "coactivation.json").read_text(encoding="utf-8")).items(): + a, b = key.split("|") + co.counts[(int(a), int(b))] = int(count) + return co diff --git a/fractus_vorax/organs/hebbian.py b/fractus_vorax/organs/hebbian.py new file mode 100644 index 0000000000000000000000000000000000000000..0a8968446001d9a4847808d4a7243305c7423393 --- /dev/null +++ b/fractus_vorax/organs/hebbian.py @@ -0,0 +1,81 @@ +# fractus_vorax/organs/hebbian.py +"""Organe 2 — cortex hebbien : mémoire associative en forme close. +Écriture = produit externe additionné (jamais un gradient), complétion = sign(M @ q). +Porte ΔE : une écriture qui casse une sonde existante est refusée et annulée.""" +import json +from pathlib import Path + +import numpy as np + +from ..hv import pack, unpack + +D_H_DEFAULT = 2048 + + +class HebbianMemory: + def __init__(self, D_h: int = D_H_DEFAULT) -> None: + self.D_h = D_h + self.M = np.zeros((D_h, D_h), dtype=np.int32) + self._ledger: list[tuple[np.ndarray, np.ndarray]] = [] + + @property + def n_writes(self) -> int: + return len(self._ledger) + + def __len__(self) -> int: + return len(self._ledger) + + def write(self, key: np.ndarray, value: np.ndarray) -> int: + # M = Σ v kᵀ (hétéro-associatif) pour que sign(M @ k_j) ≈ v_j ; + # l'orientation k vᵀ du brief rendait M @ q = k·(v·q), i.e. ±k, jamais v. + self.M += np.outer(value.astype(np.int32), key.astype(np.int32)) + self._ledger.append((key, value)) + return len(self._ledger) - 1 + + def write_gated( + self, key: np.ndarray, value: np.ndarray, + probes: list[tuple[np.ndarray, np.ndarray]], + ) -> bool: + self.write(key, value) + if all(np.array_equal(self.complete(pk), pv) for pk, pv in probes): + return True + self.rollback() + return False + + def complete(self, query: np.ndarray) -> np.ndarray: + acc = self.M @ query.astype(np.int32) + return np.where(acc >= 0, 1, -1).astype(np.int8) + + def rollback(self) -> bool: + if not self._ledger: + return False + key, value = self._ledger.pop() + self.M -= np.outer(value.astype(np.int32), key.astype(np.int32)) + return True + + def save(self, path: Path) -> None: + path.mkdir(parents=True, exist_ok=True) + n = len(self._ledger) + n_bytes = self.D_h // 8 + if n: + keys = np.stack([pack(k) for k, _ in self._ledger]) + values = np.stack([pack(v) for _, v in self._ledger]) + else: + keys = np.zeros((0, n_bytes), dtype=np.uint8) + values = keys.copy() + np.savez_compressed(path / "hebbian.npz", M=self.M, keys=keys, values=values) + (path / "hebbian_meta.json").write_text( + json.dumps({"D_h": self.D_h, "n_writes": n}, sort_keys=True), encoding="utf-8" + ) + + @classmethod + def load(cls, path: Path) -> "HebbianMemory": + meta = json.loads((path / "hebbian_meta.json").read_text(encoding="utf-8")) + mem = cls(D_h=int(meta["D_h"])) + data = np.load(path / "hebbian.npz") + mem.M = data["M"].astype(np.int32) + for i in range(int(meta["n_writes"])): + key = unpack(data["keys"][i], mem.D_h) + value = unpack(data["values"][i], mem.D_h) + mem._ledger.append((key, value)) + return mem diff --git a/fractus_vorax/organs/spawn.py b/fractus_vorax/organs/spawn.py new file mode 100644 index 0000000000000000000000000000000000000000..4550c8a7e971dd4780f1620b1421bcf994ed35e8 --- /dev/null +++ b/fractus_vorax/organs/spawn.py @@ -0,0 +1,78 @@ +# fractus_vorax/organs/spawn.py +"""Organe 3 — croissance : chaque source ingérée devient un expert routé par +sa signature HV. Le modèle grossit physiquement, sans entraînement conjoint.""" +import json +from dataclasses import dataclass +from pathlib import Path + +import numpy as np + +from ..hv import pack, sim, unpack + +D_SIG = 8192 # les signatures vivent dans l'espace D du brain + + +@dataclass(frozen=True) +class Expert: + name: str + atom_start: int + atom_end: int + n_atoms: int + + +class ExpertRegistry: + def __init__(self) -> None: + self.experts: dict[str, Expert] = {} + self.signatures: dict[str, np.ndarray] = {} + + def __len__(self) -> int: + return len(self.experts) + + def spawn(self, name: str, signature: np.ndarray, atom_start: int, atom_end: int) -> Expert: + expert = Expert(name, atom_start, atom_end, atom_end - atom_start + 1) + self.experts[name] = expert + self.signatures[name] = signature + return expert + + def get(self, name: str) -> Expert | None: + return self.experts.get(name) + + def route(self, query_hv: np.ndarray, top: int = 2) -> list[tuple[str, float]]: + if not self.experts: + return [] + scored = [(name, sim(query_hv, sig)) for name, sig in self.signatures.items()] + scored.sort(key=lambda x: (-x[1], x[0])) + return scored[:top] + + def save(self, path: Path) -> None: + path.mkdir(parents=True, exist_ok=True) + payload = [ + {"name": e.name, "atom_start": e.atom_start, "atom_end": e.atom_end, "n_atoms": e.n_atoms} + for e in sorted(self.experts.values(), key=lambda e: e.name) + ] + (path / "experts.json").write_text( + json.dumps(payload, sort_keys=True, indent=2), encoding="utf-8" + ) + names = sorted(self.signatures) + bits = [pack(self.signatures[n]) for n in names] + np.savez_compressed( + path / "signatures.npz", + names=np.array(names), + bits=np.stack(bits) if bits else np.array([], dtype=np.uint8), + ) + + @classmethod + def load(cls, path: Path) -> "ExpertRegistry": + reg = cls() + for item in json.loads((path / "experts.json").read_text(encoding="utf-8")): + reg.experts[item["name"]] = Expert( + item["name"], item["atom_start"], item["atom_end"], item["n_atoms"] + ) + data = np.load(path / "signatures.npz", allow_pickle=False) + names = [str(n) for n in data["names"]] + if names: + n_bytes = data["bits"].shape[1] + D = n_bytes * 8 + for i, name in enumerate(names): + reg.signatures[name] = unpack(data["bits"][i], D) + return reg diff --git a/fractus_vorax/organs/traces.py b/fractus_vorax/organs/traces.py new file mode 100644 index 0000000000000000000000000000000000000000..c312f8ce69f2dba76c56e073c476b6f45ec956fa --- /dev/null +++ b/fractus_vorax/organs/traces.py @@ -0,0 +1,78 @@ +# fractus_vorax/organs/traces.py +"""Organe 1 — hippocampe : mémoire append-only, on n'écrase jamais, on superpose.""" +import json +from pathlib import Path + +import numpy as np + +from ..hv import D_DEFAULT, pack, unpack + + +class TraceStore: + def __init__(self, D: int = D_DEFAULT) -> None: + self.D = D + self.addrs: list[np.ndarray] = [] + self.values: list[np.ndarray] = [] + self.weights: list[float] = [] + self.ids: list[int] = [] + self._stack: np.ndarray | None = None + + def __len__(self) -> int: + return len(self.ids) + + def write(self, atom_id: int, addr: np.ndarray, value: np.ndarray, weight: float = 1.0) -> int: + self.addrs.append(addr) + self.values.append(value) + self.weights.append(float(weight)) + self.ids.append(int(atom_id)) + self._stack = None + return len(self.ids) - 1 + + def _mat(self) -> np.ndarray: + if self._stack is None: + self._stack = np.stack(self.addrs).astype(np.int16) + return self._stack + + def retrieve(self, query: np.ndarray, k: int = 8) -> list[tuple[int, float]]: + if not self.ids: + return [] + sims = (self._mat() @ query.astype(np.int16)) / self.D + sims = sims * np.asarray(self.weights, dtype=np.float64) + order = np.argsort(-sims)[:k] + return [(self.ids[i], float(sims[i])) for i in order] + + def signature(self) -> np.ndarray: + """Bundle de toutes les addresses (clé de routage future — spawn).""" + if not self.addrs: + raise ValueError("empty store has no signature") + total = np.sum(self._mat(), axis=0, dtype=np.int32) + return np.where(total >= 0, 1, -1).astype(np.int8) + + def save(self, path: Path) -> None: + path.mkdir(parents=True, exist_ok=True) + packed_a = np.concatenate([pack(a) for a in self.addrs]) if self.addrs else np.array([], dtype=np.uint8) + packed_v = np.concatenate([pack(v) for v in self.values]) if self.values else np.array([], dtype=np.uint8) + np.savez_compressed( + path / "traces.npz", + a=packed_a, + v=packed_v, + w=np.asarray(self.weights, dtype=np.float32), + ids=np.asarray(self.ids, dtype=np.int64), + ) + (path / "meta.json").write_text( + json.dumps({"D": self.D, "n": len(self.ids)}, sort_keys=True), encoding="utf-8" + ) + + @classmethod + def load(cls, path: Path) -> "TraceStore": + meta = json.loads((path / "meta.json").read_text(encoding="utf-8")) + store = cls(D=int(meta["D"])) + data = np.load(path / "traces.npz") + for i in range(int(meta["n"])): + a = unpack(data["a"][i * (store.D // 8):(i + 1) * (store.D // 8)], store.D) + v = unpack(data["v"][i * (store.D // 8):(i + 1) * (store.D // 8)], store.D) + store.addrs.append(a) + store.values.append(v) + store.weights = [float(x) for x in data["w"]] + store.ids = [int(x) for x in data["ids"]] + return store diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..2f6a16019fc5ec78464e95e214bd04f5775f6da3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +# pyproject.toml +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "fractus-vorax" +version = "1.0.0" +description = "Fractus ne s'entraine plus : il mange. Knowledge ingestion without training." +requires-python = ">=3.10" +dependencies = ["numpy>=1.24"] + +[project.optional-dependencies] +dev = ["pytest>=8"] + +[project.scripts] +fractus-vorax = "fractus_vorax.agent.cli:main" + +[tool.setuptools.packages.find] +include = ["fractus_vorax*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = ["slow: tests lourds exclus par défaut"] diff --git a/tests/test_analogy.py b/tests/test_analogy.py new file mode 100644 index 0000000000000000000000000000000000000000..06ab2e340aed51b41dc010a57c8b8d2724df8ce0 --- /dev/null +++ b/tests/test_analogy.py @@ -0,0 +1,63 @@ +# tests/test_analogy.py +from fractus_vorax.compiler.analogy import extract_patterns, match_pattern, solve_analogy +from fractus_vorax.compiler.atoms import Atom + +CAPITALS = [ + Atom("what is the capital of france", "paris", "cap.csv:2"), + Atom("what is the capital of spain", "madrid", "cap.csv:3"), + Atom("what is the capital of japan", "tokyo", "cap.csv:4"), + Atom("what is the capital of italy", "rome", "cap.csv:5"), + Atom("who wrote hamlet", "william shakespeare", "books.csv:2"), +] + + +def test_extract_patterns_min_support(): + patterns = extract_patterns(CAPITALS, min_support=3) + assert ("what", "is", "the", "capital", "of") in patterns + assert ("who", "wrote") not in patterns # support 1 < 3 + rel = patterns[("what", "is", "the", "capital", "of")] + assert rel["france"] == "paris" and rel["italy"] == "rome" + + +def test_match_pattern(): + pattern, slot = match_pattern("What is the capital of Korea?") + assert pattern == ("what", "is", "the", "capital", "of") + assert slot == "korea" + assert match_pattern("hello") is None + + +def test_solve_analogy_typo_slot(): + """Slot jamais vu mais morphologiquement proche d'un slot connu.""" + patterns = extract_patterns(CAPITALS) + rel = patterns[("what", "is", "the", "capital", "of")] + hits = solve_analogy("what is the capital of franc", rel, D=4096) + assert hits and hits[0][0] == "paris" + assert hits[0][1] > 0.2 + + +def test_solve_analogy_known_slot_returns_empty(): + """Slot déjà connu = retrieval (job des traces), pas analogie.""" + patterns = extract_patterns(CAPITALS) + rel = patterns[("what", "is", "the", "capital", "of")] + assert solve_analogy("what is the capital of france", rel, D=4096) == [] + + +def test_solve_analogy_unknown_pattern_returns_empty(): + assert solve_analogy("qui est le capitaine", {}, D=4096) == [] + + +def test_morphological_filter(): + """Candidat partageant un préfixe >= 5 chars avec le slot requête est exclu.""" + rel = {"australia": "australian", "austria": "austrian", "bulgaria": "bulgarian", + "croatia": "croatian", "romania": "romanian"} + hits = solve_analogy("demonym of australian", rel, D=4096) # slot requete = australian + answers = [a for a, _ in hits] + assert "australian" not in answers # préfixe partagé avec le slot requête + + +def test_both_methods_agree_on_typo_case(): + patterns = extract_patterns(CAPITALS) + rel = patterns[("what", "is", "the", "capital", "of")] + for method in ("cosadd", "cosmul"): + hits = solve_analogy("what is the capital of spane", rel, D=4096, method=method) + assert hits and hits[0][0] == "madrid" diff --git a/tests/test_answer_lock.py b/tests/test_answer_lock.py new file mode 100644 index 0000000000000000000000000000000000000000..354dec2b3b7bcee6a57f8e7f6686b477cc12e567 --- /dev/null +++ b/tests/test_answer_lock.py @@ -0,0 +1,320 @@ +# tests/test_answer_lock.py +"""Plan 7 — le verrou de réponse : les organes articulent à travers le noyau. + +Quatre angles : +- ``speak(..., answer_lock, answer_boost)`` : à l'étape i, +boost sur + answer_lock[i] (APRÈS z-norm/rep-pen, AVANT top-k/p) — boost par défaut + 30, CALIBRÉ PAR MESURE (le 12 du plan perd le tirage quand le token + verrouillé vit à −1σ et le top à +4σ : z[' par']=−3.0 vs zmax=11.9 sur + le 1B — 2/4 capitales seulement ; le tiny core non-entraîné porte les + mêmes outliers froids, zmax 17-20) : le token verrouillé est choisi à + CHAQUE étape i, 10 seeds sur 10 ; +- ``Brain.speak_answer`` : la liste COMPLÈTE des tokens BPE de " "+réponse + (tokyo pour japan), ANALOGIE en repli, LACUNE -> [] ; +- ``:say`` sans noyau : la dégradation honnête (mingw-safe, jamais torch) ; +- intégration tiny : REPL + tiny CteCore -> les lignes [ORGANES]/[PAROLE], + la réponse verrouillée VISIBLE dans la génération. +""" +import pytest + +from fractus_vorax.agent.repl import Repl +from fractus_vorax.brain import Brain +from fractus_vorax.compiler.atoms import Atom + +CAPITALS = [ + Atom("what is the capital of france", "paris", "cap.csv:2"), + Atom("what is the capital of spain", "madrid", "cap.csv:3"), + Atom("what is the capital of japan", "tokyo", "cap.csv:4"), + Atom("what is the capital of italy", "rome", "cap.csv:5"), +] + + +def _brain() -> Brain: + brain = Brain(D=2048) + brain.ingest_source("capitals", CAPITALS) + return brain + + +class FakeAnswerTok: + """Tokenizer fake du protocole minimal (même table que test_steering) : + « paris » -> [7, 8] comme un vrai BPE qui fusionnerait — la liste + COMPLÈTE est observable, pas juste le premier geste.""" + + vocab_size = 128 + eos_token_id = 127 + _WORDS = { + " paris": [7, 8], + " madrid": [9], + " tokyo": [10], + " rome": [11], + } + + def encode(self, text: str) -> list[int]: + return list(self._WORDS.get(text, [1])) + + def decode(self, ids: list[int]) -> str: + rev = {v[0]: k.strip() for k, v in self._WORDS.items()} + return "".join(rev.get(int(i), "?") for i in ids) + + +class _TinyCharTok: + """Tokenizer char-level sur le vocab tiny (128) — injectif sur les ids < 128.""" + + vocab_size = 128 + + def encode(self, text: str) -> list[int]: + return [ord(c) % 128 for c in text] + + def decode(self, ids: list[int]) -> str: + return "".join(chr(int(i) % 128) for i in ids) + + +# --------------------------------------------------------------------------- +# speak(answer_lock=...) — le verrou domine chaque pas (torch, skip en mingw) +# --------------------------------------------------------------------------- +def _torch_tiny(): + torch = pytest.importorskip( + "torch", reason="speak nécessite torch (substrat sans torch)" + ) + from fractus_vorax.model.cte_core import CteCore, CteCoreConfig + + torch.manual_seed(0) + return torch, CteCore(CteCoreConfig(vocab_size=128)) + + +def test_answer_lock_locks_every_step_ten_seeds(): + """Boost par défaut (30, calibré par mesure sur le 1B — le 12 du plan + perdait contre les outliers zmax≈12-20 alors que z[locked] peut vivre à + −3σ) : à CHAQUE étape i le token émis EST answer_lock[i], 10 seeds sur + 10 — la réponse des organes passe À TRAVERS la distribution du noyau, + pas à côté.""" + _, core = _torch_tiny() + from fractus_vorax.model.speak import speak + + tok = _TinyCharTok() + lock = [42, 84, 7] # ids distincts : le rep-pen ne touche jamais le verrou + for seed in range(10): + _, diags = speak( + core, tok, "hello", max_new_tokens=3, + answer_lock=lock, seed=seed, + ) + assert [d["chosen"] for d in diags] == [tok.decode([i]) for i in lock], ( + f"seed {seed}: le verrou n'a pas tenu à chaque pas" + ) + + +def test_answer_lock_boost_twelve_is_honestly_weak(): + """Le 12 du plan, gardé comme MESURE honnête : sur le tiny core non + entraîné (outliers zmax≈18 froids), +12 sur un token à −1σ NE domine + PAS — c'est la mesure qui a motivé le défaut 30 (voir speak.py). Ce + test fige la frontière : le boost place le token, il ne le force pas + magiquement.""" + _, core = _torch_tiny() + from fractus_vorax.model.speak import speak + + tok = _TinyCharTok() + text12, _ = speak( + core, tok, "hello", max_new_tokens=1, + answer_lock=[42], answer_boost=12.0, seed=0, + ) + text30, _ = speak( + core, tok, "hello", max_new_tokens=1, + answer_lock=[42], answer_boost=30.0, seed=0, + ) + assert text30 == tok.decode([42]) # 30 : le verrou tient (même seed) + assert text12 != tok.decode([42]) # 12 : z[42]−1.2+12 < zmax 18.4 — perdu + + +def test_answer_lock_survives_top_k_mask(): + """Le verrou vit AVANT top-k/p : le token boosté est top-1, il survit au + masque — top_k=5 ne casse pas l'articulation.""" + _, core = _torch_tiny() + from fractus_vorax.model.speak import speak + + tok = _TinyCharTok() + for seed in range(5): + _, diags = speak( + core, tok, "hello", max_new_tokens=2, + answer_lock=[42, 84], top_k=5, seed=seed, + ) + assert [d["chosen"] for d in diags] == [tok.decode([42]), tok.decode([84])] + + +def test_answer_lock_longer_than_generation_is_fine(): + """len(answer_lock) > max_new_tokens : le verrou s'arrête avec la boucle, + aucune erreur, les pas émis sont verrouillés.""" + _, core = _torch_tiny() + from fractus_vorax.model.speak import speak + + tok = _TinyCharTok() + text, diags = speak( + core, tok, "hello", max_new_tokens=2, + answer_lock=[42, 84, 7, 90, 55], seed=0, + ) + assert len(diags) == 2 + assert [d["chosen"] for d in diags] == [tok.decode([42]), tok.decode([84])] + assert isinstance(text, str) + + +def test_answer_lock_rejects_bad_arguments(): + _, core = _torch_tiny() + from fractus_vorax.model.speak import speak + + tok = _TinyCharTok() + with pytest.raises(ValueError): # id hors vocab + speak(core, tok, "hello", max_new_tokens=2, answer_lock=[999]) + with pytest.raises(ValueError): # boost nul ou négatif + speak(core, tok, "hello", max_new_tokens=2, answer_lock=[42], answer_boost=0.0) + + +def test_answer_lock_visible_in_top3_diagnostics(): + """Le boost est posé AVANT les diagnostics top3 : le token verrouillé + y apparaît (observabilité du mécanisme — il est affiché, pas caché).""" + _, core = _torch_tiny() + from fractus_vorax.model.speak import speak + + tok = _TinyCharTok() + _, diags = speak(core, tok, "hello", max_new_tokens=1, answer_lock=[42], seed=0) + top3_tokens = [s for s, _v in diags[0]["top3"]] + assert tok.decode([42]) in top3_tokens + assert top3_tokens[0] == tok.decode([42]) # top-1 après le boost + + +# --------------------------------------------------------------------------- +# Brain.speak_answer — substrat (les deux venvs) +# --------------------------------------------------------------------------- +def test_speak_answer_japan_is_full_tokyo_list(): + """japan -> la liste COMPLÈTE de " tokyo" (pas juste le premier token — + c'est la différence avec speak_bias : UN verrou, TOUS ses tokens).""" + assert _brain().speak_answer("what is the capital of japan", FakeAnswerTok()) == [10] + + +def test_speak_answer_france_is_full_paris_list(): + """france -> [7, 8] : les DEUX tokens de " paris" — le verrou couvre la + réponse entière, fragment après fragment.""" + assert _brain().speak_answer("what is the capital of france", FakeAnswerTok()) == [7, 8] + + +def test_speak_answer_lacune_is_empty(): + """Cerveau vide -> seule carte LACUNE -> [] : on ne verrouille JAMAIS la + bouche sur une lacune.""" + assert Brain(D=2048).speak_answer("anything", FakeAnswerTok()) == [] + + +def test_speak_answer_rejects_bad_k(): + with pytest.raises(ValueError): + _brain().speak_answer("q", FakeAnswerTok(), k=0) + + +def test_speak_answer_deterministic(): + brain = _brain() + a = brain.speak_answer("what is the capital of japan", FakeAnswerTok()) + b = brain.speak_answer("what is the capital of japan", FakeAnswerTok()) + assert a == b == [10] + + +def test_speak_answer_with_real_gpt2_bpe(): + """Le VRAI BPE GPT-2 : japan -> exactement encode(" tokyo"), liste + complète (se skipe en substrat mingw sans lib tokenizers).""" + bpt = pytest.importorskip("fractus_vorax.model.bpe_tokenizer") + if not bpt.bpe_available(): + pytest.skip("tokenizers indisponible") + tok = bpt.Gpt2BpeTokenizer() + assert _brain().speak_answer("what is the capital of japan", tok) == tok.encode(" tokyo") + assert _brain().speak_answer("what is the capital of france", tok) == tok.encode(" paris") + + +# --------------------------------------------------------------------------- +# :say — dégradation honnête (substrat, les deux venvs) +# --------------------------------------------------------------------------- +def _repl(tmp_path) -> Repl: + brain = Brain(D=2048) + brain.ingest_source("capitals", CAPITALS) + brain.save(tmp_path / "brain") + return Repl(tmp_path / "brain", D=2048) + + +def test_say_without_core_degrades_honestly(tmp_path): + """Sans :core : UNE ligne honnête, jamais de traceback — le contrat + mingw (ce test ne touche jamais torch).""" + r = _repl(tmp_path) + assert r.core is None and r.tokenizer is None + assert r.feed(":say what is the capital of japan") == [ + "[PAROLE] noyau non attaché (:core d'abord)" + ] + # le REPL reste vivant après la dégradation + assert any("[CARTE]" in l for l in r.feed("what is the capital of japan")) + + +def test_say_usage_line_without_question(tmp_path): + r = _repl(tmp_path) + assert r.feed(":say") == ["[PAROLE] usage: :say "] + + +# --------------------------------------------------------------------------- +# Intégration tiny — :say complet sur un tiny CteCore (torch) +# --------------------------------------------------------------------------- +def test_say_tiny_core_organes_and_locked_parole(tmp_path): + """Noeud torch : tiny CteCore + tokenizer char -> les DEUX lignes. + [ORGANES] porte la carte (paris), [PAROLE] porte le prompt + d'articulation ET la génération où la réponse verrouillée est VISIBLE : + les 6 tokens char de " paris" passent À TRAVERS la boucle du noyau.""" + torch = pytest.importorskip( + "torch", reason=":say mécanique nécessite torch (substrat sans torch)" + ) + from fractus_vorax.model.cte_core import CteCore, CteCoreConfig + + r = _repl(tmp_path) + torch.manual_seed(0) + r.core = CteCore(CteCoreConfig(vocab_size=128)) + r.tokenizer = _TinyCharTok() + out = r.feed(":say what is the capital of france") + organes = [l for l in out if l.startswith("[ORGANES]")] + parole = [l for l in out if l.startswith("[PAROLE]")] + assert len(organes) == 1 and len(parole) == 1, f"lignes manquantes: {out}" + assert "paris" in organes[0] # la carte qui connaît : stmt => paris + assert parole[0].startswith( + "[PAROLE] what is the capital of france The answer is" + ) + # LE point du Plan 7 : la réponse des organes apparaît dans la parole + # du noyau (verrou = " paris" char par char, puis suite libre) + assert " paris" in parole[0] + # déterminisme : même seed interne (7) -> même articulation + again = r.feed(":say what is the capital of france") + assert [l for l in again if l.startswith("[PAROLE]")] == parole + + +def test_say_tiny_core_lacune_says_no_lock(tmp_path): + """Cerveau SANS connaissance (aucun atome -> carte LACUNE) : la parole + coule SANS verrou et le DIT — honnête, jamais de verrou sur rien. + (Un brain non vide répond toujours des FAIT bas-sim : la lacune vraie + est le cerveau vide.)""" + torch = pytest.importorskip( + "torch", reason=":say mécanique nécessite torch (substrat sans torch)" + ) + from fractus_vorax.model.cte_core import CteCore, CteCoreConfig + + r = Repl(tmp_path / "brain-vide", D=2048) # pas de meta.json -> brain vide + torch.manual_seed(0) + r.core = CteCore(CteCoreConfig(vocab_size=128)) + r.tokenizer = _TinyCharTok() + out = r.feed(":say what is the capital of france") + parole = [l for l in out if l.startswith("[PAROLE]")] + assert parole and "sans verrou" in parole[0] + assert any("LACUNE" in l for l in out) # la lacune est dite comme lacune + + +def test_say_tiny_core_failure_is_honest(tmp_path): + """Un noyau qui échoue (tokenizer incohérent avec le vocab) -> ligne + ``[PAROLE] indisponible: ...``, jamais de traceback.""" + torch = pytest.importorskip( + "torch", reason=":say mécanique nécessite torch (substrat sans torch)" + ) + from fractus_vorax.model.cte_core import CteCore, CteCoreConfig + + r = _repl(tmp_path) + torch.manual_seed(0) + r.core = CteCore(CteCoreConfig(vocab_size=8)) # vocab minuscule + r.tokenizer = _TinyCharTok() # ids jusqu'à 127 -> hors vocab + out = r.feed(":say what is the capital of france") + assert any(l.startswith("[PAROLE] indisponible:") for l in out) diff --git a/tests/test_atoms.py b/tests/test_atoms.py new file mode 100644 index 0000000000000000000000000000000000000000..f0254b24134db834ee7befc9a50840ee098eaf6a --- /dev/null +++ b/tests/test_atoms.py @@ -0,0 +1,40 @@ +# tests/test_atoms.py +from fractus_vorax.compiler.atoms import Atom + + +def test_row_roundtrip(): + a = Atom("what is the capital of france", "paris", "capitals.csv:2", 1.0) + assert Atom.from_row(a.row()) == a + + +def test_row_roundtrip_default_confidence(): + a = Atom("q", "r", "src:1") + assert Atom.from_row(a.row()) == a + + +def test_row_is_tsv_safe(): + a = Atom("a\tb", "c\td", "s", 0.5) + parts = a.row().split("\t") + assert len(parts) == 4 + assert "\t" not in parts[0] + + +def test_row_newline_safe_roundtrip(): + a = Atom("line1\nline2", "a\rb", "s\tc", 0.5) + r = a.row() + assert "\n" not in r + assert "\r" not in r + b = Atom.from_row(r) + assert b.statement == "line1 line2" + assert b.context == "a b" + assert b.provenance == "s c" + assert b.confidence == 0.5 + + +def test_frozen(): + a = Atom("q", "r", "s") + try: + a.statement = "x" # type: ignore[misc] + assert False, "should be frozen" + except AttributeError: + pass diff --git a/tests/test_bench_smoke.py b/tests/test_bench_smoke.py new file mode 100644 index 0000000000000000000000000000000000000000..dd4179f9e3e7f8f0e5754deee7b4dc6c6716084e --- /dev/null +++ b/tests/test_bench_smoke.py @@ -0,0 +1,14 @@ +# tests/test_bench_smoke.py +import csv +from pathlib import Path + +from bench.ingest_bench import generate_csv + + +def test_generate_csv_deterministic(tmp_path): + p1, p2 = tmp_path / "a.csv", tmp_path / "b.csv" + n1, n2 = generate_csv(p1, 50), generate_csv(p2, 50) + assert n1 == n2 == 50 + assert p1.read_bytes() == p2.read_bytes() + with open(p1, newline="", encoding="utf-8") as fh: + assert sum(1 for _ in csv.reader(fh)) == 51 # header + 50 diff --git a/tests/test_bpe_tokenizer.py b/tests/test_bpe_tokenizer.py new file mode 100644 index 0000000000000000000000000000000000000000..b8f6515bb05441c6f1c5a93330531d78dde87ace --- /dev/null +++ b/tests/test_bpe_tokenizer.py @@ -0,0 +1,36 @@ +# tests/test_bpe_tokenizer.py +import pytest + +bpt = pytest.importorskip("fractus_vorax.model.bpe_tokenizer") + + +def test_bpe_available_probe(): + from fractus_vorax.model.bpe_tokenizer import bpe_available + assert isinstance(bpe_available(), bool) + + +def test_gpt2_known_encodings(): + if not bpt.bpe_available(): + pytest.skip("tokenizers indisponible") + tok = bpt.Gpt2BpeTokenizer() + assert tok.vocab_size == 50257 + assert tok.eos_token_id == 50256 + # encodages GPT-2 canoniques + assert tok.encode("hello world")[0] == 31373 + assert tok.decode([31373, 995]) == "hello world" + + +def test_roundtrip(): + if not bpt.bpe_available(): + pytest.skip("tokenizers indisponible") + tok = bpt.Gpt2BpeTokenizer() + text = "What is the capital of france? ÀÉïö — naïve" + assert tok.decode(tok.encode(text)) == text + + +def test_decode_out_of_range_ids_do_not_crash(): + if not bpt.bpe_available(): + pytest.skip("tokenizers indisponible") + tok = bpt.Gpt2BpeTokenizer() + out = tok.decode([31373, 99999999, -5, 50256]) + assert isinstance(out, str) # filtre, pas d'exception diff --git a/tests/test_brain.py b/tests/test_brain.py new file mode 100644 index 0000000000000000000000000000000000000000..5f37bcf29cd2e67068f9a7b51a3b1725793e7ec2 --- /dev/null +++ b/tests/test_brain.py @@ -0,0 +1,51 @@ +# tests/test_brain.py +import gzip +import json +from pathlib import Path + +from fractus_vorax.brain import Brain +from fractus_vorax.compiler.atoms import Atom + +QA = [ + Atom("what is the capital of france", "paris", "cap.csv:2"), + Atom("what is the capital of spain", "madrid", "cap.csv:3"), + Atom("what is the capital of japan", "tokyo", "cap.csv:4"), + Atom("who wrote hamlet", "william shakespeare", "books.csv:2"), +] + + +def test_ingest_and_ask_top1(): + brain = Brain(D=2048) + brain.ingest_atoms(QA) + assert len(brain) == 4 + hits = brain.ask("what is the capital of france", k=2) + assert hits[0][0].context == "paris" + assert hits[0][1] > 0.3 + + +def test_ask_generalizes_token_overlap(): + """Jamais vu mot à mot, mais les tokens partagés doivent hisser le bon domaine.""" + brain = Brain(D=2048) + brain.ingest_atoms(QA) + hits = brain.ask("what is the capital of germany", k=1) + assert hits[0][0].statement.startswith("what is the capital of") + + +def test_save_load_roundtrip(tmp_path): + brain = Brain(D=2048) + brain.ingest_atoms(QA) + brain.save(tmp_path / "brain") + loaded = Brain.load(tmp_path / "brain") + assert len(loaded) == 4 + assert loaded.ask("who wrote hamlet", k=1)[0][0].context == "william shakespeare" + + +def test_save_is_deterministic(tmp_path): + b1, b2 = Brain(D=2048), Brain(D=2048) + b1.ingest_atoms(QA) + b2.ingest_atoms(QA) + b1.save(tmp_path / "b1") + b2.save(tmp_path / "b2") + assert (tmp_path / "b1" / "meta.json").read_bytes() == (tmp_path / "b2" / "meta.json").read_bytes() + assert (tmp_path / "b1" / "atoms.jsonl.gz").read_bytes() == (tmp_path / "b2" / "atoms.jsonl.gz").read_bytes() + assert (tmp_path / "b1" / "traces.npz").read_bytes() == (tmp_path / "b2" / "traces.npz").read_bytes() diff --git a/tests/test_brain_hebbian.py b/tests/test_brain_hebbian.py new file mode 100644 index 0000000000000000000000000000000000000000..6ddc1876b2b1aae5ba9b9f9c3f32fc89c41de27c --- /dev/null +++ b/tests/test_brain_hebbian.py @@ -0,0 +1,32 @@ +# tests/test_brain_hebbian.py +from fractus_vorax.brain import Brain +from fractus_vorax.compiler.atoms import Atom + +CAPITALS = [ + Atom("what is the capital of france", "paris", "cap.csv:2"), + Atom("what is the capital of spain", "madrid", "cap.csv:3"), + Atom("what is the capital of japan", "tokyo", "cap.csv:4"), + Atom("what is the capital of italy", "rome", "cap.csv:5"), +] + + +def test_hebbian_card_on_question(): + brain = Brain(D=2048) + brain.ingest_source("capitals", CAPITALS) + cards = brain.ask_cards("what is the capital of spain", k=1) + kinds = [c.kind for c in cards] + 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 + heb = [c for c in cards if c.kind == "HEBBIEN"] + if heb: + assert "madrid" in heb[0].text + assert kinds.index("HEBBIEN") == 0 # en tête + + +def test_hebbian_index_roundtrip(tmp_path): + brain = Brain(D=2048) + brain.ingest_source("capitals", CAPITALS) + brain.save(tmp_path / "brain") + loaded = Brain.load(tmp_path / "brain") + assert len(loaded._hebbian_index) == len(brain._hebbian_index) + cards = loaded.ask_cards("what is the capital of japan", k=1) + assert any(c.kind in ("HEBBIEN", "FAIT") for c in cards) diff --git a/tests/test_brain_v2.py b/tests/test_brain_v2.py new file mode 100644 index 0000000000000000000000000000000000000000..92cfd3a69d431451831d2489ecdef1c07ae945a2 --- /dev/null +++ b/tests/test_brain_v2.py @@ -0,0 +1,188 @@ +# tests/test_brain_v2.py +import numpy as np + +from fractus_vorax.brain import ANALOGY_TRIGGER_SIM, Brain +from fractus_vorax.cards import Card +from fractus_vorax.compiler.atoms import Atom +from fractus_vorax.compiler.encode import text_hv +from fractus_vorax.hv import bind + +CAPITALS = [ + Atom("what is the capital of france", "paris", "cap.csv:2"), + Atom("what is the capital of spain", "madrid", "cap.csv:3"), + Atom("what is the capital of japan", "tokyo", "cap.csv:4"), + Atom("what is the capital of italy", "rome", "cap.csv:5"), +] +BOOKS = [Atom("who wrote hamlet", "william shakespeare", "books.csv:2")] + + +def _brain(): + brain = Brain(D=2048) + brain.ingest_source("capitals", CAPITALS) + brain.ingest_source("books", BOOKS) + return brain + + +def test_ingest_source_spawns_expert_and_relations(): + brain = _brain() + assert len(brain.experts) == 2 + assert brain.experts.get("capitals") is not None + assert any("capital" in " ".join(p) for p in brain.relations) + assert brain.hebbian.n_writes >= 5 + + +def test_ask_cards_fact(): + brain = _brain() + cards = brain.ask_cards("what is the capital of japan", k=2) + facts = [c for c in cards if c.kind == "FAIT"] + assert facts and "tokyo" in facts[0].text + assert facts[0].sim > 0.5 + + +def test_ask_cards_analogy_on_typo(): + brain = _brain() + cards = brain.ask_cards("what is the capital of franc", k=3) + ana = [c for c in cards if c.kind == "ANALOGIE"] + assert ana and "paris" in ana[0].text + + +def test_consolidate_creates_composite(): + brain = _brain() + for _ in range(3): + brain.ask_cards("what is the capital of france", k=2) + n0 = len(brain) + promoted = brain.consolidate(threshold=3) + assert promoted >= 1 + assert len(brain) == n0 + promoted + comp = brain.atoms[-1] + assert comp.provenance == "consolidated" + + +def test_consolidate_is_idempotent(): + brain = _brain() + for _ in range(3): + brain.ask_cards("what is the capital of france", k=2) + n1 = brain.consolidate(threshold=3) + n2 = brain.consolidate(threshold=3) + assert n1 >= 1 and n2 == 0 # plus de re-promotion + + +def test_plan1_compat_ask_still_works(): + brain = _brain() + hits = brain.ask("what is the capital of spain", k=1) + assert hits[0][0].context == "madrid" + + +def test_save_load_roundtrip_v2(tmp_path): + brain = _brain() + brain.save(tmp_path / "brain") + loaded = Brain.load(tmp_path / "brain") + assert len(loaded.experts) == 2 + assert loaded.hebbian.n_writes == brain.hebbian.n_writes + assert loaded.ask("who wrote hamlet", k=1)[0][0].context == "william shakespeare" + + +def test_load_plan1_brain_still_works(tmp_path): + """Un brain Plan 1 (sans organes persistés) doit se charger.""" + brain = Brain(D=2048) + brain.ingest_atoms(CAPITALS) + brain.save(tmp_path / "old") + # simuler l'absence des fichiers v2 + import shutil + for sub in ("experts", "hebbian"): + shutil.rmtree(tmp_path / "old" / sub, ignore_errors=True) + (tmp_path / "old" / "coactivation.json").unlink(missing_ok=True) + (tmp_path / "old" / "relations.json").unlink(missing_ok=True) + loaded = Brain.load(tmp_path / "old") + assert len(loaded) == 4 + assert len(loaded.experts) == 0 + + +def test_analogy_trigger_is_named_constant(): + """Le seuil d'analogie est nommé et calibré : typos ~0.63-0.69, exact à 1.0.""" + assert 0.6 < ANALOGY_TRIGGER_SIM < 0.9 + + +def test_consolidate_roundtrip_preserves_composite_addr(tmp_path): + """save/load doit rejouer addr = bind(addr_a, addr_b), pas text_hv(statement).""" + brain = _brain() + for _ in range(3): + brain.ask_cards("what is the capital of france", k=2) + assert brain.consolidate(threshold=3) >= 1 + comp_id = len(brain) - 1 + a_id, b_id = brain._composite_parents[comp_id] + addr_before = brain.traces.addrs[comp_id].copy() + expected = bind(brain.traces.addrs[a_id], brain.traces.addrs[b_id]) + assert np.array_equal(addr_before, expected) + query = text_hv("what is the capital of france", brain.D, brain.seed) + top_before = brain.traces.retrieve(query, k=3)[0][0] + brain.save(tmp_path / "brain") + loaded = Brain.load(tmp_path / "brain") + assert np.array_equal(loaded.traces.addrs[-1], addr_before) + assert loaded._composite_parents[comp_id] == (a_id, b_id) + assert np.array_equal( + loaded.traces.addrs[comp_id], + bind(loaded.traces.addrs[a_id], loaded.traces.addrs[b_id]), + ) + # le retrieval retrace pareil : même top-1 qu'avant la sauvegarde + assert loaded.traces.retrieve(query, k=3)[0][0] == top_before + + +def _force_depth2_composite(brain: Brain, comp_id: int, threshold: int = 3) -> None: + """Fait entrer le composite (profondeur 1) dans une paire co-activée. + + Les addr bind étant quasi orthogonales à tout, une requête hors-domaine + les classe dans le bruit proche de zéro : le top-k déterministe peut + inclure le composite. On essaie quelques (requête, k) ; sinon fallback + white-box : brain.coact.record() direct — même compteur qu'alimente + ask_cards(), acceptable et documenté. + """ + for query, k in [("zzz quantum lorem ipsum", 2), ("zzz quantum lorem ipsum", 3), + ("xyzw quokka blorb", 2), ("xyzw quokka blorb", 3)]: + for _ in range(threshold): + brain.ask_cards(query, k=k) + if any(c >= threshold and comp_id in pair for pair, c in brain.coact.counts.items()): + return + for _ in range(threshold): + brain.coact.record([comp_id, 0]) + + +def test_nested_composite_roundtrip_preserves_addr(tmp_path): + """Composites imbriqués (profondeur ≥ 2) : addr bit-identique au save/load. + + consolidate() lie les addr STOCKÉES des parents (le bind d'un parent + composite n'est pas re-productible par encode_atom), donc + addr = bind(addr_a_stockée, addr_b_stockée) à toute profondeur — + exactement le replay de Brain.load(). Avant le fix, le ré-encodage + divergeait dès la profondeur 2. + + Étape « force top-k » : voir _force_depth2_composite — white-box fallback + documenté si la requête hors-domaine ne classe pas le composite. + """ + brain = _brain() + # profondeur 1 : co-activation répétée sur une question du domaine + for _ in range(3): + brain.ask_cards("what is the capital of france", k=2) + assert brain.consolidate(threshold=3) >= 1 + comp_id = len(brain) - 1 + assert comp_id in brain._composite_parents + + # profondeur 2 : le composite doit co-activer avec un autre atome + _force_depth2_composite(brain, comp_id) + n_before = len(brain) + brain.consolidate(threshold=3) + assert len(brain) > n_before, "la consolidation de profondeur 2 n'a pas eu lieu" + d2 = [aid for aid, (pa, pb) in brain._composite_parents.items() + if aid > comp_id and comp_id in (pa, pb)] + assert d2, "aucun composite de profondeur 2 créé (parents ne contiennent pas comp_id)" + d2_id = d2[0] + p_a, p_b = brain._composite_parents[d2_id] + + addr_before = brain.traces.addrs[d2_id].copy() + brain.save(tmp_path / "brain") + loaded = Brain.load(tmp_path / "brain") + assert np.array_equal(loaded.traces.addrs[d2_id], addr_before) + assert np.array_equal( + loaded.traces.addrs[d2_id], + bind(loaded.traces.addrs[p_a], loaded.traces.addrs[p_b]), + ) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000000000000000000000000000000000000..d1a3a537571eef1be274da4bba560516a12a6390 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,91 @@ +# tests/test_cli.py +from pathlib import Path + +from fractus_vorax.agent import cli + +CSV_CONTENT = ( + "question,answer\n" + "what is the capital of france,paris\n" + "what is the capital of spain,madrid\n" + "what is the capital of japan,tokyo\n" + "who wrote hamlet,william shakespeare\n" +) + + +def _make_csv(tmp_path): + p = tmp_path / "cap.csv" + p.write_text(CSV_CONTENT, encoding="utf-8") + return p + + +def test_ingest_then_ask_end_to_end(tmp_path, capsys): + csv = _make_csv(tmp_path) + brain_dir = tmp_path / "brain" + n = cli.ingest(csv, brain_dir, D=2048, seed=0, kn_cache=tmp_path / "kn") + assert n == 4 + assert (brain_dir / "meta.json").exists() + assert (tmp_path / "kn" / "cap.kn" / "manifest.json").exists() # l'artefact .kn existe + + lines = cli.ask("what is the capital of japan", brain_dir, k=1) + out = "\n".join(lines) + assert "tokyo" in out + assert "[CARTE]" in out + assert "=>" in out # format v2 : FAIT {statement} => {context} (remplace RÉP:) + print(out) + captured = capsys.readouterr() + assert "tokyo" in captured.out + + +def test_ask_empty_brain(tmp_path): + lines = cli.ask("anything", tmp_path / "nope", k=1) + assert lines == ["[CARTE] LACUNES: aucune trace (brain vide ou absent)"] + + +def test_status_reports_counts(tmp_path, capsys): + csv = _make_csv(tmp_path) + cli.ingest(csv, tmp_path / "brain", D=2048, seed=0, kn_cache=tmp_path / "kn") + s = cli.status(tmp_path / "brain") + assert "4" in s + + +def test_main_subcommands(tmp_path, capsys): + csv = _make_csv(tmp_path) + brain = tmp_path / "brain" + assert cli.main(["ingest", str(csv), "--brain", str(brain), "--D", "2048", "--kn-cache", str(tmp_path / "kn")]) == 0 + assert cli.main(["ask", "who wrote hamlet", "--brain", str(brain), "--k", "1"]) == 0 + assert "william shakespeare" in capsys.readouterr().out + assert cli.main(["status", "--brain", str(brain)]) == 0 + + +def test_ingest_recompiles_when_source_changes(tmp_path): + csv = tmp_path / "cap.csv" + csv.write_text( + "question,answer\nwhat is the capital of france,paris\nwho wrote hamlet,william shakespeare\n", + encoding="utf-8", + ) + brain = tmp_path / "brain" + cli.ingest(csv, brain, D=2048, seed=0, kn_cache=tmp_path / "kn") + # Source éditée : 3 QA nouvelles — le cache .kn doit être invalidé puis recompilé. + csv.write_text( + "question,answer\nwhat color is the sky,blue\nwhat sound does a cow make,moo\nwhat is two plus two,four\n", + encoding="utf-8", + ) + cli.ingest(csv, brain, D=2048, seed=0, kn_cache=tmp_path / "kn") + out = "\n".join(cli.ask("what sound does a cow make", brain, k=1)) + assert "moo" in out # réponse uniquement présente dans les nouvelles lignes + + +def test_ingest_reuses_cache_when_source_unchanged(tmp_path): + csv = _make_csv(tmp_path) + kn_dir = tmp_path / "kn" / "cap.kn" + cli.ingest(csv, tmp_path / "brain", D=2048, seed=0, kn_cache=tmp_path / "kn") + mtime = (kn_dir / "atoms.tsv.gz").stat().st_mtime_ns + cli.ingest(csv, tmp_path / "brain2", D=2048, seed=0, kn_cache=tmp_path / "kn") + assert (kn_dir / "atoms.tsv.gz").stat().st_mtime_ns == mtime # pas de recompilation + + +def test_main_missing_source_clean_error(tmp_path, capsys): + rc = cli.main(["ingest", str(tmp_path / "nope.csv"), "--brain", str(tmp_path / "b")]) + assert rc == 1 + out = capsys.readouterr().out + assert any(line.startswith("[VORAX] ERREUR:") for line in out.splitlines()) diff --git a/tests/test_cli_v2.py b/tests/test_cli_v2.py new file mode 100644 index 0000000000000000000000000000000000000000..66fe2db6fe8bb1b94e6b2a5b54c88531d8844eea --- /dev/null +++ b/tests/test_cli_v2.py @@ -0,0 +1,60 @@ +# tests/test_cli_v2.py +from pathlib import Path + +from fractus_vorax.agent import cli + +CSV_CONTENT = ( + "question,answer\n" + "what is the capital of france,paris\n" + "what is the capital of spain,madrid\n" + "what is the capital of japan,tokyo\n" + "what is the capital of italy,rome\n" + "who wrote hamlet,william shakespeare\n" +) + + +def _setup(tmp_path): + p = tmp_path / "cap.csv" + p.write_text(CSV_CONTENT, encoding="utf-8") + brain = tmp_path / "brain" + cli.ingest(p, brain, D=2048, seed=0, kn_cache=tmp_path / "kn") + return p, brain + + +def test_ingest_announces_expert(tmp_path, capsys): + _setup(tmp_path) + out = capsys.readouterr().out + assert "expert 'cap' spawné" in out + + +def test_ask_prints_fact_cards(tmp_path, capsys): + _, brain = _setup(tmp_path) + cli.main(["ask", "what is the capital of japan", "--brain", str(brain), "--k", "2"]) + out = capsys.readouterr().out + assert "[CARTE] FAIT:" in out and "tokyo" in out + + +def test_ask_typo_prints_analogy_card(tmp_path, capsys): + _, brain = _setup(tmp_path) + cli.main(["ask", "what is the capital of franc", "--brain", str(brain), "--k", "2"]) + out = capsys.readouterr().out + assert "[CARTE] ANALOGIE:" in out and "paris" in out + + +def test_status_lists_organs(tmp_path, capsys): + _, brain = _setup(tmp_path) + cli.main(["status", "--brain", str(brain)]) + out = capsys.readouterr().out + assert "organes:" in out and "expert" in out and "relation" in out + + +def test_ingest_empty_csv_no_expert(tmp_path, capsys): + """Un CSV réduit à son en-tête n'ingère rien et ne spawn aucun expert.""" + p = tmp_path / "empty.csv" + p.write_text("question,answer\n", encoding="utf-8") + brain = tmp_path / "brain" + n = cli.ingest(p, brain, D=2048, seed=0, kn_cache=tmp_path / "kn") + out = capsys.readouterr().out + assert n == 0 + assert "aucun expert" in out + assert "spawné" not in out diff --git a/tests/test_consolidation.py b/tests/test_consolidation.py new file mode 100644 index 0000000000000000000000000000000000000000..7a1b0dadfcfca63d147c90f8808745db6abc4765 --- /dev/null +++ b/tests/test_consolidation.py @@ -0,0 +1,29 @@ +# tests/test_consolidation.py +from fractus_vorax.organs.consolidation import CoActivation + + +def test_record_and_promote(): + co = CoActivation() + for _ in range(3): + co.record([0, 1, 2]) # paires (0,1) (0,2) (1,2) x3 + co.record([7, 8]) # x1 + pairs = co.promote(threshold=3) + assert set(pairs) == {(0, 1), (0, 2), (1, 2)} + assert (7, 8) not in pairs + + +def test_order_independent(): + co = CoActivation() + co.record([2, 0]) + co.record([0, 2]) + assert co.promote(threshold=2) == [(0, 2)] + + +def test_stats_and_roundtrip(tmp_path): + co = CoActivation() + for _ in range(4): + co.record([3, 4]) + co.save(tmp_path / "co") + loaded = CoActivation.load(tmp_path / "co") + assert loaded.promote(threshold=4) == [(3, 4)] + assert loaded.stats()["pairs"] == 1 diff --git a/tests/test_core_speak.py b/tests/test_core_speak.py new file mode 100644 index 0000000000000000000000000000000000000000..f704449a5417619841eecc2d99a791d1d46c150c --- /dev/null +++ b/tests/test_core_speak.py @@ -0,0 +1,27 @@ +# tests/test_core_speak.py +"""Plan 4, tâche 4 — bench/core_speak.py : l'évaluation honnête du noyau réel. + +Le test charge le VRAI checkpoint fractus (chargement strict, greedy court) : +skippé sans torch (substrat mingw), skippé si le checkpoint est absent. +Le contrat mesuré est le harnais honnête (runs verbatim + taux), pas la +qualité du texte — le checkpoint actuel est de la salade de mots et le test +l'accepte. +""" +import pytest + +torch = pytest.importorskip("torch", reason="core_speak nécessite torch") + +from bench.core_speak import run_core_speak + + +def test_core_speak_reports_honestly(tmp_path): + import pathlib + ckpt = pathlib.Path(r"C:\Users\PHIL\ZCodeProject\fractus\checkpoints\checkpoints\fractus_1b_latest.pt") + if not ckpt.exists(): + pytest.skip("checkpoint absent") + result = run_core_speak(ckpt, ["what is the capital of france"], brain=None, max_new_tokens=6) + assert len(result["runs"]) == 1 + run = result["runs"][0] + for key in ("question", "no_cards_output", "with_cards_output", "answer_in_output"): + assert key in run + assert isinstance(result["with_cards_answer_rate"], float) diff --git a/tests/test_cte_core.py b/tests/test_cte_core.py new file mode 100644 index 0000000000000000000000000000000000000000..03d45bf081b074a11d7282606a5fd351369ec3c7 --- /dev/null +++ b/tests/test_cte_core.py @@ -0,0 +1,230 @@ +# tests/test_cte_core.py +"""Tests du noyau CTE natif (torch optionnel : SKIP sans torch). + +Fidélité au doc de référence docs/heritage/cte-architecture.md (T1, source de vérité) : +- layout des clés EXACT : 8 top-level + n_layers×27 par bloc (440 sur le réel) ; +- chemin tick_chunk EXCLUSIF ; états porteurs batchés (thought_state, attn_S/z) ; +- recette d'inférence : reset complet → UN chunk prompt → boucle chunks de 1, + greedy = argmax(logits[0, -1]). +""" +from pathlib import Path + +import pytest + +torch = pytest.importorskip("torch", reason="noyau torch optionnel (substrat sans torch)") + +from fractus_vorax.model.cte_core import CteCore, CteCoreConfig + +_REPO = Path(__file__).parent.parent +# Le cerveau de naissance d'abord (canonique, tâche 2 du plan du relais) ; +# repli sur l'héritage vorax (checkpoints/checkpoints/) pour compat. +REAL_CKPT_CANDIDATES = ( + _REPO / "brain" / "FRACTUS_BIRTH.pt", + _REPO / "checkpoints" / "checkpoints" / "FRACTUS_1B_PHASE2_FROZEN_MERGED.pt", +) +REAL_CKPT = next((p for p in REAL_CKPT_CANDIDATES if p.exists()), REAL_CKPT_CANDIDATES[0]) + + +def _tiny(**overrides) -> CteCoreConfig: + return CteCoreConfig(vocab_size=128, **overrides) + + +def test_forward_logits_shape(): + torch.manual_seed(0) + core = CteCore(_tiny()) + ids = torch.randint(0, 128, (1, 8)) + logits = core(ids) + assert logits.shape == (1, 8, 128) + assert torch.isfinite(logits).all() + + +def test_forward_supports_batched_states(): + """Les états porteurs sont batchés : forward (B, L) avec B ≤ config.batch. + + Un forward plus petit tranche les états à B (ils deviennent B-sized). + """ + torch.manual_seed(0) + core = CteCore(_tiny(batch=2)) + assert core.thought_state.shape == (2, 1, 64) + logits = core(torch.randint(0, 128, (2, 8))) + assert logits.shape == (2, 8, 128) + assert core.thought_state.shape == (2, 1, 64) + logits1 = core(torch.randint(0, 128, (1, 5))) + assert logits1.shape == (1, 5, 128) + assert core.thought_state.shape == (1, 1, 64) + + +def test_forward_rejects_batch_above_states(): + core = CteCore(_tiny()) # batch=1 + with pytest.raises(ValueError): + core(torch.zeros(2, 4, dtype=torch.long)) + + +def test_generate_greedy_deterministic(): + torch.manual_seed(0) + core = CteCore(_tiny()) + ids = torch.tensor([[1, 2, 3]]) + a = core.generate(ids, max_new_tokens=8) + b = core.generate(ids, max_new_tokens=8) + assert torch.equal(a, b) + assert a.shape == (1, 11) + # greedy = argmax des logits du prompt chunk (recette : logits[0, -1]) + core.reset_states(1) + with torch.no_grad(): + prompt_logits = core(ids) + assert int(a[0, 3].item()) == int(prompt_logits[0, -1].argmax().item()) + # température > 0 : même seed → même trajectoire (Generator seedé) + c = core.generate(ids, max_new_tokens=8, temperature=1.0, seed=7) + d = core.generate(ids, max_new_tokens=8, temperature=1.0, seed=7) + assert torch.equal(c, d) + # greedy : indépendant du seed (argmax, aucun aléa) + e = core.generate(ids, max_new_tokens=8, seed=123) + assert torch.equal(a, e) + + +def test_generate_stops_at_eos(): + """Append-then-break (sémantique ratifiée P4) : l'EOS clôt, rien après lui.""" + torch.manual_seed(0) + core = CteCore(_tiny()) + ids = torch.tensor([[1, 2, 3]]) + core.reset_states(1) + with torch.no_grad(): + first = int(core(ids)[0, -1].argmax().item()) + out = core.generate(ids, max_new_tokens=8, eos_token_id=first) + assert out.shape == (1, 4) # prompt + 1 token, puis arrêt + assert int(out[0, -1].item()) == first + + +def test_states_update_and_reset(): + torch.manual_seed(0) + core = CteCore(_tiny()) + blk = core.blocks[0] + assert torch.count_nonzero(blk.attn_S) == 0 + ids = torch.randint(0, 128, (1, 8)) + with torch.no_grad(): + l1 = core(ids).clone() + # les états porteurs ont bougé + assert torch.count_nonzero(blk.attn_S) > 0 + assert torch.count_nonzero(blk.attn_z) > 0 + assert torch.count_nonzero(core.thought_state) > 0 + # kuramoto_phases : write-only, shape du dernier forward (B, C, N) + assert blk.kuramoto_phases.shape == (1, 8, core.config.n_oscillators) + # carry : un second forward sur le MÊME prompt donne d'autres logits + with torch.no_grad(): + l2 = core(ids).clone() + assert not torch.equal(l1, l2) + # reset → zéros + replay déterministe + core.reset_states() + assert torch.count_nonzero(blk.attn_S) == 0 + assert torch.count_nonzero(blk.attn_z) == 0 + assert torch.count_nonzero(core.thought_state) == 0 + with torch.no_grad(): + l3 = core(ids).clone() + assert torch.equal(l1, l3) + + +def test_state_dict_key_layout(): + """8 clés top + n_layers×27 par bloc — le motif EXACT des 440 clés réelles + (doc §Table des clés : 8 + 16×27 = 440).""" + cfg = _tiny() + core = CteCore(cfg) + keys = set(core.state_dict().keys()) + + expected = { + "observe.weight", + "output_head.weight", + "confidence_head.weight", "confidence_head.bias", + "salience_head.weight", "salience_head.bias", + "last_lb_loss", + "thought_state", + } + for i in range(cfg.n_layers): + expected |= { + f"blocks.{i}.attn_S", f"blocks.{i}.attn_z", f"blocks.{i}.kuramoto_phases", + f"blocks.{i}.attn.w_qkv", f"blocks.{i}.attn.b_qkv", + f"blocks.{i}.attn.w_out", f"blocks.{i}.attn.b_out", + f"blocks.{i}.attn.level_logits", f"blocks.{i}.attn.level_offsets", + f"blocks.{i}.norm_attn.weight", f"blocks.{i}.norm_attn.bias", + f"blocks.{i}.kuramoto.omega", f"blocks.{i}.kuramoto.coupling_u", + f"blocks.{i}.kuramoto.coupling_lambda", + f"blocks.{i}.norm_kur.weight", f"blocks.{i}.norm_kur.bias", + f"blocks.{i}.moe.U1", f"blocks.{i}.moe.V1", + f"blocks.{i}.moe.U2", f"blocks.{i}.moe.V2", + f"blocks.{i}.moe.scale1", f"blocks.{i}.moe.scale2", + f"blocks.{i}.moe.b1", f"blocks.{i}.moe.b2", + f"blocks.{i}.moe.expert_phases", + f"blocks.{i}.norm_moe.weight", f"blocks.{i}.norm_moe.bias", + } + + assert keys == expected, ( + f"manquantes: {sorted(expected - keys)[:5]} / " + f"inattendues: {sorted(keys - expected)[:5]}" + ) + assert len(keys) == 8 + cfg.n_layers * 27 + # tête liée (doc : les deux clés existent, poids bit-identiques) + assert core.output_head.weight is core.observe.weight + # shapes des états porteurs (batch = config.batch, doc §États porteurs) + assert core.thought_state.shape == (cfg.batch, 1, cfg.d_model) + assert core.blocks[0].attn_S.shape == (cfg.batch, cfg.d_model, cfg.d_model) + assert core.blocks[0].attn_z.shape == (cfg.batch, cfg.d_model) + assert core.blocks[0].kuramoto_phases.shape == ( + cfg.batch, cfg.train_seq_len, cfg.n_oscillators + ) + assert core.last_lb_loss.shape == () + + +def test_checkpoint_roundtrip(tmp_path): + """Sauvegarde {"model_state": sd} → from_checkpoint strict → même génération.""" + torch.manual_seed(0) + cfg = _tiny() + core = CteCore(cfg) + torch.save({"model_state": core.state_dict()}, tmp_path / "tiny_cte.pt") + loaded = CteCore.from_checkpoint(tmp_path / "tiny_cte.pt") + assert loaded.config.d_model == cfg.d_model + # le split n_heads×d_head n'est PAS encodé dans les shapes (w_qkv cubique) — + # l'inférence par défaut doit retomber sur le split tiny 4×16. + assert loaded.config.n_heads == cfg.n_heads + assert loaded.config.d_head == cfg.d_head + # Identité bit à bit PAR CLÉ (revue T2) : le chemin mmap+assign ne doit + # altérer AUCUN octet — y compris les clés sans effet forward que la + # comparaison de génération ne couvrait pas (confidence/salience_head, + # buffers porteurs). AVANT generate : la recette mut les états porteurs. + for k, v in core.state_dict().items(): + assert torch.equal(loaded.state_dict()[k], v), k + assert torch.equal( + loaded.generate(torch.tensor([[1, 2, 3]]), 4), + core.generate(torch.tensor([[1, 2, 3]]), 4), + ) + + +@pytest.mark.slow +def test_real_checkpoint_strict_load(): + """Le VRAI 4.66 GB : strict 440/440, UN forward B=batch, logits finis. + + Lit brain/FRACTUS_BIRTH.pt (le cerveau de naissance, canonique), sinon + l'héritage checkpoints/checkpoints/. Skip si aucun des deux n'est présent. + ~0.5 GB via mmap (fallback: copie complète) — venv torch uniquement. + """ + if not REAL_CKPT.exists(): + pytest.skip("checkpoint CTE 1B FROZEN_MERGED non présent") + core = CteCore.from_checkpoint(REAL_CKPT) + assert len(core.state_dict()) == 440 + assert core.config.d_model == 1280 + assert core.config.n_heads == 20 and core.config.d_head == 64 + assert core.config.n_layers == 16 + assert core.config.n_experts == 128 + assert core.config.vocab_size == 50257 + assert core.config.batch == 2 + ids = torch.zeros(2, 4, dtype=torch.long) + with torch.no_grad(): + logits = core(ids) + assert logits.shape == (2, 4, 50257) + assert torch.isfinite(logits).all() + # Le chemin generate du 1B, lui aussi (revue T2 : il n'était pas + # exercé — un bug dans la recette d'inférence batch 1 passerait inaperçu). + # Recette complète : reset_states(1), UN chunk prompt, boucle chunks de 1. + # CPU ~10-30 s — couvert par le marqueur slow. + with torch.no_grad(): + gen_ids = torch.zeros(1, 2, dtype=torch.long) + out = core.generate(gen_ids, 2) + assert out.shape == (1, 4) diff --git a/tests/test_cte_probe.py b/tests/test_cte_probe.py new file mode 100644 index 0000000000000000000000000000000000000000..058459e33c0e312424e793d4ecf2599b4750bda4 --- /dev/null +++ b/tests/test_cte_probe.py @@ -0,0 +1,34 @@ +# tests/test_cte_probe.py +"""Sonde du doc d'architecture CTE (docs/heritage/cte-architecture.md). + +Doc produit par la tâche T1 du plan 5 (CteCore, vorax — archive heritage) : +il est la source de vérité des sémantiques de forward extraites de +../fractus-cte (LECTURE SEULE) et du sondage du checkpoint 1B (440 clés). +Ces tests sont purs Python (aucun torch requis) — ils tournent dans le venv +mingw. +""" +from pathlib import Path + +DOC = Path(__file__).resolve().parents[1] / "docs" / "heritage" / "cte-architecture.md" + +REQUIRED_SECTIONS = ( + "## Forward", + "## États porteurs", + "## MoE batché", + "## Recette d'inférence", + "## Table des clés", +) + + +def test_cte_doc_exists_and_has_sections(): + """Le doc existe et contient les cinq ancres exigées par le plan 5 T1.""" + text = DOC.read_text(encoding="utf-8") + missing = [s for s in REQUIRED_SECTIONS if s not in text] + assert not missing, f"sections manquantes dans {DOC}: {missing}" + + +def test_cte_doc_cites_sources(): + """Chaque section clé cite ses sources fichier:ligne (rigueur P3).""" + text = DOC.read_text(encoding="utf-8") + assert "continuous_engine.py:" in text, "références continuous_engine.py:ligne absentes" + assert "moe.py:" in text, "références moe.py:ligne absentes (GELU !)" diff --git a/tests/test_cte_wiring.py b/tests/test_cte_wiring.py new file mode 100644 index 0000000000000000000000000000000000000000..db5629b3fcbb8d5d92a397f90971403865fdd73c --- /dev/null +++ b/tests/test_cte_wiring.py @@ -0,0 +1,125 @@ +# tests/test_cte_wiring.py +"""Tests du branchement CTE (Plan 5, tâche 3) : sonde + wiring repl/core_speak. + +torch optionnel : SKIP sans torch (substrat mingw) — même contrat que +tests/test_cte_core.py. + +- ``probe_checkpoint_kind`` distingue un fake CteCore tiny ("cte") d'un fake + FractusCore tiny ("fractus"), wrapper ``model_state`` OU state_dict nu ; + dict sans marqueurs -> "unknown" ; fichier absent -> FileNotFoundError + (les appelants dégradent, ils ne tracent pas). +- REPL ``:core`` : sonde -> CteCore OU FractusCore ; le fake CTE tiny + s'attache sans traceback et répond (ou se dégrade honnêtement) ; un + checkpoint inconnu -> ligne honnête, PAS d'attach. +""" +from pathlib import Path + +import pytest + +torch = pytest.importorskip("torch", reason="noyau torch optionnel (substrat sans torch)") + +from fractus_vorax.model.cte_core import CteCore, CteCoreConfig, probe_checkpoint_kind +from fractus_vorax.model.fractus_core import FractusCore, FractusCoreConfig +from fractus_vorax.agent.repl import Repl + + +# --------------------------------------------------------------------------- +# Fakes tiny : les DEUX espèces de checkpoints, sauvegardés comme le réel +# --------------------------------------------------------------------------- + +def _save_cte_tiny(tmp_path: Path) -> Path: + torch.manual_seed(0) + core = CteCore(CteCoreConfig(vocab_size=128)) + path = tmp_path / "tiny_cte.pt" + torch.save({"model_state": core.state_dict()}, path) + return path + + +def _save_fractus_tiny(tmp_path: Path) -> Path: + torch.manual_seed(0) + core = FractusCore(FractusCoreConfig( + vocab_size=128, d_model=64, n_layers=2, n_experts=4, rank=8, + max_seq_len=32, d_ff=64, + )) + path = tmp_path / "tiny_fractus.pt" + torch.save({"model_state": core.state_dict()}, path) + return path + + +# --------------------------------------------------------------------------- +# probe_checkpoint_kind +# --------------------------------------------------------------------------- + +def test_probe_detects_cte(tmp_path): + assert probe_checkpoint_kind(_save_cte_tiny(tmp_path)) == "cte" + + +def test_probe_detects_fractus(tmp_path): + assert probe_checkpoint_kind(_save_fractus_tiny(tmp_path)) == "fractus" + + +def test_probe_accepts_raw_state_dict(tmp_path): + """State_dict NU au niveau supérieur (format accepté par from_checkpoint).""" + torch.manual_seed(0) + core = CteCore(CteCoreConfig(vocab_size=128)) + path = tmp_path / "raw_cte.pt" + torch.save(core.state_dict(), path) + assert probe_checkpoint_kind(path) == "cte" + + +def test_probe_unknown_dict(tmp_path): + path = tmp_path / "unknown.pt" + torch.save({"model_state": {"foo.weight": torch.zeros(2, 2)}}, path) + assert probe_checkpoint_kind(path) == "unknown" + + +def test_probe_absent_file_raises(tmp_path): + with pytest.raises(FileNotFoundError): + probe_checkpoint_kind(tmp_path / "absent.pt") + + +# --------------------------------------------------------------------------- +# REPL :core — sonde puis classe adéquate, jamais de traceback +# --------------------------------------------------------------------------- + +def _repl(tmp_path) -> Repl: + return Repl(tmp_path / "brain", D=2048) + + +def test_repl_core_attaches_cte_tiny(tmp_path, monkeypatch): + # tokenizer déterministe : pas de réseau dans les tests (le repli + # CharTokenizer de :core est le comportement testé, pas la side-effect). + monkeypatch.setattr("fractus_vorax.agent.repl.bpe_available", lambda: False) + r = _repl(tmp_path) + out = r.feed(f":core {_save_cte_tiny(tmp_path)}") + assert any("[NOYAU] type: cte (1B Continuous Thought Engine)" in l for l in out) + assert any("[NOYAU] noyau chargé" in l for l in out) + assert isinstance(r.core, CteCore) + # une question : le noyau répond OU se dégrade honnêtement — ligne [NOYAU] + # dans les deux cas, jamais de traceback. + q = r.feed("what is the capital of france") + assert any(l.startswith("[NOYAU]") for l in q) + + +def test_repl_core_attaches_fractus_tiny(tmp_path, monkeypatch): + monkeypatch.setattr("fractus_vorax.agent.repl.bpe_available", lambda: False) + r = _repl(tmp_path) + out = r.feed(f":core {_save_fractus_tiny(tmp_path)}") + assert any("[NOYAU] type: fractus" in l for l in out) + assert isinstance(r.core, FractusCore) + + +def test_repl_core_unknown_checkpoint_no_attach(tmp_path): + path = tmp_path / "unknown.pt" + torch.save({"model_state": {"foo.weight": torch.zeros(2, 2)}}, path) + r = _repl(tmp_path) + out = r.feed(f":core {path}") + assert any("[NOYAU]" in l for l in out) + assert r.core is None # pas d'attach sur un type inconnu + + +def test_repl_core_absent_checkpoint_degrades(tmp_path): + r = _repl(tmp_path) + out = r.feed(f":core {tmp_path / 'nope.pt'}") + assert any("[NOYAU] indisponible" in l for l in out) + assert r.core is None diff --git a/tests/test_demo.py b/tests/test_demo.py new file mode 100644 index 0000000000000000000000000000000000000000..22f5e919a90586060d8090b88b39d153efe83812 --- /dev/null +++ b/tests/test_demo.py @@ -0,0 +1,14 @@ +# tests/test_demo.py +"""Tâche 7 — la démo autonome : tokyo (FAIT), typo -> ANALOGIE paris, organes, résumé final.""" +from demo.demo import main + + +def test_demo_runs(): + lines = main() + out = "\n".join(lines) + assert "tokyo" in out # question exacte -> carte FAIT + assert "ANALOGIE" in out # typo 'franc' -> organe d'analogie + assert "organes" in out or "expert" in out # status (CLI ou REPL) + assert lines[-1] == "[VORAX] Démo complète - zéro gradient." + # console-safe : jamais de flèche unicode dans les lignes de sortie (cp1252) + assert all("→" not in l for l in lines) diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py new file mode 100644 index 0000000000000000000000000000000000000000..809c871adbeb672230070f1ffbfc410ef1b9909e --- /dev/null +++ b/tests/test_diagnostics.py @@ -0,0 +1,204 @@ +# tests/test_diagnostics.py +"""Plan 6, tâche 2 — diagnostics.py : l'observabilité à ciel ouvert. + +expert_routing_snapshot : structure valide sur un tiny CteCore (une clé +`layer_{i}` par couche, 2 experts retenus par layer, poids renormalisés +sommant à 1, ids dans [0, n_experts), triés par gate décroissante) ; +EXACTITUDE contre la formule von Mises réécrite à la main sur les phases +captées par un hook frais (κ_eff = 4.0/2.5 = 1.6 — le PIÈGE température du +doc §MoE batché — top-2 renormalisé) ; reproductibilité malgré la pollution +des états porteurs (reset au départ) ; hooks retirés après usage. + +head_readout : floats Python bornés [0, 1) sur CTE (les deux têtes), None +sur FractusCore (pas de têtes confiance/salience — duck-typing honnête). +""" +import pytest + +torch = pytest.importorskip( + "torch", reason="diagnostics nécessite torch (substrat sans torch)" +) + +from fractus_vorax.model.cte_core import CteCore, CteCoreConfig +from fractus_vorax.model.diagnostics import expert_routing_snapshot, head_readout + + +def _tiny(**overrides) -> CteCoreConfig: + return CteCoreConfig(vocab_size=128, **overrides) + + +def _tiny_fractus(): + from fractus_vorax.model.fractus_core import FractusCore, FractusCoreConfig + + cfg = FractusCoreConfig( + vocab_size=128, d_model=64, n_layers=2, n_experts=4, top_k=2, + rank=16, max_seq_len=64, + ) + return FractusCore(cfg) + + +# --------------------------------------------------------------------------- +# expert_routing_snapshot — structure +# --------------------------------------------------------------------------- + +def test_snapshot_structure_tiny(): + torch.manual_seed(0) + cfg = _tiny() + core = CteCore(cfg) + snap = expert_routing_snapshot(core, [10, 20, 30]) + assert set(snap.keys()) == {f"layer_{i}" for i in range(cfg.n_layers)} + for entries in snap.values(): + assert len(entries) == cfg.top_k # 2 experts retenus par layer + ids = [e for e, _ in entries] + weights = [w for _, w in entries] + assert all(isinstance(e, int) and 0 <= e < cfg.n_experts for e in ids) + assert all(isinstance(w, float) and w > 0.0 for w in weights) + assert abs(sum(weights) - 1.0) < 1e-5 # top-k RENORMALISÉ + # triées par gate décroissante (convention torch.topk) + assert weights == sorted(weights, reverse=True) + + +def test_snapshot_layer_count_follows_config(): + """Le snapshot suit le nombre de couches du noyau (tiny 2, ici 3 ; le 1B + réel en aurait 16 — une clé layer_{i} par bloc).""" + torch.manual_seed(0) + cfg = _tiny(n_layers=3) + core = CteCore(cfg) + snap = expert_routing_snapshot(core, [1, 2, 3, 4, 5]) + assert len(snap) == 3 + assert set(snap) == {"layer_0", "layer_1", "layer_2"} + + +# --------------------------------------------------------------------------- +# expert_routing_snapshot — exactitude vs la formule von Mises à la main +# --------------------------------------------------------------------------- + +def test_snapshot_matches_von_mises_formula(): + """EXACTITUDE : ids EXACTS et poids ≈ identiques à la formule von Mises + réécrite à la main dans CE test (doc §MoE batché) sur les phases captées + au hook — κ_eff = 4.0/2.5 = 1.6 hardcodé ici : le test attraperait un + noyau qui aurait oublié le piège température.""" + torch.manual_seed(0) + cfg = _tiny() + core = CteCore(cfg) + ids = torch.tensor([[10, 20, 30]]) + snap = expert_routing_snapshot(core, ids) + + captured = [] # (moe, phases réellement consommées par la passe MoE) + + def hook(module, args, output): + captured.append((module, args[1].detach().clone())) + + handles = [blk.moe.register_forward_hook(hook) for blk in core.blocks] + try: + core.reset_states(1) + core(ids) # même forward, mêmes états initiaux => mêmes phases + finally: + for h in handles: + h.remove() + + assert len(captured) == cfg.n_layers + kappa_eff = 4.0 / 2.5 # doc §MoE batché : κ=4.0, temperature=2.5 + for i, (moe, phases) in enumerate(captured): + theta_bar = torch.atan2( + torch.sin(phases).sum(dim=-1), torch.cos(phases).sum(dim=-1) + )[0, -1] + gates = torch.exp(kappa_eff * torch.cos(theta_bar - moe.expert_phases)) + gates = gates / gates.sum() + vals, idx = gates.topk(cfg.top_k) + expected = [ + (int(e), float(w)) + for e, w in zip(idx.tolist(), (vals / vals.sum()).tolist()) + ] + got = snap[f"layer_{i}"] + assert [e for e, _ in got] == [e for e, _ in expected] + assert all(abs(a - b) < 1e-5 for (_, a), (_, b) in zip(got, expected)) + + +# --------------------------------------------------------------------------- +# expert_routing_snapshot — robustesse, hygiène, duck-typing +# --------------------------------------------------------------------------- + +def test_snapshot_deterministic_across_state_pollution(): + """Le reset au départ rend la lecture reproductible même après que le + noyau ait avalé d'autres chunks (états porteurs mutés par le forward).""" + torch.manual_seed(0) + core = CteCore(_tiny()) + ids = [7, 8, 9] + first = expert_routing_snapshot(core, ids) + core(torch.tensor([[40, 50, 60, 61]])) # pollue attn_S/attn_z/thought_state + second = expert_routing_snapshot(core, ids) + assert first == second + + +def test_snapshot_list_and_tensor_equivalent(): + """Ergonomie : list[int] et tensor (accepté (1, L) et (L,)) disent la + même chose sur le même noyau.""" + torch.manual_seed(0) + core = CteCore(_tiny()) + a = expert_routing_snapshot(core, [10, 20, 30]) + b = expert_routing_snapshot(core, torch.tensor([10, 20, 30])) + c = expert_routing_snapshot(core, torch.tensor([[10, 20, 30]])) + assert a == b == c + + +def test_snapshot_rejects_empty_ids(): + torch.manual_seed(0) + core = CteCore(_tiny()) + with pytest.raises(ValueError): + expert_routing_snapshot(core, []) + + +def test_hooks_removed_and_core_still_usable(): + """Hygiène : les hooks partent après l'appel, le noyau fonctionne + exactement comme avant (aucune trace du passage du diagnosticien).""" + torch.manual_seed(0) + core = CteCore(_tiny()) + expert_routing_snapshot(core, [1, 2]) + head_readout(core, [1, 2]) + for blk in core.blocks: + assert not blk.moe._forward_hooks + assert not core.output_head._forward_hooks + logits = core(torch.tensor([[3, 4]])) + assert tuple(logits.shape) == (1, 2, 128) + + +def test_snapshot_duck_types_fractus_core(): + """Le routage est lisible sur les DEUX noyaux (mêmes conventions moe : + forward(h, phases) + _compute_gates) — FractusCore κ=4.0 direct, phases + par position (on lit la dernière).""" + torch.manual_seed(0) + core = _tiny_fractus() + snap = expert_routing_snapshot(core, [10, 20, 30]) + assert set(snap) == {"layer_0", "layer_1"} + for entries in snap.values(): + assert len(entries) == 2 + weights = [w for _, w in entries] + assert abs(sum(weights) - 1.0) < 1e-5 + + +# --------------------------------------------------------------------------- +# head_readout — têtes confiance/salience du CTE +# --------------------------------------------------------------------------- + +def test_head_readout_cte_floats_bounded(): + """CTE : les deux têtes se lisent — floats Python dans [0, 1) (sigmoid), + reproductibles malgré la pollution des états porteurs.""" + torch.manual_seed(0) + core = CteCore(_tiny()) + out = head_readout(core, [10, 20, 30]) + assert set(out) == {"confidence", "salience"} + assert isinstance(out["confidence"], float) + assert isinstance(out["salience"], float) + assert 0.0 <= out["confidence"] < 1.0 # borné (le plan : « head_readout borné ») + assert 0.0 <= out["salience"] < 1.0 + core(torch.tensor([[90, 91]])) # pollue les états porteurs + again = head_readout(core, [10, 20, 30]) + assert out == again + + +def test_head_readout_fractus_none(): + """FractusCore n'a PAS de têtes confiance/salience (lm_head seule) : + lecture honnête → les deux à None, sans lancer le moindre forward.""" + torch.manual_seed(0) + core = _tiny_fractus() + assert head_readout(core, [1, 2, 3]) == {"confidence": None, "salience": None} diff --git a/tests/test_encode.py b/tests/test_encode.py new file mode 100644 index 0000000000000000000000000000000000000000..80538b059752c181c5190df209e33ed7e0fdbcce --- /dev/null +++ b/tests/test_encode.py @@ -0,0 +1,76 @@ +# tests/test_encode.py +import numpy as np + +from fractus_vorax.compiler.atoms import Atom +from fractus_vorax.compiler.encode import ( + char_ngrams, + encode_atom, + normalize, + slot_hv, + text_hv, + tokenize, +) +from fractus_vorax.hv import sim + + +def test_normalize(): + assert normalize(" What IS The Capital? ") == "what is the capital" + + +def test_tokenize(): + assert tokenize("What is the capital of France?") == ["what", "is", "the", "capital", "of", "france"] + + +def test_text_hv_identical_sentences(): + a = text_hv("what is the capital of france") + b = text_hv("What IS the capital of France?") + assert np.array_equal(a, b) + + +def test_related_more_similar_than_unrelated(): + q1 = text_hv("what is the capital of france") + q2 = text_hv("what is the capital of spain") + far = text_hv("quantum entanglement violates locality") + assert sim(q1, q2) > 0.3 # 5 tokens partagés sur 6 + assert sim(q1, far) < 0.2 # quasi-orthogonal + assert sim(q1, q2) > sim(q1, far) + + +def test_encode_atom_deterministic(): + atom = Atom("what is the capital of france", "paris", "cap.csv:2") + a1, v1 = encode_atom(atom) + a2, v2 = encode_atom(atom) + assert np.array_equal(a1, a2) and np.array_equal(v1, v2) + + +def test_encode_atom_addr_is_statement(): + atom = Atom("who wrote hamlet", "william shakespeare", "s:1") + addr, value = encode_atom(atom) + assert np.array_equal(addr, text_hv("who wrote hamlet")) + assert not np.array_equal(value, addr) # le contexte change la valeur + + +def test_encode_atom_no_context_value_equals_addr(): + atom = Atom("un fait nu", "", "s:1") + addr, value = encode_atom(atom) + assert np.array_equal(value, addr) + + +def test_char_ngrams_padded(): + assert char_ngrams("ab") == ["#ab", "ab#"] + + +def test_char_ngrams_length_three(): + assert char_ngrams("abcd") == ["#ab", "abc", "bcd", "cd#"] + + +def test_slot_hv_typo_similarity(): + a = slot_hv("france", D=4096) + b = slot_hv("franca", D=4096) + c = slot_hv("japan", D=4096) + assert sim(a, b) > 0.4 # 3-grammes partagés + assert sim(a, c) < 0.2 # quasi-orthogonal + + +def test_slot_hv_deterministic(): + assert np.array_equal(slot_hv("spain", D=2048), slot_hv("spain", D=2048)) diff --git a/tests/test_fractus_core.py b/tests/test_fractus_core.py new file mode 100644 index 0000000000000000000000000000000000000000..dd20f5b4e0bf801aae11e77276e7911b5633714b --- /dev/null +++ b/tests/test_fractus_core.py @@ -0,0 +1,116 @@ +# tests/test_fractus_core.py +"""Tests du noyau fractus natif (torch optionnel : SKIP sans torch). + +Le module entier est skippé dans le venv mingw (sans torch) via +pytest.importorskip — le substrat fractus_vorax reste utilisable sans torch. +""" +import pytest + +torch = pytest.importorskip("torch", reason="noyau torch optionnel (substrat sans torch)") + +from fractus_vorax.model.fractus_core import FractusCore, FractusCoreConfig + + +def _tiny(): + return FractusCoreConfig(vocab_size=128, d_model=64, n_layers=2, n_experts=4, top_k=2, rank=16, max_seq_len=64) + + +def test_forward_logits_shape(): + torch.manual_seed(0) + core = FractusCore(_tiny()) + ids = torch.randint(0, 128, (1, 10)) + logits = core(ids) + assert logits.shape == (1, 10, 128) + + +def test_generate_greedy_deterministic(): + torch.manual_seed(0) + core = FractusCore(_tiny()) + ids = torch.tensor([[1, 2, 3]]) + a = core.generate(ids, max_new_tokens=8) + b = core.generate(ids, max_new_tokens=8) + assert torch.equal(a, b) + assert a.shape == (1, 11) + + +def test_generate_stops_at_eos(): + torch.manual_seed(0) + core = FractusCore(_tiny()) + ids = torch.tensor([[1, 2, 3]]) + # déterminer le token que greedy émettrait au 1er pas, l'utiliser comme EOS + with torch.no_grad(): + first = int(core(ids)[0, -1, :].argmax().item()) + out = core.generate(ids, max_new_tokens=8, eos_token_id=first) + assert out.shape == (1, 4) # prompt + 1 token, puis arrêt + assert int(out[0, -1].item()) == first + + +def test_state_dict_matches_canonical_fractus_keys(): + """Les clés du state_dict reproduisent EXACTEMENT le motif du checkpoint réel (doc §5). + + Top-level (7) + par bloc : 16 clés hors experts + 2×E×4 clés experts. + """ + cfg = _tiny() + core = FractusCore(cfg) + keys = set(core.state_dict().keys()) + + expected = { + "embed.tok_embed.weight", + "embed.pos_embed.weight", + "embed.norm.weight", + "embed.norm.bias", + "norm.weight", + "norm.bias", + "lm_head.weight", + } + for i in range(cfg.n_layers): + expected |= { + f"blocks.{i}.norm1.weight", f"blocks.{i}.norm1.bias", + f"blocks.{i}.attn.w_qkv", f"blocks.{i}.attn.b_qkv", + f"blocks.{i}.attn.w_out", f"blocks.{i}.attn.b_out", + f"blocks.{i}.attn.level_logits", f"blocks.{i}.attn.level_offsets", + f"blocks.{i}.norm_kur.weight", f"blocks.{i}.norm_kur.bias", + f"blocks.{i}.kuramoto.omega", f"blocks.{i}.kuramoto.coupling_u", + f"blocks.{i}.kuramoto.coupling_lambda", + f"blocks.{i}.norm_moe.weight", f"blocks.{i}.norm_moe.bias", + f"blocks.{i}.moe.expert_phases", + } + for e in range(cfg.n_experts): + for w in ("w1", "w2"): + expected |= { + f"blocks.{i}.moe.experts_{w}.{e}.U", + f"blocks.{i}.moe.experts_{w}.{e}.V", + f"blocks.{i}.moe.experts_{w}.{e}.scale", + f"blocks.{i}.moe.experts_{w}.{e}.bias", + } + + assert keys == expected, ( + f"manquantes: {sorted(expected - keys)[:5]} / inattendues: {sorted(keys - expected)[:5]}" + ) + # lm_head liée à l'embedding (doc §5 : poids identiques). + assert core.lm_head.weight is core.embed.tok_embed.weight + + +def test_checkpoint_roundtrip(tmp_path): + """Sauvegarde/charge au format fractus (clé 'model') — strict load.""" + torch.manual_seed(0) + core = FractusCore(_tiny()) + torch.save({"model": core.state_dict()}, tmp_path / "tiny.pt") + loaded = FractusCore.from_checkpoint(tmp_path / "tiny.pt") + assert torch.equal(loaded.generate(torch.tensor([[1, 2, 3]]), 4), + core.generate(torch.tensor([[1, 2, 3]]), 4)) + + +@pytest.mark.slow +def test_real_checkpoint_strict_load(): + """Marqué slow : charge le VRAI checkpoint fractus (lecture seule). + Skip si absent. Ne s'exécute que dans .venv-torch.""" + import pathlib + ckpt = pathlib.Path(r"C:\Users\PHIL\ZCodeProject\fractus\checkpoints\checkpoints\fractus_1b_latest.pt") + if not ckpt.exists(): + pytest.skip("checkpoint fractus non présent") + core = FractusCore.from_checkpoint(ckpt) + ids = torch.zeros(1, 4, dtype=torch.long) + with torch.no_grad(): + logits = core(ids) + assert logits.ndim == 3 diff --git a/tests/test_gates.py b/tests/test_gates.py new file mode 100644 index 0000000000000000000000000000000000000000..206c14f299c804b326b5daa59abfd45b5ce64d38 --- /dev/null +++ b/tests/test_gates.py @@ -0,0 +1,31 @@ +# tests/test_gates.py +"""Gates structurels : torch ne vit que dans fractus_vorax/model/.""" +from pathlib import Path + +REPO = Path(__file__).parent.parent + + +def test_torch_isolated_in_model_dir(): + package = REPO / "fractus_vorax" + offenders = [] + for py in package.rglob("*.py"): + if py.parent == package / "model": + continue # autorisé ici et ici seulement + text = py.read_text(encoding="utf-8") + if "import torch" in text or "from torch" in text: + offenders.append(str(py.relative_to(REPO))) + # imports dynamiques : importlib.import_module("torch"), + # __import__("torch") — même confinement, motif "torch" + appel. + elif "torch" in text and ("import_module" in text or "__import__" in text): + offenders.append(str(py.relative_to(REPO))) + # tokenizers : même confinement que torch (fractus_vorax/model/ seulement). + if "import tokenizers" in text or "from tokenizers" in text: + offenders.append(str(py.relative_to(REPO))) + assert offenders == [] + + +def test_gate_catches_dynamic_import_patterns(tmp_path): + fake = tmp_path / "fake.py" + fake.write_text('import importlib\nimportlib.import_module("torch")\n', encoding="utf-8") + text = fake.read_text(encoding="utf-8") + assert ("torch" in text and ("import_module" in text or "__import__" in text)) diff --git a/tests/test_hebbian.py b/tests/test_hebbian.py new file mode 100644 index 0000000000000000000000000000000000000000..d6c02df0794d297e824b7f9405feb4c4424fa7c5 --- /dev/null +++ b/tests/test_hebbian.py @@ -0,0 +1,64 @@ +# tests/test_hebbian.py +from pathlib import Path + +import numpy as np + +from fractus_vorax.hv import hash_hv, sim, unpack, pack +from fractus_vorax.organs.hebbian import HebbianMemory + + +def _pair(text: str, D: int = 2048): + return hash_hv(f"key:{text}", D), hash_hv(f"val:{text}", D) + + +def test_write_and_complete(): + mem = HebbianMemory(D_h=2048) + pairs = [_pair(t) for t in ("france", "spain", "japan")] + for k, v in pairs: + mem.write(k, v) + for k, v in pairs: + assert np.array_equal(mem.complete(k), v) + + +def test_complete_noise_robustness(): + mem = HebbianMemory(D_h=2048) + k, v = _pair("attractor") + mem.write(k, v) + rng = np.random.default_rng(42) + flip = rng.choice(2048, size=100, replace=False) # ~5% de bruit + noisy = k.copy() + noisy[flip] *= -1 + assert np.array_equal(mem.complete(noisy), v) + + +def test_gate_rejects_degrading_write(): + mem = HebbianMemory(D_h=2048) + k1, v1 = _pair("un") + k2, v2 = _pair("deux") + assert mem.write_gated(k1, v1, probes=[]) is True + # écrire (k1, -v1) dégraderait la sonde (k1 -> v1) + assert mem.write_gated(k1, -v1, probes=[(k1, v1)]) is False + assert mem.n_writes == 1 + assert np.array_equal(mem.complete(k1), v1) # rien n'a bougé + + +def test_rollback(): + mem = HebbianMemory(D_h=2048) + k, v = _pair("garder") + k2, v2 = _pair("jeter") + mem.write(k, v) + mem.write(k2, v2) + assert mem.rollback() is True + assert mem.n_writes == 1 + assert np.array_equal(mem.complete(k), v) + + +def test_save_load_roundtrip(tmp_path): + mem = HebbianMemory(D_h=2048) + for t in ("a", "b", "c"): + mem.write(*_pair(t)) + mem.save(tmp_path / "heb") + loaded = HebbianMemory.load(tmp_path / "heb") + assert loaded.n_writes == 3 + k, v = _pair("b") + assert np.array_equal(loaded.complete(k), v) diff --git a/tests/test_hv.py b/tests/test_hv.py new file mode 100644 index 0000000000000000000000000000000000000000..bd80d384931f55a98badb31b8d623c1509870aa9 --- /dev/null +++ b/tests/test_hv.py @@ -0,0 +1,67 @@ +# tests/test_hv.py +import numpy as np + +from fractus_vorax.hv import D_DEFAULT, SEED_DEFAULT, bind, bundle, hash_hv, pack, sim, unpack + + +def test_hash_hv_deterministic(): + a = hash_hv("what is the capital of france") + b = hash_hv("what is the capital of france") + assert np.array_equal(a, b) + + +def test_hash_hv_shape_and_values(): + hv = hash_hv("hello", D=2048) + assert hv.shape == (2048,) + assert set(np.unique(hv)) <= {-1, 1} + + +def test_hash_hv_seed_changes_result(): + a = hash_hv("hello", seed=0) + b = hash_hv("hello", seed=1) + assert not np.array_equal(a, b) + + +def test_near_orthogonality(): + a = hash_hv("chat", D=8192) + b = hash_hv("chien", D=8192) + assert abs(sim(a, b)) < 0.2 + + +def test_self_similarity_is_one(): + a = hash_hv("n'importe quoi") + assert sim(a, a) == 1.0 + + +def test_bind_is_involution_and_orthogonal(): + a, b = hash_hv("cle"), hash_hv("valeur") + c = bind(a, b) + assert np.array_equal(bind(c, b), a) # XOR-like involution + assert abs(sim(c, a)) < 0.2 # bind obscurcit + + +def test_bundle_majority_with_tie_plus_one(): + a, b, c = hash_hv("x"), hash_hv("y"), hash_hv("z") + m = bundle([a, b, c]) + assert set(np.unique(m)) <= {-1, 1} + assert np.array_equal(bundle([a, a, b]), a) # majorité écrasante + + +def test_bundle_empty_raises(): + try: + bundle([]) + assert False, "should raise" + except ValueError: + pass + + +def test_pack_unpack_roundtrip(): + a = hash_hv("roundtrip", D=2048) + bits = pack(a) + assert bits.dtype == np.uint8 + assert np.array_equal(unpack(bits, 2048), a) + + +def test_defaults(): + assert D_DEFAULT == 8192 + assert SEED_DEFAULT == 0 diff --git a/tests/test_inauguration.py b/tests/test_inauguration.py new file mode 100644 index 0000000000000000000000000000000000000000..09649957ca281776fdec018b2a647f652a83fe24 --- /dev/null +++ b/tests/test_inauguration.py @@ -0,0 +1,57 @@ +# tests/test_inauguration.py +"""Tâche 3 (le relais) — L'INAUGURATION : la démo du takeover. + +Un SEUL test (inauguration() est coûteuse quand le noyau 1B vit dans le +python) qui doit passer dans les DEUX venvs : +- mingw sans torch : le cerveau se dégrade en une ligne honnête d'absence, + les organes portent toute la démo (marqueurs 2/3/4/5 + finale) ; +- torch + brain/FRACTUS_BIRTH.pt : en plus, le statut réel du cerveau + (strict 440/440) et la parole du noyau rapportée VERBATIM (word salad + accepté et documenté — docs/NAISSANCE.md). +""" +import re + +from demo.inauguration import inauguration + +FINAL = "[FRACTUS-VORAX] Le relais est pris. L'entraînement est fini. Il mange." + + +def _n_atomes(status_line: str) -> int: + """Extrait le compteur d'une ligne ``[VORAX] brain: N atomes, ...``.""" + m = re.search(r"(\d+) atomes", status_line) + assert m, f"ligne sans compteur d'atomes: {status_line!r}" + return int(m.group(1)) + + +def test_inauguration(): + lines = inauguration() + out = "\n".join(lines) + + # --- marqueurs TOUJOURS présents (organes — les deux venvs) --- + assert "[FRACTUS-VORAX] 1/7 noyau de naissance" in out # statut du cerveau + assert "[FRACTUS-VORAX] 2/7 ingestion" in out + assert "[FRACTUS-VORAX] 3/7 question exacte" in out + assert "tokyo" in out and "FAIT" in out # japon -> carte FAIT + assert "[FRACTUS-VORAX] 4/7 typo" in out + assert "ANALOGIE" in out and "paris" in out # 'franc' -> analogie + assert "[FRACTUS-VORAX] 5/7 session REPL scriptée" in out + assert lines[-1] == FINAL # la ligne du relais pris + + # --- la session écrit : n atomes CROÎT d'un :status à l'autre --- + statuses = [l for l in lines if l.startswith("[VORAX] brain:")] + assert len(statuses) == 2, "deux :status attendus (avant/après la question)" + assert _n_atomes(statuses[1]) > _n_atomes(statuses[0]) + + # --- branche noyau : skip-friendly (dépend de l'environnement) --- + if "cerveau: cte" in out: # torch + tokenizers + cerveau de naissance présents + assert "strict 440/440" in out + assert "[FRACTUS-VORAX] 6/7 la parole du noyau" in out + # la parole, VERBATIM et honnête (word salad accepté) — ou l'aveu + # muet si le tokenizer BPE est indisponible dans cet environnement. + assert '[FRACTUS-VORAX] noyau: "' in out or "noyau muet" in out + else: # mingw sans torch (ou cerveau absent) — les organes portent la démo + assert "cerveau absent dans cet environnement" in out + assert "6/7" not in out + + # --- console-safe : jamais → ni — (cp1252-hostiles) --- + assert all("→" not in l and "—" not in l for l in lines) diff --git a/tests/test_killer_bench.py b/tests/test_killer_bench.py new file mode 100644 index 0000000000000000000000000000000000000000..5c0afecd3d58a127a4519f732dec73dcdd0d32b4 --- /dev/null +++ b/tests/test_killer_bench.py @@ -0,0 +1,18 @@ +# tests/test_killer_bench.py +from pathlib import Path + +from bench.killer_bench import generate_domain_csv, run_bench + + +def test_generate_domain_csv_deterministic(tmp_path): + a, b = tmp_path / "a.csv", tmp_path / "b.csv" + n1, n2 = generate_domain_csv(a, 6, 3), generate_domain_csv(b, 6, 3) + assert n1 == n2 == 18 and a.read_bytes() == b.read_bytes() + + +def test_run_bench_small(tmp_path): + metrics = run_bench(rows=24, D=2048, out_dir=tmp_path) + assert 0.0 <= metrics["heldout_accuracy"] <= 1.0 + assert metrics["ingest_atoms_per_s"] > 0 + assert metrics["query_ms"] > 0 + assert metrics["core_cards_ablation"] is None or isinstance(metrics["core_cards_ablation"], dict) diff --git a/tests/test_kn.py b/tests/test_kn.py new file mode 100644 index 0000000000000000000000000000000000000000..e2314b5e7d2e3c082bfd581a648aed83edeccb12 --- /dev/null +++ b/tests/test_kn.py @@ -0,0 +1,56 @@ +# tests/test_kn.py +import gzip +import json +from pathlib import Path + +from fractus_vorax.compiler.kn import compile_kn, load_kn, source_sha256 + + +def _csv(tmp_path): + p = tmp_path / "cap.csv" + p.write_text( + "question,answer\nwhat is the capital of france,paris\nwho wrote hamlet,william shakespeare\n", + encoding="utf-8", + ) + return p + + +def test_compile_produces_kn_layout(tmp_path): + kn = compile_kn(_csv(tmp_path), tmp_path / "cap.kn") + assert (kn / "manifest.json").exists() + assert (kn / "atoms.tsv.gz").exists() + assert (kn / "signature.bin").exists() + manifest = json.loads((kn / "manifest.json").read_text(encoding="utf-8")) + assert manifest["n_atoms"] == 2 + assert manifest["format_version"] == 1 + assert "created" not in manifest # jamais de timestamp + + +def test_load_kn_roundtrip(tmp_path): + src = _csv(tmp_path) + kn = compile_kn(src, tmp_path / "cap.kn") + atoms = load_kn(kn) + assert atoms[0].statement == "what is the capital of france" + assert atoms[0].context == "paris" + assert atoms[0].confidence == 1.0 + + +def test_compile_is_bit_identical(tmp_path): + src = _csv(tmp_path) + k1 = compile_kn(src, tmp_path / "a.kn") + k2 = compile_kn(src, tmp_path / "b.kn") + for name in ("manifest.json", "atoms.tsv.gz", "signature.bin"): + assert (k1 / name).read_bytes() == (k2 / name).read_bytes(), name + + +def test_source_sha256_stable(tmp_path): + src = _csv(tmp_path) + assert source_sha256(src) == source_sha256(src) + assert len(source_sha256(src)) == 64 + + +def test_manifest_records_source_hash(tmp_path): + src = _csv(tmp_path) + kn = compile_kn(src, tmp_path / "cap.kn") + manifest = json.loads((kn / "manifest.json").read_text(encoding="utf-8")) + assert manifest["source_sha256"] == source_sha256(src) diff --git a/tests/test_parse.py b/tests/test_parse.py new file mode 100644 index 0000000000000000000000000000000000000000..2de543ed50dfce91c60ce77adb45efaa686c9769 --- /dev/null +++ b/tests/test_parse.py @@ -0,0 +1,96 @@ +# tests/test_parse.py +import json +from pathlib import Path + +from fractus_vorax.compiler.parse import detect_format, flatten_record, parse_file + + +def _write(tmp_path, name, content): + p = tmp_path / name + p.write_text(content, encoding="utf-8") + return p + + +def test_detect_format(tmp_path): + assert detect_format(_write(tmp_path, "a.csv", "x\n1")) == "csv" + assert detect_format(_write(tmp_path, "a.tsv", "x\n1")) == "tsv" + assert detect_format(_write(tmp_path, "a.jsonl", "{}")) == "jsonl" + assert detect_format(_write(tmp_path, "a.json", "[]")) == "json" + assert detect_format(_write(tmp_path, "a.md", "# t")) == "md" + assert detect_format(_write(tmp_path, "a.txt", "hi")) == "txt" + assert detect_format(_write(tmp_path, "a.xyz", "hi")) == "raw" + + +def test_parse_csv_two_columns_is_qa(tmp_path): + p = _write(tmp_path, "cap.csv", "question,answer\nwhat is the capital of france,paris\nwho wrote hamlet,william shakespeare\n") + atoms = parse_file(p) + assert len(atoms) == 2 + assert atoms[0].statement == "what is the capital of france" + assert atoms[0].context == "paris" + assert atoms[0].provenance.startswith("cap.csv") + assert atoms[0].confidence == 1.0 + + +def test_parse_csv_wide_table_serializes_pairs(tmp_path): + p = _write(tmp_path, "rows.csv", "name,city,height\neiffel,paris,330\n") + atoms = parse_file(p) + assert len(atoms) == 1 + assert "name: eiffel" in atoms[0].statement + assert "city: paris" in atoms[0].statement + + +def test_parse_jsonl(tmp_path): + line = json.dumps({"name": "eiffel tower", "city": "paris", "height_m": 330}) + p = _write(tmp_path, "d.jsonl", line + "\n") + atoms = parse_file(p) + assert len(atoms) == 1 + assert "name: eiffel tower" in atoms[0].statement + assert "height_m: 330" in atoms[0].statement + + +def test_parse_json_list(tmp_path): + p = _write(tmp_path, "d.json", json.dumps([{"a": 1}, {"b": 2}])) + atoms = parse_file(p) + assert len(atoms) == 2 + + +def test_flatten_record_nested(): + pairs = flatten_record({"x": {"y": "z"}, "l": [1, 2]}) + assert "x.y: z" in pairs + assert "l: 1; 2" in pairs + + +def test_parse_txt_paragraphs(tmp_path): + p = _write(tmp_path, "doc.txt", "Premier paragraphe sur paris.\n\nDeuxieme paragraphe.\n") + atoms = parse_file(p) + assert len(atoms) == 2 + assert atoms[0].statement == "Premier paragraphe sur paris." + + +def test_parse_md_headings_become_context(tmp_path): + p = _write(tmp_path, "doc.md", "# Capitales\n\nparis est la capitale de la france\n") + atoms = parse_file(p) + assert atoms[0].context == "Capitales" + assert "paris" in atoms[0].statement + + +def test_parse_raw_fallback_reads_lines(tmp_path): + p = _write(tmp_path, "weird.xyz", "ligne une\nligne deux\n") + atoms = parse_file(p) + assert len(atoms) == 2 + + +def test_parse_jsonl_skips_malformed_lines(tmp_path): + p = _write(tmp_path, "d.jsonl", '{"a": 1}\nnot json {\n{"b": 2}\n') + atoms = parse_file(p) + assert len(atoms) == 2 + assert "a: 1" in atoms[0].statement + assert "b: 2" in atoms[1].statement + + +def test_parse_csv_latin1_does_not_crash(tmp_path): + p = tmp_path / "latin.csv" + p.write_bytes(b"question,answer\ncaf\xe9,paris\n") + atoms = parse_file(p) + assert len(atoms) == 1 + assert atoms[0].context == "paris" diff --git a/tests/test_repl.py b/tests/test_repl.py new file mode 100644 index 0000000000000000000000000000000000000000..ed9cb0ab6bb4d6939b2e5d4478c56271d37d9fd3 --- /dev/null +++ b/tests/test_repl.py @@ -0,0 +1,63 @@ +# tests/test_repl.py +from pathlib import Path + +from fractus_vorax.agent.repl import Repl + +CSV = ("question,answer\n" + "what is the capital of france,paris\n" + "what is the capital of spain,madrid\n" + "what is the capital of japan,tokyo\n" + "what is the capital of italy,rome\n" + "who wrote hamlet,william shakespeare\n") + + +def _repl(tmp_path): + csv = tmp_path / "cap.csv" + csv.write_text(CSV, encoding="utf-8") + r = Repl(tmp_path / "brain", D=2048) + return r, csv + + +def test_ingest_and_ask(tmp_path): + r, csv = _repl(tmp_path) + out = r.feed(f":ingest {csv}") + assert any("expert" in l for l in out) + out = r.feed("what is the capital of japan") + assert any("[CARTE]" in l and "tokyo" in l for l in out) + + +def test_conversation_feeds_brain(tmp_path): + r, csv = _repl(tmp_path) + r.feed(f":ingest {csv}") + n0 = len(r.brain) + r.feed("who wrote hamlet") + assert len(r.brain) == n0 + 1 # échange écrit O(1) + assert r.brain.atoms[-1].provenance == "session" + out = r.feed(":status") + assert any("atomes" in l for l in out) + + +def test_quit_and_unknown(tmp_path): + r, _ = _repl(tmp_path) + assert r.feed(":quit") == [] + out = r.feed("zzz nothing here") + assert any("LACUNE" in l or "[CARTE]" in l for l in out) + + +def test_core_command_degrades_gracefully(tmp_path): + # Sans torch OU avec un checkpoint inexistant : jamais de traceback, + # toujours une ligne honnête "[NOYAU] indisponible: ...". + r, _ = _repl(tmp_path) + out = r.feed(f":core {tmp_path / 'nope.pt'}") + assert any("[NOYAU] indisponible" in l for l in out) + assert r.core is None + + +def test_core_uses_bpe_when_available(tmp_path, capsys): + from fractus_vorax.model.bpe_tokenizer import bpe_available + r, _ = _repl(tmp_path) + fake_ckpt = tmp_path / "nope.pt" + out = r.feed(f":core {fake_ckpt}") + # sans torch ou fichier absent : dégradation propre déjà testée en P3-T5 ; + # ici on vérifie juste qu'aucun crash tokenizer ne fuit + assert all(isinstance(l, str) for l in out) diff --git a/tests/test_scaffold.py b/tests/test_scaffold.py new file mode 100644 index 0000000000000000000000000000000000000000..5823b256b7078a70ee88a1695fd356fe838893b2 --- /dev/null +++ b/tests/test_scaffold.py @@ -0,0 +1,4 @@ +# tests/test_scaffold.py +def test_package_importable(): + import fractus_vorax + assert fractus_vorax.__version__ == "1.0.0" diff --git a/tests/test_spawn.py b/tests/test_spawn.py new file mode 100644 index 0000000000000000000000000000000000000000..17edd224a6e889691b1cbec228a0232b3f035c47 --- /dev/null +++ b/tests/test_spawn.py @@ -0,0 +1,42 @@ +# tests/test_spawn.py +from fractus_vorax.hv import bundle, hash_hv +from fractus_vorax.organs.spawn import ExpertRegistry + +CAP_TOKENS = ["what", "is", "the", "capital", "of"] +BOOK_TOKENS = ["who", "wrote"] + + +def _sig(tokens, D=2048): + return bundle([hash_hv(t, D) for t in tokens]) + + +def test_spawn_and_route(): + reg = ExpertRegistry() + reg.spawn("capitals", _sig(CAP_TOKENS), 0, 4) + reg.spawn("books", _sig(BOOK_TOKENS), 5, 9) + assert len(reg) == 2 + hits = reg.route(_sig(["what", "is", "the", "capital", "of", "germany"])) + assert hits[0][0] == "capitals" + assert hits[0][1] > 0.5 + + +def test_route_empty(): + assert ExpertRegistry().route(_sig(CAP_TOKENS)) == [] + + +def test_get(): + reg = ExpertRegistry() + e = reg.spawn("capitals", _sig(CAP_TOKENS), 0, 4) + assert reg.get("capitals") == e + assert reg.get("nope") is None + + +def test_save_load_roundtrip(tmp_path): + reg = ExpertRegistry() + reg.spawn("capitals", _sig(CAP_TOKENS), 0, 4) + reg.spawn("books", _sig(BOOK_TOKENS), 5, 9) + reg.save(tmp_path / "experts") + loaded = ExpertRegistry.load(tmp_path / "experts") + assert len(loaded) == 2 + hits = loaded.route(_sig(["who", "wrote", "hamlet"])) + assert hits[0][0] == "books" diff --git a/tests/test_speak.py b/tests/test_speak.py new file mode 100644 index 0000000000000000000000000000000000000000..ff67d1043384c36b989c8cfd2692c6e12af5026c --- /dev/null +++ b/tests/test_speak.py @@ -0,0 +1,221 @@ +# tests/test_speak.py +"""Plan 6, tâche 1 — speak() : la boucle de décodage mécanique anti-attracteur. + +Transforms (znorm, rep-pen, top-k, top-p) vérifiés sur des tenseurs CONNUS ; +puis la boucle speak() sur un tiny CteCore réel (et un tiny FractusCore pour +le duck-typing) : déterminisme par seed, anti-répétition à rep_pen élevé +(aucun token émis 3 fois sur 12 pas), domination du bias fort (δ=+50 → +premier choix, 10 seeds), EOS append-then-break strippé au décodage. +""" +from collections import Counter + +import pytest + +torch = pytest.importorskip("torch", reason="speak nécessite torch (substrat sans torch)") + +from fractus_vorax.model.cte_core import CteCore, CteCoreConfig +from fractus_vorax.model.speak import ( + apply_rep_penalty, + apply_top_k, + apply_top_p, + speak, + znorm_logits, +) + + +def _tiny(**overrides) -> CteCoreConfig: + return CteCoreConfig(vocab_size=128, **overrides) + + +class _TinyCharTok: + """Tokenizer char-level sur le vocab tiny (128) — injectif sur les ids < 128.""" + + vocab_size = 128 + + def encode(self, text: str) -> list[int]: + return [ord(c) % 128 for c in text] + + def decode(self, ids: list[int]) -> str: + return "".join(chr(int(i) % 128) for i in ids) + + +# --------------------------------------------------------------------------- +# Transforms — tenseurs connus +# --------------------------------------------------------------------------- + +def test_znorm_known_tensor(): + x = torch.tensor([1.0, 2.0, 3.0]) # mean 2, std non biaisé 1 + assert torch.allclose(znorm_logits(x, scale=3.0), torch.tensor([-3.0, 0.0, 3.0])) + # défaut = 3.0 (l'échelle empirique qui libère le lexique sur le 1B) + assert torch.allclose(znorm_logits(x), znorm_logits(x, scale=3.0)) + assert torch.allclose(znorm_logits(x, scale=1.0), torch.tensor([-1.0, 0.0, 1.0])) + # pur : l'entrée n'est jamais mutée + assert torch.equal(x, torch.tensor([1.0, 2.0, 3.0])) + + +def test_znorm_crushes_attractor_scale(): + """Propriété clé : std de sortie == scale, quelle que soit l'échelle + d'entrée (état calme std ~26 ou état-attracteur à des centaines).""" + calm = torch.randn(2048) * 26.0 + attractor = torch.randn(2048) * 400.0 + for src in (calm, attractor): + out = znorm_logits(src, scale=3.0) + assert abs(float(out.std()) - 3.0) < 1e-3 + assert abs(float(out.mean())) < 1e-3 + + +def test_apply_rep_penalty_known_tensor(): + lg = torch.tensor([6.0, 3.0, -3.0]) + out = apply_rep_penalty(lg, {0}, penalty=6.0) + assert torch.allclose(out, torch.tensor([1.0, 3.0, -3.0])) + out2 = apply_rep_penalty(lg, {0, 1}, penalty=3.0) + assert torch.allclose(out2, torch.tensor([2.0, 1.0, -3.0])) + # division SIMPLE (spec du plan) : un logit négatif pénalisé se rapproche + # de 0 — comportement documenté, sans effet pratique (les tokens émis post- + # znorm vivent dans le haut de la distribution, positifs) + out3 = apply_rep_penalty(lg, {2}, penalty=6.0) + assert torch.allclose(out3, torch.tensor([6.0, 3.0, -0.5])) + # pur ; emitted vide → inchangé + assert torch.equal(lg, torch.tensor([6.0, 3.0, -3.0])) + assert torch.equal(apply_rep_penalty(lg, set()), lg) + with pytest.raises(ValueError): + apply_rep_penalty(lg, {0}, penalty=0.0) + + +def test_apply_top_k_known_tensor(): + lg = torch.tensor([1.0, 5.0, 3.0, 2.0]) + out = apply_top_k(lg, 2) + expected = torch.tensor([float("-inf"), 5.0, 3.0, float("-inf")]) + assert torch.equal(out, expected) + # k = taille du vocab → tout survit (identité) + assert torch.equal(apply_top_k(lg, 4), lg) + with pytest.raises(ValueError): + apply_top_k(lg, 0) + + +def test_apply_top_p_known_tensor(): + lg = torch.log(torch.tensor([0.6, 0.3, 0.1])) + # p=0.85 : cumul EXCLUSIF = [0, 0.6, 0.9] → les 2 premiers survivent + out = apply_top_p(lg, 0.85) + assert torch.isfinite(out[0]) and torch.isfinite(out[1]) + assert out[2] == float("-inf") + # p minuscule : le top-1 SURVIT TOUJOURS (jamais de masque vide) + out1 = apply_top_p(lg, 0.01) + assert torch.isfinite(out1[0]) + assert out1[1] == float("-inf") and out1[2] == float("-inf") + # p >= 1 → identité + assert torch.equal(apply_top_p(lg, 1.0), lg) + with pytest.raises(ValueError): + apply_top_p(lg, 0.0) + + +# --------------------------------------------------------------------------- +# speak() — tiny CteCore réel +# --------------------------------------------------------------------------- + +def test_speak_deterministic_same_seed(): + torch.manual_seed(0) + core = CteCore(_tiny()) + tok = _TinyCharTok() + text_a, diags_a = speak(core, tok, "hello", max_new_tokens=8, seed=42) + text_b, diags_b = speak(core, tok, "hello", max_new_tokens=8, seed=42) + assert text_a == text_b + # rejeu EXACT y compris diagnostics : reset_states(1) au départ rend la + # boucle indépendante des états porteurs laissés par l'appel précédent + assert diags_a == diags_b + assert isinstance(text_a, str) and text_a != "" + # structure des diagnostics par pas + assert 1 <= len(diags_a) <= 8 + d = diags_a[0] + assert set(d.keys()) == {"logit_std_avant", "top3", "chosen"} + assert isinstance(d["logit_std_avant"], float) and d["logit_std_avant"] > 0.0 + assert len(d["top3"]) == 3 + assert all(isinstance(s, str) and isinstance(v, float) for s, v in d["top3"]) + assert isinstance(d["chosen"], str) + + +def test_speak_high_rep_pen_never_triple(): + """rep_pen élevé (12, haut de la fourchette empirique 6-12) : aucun token + n'est émis 3 fois sur 12 pas — l'attracteur de répétition est brisé.""" + torch.manual_seed(0) + core = CteCore(_tiny()) + tok = _TinyCharTok() + text, diags = speak( + core, tok, "the meaning of", max_new_tokens=12, rep_pen=12.0, seed=0 + ) + assert len(diags) == 12 + counts = Counter(d["chosen"] for d in diags) + assert max(counts.values()) <= 2, f"attracteur non brisé: {counts}" + + +def test_speak_strong_bias_dominates_first_pick(): + """δ=+50 sur un id : le premier choix EST cet id, 10 seeds sur 10.""" + torch.manual_seed(0) + core = CteCore(_tiny()) + tok = _TinyCharTok() + for seed in range(10): + text, diags = speak( + core, tok, "hello", max_new_tokens=1, bias_tokens={42: 50.0}, seed=seed + ) + assert diags[0]["chosen"] == tok.decode([42]) + + +def test_speak_eos_append_then_break_stripped(): + """Le bias force 42 = EOS au premier pas → append-then-break : 1 pas de + diagnostic, EOS strippé au décodage → texte vide (sémantique P4).""" + torch.manual_seed(0) + core = CteCore(_tiny()) + tok = _TinyCharTok() + text, diags = speak( + core, tok, "hello", max_new_tokens=6, + eos_token_id=42, bias_tokens={42: 50.0}, seed=0, + ) + assert len(diags) == 1 + assert text == "" + + +def test_speak_eos_from_tokenizer(): + """Le tokenizer peut exposer eos_token_id (Gpt2BpeTokenizer le fait) : + sans paramètre explicite, c'est lui qui clot la boucle.""" + + class _TokWithEos(_TinyCharTok): + eos_token_id = 42 + + torch.manual_seed(0) + core = CteCore(_tiny()) + text, diags = speak( + core, _TokWithEos(), "hi", max_new_tokens=6, bias_tokens={42: 50.0}, seed=0 + ) + assert len(diags) == 1 + assert text == "" + + +def test_speak_rejects_bad_arguments(): + torch.manual_seed(0) + core = CteCore(_tiny()) + tok = _TinyCharTok() + with pytest.raises(ValueError): + speak(core, tok, "", max_new_tokens=4) # prompt vide → aucun token + with pytest.raises(ValueError): + speak(core, tok, "hello", temperature=0.0) # greedy = core.generate + with pytest.raises(ValueError): + speak(core, tok, "hello", bias_tokens={999: 1.0}) # id hors vocab + + +def test_speak_duck_types_fractus_core(): + """Le contrat est le duck-typing : FractusCore (stateless, sans + reset_states) parle aussi, de façon déterministe.""" + from fractus_vorax.model.fractus_core import FractusCore, FractusCoreConfig + + torch.manual_seed(0) + cfg = FractusCoreConfig( + vocab_size=128, d_model=64, n_layers=2, n_experts=4, top_k=2, + rank=16, max_seq_len=64, + ) + core = FractusCore(cfg) + tok = _TinyCharTok() + text_a, diags_a = speak(core, tok, "hello", max_new_tokens=6, seed=3) + text_b, diags_b = speak(core, tok, "hello", max_new_tokens=6, seed=3) + assert text_a == text_b and diags_a == diags_b + assert len(diags_a) == 6 + assert all(set(d.keys()) == {"logit_std_avant", "top3", "chosen"} for d in diags_a) diff --git a/tests/test_speak_integration.py b/tests/test_speak_integration.py new file mode 100644 index 0000000000000000000000000000000000000000..4034117116f1e9c160b69856c8f592feb6cbdf8b --- /dev/null +++ b/tests/test_speak_integration.py @@ -0,0 +1,207 @@ +# tests/test_speak_integration.py +"""Plan 6, tâche 4 — l'intégration : ``:speak`` dans le REPL, core_speak v2. + +Substrat (mingw, TOUJOURS vert) : ``:speak`` sans noyau attaché → la ligne +honnête ``[PAROLE] noyau non attaché (:core d'abord)`` — jamais de traceback, +le substrat ne touche pas torch. + +Torch (se skipe en mingw) : un tiny CteCore + tokenizer char attachés au +REPL → les trois lignes ``[PAROLE]`` (cartes, steering, parole mécanique) ; +``run_core_speak(mode=...)`` sur un tiny checkpoint fake → le dict v2 avec +greedy/mécanique (et steerée avec brain) + le ciel ouvert (routage, têtes). +""" +import pytest + +from fractus_vorax.agent.repl import Repl +from fractus_vorax.brain import Brain +from fractus_vorax.compiler.atoms import Atom + +CAPITALS = [ + Atom("what is the capital of france", "paris", "cap.csv:2"), + Atom("what is the capital of spain", "madrid", "cap.csv:3"), + Atom("what is the capital of japan", "tokyo", "cap.csv:4"), + Atom("what is the capital of italy", "rome", "cap.csv:5"), +] + +DEGRADE = "[PAROLE] noyau non attaché (:core d'abord)" + + +def _repl(tmp_path) -> Repl: + brain = Brain(D=2048) + brain.ingest_source("capitals", CAPITALS) + brain.save(tmp_path / "brain") + return Repl(tmp_path / "brain", D=2048) + + +class _Tok128: + """Tokenizer char-level sur le vocab tiny 128 (protocole minimal).""" + + vocab_size = 128 + + def encode(self, text: str) -> list[int]: + return [ord(c) % 128 for c in text] + + def decode(self, ids: list[int]) -> str: + return "".join(chr(int(i) % 128) for i in ids) + + +# --------------------------------------------------------------------------- +# :speak — dégradation honnête (substrat, les deux venvs) +# --------------------------------------------------------------------------- +def test_speak_without_core_degrades_honestly(tmp_path): + """Sans :core : UNE ligne honnête, jamais de traceback — le contrat + mingw du plan (skip-proof : ce test ne touche jamais torch).""" + r = _repl(tmp_path) + assert r.core is None and r.tokenizer is None + out = r.feed(":speak hello") + assert out == [DEGRADE] + # le REPL reste vivant après la dégradation (pas d'état cassé) + assert any("[CARTE]" in l for l in r.feed("what is the capital of japan")) + + +def test_speak_usage_line_without_text(tmp_path): + r = _repl(tmp_path) + assert r.feed(":speak") == ["[PAROLE] usage: :speak "] + + +def test_speak_with_fake_tiny_core(tmp_path): + """Noeud torch : tiny CteCore + tokenizer char attachés → les TROIS + lignes ``[PAROLE]`` (cartes, steering, parole) — la boucle mécanique + vit dans le REPL, pilotée par les organes.""" + torch = pytest.importorskip( + "torch", reason=":speak mécanique nécessite torch (substrat sans torch)" + ) + from fractus_vorax.model.cte_core import CteCore, CteCoreConfig + + r = _repl(tmp_path) + torch.manual_seed(0) + r.core = CteCore(CteCoreConfig(vocab_size=128)) + r.tokenizer = _Tok128() + out = r.feed(":speak what is the capital of france") + assert any(l.startswith("[PAROLE] cartes: ") for l in out) + steering = [l for l in out if l.startswith("[PAROLE] steering:")] + assert steering, f"ligne steering absente: {out}" + # les organes steered : le premier token char de ' paris' (l'espace, 32) + assert " x8" in steering[0] + speeches = [l for l in out if l.startswith('[PAROLE] "')] + assert speeches, f"ligne parole absente: {out}" + # déterminisme : même seed (7, interne au :speak) => même parole + again = r.feed(":speak what is the capital of france") + assert [l for l in again if l.startswith('[PAROLE] "')] == speeches + + +def test_speak_tiny_core_failure_is_honest(tmp_path): + """Un noyau qui échoue (tokenizer incohérent avec le vocab) → ligne + ``[PAROLE] indisponible: ...``, jamais de traceback.""" + torch = pytest.importorskip( + "torch", reason=":speak mécanique nécessite torch (substrat sans torch)" + ) + from fractus_vorax.model.cte_core import CteCore, CteCoreConfig + + r = _repl(tmp_path) + torch.manual_seed(0) + r.core = CteCore(CteCoreConfig(vocab_size=8)) # vocab minuscule + r.tokenizer = _Tok128() # ids jusqu'à 127 -> hors vocab + out = r.feed(":speak what is the capital of france") + assert any(l.startswith("[PAROLE] indisponible:") for l in out) + + +# --------------------------------------------------------------------------- +# core_speak v2 — run function sur un tiny checkpoint fake (torch) +# --------------------------------------------------------------------------- +def _tiny_ckpt(tmp_path): + torch = pytest.importorskip("torch") + from fractus_vorax.model.cte_core import CteCore, CteCoreConfig + + # vocab BPE complet (50257) : le harnais tokenize en vrai GPT-2 — les + # ids doivent vivre dans le vocab du noyau pour que forward passe. + torch.manual_seed(0) + core = CteCore(CteCoreConfig(vocab_size=50257)) + ckpt = tmp_path / "tiny_cte.pt" + torch.save({"model_state": core.state_dict()}, ckpt) + return ckpt + + +def _bpe_or_skip(): + from fractus_vorax.model.bpe_tokenizer import bpe_available + + if not bpe_available(): + pytest.skip("tokenizers indisponible") + try: + from fractus_vorax.model.bpe_tokenizer import Gpt2BpeTokenizer + + Gpt2BpeTokenizer() + except Exception as exc: # cache HF vide + réseau injoignable + pytest.skip(f"tokenizer GPT-2 indisponible: {exc}") + + +def test_core_speak_mechanic_mode_tiny(tmp_path): + """mode mechanic : baseline greedy + parole mécanique par question, ciel + ouvert (routage top-2/couche + têtes) sur la première question.""" + _bpe_or_skip() + from bench.core_speak import run_core_speak + + result = run_core_speak( + _tiny_ckpt(tmp_path), + ["what is the capital of france", "what is the capital of spain"], + brain=None, + max_new_tokens=3, + mode="mechanic", + ) + assert result["mode"] == "mechanic" + assert len(result["runs"]) == 2 + for run in result["runs"]: + assert isinstance(run["greedy_output"], str) + assert isinstance(run["mechanic_output"], str) + assert isinstance(run["mechanic_diags"], list) and run["mechanic_diags"] + assert run["mechanic_answer_token"] is False # sans brain -> pas d'attendu + # ciel ouvert : top-2 experts par couche (2 couches sur le tiny), têtes lues + assert set(result["routing"]) == {"layer_0", "layer_1"} + for entries in result["routing"].values(): + assert len(entries) == 2 and abs(sum(w for _, w in entries) - 1.0) < 1e-5 + conf_sal = result["head_readout"] + assert 0.0 <= conf_sal["confidence"] < 1.0 and 0.0 <= conf_sal["salience"] < 1.0 + assert result["answer_token_rate_steered"] is None # pas de steered en mechanic + + +def test_core_speak_steered_mode_tiny(tmp_path): + """mode steered (exige brain) : les TROIS générations par question — + greedy attracteur, mécanique non-steerée, mécanique steerée (même seed, + bias organique) — et les deux taux de tokens-réponse mesurés.""" + _bpe_or_skip() + from bench.core_speak import run_core_speak + + brain = Brain(D=2048) + brain.ingest_source("capitals", CAPITALS) + brain.save(tmp_path / "brain") + result = run_core_speak( + _tiny_ckpt(tmp_path), + ["what is the capital of france", "what is the capital of spain"], + brain=tmp_path / "brain", + max_new_tokens=3, + mode="steered", + ) + assert result["mode"] == "steered" + for run in result["runs"]: + for key in ("greedy_output", "mechanic_output", "steered_output"): + assert isinstance(run[key], str) + assert run["bias_tokens"], "steering organique vide alors que le brain sait" + assert isinstance(run["steered_answer_token"], bool) + assert run["expected_answer"] in ("paris", "madrid") + assert isinstance(run["answer_token"], int) + assert isinstance(result["answer_token_rate_mechanic"], float) + assert isinstance(result["answer_token_rate_steered"], float) + assert result["verdict"] in ("word salad", "answers present") + + +def test_core_speak_steered_without_brain_is_refused(tmp_path): + """steered sans brain : précondition refusée proprement (ValueError du + run, exit 1 du CLI) — pas d'organes, pas de steering.""" + _bpe_or_skip() + from bench.core_speak import main, run_core_speak + + ckpt = _tiny_ckpt(tmp_path) + with pytest.raises(ValueError): + run_core_speak(ckpt, ["q"], brain=None, max_new_tokens=2, mode="steered") + code = main(["--ckpt", str(ckpt), "--mode", "steered"]) + assert code == 1 diff --git a/tests/test_steering.py b/tests/test_steering.py new file mode 100644 index 0000000000000000000000000000000000000000..c89306584918e34b7ea33f39d7e79241b6c08637 --- /dev/null +++ b/tests/test_steering.py @@ -0,0 +1,211 @@ +# tests/test_steering.py +"""Plan 6, tâche 3 — steering organique : les cartes dirigent la bouche. + +``Brain.speak_bias(question, tokenizer)`` : ask_cards → PREMIER token BPE de +chaque réponse de carte (convention GPT-2 : espace initial, ``" paris"``) → +``{token_id: boost décroissant par rang}`` — le dictionnaire qui entre dans +``speak(..., bias_tokens=...)`` (les organes orientent la bouche). + +Le cerveau est SUBSTRAT (numpy seul, gate mingw) : le tokenizer est passé EN +PARAMÈTRE — aucun import torch/tokenizers ici au niveau module. Les tests +substrat tournent donc dans les DEUX venvs ; les tests BPE (lib `tokenizers`) +et l'intégration ``speak()`` (torch) se skipent proprement en mingw. +""" +import pytest + +from fractus_vorax.brain import Brain +from fractus_vorax.compiler.atoms import Atom + +CAPITALS = [ + Atom("what is the capital of france", "paris", "cap.csv:2"), + Atom("what is the capital of spain", "madrid", "cap.csv:3"), + Atom("what is the capital of japan", "tokyo", "cap.csv:4"), + Atom("what is the capital of italy", "rome", "cap.csv:5"), +] +BOOKS = [Atom("who wrote hamlet", "william shakespeare", "books.csv:2")] + +# Ordre des cartes pour la question france (vérifié, D=2048) : +# [HEBBIEN paris, FAIT paris, FAIT rome, FAIT madrid] — rangs 0..3. + + +def _brain() -> Brain: + brain = Brain(D=2048) + brain.ingest_source("capitals", CAPITALS) + brain.ingest_source("books", BOOKS) + return brain + + +class FakeAnswerTok: + """Tokenizer fake du protocole minimal : le PREMIER id encode le mot + entier préfixé d'un espace (« paris » → [7, 8] comme un vrai BPE qui + fusionnerait). Injectif sur les ids connus → les poids par token sont + observables sans ambiguïté.""" + + vocab_size = 128 + eos_token_id = 127 + _WORDS = { + " paris": [7, 8], + " madrid": [9], + " tokyo": [10], + " rome": [11], + } + + def encode(self, text: str) -> list[int]: + return list(self._WORDS.get(text, [1])) + + def decode(self, ids: list[int]) -> str: + rev = {v[0]: k.strip() for k, v in self._WORDS.items()} + return "".join(rev.get(int(i), "?") for i in ids) + + +# --------------------------------------------------------------------------- +# speak_bias — substrat (les deux venvs) +# --------------------------------------------------------------------------- + +def test_speak_bias_contains_paris_token_positive_weight(): + """La question france → le premier token de " paris" est dans le bias, + avec le boost MAXIMAL (rang 0 : HEBBIEN/FAIT paris).""" + bias = _brain().speak_bias("what is the capital of france", FakeAnswerTok()) + assert bias[7] == pytest.approx(8.0) # boost par défaut, rang 0 + assert all(isinstance(t, int) for t in bias) + assert all(isinstance(w, float) for w in bias.values()) + assert all(0 <= t < FakeAnswerTok.vocab_size for t in bias) + + +def test_speak_bias_max_not_sum(): + """HEBBIEN (rang 0) et FAIT top-1 (rang 1) donnent la MÊME réponse + "paris" → accumulation MAX : le poids reste boost (8.0), PAS 8+4=12 — + l'échelle du steering reste bornée par boost quel que soit l'accord + des cartes (calibrée contre l'échelle z-norm, std 3).""" + bias = _brain().speak_bias("what is the capital of france", FakeAnswerTok()) + assert bias[7] == pytest.approx(8.0) + assert bias[7] < 12.0 # pas de sommation + + +def test_speak_bias_weights_decay_by_rank(): + """Rangs 2 et 3 (FAIT rome, madrid) : boost × 0.5² et × 0.5³ — les poids + décroissent strictement avec le rang, tous < au poids du rang 0.""" + bias = _brain().speak_bias("what is the capital of france", FakeAnswerTok()) + assert bias[11] == pytest.approx(8.0 * 0.25) # rome, rang 2 + assert bias[9] == pytest.approx(8.0 * 0.125) # madrid, rang 3 + others = [w for t, w in bias.items() if t != 7] + assert others and all(w < bias[7] for w in others) + + +def test_speak_bias_boost_scales_ladder(): + """Le ladder entier est proportionnel à boost : {8, 2, 1} à boost 8, + {50, 12.5, 6.25} à boost 50 (rang 0/2/3, rang 1 fusionné par max).""" + bias = _brain().speak_bias("what is the capital of france", FakeAnswerTok()) + assert sorted(bias.values(), reverse=True) == pytest.approx([8.0, 2.0, 1.0]) + big = _brain().speak_bias( + "what is the capital of france", FakeAnswerTok(), boost=50.0 + ) + assert big[7] == pytest.approx(50.0) + assert big[11] == pytest.approx(12.5) + + +def test_speak_bias_lacune_gives_empty_bias(): + """Cerveau vide → seule carte LACUNE (texte placeholder, pas une + réponse) → bias vide : on ne steere jamais la bouche avec une lacune.""" + empty = Brain(D=2048) + assert empty.speak_bias("anything", FakeAnswerTok()) == {} + + +def test_speak_bias_deterministic(): + """Même question, même cerveau → même dict (ordre d'insertion inclus) : + le steering est reproductible, comme toute la parole mécanique.""" + brain = _brain() + a = brain.speak_bias("what is the capital of france", FakeAnswerTok()) + b = brain.speak_bias("what is the capital of france", FakeAnswerTok()) + assert a == b and list(a) == list(b) + + +def test_speak_bias_rejects_bad_arguments(): + brain = _brain() + with pytest.raises(ValueError): + brain.speak_bias("q", FakeAnswerTok(), boost=0.0) + with pytest.raises(ValueError): + brain.speak_bias("q", FakeAnswerTok(), boost=-1.0) + with pytest.raises(ValueError): + brain.speak_bias("q", FakeAnswerTok(), k=0) + + +# --------------------------------------------------------------------------- +# BPE réel (lib tokenizers — se skipe en substrat mingw) +# --------------------------------------------------------------------------- + +def test_speak_bias_with_real_gpt2_bpe(): + """Le VRAI tokenizer GPT-2 : le bias contient le premier token de + " paris" (convention GPT-2 : l'espace initial fait partie du token — + encode(' paris')[0] != encode('paris')[0]) avec un poids > 0.""" + bpt = pytest.importorskip("fractus_vorax.model.bpe_tokenizer") + if not bpt.bpe_available(): + pytest.skip("tokenizers indisponible") + tok = bpt.Gpt2BpeTokenizer() + # la convention espace-initial est réelle : deux ids différents + assert tok.encode(" paris")[0] != tok.encode("paris")[0] + + bias = _brain().speak_bias("what is the capital of france", tok) + paris_id = tok.encode(" paris")[0] + assert paris_id in bias + assert bias[paris_id] > 0.0 + assert bias[paris_id] == pytest.approx(8.0) # rang 0, max + assert all(0 <= t < tok.vocab_size for t in bias) + + +# --------------------------------------------------------------------------- +# Intégration : speak(tiny, ..., bias_tokens=speak_bias(...)) (torch) +# --------------------------------------------------------------------------- + +def test_speak_runs_with_organic_bias_deterministic(): + """Intégration mini du plan : speak() avec le bias organique s'exécute + et est déterministe (même seed ⇒ même texte, diagnostics inclus).""" + torch = pytest.importorskip( + "torch", reason="speak nécessite torch (substrat sans torch)" + ) + from fractus_vorax.model.cte_core import CteCore, CteCoreConfig + from fractus_vorax.model.speak import speak + + torch.manual_seed(0) + core = CteCore(CteCoreConfig(vocab_size=128)) + tok = FakeAnswerTok() + bias = _brain().speak_bias("what is the capital of france", tok) + assert bias # non vide, ids dans le vocab tiny (<= 128) + assert all(0 <= t < 128 for t in bias) + + text_a, diags_a = speak( + core, tok, "what is the capital of france", + max_new_tokens=4, bias_tokens=bias, seed=0, + ) + text_b, diags_b = speak( + core, tok, "what is the capital of france", + max_new_tokens=4, bias_tokens=bias, seed=0, + ) + assert text_a == text_b and diags_a == diags_b + assert isinstance(text_a, str) + + +def test_speak_strong_organic_bias_emits_answer_first(): + """Boost fort (50, le δ prouvé dominateur en tâche 1) : le PREMIER token + émis EST le token-réponse des organes (le id 7 = " paris") — les organes + dirigent la bouche, mesurable pas seulement exécutable.""" + torch = pytest.importorskip( + "torch", reason="speak nécessite torch (substrat sans torch)" + ) + from fractus_vorax.model.cte_core import CteCore, CteCoreConfig + from fractus_vorax.model.speak import speak + + torch.manual_seed(0) + core = CteCore(CteCoreConfig(vocab_size=128)) + tok = FakeAnswerTok() + bias = _brain().speak_bias( + "what is the capital of france", tok, boost=50.0 + ) + for seed in range(10): + _, diags = speak( + core, tok, "hello", max_new_tokens=1, + bias_tokens=bias, seed=seed, + ) + assert diags[0]["chosen"] == tok.decode([7]), ( + f"seed {seed}: les organes n'ont pas dirigé le premier pas" + ) diff --git a/tests/test_traces.py b/tests/test_traces.py new file mode 100644 index 0000000000000000000000000000000000000000..7784fe95998dc7e669cca0b0492cdf6642f6c446 --- /dev/null +++ b/tests/test_traces.py @@ -0,0 +1,49 @@ +# tests/test_traces.py +from pathlib import Path + +import numpy as np + +from fractus_vorax.compiler.encode import text_hv +from fractus_vorax.hv import D_DEFAULT, sim +from fractus_vorax.organs.traces import TraceStore + + +def _fill(store: TraceStore) -> None: + for i, q in enumerate(["what is the capital of france", "who wrote hamlet", "quantum stuff"]): + addr, value = text_hv(q, D=2048), text_hv(q, D=2048) + store.write(i, addr, value) + + +def test_write_and_retrieve_top1(tmp_path): + store = TraceStore(D=2048) + _fill(store) + hits = store.retrieve(text_hv("what is the capital of france", D=2048), k=2) + assert len(hits) == 2 + assert hits[0][0] == 0 # le bon atom_id en tête + assert hits[0][1] > 0.5 # similarité élevée + assert hits[0][1] >= hits[1][1] # tri décroissant + + +def test_empty_retrieve_returns_empty(): + store = TraceStore(D=2048) + assert store.retrieve(text_hv("rien", D=2048)) == [] + assert len(store) == 0 + + +def test_weight_boosts_ranking(): + store = TraceStore(D=2048) + q1, q2 = "what is the capital of france", "what is the capital of spain" + store.write(0, text_hv(q1, D=2048), text_hv(q1, D=2048), weight=0.1) + store.write(1, text_hv(q2, D=2048), text_hv(q2, D=2048), weight=2.0) + hits = store.retrieve(text_hv("what is the capital of france", D=2048), k=2) + assert hits[0][0] == 1 # poids fort domine malgré des tokens partagés + + +def test_save_load_roundtrip(tmp_path): + store = TraceStore(D=2048) + _fill(store) + store.save(tmp_path / "brain") + loaded = TraceStore.load(tmp_path / "brain") + assert len(loaded) == 3 + q = text_hv("who wrote hamlet", D=2048) + assert [i for i, _ in loaded.retrieve(q, k=3)] == [i for i, _ in store.retrieve(q, k=3)] diff --git a/tests/test_vocal.py b/tests/test_vocal.py new file mode 100644 index 0000000000000000000000000000000000000000..aca9509513053ab4b5b36ac3aa004820046410bf --- /dev/null +++ b/tests/test_vocal.py @@ -0,0 +1,52 @@ +# tests/test_vocal.py +from fractus_vorax.cards import Card +from fractus_vorax.model.vocal import CharTokenizer, core_available, serialize_cards, verbalize + +CARDS = [ + Card("FAIT", "what is the capital of japan => tokyo", 0.9, "cap.csv:4"), + Card("ANALOGIE", "paris", 1.0, "cap"), +] + + +def test_serialize_cards_deterministic_and_sorted(): + s1 = serialize_cards("q?", CARDS) + s2 = serialize_cards("q?", CARDS) + assert s1 == s2 + assert s1.startswith("[FAIT]") or "[ANALOGIE]" in s1 + assert s1.endswith("Q: q?") + lines = [l for l in s1.splitlines() if l] + assert lines == sorted(lines, key=lambda l: (not l.startswith("["), l)) # cartes avant Q + + +def test_char_tokenizer_roundtrip(): + tok = CharTokenizer(vocab_size=1114112) + assert tok.decode(tok.encode("héllo wörld")) == "héllo wörld" + + +def test_verbalize_returns_text_when_core_available(): + if not core_available(): + import pytest + pytest.skip("torch indisponible dans ce python") + from fractus_vorax.model.fractus_core import FractusCore, FractusCoreConfig + import torch + torch.manual_seed(0) + core = FractusCore(FractusCoreConfig(vocab_size=1114112, d_model=64, n_layers=1, n_experts=2, rank=8, max_seq_len=512)) + out = verbalize(core, "what is the capital of japan?", CARDS[:1], CharTokenizer(vocab_size=1114112), max_new_tokens=8) + assert isinstance(out, str) and len(out) >= 0 # le noyau non-né peut sortir du bruit — le contrat est le pipeline + + +def test_safe_decode_text_replaces_surrogates(): + from fractus_vorax.model.vocal import safe_decode_text + bad = "ok\ud800\udfffend" + out = safe_decode_text(bad) + assert "\ud800" not in out and out.startswith("ok") + + +def test_strip_eos_drops_single_trailing_eos(): + # Directive de la revue T2 : generate inclut l'EOS dans la séquence + # retournée (append-then-break) — _strip_eos retire UN unique EOS + # final avant décodage, sans jamais toucher au reste. + from fractus_vorax.model.vocal import _strip_eos + assert _strip_eos([1, 2, 50256], 50256) == [1, 2] + assert _strip_eos([1, 2], 50256) == [1, 2] + assert _strip_eos([1, 2], None) == [1, 2]