Architecture
Sommaire
- Rust et Python
- Pipeline d'exécution
- 1. Parsing : Tree-sitter
- 2. Transformation : CST vers IR
- 3. Analyse sémantique
- 4. Compilation et exécution
- CFG et SSA
- Statut
- Passes inter-blocs
- Destruction et reconstruction
- Vérification
- Concepts communs
- Opcodes
- Scopes et closures
- Proper tail calls
- Évaluation différée
- Positions source
- Chargement de modules et plugins
- Frontière des plugins natifs
- Delta dataflow
- Debugger
- Vérification formelle
- Où trouver le code
- Workflow de développement
Catnip sépare l'API Python d'un pipeline Rust. Cette frontière garde l'intégration Python accessible tout en plaçant le parsing, l'analyse, le dispatch et les chemins chauds dans des composants natifs.
Rust et Python
Python porte :
- la classe
Catnipet l'API d'embedding ; - l'intégration avec les objets et modules Python ;
- une partie des commandes CLI et des adaptateurs utilisateur.
Rust porte :
- la grammaire Tree-sitter et la transformation vers l'IR ;
- l'analyse sémantique et les optimisations ;
- la VM, les valeurs, les scopes et le registre d'opérations ;
- le JIT Cranelift ;
- les outils de formatage, lint, LSP, MCP et REPL natifs.
PyO3 relie les deux sans sérialiser les objets à chaque appel. Les runtimes purs restent séparés de PyO3 afin que les binaires standalone et le serveur MCP puissent exécuter Catnip sans importer l'extension Python.
Références :
- PyO3 User Guide ;
- Extending Python with Rust (PyCon 2023).
Pipeline d'exécution
Le pipeline suit un contrat prepare/execute :
prepare(source)parse, transforme et analyse la source ;- l'IR préparée reste disponible pour l'inspection ;
execute_prepared()compile le bytecode au premier appel, puis le réutilise ;prepare()etreset()invalident ensemble l'IR et le bytecode associés.
La compilation ne dépend que de l'IR préparée. Réutiliser le CodeObject réduit le travail et conserve un seul
propriétaire pour ses constantes et sous-fonctions. parse() expose des PyIRNode en lecture seule pour les niveaux de
parsing et les outils MCP.
1. Parsing : Tree-sitter
La source de la grammaire est catnip_grammar/grammar.js. Tree-sitter fournit :
- une grammaire déclarative avec précédence et associativité explicites ;
- une récupération sur erreur utile à l'éditeur et au LSP ;
- un arbre concret avec offsets source ;
- le parsing incrémental pour les outils interactifs.
Tree-sitter n'est pas formellement prouvé dans ce dépôt. Les modèles Coq vérifient des propriétés de grammaires Catnip réduites ; voir Preuves Coq.
Références :
2. Transformation : CST vers IR
Les transformeurs Rust convertissent le CST en IR, une structure indépendante de PyO3 et indexée par IROpCode. Ils
couvrent les littéraux, opérateurs, contrôles de flux, fonctions, patterns, structures, traits, enums, unions,
broadcasts et accès.
Après la transformation complète, validate_control_flow vérifie le placement de return, break et continue. Cette
validation se déroule :
- avant les optimisations ;
- pour tous les exécuteurs ;
- même quand l'analyse sémantique est désactivée.
Une passe ultérieure ne peut donc pas masquer un contrôle de flux invalide en supprimant du code inatteignable.
3. Analyse sémantique
L'analyse sémantique :
- lit les pragmas avec la précédence CLI/environnement, fichier, puis défaut ;
- résout les identifiants et les annotations utiles ;
- détecte les positions terminales ;
- vérifie l'exhaustivité des
matchquand elle est déterminable ; - applique les passes IR activées.
optimize=0 désactive les passes ; les niveaux 1 à 3 activent le lot courant. Les règles et leur périmètre sont
documentés dans Optimisations, qui reste la source de vérité pour la liste des passes.
4. Compilation et exécution
Le compilateur unifié choisit le chemin Rust pur pour l'IR représentable sans Python et le pont PyO3 pour les constantes qui l'exigent. Les deux chemins partagent l'émission, les pools de constantes, les sauts et le peephole.
La VM est l'exécuteur par défaut. Elle utilise des valeurs NaN-boxées, des slots locaux, des frames recyclées et un dispatch Rust. Le mode AST interprète directement les opérations via le registre et sert d'oracle différentiel. Machine virtuelle décrit leurs contrats communs.
Le mode standalone réemploie les composants Rust et les adaptateurs d'intégration nécessaires aux scripts .cat. Les
différences portent sur l'hôte, pas sur la syntaxe du langage.
CFG et SSA
catnip_core/src/cfg/ peut convertir l'IR en graphe de contrôle, construire une forme SSA, appliquer des passes
inter-blocs, détruire la SSA puis reconstruire l'IR.
Le module suit l'algorithme de Braun et al. (2013), sans calcul préalable des frontières de dominance. Les boucles naturelles qui partagent un header sont fusionnées avant les passes.
Statut
Le tier est atteint au niveau d'optimisation 3 (catnip -o level:3, pragma("optimize", 3)), un cran au-dessus du
niveau par défaut. Il est donc demandé explicitement, jamais ambiant.
Le match reste préservé comme nœud opaque plutôt que reconstruit arm par arm : les passes ne descendent pas dans les
arms. Chacune s'en protège au lieu de supposer l'arm vide -- LICM scanne les noms assignés dans les arms, DSE exclut les
blocs d'arm de ses candidats, GVN reste additif. La limite porte donc sur ce que le tier optimise, pas sur ce qu'il
garantit.
Warning: ce passage augmente la résistance mentale de +5. Le niveau 3 est atteint par déclaration, ce qui n'est pas la même chose que d'y arriver.
Passes inter-blocs
Le hook interne applique, dans cet ordre :
- LICM déplace les valeurs invariantes seulement quand la boucle les exécute sur tous les chemins concernés. Une
sortie précoce, une définition conditionnelle, un contrôle de boucle caché dans un
matchou un appel qui pourrait relire une capture bloque le déplacement ; - DSE globale retire un store transparent mort sur tous les chemins. Une opération fautable ou un appel fait barrière ;
- GVN remplace les expressions redondantes seulement pour des valeurs scalaires immuables prouvées.
Chaque passe préfère refuser une transformation plutôt que spéculer sur un effet invisible au CFG.
Destruction et reconstruction
La destruction SSA matérialise les phis par lots de copies sur les arêtes. Un lot est parallèle ; les cycles utilisent
un temporaire pour éviter le problème du swap et de la lost-copy. La reconstruction marche uniquement sur les arêtes
forward pour trouver le merge d'un if, sans remonter une back-edge de boucle.
Les preuves CatnipParallelCopyProof.v, CatnipRegionMergeProof.v et CatnipDestructionBridge.v couvrent ces
invariants dans leurs modèles ; voir Preuves Coq.
Vérification
En build debug :
ControlFlowGraph::verify()contrôle les arêtes, prédécesseurs, successeurs, entry et exit ;SSAContext::verify()contrôle l'arité et le scellage des phis.
Les tests ajoutent trois niveaux :
- round-trip sur des IR réellement parsées ;
- différentiel gate off/on dans le moteur pur, seul à exposer l'interrupteur depuis que le module y a été porté ;
- génération de programmes bien formés, biaisée vers boucles imbriquées, sorties précoces,
matchet closures.
La vérification structurelle localise les graphes invalides ; le différentiel détecte les graphes valides qui ont changé de sens. Les deux sont nécessaires.
Un graphe valide peut encore calculer la mauvaise chose. La validité est une propriété de forme, pas un alibi.
Concepts communs
Opcodes
Les enums IROpCode et VMOpCode remplacent les noms d'opérations sous forme de chaînes. Ils donnent un dispatch et
une classification constants, et servent de source de vérité aux représentations générées. Les numéros ne doivent pas
être recopiés dans le code Python.
Scopes et closures
Les scopes utilisent une map plate et des marqueurs de frame :
- lookup moyen O(1) ;
- shadowing suivi explicitement ;
- nettoyage proportionnel au nombre de liaisons du frame.
Cette organisation repose sur les propriétés usuelles d'une table de hachage, auxquelles Catnip ajoute les marqueurs nécessaires au nettoyage de frame.
Une closure capture les liaisons de fonction par valeur. Les variables de module restent résolues dans la map vivante ; une écriture sur une capture modifie la capture de cette closure, pas le frame disparu. Les deux exécuteurs appliquent la même règle. La spécification est dans Scopes et variables.
Les scopes utilisent un annuaire et des marqueurs de frame. Chercher un nom reste constant ; fermer un étage demande de retirer ce qu'il a déclaré.
Proper tail calls
L'analyseur marque les appels en position terminale. La VM remplace la frame courante ; l'exécuteur AST renvoie un
TailCall à une boucle trampoline. La cible peut changer de fonction et de closure, ce qui couvre la récursion mutuelle
et les fonctions imbriquées.
Références :
Évaluation différée
Les arguments qui représentent un bloc de contrôle ne sont pas évalués avant le dispatch. if choisit une branche,
while réévalue sa condition et match lie ses captures avant le garde. Une classification centrale des opcodes de
contrôle évite d'implémenter ce contrat séparément dans chaque opération.
Positions source
Les offsets Tree-sitter traversent l'IR et les optimisations. Le compilateur produit une table de position parallèle au bytecode ; la VM capture l'offset fautif et la pile d'appels uniquement en cas d'erreur. Le frontend calcule ensuite ligne, colonne, extrait et suggestions de noms.
Les suggestions utilisent la distance de Damerau-Levenshtein sur les variables, attributs et mots-clés disponibles.
Chargement de modules et plugins
Les chemins de recherche sont déterminés au démarrage à partir du fichier appelant, de l'environnement et de la
configuration. Le programme ne dispose pas d'un équivalent mutable de sys.path : le résultat d'une résolution ne
dépend pas d'un import précédent qui aurait modifié un état global.
Deux loaders partagent la résolution :
ImportLoaderaccepte les modules Catnip, Python et les plugins natifs ;PureImportLoaderaccepte les modules Catnip et les plugins Rust sans PyO3.
Les protocoles py:, cat: et rs: sélectionnent explicitement un backend. Les packages déclarent leur entrée et
leurs exports dans lib.toml. La syntaxe et l'ordre de résolution complets sont documentés dans
Chargement de modules.
Conséquence pour les modules stdlib qui déclarent un backend PyO3 : ils sont compilés deux fois, en extension Python
et en plugin natif. Ce ne sont pas deux formes du même binaire — le premier référence libpython et ne peut pas être
chargé par un processus qui n'en a pas, ce qui est précisément le cas du moteur pur. Les deux artefacts portent le même
nom de fichier et vivent donc à deux endroits distincts : la variante PyO3 dans le paquet Python, la native dans le
lib/ du répertoire des binaires. La liste des modules à construire se dérive des spec.toml, si bien qu'un module
ajouté obtient ses deux variantes sans qu'aucune liste soit à tenir.
Frontière des plugins natifs
L'ABI v6 distingue trois canaux de retour :
- scalaire inline, validé par
from_raw_scalar; - valeur construite dans le heap de l'hôte ;
- handle d'objet opaque, manipulé par les callbacks du plugin.
Un pointeur brut non déclaré est rejeté. Un plugin utilise les builders de l'hôte pour construire chaînes, bytes,
listes, dictionnaires et grands entiers ; l'hôte ne déréférence donc pas une allocation possédée par une bibliothèque
chargée. Arc<Library> garde la bibliothèque ouverte tant que ses objets vivent, et chaque callback est contenu par
catch_unwind.
Un chargement est tout ou rien. Le descripteur est lu dans des tables locales, et le registre ne les adopte qu'une fois la bibliothèque enregistrée : un descripteur refusé à mi-parcours — un nom de fonction mal formé, par exemple — ne laisse donc aucune entrée derrière lui. Publier une entrée appelable avant de posséder la bibliothèque reviendrait à nommer du code que la sortie en erreur s'apprête à décharger.
Chaque constructeur de l'hôte rend une valeur possédée, et l'ABI n'offre aucune primitive pour en rendre une : ce qu'un
plugin construit sans le remettre à l'hôte n'est récupérable par personne. Un retour en erreur peut donc désigner la
valeur qu'il abandonne, que l'hôte libère alors — un seul jeton fait le voyage, si bien qu'un plugin assemblant son
résultat par morceaux accumule dans son propre tas et n'appelle les constructeurs qu'une fois sûr d'aboutir. Le contrat
complet est écrit sur PluginResult.
Le même registre est accessible depuis la PureVM et le pont PyO3. Les erreurs de plugin gardent un code distinct pour
les demandes de sortie, afin qu'un exit ne soit pas aplati en message d'erreur ordinaire.
Le message d'une erreur voyage par pointeur brut, et l'ABI n'offre aucun moyen de le libérer. Il n'est pas abandonné
pour autant : retain_message garde le dernier message par fil et rend l'adresse de ce tampon, que l'hôte recopie
avant de revenir au plugin — une allocation par fil au lieu d'une par erreur. La règle qui en découle, pour un auteur de
plugin : ne jamais relire un résultat d'erreur après en avoir construit un second, sinon le plus ancien rend le
message du plus récent. Les 34 sites qui en construisent un le font tous en position terminale, donc aucun ne le peut ;
et si l'un s'y prenait mal un jour, le pire cas est un message faux, jamais un pointeur pendant.
Les arguments nommés voyagent depuis la v6, en deux tableaux parallèles — les noms d'un côté, les valeurs de l'autre, même longueur. Un dictionnaire construit par l'hôte n'était pas une option : l'API que l'hôte expose aux plugins ne contient que des constructeurs de valeurs, aucun lecteur, si bien qu'un plugin ne saurait pas le relire. Les valeurs sont empruntées comme les positionnelles, et un nom qu'un plugin ne connaît pas doit être refusé, jamais ignoré : c'est un argument silencieusement perdu qui a motivé cette version.
Un handle rendu plusieurs fois est possédé plusieurs fois : chaque valeur hôte construite autour de lui porte son propre
compteur et appellera le callback de destruction à sa mort. Un plugin qui rend le même handle depuis deux appels — ce
que fait __enter__ sur un descripteur de fichier, qui doit se rendre lui-même — compte donc ses porteurs de son côté,
sinon la première mort libère ce que la seconde utilise encore. L'ABI n'offre pas d'incrément côté hôte : la
responsabilité est celle du plugin, qui seul sait quand deux handles désignent la même ressource.
Un handle n'appartient pas au fil qui l'a émis. Via le pont PyO3, la valeur qui le porte est un objet Python ordinaire :
elle circule entre fils, ses méthodes s'appellent depuis n'importe lequel, et son callback de destruction s'exécute sur
celui où meurt la dernière référence. Le moteur pur ne le permet pas — ses valeurs ne sont ni Send ni Sync — mais un
plugin est chargé par les deux hôtes, donc c'est le contrat le plus large qui l'engage. Un plugin range donc ses objets
là où la fin d'un fil ne les emporte pas, et n'utilise pas d'identifiant dont le sens dépende du fil qui le lit — un
indice dans une table par fil satisfait les deux conditions à l'envers, et rend l'objet d'un autre plutôt que rien.
En contrepartie, tant qu'un appel est en vol sur un objet, son handle ne peut pas être libéré : l'appelant détient une référence à la valeur pour la durée de l'appel. Un plugin peut donc relâcher son propre verrou avant un appel bloquant sans craindre que l'objet disparaisse sous lui.
Un plugin qui construit un entier, au lieu de rendre celui qu'il a reçu, doit en vérifier la portée. from_int ne
la vérifie pas : elle porte une assertion de debug, donc rien en release, où le payload est simplement masqué. Une somme
qui dépasse SMALLINT_MAX revient alors avec son signe inversé, et en debug l'assertion panique dans une fonction
extern "C", ce qui abandonne le processus. try_from_int est le constructeur qui vérifie ; un plugin qui veut la
sémantique du langage plutôt qu'un refus promeut en grand entier par make_bigint. Le plugin d'exemple ne le faisait
pas jusqu'au 2026-08-03, ce qui en faisait le modèle exact à ne pas copier.
Delta dataflow
catnip_core/src/delta/ contient un noyau différentiel inerte : aucun chemin du pipeline ne l'appelle.
Une Collection<V> représente un multiset versionné ; un Delta<V> représente une transition compactée. Les opérateurs
sans état map, filter et concat séparent compute, qui peut échouer sans mutation, de commit, qui applique les
écritures préparées sans pouvoir échouer. Les lois algébriques correspondantes sont dans proof/delta/.
Le moteur ne recalcule que ce qui change. Pour l'instant, rien ne l'appelle, donc son bilan est exact.
Debugger
Le debugger relie une VM exécutée en arrière-plan à un frontend console ou MCP par deux channels :
Un breakpoint peut venir de breakpoint() ou d'un offset ajouté par le frontend. La pause capture locals, position et
pile d'appels. Le callback libère le GIL pendant l'attente de la commande.
| Action | Arrêt suivant |
|---|---|
| continue | prochain breakpoint |
| step into | prochaine instruction |
| step over | prochaine instruction à la même profondeur |
| step out | retour de la frame courante |
Le debugger observe une VM suspendue. L'observateur obtient un snapshot, pas le droit de réécrire le passé.
Vérification formelle
Les modèles Coq couvrent notamment :
- précédence et monotonie du parseur formel ;
- broadcasting et ND-récursion ;
- scopes, patterns, fonctions et TCO ;
- encodage des valeurs, pile VM et frames ;
- passes IR, liveness, dominance et SSA ;
- linéarisation C3, structures, traits et opérateurs.
make proof compile le corpus. Preuves Coq précise les hypothèses et les fichiers.
Où trouver le code
| Dossier | Rôle |
|---|---|
catnip/ |
API et intégration Python |
catnip_core/ |
IR, analyse, CFG/SSA, arithmétique, JIT et composants purs partagés |
catnip_vm/ |
compilateur et VM sans PyO3 |
catnip_rs/ |
extension PyO3, runtime Python, VM principale et debugger |
catnip_repl/ |
REPL ratatui |
catnip_tools/ |
formatter, linter et outils de debug |
catnip_grammar/ |
grammaire Tree-sitter |
catnip_lsp/ |
serveur LSP |
catnip_mcp/ |
serveur MCP |
catnip_libs/ |
modules de bibliothèque standard |
proof/ |
modèles et preuves Coq |
Les features catnip_core/jit et catnip_tools/semantic évitent d'embarquer Cranelift ou l'analyse sémantique dans un
outil qui n'en a pas besoin.
Workflow de développement
make test-rust-fast
CATNIP_DEV=1 make compile
make test
Après une modification de catnip_grammar/grammar.js :
make grammar-deps
make ts-test