Architecture Caméléon CVM — Opérateurs & Moteur
Version : v0.21.6-dev Auteurs : O. Cugnon de Sévricourt, C. Cugnon de Sévricourt
1. Pourquoi Caméléon ?
Le problème : orchestration IA = graphe complexe
Les pipelines IA modernes sont des graphes dirigés avec branchements, boucles, routage conditionnel et appels LLM coûteux. Trois exigences émergent que les outils actuels ne couvrent pas formellement :
- Réversibilité — pouvoir revenir en arrière pas à pas pour déboguer, comprendre, et explorer des variantes
- Reproductibilité — même graphe, mêmes données, même résultat, toujours
- Vérifiabilité — pouvoir prouver formellement qu'un pipeline termine, qu'il n'a pas de deadlock, qu'il est borné
Les 5 principes fondateurs
- Formalisme Petri — vérifiabilité mathématique (vivacité, bornitude, accessibilité). Le moteur est un interpréteur de compositions basé sur un modèle formel de réseau de Petri étendu, formalisé dans un article peer-reviewed (arXiv:1110.4802).
- Bidirectionalité — forward et backward natifs. Pas d'exécution one-shot : le rollback est garanti à chaque step via l'historique déterministe.
- Séparation données / flux — opérateurs structurels (flux pur, copie de références) vs behavioral (traitement de données, interaction, injection). Le moteur ne connaît que
canFire(),run(),rollback(). - Auto-description — un opérateur porte sa règle de tir, son exécution et son rollback. Pas de dispatch centralisé dans le moteur — Template Method sur l'opérateur.
- Bijection prompt → pipeline — un LLM peut générer un fichier
.cmvalide en connaissant juste le catalogue d'opérateurs. Le.cmest pur topologie.
Positionnement vs outils existants
| Critère | LangChain / LangGraph | CrewAI | Caméléon CVM |
|---|---|---|---|
| Modèle d'exécution | Event-driven / DAG | Agent-based | Petri net formel |
| Vérification formelle | Non | Non | Oui (vivacité, bornitude) |
| Réversibilité | Non | Non | Oui (backward natif) |
| Séparation flux / données | Partielle | Non | Oui (2 familles) |
| Reproductibilité | Manuelle | Non | Oui (historique déterministe) |
| Extensibilité | Plugin custom | Agent custom | Objet dans lib (canFire/run/rollback) |
| Standard patterns | Aucun | Aucun | WCP van der Aalst |
Filiation : Petri 1962 (places + transitions) → Jensen 1992 (tokens typés = CPN) → van der Aalst 1999 (Workflow Patterns en CPN) → Caméléon (connexion = place, opérateur = transition, données typées = coloured tokens)
2. Architecture Caméléon — les 4 couches
Le découplage fondamental
L'architecture repose sur 4 couches indépendantes, chacune testable et utilisable séparément. Aucune couche ne dépend d'une couche au-dessus d'elle :
- Renderer (
cameleon-cvm-poc-x.y.html) — vues Visual / Code / Split. Lit la lib et le.cm, aucune logique métier. Responsable du SVG, drag & drop, annotations. - CVM Engine (
cvm.js) — orchestre forward / backward / run. Appelleop.canFire(),op.run(),op.rollback(). Ne connaît aucune logique opérateur. - Composition (
.cm) — topologie pure : instances d'opérateurs + connexions + positions. Pas de déclaration de ports (ils viennent de la lib). Format YAML (actuel), futur JS. - Operators Lib (
operators-lib.js) — catalogue auto-descriptif. Chaque opérateur portecanFire(),run(),rollback()et ses déclarations de ports.
Format .cm (composition)
Le fichier .cm ne porte que la topologie — instances, connexions et layout visuel :
# pipeline.cm — topologie pure
operators:
- id: pdf_loader
label: PDF Loader
instance: 1
x: 90
y: 150
connections:
- from: pdf_loader.out_pdf
to: binary_to_string.in_binary
Évolution : dans la version cible avec
operators-lib.js, le.cmne portera plus les inputs/outputs (ils viennent de la lib). Il ne restera quetype,instance,x,yet les connexions.
Flux de données : du .cm à l'exécution
Extensibilité : ajouter un opérateur
Ajouter un opérateur = ajouter un seul objet dans operators-lib.js. Rien d'autre à modifier. Le moteur le découvre via canFire(), le renderer l'affiche via les déclarations de ports de la lib.
Extensibilité renderer
Ajouter un viewer ou un éditeur = se brancher sur l'état CVM. Le renderer lit l'état du moteur, s'abonne aux changements, et rend. Les 3 vues existantes (Visual, Code, Split) sont des exemples. De futures vues (timeline, debug, profiling) pourront être ajoutées indépendamment.
⚠️ Note : le POC v0.12 intègre encore les 4 couches dans un fichier unique (
cameleon-cvm-poc-0.12.html). L'extraction en fichiers séparés est prévue à partir de la v0.13.
3. Architecture CVM — le moteur
La CVM (Caméléon Virtual Machine) est le moteur d'exécution. Elle implémente un interpréteur de compositions basé sur un modèle formel de réseau de Petri : connexions = places, opérateurs = transitions, données = coloured tokens.
Règle de tir par défaut
La règle standard d'exécution d'un opérateur dans Caméléon est ANY + garde EMPTY :
- Un opérateur tire dès qu'au moins une entrée est
NEW - À condition qu'aucune entrée ne soit
EMPTY
Conséquence : au premier passage, l'opérateur attend que chaque entrée ait reçu au moins une donnée. Ensuite il devient réactif — il tire dès qu'un input est rafraîchi, même si les autres sont OLD.
Historique : ce comportement réactif était déjà celui de l'implémentation originale de Caméléon en C++/QT. L'opérateur Sync a été créé précisément pour imposer une barrière
ALLquand nécessaire.
Les trois firing policies
Chaque opérateur porte une propriété firingPolicy qui détermine sa condition de tir. Si omise, le défaut est ANY.
| Policy | Condition de tir | Cas d'usage |
|---|---|---|
ANY | Au moins 1 entrée NEW, aucune EMPTY | Défaut — Processor, Fork, Sink, Accumulator, Join/Merge |
ALL | Toutes les entrées sont NEW | Sync (barrière de synchronisation explicite) |
COND | Sous-ensemble spécifique NEW (délégué à l'opérateur) | Switch, Gate |
⚠️ Source a une règle spéciale : il tire si toutes ses sorties sont
EMPTY(producteur initial de données).
Deux familles d'opérateurs
Les opérateurs Caméléon se répartissent en deux familles :
- Structurels — contrôle de flux, pas de transformation de données (copie de références)
- Behavioral — traitement de données, interaction humaine, injection live (
run()produit de nouvelles données ou déclenche une interaction)
Opérateurs structurels
Les structurels contrôlent le flux sans transformer les données. Leur run() copie des références, pas de calcul.
| Opérateur | Ports | Policy | Rôle |
|---|---|---|---|
| Sync | N → N | ALL | Barrière — ne tire que si toutes les entrées sont NEW simultanément. Copie les références en sortie. |
| Fork | 1 → N | ANY | Duplique une donnée vers N sorties |
| Join / Merge | N → 1 | ANY | Fusionne N branches — pièce maîtresse des boucles |
| Switch | 1 → N | COND | Routage conditionnel — active une seule sortie |
| Gate | 1 → 1 | COND | Laisse passer ou bloque selon un signal de contrôle |
Opérateurs behavioral
Les behavioral effectuent un traitement de données, une interaction humaine ou une injection live. Leur run() produit de nouvelles données en sortie ou déclenche une interaction.
| Opérateur | Ports | Policy | Rôle |
|---|---|---|---|
| Source | 0 → N | Spécial | Producteur initial — tire si toutes ses sorties sont EMPTY |
| Sink | N → 0 | ANY | Consomme / termine un flux |
| Processor | N → M | ANY | Transformation générique |
| Accumulator | N → M | ANY | Traitement par lot (batch) |
canFire() — méthode sur l'opérateur
La règle de tir est une méthode portée par l'opérateur (Template Method), pas un dispatch centralisé dans le moteur. Le moteur appelle op.canFire(), point.
| Opérateur | canFire() | Logique |
|---|---|---|
| Défaut (ANY) | !EMPTY && ≥1 NEW | Hérité par Processor, Fork, Merge, Sink, Accumulator |
| Sync (ALL) | all NEW | Barrière de synchronisation |
| Switch (COND) | in_data NEW | Délégué à l'opérateur |
| Source | all outputs EMPTY | Producteur initial — règle inversée |
// Défaut (ANY + garde EMPTY) — hérité par Processor, Fork, Merge, Sink, Accumulator
const Operator = {
canFire(inputStates, outputStates) {
if (inputStates.some(s => s === "EMPTY")) return false;
return inputStates.some(s => s === "NEW");
}
};
// Overrides structurels
Sync.canFire = (in) => in.every(s => s === "NEW"); // ALL
Switch.canFire = (in) => in.in_data === "NEW"; // COND custom
Source.canFire = (in, out) => out.every(s => s === "EMPTY"); // sorties EMPTY
POC v0.12 : le moteur actuel utilise un
canFire()centralisé dansfireable(). La migration vers Template Method est prévue pour l'extractioncvm.js(v0.14+).
Boucles
Les connexions peuvent revenir à NEW après OLD (cycle dans le graphe). Le Merge (ANY) est la pièce maîtresse : il réinjecte un token dans la boucle.
- Merge (ANY, défaut) réinjecte un token dans la boucle à chaque itération
- Guard/Break (COND) sort de la boucle quand une condition est remplie
- Sécurité :
max_loop_iterationsdans la config pour détecter les boucles infinies
Modes d'exécution
| Mode | Comportement | Historique |
|---|---|---|
| Séquentiel (actuel) | Un seul opérateur tire par step | history[i] = un tir |
| Concurrent (futur) | Tous les READY tirent simultanément | history[i] = groupe de tirs |
| Configuration : `execution.mode: sequential | concurrent dans le .cm`. |
|---|
Signature type dans operators-lib
Chaque opérateur est un objet auto-descriptif dans operators-lib.js. Convention : si firingPolicy est omis, le défaut ANY s'applique.
// Behavioral (Processor) — firingPolicy omis = ANY
{
id: "processor_example",
label: "Mon Processor",
family: "behavioral",
inputs: [{ id: "in_a", type: "DataString" }],
outputs: [{ id: "out_a", type: "DataString" }],
run(inputs) {
return { out_a: transform(inputs.in_a) };
},
rollback(outputs) { /* inverse */ }
}
// Structurel (Sync) — firingPolicy explicitement ALL
{
id: "sync",
label: "Sync",
family: "structural",
firingPolicy: "ALL",
inputs: [{ id: "in_a", type: "Any" }, { id: "in_b", type: "Any" }],
outputs: [{ id: "out_a", type: "Any" }, { id: "out_b", type: "Any" }],
run(inputs) {
return { out_a: inputs.in_a, out_b: inputs.in_b }; // copie de ref
},
rollback(outputs) { /* inverse */ }
}
// Source — règle spéciale (tire si sorties EMPTY)
{
id: "pdf_loader",
label: "PDF Loader",
family: "behavioral",
inputs: [],
outputs: [{ id: "out_pdf", type: "Binary" }],
canFire(inputStates, outputStates) {
return outputStates.every(s => s === "EMPTY");
},
run(inputs) {
return { out_pdf: /* binary data */ };
},
rollback(outputs) { /* inverse */ }
}
Convention :
familyvaut"structural"ou"behavioral". Le moteur utilise cette information pour le futur mode concurrent (structurels = copie de ref instantanée, behavioral = exécution asynchrone possible).
Pseudo-code moteur CVM
Le moteur est agnostique — il ne connaît aucun opérateur spécifique. Trois méthodes suffisent :
// 1. Liste des opérateurs READY
function fireable() {
return OPS.filter(op =>
op.canFire(inputStates(op), outputStates(op))
);
}
// 2. Exécution forward
function execFire(op) {
const inputs = collectInputs(op);
const result = op.run(inputs);
// Mettre à jour les états des connexions
incomingConns(op).forEach(c => c.state = "OLD");
outgoingConns(op).forEach(c => {
c.state = "NEW";
c.data = result[c.plugFrom];
});
pushHistory(op);
}
// 3. Exécution backward (rollback)
function execBackward() {
const prev = popHistory();
prev.op.rollback(prev.outputs);
restoreState(prev.snapshot);
}
// Boucle principale (mode séquentiel)
function forward() {
const ready = fireable();
if (ready.length === 0) return detectEndState();
execFire(ready[0]); // séquentiel = premier READY
}
// Détection état terminal
function detectEndState() {
const hasNew = CONNS.some(c => c.state === "NEW");
const hasEmpty = CONNS.some(c => c.state === "EMPTY");
if (!hasNew && !hasEmpty) return "COMPLETED";
return "DEADLOCK";
}
3 méthodes suffisent :
fireable()+execFire()+execBackward(). Le moteur est agnostique — toute la logique spécifique est dans les opérateurs de la lib.
Workflow Patterns (WCP)
Référence : workflowpatterns.com — 43 patterns control-flow formalisés en Coloured Petri Nets par van der Aalst et al.
Correspondance directe (basiques WCP 1–5)
| WCP | Pattern | Caméléon |
|---|---|---|
| WCP-1 | Sequence | Implicite (connexion) |
| WCP-2 | Parallel Split | Fork (1→N) |
| WCP-3 | Synchronization | Sync (ALL) |
| WCP-4 | Exclusive Choice | Switch (COND) |
| WCP-5 | Simple Merge | Merge (ANY) |
Composables avec nos primitives
| WCP | Pattern | Réalisation Caméléon |
|---|---|---|
| WCP-6 | Multi-Choice | Switch non-exclusif (multi-sortie) |
| WCP-8 | Multi-Merge | Merge ANY (déjà couvert) |
| WCP-10 | Arbitrary Cycles | Merge + Guard |
| WCP-11 | Implicit Termination | Détection Completed actuelle |
| WCP-21 | Structured Loop | Merge + Guard (while/repeat) |
Extensions futures prioritaires (pour IA)
| WCP | Pattern | Extension prévue |
|---|---|---|
| WCP-9 | Discriminator | Policy FIRST (premier LLM qui répond gagne) |
| WCP-12–15 | Multiple Instances | MapReduce (fork dynamique + sync) |
| WCP-19/20/25 | Cancel | Signal CANCEL propagé sur sous-graphe (timeout LLM) |
Stratégie : couvrir les 43 WCP en 3 cercles — directs (5 patterns = 5 primitives), composables (combinaison de primitives), extensions (nouvelles policies ou mécanismes). Référence : van der Aalst et al., Workflow Patterns: The Definitive Guide, MIT Press 2016.
4. Architecture Données
Types de données
Caméléon utilise un système de types simples pour les données transitant sur les connexions. Chaque type a une couleur associée pour le rendu visuel.
| Type | Couleur | Description |
|---|---|---|
Binary | #a0b0c8 | Données binaires brutes (PDF, images, audio) |
DataString | #4f8aff | Chaîne de caractères (texte, prompts, réponses LLM) |
DataJSON | #a78bfa | Structure JSON (metadata, config, réponse structurée) |
DataBool | #f4845f | Booléen (signal de contrôle, résultat de validation) |
DataChunk | #00e5a0 | Fragment de texte extrait (chunk RAG, embedding) |
// Registre TYPES du POC
const TYPES = {
Binary: { color: "#a0b0c8", label: "Binary" },
DataString: { color: "#4f8aff", label: "String" },
DataJSON: { color: "#a78bfa", label: "JSON" },
DataBool: { color: "#f4845f", label: "Bool" },
DataChunk: { color: "#00e5a0", label: "Chunk" },
};
Typage des connexions
Le type d'une connexion est déterminé par le plug source (output). Le moteur valide la compatibilité entre le type du plug source et le type du plug cible (input).
| Règle | Source | Cible | Résultat |
|---|---|---|---|
| Identique | DataString | DataString | ✓ Valide |
| Any accepté | DataString | Any | ✓ Valide (Any accepte tout) |
| Any source | Any | DataJSON | ✓ Valide (vérifié au runtime) |
| Incompatible | Binary | DataBool | ✗ Rejeté |
| Coërcion (futur) | DataJSON | DataString | ≈ Via convertisseur implicite |
Sérialisation des données
Les données transitent sur les connexions sous forme de tokens typés. Le moteur ne regarde jamais le contenu — seuls les opérateurs interprètent les données.
// Transit données sur une connexion
connexion.state = "NEW";
connexion.data = {
type: "DataString",
value: "Résultat du LLM...",
meta: { timestamp: Date.now(), source: "gpt4" }
};
Extensibilité : ajouter un type
// Ajouter un type = une ligne dans le registre
TYPES.DataImage = { color: "#e879f9", label: "Image" };
Référence : les Data Patterns (Russell et al., 2005) complètent les Workflow Patterns en formalisant 40 patterns de visibilité, interaction et transfert de données. L'architecture Caméléon est conçue pour couvrir ces patterns à terme, en complément des WCP control-flow déjà couverts.
5. Questions ouvertes
Système de types
Coërcion vs strict Faut-il un mode strict (rejet immédiat si types différents) et un mode coërcion (conversion implicite via registre de convertisseurs) ? Ou un seul mode avec opt-in explicite ?
Type Any Any est actuellement accepté partout. Faut-il restreindre son usage aux structurels (Sync, Fork, Merge) et l'interdire dans les behavioral pour forcer le typage explicite ?
Types composites Faut-il des types paramétrés comme List<DataString> ou Map<String, DataJSON> ? Quand ? L'Accumulator en aurait besoin pour le batch.
Exécution
Data Patterns (Russell et al.) Les 40 Data Patterns couvrent la visibilité, l'interaction et le transfert de données. Comment les intégrer au modèle Caméléon sans compliquer l'API opérateur ?
Persistance Le history[] est en mémoire. Faut-il une persistance disque pour les pipelines longs (RAG sur corpus massif) ? Quel format de sérialisation pour le state snapshot ?
Propagation des erreurs Un run() qui échoue doit-il propager un token d'erreur typé (DataError) ou arrêter le pipeline ? Faut-il un mécanisme de retry intégré au moteur ou dans un opérateur dédié ?
Topologie et sécurité
Topologie dynamique Un opérateur peut-il créer des connexions à la volée (spawn de sous-graphe) ? C'est nécessaire pour WCP-12–15 (Multiple Instances / MapReduce). Comment cela affecte-t-il la vérification formelle ?
Sandboxing run() Les opérateurs run() exécutent du code arbitraire. Faut-il un sandboxing (Web Worker, VM isolée, Deno permissions) pour protéger le moteur ? Quel impact sur la performance ?
Versioning
Versions opérateurs dans .cm existants Si un opérateur change de signature (ajout d'un port, changement de type), comment gérer les .cm existants ? Migration automatique, version pinning, ou rejet avec message explicite ?
Ces questions sont ouvertes — chacune fera l'objet d'un ADR dédié quand une décision sera prise.