Caméléon orchestre des pipelines IA complexes. L'implémentation originale C++/Qt (2012) reposait déjà sur un modèle formel de réseau de Petri étendu, formalisé dans un article peer-reviewed (arxiv.org/abs/1110.4802). Il faut décider si ce modèle reste le fondement de la v2 ou s'il est remplacé par un modèle event-driven plus courant.
Le moteur CVM est un interpréteur de compositions basé sur un modèle formel de réseau de Petri étendu. Le terme "Virtual Machine" est conservé comme nom de marque. Le choix du modèle Petri repose sur trois critères décisifs :
Dans un réseau de Petri classique, les places portent les tokens. Pour mapper ce modèle sur Caméléon, deux options existent : place = opérateur (les opérateurs portent l'état), ou place = connexion entre plugs (les connexions portent l'état).
Une connexion (lien plug output → plug input) est une place. L'état NEW/OLD/EMPTY porte sur les connexions. Ce choix repose sur deux critères :
| Petri net | Caméléon |
|---|---|
| Place | Connexion (plug out → plug in) |
| Transition | Opérateur |
| Token | Donnée en transit |
| Arc place→transition | Plug input |
| Arc transition→place | Plug output |
.cmUn opérateur peut avoir plusieurs entrées. Il faut définir la condition minimale pour qu'il tire. Deux approches : attendre que toutes les entrées soient NEW (ALL), ou tirer dès qu'au moins une est NEW (ANY). Ce comportement doit aussi gérer le cas du premier passage où certaines entrées n'ont encore jamais reçu de donnée.
Un opérateur tire dès qu'au moins une entrée est NEW, à condition qu'aucune entrée ne soit EMPTY. C'est le comportement de l'implémentation C++/Qt originale, confirmé comme correct. La garde EMPTY est le mécanisme clé : elle bloque tout tir tant que toutes les entrées n'ont pas reçu au moins une donnée une première fois.
canFire(op):
si Source : return toutes sorties EMPTY
si une entrée est EMPTY : return false // garde universelle
return au moins une entrée est NEW // ANY défaut
EMPTY). Comportement de barrière implicite.NEW), même si les autres sont OLD. Comportement de streaming naturel.EMPTYLa règle ANY (ADR-003) est suffisante pour la majorité des opérateurs. Mais certains cas nécessitent une synchronisation stricte (barrière) ou un routage conditionnel. Il faut un mécanisme extensible de policies de tir sans alourdir le moteur.
Trois policies couvrent l'ensemble des cas d'usage. La garde EMPTY reste universelle pour toutes. Le choix de trois policies — et pas plus — est délibéré : elles sont composables pour couvrir les 43 Workflow Patterns (voir ADR-012).
| Policy | Condition de tir | Cas d'usage |
|---|---|---|
| ANY | ≥ 1 entrée NEW, aucune EMPTY | Défaut — Processor, Fork, Sink, Merge |
| ALL | Toutes entrées NEW | Sync — barrière de synchronisation explicite |
| COND | Délégué à op.condition(états) | Switch, Gate — routage conditionnel |
operators-lib.js — le .cm n'y touche pasFIRST est prévue pour WCP-9 (Discriminator — premier LLM qui répond)Pour les opérateurs Switch et Gate, la condition de tir (policy COND) peut inspecter soit les états (NEW/OLD/EMPTY) soit les valeurs des données transportées. Ce choix détermine si le moteur est purement structurel ou s'il doit transporter et inspecter les données.
op.condition(inputStates) reçoit uniquement les états, jamais les valeurs. La séparation est stricte : condition décide si l'opérateur tire, run() décide quoi produire.
// condition — moteur, états uniquement
condition(inputStates) {
// inputStates = { in_a: "NEW", ctrl: "NEW" }
return inputStates.ctrl === "NEW";
}
// run — opérateur, accès aux valeurs
run(inputs) {
return inputs.signal > 0
? { out_true: inputs.data }
: { out_false: inputs.data };
}
condition = topologie du flux, run = logique métierDans une composition, plusieurs opérateurs peuvent être READY simultanément. Il faut décider si le moteur les exécute un par un (séquentiel) ou tous en parallèle (concurrent). Ce choix impacte le déterminisme, le rendu visuel et la complexité du rollback.
Deux modes sont définis, configurables dans le .cm. Le mode séquentiel est le défaut et le seul implémenté aujourd'hui.
| Mode | Comportement | history[i] | Statut |
|---|---|---|---|
| Séquentiel | Un seul opérateur tire par step | Un tir | Actuel |
| Concurrent | Tous les READY tirent simultanément | Groupe de tirs | Futur |
execution:
mode: sequential | concurrent # config .cm
execBackward() capable de gérer un groupe de tirs avec un ordre d'annulation déterministe garanti — point de vigilance architecturalL'implémentation C++/Qt utilisait le terme "generics" pour les opérateurs de contrôle de flux. Ce terme n'est pas évocateur. Il faut une taxonomie claire qui distingue les opérateurs qui contrôlent le flux de ceux qui ont un comportement (transformation, interaction, injection), afin de guider la conception du catalogue et le rendu visuel.
Deux familles exclusives. Le critère de classification est simple : l'opérateur modifie-t-il les données ou seulement leur flux ?
Structurels — contrôle de flux, pas de transformation de données
| Opérateur | Ports | Policy | Rôle |
|---|---|---|---|
| Sync | N→N | ALL | Barrière — attend toutes les entrées simultanément. Copie les références. |
| 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 exclusif |
| Gate | 1→1 | COND | Laisse passer ou bloque selon signal de contrôle |
Behavioral — traitement de données, interaction, injection
| Opérateur | Ports | Policy | Rôle |
|---|---|---|---|
| Source | 0→N | Spécial | Produit données initiales (tire si sorties 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) |
| - La famille est déclarée via `family: "structural" | "behavioral"` |
|---|---|
| - Le renderer visuel peut différencier les familles par style (forme, couleur de fond) | |
- Les structurels n'ont pas de logique métier dans run() — ils copient ou routent des références | |
| - Merge (ANY) est la pièce maîtresse des boucles — il réinjecte un token dans le cycle |
Les Workflow Patterns (WCP) de van der Aalst et al. constituent la référence académique pour les langages d'orchestration. Établir la correspondance entre Caméléon et les WCP valide la couverture théorique des primitives et fournit une roadmap d'extensibilité rigoureuse.
Les primitives actuelles (ADR-004, ADR-005) couvrent directement les WCP 1–5 et sont composables pour les WCP 6–21. Cette correspondance est le critère de complétude du catalogue d'opérateurs.
| 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) |
| WCP-6/8/10/11/21 | Multi-Choice, Cycles, Termination… | Composables avec les primitives |
FIRST (premier LLM qui répond gagne)CANCEL propagé (timeout LLM)ADR-008 décrit la séparation en trois couches et esquisse la signature d'un opérateur. Il faut maintenant spécifier formellement le contrat complet — champs, types, règles d'interface run / rollback — que tout opérateur de operators-lib.js doit respecter.
Les viewers et éditeurs associés aux plugs sont hors périmètre de cet ADR (voir ADR-016).
Un opérateur dans operators-lib.js est un objet JS plain (pas une classe instanciée). Il est exporté en named export. Le moteur cvm.js consomme ces descripteurs sans les instancier.
Champs obligatoires
| Champ | Type | Description | |
|---|---|---|---|
id | string | Identifiant unique dans la lib (snake_case) | |
label | string | Nom affiché dans l'interface | |
family | "structural" \ | "transform" | Famille de l'opérateur (ADR-005) |
inputs | Plug[] | Liste des plugs d'entrée — tableau vide pour les sources | |
outputs | Plug[] | Liste des plugs de sortie — tableau vide pour les sinks | |
run | Function | Logique d'exécution (voir contrat ci-dessous) |
Champs optionnels
| Champ | Type | Défaut | Description | ||
|---|---|---|---|---|---|
firingPolicy | "ANY" \ | "ALL" \ | "COND" | "ANY" | Politique de tir (ADR-004) |
canFire | Function | absent | Requis si firingPolicy === "COND" — voir contrat ci-dessous | ||
rollback | Function | absent | Annulation formelle — voir contrat ci-dessous | ||
category | string | "" | Chemin dans l'arborescence du catalogue — convention "Domain / Subcategory" (ex : "AI / LLM", "Web / Connectors", "MCP") | ||
description | string | "" | Ligne de description pour le catalogue | ||
interaction | "human" \ | "live" | absent | Mode d'interaction. "human" : bloque le flux, attend validation unique (ADR-034). "live" : re-fire sur chaque événement utilisateur, ouvre la composition (ADR-035). | |
control | string | absent | Id du control UI à utiliser (ADR-034). Le renderer résout via CONTROL_MAP. |
Descripteur de plug
| Champ | Type | Description |
|---|---|---|
id | string | Identifiant court, unique dans l'opérateur (snake_case) |
type | string | Type de données : DataString, DataJSON, Binary, DataBool… |
label | string | Libellé affiché — optionnel, défaut = id |
Contrat run(inputs)
inputs : dict { plug_id: value } — uniquement les plugs connectés dont l'état est NEW{ plug_id: value } pour chaque plug de sortie déclaréPromise — le moteur attend la résolution avant de transitionner les connexionsoutputs (le moteur gère la qualification à l'exécution)Contrat canFire(states)
firingPolicy === "COND"states : dict { plug_id: "NEW" | "OLD" | "EMPTY" } — états des connexions entrantes (ADR-006)boolean — true = l'opérateur peut tirerfiringPolicy !== "COND" : ignoréContrat rollback(outputs)
run() a retourné lors de l'exécution à annulerPromise — le moteur attend avant de continuer le rewindNEW sans annuler les effets de bordExemple
// operators-lib.js
export const Converter = {
id: "converter",
label: "Converter",
family: "transform",
category: "Data / Conversion",
description: "Convertit un binaire en chaîne UTF-8",
inputs: [{ id: "in_binary", type: "Binary", label: "binary" }],
outputs: [{ id: "out_string", type: "DataString", label: "string" }],
run(inputs) {
return { out_string: inputs.in_binary.toString("utf-8") };
},
rollback(outputs) {
// Pas d'effet de bord à annuler pour une conversion pure
}
}
// Opérateur structurel COND — Gate : laisse passer si le signal de contrôle est NEW
export const Gate = {
id: "gate",
label: "Gate",
family: "structural",
category: "Control Flow",
firingPolicy: "COND",
inputs: [
{ id: "in_data", type: "DataString", label: "data" },
{ id: "in_control", type: "DataBool", label: "control signal" },
],
outputs: [{ id: "out_data", type: "DataString", label: "data" }],
canFire(states) {
// Tire uniquement si les deux entrées sont NEW (données + signal de contrôle)
return states.in_data === "NEW" && states.in_control === "NEW";
},
run(inputs) {
// Le routage effectif (passer ou bloquer) se fait dans run()
return inputs.in_control ? { out_data: inputs.in_data } : {};
},
rollback(outputs) {
// Opérateur structurel pur — pas d'effet de bord à annuler
}
}
op.run({ in_binary: buf }) sans le moteur ni l'UIcategory structure l'arborescence du catalogue dans l'UI — le split sur / donne niveau 1 (domaine) et niveau 2 (sous-catégorie) ; un opérateur sans category apparaît dans "Other"instance_alias.plug_id (convention .cm.js, ADR-014)firingPolicy === "COND", le moteur délègue la décision de tir à op.canFire(states) — la logique de condition est encapsulée dans l'opérateur, pas dans le moteurrun() → Promise) sont supportés sans changement de contrat — le moteur awaitrollback() absent = rewind permis mais sans annulation des effets de bord (opérateurs idempotents seulement)interaction ("human" / "live") et control ajoutés comme champs optionnels du descripteurADR-019 définit le contrat d'un opérateur. Les inputs d'un opérateur reçoivent des valeurs via des connexions (données en transit, état NEW). Mais deux besoins distincts émergent :
temperature = 0.7 sur un opérateur LLM).La version initiale de cet ADR confondait les deux en mettant params sur les plugs. L'amendement sépare proprement les deux mécanismes.
| Mécanisme | Niveau | Connectable | UI | Exemple |
|---|---|---|---|---|
| Configuration opérateur | Opérateur | Non | Menu sur l'opérateur → éditeur de config | API key, model, valeur initiale, file path |
| Valeur par défaut de plug | Plug d'entrée | Oui (override par connexion) | Vue (panneau droit) | temperature: 0.7 |
config)Un opérateur peut déclarer un schéma de configuration dans son descripteur. La configuration est fixée au niveau de l'instance dans la composition, pas au niveau des plugs.
Déclaration dans le descripteur (ADR-019)
export const Source = {
id: "source",
label: "Source",
family: "structural",
category: "I/O",
inputs: [],
outputs: [{ id: "out_data", type: "DataString", label: "data" }],
config: {
value: { type: "DataString", label: "Initial value", default: "Hello from Source" },
},
run(inputs, { signal, config } = {}) {
return { out_data: config.value };
},
};
Le champ config est un dict de paramètres. Chaque paramètre déclare :
| Champ | Type | Description |
|---|---|---|
type | string | Type de la valeur (pour l'éditeur de config) |
label | string | Libellé affiché dans l'éditeur de config |
default | any | Valeur par défaut (optionnel) |
Instanciation dans la composition (.cm.js)
import { Source, Sink } from '../engine/libs/std-io-lib.js';
import { CallOpenAI } from '../engine/libs/std-ai-lib.js';
export const operators = [
{ op: Source, as: "prompt", config: { value: "Explain Petri nets" } },
{ op: CallOpenAI, as: "llm", config: { model: "gpt-4-turbo", apiKey: "sk-..." } },
{ op: Sink, as: "output" },
];
Résolution par le moteur
Le moteur fusionne la config de l'instance avec les défauts du descripteur :
config effectif = { ...descripteur.config.defaults, ...instance.config }
Le moteur passe config à run() via le second argument : run(inputs, { signal, config }).
UI
L'éditeur de configuration est accessible via un menu contextuel sur l'opérateur (clic droit ou icône). Ce n'est pas la Vue (panneau droit) — c'est un formulaire dédié à la configuration de l'opérateur.
default)Un plug d'entrée peut déclarer une valeur par défaut. Elle est utilisée quand le plug n'est pas connecté et qu'aucune connexion ne fournit de valeur.
Déclaration dans le plug descriptor
export const CallOpenAI = {
id: "call_openai",
label: "Call OpenAI",
family: "transform",
category: "AI / LLM",
inputs: [
{ id: "in_prompt", type: "DataString", label: "prompt" }, // obligatoire — doit être connecté
],
outputs: [
{ id: "out_response", type: "DataString", label: "response" },
],
config: {
model: { type: "DataString", label: "Model", default: "gpt-4" },
apiKey: { type: "DataString", label: "API Key", default: "" },
systemPrompt: { type: "DataString", label: "System prompt", default: "" },
},
async run(inputs, { signal, config } = {}) {
// config.model, config.apiKey, config.systemPrompt
// inputs.in_prompt
},
};
Règle de résolution par le moteur
Pour chaque plug d'entrée avant d'appeler run() :
NEW → valeur de la connexiondefault est déclaré dans le plug descriptor → valeur par défautinputs (non transmis à run())Plug sans connexion ni valeur résolue
Si un plug obligatoire (sans default) n'est pas connecté, le moteur considère l'opérateur comme non-fireable. Ce cas est détecté à la validation de la composition, pas à l'exécution.
run() (ADR-019)Le second argument de run() est étendu pour inclure config :
run(inputs, { signal, config } = {})
inputs : dict { plug_id: value } — données des plugs connectés (inchangé)signal : AbortSignal pour interruption (ADR-027)config : dict des paramètres de configuration résolus (nouveau)Les opérateurs sans config continuent de fonctionner sans modification — le champ est optionnel.
run(inputs, { signal, config }) reçoit les deux : données en transit (plugs) et paramètres internes (config) — l'opérateur sait exactement d'où vient chaque valeurconfig pour ses valeurs — pas de plug factice.cm.js au niveau de l'instance (config: { ... }) — lisible et diffabledefault de plug permettent un comportement out-of-the-box sur les plugs connectables mais facultatifsconfig requis (sans default) est fourni dans l'instanceCaméléon v2 — 04/03/2026 (amendé 05/03/2026)
ADR-019 spécifie le contrat statique d'un opérateur (run, rollback, canFire) et la signature de chaque méthode. Il ne décrit pas le cycle de vie complet d'un opérateur à l'exécution — les états qu'il traverse, les transitions entre ces états, et les responsabilités respectives du moteur et de l'opérateur à chaque phase.
Sans cette spécification, les questions suivantes restent ouvertes : - Que se passe-t-il si run() échoue (exception, timeout, rejet de Promise) ? - Comment le moteur signale-t-il l'état d'un opérateur en cours d'exécution async ? - Comment le rollback interagit-il avec l'historique CVM ?
Un opérateur traverse les états suivants pendant l'exécution. Le moteur est responsable de toutes les transitions — l'opérateur ne gère jamais son propre état.
| État | Condition | Responsable |
|---|---|---|
| IDLE | L'opérateur existe dans la composition mais ses préconditions ne sont pas remplies | Moteur |
| READY | canFire() retourne true (ou policy ANY/ALL satisfaite) | Moteur |
| RUNNING | run() a été appelé, la Promise n'est pas encore résolue | Moteur |
| COMPLETED | run() a retourné avec succès — les sorties sont produites | Moteur |
| FAILED | run() a levé une exception ou la Promise a été rejetée | Moteur |
| CANCELLED | run() interrompu via signal.aborted | Moteur |
Transitions valides
IDLE → READY (préconditions remplies)
READY → RUNNING (step() appelé par le moteur)
RUNNING → COMPLETED (run() résolu)
RUNNING → FAILED (run() rejeté ou exception)
RUNNING → CANCELLED (signal.aborted)
COMPLETED → IDLE (undo — rollback() appelé si présent)
FAILED → IDLE (reset ou undo — pas de rollback())
CANCELLED → IDLE (reset ou relance — pas de rollback())
FAILED vs CANCELLED
| FAILED | CANCELLED | |
|---|---|---|
| Cause | Exception / Promise rejetée | Interruption explicite via signal |
| Entrées consommées ? | Non (restent NEW) | Non (restent NEW) |
rollback() appelé ? | Non | Non |
| Réexécutable ? | Oui (après reset) | Oui (immédiatement) |
| CVM Log | Erreur avec message | "Interrupted by user" |
Gestion des erreurs
run() lève une exception synchrone : l'opérateur passe en FAILED, les connexions entrantes restent en état NEW (pas consommées), les sorties ne sont pas produitesrun() retourne une Promise rejetée : même comportement que l'exception synchronerollback() n'est jamais appelé sur un opérateur FAILED — il n'y a rien à annulerTimeout
AbortController ou équivalent dans run())run(inputs, { signal }) — l'opérateur peut écouter signal.aborted pour interrompre proprementAmendment de la signature run() (ADR-019)
Le signal d'interruption étend la signature de run() définie en ADR-019 :
run(inputs, { signal } = {})
Le second argument est optionnel. Les opérateurs qui n'écoutent pas signal continuent de fonctionner sans modification. Les opérateurs qui souhaitent supporter l'interruption propre déclarent run(inputs, { signal }) et écoutent signal.aborted.
Le moteur passe toujours { signal } quand il appelle run(). C'est à l'opérateur de décider s'il l'utilise ou l'ignore.
Concurrence
Dans la version actuelle, le moteur est séquentiel : un seul opérateur peut être en état RUNNING à la fois. L'exécution concurrente (plusieurs opérateurs RUNNING simultanément) est prévue dans ADR-011 — l'état RUNNING est conçu pour le supporter.
run() réussit — un échec ne corrompt pas l'état du réseauCaméléon v2 — 04/03/2026
ADR-030 définit le framework de libraries (convention *-lib.js, metadata meta, découverte par engine/libs/). Il ne spécifie pas le contenu de la standard library — quels opérateurs, dans quelles libraries, avec quels plugs et quels types.
ADR-020 distingue deux mécanismes de paramétrage : la configuration opérateur (config — paramètres internes non connectables) et les valeurs par défaut de plug (default — valeur quand le plug n'est pas connecté). Cette distinction structure la conception des opérateurs ci-dessous.
ADR-027 définit le lifecycle d'exécution des opérateurs (IDLE → READY → RUNNING → COMPLETED / FAILED / CANCELLED). L'état FAILED est produit lorsqu'un run() lève une exception — les opérateurs de parsing (JSONParse) ou d'I/O (FileLoader, APICall) peuvent entrer dans cet état.
Ce contenu doit être formalisé avant l'implémentation pour : - Guider le split de operators-lib.js (étape 1 du plan v0.15) - Définir les signatures run() et config de chaque opérateur (étape 6) - Servir de référence pour le catalogue UI (étape 2) - Délimiter le périmètre v0.15 vs backlog
Signature canonique — cet ADR amende ADR-019 : la signature canonique complète est run(inputs, { signal, config } = {}). Le second argument est optionnel — les opérateurs qui n'ont besoin ni de signal ni de config continuent de déclarer run(inputs) et restent conformes.
| Library | Fichier | Domain | Description |
|---|---|---|---|
| std-io | std-io-lib.js | I/O | Data sources and sinks |
| std-struct | std-struct-lib.js | Structure | Control flow operators |
| std-data | std-data-lib.js | Data | Data transformation and formatting |
| std-human | std-human-lib.js | Human | Human-in-the-loop interaction (ADR-034) |
| std-live | std-live-lib.js | Live | Live interaction — open compositions (ADR-035) |
| std-connect | std-connect-lib.js | Connect | External connectivity (HTTP, mail) |
| std-ai | std-ai-lib.js | AI | LLM and AI service calls |
Les 5 premières libraries portent les opérateurs sans dépendance externe. Les 2 dernières (std-connect, std-ai) sont reportées en v0.20.
| Opérateur | id | family | category | Inputs | Outputs | Config | v0.15 |
|---|---|---|---|---|---|---|---|
| Source | source | structural | I/O | — | out_data : DataString | value : DataString | oui |
| Sink | sink | structural | I/O | in_data : DataString | — | — | oui |
| FileLoader | file_loader | transform | I/O | — | out_data : Binary | file : Binary | v0.17 |
| FileSaver | file_saver | structural | I/O | in_data : Any | — | filename : DataString, mimeType : DataString | v0.17 |
Source : data origin — produces a configured value. - Config : value (default: "Hello from Source") — the value emitted on each fire, editable via the operator config editor - run(inputs, { config }) → { out_data: config.value } - Pas de rollback() — opérateur pur sans effet de bord
Sink : terminal output — consumes data, produces nothing. - run({ in_data }) → {} (log la valeur reçue en console) - Pas de rollback() — le log n'est pas annulable
FileLoader (v0.17) : loads a pre-configured file. - Config : file (type: Binary, required) — file loaded via the config editor (right-click → Configure) - run(inputs, { config }) → { out_data: config.file } - rollback() : absent — lecture seule
FileSaver (v0.17) : writes data to a file via browser download. - Config : filename (default: "output.txt"), mimeType (default: "text/plain") - run({ in_data }, { config }) → {} (triggers browser download) - Pas de rollback() — download non annulable
| Opérateur | id | family | category | Inputs | Outputs | Config | v0.18 |
|---|---|---|---|---|---|---|---|
| MessageReviewer | message_reviewer | transform | Human | in_message : DataString | out_validated : DataBool, out_message : DataString | — | oui |
MessageReviewer : human reviews a message — validates or rejects it. - interaction: "human", control: "message_review_control" (ADR-034) - run({ in_message }, { signal }) → Promise resolved by renderer via resolveHuman() - Closed composition — blocks until human validation
| Opérateur | id | family | category | Inputs | Outputs | Config | v0.18 |
|---|---|---|---|---|---|---|---|
| LiveSource | live_source | source | Input / Live | — | out_data : Any | — | oui |
LiveSource : injects a value into the graph on every user input — opens the composition (ADR-035). - interaction: "live", control: "live_input_control" - run(inputs, { signal }) → Promise resolved by renderer via resolveLive() on each user input - Re-fire policy : last-write-wins — renderer orchestrates re-fire loop - Open composition — determinism is reactive (conditional on input sequence E)
All structural operators use Any types — pure token routing, no data transformation. Aligned with standard workflow patterns (van der Aalst).
| Opérateur | id | family | category | Pattern | Inputs | Outputs | Policy | v0.15 |
|---|---|---|---|---|---|---|---|---|
| Fork | fork | structural | Control Flow | WCP-2 AND-split | in_data : Any | out_a : Any, out_b : Any | ANY | oui |
| Join | join | structural | Control Flow | WCP-3 AND-join | in_a : Any, in_b : Any | out_data : Any | ALL | oui |
| Merge | merge | structural | Control Flow | WCP-5 XOR-join | in_a : Any, in_b : Any | out_data : Any | ANY | oui |
| Gate | gate | structural | Control Flow | WCP-4 XOR-split | in_data : Any, in_condition : DataBool | out_true : Any, out_false : Any | COND | oui |
| Barrier | barrier | structural | Control Flow | Barrier (*) | in_a : Any, in_b : Any | out_a : Any, out_b : Any | ALL | oui |
(*) Barrier est une extension Caméléon — pas de WCP direct correspondant. Synchronisation forte sur branches parallèles asymétriques. Fonctionnellement équivalent à un AND-join (WCP-3) avec propagation paired des valeurs d'entrée. Nécessaire pour garantir la cohérence temporelle dans les compositions à branches de longueur inégale.
Fork (AND-split) : duplicates a single input to two outputs. ANY policy. - run({ in_data }) → { out_a: in_data, out_b: in_data } - Pas de config, pas de rollback()
Join (AND-join) : synchronization barrier — waits for all inputs (ALL), passes in_a through. - run({ in_a, in_b }) → { out_data: in_a } - in_b consumed as sync signal, value not transmitted - Pas de config, pas de rollback()
Merge (XOR-join) : first arrived passes through (ANY). For loops and exclusive choices. - run(inputs) → { out_data: inputs.in_a ?? inputs.in_b } - inputs contient uniquement les plugs NEW — in_a a priorité si les deux arrivent simultanément (ordre de déclaration) - Pas de config, pas de rollback()
Gate (XOR-split) : conditional routing — routes input to out_true or out_false based on in_condition. - canFire(states) → states.in_data === "NEW" && states.in_condition === "NEW" - run({ in_data, in_condition }) → in_condition ? { out_true: in_data } : { out_false: in_data } - Note : in_condition : DataBool est le seul plug non-Any de std-struct — nécessaire pour un vrai WCP-4 - Pas de config, pas de rollback()
Barrier : synchronized passthrough — ALL policy, routes each input to its paired output. Extension Caméléon — synchronisation forte sur branches parallèles asymétriques. Fonctionnellement équivalent à un AND-join (WCP-3) avec propagation paired des valeurs d'entrée. Nécessaire pour garantir la cohérence temporelle dans les compositions à branches de longueur inégale. - run({ in_a, in_b }) → { out_a: in_a, out_b: in_b } - Fires only when both inputs are NEW (not NEW/OLD) - Pas de config, pas de rollback()
Backlog (nécessitent des modifications moteur, un cas d'usage documenté, ou des plugs dynamiques) :
| Pattern | WCP | Description | Implication moteur |
|---|---|---|---|
| Loop | WCP-10 Arbitrary Cycles | Boucle sans contrainte structurelle — arc retour dans le graphe | Détection et support des cycles dans le réseau de Petri |
| Structured Loop | WCP-21 Structured Loop | Boucle avec pré/post-condition explicite (while/repeat) | Structure while/repeat, condition de sortie |
| Implicit Termination | WCP-11 | Terminaison par épuisement des tokens — pas de sink explicite | Détection automatique de l'état terminal |
| MultiChoice | WCP-6 OR-split | Routage vers une ou plusieurs branches selon conditions | Routing dynamique, plugs dynamiques |
| SyncMerge | WCP-7 OR-join | Synchronisation de branches OR-split | Détection du nombre de branches actives |
| MultiMerge | WCP-8 | Fusion sans synchronisation — chaque branche passe indépendamment | Plugs dynamiques |
| Discriminator | WCP-9 | Premier arrivé passe, les suivants sont ignorés | Compteur de tokens consommés |
| Race | — | Multi-output Merge (premier arrivé passe avec routage apparié) — extension Caméléon | Cas d'usage que Merge ne couvre pas à documenter |
| Router | — | Routage dynamique multi-branches | Plugs dynamiques, table de routage |
| Opérateur | id | family | category | Inputs | Outputs | Policy | Config | v0.15 |
|---|---|---|---|---|---|---|---|---|
| Transform | transform | transform | Data / Conversion | in_data : Any | out_data : DataString | ANY | — | oui |
| Format | format | transform | Data / Presentation | in_main : DataString, in_meta : DataString | out_data : DataString | ALL | separator : DataString | oui |
| JSONParse | json_parse | transform | Data / Conversion | in_data : DataString | out_data : DataJSON | ANY | — | v0.17 |
| JSONStringify | json_stringify | transform | Data / Conversion | in_data : DataJSON | out_data : DataString | ANY | pretty : DataBool | v0.17 |
| Template | template | transform | Data / Presentation | in_template : DataString, in_data : DataJSON | out_data : DataString | ALL | — | v0.17 |
Transform : converts any input to string output. - run({ in_data }) → { out_data: String(in_data) } - Pas de config - Pas de rollback()
Format : combines two inputs into a formatted string. - Config : separator (default: " | ") — the separator between main and meta - run({ in_main, in_meta }, { config }) → ` { out_data: ${in_main}${config.separator}${in_meta} } ` - Pas de rollback()
JSONParse : parses a JSON string into a structured object. - run({ in_data }) → { out_data: JSON.parse(in_data) } - Pas de config, pas de rollback() - Peut throw si le JSON est invalide → état FAILED (ADR-027)
JSONStringify : serializes a structured object to JSON string. - Config : pretty (default: false) — pretty-print with 2-space indent - run({ in_data }, { config }) → { out_data: JSON.stringify(in_data, null, config.pretty ? 2 : 0) } - Pas de rollback()
Template : string interpolation — replaces {{key}} placeholders with values from a JSON object. - run({ in_template, in_data }) → { out_data: template.replace(/\{\{(\w+)\}\}/g, (_, k) => data[k] ?? "") } - Pas de config, pas de rollback()
Backlog : Filter, Map — nécessitent un langage d'expression.
| Opérateur | id | family | category | Inputs | Outputs | Config | v0.15 |
|---|---|---|---|---|---|---|---|
| APICall | api_call | transform | Connect / HTTP | in_url : DataString, in_body : DataJSON | out_response : DataJSON, out_status : DataString | method : DataString, headers : DataJSON | stretch |
APICall : HTTP request to external API. - Config : method (default: "GET"), headers (default: { "Content-Type": "application/json" }) - run({ in_url, in_body }, { signal, config }) → { out_response: <JSON>, out_status: "200" } (async, fetch() + signal for cancellation) - Pas de rollback() — l'appel HTTP n'est pas annulable
Backlog : SendMail, WebSocket — non implémentés en v0.15.
| Opérateur | id | family | category | Inputs | Outputs | Config | v0.15 |
|---|---|---|---|---|---|---|---|
| CallOpenAI | call_openai | transform | AI / LLM | in_prompt : DataString | out_response : DataString | model, apiKey, systemPrompt | stretch |
| CallClaude | call_claude | transform | AI / LLM | in_prompt : DataString | out_response : DataString | model, apiKey, systemPrompt | stretch |
CallOpenAI : OpenAI API call (chat completions). - Config : model (default: "gpt-4"), apiKey (default: ""), systemPrompt (default: "") - run({ in_prompt }, { signal, config }) → { out_response: "<LLM response>" } (async) - Pas de rollback() — appel API non annulable
CallClaude : Anthropic API call (messages). - Config : model (default: "claude-sonnet-4-6-20250514"), apiKey (default: ""), systemPrompt (default: "") - run({ in_prompt }, { signal, config }) → { out_response: "<LLM response>" } (async) - Pas de rollback()
Backlog : CallMistral, Embeddings, ChatSession — non implémentés en v0.15.
Tous les types référencés dans le catalogue, avec leur registre (ADR-028) :
| Type | Couleur | Usage |
|---|---|---|
DataString | #4f8aff (signal) | Text, prompts, responses |
DataJSON | #00e5a0 (cvm) | Structured data, config |
DataBool | #f4845f (rust) | Control signals, conditions |
Binary | #ffb547 (amber) | Files, images, raw data |
Any | #8899aa (dim) | Debug, log, passthrough |
Les types custom (ex : ImagePNG, CSVTable) utilisent la couleur par défaut #8899aa.
v0.15 — implémentation complète (9 opérateurs, aucune dépendance externe) : Source, Sink, Fork, Join, Merge, Gate, Barrier, Transform, Format
v0.17 — sans dépendance externe (6 opérateurs) : JSONParse, JSONStringify, Template, FileLoader, FileSaver
v0.18 — interactions humaines et live (2 opérateurs) : MessageReviewer (std-human), LiveSource (std-live)
Reporté en v0.20 (dépendances externes) : APICall, CallOpenAI, CallClaude (libraries std-connect et std-ai)
Backlog (nécessitent des modifications moteur ou un langage d'expression) : Race, Loop (WCP-10), Structured Loop (WCP-21), MultiChoice (WCP-6), SyncMerge (WCP-7), MultiMerge (WCP-8), Discriminator (WCP-9), Router, Filter, Map, SendMail, WebSocket, CallMistral, Embeddings, ChatSession
operators-lib.js → engine/libs/ suit exactement la répartition ci-dessus — les 3 premières libraries portent les opérateurs v0.15run(inputs, { signal, config } = {}) amende ADR-019 — les opérateurs simples continuent de déclarer run(inputs)config d'ADR-020 — pas de plugs facticesmeta.domain puis op.category — la structure du catalogue est définie par cet ADRstd-human et std-live ajoutent les opérateurs interactifs (v0.18) — interaction: "human" (ADR-034) et interaction: "live" (ADR-035)doc/architecture/libs/ — cet ADR est la vue haute, les designs détaillent les signatures et comportementsstd-connect et std-ai sont reportées en v0.20 — elles nécessitent des dépendances externes (HTTP, clés API)engine/types.js (ADR-028) — toute extension de type est documentée iciCaméléon v2 — 05/03/2026
Dans le POC actuel, le modèle de données (OPS, CONNS), le moteur (canFire, execFire) et le renderer (buildSVG, updateSVG) sont dans un seul fichier. Pour passer à une architecture testable, extensible et distribuable, il faut séparer les responsabilités.
Trois couches avec des responsabilités strictement séparées. Le critère est : chaque couche doit pouvoir être testée et utilisée indépendamment des autres.
operators-lib.js ← catalogue (auto-descriptifs + run + rollback)
pipeline.cm.js ← topologie pure (instances + connexions + layout)
cvm.js ← moteur (résout lib + exécute)
cameleon-ui ← renderer visuel (pas de logique métier)
Signature d'un opérateur dans operators-lib.js :
{
id: "processor", label: "Mon Processor",
family: "transform", firingPolicy: "ANY", // omis → défaut ANY
inputs: [{ id: "in_a", type: "DataString" }],
outputs: [{ id: "out_a", type: "DataString" }],
run(inputs) { return { out_a: transform(inputs.in_a) }; },
rollback(outputs) { /* inverse */ }
}
.cm ne redéclare jamais inputs/outputs — ils viennent de la lib, le fichier est court et lisiblerun() et rollback() testables unitairement sans le moteur.cm valide en connaissant juste le catalogue — bijection prompt → pipelineLe format .cm initial du POC était en YAML. Ce choix était justifié à l'époque car le fichier portait tout — déclarations d'inputs, d'outputs, de types — faute d'une operators-lib pour les résoudre. Avec la décision ADR-008 (séparation en trois couches), la lib prend en charge la description des opérateurs. Le .cm ne porte plus que la topologie. La question se pose alors du format le plus adapté : YAML ou JS ?
Les fichiers .cm sont des modules JavaScript (.cm.js). Le moteur les importe directement. Les critères ayant guidé ce choix :
.cm.js nativement, sans étape de transformation.cm ne déclare que les instances et les connexions.cm s'y intègre naturellement
// exemple.cm.js
import { PDFLoader, Converter, GPT4Call, FormatResponse, Print } from './operators-lib.js'
export default {
nodes: {
pdf: PDFLoader,
converter: Converter,
gpt4: GPT4Call,
formatter: FormatResponse,
print: Print,
},
connections: [
[ "pdf.out_pdf", "converter.in_binary" ],
[ "converter.out_string", "gpt4.in_context" ],
[ "gpt4.out_response", "formatter.in_response" ],
[ "formatter.out_string", "print.in_string" ],
]
}
.cm existants du POC seront migrés.cm.js directementLe modèle original de Caméléon incluait des widgets formels directement insérés dans la composition : un viewer se connectait à un plug de sortie (lecture des données produites), un éditeur se connectait à un plug d'entrée (injection de données dans le pipeline). Ces widgets étaient des nœuds à part entière du graphe, au même titre que les opérateurs, et participaient à la sémantique Petri-net.
Deux questions se posent maintenant :
Un composant interactif est un nœud formel si et seulement s'il participe à la sémantique de tir — c'est-à-dire s'il peut bloquer, déclencher ou conditionner l'avancement de la composition.
Le critère discriminant est la présence d'une sortie qui participe au flux. Un viewer n'a pas de sortie — il n'est jamais un nœud formel. Un éditeur produit toujours une sortie — il est toujours un nœud formel.
Cette règle donne une taxonomie claire et sans ambiguïté :
| Composant | Ports | Rôle |
|---|---|---|
| Éditeur sans entrée | 0→N | Injecte une valeur initiale — équivalent UserInput |
| Éditeur avec entrée | N→M | Reçoit un contexte, attend interaction humaine, produit une valeur validée — HumanReview |
Un éditeur avec entrée peut recevoir un ou plusieurs plugs — par exemple un résultat intermédiaire à valider, un formulaire pré-rempli, ou un contexte à enrichir. Il bloque le flux jusqu'à l'action humaine, exactement comme tout autre opérateur en attente de ses entrées NEW.
const HumanReview = {
id: "human_review",
executionMode: "local",
family: "transform",
inputs: [{ id: "in_context", type: "DataString" }], // contexte affiché à l'utilisateur
outputs: [{ id: "out_value", type: "DataString" }], // valeur validée ou modifiée
run(inputs) {
// bloque jusqu'à interaction humaine
// affiche inputs.in_context, attend saisie
return { out_value: /* valeur saisie */ };
},
rollback(outputs) { /* efface la saisie */ }
}
Un viewer observe une connexion ou un plug — il affiche la valeur en transit sans participer au flux. Il n'a pas de sortie, ne bloque rien, ne conditionne rien. Il est accessible via le panneau d'inspection latéral : clic sur un plug → affichage de la valeur courante selon son type.
.cm.js reste court — les viewers passifs ne polluent pas la topologieHuman in the loop sans introduire de type de nœud spécialHumanReview est déclaré executionMode: "local" dans l'exemple, mais une interaction humaine a une latence indéterminée. Selon les critères d'ADR-015 (latence haute, I/O externe), il devrait probablement être "remote". Ce point sera tranché quand ADR-015 sera accepté.Le moteur CVM doit s'exécuter dans le browser pour le renderer visuel, et en dehors pour le CLI. Trois options techniques ont été évaluées :
Le moteur CVM est implémenté en JS pur, exécuté nativement dans le browser et dans le runtime Deno pour le CLI. Les critères ayant guidé ce choix :
cvm.js tourne dans le browser (renderer), le CLI (Deno), et Tauri (desktop) sans modificationWASM est écarté : la complexité de build et de debugging n'est pas justifiée par un gain de performance réel pour des graphes de taille raisonnable.
remote (ADR-015) font des appels HTTP depuis le browser — les CORS doivent être gérés côté serveur des APIs cibles ou via un proxy légerADR-014 établit le choix JS plutôt que YAML et montre un exemple minimal (nodes + connections). Depuis, ADR-019 formalise le contrat des opérateurs et ADR-020 introduit les paramètres statiques de plug (params). L'UI visuelle nécessite en outre les positions des nœuds et l'angle de chaque plug sur son orbite (layout). Le format .cm.js doit être spécifié complètement pour que le moteur, l'UI et le CLI en aient une lecture identique.
Un fichier .cm.js est un module ES avec un export default structuré en quatre sections. Seules nodes et connections sont obligatoires.
Structure complète
// rag-pipeline.cm.js
import { PDFLoader, GPT4Call, FormatResponse, Print } from './operators-lib.js'
export default {
// ── Métadonnées (optionnel) ──────────────────────────────────────────────
meta: {
name: "RAG Pipeline",
description: "Charge un PDF, appelle GPT-4 et affiche la réponse.",
version: "1.0.0",
authors: ["O. Cugnon de Sévricourt"],
},
// ── Instances d'opérateurs (obligatoire) ────────────────────────────────
// Forme simple : alias → opérateur de la lib
// Forme étendue : alias → { ...opérateur, params: { plug_id: value } }
nodes: {
pdf: PDFLoader,
gpt4: { ...GPT4Call, params: { model: "gpt-4-turbo", system_prompt: "You are a helpful assistant." } },
formatter: FormatResponse,
print: Print,
},
// ── Connexions (obligatoire) ─────────────────────────────────────────────
// Chaque connexion : [ "alias_source.plug_id", "alias_cible.plug_id" ]
connections: [
[ "pdf.out_pdf", "gpt4.in_context" ],
[ "gpt4.out_response", "formatter.in_response" ],
[ "formatter.out_string", "print.in_string" ],
],
// ── Positions visuelles (optionnel — UI uniquement) ──────────────────────
// Ignoré par le moteur et le CLI. Géré par l'éditeur visuel.
// x, y : position du centre de l'opérateur sur le canvas
// plugs : angle en radians de chaque plug sur son orbite (0 = droite, sens trigo)
layout: {
pdf: { x: 90, y: 240, plugs: { out_pdf: 0 } },
gpt4: { x: 530, y: 240, plugs: { in_context: 2.8, in_user_input: -2.8, out_response: 0 } },
formatter: { x: 760, y: 240, plugs: { in_response: 2.5, out_string: 0 } },
print: { x: 980, y: 240, plugs: { in_string: 3.14 } },
},
}
Règles
| Section | Obligatoire | Consommé par |
|---|---|---|
meta | non | UI, CLI (affichage), LLM |
nodes | oui | moteur, UI, CLI |
connections | oui | moteur, UI, CLI |
layout | non | UI uniquement — moteur et CLI l'ignorent |
Identifiant de plug qualifié
Le format "alias.plug_id" dans connections combine l'alias du nœud (clé dans nodes) et l'id court du plug (déclaré dans l'opérateur, ADR-019). Le moteur résout la connexion à partir de ces deux parties.
Forme simple vs forme étendue d'un nœud
// Forme simple — pas de paramètres
pdf: PDFLoader
// Forme étendue — paramètres statiques (ADR-020)
gpt4: { ...GPT4Call, params: { model: "gpt-4-turbo" } }
Le spread ...GPT4Call copie le descripteur de l'opérateur. Le moteur lit node.params pour résoudre les valeurs statiques avant d'appeler run().
Date : 04/03/2026 Origine : Revue Mira (ADR-023) — découpage du périmètre runtime loader
ADR-023 introduit loadComposition(text) (texte → objet). La direction inverse — objet → texte — doit produire un .cm.js conforme au présent ADR. La question architecturale : qui fournit le layout à sérialiser ?
Le layout (positions x/y, angles des plugs) vit dans le renderer. Si serializeComposition va chercher le layout dans le renderer, elle crée une dépendance circulaire :
renderer.js ← dépend de → runtime-loader.js
runtime-loader.js ← dépend de → renderer.js ← CYCLE
Le layout est passé en paramètre par le renderer au moment de l'appel. Le runtime loader reste passif — il reçoit une donnée, ne va pas la chercher.
serializeComposition(composition, layout = {})
// ↑ fourni par le renderer, optionnel
Le paramètre est optionnel : en contexte CLI (pas de renderer), layout = {} produit un .cm.js sans section layout — valide per ADR-021.
// renderer.js — export vers fichier
const text = serializeComposition(composition, getCurrentLayout());
// renderer.js — bijection visuel → texte
codeEditor.value = serializeComposition(composition, getCurrentLayout());
Le round-trip est complet : loadComposition(serializeComposition(comp, layout)) préserve la topologie et le layout visuel.
Note (review Nora) :
serializeCompositiongénère actuellement un import unique versoperators-lib.js. Quand ADR-030 sera implémenté, l'implémentation devra résoudre le chemin de library de chaque opérateur depuis le registre — les imports deviendront multi-sources (ex :std-string-lib.js,cst-twitter-lib.js).
serializeComposition — implémentation de référenceGénère un .cm.js conforme ADR-021 avec les quatre sections (meta, nodes, connections, layout). Résout les descripteurs d'opérateurs vers leurs noms dans le registre (ADR-023) pour produire les import corrects.
function serializeComposition(composition, layout = {}) {
const imports = [];
const nodeEntries = [];
for (const [alias, template] of Object.entries(composition.nodes)) {
const name = Object.entries(registry)
.find(([_, desc]) => desc.id === template.id)?.[0];
if (name) imports.push(name);
nodeEntries.push(` ${alias}: ${name || JSON.stringify(template)},`);
}
const conns = composition.connections
.map(([from, to]) => ` ["${from}", "${to}"],`)
.join('\n');
const layoutEntries = Object.entries(layout)
.map(([alias, lay]) => {
const plugs = lay.plugs
? `, plugs: { ${Object.entries(lay.plugs).map(([k,v]) => `${k}: ${v}`).join(', ')} }`
: '';
return ` ${alias}: { x: ${lay.x}, y: ${lay.y}${plugs} },`;
})
.join('\n');
const sections = [
`import { ${[...new Set(imports)].join(', ')} } from '../engine/operators-lib.js';`,
'',
'const composition = {',
'',
' meta: ' + JSON.stringify(composition.meta, null, 4).replace(/\n/g, '\n ') + ',',
'',
' nodes: {',
nodeEntries.join('\n'),
' },',
'',
' connections: [',
conns,
' ],',
];
if (layoutEntries) {
sections.push('', ' layout: {', layoutEntries, ' },');
}
sections.push('', '};', '', 'export default composition;');
return sections.join('\n');
}
.cm.js sans layout est valide et exécutable en CLI — la position n'est pas un prérequis fonctionnel.cm.js sans meta est valide — les métadonnées sont éditoriales, pas structurelleslayout automatiquement — positions des nœuds et angles des plugs sont sauvegardés à chaque déplacementplugs: { plug_id: radians }) correspondent à leur position sur l'orbite autour de l'opérateur (0 = droite, sens trigonométrique) ; les plugs absents de plugs sont positionnés par défaut par l'UI.cm.js valide en connaissant les exports de operators-lib.js — il n'a pas besoin de connaître layout{ ...op, params } est du JS standard — pas de syntaxe propriétaire ni de helper à importerL'étape 2 de la v0.14 a extrait l'intégralité du <script> de cameleon.html dans engine/cvm.js (1027 lignes). Ce fichier contient deux responsabilités mélangées :
canFire, fireable, initHistory, currentState, index incomingConns/outgoingConns — aucune dépendance DOM.ADR-008 pose les trois couches (operators-lib / .cm / moteur) mais ne formalise pas la frontière moteur/UI. Le moteur doit être importable sans DOM (tests Deno, futur CLI per ADR-009) tandis que le renderer est spécifique au browser.
Séparer engine/cvm.js en deux fichiers à responsabilité unique. Le critère est : le moteur CVM ne connaît pas le DOM ; le renderer importe le moteur.
engine/cvm.js ← moteur pur (zéro DOM, zéro SVG)
editor/renderer.js ← renderer UI (importe ../engine/cvm.js)
editor/cameleon.html ← importe ./renderer.js uniquement
engine/cvm.js — moteur purContient exclusivement :
| Bloc | Contenu |
|---|---|
| Données | OPS, CONNS (temporaire — migreront vers operators-lib + composition) |
| Index | CONN_BY_ID, CONN_TO_PLUG, incomingConns, outgoingConns |
| CVM | history, cursor, initHistory, canFire, fireable, currentState, clone |
TYPES (palette de couleurs par type de plug) ne reste pas dans le moteur — c'est de la métadonnée de rendu, pas de la logique d'exécution. Il migre dans editor/renderer.js. Si une vérification de compatibilité de types entre plugs est ajoutée (v0.16+), elle sera formalisée comme règle CVM distincte, sans dépendre de la palette de couleurs.
Surface publique du moteur — contrat d'import :
// engine/cvm.js — exports publics
export {
// Données (temporaire — migreront en 5b/5c)
OPS, CONNS,
// Index
CONN_BY_ID, CONN_TO_PLUG, incomingConns, outgoingConns,
// État CVM
history, cursor,
// Fonctions CVM
initHistory, canFire, fireable, currentState, clone,
};
Tout ce qui n'est pas dans cette liste est un détail d'implémentation interne. Aucune référence à document, window, SVG, DOM.
editor/renderer.js — renderer UIContient tout le code qui manipule le DOM :
| Bloc | Contenu |
|---|---|
| Types visuels | TYPES (palette de couleurs par type de plug), tc() |
| SVG helpers | NS, se, $, bez |
| Plug géométrie | PLUG_R, PLUG_DIST, PLUG_ANGLES, initPlugAngles, plugPositions, PLUG_IDX, buildPlugIndex, connEP |
| Rendu | buildSVG, updateSVG, updateSidebar |
| Interactions | moveOp, setupDrag, setupZoom, setupResize |
| Animations | animateTokens |
| Commandes | doForward, execFire, doBackward, execBackward, toggleRun, toggleRewind, doReset, onScrub |
| UI | addLog, setupTooltips, renderAnnotations, toggleAnnotations, openRN, closeRN, togglePanel, switchMode, dismissHint |
| Boot | séquence d'init |
Importe depuis ../engine/cvm.js. Exporte les handlers pour les onclick/oninput du HTML.
editor/Le répertoire editor/ est la convention existante du projet — cameleon.html y vit depuis v0.1. Le renderer.js est placé à côté du HTML qu'il alimente. Ce choix est cohérent avec la structure documentée dans CLAUDE.md.
engine/cvm.js est importable dans Deno sans polyfill DOM — les tests de non-régression (tests/cvm.test.js) ne dépendent que du moteurengine/cvm.js directement — pas besoin d'extraire le moteur d'un fichier mixtecameleon-ui mentionnée dans ADR-008 correspond maintenant à editor/renderer.jsinitPlugAngles, plugPositions) restent dans le renderer car elles servent exclusivement au rendu SVG — le moteur ne connaît pas les coordonnéesTrois fonctionnalités prévues dans la roadmap nécessitent l'évaluation dynamique de code JS dans le navigateur :
.cm.js depuis le disque et l'instancier comme composition activerun() et rollback() directement dans le panneau inspecteur.cm.js texte) et la vue canvas (graphe SVG) — voir ADR-024Le mode de déploiement cible est le standalone file:// — un seul fichier HTML, sans serveur, sans dépendance. Or les mécanismes standards du navigateur (import() dynamique, fetch(), ES modules) sont bloqués par les restrictions CORS/module du protocole file://.
Le build standalone (build-doc.py) résout déjà ce problème au build time via _strip_imports_exports() — la même logique doit être disponible au runtime.
Créer un module engine/runtime-loader.js qui encapsule le chargement et l'évaluation de code JS sans dépendance module/réseau. Ce composant sert les trois fonctionnalités ci-dessus via une API unique.
Un fichier .cm.js est un module ES avec des import et un export default. En standalone, les opérateurs du catalogue sont déjà en mémoire. Le runtime loader :
FileReader ou directement depuis un <textarea>)import, export) — même logique que _strip_imports_exportsnew Function, en injectant les opérateurs connus comme paramètresLe runtime loader maintient un registre — un dictionnaire { name: descriptor } — de tous les opérateurs connus. Ce registre est alimenté depuis operators-lib.js au boot. Les opérateurs custom (v0.19) y sont ajoutés dynamiquement.
// engine/runtime-loader.js
const registry = {};
function register(name, descriptor) {
registry[name] = descriptor;
}
function getRegistry() {
return { ...registry };
}
loadComposition(text) → composition
Charge une composition depuis son code source .cm.js (texte brut).
Le strip remplace export default par une assignation à une variable sentinelle _cm_, ce qui gère les deux formats valides : - const composition = {...}; export default composition; → _cm_ = composition - export default { ... } directement → _cm_ = { ... }
function loadComposition(text) {
const stripped = stripImportsExports(text);
const names = Object.keys(registry);
const values = Object.values(registry);
const fn = new Function(...names, stripped + '; return _cm_;');
return fn(...values);
}
evalOperatorFn(code, argNames) → Function
Évalue le body d'une fonction opérateur (run ou rollback) écrite par l'utilisateur dans l'éditeur inline.
function evalOperatorFn(code, argNames = ['inputs']) {
return new Function(...argNames, code);
}
Usage — éditeur inline dans le panneau inspecteur :
// L'utilisateur écrit dans le textarea :
// return { out_string: inputs.in_binary.toString("utf-8") };
operator.run = evalOperatorFn(editorContent, ['inputs']);
stripImportsExports(text) → text
Fonction utilitaire partagée — miroir JS de _strip_imports_exports en Python. Transformations :
| Source | Résultat |
|---|---|
import { ... } from '...' | supprimé |
export { ... } | supprimé |
export function ... | supprimé |
export const X = ... | const X = ... |
export default X | var _cm_ = X |
export { register, getRegistry, loadComposition, evalOperatorFn, stripImportsExports }
Dans le build standalone (build-doc.py), runtime-loader.js est inliné comme les autres fichiers. Les import/export sont strippés au build. Le registre est alimenté au boot par les opérateurs déjà présents en mémoire.
Ordre d'inlining : operators-lib → runtime-loader → composition → cvm → renderer.
Le runtime loader n'a aucune dépendance sur le moteur CVM ni sur le renderer — il se place naturellement après operators-lib (qui alimente le registre) et avant les modules qui l'utilisent.
file:// supporte l'import/export de compositions, l'édition d'opérateurs et la bijection — aucun serveur HTTP requisnew Function est du code local de l'utilisateur, même modèle de sécurité que la console DevTools — pas de risque supplémentaire en contexte desktopnew Function est incompatible avec les Content Security Policy strictes (script-src sans 'unsafe-eval'). Ce point est à réévaluer avant l'activation du canal PWA/webapp (ADR-013). En contexte Tauri, la CSP est configurable dans tauri.conf.jsonstripImportsExports existe en Python (build) et en JS (runtime) — même logique, deux contextesloadComposition est une fonction pure (texte → objet) — testable unitairement sans DOMLes sujets suivants sont liés mais traités dans des ADRs dédiés :
| Sujet | ADR |
|---|---|
Sérialisation du layout (serializeComposition) | ADR-021 amendment |
| Bijection texte ↔ visuel — synchronisation, états, UX | ADR-024 |
| Mode Code-only — CVM sans renderer | ADR-025 |
Le canvas de l'éditeur Caméléon repose sur du SVG pur rendu dans le DOM. Le moteur de rendu (renderer.js, ~2000 lignes) gère les opérateurs, connexions, plugs, annotations, sélection et animations — tout en SVG manipulé via setAttribute et insertion/suppression de nœuds DOM.
À ce stade (v0.17, compositions de 5-15 opérateurs), les performances sont suffisantes. Mais deux évolutions prévues dans la roadmap vont augmenter significativement la charge de rendu :
La question est : faut-il anticiper une migration vers un pipeline de rendu accéléré matériellement, et si oui, quelle stratégie adopter ?
Stratégie en trois paliers progressifs, déclenchés par le besoin réel — pas par anticipation.
Optimiser le pipeline de composition du navigateur sans toucher au code JS :
#svg-main {
will-change: transform;
contain: layout style paint;
}
#canvas-area {
transform: translateZ(0); /* force GPU compositing layer */
}
will-change: transform : le navigateur promeut l'élément en couche GPU, anticipant les transformations (pan/zoom)transform: translateZ(0) : force la création d'une couche compositing séparéecontain: layout style paint : isole le reflow — les modifications dans le canvas ne déclenchent pas de recalcul dans les panneaux latérauxGain attendu : 20-40% sur les opérations de pan/zoom. Zéro risque de régression.
Remplacement du rendu SVG DOM par un rendu programmatique sur <canvas> 2D. Le DOM SVG est remplacé par un dessin batch — un seul élément DOM au lieu de centaines.
Avantages : - Pas de DOM tree à maintenir → pas de reflow - Rendu batch : dessiner 500 opérateurs coûte presque autant que 50 - OffscreenCanvas permet le rendu dans un Web Worker (thread séparé) - Les interactions (hit-testing) passent par les coordonnées mathématiques, plus par le DOM
Inconvénients : - Réécriture majeure du renderer (~2000 lignes) - Perte du CSS natif sur les éléments du canvas (gradients, hover, transitions) - Hit-testing à réimplémenter (plus de event.target) - Accessibilité réduite (pas d'éléments DOM pour les screen readers)
Seuil de déclenchement : quand les sous-compositions (v0.19) ou les compositions complexes montrent des ralentissements mesurables avec le palier 1 en place.
Pipeline GPU complet via une bibliothèque spécialisée (PixiJS, Three.js 2D, ou WebGPU natif).
Avantages : - Rendu de milliers de nœuds à 60fps - Effets visuels avancés (bloom, shadows, particle effects pour les tokens) - Compute shaders pour le layout automatique (force-directed, hierarchical)
Inconvénients : - Dépendance externe lourde (PixiJS ~500KB, Three.js ~600KB) - Complexité de développement × 3-5 - Debugging beaucoup plus difficile - Support WebGPU encore limité (Chrome/Edge uniquement en 2026)
Seuil de déclenchement : pas avant v1.0. Pertinent uniquement si Caméléon cible des compositions industrielles (centaines d'opérateurs, exécution en continu).
Appliquer le palier 1 maintenant (3 lignes CSS, zéro risque). Ne pas anticiper les paliers 2 et 3 — les déclencher uniquement sur mesure de performance réelle. Le SVG DOM est la bonne abstraction pour le scope actuel et prévisible (v0.18-v0.22).
Le renderer actuel est bien structuré (addOperatorSVG, removeOperatorSVG, updateSVG — fonctions atomiques) : si une migration Canvas 2D devient nécessaire, l'interface publique du renderer ne change pas, seule l'implémentation interne est remplacée.
Caméléon v2 — 06/03/2026
Le POC doit être démontrable immédiatement, sans installation, partageable facilement, et extensible vers un CLI et un desktop natif. Le choix de la stack conditionne la portabilité, la maintenabilité et la capacité à embarquer le moteur dans d'autres contextes.
Le renderer visuel et le moteur POC sont implémentés en HTML/JS/SVG pur, sans framework, dans un fichier unique auto-contenu. Le SVG est conservé comme renderer cible y compris pour la version production — zoom/pan sont implémentés nativement en v0.12 et les performances sont suffisantes pour les graphes attendus.
cvm.js tourne dans le browser, en CLI Deno, et dans TauriLe flux cible est : composer dans le browser → exporter un .cm → exécuter en production de façon autonome, sans browser. Il faut un runtime JS capable de produire un binaire standalone distribuable à des utilisateurs non-développeurs.
Deno compile est retenu pour produire le binaire CLI.
| Critère | Deno | Node + pkg/nexe | QuickJS |
|---|---|---|---|
| Binaire standalone natif | Natif | Bricolage | Oui |
| ES modules natifs | Oui | Partiel | Non |
| Même code que le browser | Oui | Oui | Non |
| Taille binaire | ~90 Mo | ~50 Mo | ~1 Mo |
cameleon run pipeline.cm # usage
deno compile --allow-read main.js # build → binaire `cameleon`
cvm.js tourne dans le browser et dans le CLI — zéro duplicationLa landing page Caméléon mentionne un moteur Go en développement. La question se pose de maintenir deux implémentations du moteur — JS pour le browser et Go/Rust pour la production. Les arguments habituels pour Go/Rust sont la performance et le "sérieux" perçu.
Le moteur reste en JS/Deno jusqu'à ce qu'un problème réel de performance mesuré justifie un portage.
"Make it work, make it right, make it fast" — dans cet ordre.
Caméléon est open source et doit atteindre le maximum d'utilisateurs avec le minimum de friction : développeurs en CI/CD, utilisateurs isolés sur desktop, équipes via le web, intégrateurs via npm. Chaque canal a habituellement sa stack dédiée, ce qui implique plusieurs codebases. La stack HTML/JS change cette équation.
Une seule base de code cvm.js + operators-lib.js + UI HTML/SVG couvre tous les canaux. Le critère est : aucun canal ne doit nécessiter de réécriture du moteur. La séquence de déploiement est progressive, validée canal par canal.
| Phase | Canal | Techno | Cible |
|---|---|---|---|
| 1 | Web | HTML auto-contenu | URL, iframe, intégration plateforme |
| 1 | CLI | Deno compile | Binaire standalone, CI/CD, scripts |
| 2 | PWA | manifest + service worker | Installable sans App Store, offline |
| 3 | Desktop | Tauri (~5 Mo) | Mac / Windows / Linux natif |
| 4 | Mobile | Capacitor | iOS / Android si besoin confirmé |
| ∞ | Package | npm / jsr | Embarquable dans outils tiers JS |
Tauri est retenu pour le desktop plutôt qu'Electron : binaire ~5 Mo vs ~150 Mo, WebView natif du système, UI HTML/SVG inchangée, build multi-OS depuis une seule codebase.
.cm est lisible et générable par tout modèleCaméléon est un projet open source avec un modèle formel fort. Les décisions d'architecture doivent être tracées, datées, justifiées et navigables — aussi bien pour les contributeurs que pour les auteurs eux-mêmes sur le long terme. Il faut définir un format et un cycle de vie communs pour tous les ADR du projet.
Les ADR Caméléon suivent le format et les conventions définis ci-dessous. ADR-000 est la référence normative pour tout nouvel ADR.
Format
Chaque ADR est un fichier Markdown nommé adr-NNN.md (numérotation séquentielle à trois chiffres) stocké dans docs/adr/.
# ADR-NNN — Titre court et explicite
**Thème** : Catégorie (Modèle d'exécution · SDK · Stack technique · Stratégie produit…)
**Statut** : Voir cycle de vie ci-dessous
**Date** : JJ/MM/AAAA
**Auteurs** : Prénom NOM, …
---
## Contexte
La situation qui force une décision. Les options envisagées.
## Décision
Le choix retenu. Les critères clés en gras.
## Conséquences
Ce que ça implique — positif, négatif, points de vigilance.
Cycle de vie des statuts
| Statut | Signification |
|---|---|
| Proposed | Rédigée, en attente de discussion |
| In Review | En cours de revue entre les auteurs |
| Accepted | Décision prise — fait loi pour le projet |
| Rejected | Explicitement rejetée — conservée pour tracer le raisonnement |
| Deprecated | Toujours valide mais plus recommandée |
| Superseded by ADR-NNN | Remplacée — référence obligatoire vers le successeur |
Règles
adr-NNN.md (source) et intégré dans docs/adr/cameleon-adr.html (navigation)Superseded by ADR-NNN avec référence explicite permet de suivre l'évolution d'une décision dans le tempsLes opérateurs Caméléon traitent des données de nature et de volume très variables : texte court, prompt, réponse LLM, fichier CSV léger d'un côté — fichiers volumineux, embeddings, inférence GPU, appels API externes de l'autre. Ces deux catégories ne peuvent pas être traitées de la même façon par le runtime CVM. Il faut décider comment distinguer les opérateurs qui s'exécutent localement dans le runtime de ceux qui délèguent à un service externe, et comment le SDK impose cette distinction de façon explicite et contrôlée.
Chaque opérateur déclare obligatoirement son executionMode : "local" ou "remote". Le runtime CVM adapte son comportement en fonction de cette déclaration. Le mode n'est jamais inféré — il est toujours explicite dans operators-lib.js.
Critères de classification :
| Facteur | Local | Remote |
|---|---|---|
| Volume de données | Petit — texte, prompt, JSON, CSV léger | Grand — fichiers, embeddings, images |
| Latence acceptable | Faible — traitement synchrone | Haute — I/O réseau toléré |
| Dépendance externe | Aucune — pur JS dans le runtime | API, GPU, service tiers |
Signature SDK :
// Opérateur local — s'exécute dans le runtime CVM
const FormatResponse = {
id: "format_response",
executionMode: "local", // obligatoire
family: "transform",
inputs: [{ id: "in_text", type: "DataString" }],
outputs: [{ id: "out_text", type: "DataString" }],
run(inputs) { return { out_text: format(inputs.in_text) }; },
rollback(outputs) { /* inverse */ }
}
// Opérateur remote — wrapper d'appel externe
const GPT4Call = {
id: "gpt4_call",
executionMode: "remote", // obligatoire
remote: {
type: "http",
endpoint: "https://api.openai.com/v1/chat/completions",
timeoutMs: 30000 // obligatoire pour remote
},
family: "transform",
inputs: [{ id: "in_context", type: "DataString" },
{ id: "in_prompt", type: "DataString" }],
outputs: [{ id: "out_response", type: "DataString" }],
run(inputs) { /* wrapper appel HTTP */ },
rollback(outputs) { /* annulation si applicable */ }
}
Règles imposées par le SDK :
executionMode est obligatoire — un opérateur sans cette propriété est invalideremote nécessite timeoutMs obligatoire — pas de remote sans timeout déclarérollback() est obligatoire pour les deux modes — le moteur doit pouvoir reculer dans tous les casCANCEL (WCP-19/20/25) est propagé automatiquement par le runtime aux opérateurs remote en cours d'exécutionremote participent naturellement au pattern Discriminator (WCP-9) — le runtime peut annuler les appels en cours dès qu'un résultat est obtenuexecutionMode à l'importremote ouvre la voie au mode d'exécution concurrent (ADR-011) — plusieurs appels LLM en parallèlerollback(), gérer les erreurs réseau dans run()local ne doit jamais faire d'I/O bloquant — c'est une règle SDK, pas technique (le moteur ne peut pas l'enforcer automatiquement)Caméléon est open source depuis 2012. La v2 doit choisir une licence qui maximise l'adoption tout en préservant les intérêts du projet. Deux options sont en discussion :
L'option open-core (MIT + licence commerciale pour des fonctionnalités avancées) a été écartée — elle est contraire à l'ADN du projet et crée une friction pour les contributeurs.
Non tranchée — en cours de discussion entre les auteurs.
ADR-023 fournit le mécanisme technique de la bijection (loadComposition, serializeComposition). Il reste à décider comment la bijection se comporte du point de vue utilisateur : quand se déclenche-t-elle, comment signale-t-elle son état, comment gère-t-elle les conflits d'édition. Ces décisions impactent directement la perception de fiabilité du produit — une bijection qu'on ne comprend pas est une bijection dont on ne fait pas confiance.
La bijection a trois états, chacun avec un signal visuel distinct. L'indicateur ⇄ dans le header du mode Split est le point focal.
| État | Condition | Signal |
|---|---|---|
| Synced | Les deux vues sont cohérentes | ⇄ neutre — rien d'autre |
| Code pending | Texte modifié, canvas en attente | ⇄ amber + canvas légèrement désaturé |
| Updating | Canvas modifié, code en cours de mise à jour | ⇄ pulse bref |
La synchro ne se déclenche pas à chaque frappe — un .cm.js en cours d'écriture est structurellement invalide.
| Déclencheur | Sens | Mode |
|---|---|---|
Ctrl+Enter | texte → visuel | Explicite, toujours disponible |
| 800ms d'inactivité dans l'éditeur | texte → visuel | Debounce automatique |
| Déplacement / modification sur le canvas | visuel → texte | Temps réel, sans délai |
La synchro visuel → texte est toujours temps réel — elle ne génère jamais d'état invalide.
Si du texte non appliqué existe au moment où l'utilisateur agit sur le canvas, une micro-alerte inline non bloquante apparaît sous le header du canvas :
"Code changes will be discarded — apply first?"
[ Apply & continue ]·[ Discard ]
Quand un opérateur créé côté code n'a pas de coordonnées x/y dans le layout, le renderer le place séquentiellement — à une position de départ fixe, décalée par index d'apparition dans la déclaration.
position(i) = { x: 100 + i * 180, y: 100 }
Le recouvrement avec les opérateurs existants n'est pas garanti évité — c'est une limite connue et acceptée de v0.17. L'utilisateur repositionne à la main si nécessaire. Promettre la détection de collision impliquerait que le placement séquentiel connaît le bounding box de tous les opérateurs placés — complexité injustifiée à ce stade.
Limite connue et acceptée : le placement séquentiel ignore la topologie. L'utilisateur repositionne à la main si besoin. Un layout automatique basé sur la topologie (hiérarchique, sources → sinks) est prévu en backlog.
⇄ est visible uniquement en mode Split et Code — pas en mode Visual seul.cm.js invalide en cours d'écritureCtrl+Enter est documenté dans la charte des raccourcis clavier (guide utilisateur section 7)Caméléon v2 — 04/03/2026
La bijection texte ↔ visuel (ADR-024) ouvre un flux d'usage où l'utilisateur crée et édite sa composition entièrement depuis le mode Code, sans jamais passer par le canvas visuel. La décision PO est explicite : un utilisateur peut ouvrir Caméléon, écrire son .cm.js en mode Code, et lancer l'exécution sans jamais passer en mode Visual.
Ce flux est particulièrement pertinent pour les utilisateurs LLM-assisted qui génèrent des compositions programmatiquement. Il impose de vérifier que la CVM peut être initialisée depuis loadComposition(text) sans dépendance au renderer.
Le mode Code-only est un flux d'exécution complet et autonome. La CVM doit être initialisable depuis une composition chargée via loadComposition(text) sans que le renderer soit actif.
Mode Code-only :
loadComposition(text) → composition
cvm.init(composition) → history, état initial
cvm.forward() → exécution
↓
Feedback : panneau bas (Connexions, Opérateurs, CVM Log)
— alimenté par la CVM directement, pas par le renderer SVG
Le renderer est optionnel dans ce flux — la CVM ne dépend pas d'un état SVG pour démarrer ou exécuter.
⇄ signale que la composition en mémoire est bien celle du code affiché — fonctionnel, pas cosmétiqueLe panneau bas doit être alimenté par la CVM directement (état en mémoire), pas par le renderer (état SVG). Si le panneau lit le renderer, il faut découpler avant que le mode Code-only soit viable.
Vérifié par Axel : createCVM(ops, conns) est appelable indépendamment du renderer. Le panneau bas (updateSidebar(), ligne 505 de renderer.js) lit l'état CVM en mémoire (cvm.currentState(), cvm.canFire()), pas l'état SVG. La condition de viabilité est remplie.
.cm.js programmatiquement peuvent exécuter sans ouvrir le canvas — cas d'usage CLI et intégration externe couvertdoForward, doBackward, etc.) conditionnent l'appel à updateSVG() à la présence du canvas — if (canvas) updateSVG(). Cette garde est la seule modification requise pour que le mode Code-only soit opérationnel. Elle est implémentée dans editor/renderer.jsCaméléon v2 — 04/03/2026
Caméléon v2 est un moteur d'orchestration open source. Le choix de licence conditionne l'adoption, les contributions, et les routes de monétisation futures.
Trois options ont été considérées :
L'objectif prioritaire est la maximisation de l'adoption — notamment par des acteurs commerciaux susceptibles d'embarquer le moteur dans leurs produits.
Le projet Caméléon v2 est publié sous licence MIT.
Critères déterminants :
Pourquoi Apache 2.0 a été écarté Apache 2.0 offre une clause de protection de brevets que MIT n'a pas — pertinente pour un projet avec un paper académique. Écarté pour deux raisons : (1) le risque de dépôt de brevet sur une technique dérivée est faible pour un projet de cette taille et avec 14 ans de prior art public ; (2) MIT est perçu plus favorablement par les développeurs individuels — signal d'ouverture maximal.
Ce que MIT implique explicitement Un tiers peut prendre le moteur, le modifier, l'embarquer dans un produit commercial et le revendre — sans demander d'autorisation, sans payer de redevance, sans contribuer en retour. La seule obligation : conserver le copyright dans les crédits.
Positif - Adoption sans friction pour les contributeurs individuels et les acteurs commerciaux - Signal fort d'ouverture — "on veut que tu l'utilises, on ne va pas te piéger" - Compatible avec toutes les stacks et tous les environnements d'entreprise - Continuité avec la licence MIT de la v1 (2012) — cohérence historique
Négatif - Aucune obligation de contribution en retour pour les acteurs commerciaux - Un tiers peut porter le moteur (Go, Python, Rust) et le distribuer sous licence commerciale sans obligation envers le projet original au-delà du copyright - Impossible de revenir sur une licence plus restrictive une fois la base de code publique et adoptée — décision irréversible en pratique
Points de vigilance
good first issue sur le repo GitHub — autonomes, périmètre limité, sans dépendance| Action | Priorité | Qui |
|---|---|---|
| Passer ADR-018 en Rejected (renvoi → ADR-026) | Avant Accepted | PO |
Créer le fichier LICENSE (MIT, nominatif) | Avant première release | Axel |
| Mettre à jour README avec la licence | Après Accepted | Axel |
Préparer 3-5 issues good first issue | Avant J0 launch | PO + Nora |
| Réévaluer Apache 2.0 vs MIT | Avant première release | PO + Léa |
| Soumettre le paper à une venue à comité | Priorité haute — hors ADR | PO + Léa |
| Persona | Statut | Date |
|---|---|---|
| Mira — Architecte | ✅ Favorable | 04/03/2026 |
| Léa — Recherche | ✅ Approuvé avec réserves intégrées | 04/03/2026 |
| Nora — Product Design | ✅ Validé | 04/03/2026 |
| Axel — Développeur | ✅ Favorable | 04/03/2026 |
| O. Cugnon de Sévricourt — PO | ✅ Accepté | 04/03/2026 |
Décision finale — Accepté par le PO le 04/03/2026
ADR-019 déclare un champ type sur chaque plug (DataString, DataJSON, Binary, DataBool…) mais ne spécifie pas : - Si les types sont vérifiés à la connexion (design-time) ou à l'exécution (runtime) - Ce qui se passe quand on connecte deux plugs de types différents - Si le système de types est ouvert (extensible par l'utilisateur) ou fermé
Le typage des plugs est un point d'architecture critique : trop strict, il bloque la composition ; trop lâche, il reporte les erreurs à l'exécution où elles sont plus coûteuses.
Vérification à la connexion (design-time), pas à l'exécution.
Quand l'utilisateur connecte un output à un input (glisser-déposer ou écriture .cm.js), le système vérifie la compatibilité des types. Une connexion invalide est refusée dans l'éditeur visuel et signalée comme erreur dans le mode Code.
Règles de compatibilité
| Output type | Input type | Compatible | Raison |
|---|---|---|---|
T | T | Oui | Types identiques |
T | Any | Oui | Any accepte tout — plug générique |
Any | T | Oui | L'output générique est accepté — la vérification de valeur est déléguée à run() |
T | U (T ≠ U) | Non | Types incompatibles |
Type Any
Le type Any est le seul type générique du système. Il est réservé aux opérateurs qui manipulent des données sans en connaître la structure (log, debug, passthrough, routage). Un opérateur bien conçu n'utilise Any que sur les plugs dont le type est réellement indifférent.
Types de base
| Type | Description | Exemple d'usage |
|---|---|---|
DataString | Chaîne de caractères | Texte, prompt, réponse LLM |
DataJSON | Objet JSON sérialisable | Données structurées, config |
DataBool | Booléen | Signal de contrôle, condition |
Binary | Données binaires (ArrayBuffer) | Fichier, image, PDF |
Any | Accepte tout type | Debug, log, passthrough |
Système ouvert
Les types ne sont pas un enum fermé. Un opérateur peut déclarer un type custom (ex : ImagePNG, CSVTable, LLMResponse). Les règles de compatibilité restent les mêmes : identité stricte ou Any.
Pas de sous-typage
Il n'y a pas de hiérarchie de types (ImagePNG n'est pas un sous-type de Binary). Si un opérateur produit un ImagePNG et le suivant attend un Binary, la connexion est refusée. L'opérateur intermédiaire (Converter) fait la transformation explicitement.
Cette contrainte est volontaire : le sous-typage introduit de l'implicite dans le graphe, ce qui contredit le principe de lisibilité formelle du réseau de Petri.
Source de vérité unique — isCompatible()
La logique de compatibilité est extraite dans une fonction unique, partagée par le renderer (à la connexion visuelle) et le runtime loader (au chargement du .cm.js) :
// engine/types.js
export function isCompatible(outputType, inputType) {
if (outputType === inputType) return true;
if (inputType === 'Any') return true;
if (outputType === 'Any') return true;
return false;
}
Le renderer et le runtime loader importent isCompatible — une seule implémentation, deux consommateurs. Toute évolution des règles se fait en un seul endroit.
Exception runtime : règle Any → T
La règle Any → T est la seule exception au principe design-time. Elle introduit un risque d'erreur runtime localisé aux opérateurs produisant Any. Ce risque est acceptable et documenté — c'est le prix du type générique.
Types custom — identité par nom
Les types custom sont compatibles par identité de nom (string equality). Si deux opérateurs provenant de libraries différentes déclarent un type ImagePNG, ils sont compatibles. La responsabilité de la cohérence sémantique repose sur le développeur de la library — le moteur ne vérifie que l'identité du nom.
Registre extensible — registerType() (amendement 05/03/2026)
engine/types.js expose un registre de types (TYPE_REGISTRY) pré-rempli avec les types standard, et une fonction registerType() permettant aux libraries ODK d'enregistrer des types custom au runtime sans modifier types.js :
// engine/types.js
const TYPE_REGISTRY = {
DataString: { color: "#4f8aff", label: "String" },
DataJSON: { color: "#00e5a0", label: "JSON" },
DataBool: { color: "#f4845f", label: "Bool" },
Binary: { color: "#ffb547", label: "Binary" },
Any: { color: "#8899aa", label: "Any" },
};
const DEFAULT_TYPE_COLOR = "#8899aa";
export function registerType(id, { color, label }) {
TYPE_REGISTRY[id] = { color, label };
}
export function typeColor(t) {
return (TYPE_REGISTRY[t] || {}).color || DEFAULT_TYPE_COLOR;
}
Usage depuis une library ODK :
// my-image-lib.js
import { registerType } from '../engine/types.js';
registerType("ImagePNG", { color: "#e040fb", label: "PNG Image" });
registerType("ImageJPEG", { color: "#e040fb", label: "JPEG Image" });
Le registre est un objet mutable — registerType() ajoute une entrée au runtime. Les types non enregistrés restent fonctionnels (compatibilité par identité de nom dans isCompatible), mais sans couleur dédiée dans l'UI (fallback DEFAULT_TYPE_COLOR).
L'enregistrement est optionnel : un opérateur peut déclarer un type custom sur ses plugs sans appeler registerType(). Le type sera compatible par identité de nom (règle existante), simplement affiché avec la couleur par défaut dans le renderer.
Impact formel
Le typage des plugs introduit une fonction de typage τ : D → T dans la structure formelle du réseau. La structure étendue est (D, τ, Op, I, O). La compatibilité des types est une contrainte structurelle sur le graphe (vérifiée au design-time), pas une règle d'exécution. Cette extension sera documentée dans le paper v2.
loadComposition) valide les connexions du .cm.js au chargement — les erreurs de type sont détectées avant l'exécutionTYPES) est indexée par type — les types custom utilisent une couleur par défaut (#8899aa)engine/types.js — source de vérité unique pour le renderer et le runtime loaderAny est un opt-out explicite — son usage excessif est un signal de design smell dans un opérateurCaméléon v2 — 04/03/2026
Caméléon permet d'écrire des opérateurs custom en JS (opérateur JS inline, v0.19) et de charger des compositions .cm.js contenant du code arbitraire via le runtime loader (ADR-023). Le loader utilise new Function pour évaluer le code — ce qui est fonctionnel mais exécute le code dans le contexte global du browser.
La question : quel niveau d'isolation appliquer aux opérateurs custom, et à quel coût pour l'expérience développeur ?
Deux niveaux de confiance, pas de sandbox universelle.
| Niveau | Source | Isolation | Exemple |
|---|---|---|---|
| Trusted | operators-lib.js (opérateurs livrés avec le produit) | Aucune — accès complet au contexte | Source, Merge, Gate, GPT4Call |
| Userland | Opérateurs écrits par l'utilisateur (JS inline, .cm.js importé) | Restreinte — API contrôlée | Custom converter, script de traitement |
Isolation Userland
Les opérateurs userland s'exécutent dans un contexte restreint :
run(inputs, { signal, fetch, log }) — l'opérateur reçoit un jeu d'API contrôlé, pas l'accès au window globalfetch est un wrapper qui applique les politiques de sécurité (CORS, domaines autorisés configurables)log est un canal structuré vers le CVM Log — pas console.logMécanisme d'isolation
L'isolation repose sur le contexte d'exécution passé à new Function, pas sur un Worker ou un iframe :
{ inputs, signal, fetch, log }document, window, eval, Function) ne sont pas injectés dans le scopeCe qui est explicitement hors périmètre
run(inputs, { signal, fetch, log })--allow-net, --allow-read) — l'isolation est plus forte sans effort supplémentairenew Function nécessite 'unsafe-eval' dans la CSP du browser — incompatible avec les politiques CSP strictes (PWA, extensions)run() — le changement est dans le runner, pas dans l'opérateurCaméléon v2 — 04/03/2026
ADR-019 spécifie le contrat d'un opérateur individuel. ADR-021 spécifie le format d'une composition .cm.js et son import d'opérateurs. Aujourd'hui, tous les opérateurs vivent dans un seul fichier operators-lib.js — qui mélange structurels, transformants, connecteurs et domaines métier.
Ce modèle ne passe pas à l'échelle : - Un développeur tiers qui veut créer une intégration Twitter n'a pas vocation à modifier operators-lib.js - Un utilisateur qui n'a besoin que d'opérateurs string ne veut pas charger les connecteurs API - Le catalogue UI doit pouvoir lister les opérateurs de toutes les libraries chargées, pas d'une seule
Le projet a besoin d'un framework de libraries — une convention de packaging, de namespacing et de découverte qui permette à n'importe qui de créer, distribuer et consommer une library d'opérateurs.
Une library d'opérateurs est un fichier JS unique qui exporte des opérateurs conformes à ADR-019 en named exports. La convention de nommage distingue les libraries standard des libraries tierces :
| Préfixe | Source | Exemple |
|---|---|---|
std- | Standard library livrée avec Caméléon | std-string-lib.js, std-struct-lib.js |
cst- | Library tierce (custom) | cst-twitter-lib.js, cst-salesforce-lib.js |
Le suffixe -lib.js est obligatoire — il permet au runtime loader et au catalogue UI de distinguer une library d'un fichier JS quelconque.
L'actuel operators-lib.js est découpé en libraries thématiques :
| Library | Domaine | Exemples d'opérateurs |
|---|---|---|
std-struct-lib.js | Contrôle de flux (structurels) | Gate, Merge, Fork, Join, Loop |
std-string-lib.js | Manipulation de chaînes | Concat, Split, Replace, Template |
std-data-lib.js | Données structurées | JSONParse, JSONStringify, Filter, Map |
std-io-lib.js | Entrées/sorties | Source, Sink, Input, FileLoader |
std-connect-lib.js | Connectivité externe | APICall, SendMail, WebSocket |
std-ai-lib.js | Appels LLM | CallOpenAI, CallClaude, CallMistral |
Ce découpage n'est pas une décision figée — les libraries standard évoluent avec le catalogue. La convention de nommage, elle, est stable.
Chaque library expose un objet meta en named export, décrivant la library :
// std-string-lib.js
export const meta = {
name: "std-string",
label: "String operations",
version: "0.15.0",
author: "shinoe",
description: "Standard string manipulation operators",
domain: "String",
};
export const Concat = { /* ADR-019 */ };
export const Split = { /* ADR-019 */ };
Le champ domain alimente le premier niveau de l'arborescence du catalogue UI. Le champ category de chaque opérateur (ADR-019) fournit le second niveau.
Toutes les libraries vivent dans le dossier engine/libs/. L'éditeur et le catalogue UI scannent automatiquement ce dossier au démarrage — tout fichier *-lib.js trouvé est chargé.
| Contenu | Chemin |
|---|---|
| Libraries standard | engine/libs/std-*-lib.js |
| Libraries tierces | engine/libs/cst-*-lib.js |
Pas de fichier de configuration — la convention de dossier est le mécanisme de découverte. Pour ajouter une library tierce, il suffit de déposer le fichier cst-xxx-lib.js dans engine/libs/.
Les libraries standard (std-*) sont toutes chargées par défaut. Un utilisateur qui ne veut pas d'une library standard peut la retirer du dossier.
Une composition importe ses opérateurs depuis les libraries dont elle a besoin :
import { Gate, Merge } from '../engine/libs/std-struct-lib.js';
import { Concat } from '../engine/libs/std-string-lib.js';
import { Tweet } from '../engine/libs/cst-twitter-lib.js';
Le runtime loader (ADR-023) résout les imports. L'import JS standard est le mécanisme de distribution — pas de registre centralisé.
Un développeur tiers crée une library en respectant : 1. Préfixe cst- + suffixe -lib.js 2. Export meta avec les champs requis 3. Chaque opérateur conforme à ADR-019 (id, label, family, inputs, outputs, run) 4. Un fichier .cm.js d'exemple qui démontre l'usage 5. Déposer le fichier dans engine/libs/ pour qu'il soit découvert par l'éditeur
operators-lib.js actuel est découpé en std-*-lib.js — migration progressive, pas big-bangdomain de la library + la category de l'opérateur structurent l'arborescenceodk/README.md) documente comment créer une library tierce, pas seulement un opérateur isoléodk/templates/cst-example-lib.js)engine/libs/) — zéro configurationstd-* (trusted) — elle est importée explicitement par le développeur, pas injectée dynamiquement. Le sandboxing (ADR-029) ne s'applique qu'au JS inline en GUICaméléon v2 — 05/03/2026
Le format de composition Caméléon (.cm.js) est aujourd'hui traité comme un fichier de configuration déclaratif — des paramètres statiques décrivant des nœuds et des connexions. Cette approche limite l'expressivité : elle ne permet pas d'instancier des opérateurs via des classes JS, de passer des fonctions de transformation inline, ou d'utiliser des attributs d'objet dans la définition d'un opérateur.
L'objectif du DSL Caméléon est de permettre une écriture réellement JS tout en garantissant que le moteur CVM peut toujours construire le graphe de façon déterministe et que la bijection topologique code ↔ graphe est préservée.
Le .cm.js n'est pas du JS arbitraire. C'est un sous-langage de JS contraint aux constructions de composition topologique. Tout ce qui relève du comportement des opérateurs reste du micro-programming JS libre, hors du langage Caméléon au sens strict.
Le DSL Caméléon repose sur une séparation stricte entre deux niveaux :
| Niveau | Nom | Langage | Rôle |
|---|---|---|---|
| Macro | Composition | DSL Caméléon (sous-ensemble JS) | Topologie du graphe — nœuds, connexions, types |
| Micro | Comportement | JS libre | Logique interne de chaque opérateur (run()) |
La bijection topologique s'applique au niveau macro uniquement. Le niveau micro est une boîte noire pour le moteur CVM et pour le renderer graphique.
Le niveau macro autorise exclusivement les constructions suivantes :
// 1. Import d'opérateurs depuis une library
import { JSONParse, Format } from './std-data-lib.js'
import { Fork, Merge } from './std-struct-lib.js'
// 2. Instanciation d'opérateurs (stdlib ou inline)
const myOp = new Operator({
id: 'my_transform',
in: { data: DataString },
out: { result: DataJSON },
run: (inputs) => ({ result: JSON.parse(inputs.data.trim()) })
})
// 3. Configuration statique
const config = { threshold: 0.8, separator: ' | ' }
// 4. Ajout au graphe
composition.add(source, myOp, sink)
// 5. Connexion entre opérateurs
composition.connect(source.out.data, myOp.in.data)
composition.connect(myOp.out.result, sink.in.data)
Le contrôle de flux et la mutation d'état appartiennent au graphe, pas au code de composition. Les constructions suivantes sont invalides au niveau macro et doivent être rejetées avec un message d'erreur explicite :
// ❌ Boucle au niveau composition
// → utiliser Merge + loop-back dans le graphe
for (let i = 0; i < 3; i++) {
composition.add(new Process())
}
// ❌ Condition au niveau composition
// → utiliser Gate (WCP-4) dans le graphe
if (config.mode === 'fast') {
composition.connect(a.out, b.in)
}
// ❌ Appel direct entre opérateurs — court-circuite le moteur CVM
const result = opA.run(opB.result)
// ❌ Mutation d'état entre opérateurs — effets de bord invisibles au graphe
opA.state = opB.output
Chaque construction du DSL correspond à un élément visuel précis dans le renderer graphique. La bijection est topologique — structurelle, pas comportementale.
| Concept DSL | Concept graphique | Note | ||
|---|---|---|---|---|
new Operator({ id, in, out }) | Nœud du graphe | id = label, in/out = plugs | ||
composition.add(op) | Apparition du nœud dans le canvas | — | ||
composition.connect(a.out.x, b.in.y) | Arc orienté entre deux plugs | Couleur = type (ADR-028) | ||
in: { data: DataString } | Plug d'entrée typé | Côté gauche du nœud | ||
out: { result: DataJSON } | Plug de sortie typé | Côté droit du nœud | ||
| `policy: 'ANY' \ | 'ALL' \ | 'COND'` | Indicateur de firing policy sur le nœud | ADR-019 |
run: (inputs) => { ... } | Boîte noire — non représenté | Micro-niveau JS libre | ||
import { Fork } from '...' | Opérateur de la stdlib dans le catalogue | ADR-031 | ||
new Operator({ ... }) inline | Opérateur custom — nœud avec icône distincte | Opérateur non-catalogue | ||
config statique | Panneau de configuration du nœud (menu contextuel) | ADR-020 | ||
composition.sub('Label') | Nœud de sous-composition sur le canvas | ADR-038 | ||
rag.add(op) | Opérateur interne à la sous-composition | ADR-038 | ||
rag.in(op.in.plug) | Bridge entrant — plug exposé sur l'orbite | ADR-038 | ||
rag.out(op.out.plug) | Bridge sortant — plug exposé sur l'orbite | ADR-038 |
L'opérateur inline est le cas où les deux niveaux (macro et micro) coexistent dans le même fichier. Le moteur CVM ne voit que les plugs et la firing policy — le contenu de run() lui est opaque.
// Macro — visible dans le graphe
const embedder = new Operator({
id: 'text_embedder',
in: { text: DataString },
out: { vector: DataJSON },
policy: 'ANY',
// Micro — boîte noire pour le graphe
run: async (inputs, { signal }) => {
const response = await fetch('/embed', {
method: 'POST',
body: JSON.stringify({ text: inputs.text }),
signal
})
return { vector: await response.json() }
}
})
Le graphe affiche text_embedder avec un plug text : DataString en entrée et vector : DataJSON en sortie — et rien d'autre. La logique fetch est invisible au renderer.
Une sous-composition est déclarée via composition.sub() — même style que composition.add(). Les opérateurs internes sont ajoutés et connectés sur l'objet retourné. Les connexions qui franchissent la frontière créent des bridges automatiquement via rag.in() et rag.out().
import { Source, Sink } from './std-struct-lib.js'
import { FileLoader, JSONParse } from './std-io-lib.js'
import { Embed, CallClaude } from './std-ai-lib.js'
// Déclaration des opérateurs
const source = new Operator({ id: 'source', in: {}, out: { data: DataString } })
const loader = new Operator({ id: 'loader', in: { path: DataString }, out: { file: DataString } })
const parse = new Operator({ id: 'parse', in: { data: DataString }, out: { data: DataJSON } })
const embed = new Operator({ id: 'embed', in: { text: DataJSON }, out: { vector: DataJSON } })
const agent = new Operator({ id: 'agent', in: { context: DataJSON }, out: { response: DataString } })
const output = new Operator({ id: 'output', in: { data: DataString }, out: {} })
// Sous-composition
const rag = composition.sub('RAG Module')
// Opérateurs et connexions internes
rag.add(loader, parse, embed)
rag.connect(loader.out.file, parse.in.data)
rag.connect(parse.out.data, embed.in.text)
// Niveau parent
composition.add(source, agent, output)
// Connexions franchissant la frontière → bridges automatiques
composition.connect(source.out.data, rag.in(loader.in.path))
composition.connect(rag.out(embed.out.vector), agent.in.context)
composition.connect(agent.out.response, output.in.data)
rag.in(op.in.plug) — expose un plug d'entrée interne comme bridge entrant sur le nœud de sous-composition.
rag.out(op.out.plug) — expose un plug de sortie interne comme bridge sortant sur le nœud de sous-composition.
Les bridges sont nommés automatiquement depuis le label du plug interne (path, vector). En cas de collision, un index est ajouté (data_2).
Mapping DSL ↔ graphique :
| Construction DSL | Concept graphique |
|---|---|
composition.sub('RAG Module') | Nœud de sous-composition sur le canvas parent |
rag.add(op) | Opérateur visible à l'intérieur de la sous-composition |
rag.connect(a.out.x, b.in.y) | Arc interne — visible en entrant dans la sous-composition |
rag.in(op.in.plug) | Plug d'entrée exposé sur l'orbite du nœud |
rag.out(op.out.plug) | Plug de sortie exposé sur l'orbite du nœud |
composition.connect(x, rag.in(...)) | Arc parent → bridge — visible aux deux niveaux |
Patterns — réutilisation :
// Sauvegarder une sous-composition comme pattern
composition.savePattern(rag, 'rag-preprocessing')
// Instancier depuis un pattern (snapshot — pas de live link)
const rag2 = composition.fromPattern('rag-preprocessing')
composition.add(rag2)
composition.connect(source.out.data, rag2.in(loader.in.path))
composition.connect(rag2.out(embed.out.vector), agent.in.context)
Compilation vers le format plat :
composition.sub(), rag.add(), rag.connect(), rag.in(), rag.out() sont du sucre syntaxique. À la compilation, ils produisent le format plat d'ADR-021 + ADR-038 : - operators : liste plate de tous les opérateurs, tous niveaux confondus - connections : liste plate de toutes les connexions, bridges inclus - subCompositions : métadonnée de rendu uniquement — invisible au CVM
La bijection topologique est préservée : le round-trip DSL → format plat → graphe ne perd aucune information de topologie.
**Tout ce qui relève du flux de données et du contrôle de flux appartient au graphe** — exprimé via
connect()et les opérateurs structuraux (Gate, Merge, Fork, Join, Sync — ADR-031). Tout ce qui relève du comportement d'un opérateur appartient aurun()— JS libre, invisible au graphe, hors du langage Caméléon au sens strict.
.cm.js est un programme JS exécutable, pas un fichier de.cm.js en inspectant les déclarationsconnect() — sans exécuter les run()Caméléon v2 — 05/03/2026
Le modèle CVM actuel est un réseau de Petri coloré fermé — les tokens n'entrent dans le graphe que via des transitions internes (opérateurs). Cette propriété garantit le déterminisme absolu et toutes les garanties formelles documentées dans la note de recherche.
Le besoin d'interactivité temps réel — modifier la valeur d'un plug pendant l'exécution, changer un paramètre dans une sous-branche sans tout recalculer — était l'implémentation initiale de la v1 et est un must-have de la v2.
Ce besoin est incompatible avec le déterminisme absolu des réseaux de Petri fermés. Il nécessite une extension formelle explicite : les réseaux de Petri ouverts (open nets), où l'environnement externe peut déposer des tokens sur des places d'interface à tout moment.
Plutôt que de silencieusement violer le modèle ou de sacrifier l'interactivité, Caméléon introduit une distinction explicite entre deux modes de composition — dérivée de la topologie, pas d'un flag de configuration.
| Composition fermée | Composition ouverte | |
|---|---|---|
| Définition | Aucun opérateur live ou stream dans le graphe | Au moins un opérateur live ou stream |
| Modèle formel | CPN standard (fermé) | Open nets (Lohmann et al., 2007) |
| Déterminisme | Absolu | Réactif — déterministe pour une séquence d'inputs E donnée |
| Garanties WCP | Toutes | Conditionnelles à E |
| Interactivité | Statique (valeurs fixes) | Temps réel (valeurs modifiables pendant le run) |
| Détection | Statique — à l'init du run | Statique — à l'init du run |
| Signal UI | Aucun | Badge ⚡ dans le header + sous le label opérateur |
La nature d'une composition est dérivée de sa topologie.
Les opérateurs temps réel se distinguent par la **sémantique de leur place de sortie** :
| Sémantique | Modèle de la place | Comportement | Opérateur |
|---|---|---|---|
| État | Valeur scalaire — la dernière valeur remplace l'ancienne | last-write-wins | LiveSource |
| Événement | File FIFO — chaque occurrence est une donnée distincte | aucune perte | EventSource (ADR-037) |
LiveSource a une sémantique état — intentionnelle et correcte pour les cas d'usage d'édition temps réel (champ texte, slider, paramètre). La valeur courante est la seule qui compte. Un tweet en revanche est un événement : chaque occurrence est une donnée distincte, aucune ne peut être perdue. Ce cas est couvert par EventSource (ADR-037).
La re-fire policy last-write-wins de LiveSource est donc une propriété sémantique, pas une limitation technique.
LiveSource est un nouvel opérateur de la stdlib. C'est une transition d'environnement à sémantique état — elle fire sur événement utilisateur, indépendamment de l'état des connecteurs.
Contrat formel :
I(LiveSource) = ∅
O(LiveSource) = { d_out }
eop_LIVE(µᵗ) ≡ user_event() ← événement externe, pas de condition sur µᵗ
Update — Re-fire policy : last-write-wins (sémantique état)
µᵗ⁺¹(d_out) = NEW (écrase EMPTY, OLD ou même NEW)
aᵗ⁺¹(d_out) = v_user (valeur courante — la précédente est perdue)
Re-fire policy — écrasement (last-write-wins) : si LiveSource fire alors que d_out est déjà NEW (token en transit), la nouvelle valeur écrase l'ancienne. C'est la sémantique attendue : seule la valeur courante de l'état compte. Un debounce côté control (pas moteur) limite la fréquence si nécessaire.
Différence avec Source et EventSource :
| Source | LiveSource | EventSource | |
|---|---|---|---|
| Fire | Une fois (sorties EMPTY) | À chaque changement utilisateur | À chaque événement externe |
| Sémantique | — | État — last-write-wins | Événement — FIFO, aucune perte |
| Valeur | Config statique | Valeur courante | Prochaine valeur dans la file |
| Composition | Fermée | Ouverte | Ouverte |
| Cas d'usage | Paramètre fixe | Champ texte, slider | Twitter, webhook, queue |
Descripteur SDK :
export const LiveSource = {
id: "live_source",
label: "Live Source",
family: "behavioral",
attributes: ["live"],
interaction: "live",
control: "live_input_control",
description: "Injects the current user input value on every change — state semantics, last-write-wins",
inputs: [],
outputs: [{ id: "out_data", type: "Any", label: "data" }],
run(inputs, { signal }) {
return new Promise((resolve, reject) => {
signal?.addEventListener("abort", () => reject(new Error("cancelled")))
})
}
}
interaction| Valeur | Sémantique | Comportement | Composition |
|---|---|---|---|
"human" | — | Bloque le flux — attend une validation unique | Fermée |
"live" | État | Re-fire sur chaque événement, last-write-wins | Ouverte |
"event" | Événement | Re-fire en dépilant la FIFO — aucune perte | Ouverte |
const isOpen = Object.values(composition.nodes)
.some(op => op.interaction === "live" || op.interaction === "event")
if (isOpen) {
engine.mode = "reactive"
renderer.showLiveBadge()
cvm.log("⚡ Live composition — Execution depends on user input. " +
"Formal guarantees apply to a given input sequence E.")
} else {
engine.mode = "closed"
}
Badge ⚡ dans le header — visible avant le Run :
┌──────────────────────────────────────────────────────────┐
│ Caméléon [Visual] [Code] [Split] ⚡ Live [▶ Run] │
└──────────────────────────────────────────────────────────┘
Tooltip au survol : "Live composition — Execution depends on user input."
Badge ⚡ sous le label opérateur sur le canvas.
Message informatif dans le CVM Log au démarrage du Run :
⚡ Live composition — Execution depends on user input.
Formal guarantees apply to a given input sequence E.
| Opérateur | Zone | Nature |
|---|---|---|
| HumanInput | Contrôleur (quand RUNNING) | Bloquant — formulaire + Valider |
| LiveSource | Contrôleur (dès que dans la composition) | Continu — saisie temps réel |
| EventSource | Contrôleur (dès que dans la composition) | File entrante — indicateur de profondeur |
v0.18 — Cap glissant : historique limité à N entrées (suggestion 50). Les entrées les plus anciennes sont éjectées. Le Bwd/Rwd est dégradé en mode réactif — documenté dans le CVM Log.
Backlog : historique coalescent.
| Opérateur | Interaction | Sémantique | Mode | Différence clé |
|---|---|---|---|---|
| Source | — | — | Fermé | Valeur statique, fire once |
| HumanInput | "human" | — | Fermé | Bloque, attend validation unique |
| LiveSource | "live" | État | Ouvert | Re-fire continu, last-write-wins |
| EventSource | "event" | Événement | Ouvert | FIFO, aucune perte, dépile un event/step |
closed / reactive) — détectés àObject.values(composition.nodes)interaction: "live" et interaction: "event" ajoutés| Sujet | ADR / Backlog |
|---|---|
| Éditeur inline LiveSource sur le canvas | ADR-034 + backlog |
| Historique coalescent | Backlog post-release |
| Compositions mixtes — plusieurs LiveSource / EventSource | Même mode réactif, comportement additif |
| Pause / resume du mode réactif | Backlog post-release |
| Valeur de N pour le cap glissant | À définir avec Axel en implémentation |
| Back-pressure EventSource (file pleine) | ADR-037 |
Caméléon v2 — 05/03/2026 — révisé 06/03/2026 — Amendment 1 : 07/03/2026
ADR-035 introduit LiveSource avec une sémantique état — la valeur courante remplace la précédente (last-write-wins). C'est la sémantique correcte pour un champ texte ou un slider.
Certains cas d'usage requièrent une sémantique événement : chaque occurrence est une donnée distincte qu'on ne peut pas se permettre de perdre. Exemples : listener Twitter/X, webhook entrant, queue de messages, flux de capteurs.
Dans ces cas, last-write-wins est une corruption silencieuse des données. Il faut un opérateur distinct avec un modèle formel différent.
EventSource est un opérateur de la stdlib. C'est une **transition d'environnement à sémantique événement** — chaque événement externe est enfilé dans une file FIFO, le moteur dépile un événement par step. Aucun événement n'est perdu.
Différence fondamentale avec LiveSource :
| LiveSource | EventSource | |
|---|---|---|
Modèle de la place d_out | Valeur scalaire | File FIFO |
| Nouveau fire pendant transit | Écrase la valeur (last-write-wins) | Enfile — traité après |
| Perte possible | Oui — intentionnelle (sémantique état) | Non — aucune perte |
| Cas d'usage | Champ texte, slider, paramètre | Twitter, webhook, queue, capteur |
L'EventSource nécessite une extension mineure du modèle de marquage pour la place de sortie d_out : la valuation aᵗ(d_out) est une file FIFO plutôt qu'une valeur scalaire.
I(EventSource) = ∅
O(EventSource) = { d_out }
Marquage étendu pour d_out :
aᵗ(d_out) : FIFO[Any] ← file ordonnée, potentiellement vide
eop_STREAM(µᵗ) ≡ user_event() ∨ external_event()
← fire sur tout événement externe, indépendamment de µᵗ
Update — enfilage :
queue ← aᵗ(d_out)
queue.enqueue(v_event) ← aucune perte
aᵗ⁺¹(d_out) = queue
µᵗ⁺¹(d_out) = NEW si queue non vide
Consommation par l'opérateur aval (fire) :
v ← aᵗ(d_out).dequeue() ← dépile un événement
µᵗ⁺¹(d_out) = NEW si queue encore non vide, EMPTY sinon
Invariant : µᵗ(d_out) = NEW ⟺ aᵗ(d_out) non vide.
La file est portée par la valuation aᵗ — le marquage µᵗ reste binaire (EMPTY / NEW). L'extension est localisée à la place d_out d'EventSource — elle ne modifie pas le modèle global.
Note académique : les files FIFO sur les places d'interface sont documentées dans les P/T nets avec arcs inhibiteurs et dans les communicating nets (Kindler & Wagner, 1997). L'extension est conservative — elle n'affecte pas les propriétés des places internes.
Quand la file dépasse un seuil configurable max_queue_depth :
| Option | Comportement |
|---|---|
| A — Drop oldest (défaut) | Les événements les plus anciens sont éjectés. Un warning est émis dans le CVM Log avec : nombre d'événements droppés, stratégie appliquée, horodatage du premier et dernier drop, identifiant de l'opérateur. |
| B — Drop newest | Les nouveaux événements sont ignorés quand la file est pleine. Même format de warning. |
| C — Error | La composition passe en état ERROR. |
| La stratégie est configurable par opérateur (`backpressure: "drop_oldest" | "drop_newest" | "error"`). |
|---|---|---|
Défaut : "drop_oldest" — comportement le moins surprenant pour un flux temps réel. |
export const EventSource = {
id: "event_source",
label: "Event Source",
family: "behavioral",
attributes: ["live"], // ouvre la composition comme LiveSource
interaction: "event", // nouveau mode ADR-019
control: "event_source_control",
description: "Queues every incoming event — FIFO, no loss",
inputs: [],
outputs: [{ id: "out_event", type: "Any", label: "event" }],
config: {
max_queue_depth: 100, // défaut
backpressure: "drop_oldest" // défaut
},
run(inputs, { signal }) {
// Transparence aval — le moteur dépile de façon transparente.
// L'opérateur aval reçoit un scalaire dans inputs.in_xxx —
// il ne sait pas que la source est une FIFO. La sémantique FIFO
// est détectée par le moteur via interaction: "event" sur le
// descripteur source. _connData[connId] est une FIFO quand
// interaction: "event", scalaire dans tous les autres cas.
//
// Boucle de drain — orchestrée par le renderer, pas le moteur.
// Après chaque résolution, le renderer vérifie si la file est
// non vide et re-appelle execStep via setTimeout(0) pour céder
// le thread UI entre chaque dépilage. Les événements arrivant
// pendant le drain sont enfilés normalement et traités au tour
// suivant. Garantit qu'aucun événement n'est perdu pendant le drain.
return new Promise((resolve, reject) => {
signal?.addEventListener("abort", () => reject(new Error("cancelled")))
})
}
}
Deux sources de re-fire pour EventSource :
La boucle s'arrête quand la file est vide ET qu'aucun événement n'arrive. L'opérateur passe READY (pas COMPLETED — il attend le prochain événement).
EventSource est affiché dans le Contrôleur avec un indicateur de profondeur de file :
┌─ Event Source ────────────────────┐
│ ⬤ Listening Queue: 3 events │
│ [━━━━━━━━░░░░░░░░░░░░░░] 3/100 │
└───────────────────────────────────┘
max_queue_depthbackpressure déclenche un dropMême règle que LiveSource — cap glissant N entrées (ADR-035). Chaque dépilage crée une entrée dans history[].
| Opérateur | Interaction | Sémantique | Fire | Perte possible | Composition |
|---|---|---|---|---|---|
| Source | — | — | Once | — | Fermée |
| FileLoader | — | — | Once au démarrage | — | Fermée |
| HumanInput | "human" | — | Once par validation | — | Fermée |
| LiveSource | "live" | État | Sur changement | Oui — intentionnelle | Ouverte |
| EventSource | "event" | Événement | Sur événement + drain FIFO | Non | Ouverte |
aᵗ(d_out) est une FIFOinteraction: "event" ajouté"event"live,interaction: "event"LiveSource git de Context KeeperEventSource git — chaque delta de Team| Sujet | Backlog |
|---|---|
| Adapter / transformer les événements avant enfilage | Backlog — EventSource + Processor en séquence suffit |
| EventSource avec source externe (WebSocket, SSE) | Backlog — même modèle, adapter côté control |
| Replay de la file sur Bwd/Rwd | Backlog post-release |
| Drain partiel (dépiler N events par step) | Backlog |
Caméléon v2 — 07/03/2026
Le modèle formel Caméléon v2 comporte jusqu'ici trois concepts de premier rang :
operator → engine/operators-lib.js (+ ODK)
type → engine/types.js (ouvert, extensible)
composition → .cm.js
ADR-016 (viewers et éditeurs) et ADR-020 (configuration opérateur) introduisent des composants UI associés aux plugs et aux opérateurs — viewers de données, éditeurs inline, formulaires de configuration. Ces composants sont traités comme du code renderer ad hoc, sans contrat formel.
ADR-030 (ODK packaging) permet à un développeur tiers de créer des opérateurs et des types custom. Sans contrat formel pour les composants UI, il ne peut pas créer la vue associée à son type ou à son opérateur.
Un quatrième concept de premier rang est nécessaire : le control.
Un control est un composant UI associé à un type de plug ou à un opérateur. Il sait afficher et éditer une valeur, et peut bloquer le flux CVM jusqu'à une interaction humaine.
Le modèle formel devient :
operator → engine/operators-lib.js (+ ODK)
type → engine/types.js (ouvert)
control → engine/controls.js (ouvert, + ODK)
composition → .cm.js
Un control est un objet JS plain (même pattern qu'un opérateur — ADR-019). Il est exporté en named export depuis controls.js ou depuis une library ODK. Il n'a pas de sémantique de tir — il ne participe pas au flux CVM. Il est instancié par le renderer à la demande.
Champs obligatoires
| Champ | Type | Description |
|---|---|---|
id | string | Identifiant unique (snake_case) |
label | string | Nom affiché |
type | string | Type de plug associé (DataString, Binary, Any…) — doit exister dans types.js |
render | Function | Affiche une valeur dans un container DOM — voir contrat ci-dessous |
Champs optionnels
| Champ | Type | Défaut | Description | |||
|---|---|---|---|---|---|---|
onInput | Function | absent | Branche les interactions DOM au callback — onInput(callback, container) — le control fait le wiring lui-même | |||
resize | "none" \ | "horizontal" \ | "vertical" \ | "both" | "none" | Redimensionnabilité du control |
inline | boolean | false | Si true, le control peut être affiché dans le canvas (mode HumanInput) | |||
description | string | "" | Description pour le catalogue ODK |
Contrat render(value, container)
value : la valeur courante du plug (peut être null si EMPTY)container : élément DOM dans lequel le control doit se rendrecontainer, ne retourne rienContrat onInput(callback, container)
callback : fonction appelée par le control quand l'utilisateur produit une valeur — callback(value)container : le même container DOM que celui passé à render() — le control y accède pour brancher ses éléments internesrender(), le renderer ne les inspecte jamaiscallback avant de transitionner (cas HumanInput)Un control est associé à un type de plug via le champ type. Le renderer résout le control à afficher pour une connexion donnée :
connectionId → type de plug → control associé → render(value, container)
Le registre de controls est une map { type: control } dans controls.js. Un type peut n'avoir aucun control (pas d'affichage disponible) ou en avoir un seul (le défaut). Un développeur ODK peut déclarer un control custom pour son type custom.
Un opérateur avec interaction: "human" (ADR-016, Amendement 1) requiert un control avec inline: true. Ce control est la vue graphique de l'opérateur dans le canvas quand il est en état RUNNING.
Opérateur standard → cercle seul dans le canvas
Opérateur + control inline → cercle + vue graphique dans le canvas
HumanInput → opérateur + control inline obligatoire
run() bloque jusqu'à onInput(callback)
Le control inline est déclaré dans le descripteur de l'opérateur via le champ control :
export const HumanInput = {
id: "human_input",
interaction: "human",
control: "text_input_control", // ← id du control associé
family: "transform",
inputs: [{ id: "in_context", type: "Any", label: "context" }],
outputs: [{ id: "out_value", type: "DataString", label: "value" }],
run(inputs, { signal }) {
// Retourne une Promise résolue par onInput du control
return new Promise((resolve, reject) => {
signal?.addEventListener("abort", () => reject(new Error("cancelled")));
// Le renderer appelle resolve via onInput quand l'utilisateur valide
});
}
}
Le renderer, à la réception de l'événement onInput(value) du control, appelle le resolve de la Promise retournée par run().
Un opérateur HumanInput peut être affiché de deux façons selon le champ inline de son control associé. Du point de vue du moteur CVM, les deux cas sont identiques — même contrat ADR-019, même Promise bloquante, même sémantique de tir.
| Cas 1 — Inspecteur (v0.18) | Cas 2 — Inline canvas (backlog) | |
|---|---|---|
control.inline | false | true |
| Rendu RUNNING | Cercle standard + control dans le panneau droit | Control à la place du cercle dans le canvas |
| Connexions visibles | ✅ | ✅ |
| Bijection topologique | ✅ | ✅ |
| Moteur CVM | Identique | Identique |
Le champ inline: true est le seul discriminant — le renderer choisit le mode d'affichage selon ce flag. Le moteur CVM l'ignore complètement.
v0.18 — Cas 1 uniquement (inspecteur). Le Cas 2 (inline canvas) est en backlog post-release.
Le renderer est le seul acteur qui connaît à la fois l'opérateur et le control. Il orchestre le raccordement entre les deux au moment où l'opérateur passe RUNNING.
Séquence d'instanciation d'un HumanInput RUNNING :
1. Moteur : opérateur passe RUNNING → run() retourne une Promise P
2. Renderer détecte RUNNING + interaction: "human"
3. Renderer résout le control : registry[op.control] → control
4. Renderer crée un container DOM (inline canvas si control.inline,
panneau d'inspection sinon)
5. Renderer appelle control.render(currentInputValue, container)
6. Renderer appelle control.onInput((value) => resolveP(value), container)
7. Renderer stocke { container, resolveP, rejectP } pour cleanup ultérieur
Séquence de teardown — COMPLETED :
1. Promise P résolue → moteur : opérateur passe COMPLETED
2. Renderer détecte COMPLETED + interaction: "human"
3. Renderer retire le container du canvas / panneau
4. Renderer libère la référence { container, resolveP, rejectP }
Séquence de teardown — CANCELLED :
1. signal.aborted → run() rejette → moteur : opérateur passe CANCELLED
2. Renderer détecte CANCELLED + interaction: "human"
3. Renderer désactive le control (container.style.pointerEvents = "none")
4. Renderer retire le container dans le même cycle updateSVG()
5. Le callback onInput devient no-op — plus aucun appel à resolveP
Le control n'écoute pas signal directement. Le renderer est le seul responsable du cycle de vie du container et du callback.
// engine/controls.js
export const TextViewerControl = {
id: "text_viewer_control",
label: "Text viewer",
type: "DataString",
description: "Displays a string value",
render(value, container) {
container.textContent = value ?? "—";
}
// Pas de onInput — viewer pur
}
export const TextInputControl = {
id: "text_input_control",
label: "Text input",
type: "DataString",
inline: true,
resize: "vertical",
description: "Text editor — inline or in panel",
render(value, container) {
if (!container._textarea) {
const ta = document.createElement("textarea");
container.appendChild(ta);
container._textarea = ta;
}
container._textarea.value = value ?? "";
},
onInput(callback, container) {
// Le control fait le wiring lui-même — il connaît ses éléments internes
container._textarea.addEventListener("input", () =>
callback(container._textarea.value)
);
}
}
Cas d'usage : un opérateur reçoit un message (DataString) en entrée, l'affiche à l'utilisateur, et attend une validation humaine. Il produit deux sorties — un booléen (DataBool) indiquant si le message est validé, et le message éventuellement modifié (DataString).
Le control associé — MessageReviewControl
// engine/controls.js
export const MessageReviewControl = {
id: "message_review_control",
label: "Message reviewer",
type: "DataString",
inline: true,
resize: "vertical",
description: "Displays a message for human review — produces validated bool + edited message",
render(value, container) {
// Construit le control une seule fois, met à jour la valeur si réappelé
if (!container._built) {
const label = document.createElement("div");
label.textContent = "Message à valider :";
label.style.cssText = "font-size:11px;color:#8899aa;margin-bottom:4px;";
const textarea = document.createElement("textarea");
textarea.style.cssText = "width:100%;min-height:80px;resize:vertical;";
const actions = document.createElement("div");
actions.style.cssText = "display:flex;gap:8px;margin-top:8px;justify-content:flex-end;";
const btnReject = document.createElement("button");
btnReject.textContent = "✗ Rejeter";
btnReject.style.cssText = "padding:4px 12px;background:#f4845f;color:#fff;border:none;border-radius:3px;cursor:pointer;";
const btnValidate = document.createElement("button");
btnValidate.textContent = "✓ Valider";
btnValidate.style.cssText = "padding:4px 12px;background:#00e5a0;color:#000;border:none;border-radius:3px;cursor:pointer;";
actions.append(btnReject, btnValidate);
container.append(label, textarea, actions);
container._textarea = textarea;
container._btnValidate = btnValidate;
container._btnReject = btnReject;
container._built = true;
}
// Mise à jour de la valeur affichée
container._textarea.value = value ?? "";
},
onInput(callback, container) {
// Le control fait le wiring lui-même
container._btnValidate.onclick = () =>
callback({ validated: true, message: container._textarea.value });
container._btnReject.onclick = () =>
callback({ validated: false, message: container._textarea.value });
}
}
L'opérateur associé — MessageReviewer
// engine/operators-lib.js (ou cst-review-lib.js)
export const MessageReviewer = {
id: "message_reviewer",
label: "Message Reviewer",
family: "transform",
category: "Human / Review",
interaction: "human",
control: "message_review_control", // ← id du control ci-dessus
description: "Displays a message and waits for human validation",
inputs: [
{ id: "in_message", type: "DataString", label: "message" },
],
outputs: [
{ id: "out_validated", type: "DataBool", label: "validated" },
{ id: "out_message", type: "DataString", label: "message" },
],
run({ in_message }, { signal }) {
// Retourne une Promise — résolue par onInput du control via le renderer
return new Promise((resolve, reject) => {
signal?.addEventListener("abort", () =>
reject(new Error("cancelled"))
);
// Le renderer appelle resolve({ validated, message })
// quand l'utilisateur clique Valider ou Rejeter
});
},
async rollback({ out_validated, out_message }) {
// Pas d'effet de bord à annuler — la saisie humaine est idempotente
// Le rewind remet l'opérateur en READY, le control est réaffiché
}
}
Flux d'exécution complet
1. Source émet un message → in_message : NEW
2. MessageReviewer : READY → RUNNING
- run() retourne une Promise non résolue
- renderer instancie MessageReviewControl dans le canvas (inline: true)
- control affiche le message dans le textarea
- boutons Valider / Rejeter sont actifs
3. Utilisateur lit le message, l'édite si besoin, clique Valider
- btnValidate.onclick → callback({ validated: true, message: "..." })
- renderer appelle resolve({ validated: true, message: "..." })
- Promise résolue
4. Moteur reçoit les sorties :
{ out_validated: true, out_message: "message édité" }
→ out_validated : NEW, out_message : NEW
→ MessageReviewer : COMPLETED
→ control retiré du canvas
5. Opérateurs suivants tirent sur out_validated et out_message
Usage dans une composition .cm.js
import { Source, Sink } from '../engine/std-io-lib.js';
import { MessageReviewer } from '../engine/operators-lib.js';
export default {
meta: { name: "Message Review Pipeline" },
nodes: {
source: { ...Source, params: { value: "Bonjour, voici le message à valider." } },
reviewer: MessageReviewer,
sink_ok: Sink,
sink_ko: Sink,
},
connections: [
[ "source.out_data", "reviewer.in_message" ],
[ "reviewer.out_message", "sink_ok.in_data" ],
[ "reviewer.out_validated","sink_ko.in_data" ],
],
}
controls.js exporte un objet registry qui mappe chaque type à son control par défaut :
export const registry = {
DataString: TextViewerControl,
DataJSON: JSONTreeControl,
Binary: HexViewerControl,
DataBool: BoolViewerControl,
Any: RawViewerControl,
};
Une library ODK peut étendre le registre via registerControl(type, control) au boot. Les controls standards sont dans engine/controls.js, les controls tiers dans libs/cst-*-lib.js.
cvm.observe(connectionId) fournit la valeur, le control la rend dans le panneauinteraction: "human" a un control inline: true — c'est sa vue graphique RUNNING dans le canvasImageClassifier + ImageViewerControl pour le type ImagePNG — packagés ensemble dans cst-vision-lib.jsRawViewerControl)controls.js (UI), opérateurs dans operators-lib.js (logique), types dans types.js (données) — trois responsabilités, trois fichierscontrol hors topologie CVM : le champ control dans le descripteur d'un opérateur est une métadonnée de rendu — comme interaction ou layout. Le moteur CVM l'ignore à l'exécution. La bijection topologique (ADR-024) ne le couvre pas. Il n'appartient pas au graphe formel (D, τ, Op, I, O)updateSVG(). Le control n'écoute pas signal directement — la responsabilité de désactivation appartient au renderer, pas au control| Sujet | ADR |
|---|---|
| Signal visuel RUNNING distinct pour HumanInput | ADR-016 Amendement 1 |
| État MISCONFIGURED et badge ⚙ | ADR-020 Amendement 1 |
Bijection texte ↔ visuel — synchronisation du control avec le .cm.js | ADR-024 |
| Viewers inline optionnels en mode debug | Backlog post-release |
Caméléon v2 — 05/03/2026
Today, any consumer of the CVM engine (currently renderer.js, soon a CLI) must import 9+ modules individually:
import { createCVM, instantiate } from '../engine/cvm.js';
import { typeColor, TYPE_REGISTRY, isCompatible } from '../engine/libs/std-types.js';
import { registry, editors } from '../engine/libs/std-controls.js';
import { meta as ioMeta, Source, Sink, ... } from '../engine/libs/std-io-lib.js';
import { meta as structMeta, Fork, Join, ... } from '../engine/libs/std-struct-lib.js';
// ... and more
This creates high coupling between consumers and internal module structure. Adding a new library, renaming a module, or restructuring engine/libs/ breaks every consumer. With the CLI arriving (v0.21), this problem doubles — two consumers duplicating the same import boilerplate.
Introduce engine/engine.js as the sole public API of the CVM platform. All consumers (renderer, CLI, future plugins) import only from engine.js.
engine.js is not a barrel re-export — it exposes a high-level API that encapsulates internal module structure:
import { engine } from '../engine/engine.js';
// Execution
const cvm = engine.createCVM(ops, conns);
const cvm = engine.instantiate(composition);
// Catalogue
const catalogue = engine.catalogue(); // → [{ meta, operators }]
const op = engine.operator('Source'); // → operator definition
// Types
const color = engine.typeColor('DataString');
const ok = engine.isCompatible('DataString', 'Any');
const types = engine.types(); // → full registry
// Controls
const viewer = engine.viewerFor('DataString');
const editor = engine.editorFor('DataString');
// Interaction
engine.resolveHuman(result);
engine.resolveLive(value);
The existing modules (cvm.js, std-types.js, std-controls.js, std-*-lib.js) keep their current structure and responsibilities. engine.js composes them — it does not replace them.
operators-lib.js becomes redundantThe current façade (operators-lib.js) is a subset of what engine.js provides. It will be deprecated once all consumers migrate.
engine.js and gets everything it needsengine/engine.js with the high-level APIrenderer.js to import from engine.js onlyengine.jsoperators-lib.jsbuild-doc.py standalone inlining if neededA simple export * from './libs/...' file. Reduces import lines but provides no abstraction — consumers remain coupled to internal naming. Rejected.
Works today with one consumer. Does not scale to CLI + plugins. Rejected for v0.21+.
Cameleon compositions are currently flat — all operators live on a single canvas level. As compositions grow (RAG pipelines, multi-agent workflows), the canvas becomes unreadable and patterns emerge that users want to encapsulate and reuse.
The v1 had sub-compositions but the model was never formalized. This ADR defines the formal model, the engine contract, and the interaction rules for sub-compositions and patterns in v2.
Formal justification for the transparent model: a standard P/T Petri net is defined on a flat set of places and transitions. Introducing an execution hierarchy (nested CVM) would create a hierarchical Petri net — a distinct formal extension with its own liveness and safety properties (Jensen, 1992 — Hierarchical Coloured Petri Nets), not covered by the existing ADRs. The transparent model preserves all current formal invariants without extension. Kindler & Wagner (1997) show that modules for Petri nets are structural groupings that can always be flattened — this is exactly what Cameleon implements.
A sub-composition is a visual grouping mechanism. It is NOT an operator with its own run(). The operators inside fire individually, driven by the same CVM engine as the parent level.
Formally:
SubComposition S = (V_S, E_S, B_S)
V_S = subset of operators from the parent composition
E_S = connections between operators in V_S (internal edges)
B_S = bridges — connections between V_S and the parent level
A sub-composition does not create a new CVM instance. All operators (parent + nested) belong to the same flat Petri net. The nesting is purely a UI/organizational concern.
Consequences: - Firing, history, undo/redo, backward/forward work unchanged - The scrubber traverses all levels transparently — no special handling - fireable() returns operators from all levels - execStep(opId) works on any operator regardless of nesting depth
A bridge is a connection that crosses a sub-composition boundary. It connects an operator inside the sub-composition to an operator outside (or vice versa).
From the CVM perspective, a bridge is a normal connection — same { id, from, to } structure. The "bridge" concept exists only in the UI layer to render exposed plugs on the sub-composition node.
Formal mapping (Kindler & Wagner, 1997): bridges correspond to interface places in K&W modules. A connection crossing a sub-composition boundary is formally an arc incident to an interface place — the firing semantics are unchanged.
| ADR-038 concept | K&W (1997) concept |
|---|---|
| Sub-composition | Module |
| Bridge | Interface place |
| Internal connection | Arc internal to the module |
| Parent → bridge connection | Arc incident to an interface place |
Bridge metadata (UI only):
{
id: "b0",
from: "inner_op.out_data",
to: "outer_op.in_data",
bridge: {
subCompId: "sub1", // which sub-composition this crosses
side: "output", // "input" or "output" from sub-comp perspective
label: "data" // auto-generated from plug label
}
}
Bridge creation: - Route A (merge selection): external connections become bridges automatically - Route B (empty sub-comp): no bridges initially - Route C (drag plug onto sub-comp body): bridge created automatically
Bridge naming: auto-generated from the plug label of the connected operator. Format: {plug_label} (e.g. "data", "message"). If collision, append index: "data_2".
Dynamic bridges: bridges can be added/removed from both inside and outside the sub-composition: - From outside: drag a plug onto the sub-comp body (Route C) - From inside: connect an operator to an exposed plug on the canvas edge
The sub-composition is rendered as a special node on the parent canvas.
| Property | Value |
|---|---|
| Shape | Circle, r=56 |
fill | #1a2640 — solid, no gradient |
stroke | State-dependent (same as operators) |
| Icon | U+25A3 centered |
| Plugs | Bridges rendered as standard plugs on the orbit |
The sub-composition node does NOT have run(), canFire(), or lifecycle states of its own. It is not an operator — it is a container.
Aggregate state indicator: the stroke color reflects the worst state of any operator inside, using a formal priority order:
FAILED > RUNNING > READY > PARTIAL > COMPLETED > IDLE
| Condition | Displayed state |
|---|---|
| Any FAILED | FAILED stroke |
| Any RUNNING (no FAILED) | RUNNING stroke (pulsed) |
| Any READY (no FAILED/RUNNING) | READY stroke |
| Mixed COMPLETED + IDLE (no FAILED/RUNNING/READY) | PARTIAL stroke (COMPLETED at reduced opacity) |
| All COMPLETED | COMPLETED stroke |
| All IDLE | IDLE stroke |
Note: PARTIAL covers the case where some operators completed but others have not yet been reached by a token. This is the correct behavior — the sub-composition has not finished its work.
Clicking a FAILED sub-composition node opens the interior view with the FAILED operator highlighted — standard navigation behavior applied to the error case.
... — standard breadcrumb truncation pattern.#1a2640, stroke-width 180, clippedRight panel on navigation: - Entering a sub-composition resets the right panel to its default state (no selection, no active context). - The right panel always reflects the current level only — operators and plugs from parent levels are not accessible from inside. - Exiting a sub-composition also resets the right panel.
Renderer navigation model — currentLevel:
The renderer maintains a currentLevel variable (sub-composition id, or null for root canvas). This determines which operators and connections are visible:
currentLevel = null): show root-level operators +currentLevel = "sub1"): show operatorsThe filtering logic is a renderer responsibility — the CVM sees the full flat graph at all times.
| Route | Trigger | Result |
|---|---|---|
| A — from selection | Multi-select + right-click → "Merge into sub-composition" | Selected ops encapsulated, external connections become bridges |
| B — empty | Right-click canvas → "New sub-composition" | Empty sub-comp node, no bridges |
| C — drag onto body | Drag plug → drop on sub-comp body | Bridge created automatically |
Route A algorithm:
SS) and external (one end in S)SUnmerge (reverse of Route A):
A pattern is a snapshot of a sub-composition saved for reuse.
Storage: embedded in the .cm.js file under a patterns key.
export const patterns = {
"rag-preprocessing": {
label: "RAG Preprocessing",
description: "File -> Transform -> JSONParse chain",
operators: [ /* ... */ ],
connections: [ /* ... */ ],
bridges: [ /* ... */ ],
}
};
Lifecycle: - Save: right-click sub-comp → "Save as pattern" → snapshot stored - Instantiate: drag from PATTERNS section in catalogue → new sub-comp - No live link: pattern is a copy at save time — modifying the instance does not modify the pattern - Unmerge: works the same as any sub-composition
Catalogue section: - Dedicated PATTERNS section at the bottom of the catalogue panel - Same search integration as operators - Icon: solid circle #1a2640 (same as sub-comp node)
Sub-composition mutations (create, unmerge, add bridge, remove bridge) are compound commands in the existing undo/redo system:
| Action | Compound command |
|---|---|
| Merge into sub-comp | RemoveOps + RemoveConns + AddSubComp + AddBridges |
| Unmerge | RemoveSubComp + RemoveBridges + AddOps + AddConns |
| Add bridge (Route C) | AddConn (bridge metadata) |
Minimal. The CVM operates on a flat list of operators and connections. Sub-compositions are a UI-layer concept.
Required engine changes: - None for firing, execution, history, undo/redo - addOp / removeOp / addConn / removeConn already support dynamic topology — sufficient for merge/unmerge
Renderer changes (significant): - Sub-composition node rendering (addSubCompSVG, removeSubCompSVG) - Navigation stack (enter/exit levels) with currentLevel state - Interior canvas with vignetting + breadcrumb - Bridge rendering (exposed plugs on sub-comp orbit + edge plugs inside) - Catalogue PATTERNS section - Context menu entries - localStorage persistence of navigation state (current level — not sub-composition structure, which lives in .cm.js per ADR-021)
The .cm.js format (ADR-021) is extended:
export const composition = {
operators: [ /* flat list — ALL operators, all levels */ ],
connections: [ /* flat list — ALL connections including bridges */ ],
subCompositions: [
{
id: "sub1",
label: "RAG Module",
operators: ["op_a", "op_b", "op_c"], // refs to operator ids
parent: null, // null = direct child of root canvas (not orphan)
x: 400, y: 300, r: 56, // position on parent canvas
},
{
id: "sub2",
label: "Preprocessing",
operators: ["op_d", "op_e"],
parent: "sub1", // nested in sub1
x: 200, y: 200, r: 56,
},
],
patterns: { /* ... */ },
};
Key design choice: operators and connections are stored flat. The subCompositions array is metadata that tells the renderer which operators to show at which level. The CVM never reads subCompositions.
.cm.js extended with subCompositions and patterns.cm.js — no external files| Subject | Where |
|---|---|
| Pattern versioning / live link | Backlog |
Pattern sharing across .cm.js files | Backlog — requires package format |
Sub-composition as reusable operator (with own run()) | Rejected — transparent model chosen |
| Pattern categories / folders in catalogue | Backlog (v0.24+) |
| Pattern instance "detached" visual indicator | Backlog — no signal when instance diverges from saved pattern (UX debt) |
| Missing operator resolution in patterns | Backlog — lib removed or operator renamed between versions (ADR-030) |
DSL syntax for sub-compositions (composition.sub()) | ADR-033 revision — see review-adr-038-lea.md point 6 |
Cameleon v2 — 08/03/2026 — revised 08/03/2026 (reviews Lea, Nora, Mira)
Le format canonique de composition Caméléon est le .cm.js (ADR-021) — un module ES qui importe des opérateurs depuis le catalogue et exporte un objet `{meta, nodes, connections, layout}`. Ce format est conçu pour l'écriture humaine et la génération LLM.
Mais l'éditeur visuel ne manipule pas des modules ES à l'exécution. Il travaille avec des tableaux d'objets runtime — OPS[], CONNS[], SUB_COMPS[], PATTERNS{} — qui contiennent des instances vivantes (avec configValues, id d'instance, rayons, coordonnées). Le .cm.js ne peut pas représenter cet état runtime directement :
src_1, trn_2)configValues sont des valeurs runtime, pas des paramètres statiquesADR-033 définit un DSL de composition qui résoudra ces questions — mais il est en statut In Review et ne sera implémenté qu'en v0.26. En attendant, l'éditeur a besoin d'un format de sauvegarde/chargement dès v0.20 pour être utilisable comme outil pro quotidien.
.cm.json — sérialisation projetLe format .cm.json est un fichier JSON qui capture l'état complet de l'éditeur visuel. C'est un format de projet (sauvegarde/restauration), pas un format d'échange (interopérabilité). Il est transitoire — ADR-033 le remplacera à terme.
{
"formatVersion": "0.20",
"meta": {
"name": "My Pipeline",
"description": "",
"version": "1.0.0",
"authors": [],
"savedAt": "2026-03-08T14:30:00.000Z"
},
"ops": [
{
"templateId": "source",
"alias": "src_1",
"instance": 1,
"x": 100, "y": 200, "r": 50,
"configValues": { "value": "hello world" },
"plugAngles": { "out_data": 0 }
}
],
"conns": [
{ "id": "c0", "from": "src_1.out_data", "to": "trn_2.in_data" }
],
"subCompositions": [
{
"id": "sc_1", "label": "RAG Module",
"parent": null,
"operators": ["ldr_3", "prs_4"],
"bridges": [
{ "id": "br_1", "side": "in", "plugId": "in_data",
"internalOp": "ldr_3", "internalPlug": "in_data",
"externalPlug": null, "angle": 3.14, "envAngle": 3.14 }
],
"x": 400, "y": 250, "r": 56
}
],
"patterns": {
"rag-basic": {
"label": "RAG Basic", "description": "...",
"operators": [], "connections": [], "bridges": []
}
},
"counters": { "idx": 5, "connIdx": 3, "scIdx": 1, "brIdx": 1 }
}
Le .cm.json est conçu pour garantir un round-trip sans perte :
État runtime .cm.json
OPS[] ─── serialize ───→ ops[]
CONNS[] ─── serialize ───→ conns[]
SUB_COMPS[] ─── serialize ───→ subCompositions[]
PATTERNS{} ─── serialize ───→ patterns{}
idx, connIdx… ─── serialize ───→ counters{}
│
OPS[] ←── deserialize ── ops[]
CONNS[] ←── deserialize ── conns[]
SUB_COMPS[] ←── deserialize ── subCompositions[]
PATTERNS{} ←── deserialize ── patterns{}
idx, connIdx… ←── deserialize ── counters{}
Propriété fondamentale : deserialize(serialize(state)) ≡ state pour toute donnée JSON-sérialisable. Les valeurs non sérialisables (ArrayBuffer, FileSystemFileHandle) sont filtrées à la sérialisation — seul le name est conservé.
Le .cm.json stocke templateId (identifiant du template dans le catalogue) et non le descripteur complet de l'opérateur. À la désérialisation, chaque templateId est résolu via un templateMap fourni par le renderer :
const op = instantiate(templateMap[entry.templateId], entry.alias, ...);
Si un templateId est inconnu (opérateur supprimé du catalogue entre deux versions), l'entrée est ignorée avec un warning — pas de crash.
Les IDs (alias, id de connexion, id de sous-composition, id de bridge) sont préservés tels quels à l'import. L'import est un remplacement total : la composition courante est effacée et remplacée par le contenu du fichier. Les compteurs (idx, connIdx, scIdx, brIdx) sont restaurés depuis le JSON, garantissant que les prochains IDs générés ne collisionneront pas avec les existants.
Pas de régénération — les IDs du JSON sont réutilisés directement, comme pour la restauration depuis localStorage. Cela simplifie le round-trip et préserve la correspondance exacte entre sauvegardes successives.
Merge hors scope — si deux compositions sauvegardées indépendamment partagent des IDs identiques (ex : src_1 dans les deux), un futur merge nécessitera un remapping d'IDs. Cette logique n'existe pas en v0.20 et sera traitée si/quand le merge est implémenté. La décision de préserver les IDs à l'import ne crée pas de dette technique — le remapping serait nécessaire dans tous les cas, même avec des IDs régénérés à l'import.
| Aspect | .cm.js (ADR-021) | .cm.json (ADR-039) |
|---|---|---|
| Format | Module ES (JavaScript) | JSON pur |
| Rôle | Format d'échange, écriture humaine/LLM | Format de projet, sauvegarde/restauration |
| Templates | Imports ES depuis les libraries | templateId string → résolu au chargement |
| Sous-comps | Pas encore supporté (ADR-033) | Supporté (ADR-038) |
| Patterns | Non | Oui |
| Compteurs | Non (instanciation fraîche) | Oui (continuité des IDs) |
| Config | params statiques (ADR-020) | configValues runtime |
| Positions | layout.plugs en radians | plugAngles + x, y, r |
ADR-033 (In Review) définit le futur .cm.js comme un sous-langage JS contraint avec composition.add(), composition.connect(), composition.sub(). Quand ADR-033 sera implémenté (v0.26), le .cm.js deviendra le format canonique de projet et le .cm.json sera déprécié.
La migration sera un export one-shot : .cm.json → .cm.js.
Un .cm.json sans formatVersion est traité comme v0.19 implicite (format localStorage actuel, même structure sans l'enveloppe meta/formatVersion).
_saveToStorage) avecmeta + formatVersion — pas de nouvelle logique de sérialisationdeserialize(serialize(state)) ≡ state.cm.json est un format transitoire — il sera remplacé par le .cm.js DSL.cm.json ne prétend pas être un format d'échange — il est couplé à la.cm.json créé en v0.20 ne fonctionnera avec unetemplateId restent stablesCaméléon v2 — 08/03/2026
editor/renderer.js is a 5233-line monolith containing 29 sections and ~90 functions. It handles everything: SVG rendering, CVM orchestration, drag & drop, undo/redo, panels, menus wiring, file operations, catalogue, tooltips, zoom/pan, annotations, and boot.
Each version adds features that increase coupling: - v0.19 added sub-composition rendering and navigation (~700 lines) - v0.20 added menu wiring, file operations, and dirty state (~200 lines)
The monolith blocks progress: - No unit tests — pure logic is entangled with DOM manipulation - Hard to reason about — 29 sections share ~40 module-level variables - Build fragility — build-doc.py standalone inlining must handle all modules - CLI blocked — execution logic is trapped inside DOM-dependent code - Debugging blind spots — when a bug occurs, 29 interconnected sections are suspects
editor/
cameleon.html ← HTML + inline CSS (~600 lines)
renderer.js ← MONOLITH (5233 lines)
subcomp.js ← pure functions, extracted v0.19 (130 lines)
menus.js ← menu bar system, extracted v0.20 (140 lines)
file-io.js ← serialization + File API, extracted v0.20 (280 lines)
Full audit in doc/projet/etudes/architecture/audit-architecture-renderer.md. Key metrics:
The engine becomes a factory (createEngine()) that returns an instance encapsulating state, bus (~15 loc Observer in closure), and API. The editor monolith is decomposed into 6 responsibility-based modules that consume the engine's two faces: sync methods and async events. renderer.js ceases to exist. The existing extracted modules (subcomp.js, menus.js, file-io.js) are absorbed into the new structure.
engine/
engine.js ← createEngine() factory — single entry point
cvm.js ← Petri net engine (internal to engine.js)
libs/ ← types, controls, operator libraries
editor/
cameleon.html ← HTML only (no inline CSS)
cameleon.css ← extracted CSS + custom properties
system.js ← boot, opsRegistry, storage, File API (I/O only)
geometry.js ← pure: geometry, hit testing, visibility, bridges
render.js ← SVG build/update/incr., annotations
interact.js ← Drag & Drop, selection, zoom/pan
commands.js ← undo/redo + execution playback
ui.js ← panels, catalogue, config editor, menus
Files deleted: renderer.js, subcomp.js (→ geometry.js), menus.js (→ ui.js), file-io.js (→ system.js).
createEngine() returns a single instance with two faces — sync API for mutations, async events for observation. The bus is ~15 lines of Observer pattern in the factory closure. No separate bus.js file.
// engine/engine.js
export function createEngine() {
// --- state (model) ---
const state = { OPS: [], CONNS: [], SUB_COMPS: [], PATTERNS: {},
idx: 0, connIdx: 0, scIdx: 0, brIdx: 0, currentLevel: null,
cvm: null, running: false, busy: false, rewinding: false,
firingId: null, animEnabled: true, stepDelay: 0, simMode: false };
// --- bus (Observer ~15 loc, internal) ---
const handlers = {};
function on(event, fn) { (handlers[event] ??= []).push(fn); }
function off(event, fn) { handlers[event] = (handlers[event] ?? []).filter(f => f !== fn); }
function emit(event, ...args) { (handlers[event] ?? []).forEach(fn => fn(...args)); }
// --- API (mutate state + auto-emit) ---
function addOp(op) { state.OPS.push(op); state.idx++; emit('op:added', op); }
function execStep(opId) { /* delegate to CVM, emit('cvm:step', ...) */ }
function notifyTopologyChanged() { emit('topology:changed'); }
// ...
return { state, on, off, addOp, removeOp, addConn, removeConn,
loadComposition, serializeComposition, notifyTopologyChanged,
execStep, fireable, reset, opsRegistry, ctrlRegistry, typeColor, isCompatible };
}
Parallel: createEngine() is to the composition model what createCVM() is to the Petri net — a factory returning an autonomous instance. No global state — each call creates an independent engine (testable, composable).
The bus is not a separate file — consumers see engine.on(event, fn) and engine.state.
Only the model state lives in the engine. UI state stays editor-side.
| Group | Variables | Where |
|---|---|---|
| Composition | OPS, CONNS, SUB_COMPS, PATTERNS, idx, connIdx, scIdx, brIdx, currentLevel | engine.state |
| Execution | cvm, running, busy, rewinding, firingId, animEnabled, stepDelay, simMode | engine.state |
| Geometry cache | PLUG_IDX, PLUG_ANGLES, _envelope | editor (geometry/render) |
| UI | selectedOps, selectedSCs, drag, zoomLevel, showAnn, currentMode | editor (interact/ui) |
| Module | Responsibility | DOM | Engine | LOC est. |
|---|---|---|---|---|
| system.js | Boot, opsRegistry, storage, File API (I/O only) | localStorage | sync + listen | ~500 |
| geometry.js | Plug geometry, Bézier, hit testing, visibility, bridges | — | — | ~300 |
| render.js | Build/Update/Incr. SVG, annotations, token animation | SVG | sync + listen | ~900 |
| interact.js | Drag & Drop, selection, zoom/pan, tooltips | DOM events | — | ~1100 |
| commands.js | Undo/redo, command factories, snapshot, execution playback | — | sync (sole mutator) | ~1030 |
| ui.js | Panels, catalogue, config editor, controller, menus | DOM panels | sync + listen | ~1200 |
geometry.js is the only entirely pure module — no DOM, no engine, no side-effects. All other modules import it for geometry calculations.
commands.js is the sole mutator of the engine. It calls engine methods (sync) which auto-emit events. It also calls notifyTopologyChanged() after each execCmd()/undo()/redo() — not inside each command factory. A compound command = one topology:changed.
| Existing module | Absorbed by | Rationale |
|---|---|---|
subcomp.js | geometry.js | Same concepts: positions, plugs, hit testing, geometry |
file-io.js | system.js | Serialization, File API, dirty state — everything persistence |
menus.js | ui.js | UI widgets — menu bar is a widget like the others |
Engine events (model → consumers) — editor modules listen (engine.on(...)) to react to model mutations. Nobody on the editor side emits — commands.js calls engine methods (sync), the engine auto-emits.
| Event | Trigger | Consumers |
|---|---|---|
op:added | engine.addOp() | render, ui |
op:removed | engine.removeOp() | render, ui |
conn:added | engine.addConn() | render |
conn:removed | engine.removeConn() | render |
topology:reset | engine.loadComposition() | render (rebuild), system, ui |
topology:changed | engine.notifyTopologyChanged() | system (auto-save) |
cvm:step | engine.execStep() | render (animation), ui (log), CLI (trace) |
cvm:reset | engine.reset() | render, ui |
execution:changed | engine.setRunning/setPaused | render, ui, CLI (status) |
Direct calls (UI → UI) — interactions between editor modules are imports and function calls. No editor-internal event bus. The complete 15-row interface matrix is documented in target-architecture.md §3.3.
Rules: - Engine bus for model events (1→N), direct calls for UI interactions (1→1). - Only commands.js mutates the engine. Other modules listen (async) or read (sync). - interact.js and geometry.js never touch the engine — interact goes through commands for mutations, geometry receives data as parameters.
Today Drag & Drop calls moveOp() directly, which manipulates SVG elements. With the decomposition, Interact calls Render's moveOp() directly (imported function call) — not via events. During a drag, this is a hot path that needs direct SVG manipulation, not event indirection.
Navigation follows the same pattern: interact → geometry.enterSubComp() then interact → render.rebuildCanvas(). Interact orchestrates, geometry computes, render draws.
The audit identified ~150 lines of exact duplicates and 8 repeated patterns (100+ occurrences):
| # | Duplicate | Locations | Action |
|---|---|---|---|
| D3 | Operator circle rendering | addOperatorSVG / buildSVG | Extract _buildOperatorGroup() |
| D4 | Plug rendering | addOperatorSVG / buildSVG | Extract _buildPlugElements() |
| D5 | Bridge plug rendering | addSubCompSVG / buildSVG | Extract _buildBridgePlugGroup() |
| P3 | SUB_COMPS.find(s => s.id) | 33 occurrences | Extract helper |
| X1 | _serializableConfig | renderer.js + file-io.js | Single location (geometry.js) |
Factored during module extraction.
Consistent naming across all modules — established for the decomposition.
Registries — suffix *Registry: opsRegistry (operator catalogue, replaces LIBS), ctrlRegistry (controls, replaces registry), typeRegistry (types). Renamed during Phase 1.
Functions — prefix conveys intent:
| Prefix | Meaning | Example |
|---|---|---|
create* | Factory returning new instance | createEngine(), createCVM() |
build* | Construct DOM/SVG, side-effects | buildSVG(), buildCatalogue() |
find* | Search, returns item or null | findPlugAtPoint() |
is / has | Boolean predicate | isRef(), isCompatible() |
cmd* | Command factory (undo/redo) | cmdAddOp(), cmdRemoveConn() |
resolve* | Resolve external dependency | resolveHuman(), resolveControl() |
render* | Render output/view | renderView(), renderAnnotations() |
notify* | Emit event, no mutation | notifyTopologyChanged() |
_* | Private to module | _buildOperatorGroup() |
init* | Module initializer, returns API | initRender(), initInteract() |
setup* | Attach event listeners | setupDrag(), setupZoom() |
Events — noun:verb lowercase: op:added, topology:changed, cvm:step. No editor-internal event bus.
State — UPPER_SNAKE for structural constants/collections (OPS, CONNS, PLUG_R), camelCase for mutable state (running, currentLevel).
Types ODK — PascalCase (DataString, DataTableRef). Reference types suffixed *Ref.
build-doc.py inlines all editor/*.js files into a single <script> block for the standalone demo. The new modules follow the same convention:
_strip_imports_exports() removes ES module syntaxexport function → function (global scope after inlining)editor_dir.glob("*.js")) picks up new files automaticallyconst declarations are deduplicated| # | Question | Resolution |
|---|---|---|
| ~~Q1~~ | ~~Engine contains DOM code~~ | Resolved — pragmatic coupling accepted. Engine is a service facade (ADR-034). Controls know how to render. Consumers pick what they need: editor uses everything, CLI uses CVM only. |
| ~~Q2~~ | ~~State mutations — who mutates?~~ | Resolved — createEngine() encapsulates. Each method (addOp, execStep, etc.) mutates engine.state and auto-emits. Consumers never touch state directly. |
| ~~Q3~~ | ~~Boot orchestration~~ | Resolved — system.js orchestrates. Explicit sequence: createEngine, buildSVG, setupDrag, buildCatalogue, etc. No implicit choreography. |
| Q4 | geometry.js signatures — globals become 4-6 params per function | Options: (a) explicit params, (b) pass engine.state, (c) context object. To resolve during Phase 2 implementation. |
| ~~Q5~~ | ~~ui.js at ~1200 loc~~ | Resolved — acceptable. Stable module, no monolith risk. Split in v0.22+ if needed. |
| ~~Q6~~ | ~~commands.js mixes sync (undo) and async (live loop)~~ | Accepted — both are user→CVM interfaces. Revisit if problematic. |
| ~~Q7~~ | ~~Presentation events (op:moved, selection:changed) — mechanism?~~ | Resolved — direct calls. No editor-internal event bus. Interact calls render/ui directly. Simpler, no traceability issue. |
renderer.js is deleted. 6 editor modules + createEngine() factory.createEngine(), not two separate files.commands.js mutates the engine, all others listen or read. Traceable mutation path.createEngine() and uses both faces. Zero editor dependency.commands.js as sole mutator.Introduce a reactive framework (Lit, Preact, Svelte) to decompose the UI into independent components. Rejected — too large a change for v0.21. The monolith's problem is organization, not technology. A framework is an option for v1.0+.
Keep the event bus as a separate module (engine/bus.js) alongside engine.js. Rejected — creates an artificial split. The bus is an implementation detail of the engine instance (~15 loc). Consumers see engine.on() and engine.state — they don't need to know the bus exists.
Keep the v0.19-v0.20 pattern: factory functions with context objects. Rejected for DOM-heavy modules — Drag & Drop alone would need 20+ callbacks. The engine bus provides cleaner decoupling for model events. Callback injection remains valid for pure modules.
Extract only pure functions and keep renderer.js as a reduced monolith (~3800 lines). Rejected — doesn't solve the core problem. The monolith shrinks but coupling, shared state, and testability issues remain.
Keep undo/redo and execution playback in separate modules. Rejected — both are user→CVM interfaces with equivalent roles. A single module (~1030 loc) is manageable.
Add a separate editor-local bus for op:moved, selection:changed, etc. Rejected — adds complexity for 1→1 interactions. Direct calls are simpler and more traceable. The engine bus handles the 1→N model events.
Caméléon v2 — 10/03/2026
Le moteur CVM interagit avec le monde extérieur à quatre niveaux :
Niveau 1 — Interaction humaine : certains opérateurs attendent une valeur externe au moment du tir (HumanInput, LiveSource). L'engine est bloqué — il a besoin que le consommateur fournisse une valeur.
Niveau 2 — Panneaux interactifs : les opérateurs interactifs exposent un widget de saisie et un éditeur de configuration. Le consommateur construit et affiche ces panneaux — l'engine délègue.
Niveau 3 — Affichage des sorties : les opérateurs de sortie (Display, Logger, et futurs viewers riches — DataTableViewer, ChartViewer, ImageViewer — ADR-043) produisent des valeurs à montrer. L'engine dit "voilà une valeur" — le consommateur décide comment l'afficher.
Niveau 4 — Sorties fichier : certains opérateurs (FileSaver) produisent des données à écrire sur le système de fichiers. Le moteur retourne les données brutes — l'adaptateur décide comment les écrire (Blob/URL dans le navigateur, fs.writeFile en CLI, noop en tests). Les I/O réseau (PostgreSQL, Kafka, HTTP) ne sont pas concernés — ils relèvent du comportement de l'opérateur (run()), pas d'un contrat d'adaptation.
Aujourd'hui, ces quatre niveaux sont des callbacks implicites injectés par l'éditeur. Il n'existe pas de contrat formalisé.
Dès qu'un second consommateur apparaît (CLI, serveur HTTP, tests), chacun invente son propre mécanisme — dette technique garantie.
Règle fondamentale : les libs (std-controls.js, std-*-lib.js) gardent leur logique de rendu DOM. Ce sont les adaptateurs (un par consommateur) qui décident d'appeler ou non ce rendu selon leur contexte. L'engine ne sait pas si c'est DOM, stdout ou JSON.
createEngine() accepte un config optionnel avec quatre sections :
export function createEngine(config = {}) {
const interaction = config.interaction ?? defaultInteraction;
const edit = config.edit ?? defaultEdit;
const display = config.display ?? defaultDisplay;
const files = config.files ?? defaultFiles;
// ...
}
L'engine est bloqué sur un opérateur qui attend une valeur externe. Il délègue la résolution au consommateur.
config.interaction = {
/**
* Résout une entrée HumanInput au moment du tir.
* @param {object} op — opérateur (id, label, config)
* @returns {Promise<any>} — valeur saisie
*/
resolveHuman(op): Promise<any>,
/**
* Résout une valeur LiveSource au moment du tir.
* @param {object} op — opérateur (id, label, config)
* @returns {any} — valeur courante de la source
*/
resolveLive(op): any
}
Headless par défaut — erreur explicite si absent :
const defaultInteraction = {
resolveHuman: (op) => Promise.reject(
new Error(`resolveHuman not configured (op: ${op.id})`)),
resolveLive: (op) => { throw new Error(
`resolveLive not configured (op: ${op.id})`); }
};
L'engine a besoin que le consommateur construise un widget d'édition. Deux cas — même nature (édition), cibles différentes :
config.edit = {
/**
* Construit le widget d'édition pour un opérateur interactif
* pendant l'exécution (panneau Controller).
* L'utilisateur fournit une valeur data qui alimente le run().
* @param {object} op — opérateur
* @param {object} context — contexte consommateur (container DOM…)
*/
control(op, context): void,
/**
* Construit le widget d'édition des paramètres comportementaux
* d'un opérateur (panneau Config).
* @param {object} op — opérateur
* @param {object} context — contexte consommateur
*/
config(op, context): void
}
Headless par défaut — noop silencieux :
const defaultEdit = {
control: () => {},
config: () => {}
};
Les opérateurs de sortie scalaires (Display, Logger) et les viewers riches (DataTableViewer, ChartViewer, ImageViewer — ADR-043) suivent le même contrat. L'engine dit "voilà une valeur produite" — le consommateur décide comment la montrer.
config.display = {
/**
* Affiche la valeur produite par un opérateur de sortie.
* @param {object} op — opérateur
* @param {any} value — valeur produite
* @param {object} context — contexte consommateur
*/
renderView(op, value, context): void
}
Headless par défaut — noop silencieux :
const defaultDisplay = {
renderView: () => {}
};
Les opérateurs qui produisent des données à persister (ex. FileSaver) retournent leurs données via run(). Le player appelle files.download après execStep si l'opérateur a produit un résultat de type fichier. L'engine ne touche ni Blob ni filesystem — l'adaptateur décide.
config.files = {
/**
* Écrit les données produites par un opérateur fichier.
* @param {ArrayBuffer|string} data — données brutes
* @param {string} filename — nom de fichier suggéré
*/
download(data, filename): void
}
Headless par défaut — noop silencieux :
const defaultFiles = {
download: () => {}
};
Éditeur visuel — system.js :
const engine = createEngine({
interaction: {
resolveHuman: (op) => ui.showHumanControl(op),
resolveLive: (op) => ui.readLiveInput(op)
},
edit: {
control: (op, ctx) =>
registry[op.type]?.render(op, ctx.container),
config: (op, ctx) =>
editors[op.type]?.render(op, ctx.container)
},
display: {
renderView: (op, value, ctx) =>
registry[op.type]?.renderValue(value, ctx.container)
},
files: {
download(data, filename) {
const blob = new Blob([data]);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
}
});
CLI — cli/c2.js :
const engine = createEngine({
interaction: {
resolveHuman: (op) => readline.question(`${op.label} > `),
resolveLive: (op) => liveValues[op.id] ?? op.config.defaultValue
},
edit: {
control: () => {}, // noop — pas d'UI en CLI
config: () => {}
},
display: {
renderView: (op, value) =>
console.log(JSON.stringify({ op: op.id, value }))
},
files: {
download: (data, filename) => Deno.writeFile(filename, new Uint8Array(data))
}
});
Serveur HTTP — server/c2-server.js (backlog) :
const engine = createEngine({
interaction: {
resolveHuman: (op) => waitForHttpInput(op.id),
resolveLive: (op) => liveState[op.id]
},
edit: { control: () => {}, config: () => {} },
display: {
renderView: (op, value) =>
ws.send(JSON.stringify({ op: op.id, value }))
},
files: { download: () => {} } // noop — serveur ne gère pas le download direct
});
Tests — mock :
const engine = createEngine({
interaction: {
resolveHuman: () => Promise.resolve('test-value'),
resolveLive: () => 42
},
edit: { control: () => {}, config: () => {} },
display: {
renderView: (op, value) => captured.push({ op: op.id, value })
},
files: {
download: (data, filename) => downloads.push({ data, filename })
}
});
config couvre les quatreinteraction, panels, display, filesstd-controls.js et les libs deinteraction absent = erreuredit, display et files absents = noop silencieux.config.files.download sortengine/libs/. Le moteur retourne des données,EngineConfig + defaults dans engine.jsinteraction.resolveHuman / interaction.resolveLivesystem.js passent paredit.control / edit.configdisplay.renderViewfiles.download(data, filename) post-execStep si{ _download: true, data, filename }system.js passe le config complet à createEngine()Migration atomique avec Phase 1 — pas de phase séparée.
Un ADR par contrat. Rejeté — même pattern, même justification, même migration. Un seul ADR est plus clair.
L'engine émet human:waiting, panel:render, display:render et attend des réponses via engine.resolve*(value).
Rejeté pour v0.21 — ajoute de l'asynchronisme implicite dans le bus pour des appels qui sont naturellement synchrones (panels, display) ou déjà async (interaction). À reconsidérer pour le serveur HTTP en v0.23+ si un modèle push est nécessaire.
Le pattern actuel. Rejeté — ne scale pas à deux consommateurs.
Noms initiaux. Rejetés — trop génériques, collision avec engine.widgets (registre), et ne communiquent pas l'intention (P5 — Domain Language First).
panels avec resolveControl / resolveConfigNommage intermédiaire. Rejeté — resolveControl et resolveConfig sont deux formes d'édition ; les regrouper sous edit avec control et config exprime mieux leur nature commune et élimine les préfixes redondants.
Caméléon v2 — 10/03/2026
Le moteur CVM transporte aujourd'hui des valeurs scalaires entre opérateurs — strings, numbers, booleans, JSON. Ces valeurs circulent par copie dans les connexions : chaque opérateur reçoit une copie complète de ce que l'opérateur précédent a produit.
Ce modèle atteint ses limites dès qu'on introduit des données volumineuses :
Copier un DataFrame de 500MB entre deux opérateurs est inacceptable. Le moteur CVM ne peut pas devenir un pipeline de données en mémoire.
Le modèle de tir Petri net ne doit pas changer. Une connexion transporte un token. Un token peut être un scalaire ou une référence — le moteur ne distingue pas les deux. C'est l'opérateur qui résout la référence quand il en a besoin.
Si une connexion transporte une référence vers un endpoint SQL qui change entre deux steps, le backward ne peut pas restaurer l'état précédent de la source. Cette limitation est documentée et acceptée — voir Conséquences.
Les données volumineuses circulent entre opérateurs par référence, pas par copie. Un opérateur reçoit un handle — pas le contenu.
// Token scalaire — inchangé
{ type: "DataString", value: "hello world" }
// Token référence — nouveau
{
type: "DataTableRef",
ref: {
kind: "sql",
dsn: "postgres://...",
query: "SELECT * FROM events WHERE date > '2026-01-01'"
}
}
{
type: "ImageRef",
ref: {
kind: "file",
path: "./data/photo.jpg"
}
}
{
type: "DataTableRef",
ref: {
kind: "api",
url: "https://api.example.com/data",
method: "GET",
headers: { "Authorization": "Bearer ..." }
}
}
| Type | Description | Kinds supportés |
|---|---|---|
DataTableRef | Table 2D (rows × cols) | sql, api, file (CSV, Parquet) |
TimeSeriesRef | Série temporelle indexée | api, file (CSV, JSON) |
ImageRef | Image bitmap | file, url |
ChartRef | Spécification de graphique | inline (JSON Vega-Lite) |
La résolution est à la charge de l'opérateur qui consomme la référence. Le moteur CVM ne résout jamais une référence — il transporte le handle de façon opaque.
// Opérateur Transform — consomme un DataTableRef
async function execute(inputs) {
const ref = inputs.data; // { type: "DataTableRef", ref: { kind: "sql", ... } }
const rows = await resolveRef(ref); // l'opérateur résout
return { type: "DataTableRef", ref: applyTransform(ref, transform) };
}
Règle : un opérateur qui transforme des données volumineuses retourne une nouvelle référence (avec la transformation appliquée comme paramètre de requête), pas les données matérialisées.
Les scalaires simples (DataString, DataNumber, DataBoolean, DataJSON) sont inchangés. Ils circulent toujours par copie. Les types *Ref sont additifs — pas de migration existante.
La compatibilité est vérifiable via isRef(token) :
function isRef(token) {
return token?.type?.endsWith('Ref') ?? false;
}
Le backward CVM restaure l'état des connexions à l'étape précédente. Pour les scalaires — trivial, la valeur est copiée dans l'historique. Pour les références — le handle est restauré, mais **pas l'état de la source**. Si la source SQL a changé entre deux steps, le backward ne peut pas le compenser.
Décision : cette limitation est acceptée et documentée. Le backward fonctionne sur le graphe d'exécution, pas sur les sources de données externes. C'est cohérent avec le modèle Petri net — le réseau modélise le flux, pas les sources.
Conséquence UX (à documenter dans ADR-043) : en mode Data, les vues sur des références mutables (SQL, API) affichent toujours l'état courant de la source, pas l'état au moment du step.
Le transport par référence est **transparent pour le moteur Petri net** :
DataTableRef est compatible avec DataTableRef, pas avecDataString)Lien avec les réseaux de Petri colorés (CPN) : le transport par référence est analogue aux tokens colorés (Coloured Petri Nets, Jensen & Kristensen). Un token coloré porte une valeur — scalaire ou handle. Le réseau ne contraint pas la valeur, seulement les types de places.
cvm.js*Ref sont additifsresolveRef().Les tokens référence sont sérialisables nativement — le ref est un objet JSON. Aucune modification du format .cm.json nécessaire pour le transport. La configuration des opérateurs sources (DataSource, SqlSource) est stockée dans configValues comme aujourd'hui.
Les données sont copiées mais chargées à la demande. Rejeté — la copie reste en mémoire, le problème n'est pas résolu.
Les opérateurs partagent une zone mémoire commune. Rejeté — complexité excessive, incompatible avec la sérialisation .cm.json et le modèle distribué (Raspberry Pi, ADR-013).
Ne pas supporter les données volumineuses. Rejeté — le mode Data (ADR-043) et les use cases réels (Team Collaboration, Composition Orchestra) en ont besoin.
Caméléon v2 — 10/03/2026
Caméléon dispose aujourd'hui de deux modes dans le switcher : [Visual] et [Code]. Le mode Split est abandonné (feature-mode-data.md §8).
L'introduction de données volumineuses (ADR-042) et de types riches (DataTableRef, ImageRef, ChartRef) crée un besoin d'exploration dédié que ni le canvas SVG ni le panneau Data actuel ne peuvent satisfaire :
Deux niveaux complémentaires sont identifiés :
Panneau Data (droite, toujours visible) — debug inline. Affiche les valeurs courantes de l'opérateur sélectionné. Équivalent du Output Panel de n8n.
Mode Data (switcher) — exploration dédiée. Canvas HTML libre, vues riches, tables interactives, charts, données volumineuses. Pour quand les données ont besoin de respirer.
[Visual] [Code] [Data]Le mode Split est supprimé. Le switcher final est :
[Visual] [Code] [Data]
Raccourcis clavier : Ctrl+1, Ctrl+2, Ctrl+3.
Visual — composer et observer avec le canvas SVG. Mode principal.
Code — travailler en texte. Composition en .cm.js, requêtes SQL, configurations API, scripts de transformation.
Data — explorer et visualiser les données produites par la composition. Canvas HTML libre, distinct du canvas SVG. Le CVM tourne en arrière-plan.
Le mode Data est un canvas HTML libre — pas de contrainte SVG, pas de grille imposée. L'utilisateur positionne et redimensionne les vues librement.
┌─────────────────────────────────────────────────────┐
│ [Visual] [Code] [Data] │
├──────────┬──────────────────────────┬───────────────┤
│ ENTRÉES │ │ VISUALISATIONS│
│ ──────── │ Canvas HTML libre │ ──────────── │
│ src_1 │ │ TableViewer │
│ src_2 │ ┌──────────────────┐ │ ChartViewer │
│ │ │ TableViewer │ │ ImageViewer │
│ SORTIES │ │ (résizable) │ │ TimeSeriesV. │
│ ──────── │ └──────────────────┘ │ │
│ trn_3 ● │ │ [+] Ajouter │
│ agg_4 ● │ ┌──────────┐ │ │
│ │ │ ChartV. │ │ │
│ │ └──────────┘ │ │
└──────────┴──────────────────────────┴───────────────┘
Panneau gauche — hiérarchie ENTRÉES / SORTIES. Les sorties des opérateurs actifs sont accessibles (●). L'utilisateur associe une sortie à une vue par drag depuis le panneau gauche vers une vue existante, ou par clic pour instancier une vue compatible.
Canvas central — vues positionnées librement. Layout persisté.
Panneau droit — catalogue des viewers disponibles. Format compact : icône + nom + types compatibles. Descriptions en tooltip. Bouton [+] en bas pour ajouter une vue.
Les viewers riches s'insèrent dans config.display.renderView() (ADR-041) — même contrat que les viewers scalaires. La lib sait se rendre, l'adaptateur décide du contexte.
Nouveaux types ODK à introduire :
| Type ODK | Viewer | Éditeur | Source |
|---|---|---|---|
DataTable | TableViewer — tri, filtre, pagination | TableEditor — édition cellule | DataTableRef |
TimeSeries | TimeSeriesViewer — chart ligne | — | TimeSeriesRef |
Image | ImageViewer — zoom, pan | ImageAnnotator (backlog) | ImageRef |
Chart | ChartViewer — rendu interactif | ChartConfigurator — paramètres visuels | ChartRef (spec JSON) |
DataSource | — | QueryEditor — SQL / pandas | DataTableRef |
Renderer de charts : décision ouverte — voir section "Comparatif renderers charts" ci-dessous.
Le layout du mode Data (positions, dimensions, configurations des vues) est persisté dans le .cm.json sous une section dédiée dataLayout :
{
"formatVersion": "0.21",
"meta": { ... },
"ops": [ ... ],
"conns": [ ... ],
"dataLayout": {
"views": [
{
"id": "v1",
"type": "TableViewer",
"source": "trn_3",
"x": 20, "y": 20, "w": 600, "h": 400,
"config": { "pageSize": 50, "sortCol": "date" }
},
{
"id": "v2",
"type": "ChartViewer",
"source": "agg_4",
"x": 640, "y": 20, "w": 400, "h": 400,
"config": { "spec": { ... } }
}
]
}
}
Le source est l'alias de l'opérateur dont on observe la sortie. Si l'opérateur est supprimé de la composition, la vue est conservée mais marquée comme orpheline (source introuvable).
Seules les sorties des opérateurs au niveau courant (currentLevel) sont accessibles depuis le mode Data. Les sorties des opérateurs à l'intérieur d'une sub-composition sont accessibles en naviguant dans la sub-composition en mode Visual, puis en revenant en mode Data.
L'accès est en lecture seule — le mode Data observe, il ne mute pas la composition.
Les vues sur des références mutables (SQL, API) affichent toujours l'état courant de la source (ADR-042). En mode rewind, la vue ne revient pas à l'état de la source au moment du step — elle affiche ce que la source retourne maintenant.
Cette limitation est documentée dans l'UI : icône ⚠ sur les vues dont la source est de type sql ou api.
renderView() est le seul contrat.build-doc.py doit l'inclure[Visual] [Code] [Data] sans canvas DatadataLayout dans .cm.jsonQ3 — Renderer charts : Vega-Lite acté (voir ci-dessus).
Q4 — Sorties accessibles : toutes les sorties au niveau courant. Acté ci-dessus.
Q6 — Édition trackée dans l'historique CVM : hors scope v0.22. Si TableEditor permet de modifier des données, ces modifications ne sont pas des commandes CVM — elles mutent la source directement. À traiter si/quand l'édition inline est implémentée.
Le modèle démo de Caméléon est un **unique fichier HTML sans dépendances externes** — pas de CDN, pas de serveur. Le renderer de charts doit pouvoir être inliné dans ce fichier.
Contraintes : - Inline dans un seul HTML — taille du bundle critique - Licence permissive — redistribution sans contrainte - Pas de serveur requis — rendu purement client
| Lib | Taille min | Taille gzip | Licence | Types de charts | Spec sérialisable | Standalone inline |
|---|---|---|---|---|---|---|
| uPlot | ~50 KB | ~20 KB | MIT | Line, area, bar, OHLC | Non (impératif) | ✓ trivial |
| Chart.js | ~254 KB | ~80 KB | MIT | Bar, line, pie, scatter, radar… | Non (impératif) | ✓ acceptable |
| Vega-Lite | ~8 MB (stack complète) | ~2.5 MB | BSD-3 | Tout (grammaire complète) | ✓ JSON déclaratif | ⚠ bundle lourd |
| Vega-Lite + bundler | ~300-500 KB | ~120 KB | BSD-3 | Sous-ensemble fixe | ✓ JSON déclaratif | ✓ si specs fixes |
| D3.js | ~270 KB | ~90 KB | ISC | Tout (bas niveau) | Non (impératif) | ✓ acceptable |
| SVG natif | 0 KB | 0 KB | — | Bar, line, scatter (custom) | ✓ (données JSON) | ✓ trivial |
uPlot (~50 KB, MIT) Le plus léger du marché. Canvas 2D, performant sur de très grandes séries temporelles (166k points en 25ms). Idéal pour TimeSeriesViewer. Limitation : types de charts réduits (pas de pie, scatter, heatmap), API impérative (pas de spec JSON sérialisable dans .cm.json), docs spartiates.
Chart.js (~254 KB, MIT) Bon compromis taille/types. API impérative mais familière. Couvre bar, line, pie, scatter, radar. Pas de spec JSON sérialisable nativement — la configuration du chart doit être reconstruite à partir des paramètres sauvegardés.
Vega-Lite (stack complète ~8 MB) Grammaire déclarative complète, spec JSON sérialisable directement dans .cm.json comme ChartRef. Incompatible avec le modèle standalone — la stack complète (Vega + Vega-Lite + Vega-Embed) pèse ~8 MB inline. Inacceptable.
Vega-Lite + vega-bundler vega-bundler pré-parse les specs et génère un bundle optimisé qui n'inclut que les transforms utilisés. Si le ChartViewer utilise un sous-ensemble fixe de specs (bar, line, scatter), le bundle peut descendre à ~300-500 KB. Viable si et seulement si les specs sont connues à la compilation. Si l'utilisateur peut créer des charts librement, le bundle complet est inévitable.
D3.js (~270 KB, ISC) Très bas niveau — pas un renderer de charts mais une grammaire de visualisation. Nécessite d'écrire chaque type de chart from scratch. Puissance maximale, effort maximal. Adapté si Axel veut des charts entièrement personnalisés pour Caméléon.
SVG natif (0 KB) Pas de dépendance. Bar charts et line charts simples sont implémentables en SVG pur (~100-200 lignes). Suffisant pour les use cases initiaux du mode Data. Limite naturelle sur les charts complexes (scatter, heatmap).
Q — Les ChartRef sont-ils déclaratifs ou impératifs ?
Si les charts sont déclaratifs (spec JSON dans .cm.json, l'utilisateur configure via ChartConfigurator) → Vega-Lite + vega-bundler est le bon choix, à condition que les specs soient un sous-ensemble fixe connu à la compilation.
Si les charts sont impératifs (le ChartViewer reçoit des données et les affiche avec un type prédéfini) → Chart.js ou uPlot suffisent, taille bien maîtrisée.
ChartViewer sans dette de choix de lib.TimeSeriesViewer est prioritaire.ChartConfiguratorCette approche préserve le modèle standalone à chaque version et diffère le choix définitif jusqu'à ce que les besoins réels soient connus.
Pas de nouveau canvas. Le panneau Data devient capable d'accueillir des vues riches via popout flottant. Rejeté — le panneau latéral reste contraint en taille, l'exploration de données volumineuses nécessite un workspace dédié.
Vues riches positionnées par-dessus le SVG. Rejeté — complexité z-index/interaction, lisibilité du graphe dégradée sur compositions denses. Documenté dans ADR-034 Cas 2 comme backlog.
Rejeté pour les mêmes raisons que B et C séparément. L'Option A (mode Data dédié) est plus propre architecturalement.
Caméléon v2 — 10/03/2026
engine/libs/std-controls.js (349 lines) currently mixes two concerns:
document.createElement, addEventListener, showSaveFilePicker, File APIThis module lives in engine/libs/, which is the pure engine layer. It should have zero DOM dependencies. Today it works because the only consumer is the browser editor, but it blocks:
document)engine/libs/ to define a control, mixing concernsThe same issue affects std-io-lib.js where FileSaver (lines 85-106) contains browser download logic (Blob, URL.createObjectURL, <a>.click()).
Split controls into contracts (engine) and views (editor).
engine/libs/std-controls.jsKeeps only the contract definition and registry:
export const TextInputControl = {
id: "text_input_control",
label: "Text Input",
type: "DataString",
description: "Single-line text input for string configuration",
};
export const registry = { text_input_control: TextInputControl, ... };
No document, no createElement, no event listeners. Pure data.
editor/control-views.js (new)Contains the DOM rendering implementations:
import { registry } from '../engine/libs/std-controls.js';
const views = {
text_input_control(container, value, onChange) {
const input = document.createElement("input");
input.type = "text";
input.value = value ?? "";
input.addEventListener("input", () => onChange(input.value));
container.appendChild(input);
},
// ...
};
export function renderControl(controlId, container, value, onChange) {
const renderer = views[controlId];
if (renderer) renderer(container, value, onChange);
}
Custom operators define their control contract in the operator library (engine layer):
// engine/libs/my-custom-lib.js
export const MyCustomControl = {
id: "my_custom_control",
label: "My Custom",
type: "DataJSON",
};
And register a view in the editor layer (or via a plugin mechanism):
// editor/plugins/my-custom-views.js
import { registerControlView } from '../control-views.js';
registerControlView("my_custom_control", (container, value, onChange) => {
// DOM rendering here
});
Move browser download logic from std-io-lib.js to editor/file-io.js. FileSaver's run() delegates to a callback provided by the engine config adapter (ADR-041 config.io).
registry, editors) stays the sametypeof document
render(container, value, onChange) {
if (typeof document === "undefined") return;
// DOM code
}
Rejected: band-aid that keeps the architectural violation. Still blocks alternative frontends.
Rejected: control contracts (id, type, description) are part of the operator metadata model. They belong in engine. Only the rendering belongs in editor.
Split per library. editor/control-views.js is the central registry and dispatch module. Each standard library ships its own views file alongside its engine lib:
engine/libs/std-controls.js ← contracts (pure)
editor/control-views.js ← registry + registerViewer/registerEditor + dispatch
editor/control-views-std.js ← std viewer/editor implementations (imported at boot)
ODK libraries follow the same pattern:
engine/libs/my-lib.js ← operator + control contracts
editor/my-lib-views.js ← registerViewer / registerEditor calls
Rationale: enables ODK developers to ship views alongside their lib without touching core files. The per-lib split becomes meaningful in v0.23+ when custom operator libraries are documented.
Dynamic registerViewer(type, viewObject) / registerEditor(type, viewObject). Static import would require modifying control-views.js for every new control — defeats the ODK goal. The dynamic API lets any lib file self-register at load time.
// editor/my-lib-views.js
import { registerViewer, registerEditor } from './control-views.js';
registerViewer("MyType", { render(container, value) { ... } });
registerEditor("MyType", { render(container, value) { ... }, onInput(container, onChange) { ... } });
FileSaver's browser fallback (Blob download) moves to editor/system.js via a new config.io slot in createEngine():
createEngine({ io: { download: (data, filename) => {} } })
Headless default: noop. Editor injects the Blob implementation. FileSaver's run() calls ctx.download?.(data, filename) where ctx is the execution context passed by the engine.
ADR-042 établit le transport par référence — les données volumineuses circulent via un token DataByRef, l'opérateur résout quand il en a besoin. Ce modèle est correct pour le transport mais laisse deux questions ouvertes :
Question 1 — Matérialisation Quand un opérateur reçoit un DataByRef, doit-il toujours résoudre à la demande (lazy) ? Ou le moteur peut-il matérialiser le résultat pour éviter de refaire la requête à chaque consommateur ?
Question 2 — Ressources Les opérateurs data (SqlRequest, ApiRequest, FileRequest) ont besoin de connexions — pool PostgreSQL, client HTTP, accès Databricks. Qui initie ces connexions ? Qui les libère ? Le moteur ne connaît pas SQL — cette responsabilité ne peut pas lui appartenir.
La composition d'exemple suivante expose les deux problèmes :
[TwitterStream] ──► [CsvConsolidate] ──► [InterestFilter]
│
[SqlInsert] ──► [TableViewer]
[ParquetInsert] ──► [ParquetViewer]
TwitterStream a besoin d'un client API TwitterSqlInsert a besoin d'un pool PostgreSQLParquetInsert a besoin d'un accès DatabricksInterestFilter produit un résultat qui alimente deux opérateursADR-042 introduit DataByRef. Ce ADR ajoute un seul mode supplémentaire — DataInMemory — pour les données matérialisées par le moteur.
DataByRef — token de référence, résolution lazy par l'opérateur
le moteur transporte, ne possède pas les données
DataInMemory — résultat matérialisé dans le cache du moteur
le moteur possède les données pour la durée du step
Les variations de DataByRef (SQL, API, fichier, table temporaire dans la source) ne sont pas des modes distincts — elles sont des valeurs du champ kind dans le token. Le moteur ne les distingue pas. C'est la responsabilité de la lib qui résout.
// DataByRef — variations de kind
{ type: "DataTableRef", kind: "sql",
source: { dsn: "postgresql://...", query: "SELECT ..." } }
{ type: "DataTableRef", kind: "api",
source: { url: "https://api.twitter.com/...", auth: { ... } } }
{ type: "DataTableRef", kind: "file",
source: { path: "./archive/tweets.parquet", format: "parquet" } }
{ type: "DataTableRef", kind: "source",
source: { dsn: "postgresql://...", table: "_cm_step_12_out_1" } }
// DataInMemory — matérialisé par le moteur
{ type: "DataTableRef", kind: "memory",
id: "step-12-out-1",
rows: 1240, bytes: 4200000,
source: { kind: "sql", dsn: "...", query: "..." } }
// ↑ source conservée pour refresh explicite
Le moteur ne décide pas de matérialiser. C'est le plug out qui porte le mode — la valeur qu'il produit est soit un DataByRef soit un DataInMemory. L'opérateur ne fait que retourner ce que son plug out a produit.
Le paramètre materialize est une config du plug out, pas de l'opérateur global :
// Plug out de SqlRequest
plugs: {
out: {
data: {
type: "DataTableRef",
materialize: "auto" // défaut — le plug estime et décide
"force" // toujours DataInMemory
"never" // toujours DataByRef
}
}
}
Un opérateur peut avoir plusieurs plugs out avec des stratégies différentes — par exemple un plug data en auto et un plug meta (colonnes, types) toujours DataInMemory.
En mode auto, le plug utilise les métadonnées disponibles (COUNT, taille estimée) pour choisir. Si l'estimation est impossible — DataByRef par défaut.
Seuil de référence (configurable dans EngineConfig) :
config.data = {
memoryThreshold: 50 * 1024 * 1024 // 50MB — valeur par défaut
}
Quand un opérateur retourne un DataInMemory, le moteur stocke le résultat dans un cache interne indexé par step :
engine.cache["step-12-out-1"] = { rows: [...], meta: { ... } }
Ce cache est cohérent avec l'historique CVM :
TableViewer lit le cache — pas de nouvelle requêteLe cache est en mémoire, limité à la session. Il n'est pas persisté dans .cm.json.
Le moteur ne connaît pas SQL, HTTP ou Parquet. Il ne peut pas initier les connexions. Cette responsabilité appartient au consommateur — system.js pour l'éditeur, c2.js pour la CLI.
Principe : les connexions sont déclarées dans la composition, initiées par le consommateur, injectées dans le contexte des opérateurs.
.cm.json
{
"connections": {
"prod-pg": {
"kind": "sql",
"dsn": "postgresql://prod:5432/orders"
},
"datalake": {
"kind": "databricks",
"host": "adb-xxx.azuredatabricks.net",
"token": "${DATABRICKS_TOKEN}"
},
"twitter": {
"kind": "api",
"bearer_token": "${TWITTER_BEARER}"
}
}
}
Les credentials sensibles passent par des variables d'environnement — jamais en clair dans .cm.json.
Le consommateur maintient un registre de libs — une map kind → lib :
// system.js — éditeur complet
const libRegistry = {
"sql": pgLib,
"databricks": databricksLib,
"api": httpLib,
"file": fsLib,
"memory": null // pas de lib — géré par le moteur
}
// c2.js — CLI minimale
const libRegistry = {
"file": fsLib,
"memory": null
// pas de sql, pas d'api — non supporté en CLI v0.21
}
Le consommateur scanne la composition avant l'exécution, identifie les connexions référencées, initialise uniquement celles-là :
// Pseudo-code system.js
async function startComposition(cm) {
const usedKinds = scanConnections(cm)
// → ["sql", "databricks"] — "api" pas utilisé dans ce run
const connections = {}
for (const [name, conf] of Object.entries(cm.connections)) {
if (usedKinds.includes(conf.kind)) {
connections[name] = await libRegistry[conf.kind].connect(conf)
}
}
const engine = createEngine({
...config,
data: { connections }
})
return engine
}
Pas de connexion ouverte inutilement. Pas de lib chargée si la composition ne l'utilise pas.
Les connexions sont injectées dans le ctx reçu par run() :
// Dans l'opérateur SqlInsert
{
requires: ["sql"], // déclaration de dépendance — documentaire
run(inputs, ctx) {
const conn = ctx.connections["prod-pg"]
await conn.insert(this.config.table, inputs.data)
return { type: "DataTableRef", kind: "source",
source: { dsn: conn.dsn, table: this.config.table } }
},
compensate(inputs, output, ctx) {
const conn = ctx.connections["prod-pg"]
await conn.query(`DELETE FROM ${output.source.table}
WHERE _cm_step = '${output.id}'`)
}
}
Le moteur passe ctx.connections à chaque appel run() et compensate(). Il ne sait pas ce qu'il y a dedans.
Le consommateur libère les connexions en fin de session ou sur erreur :
engine.on("composition:end", async () => {
for (const conn of Object.values(connections)) {
await conn.close()
}
})
Un plug out DataByRef et un plug in DataInMemory sont des types incompatibles. Le moteur refuse la connexion et signale l'incompatibilité dans l'éditeur.
L'utilisateur doit résoudre explicitement en insérant un opérateur Materialize entre les deux :
[SqlRequest] ──DataByRef──► [Materialize] ──DataInMemory──► [InterestFilter]
Materialize est un opérateur de la lib standard :
{
type: "Materialize",
plugs: {
in: { data: { type: "DataTableRef", kind: "byref" } },
out: { data: { type: "DataTableRef", kind: "memory" } }
},
run(inputs, ctx) {
const conn = ctx.connections[inputs.data.source.connection]
const rows = await conn.fetchAll(inputs.data.source)
return { type: "DataTableRef", kind: "memory",
id: this.stepId, rows: rows.length,
bytes: estimateBytes(rows),
source: inputs.data.source }
},
compensate(inputs, output, ctx) {
// vide le cache du step — le moteur invalide automatiquement
// mais compensate() explicite pour la cohérence du contrat
}
}
Comportement de l'éditeur sur connexion incompatible : - Refus de la connexion avec message explicite - Suggestion d'insérer un Materialize - Insertion automatique sur confirmation utilisateur
Cette approche rend la matérialisation visible dans le graphe — elle a un coût (réseau, mémoire), elle mérite un nœud.
| Plug out \ Plug in | DataByRef | DataInMemory |
|---|---|---|
| DataByRef | ✓ | ✗ → Materialize |
| DataInMemory | ✓ * | ✓ |
\ Un DataInMemory connecté à un plug in DataByRef est accepté — le plug in reçoit un token kind: "memory" qu'il peut résoudre via le cache moteur.*
ctx.connections est opaque pour luicompensate(). Le moteur appelle mécaniquementDataByRef vers une API ouMaterialize rendEngineConfig s'élargit — config.data avec connectionsmemoryThreshold. À intégrer dans ADR-041scanConnections()DataByRef vers API ouEmailSend,CompensationNotSupported.DataInMemory est perduSqlConnect produit un token connexion, les opérateurs aval le consomment via un plug conn.
Rejeté — alourdit le graphe visuellement sans valeur pour l'utilisateur. Les connexions sont une préoccupation d'infrastructure, pas de données.
Le moteur maintient un registre de connexions et les injecte lui-même.
Rejeté — le moteur ne connaît pas SQL. Couplage interdit. La séparation moteur / consommateur est le principe fondateur d'ADR-036.
Le moteur estime la taille du résultat et choisit DataInMemory ou DataByRef sans impliquer l'opérateur.
Rejeté — le moteur ne peut pas estimer sans connaître la source. Et l'opérateur est mieux placé pour décider. Le paramètre materialize sur l'opérateur est plus prévisible et pédagogique.
Si le plug in attend DataInMemory et reçoit un DataByRef, le moteur matérialise automatiquement sans intervention de l'utilisateur.
Rejeté — opaque, invisible dans le graphe. Une matérialisation a un coût réseau et mémoire non négligeable. L'utilisateur doit en être conscient. L'opérateur Materialize explicite est préférable.
Q1 — Queue / backpressure sur LiveSource Un LiveSource à fort débit peut émettre plus vite que le traitement aval. Sans queue, des tokens sont perdus. Ce n'est pas modélisé dans le CVM actuel. → ADR à ouvrir.
Q2 — Append concurrent Plusieurs tirs simultanés vers le même fichier ou la même table. Le modèle CVM est séquentiel par composition — mais un LiveSource peut saturer l'aval. Lié à Q1.
Q3 — Cache disque DataInMemory est perdu à la fermeture. Pour les résultats coûteux (requêtes longues, gros Parquet), un cache disque persistant entre sessions serait utile. Backlog v0.3x.
Caméléon v2 — 13/03/2026
Dès qu'une composition implique des données volumineuses et des ressources externes — connexions SQL, API, fichiers Parquet — trois questions deviennent légitimes pendant l'exécution :
Le mode Data (ADR-043) répond à "que contiennent mes données". Il ne répond pas à "comment circulent-elles". Le canvas Visual montre la topologie statique. Il ne montre pas l'état dynamique des ressources.
Il manque un niveau d'observabilité de l'exécution — distinct de l'exploration des résultats.
Dataiku expose les datasets intermédiaires dans le Flow mais pas l'état des connexions en temps réel. n8n montre l'exécution node par node mais pas le cache. Aucun outil ne montre simultanément la topologie, le cache et les ressources externes dans une vue unifiée. C'est un différenciateur Caméléon.
L'inspecteur I/O est une feature OSS de base. Un utilisateur qui construit une composition data sans inspecteur est aveugle — il ne sait pas pourquoi ça rame, pourquoi une connexion est tombée, pourquoi son cache est vide. L'inspecteur est ce qui rend les compositions data utilisables, pas un luxe optionnel.
Les fonctionnalités avancées d'observabilité ne sont pas des extensions entreprise — ce sont des plugins OSS qui écoutent le bus. N'importe qui peut en implémenter sans modifier le moteur ni l'inspecteur de base :
// Plugin Prometheus — observateur pur sur le bus
engine.on("conn:error", ({ name, error }) => prometheus.counter.inc())
engine.on("queue:depth", ({ opId, depth }) => alerting.check(depth))
engine.on("op:done", ({ opId, duration }) => auditLog.write(...))
C'est le même modèle de distribution que les operators-lib — le bus émet, les plugins écoutent, le moteur ne sait pas qu'ils existent.
L'inspecteur I/O couvre trois niveaux simultanément :
Niveau 1 — Connexions État en temps réel des ressources externes initiées par le consommateur (ADR-044) :
prod-pg ● actif pool 3/10 latence 12ms
datalake ● actif session ok
twitter ○ idle dernière activité 14s
fs-local ● actif —
Niveau 2 — Cache moteur État du cache DataInMemory par step (ADR-044 §3) :
step-3-out-1 memory 1 240 lignes 4.2 MB ✓ valide
step-7-out-1 memory 800 lignes 1.1 MB ✓ valide
step-12-out-1 byref/sql ~8M lignes — ⚠ mutable
step-15-out-1 byref/api 100 lignes — ⚠ mutable
Niveau 3 — Flux en cours Activité des opérateurs pendant l'exécution :
TwitterStream émission 128 tweets/min queue: 3
InterestFilter running —
SqlInsert waiting connexion prod-pg
ParquetInsert done ⚠ compensation non supportée
L'inspecteur I/O est un panneau flottant dans le mode Visual, activable pendant l'exécution. Il se superpose au canvas sans le remplacer — la topologie reste visible.
En dehors d'une exécution active, il bascule en mode historique : état du cache et des connexions après le dernier run. Accessible via le panneau droit (onglet I/O).
Ce placement est soumis à validation Nora — voir Questions ouvertes.
L'inspecteur est alimenté par le bus interne du moteur. Les événements suivants sont ajoutés au bus (ADR-036) :
// Cache
engine.on("cache:set", ({ stepId, kind, rows, bytes }) => {})
engine.on("cache:invalidated", ({ stepId }) => {})
engine.on("cache:hit", ({ stepId, consumer }) => {})
// Connexions — émis par le consommateur via hook engine
engine.on("conn:active", ({ name, kind, meta }) => {})
engine.on("conn:idle", ({ name }) => {})
engine.on("conn:error", ({ name, error }) => {})
engine.on("conn:closed", ({ name }) => {})
// Flux
engine.on("op:running", ({ opId }) => {})
engine.on("op:waiting", ({ opId, reason }) => {})
engine.on("op:done", ({ opId, duration }) => {})
engine.on("queue:depth", ({ opId, depth }) => {})
Règle : ces événements sont émis par le moteur et le consommateur — jamais consommés par le moteur lui-même. L'inspecteur est un observateur pur, sans effet de bord sur l'exécution.
queue:depthconn:*system.jsengine.emit() — contratL'inspecteur dépend des événements bus d'ADR-036, du cache d'ADR-044, et des connexions d'ADR-044. Il ne peut pas être implémenté avant que ces deux ADRs soient Accepted et en production. Version cible : v0.24+ après stabilisation du mode Data (v0.23).
L'inspecteur vit dans le panneau droit en permanence, onglet "I/O" à côté du panneau Data.
Avantage : toujours accessible, non intrusif. Inconvénient : trop petit pour afficher les trois niveaux simultanément. L'inspecteur perd sa valeur si l'information est compressée.
Décision : onglet droit pour le mode historique (post-run), panneau flottant pour le mode live (pendant l'exécution). Les deux coexistent.
Chaque nœud du canvas affiche son état directement — icône de statut, badge cache, indicateur connexion.
Avantage : information in-context, pas de panneau séparé. Inconvénient : surcharge visuelle le canvas, masque la topologie. Information partielle — pas de vue globale des connexions.
Décision : complémentaire, pas exclusif. Les badges nœuds sont une feature UX séparée (rôle Nora + Sofia), pas l'inspecteur I/O.
Un panneau de logs structurés, filtrable par niveau et par opérateur.
Avantage : simple à implémenter, familier pour les devs. Inconvénient : pas de vue spatiale, difficile à corréler avec le graphe. Les logs sont un complément, pas l'inspecteur.
Décision : les logs restent dans le panneau Console existant. L'inspecteur I/O est une vue structurée distincte.
Q1 — Placement UI final Panneau flottant vs onglet droit vs autre — à valider avec Nora. Le placement impacte la surface d'événements nécessaires (fréquence de mise à jour, throttling).
Q2 — Throttling des événements Un LiveSource à 1000 tweets/min génère 1000 événements/min sur le bus. L'inspecteur doit throttler l'affichage sans perdre les événements d'erreur. Stratégie à définir avec Axel.
Q3 — Inspecteur en CLI La CLI n'a pas d'UI mais pourrait bénéficier d'un mode --inspect qui dump l'état I/O sur stderr en JSON. Backlog — dépend de la maturité des événements bus.
Caméléon v2 — 13/03/2026
Le contrat opérateur Caméléon définit depuis ADR-015 deux méthodes :
{
run(inputs, ctx): output,
rollback(inputs, output, ctx): void
}
Le terme rollback est emprunté au vocabulaire des **transactions base de données** (ACID, SQL). Sa sémantique y est précise : annulation atomique d'une opération, comme si elle n'avait jamais eu lieu.
Cette sémantique est incorrecte pour Caméléon. Un SqlInsert qui a inséré 1240 lignes en base ne peut pas "ne jamais avoir eu lieu" — il fait un DELETE compensatoire. Un ApiPost qui a créé une ressource fait un DELETE sur l'endpoint. Ce sont des actions métier inverses, pas des annulations atomiques.
L'audit des standards workflow confirme que le terme dominant pour cette sémantique est compensate / compensation :
| Standard / outil | Exécution | Compensation |
|---|---|---|
| Saga pattern | execute / run | compensate |
| BPEL / BPMN | invoke | compensate |
| Dapr Workflow | activité | compensate |
| Elsa Workflows | execute | compensate / confirm |
| Oracle Workflow | RUN mode | CANCEL mode |
| Airflow | execute | — (idempotence recommandée) |
La distinction est fondamentale :
rollback — annulation technique atomique. Sémantique DB.compensate — action métier inverse. Sémantique workflow/saga.Caméléon est un moteur de workflow — compensate est le terme juste. C'est également cohérent avec les Workflow Patterns (van der Aalst) déjà référencés dans le modèle formel CVM.
// Avant — ADR-015, ADR-016, ADR-034, ADR-045
{
run(inputs, ctx): output,
rollback(inputs, output, ctx): void
}
// Après — cet amendement fait autorité
{
run(inputs, ctx): output,
compensate(inputs, output, ctx): void
}
// Avant
throw new Error("RollbackNotSupported")
// Après
throw new Error("CompensationNotSupported")
compensate() est une action métier inverse — elle n'annule pas l'opération au sens ACID, elle produit l'effet opposé :
| Opérateur | run() | compensate() |
|---|---|---|
SqlInsert | INSERT INTO ... | DELETE WHERE _cm_step = id |
ApiPost | POST /resource | DELETE /resource/{id} |
FileWrite | écriture fichier | suppression du fichier |
Materialize | résolution + cache externe | invalidation cache externe (Redis, Memcached) |
EmailSend | envoi email | → CompensationNotSupported |
TwitterDelete | DELETE tweet | → CompensationNotSupported |
compensate()compensate() couvre uniquement les side-effects externes (I/O, API, DB, fichiers, cache externe). Cache interne mémoire = hors périmètre (éphémère, détruit avec le process). Les données en transit (tokens, outputs) sont éphémères — leur inversion n'est pas dans le périmètre de cet ADR. Le kernel gère l'inversion des états connexions via backStep() ; compensate() gère l'effet monde réel de run().
compensate() reste obligatoire dans le contratMême si la compensation est impossible, l'opérateur doit déclarer compensate() — il lève CompensationNotSupported. Le moteur bloque le backward à ce step avec un message explicite.
Un opérateur sans compensate() déclaré est considéré comme une erreur de contrat — pas une compensation impossible.
// Opérateur EmailSend — compensation impossible, contrat respecté
{
run(inputs, ctx) { /* envoie l'email */ },
compensate(inputs, output, ctx) {
throw new Error("CompensationNotSupported: EmailSend is irreversible")
}
}
| ADR | Occurrences | Nature |
|---|---|---|
| ADR-015 | 3 | Contrat opérateur de base |
| ADR-016 | 1 | Contrat opérateur HumanInput |
| ADR-034 | 1 | Contrat opérateur Control |
| ADR-045 | 3 | Contrat opérateur data + exception |
compensatecompensate() dit explicitement "fais l'inverse métier"rollback dans operators-lib et le code existant
# Repérer toutes les occurrences
grep -r "rollback\|RollbackNotSupported" src/ operators-lib/
# Renommer mécaniquement
sed -i 's/rollback(/compensate(/g' **/*.js
sed -i 's/RollbackNotSupported/CompensationNotSupported/g' **/*.js
Migration sans risque — aucune logique ne change, seul le nom.
Caméléon v2 — 13/03/2026
ADR-036 (Proposed) a introduit engine.js comme facade. ADR-040 (Accepted) a décomposé le monolithe editor en 6 modules avec createEngine() comme point d'entrée. ADR-041 (Proposed) a défini les contrats d'adaptation.
Ces trois ADR décrivent les pièces — mais aucun ne spécifie la décomposition interne de l'engine ni le mécanisme d'exécution. La boucle d'orchestration (forward, run, backward, rewind) est aujourd'hui :
system.js (~280 loc) et runner.js (83 loc, CLI)Le design cible spécifié dans spec-cvm.md §1 introduit trois composants internes à l'engine. Cet ADR formalise cette décomposition, le contrat du bus, et la surface publique de createEngine().
L'engine est composé de trois modules internes. Les consommateurs n'en voient qu'un seul (createEngine()).
engine/
engine.js ← facade + bus owner
player.js ← orchestration (à créer)
kernel.js ← noyau Petri net pur (renommé depuis cvm.js)
libs/ ← types, controls, operator libraries
| Composant | Fichier | Rôle |
|---|---|---|
| kernel | engine/kernel.js | Noyau Petri net pur. États connexions, fireabilité, step atomique, historique. Émet cvm:stepped, cvm:undone, cvm:reset via _emit injecté. Ne détecte ni deadlock ni complétion. |
| player | engine/player.js | Orchestration temporelle. Forward, backward, run, rewind, pause, resume, stop. Arbitrage FIFO (_arbitrate). Résolution human/live/controls. Détection deadlock/complétion. Émet player:, composition:. |
| engine | engine/engine.js | Facade + bus owner. Instancie kernel + player + bus. Proxy pur — aucune logique, chaque méthode délègue. |
kernel, player, et engine sont des plain objects retournés par des factories — pas de classes, pas d'héritage (C6, ADR-019). createKernel(), createPlayer(), createEngine() retournent des objets littéraux.
engine.js ne contient aucune logique. Chaque méthode est un proxy pur vers kernel, player ou bus — zéro condition, zéro transformation. Si une méthode fait plus que déléguer, elle appartient à kernel ou player.
Le bus est un Observer (~15 loc) possédé par engine.js. Convention de nommage : noun:past-participle (C7).
Le mécanisme _emit est injecté par engine dans kernel et player à l'instanciation :
const kernel = createKernel({ _emit });
const player = createPlayer({ kernel, _emit, adapters });
Kernel et player n'importent pas le bus — ils reçoivent _emit par injection (C5).
| Groupe | Événement | Émetteur | Payload | ||||
|---|---|---|---|---|---|---|---|
| Petri | cvm:stepped | kernel | { opId, oldConns, newConns, outputs } | ||||
cvm:undone | kernel | { opId, step } | |||||
cvm:reset | kernel | — | |||||
| Player | player:state-changed | player | `{ state: idle\ | running\ | paused\ | waiting\ | error }` |
player:position-changed | player | { step, total } | |||||
player:skipped | player | { opId, reason } | |||||
| Composition | composition:started | player | { compositionId } | ||||
composition:ended | player | `{ steps, status: completed\ | deadlock }` | ||||
composition:errored | player | { opId, error } | |||||
| Topology | op:added | engine | op (descriptor) | ||||
op:removed | engine | opId | |||||
conn:added | engine | conn | |||||
conn:removed | engine | connId | |||||
subcomp:added | engine | sc | |||||
subcomp:removed | engine | scId | |||||
topology:changed | engine | — |
Note : le kernel ne détecte ni deadlock ni complétion. Il retourne
fireable() → []. Le player interprète :status: deadlocksi des connexions sont encoreNEW,status: completedsinon.
P2 —
player:state-changedhors modèle formel :idle | running | paused | waiting | errorsont des états de l'orchestrateur (player), pas du réseau de Petri. Ils n'ont pas de correspondant dans le modèle formel. Ce n'est pas une violation de P2 — P2 s'applique au modèle de composition (tokens, transitions, places), pas à l'infrastructure d'exécution. À documenter dans le paper v2.
const engine = createEngine(config?);
// Bus
engine.on(event, handler)
engine.off(event, handler)
// Transport (proxie player)
engine.forward()
engine.backward()
engine.run()
engine.runTo(opId)
engine.pause()
engine.resume()
engine.stop() // halt + rewind → idle
engine.rewind()
engine.reset()
// Chargement
engine.load(composition)
engine.serializeComposition()
// Observation
engine.state() // → { player: PlayerState, marking: Marking }
// Configuration opérateurs (ADR-020)
engine.setConfig(opId, key, value)
engine.getConfig(opId)
// Topology mutations
engine.addOp(op) // + emit op:added
engine.removeOp(opId) // + emit op:removed
engine.addConn(conn) // + emit conn:added
engine.removeConn(connId) // + emit conn:removed
engine.addSubComp(sc) // + emit subcomp:added
engine.removeSubComp(scId) // + emit subcomp:removed
engine.notifyTopologyChanged() // emit topology:changed
// Templates
engine.instantiate(tpl, alias, n, x, y, r) // → OpDescriptor
engine.findTemplate(label)
engine.getTemplate(templateId)
engine.templateIdByLabel(label)
// Registres (lecture seule)
engine.catalogue // [{ meta, ops }]
engine.types // { color, isCompatible, registry }
engine.widgets // { viewers, editors }
Exception C3 :
instantiate(tpl, alias, n, x, y, r)porte des données de layout (x,y,r) qui traversent le kernel sans être utilisées pour le firing. Choix assumé (ADR-021/039).
_arbitrate(fireables, ops) {
return ops.filter(op => fireables.includes(op.id))[0];
}
Déterministe, reproductible. Même composition, mêmes entrées → même ordre d'exécution. À formaliser dans le paper v2.
Les contrats d'adaptation définis dans ADR-041 sont injectés via config et consommés par le player :
createEngine({
interaction: { resolveHuman, resolveLive },
edit: { config, control },
display: { renderView },
files: { download },
player: { stepDelay, maxSteps, timeout }
})
Le player appelle interaction.resolveHuman(op) quand un opérateur human est fireable, files.download(data, filename) post-execStep si un opérateur produit un fichier, attend stepDelay ms entre chaque step en mode run(), etc. Le kernel ne connaît pas les adapters.
edit (config, control) est consommé par le player au moment du tir des opérateurs interactifs. display.renderView est appelé pour les opérateurs de sortie.
L'animation visuelle est best-effort : l'editor s'abonne à cvm:stepped sur le bus et anime de son côté, sans bloquer le player. stepDelay (ms, défaut 0) règle le rythme d'exécution — l'editor le fixe à ~300ms pour que l'animation soit visible, la CLI à 0.
cli/runner.js est supprimé quand player.js est créé. La CLI appelle directement createEngine(cliConfig).run(). Plus de duplication de boucle d'exécution.
_emit injecté par engine dans kernel et player (C5). Pas d'import du busGarder la boucle d'exécution dans engine.js lui-même. Rejeté — viole la discipline facade. engine.js serait à la fois proxy et logique d'orchestration. La surface est déjà à ~30 méthodes ; y ajouter ~300 loc de boucle rendrait le fichier ingérable.
Le consommateur injecte sa propre boucle d'exécution via config.player. Rejeté — la boucle est identique pour tous les consommateurs (seuls les adapters changent). Dupliquer la logique d'orchestration dans chaque consommateur est exactement le problème que player.js résout.
Ne pas renommer cvm.js → kernel.js. Rejeté — "CVM" est le nom du système complet (Caméléon Virtual Machine), pas d'un seul composant. Utiliser "CVM" pour le noyau Petri crée une ambiguïté avec la facade. "kernel" est standard dans la littérature des moteurs d'exécution.
animate callback (synchronisation player/renderer)Le player appelle animate(opId, dir, done) après chaque step et attend done() avant de continuer. L'editor contrôle ainsi le rythme d'exécution via le callback. Rejeté — couple le player (engine layer) au timing visuel de l'UI, même via adapter injecté. En mode run(), c'est l'UI qui dicte la cadence du moteur — inversion de responsabilité. stepDelay dans le player suffit pour un rendu best-effort ; la synchronisation parfaite step/animation n'est pas un prérequis fonctionnel.
Amender ADR-036 avec le contenu de cet ADR. Rejeté — ADR-036 est sous-spécifié au point que l'amendement serait une réécriture complète. Un nouvel ADR est plus honnête et plus lisible. ADR-036 reste dans l'historique avec le lien "Superseded by ADR-050".
cvm.js → kernel.js (aliaser l'ancien nom pendant la transition)player.js — extraire la boucle de system.js (~280 loc) + runner.js (83 loc)_emit dans kernel et player depuis engine.jsplayer:state-changed, composition:ended, etc.)engine.forward() → player.forward(), renommer opsRegistry → catalogue, grouper types, controlsengine.setConfig() / engine.getConfig() (ADR-020)runner.js — CLI appelle engine.run()
ADR-050 Accepted
→ renommer cvm.js → kernel.js
→ créer player.js (extraire de system.js + runner.js)
→ injecter _emit
→ aligner surface publique
→ supprimer runner.js
→ ADR-036 → Superseded
Caméléon v2 — 16/03/2026
ADR-011 a défini deux modes d'exécution — séquentiel (implémenté) et concurrent (futur). ADR-050 introduit player.js comme orchestrateur unique de l'exécution. Le player est le bon endroit pour implémenter la stratégie concurrent : il connaît les fireables, il orchestre les steps, il gère l'historique.
JavaScript est mono-thread. Pour un parallélisme réel sur des opérateurs CPU-bound, il faut des Web Workers (browser) ou Worker Threads (Node.js/Deno). Un worker est un thread OS complet avec son propre event loop, communiquant avec le thread principal par postMessage.
La théorie des réseaux de Petri garantit une propriété fondamentale : deux transitions sont indépendantes si et seulement si leurs pré-ensembles (places en entrée) sont disjoints. Des transitions indépendantes peuvent tirer simultanément sans conflit d'état — c'est la **sémantique vraie concurrence** (vs la sémantique interleaving du mode séquentiel).
En pratique, la concurrence apporte un gain réel uniquement pour les opérateurs CPU-bound (calcul lourd, transformations volumineuses). Les opérateurs I/O-bound (réseau, fichier, human, live) sont déjà concurrents via Promise.all dans le mode séquentiel — le thread principal cède la main pendant l'attente.
Le player implémente un mode concurrent basé sur un pool de Web Workers. Ce mode est opt-in par composition (execution.mode: concurrent). Le mode séquentiel reste le défaut.
Deux transitions t1 et t2 sont candidates à l'exécution parallèle si :
pre(t1) ∩ pre(t2) = ∅ (pré-ensembles disjoints — pas de place partagée)
Le CVM calcule déjà les fireables — le player étend ce calcul en partitionnant les fireables en groupes indépendants via analyse du graphe de connexions.
player.js (thread principal)
├─ _partition(fireables) → [[t1, t2], [t3]] ← groupes indépendants
├─ pour chaque groupe → WorkerPool.dispatch([t1, t2])
│ ├─ Worker 1 → op1.run(inputs) ← thread OS
│ └─ Worker 2 → op2.run(inputs) ← thread OS
├─ await Promise.all(workers)
└─ _mergeResults(results) → CVM state (section critique atomique)
cvm/
worker.js ← worker script (importé dans le Worker)
pool.js ← WorkerPool (N workers, queue de dispatch, recycling)
Le thread principal sérialise et envoie au worker :
// Main → Worker
{ opId, run: op.run.toString(), inputs: { ... } }
Le worker exécute et répond :
// Worker → Main
{ opId, outputs: { ... }, error?: string }
Contrainte sérialisation : op.run est envoyé comme string et eval()-é dans le worker, ou l'opérateur est un module importable via URL. Les opérateurs du catalogue standard sont tous importables (modules ES) — cette contrainte est satisfaite pour std-*.
Les opérateurs interactifs (interaction: "human" ou interaction: "live") restent toujours sur le thread principal — ils ont besoin des adapters DOM. Le player les identifie et les exclut du dispatch worker. Si un groupe ne contient que des opérateurs interactifs, il est traité en séquentiel.
interaction: undefined → worker (parallélisable)
interaction: "human" → main thread (séquentiel)
interaction: "live" → main thread (séquentiel)
Le mode concurrent modifie le format d'une entrée d'historique — une entrée peut représenter un groupe de tirs simultanés :
// Mode séquentiel
history[i] = { opId, snapshot }
// Mode concurrent
history[i] = { group: [{ opId, snapshot }, ...], concurrent: true }
execBackward() supporte les deux formats. Pour un groupe, le rollback est appliqué dans l'ordre inverse de l'ordre canonique (tri par opId) pour garantir le déterminisme.
// pool.js
createPool(size = navigator.hardwareConcurrency ?? 4)
.dispatch(opDescriptors) // → Promise<results[]>
.terminate()
Le pool réutilise les workers (pas de création/destruction par step). La taille est configurable, défaut = nombre de cœurs logiques.
Positives - Gain de performance réel pour les compositions CPU-intensive (Transform lourd, JSONParse sur gros fichiers, futures libs ML) - Fondement théorique solide — la partition par pré-ensembles disjoints est garantie sans race condition par la théorie Petri (ADR-001) - Transparent pour les opérateurs — run() ne change pas, seul le dispatch change - Opt-in — aucune régression sur les compositions existantes
Négatives / contraintes - op.run doit être sérialisable (string) ou importable (module URL) — interdit les closures capturant des variables externes non-sérialisables - Overhead postMessage (copie structurée) — contre-productif pour les opérateurs rapides. Le player applique un seuil : si le groupe ne contient qu'un seul opérateur non-interactif, il reste sur le main thread - SharedArrayBuffer (mémoire partagée haute-perf) requiert les headers HTTP COOP: same-origin et COEP: require-corp — réservé aux déploiements contrôlés, pas activé par défaut - Le rollback de groupes est plus complexe — format d'historique étendu (rétrocompatible)
| Alternative | Raison du rejet |
|---|---|
Promise.all sans Workers | Pas de vrai parallélisme CPU — améliore l'I/O uniquement (déjà le cas) |
| Rust/WASM pour le CVM | ADR-010 (rejet porté Go/Rust tant qu'aucun problème de perf mesuré) |
| SharedArrayBuffer seul | Trop complexe, nécessite des atomiques pour synchroniser l'état CVM |
| Un Worker par opérateur | Overhead trop élevé pour les petites compositions |
Deux phases dans la roadmap :
WorkerPool. Tests de non-régression boucle séquentielle.cvm/worker.js, cvm/pool.js, extension cvm/player.js_partition, _mergeResults, group backward), mise à jour cvm/engine.js facade, testsEn v0.21, le moteur supporte les 5 workflow patterns de base (WCP-2 à WCP-5, WCP-21), soit Fork, Join, Gate, Merge, et Loop. C'est un milestone : les patterns de contrôle fondamentaux définis par van der Aalst et al. (2003) sont couverts.
La v0.22 introduit le canvas data — une feature de représentation visuelle des flux de données dans les compositions. Le risque identifié : si le canvas data impose des comportements différents selon le contexte de rendu, la couche visuelle peut créer une dépendance implicite avec la sémantique du moteur et tordre le CVM sans que ce soit visible immédiatement.
Par ailleurs, l'évaluation systématique des WCP est planifiée comme section 6 du paper v2. Elle nécessite un protocole documenté : composition de référence par pattern, comportement attendu, verdict formel.
Deux besoins convergent donc : protéger le moteur avant v0.22, et produire la matière du paper v2.
Le protocole d'évaluation WCP est rédigé et exécuté avant v0.22.
Chaque pattern supporté est formalisé en cas de test canonique selon la structure suivante :
Pattern : WCP-N — Nom standard
Opérateur : Nom Caméléon
Composition : extrait .cm.js minimal reproductible
Entrée : état initial des tokens (NEW / OLD / EMPTY)
Attendu : état final attendu + ordre de fire
Verdict : ✅ Conforme / ⚠️ Partiel / ❌ Non conforme
Note : justification formelle si écart
Séquence :
Scope v0.23 — deux volets :
Volet 1 — Protocole WCP control flow (5 patterns supportés)
| WCP | Opérateur | Raison de priorité |
|---|---|---|
| WCP-2 AND-split | Fork | Parallélisme — risque fort si canvas data crée des branches de rendu |
| WCP-3 AND-join | Join | Synchronisation — sensible à tout changement de timing |
| WCP-4 XOR-split | Gate | Condition — dépend de DataBool, potentiellement impacté par canvas |
| WCP-5 XOR-join | Merge | ANY policy — vérifier qu'aucun rendu ne trigger un fire parasite |
| WCP-21 Structured Loop | Loop | Cycles — le plus récent, le moins éprouvé |
Les cas de test sont versionnés dans docs/tests/wcp/ et exécutés en CI avant chaque release à partir de v0.24.
Volet 2 — Réflexion WCP data patterns
En v0.22, une première réflexion est engagée sur les data patterns. En v0.23, cette réflexion est approfondie et débouche sur une position formelle : quels patterns de données le moteur doit absorber, lesquels restent dans la couche de représentation. L'objectif est d'arriver au canvas data avec une décision documentée, pas une hypothèse.
Ce protocole est une précondition au chantier canvas data (v0.26), pas à sa livraison.
Positif
Point de vigilance
Cet ADR formalise un principe fort qui doit être intégré aux principes d'architecture du produit :
Protocole avant fonctionnalité — Toute feature susceptible d'affecter la sémantique du moteur est précédée d'un protocole de test formel sur les comportements existants. Le protocole est la précondition au chantier, pas à la livraison.
Ce principe généralise au-delà des WCP : il s'applique à toute évolution du moteur (nouveaux opérateurs, canvas, transport, LiveSource, etc.).
Action requise : ajouter ce principe dans doc/architecture/principles.md avec référence à ADR-048 comme première occurrence.
Note sur l'origine de ce principe : contrairement aux 11 principes existants (SRP, DDD, Antifragility, Pure Core/Impure Shell, etc.) issus de la littérature, "Protocole avant fonctionnalité" est natif à Caméléon. Il émerge de la rencontre entre le modèle formel (CVM à sémantique prouvable), la recherche académique (vérification formelle des Petri nets), et la réalité du produit. Il n'existe que parce que le moteur a des propriétés formelles à protéger — on ne le trouve pas dans Fowler ou les 12-factor apps. Il appartient au projet.
La v0.23 lance la R&D sur les libs standards. La question se pose : comment décider quels opérateurs entrent dans le catalogue ? Trois sources possibles — liste théorique exhaustive, littérature WCP, cas d'usage terrain — mènent à des catalogues différents et potentiellement incohérents si on les combine sans méthode.
Un catalogue trop théorique contient des opérateurs jamais utilisés et manque des besoins réels. Un catalogue trop empirique produit des opérateurs ad hoc, non généralisables, difficiles à positionner académiquement.
**Un opérateur n'entre dans le catalogue que s'il est justifié par un cas d'usage ET mappé sur un pattern connu ou documenté comme extension formelle.**
Cette règle est appliquée via un protocole en trois passes.
Les cas d'usage de référence sont décomposés en primitives fonctionnelles : que fait cette composition concrètement, opérateur par opérateur ?
Cas d'usage v0.23 : - usecase-team-collaboration.cm - usecase-meute-myosis-mira.cm - usecase-context-keeper.cm (x2 variantes)
Livrable : liste des primitives nécessaires, nommées en langage métier, sans référence à un pattern ou une implémentation.
Chaque primitive de la passe 1 est mappée :
| Cas | Action |
|---|---|
| Existe dans WCP | Nommé selon le standard WCP. Implémenté. |
| Existe dans la littérature (hors WCP) | Nommé selon la référence. Documenté comme pattern connu. |
| N'existe pas dans la littérature | Documenté comme extension Caméléon avec justification formelle. |
| Existe dans la littérature mais absent des use cases | Backlog académique — pas d'implémentation sans cas d'usage. |
Livrable : tableau de mapping primitives → patterns, avec statut pour chaque.
Chaque opérateur retenu est formalisé en cas de test canonique :
Opérateur : Nom
Pattern : WCP-N ou Extension Caméléon
Composition : extrait .cm.js minimal reproductible
Entrée : état initial des tokens (NEW / OLD / EMPTY)
Attendu : état final + ordre de fire
Verdict : ✅ Conforme / ⚠️ Partiel / ❌ Non conforme
Livrable : suite de tests versionnée dans docs/tests/wcp/.
Suffisant, pas exhaustif. L'objectif n'est pas de couvrir les 43 WCP ni de produire un catalogue théoriquement complet. L'objectif est d'avoir exactement ce qu'il faut pour les cas d'usage de référence, avec une couverture WCP maximale sur ce périmètre.
Deux critères de priorisation : 1. L'opérateur est nécessaire à au moins un cas d'usage de référence 2. L'opérateur couvre un WCP non encore supporté
Un opérateur qui remplit les deux critères est prioritaire. Un opérateur qui n'en remplit aucun n'entre pas dans le catalogue en v0.23.
Le .cm.js est l'une des trois représentations de la trijection Caméléon (graphe / code / formalisme). En tant que DSL interne à JavaScript, il doit être suffisamment expressif pour couvrir les cas d'usage de référence, et suffisamment simple pour que la bijection texte ↔ visuel (v0.24) soit naturelle — pas une traduction laborieuse.
La littérature (AFLOW, ICLR 2025) a abandonné les Petri nets au profit du code Python pour des raisons d'expressivité. Le modèle plug/opérateur de Caméléon résout une partie de ce problème en ajoutant une couche sémantique au-dessus des CPN colorés : types sur les connexions, politiques de tir déclarées, direction explicite des plugs. Mais le DSL lui-même doit être travaillé pour ne pas réintroduire de la verbosité là où le modèle est élégant.
Le principe directeur est P12 — Design Equation : ce qu'on fait en deux clics sur le canvas doit s'écrire en deux lignes de code. Si la bijection crée une asymétrie de complexité dans un sens, le DSL n'est pas bon.
**Le DSL .cm.js est évalué et redessiné si nécessaire en v0.23, en parallèle de la solidification des use cases de référence.**
Le protocole est le suivant :
Passe 1 — Audit d'expressivité
Chaque use case de référence (team-collaboration, meute-myosis, context-keeper) est décomposé en composition .cm.js. Pour chaque pattern identifié, on mesure :
.cm.js sans documentationPasse 2 — Identification des frictions
Les patterns verbeux ou non-intuitifs sont documentés. Pour chaque friction : - Est-ce une limitation du DSL (syntaxe) ? - Est-ce une limitation du modèle (sémantique) ? - Est-ce une limitation des opérateurs disponibles ?
Les frictions de type syntaxe sont des candidats au redesign du DSL. Les frictions de type sémantique remontent au CVM (ADR dédié). Les frictions de type opérateurs remontent au catalogue (ADR-049).
Passe 3 — Propositions
Pour chaque friction syntaxique identifiée, une proposition de reformulation est produite avec ses implications sur la bijection texte ↔ visuel (v0.24).
Compatibilité JS : le .cm.js doit rester du JavaScript valide — pas de syntaxe custom qui nécessite un parseur dédié. Les améliorations d'expressivité passent par des conventions, des helpers, et des noms bien choisis.
Stabilité : le DSL ne se redesigne pas à chaque version. Une fois les patterns de base stabilisés en v0.23, ils sont fixés jusqu'à un ADR explicite de modification.
Trijection : toute modification du DSL doit être évaluée sur les trois représentations simultanément. Un DSL plus concis mais qui crée une asymétrie avec le graphe ou le formalisme n'est pas une amélioration.
La question de l'expressivité des Petri nets est un argument académique récurrent (AFLOW 2025 l'utilise pour justifier l'abandon des Petri nets). Le DSL .cm.js est la réponse de Caméléon à cet argument : les CPN colorés avec une couche DSL bien conçue sont plus expressifs que le code nu, tout en conservant les garanties formelles.
Ce travail alimente directement la section 2 (related work) et la section 4 (DSL design) du paper v2.
.cm.jsLa taxonomy (03/03/2026) définit trois firing policies : ANY, ALL, COND. ANY et ALL sont claires. COND est documentée comme "déléguée à l'opérateur" — ce qui est une description d'implémentation, pas une sémantique.
Deux problèmes ont émergé lors de l'implémentation des boucles et de la lib struct :
1. Merge (ANY défaut) est incompatible avec les boucles
La garde EMPTY stricte du défaut ANY (∄ EMPTY AND ∃ NEW) empêche Merge de tirer au premier tour d'une boucle quand in_a (feedback) est EMPTY. Or Merge est documenté comme "la pièce maîtresse des boucles". Contradiction.
Le comportement original correct de Merge est ∃ input NEW (EMPTY ignoré) — ce qui n'est ni ANY défaut, ni ALL, ni un COND arbitraire.
2. Gate et Switch n'ont pas de sémantique spécifiée
Gate nécessite in_data = NEW ET in_condition = NEW — c'est ALL sur deux inputs spécifiques. Switch dépend de si la clé de routage est un plug ou une config.
Multiplier les canFire() custom par opérateur produit un catalogue incohérent et difficile à auditer. Un contributeur ODK ne peut pas savoir quelle logique implémenter sans lire le code des opérateurs existants.
Remplacer le canFire() impératif par une config struct déclarative.
Chaque opérateur déclare sa condition de tir via un objet firingCondition dans son descripteur. Le moteur évalue cette config — plus besoin de canFire() custom.
firingCondition: {
require: [...], // ces inputs doivent être NEW
any: [...], // au moins un de ceux-là doit être NEW
// EMPTY est ignoré sur tous les inputs listés dans `any`
// EMPTY est bloquant sur tous les inputs listés dans `require`
}
Règles d'évaluation par le moteur :
canFire =
require.every(id => state[id] === "NEW")
AND
any.length === 0 OR any.some(id => state[id] === "NEW")
Si firingCondition est absent, le moteur applique le défaut ANY strict (∄ EMPTY AND ∃ NEW sur tous les inputs) — rétrocompatible.
| Opérateur | firingCondition | Sémantique |
|---|---|---|
| Processor, Fork, Sink | (défaut) | ANY strict — aucun EMPTY |
| Sync | { require: ["in_a", "in_b"] } | ALL — tous NEW |
| Merge | { any: ["in_a", "in_b"] } | ∃ NEW, EMPTY ignoré |
| Gate | { require: ["in_data", "in_condition"] } | ALL sur 2 inputs |
| Switch (clé plug) | { require: ["in_data", "in_key"] } | ALL sur 2 inputs |
| Switch (clé config) | { any: ["in_data"] } | ANY sur 1 input réel |
{
id: "merge",
label: "Merge",
family: "structural",
firingCondition: {
any: ["in_a", "in_b"] // EMPTY ignoré — pièce maîtresse des boucles
},
inputs: [
{ id: "in_a", type: "Any" },
{ id: "in_b", type: "Any" }
],
outputs: [{ id: "out_data", type: "Any" }],
run(inputs) {
return { out_data: inputs.in_a ?? inputs.in_b };
}
}
Positif
Points de vigilance
firingCondition gardent le comportement ANY strict actuel{ any: [...] } sanscanFire() impératif reste possible comme escape hatch pour les casLa taxonomy.md doit être mise à jour pour refléter : - La distinction ANY strict (défaut) vs any config (EMPTY ignoré) - La sémantique spécifiée de Gate (ALL sur in_data + in_condition) - La sémantique spécifiée de Merge (any, EMPTY ignoré) - La suppression de "délégué à l'opérateur" comme description de COND
canFire() original