Architecture
Sommaire
- Stratégie : Rust + Python
- Pourquoi PyO3
- Pipeline d'exécution
- 1. Parsing : Tree-sitter
- 2. Transformation : CST → IR
- 3. Semantic Analysis : IR → Op
- 4. Exécution
- Concepts Clés
- OpCode : Identifiants d'Opérations
- Scope : Variables O(1)
- Registry : Table des Opérations
- Tail Call Optimization (TCO)
- Lazy Evaluation
- Error Handling : Source Locations
- Module loading : Résolution Statique
- Delta dataflow : le noyau différentiel (en construction)
- Debugger
- Architecture
- Entrypoints dans la VM
- Step modes
- Vérification Formelle
- Où Trouver le Code
- Workflow de Développement
Vue d'ensemble de l'architecture Catnip pour contributeurs.
Stratégie : Rust + Python
Catnip utilise une architecture hybride, pour garder l'ergonomie côté Python et la performance côté Rust :
Python : API de haut niveau, orchestration, intégration
- Classe principale
Catnip, context, REPL et CLI
Rust : Composants bas niveau (via PyO3)
- Parser et transformations (tree-sitter)
- Semantic analyzer et optimisations
- Scope management (O(1) lookup)
- VM bytecode et JIT (Cranelift)
- Registry et dispatch d'opérations
Principe : Rust fait le travail lourd, Python garde l'interface simple.
Pourquoi PyO3
PyO3 sert de pont propre entre Python et Rust :
- Interopérabilité zéro-cost (pas de sérialisation)
- Memory safety garantie par Rust
- Intégration directe avec l'API Python
- Utilisé par projet production (Ruff, Polars, tiktoken)
Références :
- PyO3 User Guide
- Extending Python with Rust (PyCon 2023)
Pipeline d'exécution
En mode VM (défaut), Catnip délègue l'exécution à Pipeline, un pipeline Rust complet :
Le pipeline utilise un pattern prepare/execute : parse() appelle prepare(source) qui fait parse + semantic et
stocke l'IR optimisé. execute() appelle execute_prepared() qui compile et exécute depuis l'IR stocké (pas de
re-parse). Un seul chemin de parsing.
parse() expose les PyIRNode via get_prepared_ir_nodes() (wrappers lecture seule). Les PyIRNode servent à -p 1/2 et
au MCP parse_catnip.
PyIRNode (catnip_rs/src/ir/pyclass.rs) wrappe IR pour inspection Python : getters en lecture seule (opcode,
args, kwargs, value, name...) et sérialisation JSON native (to_json()).
1. Parsing : Tree-sitter
Le parser utilise Tree-sitter, un générateur de parseur incrémental :
Transparence : Tree-sitter n'est pas formellement prouvé dans ce repo.
Pourquoi Tree-sitter :
- Parser généré en C (performance native)
- Parsing incrémental (réévalue seulement les modifications)
- Error recovery (robuste face aux erreurs de syntaxe)
- Écosystème riche (syntax highlighting, code folding)
Avantage vs parser manuel : la précédence des opérateurs est codée dans la grammaire (prec.left(),
prec.right()), pas besoin de la recoder ailleurs.
Références :
2. Transformation : CST → IR
Le transformer convertit l'arbre de syntaxe en IR (Intermediate Representation) :
IR : structure basée sur des OpCode (entiers) pour identifier les opérations
- Sortie brute du parser, pas encore optimisée
- Utilise l'enum
IROpCode - Type
IR(Rust) sans dépendance PyO3 pour pipeline standalone PyIRNodewrappeIRpour inspection Python (getters,to_json())
72+ transformateurs en Rust pur, wrapper PyO3 pour le bridge Python. Couvrent tout le langage :
- Literals (int, float, string, list, dict)
- Operators (binary, unary, comparison, bitwise)
- Control flow (if, while, for, match, block)
- Functions (lambda, fn_def, call)
- Pattern matching (literal, var, wildcard, or, tuple, struct, enum, union variant qualifié)
- Structures, enums et unions taggées (struct, trait, enum, union)
- Broadcasting et accès (chained, getattr, index, slice)
3. Semantic Analysis : IR → Op
L'analyse sémantique transforme l'IR en Op exécutable :
Responsabilités :
- Résolution des identifiants
- Pré-scan des pragmas
tco/optimize(précédence : CLI/env > pragma in-file > défaut) - Détection des tail calls (TCO)
- Optimisations (5 passes IR)
- Vérification statique des annotations de type et exhaustivité des
match(diagnostics surfacés par le lint : E300, I103)
Optimisations (effet binaire : optimize=0 désactive toutes les passes, 1-3 les activent) :
- Passes IR (niveau expression) : simplifications locales (constant folding, dead code, etc.)
Voir OPTIMIZATIONS pour le détail des passes et des défauts par entrypoint.
Op : structure exécutable finale avec OpCode optimisé
CFG/SSA : Infrastructure Inter-blocs (non branchée par défaut)
Le module catnip_core/src/cfg/ sait construire un Control Flow Graph (CFG) puis passer en SSA pour optimiser à
l'échelle de plusieurs blocs. Pur Rust, sans dépendance Python (porté depuis catnip_rs en 2026-06, opérant sur
enum IR). Il n'est pas branché par défaut sur le pipeline sémantique : son ancien consommateur (l'analyzer PyO3) a
été supprimé, et le JIT construit ses propres CFG.
Un gate interne (SemanticAnalyzer::set_cfg_enabled, off par défaut) câble le round-trip dans analyze_full, après
les passes locales : IR → CFG → SSA → LICM → DSE → GVN → destruction → reconstruction. Trois passes inter-blocs y
tournent, chacune gardée pour refuser plutôt que dégrader (hoist gardé par la condition de boucle, stores transparents
tués sur tous les chemins, copies limitées aux scalaires immuables prouvés). Aucune surface utilisateur (ni CLI, ni
pragma, ni config) ne l'expose, délibérément : le différentiel passe la suite entière passes actives, mais le chemin
reste non distribuable en l'état (voir « Vérification » ci-dessous).
Warning: ce passage augmente la résistance mentale de +5, sur un graphe que traversent désormais trois passes.
Pipeline CFG/SSA :
Passes SSA (inter-blocs, dans l'ordre du hook) :
- LICM - Hoist les défs invariantes de
whiledans un bloc gardé par une copie de la condition (pas de spéculation zéro-itération). Un candidat doit tourner à chaque itération : son bloc domine chaque source de back-edge (les défs de branches conditionnelles restent en place), une boucle à sortie précoce est refusée en entier, et le contenu opaque d'unmatchpréservé compte — ses arms sont scannés dans l'IR pour le contrôle de boucle (break/continue/returnsans arête CFG) et leurs cibles d'affectation comptent comme défs de boucle (aucune valeur SSA ne les représente). Enfin, une boucle dont un bloc du corps contient un appel est refusée en entier : l'appel peut relire par capture de closure (late binding) un nom que le hoist déplacerait, une lecture invisible aux use-sets SSA — la même barrière-call que la DSE. Câblée - DSE globale - Élimine les stores jamais lus, v1 gardée : seuls les stores transparents (littéral scalaire ou référence) tués sur tous les chemins tombent ; un call ou un op fautable dans la fenêtre fait barrière ; câblée
- GVN - Global Value Numbering : les expressions redondantes prouvées scalaires immuables deviennent des copies
(alias single-def, snapshot
__gvnNmulti-def) ; subsume la CSE syntaxique ; câblée
IV (induction variables) — différée (2026-07-03) : la strength reduction échange un Mul contre un Add par itération,
un gain noyé dans le dispatch de la VM, et Cranelift refait la sienne sur le natif JIT — le ratio gain/risque ne
justifie pas la seule passe qui réécrirait une récurrence. ssa_iv.rs (détection BIV/DIV) reste dans le module, non
câblé ; le cadrage d'une v1 est tracé dans wip/CFG_SSA_REWIRING.md si le contexte change (backend AOT, boucles chaudes
hors JIT).
Boucles naturelles : detect_loops fusionne les boucles partageant un header (construction standard) — un
continue ajoute une seconde back-edge au même header de while, et analyser les deux corps partiels séparément
donnerait aux passes une boucle amputée des blocs et défs de la branche sœur.
Construction SSA : utilise l'algorithme de Braun et al. (2013), en un seul passage RPO (reverse postorder), sans calcul explicite des dominance frontiers.
SetLocals est le nœud IR central pour l'SSA : chaque affectation crée une nouvelle version de variable. Dans le
round-trip actuel (zéro passe), la destruction est un no-op : la reconstruction réémet les affectations préservées
des blocs, qui portent déjà chaque valeur aux jonctions. Les anciennes copies identité var = var par phi ont été
retirées — inertes dans le cas général, elles cassaient au préheader d'une variable définie pour la première fois dans
une boucle (lecture d'une variable non liée). La vraie destruction (temporaires + versioning) ne devient nécessaire
qu'avec les passes inter-blocs, qui déplacent des valeurs (swap / lost-copy).
L'SSA garantit que chaque variable n'est assignée qu'une seule fois. Ce qui est pratique pour l'optimiseur, mais existentiellement perturbant pour les variables qui se pensaient réassignables.
Vérification structurelle : le module valide ses propres invariants en build debug. ControlFlowGraph::verify()
contrôle la cohérence entry/exit, la validité des arêtes et la consistance bidirectionnelle prédécesseurs/successeurs ;
SSAContext::verify() contrôle l'arité des phi (un opérande par prédécesseur) et l'absence de phi incomplet après
scellage. Ces debug_assert! se déclenchent à la sortie de la construction CFG et SSA, transformant une corruption
silencieuse en panique localisée. Un harnais round-trip (catnip_vm/tests/cfg_roundtrip.rs) rejoue
source → IR → CFG → SSA → destruction → reconstruction sur de l'IR réellement parsé. La reconstruction (region.rs)
couvre tout le corpus du harnais avec vérification structurelle : linéaire, if/else, while, for, boucles
imbriquées et match. Le type de boucle (while/for) et ses opérandes hors-corps sont récupérés depuis l'op de
boucle que le builder stocke dans le bloc header. Le match est, lui, préservé tel quel (l'OpMatch original est
réémis) plutôt que reconstruit arm par arm — suffisant sur IR non optimisé, à durcir au moment du rebranchement.
La vérification structurelle ne suffit pas : un différentiel d'exécution (gate on vs off, même programme) a exposé trois non-identités que la forme seule masquait, toutes refermées.
- Le code suivant un
matchétait droppé (lematchpréservé était réémis, puis le parcours s'arrêtait). La reconstruction reprend désormais au bloc de merge que le builder enregistre explicitement (BasicBlock.match_merge) : l'inférer du graphe échoue dès qu'un arm finit parbreak/continue/return, puisque cet arm saute hors dumatchet ne rejoint jamais le merge. - Une boucle dont le corps sort toujours (
while True { ... break }) n'a pas de back-edge vers son header et était reconstruite enif/else(« break outside loop »). Le header est maintenant reconnu par l'op de boucle préservé, et les arêtesbreak/continue/returnarrêtent la reconstruction du corps au lieu d'y aspirer le code post-région. - Le merge d'un
ifinterne à une boucle était mal détecté (la back-edge faisait passer le header/exit de boucle pour le merge, collapsant l'if). Il est désormais contraint d'être dominé par le header de l'if.
Régression couverte par gate_roundtrip_break_continue_post_match (arms break/continue suivis de code post-match
vivant inclus).
Le différentiel a ensuite été étendu à la suite entière : une env interne (CATNIP_CFG_INTERNAL) force le gate sur
les ~2350 tests d'intégration, qui servent d'oracle (un test vert cfg-on et cfg-off prouve l'équivalence sur son
programme). Il a exposé trois nouvelles classes, toutes refermées :
- Destruction SSA triviale — voir « SetLocals » ci-dessus : les copies identité
var = varcassaient au préheader d'une variable née dans une boucle. Supprimées. - Chaîne
elif— le builder ne lisait que la première paire(condition, bloc)de l'OpIf, droppant toute branche elif. Il replie désormais les paires suivantes dans unOpIfimbriqué (cascade équivalente). - Bloc × scope — un
{...}standalone isole son scope (ses locals ne fuient pas) ; le builder l'aplatissait, droppant la valeur finale et faisant fuiter les liaisons. Il est désormais préservé opaque, tandis qu'un helper distinct aplatit les corps de contrôle (for/while/if), qui, eux, fuient.
La suite passe à 100 % cfg-on en mode VM et AST, passes actives (LICM, DSE et GVN câblées et gardées) : le
différentiel est observablement identité sur tout le corpus. Le gate reste néanmoins interne — le match round-trippe
par préservation d'op (arms non reconstruits), et l'activation par env ambiante doit sortir du binaire distribué avant
tout ship. Exposer une surface utilisateur reste prématuré.
- Le corpus fini a ensuite été dépassé par un harnais par propriétés (
catnip_vm/tests/cfg_proptest.rs, proptest) : - un générateur de programmes bien formés et terminants par construction, biaisé vers les formes que les passes réécrivent
- (boucles imbriquées,
break/continue,matchgardé, closures relues après redéfinition), avec pour seule propriété run_with_cfg(src, off) == run_with_cfg(src, on)— la référence est la spécification, aucun oracle à écrire. Sa- première campagne a débusqué sept défauts en quelques milliers d'échantillons, tous fermés avec oracle vérifié rouge
- deux hors du moteur CFG (compaction peephole sans réadressage des cibles composites, élision fautive du saut de merge
d'un
if— voir VM), et cinq dedans — recherche de merge qui suivait les back-edges (une boucle nichée dans une branche deifétait reconstruite hors de sa branche, atteinte depuis la branche sœur via la boucle englobante ; la marche est désormais forward-only), boucles naturelles non fusionnées par header, et les trois gardes LICM décrites plus haut (défs conditionnelles, sorties précoces, arms dematchopaques au CFG et au SSA). Une reprise ultérieure (2026-07-05) en a ajouté un huitième : la barrière-call LICM ci-dessus — un candidat invariant hoisté au-dessus d'un appel qui le relit par capture de closure produisait un faux résultat au premier tour ; le diagnostic « GVN/closures » initialement soupçonné était erroné, cinq variantes discriminantes l'ont isolé sur LICM. Deux preuves Coq bornent les invariants touchés :CatnipRegionMergeProof.v(la recherche forward-only retourne le merge du builder quel que soit l'ordre de parcours) etCatnipDestructionBridge.v(le lot de copies séquentialisé d'une arête réalise la sémantique parallèle des phis du join) — voir COQ_PROOFS.
Warning: un graphe que personne ne traverse n'a pas de bugs. Celui-ci en a livré quatorze au total, dont huit à un générateur qui ne sait même pas ce qu'il cherche. Zéro trou connu n'est toujours pas zéro trou.
4. Exécution
Pipeline.prepare() stocke l'IR optimisé. execute_prepared() compile et exécute depuis l'IR stocké (pas de re-parse).
Voir VM pour les détails.
Mode AST (interne, non documenté utilisateur) : parse() convertit l'IR en Op nodes via prepared_ir_to_op().
execute() interprète les Op directement via Registry (exec_stmt()). Accessible via -x ast ou
CATNIP_EXECUTOR=ast. Sert d'oracle indépendant : un test qui passe en AST et échoue en VM isole un bug de compilation
ou de dispatch VM. Code derrière le feature flag ast-executor en Rust.
Mode standalone (--executor standalone) : CatnipStandalone (catnip/compat.py) utilise les mêmes classes PyO3
que le mode DSL (PragmaContext, CatnipRuntime, _ImportWrapper, Memoization). Couvre 100% du langage Catnip. Les
seules différences sont les couches d'adaptation de l'API d'embedding Python (@pass_context, Catnip(context=ctx),
broadcast purity tracking) qui ne concernent pas les scripts .cat.
Concepts Clés
OpCode : Identifiants d'Opérations
Les opérations sont identifiées par l'enum OpCode (Rust), utilisée pour le dispatch rapide et la cohérence entre
parsing, semantic et exécution.
Avantages vs strings :
- Comparaisons O(1) (entiers vs strings)
- Lookups rapides dans dictionnaires
- Consommation mémoire réduite
Convention : Opcodes correspondant à mots-clés Python préfixés OP_ (OP_IF, OP_WHILE)
Scope : Variables O(1)
La gestion des scopes utilise un IndexMap plat en Rust, plutôt qu'une chaîne de scopes parents :
Approche classique (O(n) lookup) :
Scope 3 → Scope 2 → Scope 1 → Global
Recherche d'une variable = remonter la chaîne jusqu'à trouver
Approche Catnip (O(1) lookup) :
- Un seul IndexMap contenant toutes les variables (ordre d'insertion préservé)
- Tracking par frame pour savoir quoi nettoyer au pop
- Shadow stack pour gérer le variable shadowing
Trade-off : lookup O(1), cleanup O(n) où n = variables dans le frame
Closures : la capture est par copie à la création (snapshot des liaisons de fonction — les module globals sont
exclus et résolus vivants à l'appel), les écritures d'un nom capturé persistent dans la capture de la closure sans
remonter au parent, et un global lu depuis une fonction s'écrit dans la liaison vivante (le pendant AST de la règle
outer_names du compilateur VM). Sémantique commune aux deux exécuteurs, gravée dans la grille différentielle de
tests/language/test_closures.py ; la spec utilisateur est dans
SCOPES_AND_VARIABLES.
Références :
- Hash table (Wikipedia)
- Concept inspiré de V8's hidden classes
Les scopes classiques sont une tour d'annuaires empilés. Pour trouver un numéro, on monte étage par étage. Catnip utilise un annuaire unique avec des post-its de couleur pour savoir quel numéro appartient à quel étage. Chercher est instantané, ranger nécessite de lire les post-its.
Registry : Table des Opérations
Le Registry dispatche les opcodes vers leurs implémentations Rust via pattern matching direct (O(1), branch prediction). 12 modules spécialisés (arithmetic, logical, control_flow, functions, patterns, etc.).
Tail Call Optimization (TCO)
Catnip implémente les proper tail calls via un trampoline pattern : tout appel par nom en position terminale est optimisé, pas seulement l'auto-récursion (récursion mutuelle, fonctions imbriquées, appels terminaux vers une autre fonction).
Principe :
- Un appel en position terminale retourne
TailCall(func, args)au lieu d'appeler - La boucle trampoline détecte
TailCall, rebind les paramètres (et swap le scope si la cible change de closure), continue - Un seul frame Python pour toute la chaîne d'appels terminaux, quelle que soit la cible
Avantage : récursion possible sans gonfler la stack (O(1) stack space), y compris sur cycles mutuels f → g → f
Détection : automatique par l'analyseur sémantique (appels en position terminale, traversée de tous les corps de lambdas)
Références :
- Tail call (Wikipedia)
- Proper Tail Calls in Scheme
Lazy Evaluation
Les opérations de contrôle de flux (if, while, for, match, etc.) reçoivent leurs arguments non évalués :
Raison : les blocs doivent être évalués conditionnellement ou plusieurs fois
# if (condition) { then_block } else { else_block }
# → then_block et else_block ne sont PAS évalués immédiatement
# → Seul le bloc choisi sera exécuté
Implémentation : HashSet CONTROL_FLOW_OPS marque les opcodes lazy
Error Handling : Source Locations
Les erreurs runtime capturent la position source complète (fichier, ligne, colonne) avec une call stack claire.
Pipeline de propagation :
Line table : le CodeObject contient un vecteur parallèle aux instructions qui mappe chaque instruction vers son
start_byte. La VM maintient last_src_byte (mis à jour à chaque instruction) et une call stack avec nom de fonction
et position source.
Capture lazy : quand une erreur se produit, la VM utilise last_src_byte (toujours à jour, même si le frame est
dépilé pendant la propagation), snapshote le call stack, puis le bridge Python convertit start_byte en ligne/colonne
et enrichit l'exception avec un extrait. Le call_stack complet (liste de (func_name, line)) est attaché à
l'exception via exc.call_stack pour le traceback.
Suggestions "Did you mean?" : trois niveaux de suggestions automatiques basées sur la distance de
Damerau-Levenshtein (catnip_tools/src/suggest.rs) :
- Variables :
NameErrorcollecte locals + globals et suggère les noms proches - Attributs struct :
AttributeErrorsur fields/methods suggère l'attribut le plus similaire - Attributs Python :
AttributeErrorsur objets Python utilisedir()+ Damerau-Levenshtein pour suggérer ("hello".uper()->upper) - Keywords syntaxe : tokens inconnus sont comparés aux keywords Catnip + aliases cross-langage (
class->struct,switch->match)
Les erreurs sémantiques (unknown opcode, unknown pragma) incluent la position source via start_byte enrichi par
SourceMap dans le pipeline.
Résultat : messages d'erreur avec traceback complet et suggestions :
File '<input>', line 1, column 1: Name 'factoral' is not defined
Did you mean 'factorial'?
1 | factorial = 1; factoral
| ^
Détails : voir VM pour l'architecture.
Module loading : Résolution Statique
Le loader résout les imports par nom avec une liste de recherche fixée au démarrage : caller_dir -> CWD -> CATNIP_PATH. Le code ne peut pas modifier cette liste à l'exécution.
Deux implémentations partagent la même logique de résolution (catnip_core::loader::resolve) :
ImportLoader(catnip_rs/src/loader/) : mode PyO3 (Python + Rust), supporte.cat,.py, extensions C-Python, et les plugins natifs catnip_vm (libcatnip_{name}.so, ex.http) via le pontnative_plugin.rsPureImportLoader(catnip_vm/src/loader.rs) : mode pur Rust (PurePipeline/MCP), supporte.catet.so(plugins natifs). Les modules stdlib sont découverts par scan deCATNIP_STDLIB_PATH, exe dir, outarget/debug/pourlibcatnip_{name}.so. Parité avec le loader Python :protocol=,wild=True, import sélectif avec alias, filtrage__all__etlib.exports.include
Plugins natifs (catnip_vm/src/plugin.rs) : ABI v5 avec handles opaques et frontière de valeur verrouillée. Un
plugin exporte extern "C" catnip_plugin_init(host: *const PluginHostApi) -> *const PluginDescriptor avec magic ABI +
version + callbacks method/getattr/drop/has_member. PluginHostApi fournit les builder callbacks (make_string,
make_bytes, make_list, make_dict, make_bigint) : un plugin construit toute valeur structurée de retour dans le
heap host, jamais dans le sien. Un résultat traverse par exactement un canal -- OBJECT (handle opaque), HOSTVALUE
(valeur host-construite, de confiance), ou aucun flag (alors un scalaire inline, validé par from_raw_scalar) -- si
bien que le host ne déréférence jamais un Arc appartenant au plugin (verrou prouvé, CatnipPluginBoundaryProof). Les
attributs statiques du descriptor suivent le même verrou (v5) : un attr porte PLUGIN_ATTR_HOSTVALUE quand sa valeur
est un pointeur host-construit, sinon il doit être un scalaire validé par from_raw_scalar -- un pointeur brut non
déclaré est rejeté à l'admission au lieu d'être déréférencé. Le PluginRegistry (partage entre host et loader via
Rc<RefCell<>>) valide l'ABI, enregistre les fonctions avec noms qualifiés (__plugin::module::fn), et wrappe chaque
appel dans catch_unwind. Les objets plugin (PluginObject) sont des handles u64 opaques dont les méthodes et
attributs sont dispatchés à travers les callbacks du plugin. Arc<Library> dans les callbacks prévient le dlclose tant
que des objets vivent. Tous les modules stdlib (io, sys, http) sont des plugins natifs -- aucun code stdlib dans
catnip_vm
Le même PluginRegistry est désormais aussi monté sur l'ImportLoader PyO3. Le pont native_plugin.rs (catnip_rs)
marshale catnip_vm::Value \<-> PyObject et route getattr/method via les opcodes (OpCode::GetAttr /
OpCode::CallMethod), si bien qu'un plugin PureVM-only comme http (pyo3 = false) se charge depuis n'importe quel
exécuteur, plus seulement le PurePipeline/MCP. En VM, attribut et méthode sont distingués syntaxiquement (GetAttr vs
CallMethod) ; l'exécuteur AST abaisse obj.m(...) en getattr-puis-call et ne peut donc pas trancher au getattr, alors
le pont consulte le probe has_member du plugin : il ne lie une méthode que si le membre existe, sinon AttributeError
-- ce qui aligne accès attribut et hasattr sur la VM. Les stdlib Rust sont cached sous une clé rs::<name> pour ne
pas entrer en collision avec leur homonyme Python (protocol="py").
Choix de design : pas de sys.path
Catnip n'a délibérément pas d'équivalent mutable de sys.path. Un search path mutable est un état global implicite qui
crée des dépendances d'ordre entre modules (A modifie le path, B en dépend sans le savoir). Ça rend le résultat d'un
import('x') non-déterministe par rapport à l'ordre d'exécution - exactement le genre de couplage qu'on veut éviter.
La résolution statique garantit que import('x') produit toujours le même résultat pour un (fichier, environnement)
donné, sans dépendre de l'historique d'exécution. CATNIP_PATH couvre le cas d'usage "ajouter des répertoires" sans
mutation runtime.
Protocoles et packages : le préfixe py:/cat:/rs: force un backend ; lib.toml dans un répertoire le déclare
comme package avec entry point et exports filtrés.
Détails : voir MODULE_LOADING.
Delta dataflow : le noyau différentiel (en construction)
Le module catnip_core/src/delta/ pose le socle d'un moteur differential dataflow (McSherry et al.) pour les flux :
une Collection<V> est un multiset versionné (état = somme des diffs signées par valeur, présence = multiplicité > 0),
un Delta<V> est une transition compactée (une entrée par valeur, zéro éliminé, ordre de première apparition stable).
Les opérateurs sont two-phase : compute exécute les callbacks sans muter le nœud et retourne le delta de sortie plus
les écritures d'état différées (Staged), commit applique sans pouvoir échouer — une exception Catnip remonte au site
du push avec le graphe observationnellement intact. Trois opérateurs stateless sont livrés (map, filter,
concat), génériques sur la valeur (aucune dépendance VM : le pont est le trait DeltaHost). Les invariants
algébriques sont prouvés en Coq (proof/delta/, voir COQ_PROOFS). Le module est inerte : aucun
chemin d'appel depuis le pipeline — le graphe, les opérateurs stateful et le pont VM arrivent par étapes (roadmap
privée).
Un moteur qui ne recalcule que ce qui a changé, livré en pièces qui ne changent encore rien.
Debugger
Le debugger connecte la VM Rust à un frontend (console Rust ou MCP Python) via des channels std::sync::mpsc.
Architecture
Entrypoints dans la VM
Le breakpoint opcode est injecté par l'analyseur sémantique quand il rencontre un appel breakpoint(). La VM intercepte
aussi les instructions dont le start_byte correspond à un breakpoint utilisateur (ajouté via
add_debug_breakpoint(offset)).
Au point de pause, la VM snapshote l'état : variables locales (slotmap complet, y compris nil), call stack, et position
source. Le DebugCallback Rust construit un PauseEvent, l'envoie via event_tx, puis libère le GIL pendant
command_rx.recv_timeout(60s) (auto-continue après 5 min).
Composants : logique pure dans catnip_tools, channels et GIL dans catnip_rs/debug, wrapper Python dans
catnip/debug, 6 tools MCP dans catnip_mcp/ (Rust).
Step modes
| Action | Comportement |
|---|---|
CONTINUE |
Reprend jusqu'au prochain breakpoint |
STEP_INTO |
Pause à la prochaine instruction |
STEP_OVER |
Pause à la prochaine instruction de même profondeur |
STEP_OUT |
Pause au retour du frame courant |
Le debugger observe la VM sans la modifier. Ce qui est pratique, parce qu'un debugger qui modifie l'exécution du programme qu'il débogue serait un programme qui s'observe en train de ne pas être lui-même.
Vérification Formelle
Les propriétés structurelles du langage sont prouvées en Coq. Chaque fichier modélise un composant Rust et prouve ses invariants.
| Axe | Couverture |
|---|---|
| Syntaxe | Grammaire CF, précédence (13 niveaux), monotonie fuel |
| Sémantique | Broadcasting (foncteur, confluence), ND-récursion |
| Runtime | IR opcodes, scopes, patterns, fonctions/TCO, NaN-boxing, VM stack safety, frames/IP/jumps, C3 MRO, structs/traits/enums, desugaring opérateurs |
| Optimisations | 5 passes IR vivantes (strength reduction, blunt code, DCE, block flattening, constant folding) + gardes sur règles retirées |
| Analyses | Liveness/DSE, dominance CFG, SSA complet (49 lemmes), cache |
Preuves paramétriques, compilent avec make proof. Détails : COQ_PROOFS.
Un programme prouvé correct n'a pas de bugs. Il a des hypothèses.
Où Trouver le Code
| Dossier | Contenu |
|---|---|
catnip/ |
API Python, intégration |
catnip_core/ |
Cœur Rust pur (types, IR, VM opcodes, JIT, parser, CFG, delta dataflow, freeze, constants, symbols) |
catnip_vm/ |
VM pure Rust sans PyO3 (Value NaN-boxed, collections, structs/traits/enums, PureHost, PureCompiler) |
catnip_rs/ |
Bindings PyO3 + runtime (parser, VM, PyIRNode) |
catnip_libs/ |
Standard library (specs TOML + implémentations Rust par module) |
catnip_grammar/ |
Grammaire Tree-sitter |
catnip_tools/ |
Outils Rust (formatter, linter + CFG deep analysis, debugger) |
catnip_lsp/ |
Serveur LSP Rust (diagnostics, formatting, rename) |
catnip_mcp/ |
Serveur MCP pur Rust (rmcp, stdio, 10 tools, 4 resource templates) |
proof/ |
Preuves Coq |
Workflow de Développement
# Après modification Rust
uv pip install -e .
# Tests rapides Rust (~5s)
make test-rust-fast
# Tests complets Python (~25s)
make test
# Après modification grammar.js
make grammar-deps