Modèle d'exécution
001 Modèle d'exécution fondé sur les réseaux de Petri Accepted
01/03/2026 · V. Tariel, O. Cugnon de Sévricourt

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 :

  • Déterminisme garanti — même graphe, même résultat, toujours. Les modèles event-driven sont "best effort".
  • Observabilité complète — chaque état de connexion est explicitement visible à tout moment.
  • Fondement académique vérifié — ancré dans la littérature, formellement prouvé, peer-reviewed.
  • Pas de race conditions, pas d'exécution implicite ou probabiliste
  • Le débogage devient visuel — observer les états suffit
  • La correspondance avec les Workflow Patterns (van der Aalst) est directe — 43 patterns couverts
  • Complexité accrue par rapport à un simple event bus, justifiée par les garanties formelles
002 Mapping Place → Connexion (et non Place → Opérateur) Accepted
01/03/2026

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 :

  • Visibilité du flux — l'état des données est observable sur les arcs, là où elles transitent réellement, pas sur les nœuds qui les traitent.
  • Pureté des opérateurs — les opérateurs sont des transformations sans état interne visible, ce qui simplifie le rollback et le test unitaire.
Petri netCaméléon
PlaceConnexion (plug out → plug in)
TransitionOpérateur
TokenDonnée en transit
Arc place→transitionPlug input
Arc transition→placePlug output
  • La bijection graphe ↔ texte est naturelle — une connexion = une ligne dans le .cm
  • Le rendu visuel est direct — colorier les arcs selon leur état suffit pour l'observabilité
  • Le rollback est simple — inverser les états des connexions d'un tir
  • Les opérateurs sans inputs (Sources) requièrent une règle de tir spéciale (voir ADR-003)
003 Règle de tir par défaut : ANY + garde EMPTY Accepted
03/03/2026 — Confirmé comportement C++/Qt original

Un 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
  • Premier passage — l'opérateur attend que toutes ses entrées aient reçu une donnée (pas de EMPTY). Comportement de barrière implicite.
  • Régime établi — réactif dès qu'un input est rafraîchi (NEW), même si les autres sont OLD. Comportement de streaming naturel.
  • Les opérateurs Source requièrent une règle inversée : ils tirent si leurs sorties sont toutes EMPTY
  • La garde EMPTY est universelle — elle s'applique à toutes les policies (ANY, ALL, COND)
004 Trois firing policies : ANY, ALL, COND Accepted
03/03/2026

La 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).

PolicyCondition de tirCas d'usage
ANY≥ 1 entrée NEW, aucune EMPTYDéfaut — Processor, Fork, Sink, Merge
ALLToutes entrées NEWSync — barrière de synchronisation explicite
CONDDélégué à op.condition(états)Switch, Gate — routage conditionnel
  • La policy est déclarée dans operators-lib.js — le .cm n'y touche pas
  • La garde EMPTY reste universelle — elle s'applique avant l'évaluation de toute policy
  • COND délègue la condition à l'opérateur mais uniquement sur les états, pas les valeurs (voir ADR-006)
  • Une policy future FIRST est prévue pour WCP-9 (Discriminator — premier LLM qui répond)
006 COND évalue les états des connexions, pas les valeurs des données Accepted
03/03/2026

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 };
}
  • Moteur purement structurel — aucune connaissance des types métier, portable et testable sans données réelles
  • Déterminisme garanti — la condition ne dépend pas de valeurs volatiles ou de side-effects
  • Séparation nettecondition = topologie du flux, run = logique métier
  • Le moteur peut être vérifié formellement sans avoir à simuler des données
011 Modes d'exécution : séquentiel (actuel) / concurrent (futur) Accepted
03/03/2026

Dans 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.

ModeComportementhistory[i]Statut
SéquentielUn seul opérateur tire par stepUn tirActuel
ConcurrentTous les READY tirent simultanémentGroupe de tirsFutur

execution:
  mode: sequential | concurrent   # config .cm
  • Le mode séquentiel garantit un ordre de tir déterministe et un rollback trivial
  • Le mode concurrent nécessitera un execBackward() capable de gérer un groupe de tirs avec un ordre d'annulation déterministe garanti — point de vigilance architectural
  • Le rendu visuel du mode concurrent devra représenter plusieurs tirs simultanés — animation à concevoir
Catalogue d'opérateurs
005 Taxonomie des opérateurs — deux familles Accepted
03/03/2026 — "structurel" remplace "generics" (terme C++/Qt original)

L'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érateurPortsPolicyRôle
SyncN→NALLBarrière — attend toutes les entrées simultanément. Copie les références.
Fork1→NANYDuplique une donnée vers N sorties
Join/MergeN→1ANYFusionne N branches — pièce maîtresse des boucles
Switch1→NCONDRoutage conditionnel exclusif
Gate1→1CONDLaisse passer ou bloque selon signal de contrôle

Behavioral — traitement de données, interaction, injection

OpérateurPortsPolicyRôle
Source0→NSpécialProduit données initiales (tire si sorties EMPTY)
SinkN→0ANYConsomme / termine un flux
ProcessorN→MANYTransformation générique
AccumulatorN→MANYTraitement 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
012 Correspondance avec les Workflow Patterns (van der Aalst et al.) Accepted
03/03/2026

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.

WCPPatternCaméléon
WCP-1SequenceImplicite (connexion)
WCP-2Parallel SplitFork (1→N)
WCP-3SynchronizationSync (ALL)
WCP-4Exclusive ChoiceSwitch (COND)
WCP-5Simple MergeMerge (ANY)
WCP-6/8/10/11/21Multi-Choice, Cycles, Termination…Composables avec les primitives
  • Le catalogue est théoriquement complet pour les cas d'usage courants dès v1
  • Extensions futures prioritaires pour les cas IA :
  • WCP-9 Discriminator → policy FIRST (premier LLM qui répond gagne)
  • WCP-12→15 Multiple Instances → MapReduce (fork dynamique + sync)
  • WCP-19/20/25 Cancel → signal CANCEL propagé (timeout LLM)
  • Chaque nouvel opérateur devra référencer son WCP correspondant dans la documentation
019 Contrat d'un opérateur dans operators-lib.js Accepted
04/03/2026 · O. Cugnon de Sévricourt

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

ChampTypeDescription
idstringIdentifiant unique dans la lib (snake_case)
labelstringNom affiché dans l'interface
family"structural" \"transform"Famille de l'opérateur (ADR-005)
inputsPlug[]Liste des plugs d'entrée — tableau vide pour les sources
outputsPlug[]Liste des plugs de sortie — tableau vide pour les sinks
runFunctionLogique d'exécution (voir contrat ci-dessous)

Champs optionnels

ChampTypeDéfautDescription
firingPolicy"ANY" \"ALL" \"COND""ANY"Politique de tir (ADR-004)
canFireFunctionabsentRequis si firingPolicy === "COND" — voir contrat ci-dessous
rollbackFunctionabsentAnnulation formelle — voir contrat ci-dessous
categorystring""Chemin dans l'arborescence du catalogue — convention "Domain / Subcategory" (ex : "AI / LLM", "Web / Connectors", "MCP")
descriptionstring""Ligne de description pour le catalogue
interaction"human" \"live"absentMode d'interaction. "human" : bloque le flux, attend validation unique (ADR-034). "live" : re-fire sur chaque événement utilisateur, ouvre la composition (ADR-035).
controlstringabsentId du control UI à utiliser (ADR-034). Le renderer résout via CONTROL_MAP.

Descripteur de plug

ChampTypeDescription
idstringIdentifiant court, unique dans l'opérateur (snake_case)
typestringType de données : DataString, DataJSON, Binary, DataBool
labelstringLibellé affiché — optionnel, défaut = id

Contrat run(inputs)

  • Reçoit inputs : dict { plug_id: value } — uniquement les plugs connectés dont l'état est NEW
  • Retourne { plug_id: value } pour chaque plug de sortie déclaré
  • Peut retourner une Promise — le moteur attend la résolution avant de transitionner les connexions
  • Les plug ids utilisés sont les ids courts déclarés dans outputs (le moteur gère la qualification à l'exécution)

Contrat canFire(states)

  • Appelé par le moteur à la place de la logique ANY/ALL quand firingPolicy === "COND"
  • Reçoit states : dict { plug_id: "NEW" | "OLD" | "EMPTY" } — états des connexions entrantes (ADR-006)
  • Retourne booleantrue = l'opérateur peut tirer
  • Ne reçoit pas les valeurs des données — la condition porte uniquement sur les états (ADR-006)
  • Absent et firingPolicy !== "COND" : ignoré

Contrat rollback(outputs)

  • Reçoit exactement ce que run() a retourné lors de l'exécution à annuler
  • Void — aucun retour attendu par le moteur
  • Peut retourner une Promise — le moteur attend avant de continuer le rewind
  • Si absent : le rewind remet les connexions à NEW sans annuler les effets de bord

Exemple


// 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
  }
}

  • Tout opérateur est testable unitairement : op.run({ in_binary: buf }) sans le moteur ni l'UI
  • Les opérateurs sont de simples objets — sérialisables, diffables, générables par un LLM
  • category 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"
  • Le moteur qualifie les plug ids à l'exécution : instance_alias.plug_id (convention .cm.js, ADR-014)
  • Pour 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 moteur
  • Les opérateurs async (run() → Promise) sont supportés sans changement de contrat — le moteur await
  • rollback() absent = rewind permis mais sans annulation des effets de bord (opérateurs idempotents seulement)
  • Les viewers et éditeurs par plug type sont spécifiés séparément (ADR-016)
  • Amendé par ADR-034 / ADR-035 — interaction ("human" / "live") et control ajoutés comme champs optionnels du descripteur
020 Valeurs de plug et configuration d'opérateur In Review
04/03/2026 (amendé 05/03/2026) · O. Cugnon de Sévricourt

ADR-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 :

  1. Configuration de l'opérateur — paramètres internes qui ne sont pas des données en transit : API key, model name, valeur initiale d'une source, file path. Ces paramètres ne sont pas des plugs — ils ne sont pas connectables dans le graphe.
  1. Valeur par défaut d'un plug — un plug d'entrée connectable qui a une valeur sensible par défaut quand il n'est pas connecté (ex : 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.


Deux mécanismes distincts

MécanismeNiveauConnectableUIExemple
Configuration opérateurOpérateurNonMenu sur l'opérateur → éditeur de configAPI key, model, valeur initiale, file path
Valeur par défaut de plugPlug d'entréeOui (override par connexion)Vue (panneau droit)temperature: 0.7

1. Configuration opérateur (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 :

ChampTypeDescription
typestringType de la valeur (pour l'éditeur de config)
labelstringLibellé affiché dans l'éditeur de config
defaultanyValeur 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.


2. Valeur par défaut de plug (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() :

  1. Si le plug est connecté et la connexion est NEW → valeur de la connexion
  2. Sinon, si default est déclaré dans le plug descriptor → valeur par défaut
  3. Sinon → plug absent de inputs (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.


Amendment de la signature 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.


  • Plugs et config sont deux concepts distincts — les plugs sont des points de connexion dans le graphe, la config est un paramétrage interne de l'opérateur
  • 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 valeur
  • L'UI expose deux surfaces d'édition distinctes : la Vue (panneau droit) (données en transit) et l'éditeur de config (paramètres opérateur)
  • Un opérateur source (sans inputs) utilise config pour ses valeurs — pas de plug factice
  • La config est sérialisée dans le .cm.js au niveau de l'instance (config: { ... }) — lisible et diffable
  • Les default de plug permettent un comportement out-of-the-box sur les plugs connectables mais facultatifs
  • La validation de la composition vérifie que chaque config requis (sans default) est fourni dans l'instance

Caméléon v2 — 04/03/2026 (amendé 05/03/2026)

027 Lifecycle d'exécution d'un opérateur Accepted
04/03/2026 · O. Cugnon de Sévricourt

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.

ÉtatConditionResponsable
IDLEL'opérateur existe dans la composition mais ses préconditions ne sont pas rempliesMoteur
READYcanFire() retourne true (ou policy ANY/ALL satisfaite)Moteur
RUNNINGrun() a été appelé, la Promise n'est pas encore résolueMoteur
COMPLETEDrun() a retourné avec succès — les sorties sont produitesMoteur
FAILEDrun() a levé une exception ou la Promise a été rejetéeMoteur
CANCELLEDrun() interrompu via signal.abortedMoteur

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

FAILEDCANCELLED
CauseException / Promise rejetéeInterruption explicite via signal
Entrées consommées ?Non (restent NEW)Non (restent NEW)
rollback() appelé ?NonNon
Réexécutable ?Oui (après reset)Oui (immédiatement)
CVM LogErreur avec message"Interrupted by user"

Gestion des erreurs

  • Si 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 produites
  • Si run() retourne une Promise rejetée : même comportement que l'exception synchrone
  • L'état FAILED est enregistré dans l'historique CVM — visible dans le CVM Log
  • Le moteur ne retry pas automatiquement — le retry est une décision de l'utilisateur ou d'un opérateur superviseur en amont
  • rollback() n'est jamais appelé sur un opérateur FAILED — il n'y a rien à annuler

Timeout

  • Pas de timeout global imposé par le moteur — chaque opérateur est responsable de ses propres contraintes temporelles (via AbortController ou équivalent dans run())
  • Le moteur expose un signal d'interruption optionnel : run(inputs, { signal }) — l'opérateur peut écouter signal.aborted pour interrompre proprement

Amendment 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.


  • Le moteur ne consomme les entrées (NEW → OLD) que si run() réussit — un échec ne corrompt pas l'état du réseau
  • L'état RUNNING est nécessaire pour l'exécution concurrente future (ADR-011) — plusieurs opérateurs RUNNING simultanément
  • Le CVM Log affiche l'état de chaque opérateur, y compris FAILED avec le message d'erreur
  • L'état CANCELLED est distinct de FAILED — le CVM Log distingue une erreur d'une interruption volontaire
  • Le signal d'interruption est optionnel — les opérateurs existants qui ne l'utilisent pas continuent de fonctionner
  • Le retry n'est pas un mécanisme du moteur — c'est un pattern compositionnel (opérateur Retry en amont)

Caméléon v2 — 04/03/2026

031 Standard Library : contenu et périmètre v0.15 Accepted
05/03/2026 · O. Cugnon de Sévricourt

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.


Libraries standard

LibraryFichierDomainDescription
std-iostd-io-lib.jsI/OData sources and sinks
std-structstd-struct-lib.jsStructureControl flow operators
std-datastd-data-lib.jsDataData transformation and formatting
std-humanstd-human-lib.jsHumanHuman-in-the-loop interaction (ADR-034)
std-livestd-live-lib.jsLiveLive interaction — open compositions (ADR-035)
std-connectstd-connect-lib.jsConnectExternal connectivity (HTTP, mail)
std-aistd-ai-lib.jsAILLM 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.


Catalogue complet

std-io — I/O

OpérateuridfamilycategoryInputsOutputsConfigv0.15
SourcesourcestructuralI/Oout_data : DataStringvalue : DataStringoui
SinksinkstructuralI/Oin_data : DataStringoui
FileLoaderfile_loadertransformI/Oout_data : Binaryfile : Binaryv0.17
FileSaverfile_saverstructuralI/Oin_data : Anyfilename : DataString, mimeType : DataStringv0.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

std-human — Human (v0.18)

OpérateuridfamilycategoryInputsOutputsConfigv0.18
MessageReviewermessage_reviewertransformHumanin_message : DataStringout_validated : DataBool, out_message : DataStringoui

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

std-live — Live (v0.18)

OpérateuridfamilycategoryInputsOutputsConfigv0.18
LiveSourcelive_sourcesourceInput / Liveout_data : Anyoui

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)

std-struct — Structure

All structural operators use Any types — pure token routing, no data transformation. Aligned with standard workflow patterns (van der Aalst).

OpérateuridfamilycategoryPatternInputsOutputsPolicyv0.15
ForkforkstructuralControl FlowWCP-2 AND-splitin_data : Anyout_a : Any, out_b : AnyANYoui
JoinjoinstructuralControl FlowWCP-3 AND-joinin_a : Any, in_b : Anyout_data : AnyALLoui
MergemergestructuralControl FlowWCP-5 XOR-joinin_a : Any, in_b : Anyout_data : AnyANYoui
GategatestructuralControl FlowWCP-4 XOR-splitin_data : Any, in_condition : DataBoolout_true : Any, out_false : AnyCONDoui
BarrierbarrierstructuralControl FlowBarrier (*)in_a : Any, in_b : Anyout_a : Any, out_b : AnyALLoui

(*) 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) :

PatternWCPDescriptionImplication moteur
LoopWCP-10 Arbitrary CyclesBoucle sans contrainte structurelle — arc retour dans le grapheDétection et support des cycles dans le réseau de Petri
Structured LoopWCP-21 Structured LoopBoucle avec pré/post-condition explicite (while/repeat)Structure while/repeat, condition de sortie
Implicit TerminationWCP-11Terminaison par épuisement des tokens — pas de sink expliciteDétection automatique de l'état terminal
MultiChoiceWCP-6 OR-splitRoutage vers une ou plusieurs branches selon conditionsRouting dynamique, plugs dynamiques
SyncMergeWCP-7 OR-joinSynchronisation de branches OR-splitDétection du nombre de branches actives
MultiMergeWCP-8Fusion sans synchronisation — chaque branche passe indépendammentPlugs dynamiques
DiscriminatorWCP-9Premier arrivé passe, les suivants sont ignorésCompteur de tokens consommés
RaceMulti-output Merge (premier arrivé passe avec routage apparié) — extension CaméléonCas d'usage que Merge ne couvre pas à documenter
RouterRoutage dynamique multi-branchesPlugs dynamiques, table de routage

std-data — Data

OpérateuridfamilycategoryInputsOutputsPolicyConfigv0.15
TransformtransformtransformData / Conversionin_data : Anyout_data : DataStringANYoui
FormatformattransformData / Presentationin_main : DataString, in_meta : DataStringout_data : DataStringALLseparator : DataStringoui
JSONParsejson_parsetransformData / Conversionin_data : DataStringout_data : DataJSONANYv0.17
JSONStringifyjson_stringifytransformData / Conversionin_data : DataJSONout_data : DataStringANYpretty : DataBoolv0.17
TemplatetemplatetransformData / Presentationin_template : DataString, in_data : DataJSONout_data : DataStringALLv0.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.

std-connect — Connect (reportée en v0.20)

OpérateuridfamilycategoryInputsOutputsConfigv0.15
APICallapi_calltransformConnect / HTTPin_url : DataString, in_body : DataJSONout_response : DataJSON, out_status : DataStringmethod : DataString, headers : DataJSONstretch

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.

std-ai — AI (reportée en v0.20)

OpérateuridfamilycategoryInputsOutputsConfigv0.15
CallOpenAIcall_openaitransformAI / LLMin_prompt : DataStringout_response : DataStringmodel, apiKey, systemPromptstretch
CallClaudecall_claudetransformAI / LLMin_prompt : DataStringout_response : DataStringmodel, apiKey, systemPromptstretch

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.


Types de plug utilisés

Tous les types référencés dans le catalogue, avec leur registre (ADR-028) :

TypeCouleurUsage
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.


Périmètre v0.15 vs backlog

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


  • Le split operators-lib.jsengine/libs/ suit exactement la répartition ci-dessus — les 3 premières libraries portent les opérateurs v0.15
  • La signature canonique run(inputs, { signal, config } = {}) amende ADR-019 — les opérateurs simples continuent de déclarer run(inputs)
  • Les opérateurs source (Source, FileLoader) et les opérateurs à configuration (CallOpenAI, Format…) utilisent le mécanisme config d'ADR-020 — pas de plugs factices
  • Le catalogue UI affiche les opérateurs groupés par meta.domain puis op.category — la structure du catalogue est définie par cet ADR
  • L'éditeur de config opérateur (menu contextuel) est un composant UI distinct de la Vue (panneau droit) — à implémenter en même temps que le catalogue UI ou en follow-up
  • Les libraries std-human et std-live ajoutent les opérateurs interactifs (v0.18) — interaction: "human" (ADR-034) et interaction: "live" (ADR-035)
  • Chaque library a un document de design détaillé dans doc/architecture/libs/ — cet ADR est la vue haute, les designs détaillent les signatures et comportements
  • Les libraries std-connect et std-ai sont reportées en v0.20 — elles nécessitent des dépendances externes (HTTP, clés API)
  • Les opérateurs backlog (Loop, Switch, Router, Filter, Map) nécessitent des modifications moteur (cycles Petri) ou un langage d'expression — ils seront ajoutés incrémentalement
  • La liste des types est cohérente avec le TYPE_REGISTRY de engine/types.js (ADR-028) — toute extension de type est documentée ici
  • L'état FAILED (ADR-027) est référencé par les opérateurs pouvant lever des exceptions (JSONParse, FileLoader)

Caméléon v2 — 05/03/2026

Architecture technique
008 Séparation en trois couches : operators-lib / .cm / moteur Accepted
03/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 */ }
}
  • Le .cm ne redéclare jamais inputs/outputs — ils viennent de la lib, le fichier est court et lisible
  • run() et rollback() testables unitairement sans le moteur
  • Un LLM génère un .cm valide en connaissant juste le catalogue — bijection prompt → pipeline
  • Le moteur est portable : même code dans le browser, le CLI Deno, et Tauri
  • Migration vers cette architecture : roadmap v0.14 (operators-lib)
014 Format des compositions : JS plutôt que YAML Superseded by ADR-021
03/03/2026 · O. Cugnon de Sévricourt

Le 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 :

  • Pas de parser — le moteur importe le .cm.js nativement, sans étape de transformation
  • Pas de redondance — les inputs/outputs viennent de la lib, le .cm ne déclare que les instances et les connexions
  • Validation statique — les imports manquants ou les types incorrects sont détectés avant l'exécution
  • LLM-compatible — un LLM génère du JS aussi bien que du YAML, avec l'avantage de pouvoir valider les imports
  • Cohérence de stack — toute la codebase est en JS, le .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"       ],
  ]
}

  • Le format YAML est abandonné — les .cm existants du POC seront migrés
  • La bijection texte ↔ graphe (ADR-013, v0.13) porte sur du JS, pas du YAML — l'éditeur intégré est un éditeur JS (CodeMirror/Monaco)
  • Le public cible est des développeurs — la lisibilité JS est suffisante
  • Un non-développeur passe par l'UI visuelle pour composer, pas par le fichier .cm.js directement
  • Si un format texte non-JS devient nécessaire (export, partage), une sérialisation JSON dérivée du modèle JS reste possible sans changer l'architecture
016 Viewers et éditeurs : nœuds du graphe ou panneau d'inspection In Review
03/03/2026 · O. Cugnon de Sévricourt

Le 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 :

  1. Faut-il conserver les viewers/éditeurs comme nœuds formels du graphe ? Cela préserve la pureté du modèle : tout ce qui interagit avec les données est visible dans la composition, versionnable, diffable, inspectable formellement.
  1. Ou vaut-il mieux les externaliser dans un panneau d'inspection latéral ? L'utilisateur clique sur un plug → le panneau droit affiche le viewer ou l'éditeur adapté au type du plug. Le graphe reste épuré ; l'interaction n'est plus un nœud mais un geste UI.

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é :

Nœuds formels du graphe (catalogue operators-lib)

ComposantPortsRôle
Éditeur sans entrée0→NInjecte une valeur initiale — équivalent UserInput
Éditeur avec entréeN→MReç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 */ }
}

Panneau d'inspection (UI uniquement, pas de nœud)

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.


  • Le graphe reste lisible — seules les interactions qui participent au flux y apparaissent
  • La pureté formelle est préservée là où elle compte — les éditeurs sont modélisés, versionnés, rejouables
  • Le .cm.js reste court — les viewers passifs ne polluent pas la topologie
  • L'éditeur avec entrée(s) couvre le cas Human in the loop sans introduire de type de nœud spécial
  • Le panneau d'inspection est une feature UI pure — il ne nécessite aucune modification du moteur CVM
  • La frontière est formellement définie et non ambiguë : produit une sortie → nœud. Observe seulement → panneau.
  • Point ouvert — lié à ADR-015 : HumanReview 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é.
017 Moteur CVM : JS pur dans le browser Accepted
03/03/2026 · O. Cugnon de Sévricourt

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 :

  • Go natif — performances maximales, mais nécessite une installation et casse l'unité de codebase (ADR-010)
  • WebAssembly (WASM) — performances proches du natif dans le browser, mais complexité de build, outillage lourd, debugging difficile
  • JS pur — natif dans le browser et dans Deno, même fichier pour tous les contextes d'exécution

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 :

  • Unité de codebase — le même cvm.js tourne dans le browser (renderer), le CLI (Deno), et Tauri (desktop) sans modification
  • Portabilité maximale — une URL suffit, aucune installation requise
  • LLM-compatible — le moteur est lisible, modifiable et générable par tout modèle de langage
  • Référence d'implémentation — le moteur JS constitue la spécification exécutable pour tout port futur (Go, Python, Rust) — si un port produit un résultat différent, c'est le JS qui a raison

WASM 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.


  • Embeddable dans toute webapp, notebook Jupyter, extension VSCode, ou outil tiers JS via npm
  • Les opérateurs 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éger
  • Performances limitées aux capacités du browser pour les très grands graphes (>200 opérateurs simultanés) — cas non anticipé pour Caméléon, point de réévaluation si nécessaire
  • Le moteur JS est la référence canonique — tout port futur doit produire les mêmes résultats sur les mêmes compositions
021 Format complet d'une composition .cm.js Accepted
04/03/2026 · O. Cugnon de Sévricourt

ADR-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

SectionObligatoireConsommé par
metanonUI, CLI (affichage), LLM
nodesouimoteur, UI, CLI
connectionsouimoteur, UI, CLI
layoutnonUI 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

Contexte de l'amendment

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

Décision

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.

Usage


// 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) : serializeComposition génère actuellement un import unique vers operators-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érence

Gé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');
}

  • ADR-014 est superseded — le choix JS est conservé, le schéma est enrichi
  • Un .cm.js sans layout est valide et exécutable en CLI — la position n'est pas un prérequis fonctionnel
  • Un .cm.js sans meta est valide — les métadonnées sont éditoriales, pas structurelles
  • L'UI génère et maintient la section layout automatiquement — positions des nœuds et angles des plugs sont sauvegardés à chaque déplacement
  • Les angles de plug (plugs: { 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
  • Un LLM génère un .cm.js valide en connaissant les exports de operators-lib.js — il n'a pas besoin de connaître layout
  • La forme étendue { ...op, params } est du JS standard — pas de syntaxe propriétaire ni de helper à importer
022 Séparation moteur CVM / renderer UI Accepted
04/03/2026 · O. Cugnon de Sévricourt, Axel (dev full stack)

L'é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 :

  1. Le moteur CVM pur (~110 lignes) : états des connexions, canFire, fireable, initHistory, currentState, index incomingConns/outgoingConns — aucune dépendance DOM.
  2. Le renderer UI (~900 lignes) : construction SVG, drag & drop, zoom/pan, animations, tooltips, annotations, panneau latéral, mode switcher, log, séquence de boot — tout dépend du 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 pur

Contient exclusivement :

BlocContenu
DonnéesOPS, CONNS (temporaire — migreront vers operators-lib + composition)
IndexCONN_BY_ID, CONN_TO_PLUG, incomingConns, outgoingConns
CVMhistory, 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 UI

Contient tout le code qui manipule le DOM :

BlocContenu
Types visuelsTYPES (palette de couleurs par type de plug), tc()
SVG helpersNS, se, $, bez
Plug géométriePLUG_R, PLUG_DIST, PLUG_ANGLES, initPlugAngles, plugPositions, PLUG_IDX, buildPlugIndex, connEP
RendubuildSVG, updateSVG, updateSidebar
InteractionsmoveOp, setupDrag, setupZoom, setupResize
AnimationsanimateTokens
CommandesdoForward, execFire, doBackward, execBackward, toggleRun, toggleRewind, doReset, onScrub
UIaddLog, setupTooltips, renderAnnotations, toggleAnnotations, openRN, closeRN, togglePanel, switchMode, dismissHint
Bootséquence d'init

Importe depuis ../engine/cvm.js. Exporte les handlers pour les onclick/oninput du HTML.

Choix du répertoire 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.


  • Testabilité : 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 moteur
  • CLI : le futur binaire CLI (ADR-009) importe engine/cvm.js directement — pas besoin d'extraire le moteur d'un fichier mixte
  • Clarté : chaque fichier a une seule responsabilité — le moteur évolue indépendamment du renderer
  • Cohérence avec ADR-008 : la couche cameleon-ui mentionnée dans ADR-008 correspond maintenant à editor/renderer.js
  • Plug géométrie : les fonctions de calcul d'angles et de positions des plugs (initPlugAngles, plugPositions) restent dans le renderer car elles servent exclusivement au rendu SVG — le moteur ne connaît pas les coordonnées
023 Runtime Loader : chargement et évaluation JS sans serveur Accepted
04/03/2026 · O. Cugnon de Sévricourt

Trois fonctionnalités prévues dans la roadmap nécessitent l'évaluation dynamique de code JS dans le navigateur :

  1. Import de composition (v0.18) : charger un fichier .cm.js depuis le disque et l'instancier comme composition active
  2. Éditeur d'opérateur inline (v0.19) : écrire run() et rollback() directement dans le panneau inspecteur
  3. Bijection texte ↔ visuel (v0.17) : synchroniser la vue code (.cm.js texte) et la vue canvas (graphe SVG) — voir ADR-024

Le 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.

Principe

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 :

  1. Lit le texte source (via FileReader ou directement depuis un <textarea>)
  2. Nettoie la syntaxe module (import, export) — même logique que _strip_imports_exports
  3. Évalue le code dans un scope contrôlé via new Function, en injectant les opérateurs connus comme paramètres
  4. Retourne l'objet composition ou la fonction résultante

Registre d'opérateurs

Le 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 };
}

API publique

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 :

SourceRésultat
import { ... } from '...'supprimé
export { ... }supprimé
export function ...supprimé
export const X = ...const X = ...
export default Xvar _cm_ = X

Surface publique


export { register, getRegistry, loadComposition, evalOperatorFn, stripImportsExports }

Intégration standalone

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.


  • Zéro serveur : le standalone file:// supporte l'import/export de compositions, l'édition d'opérateurs et la bijection — aucun serveur HTTP requis
  • Sécurité : new 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 desktop
  • CSP : new 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.json
  • Un seul composant : les trois fonctionnalités (import, éditeur, bijection) partagent le même mécanisme — pas de duplication
  • Registre extensible : les opérateurs custom de l'utilisateur sont ajoutés au registre, rendant disponibles dans les compositions importées ultérieurement
  • Miroir Python/JS : stripImportsExports existe en Python (build) et en JS (runtime) — même logique, deux contextes
  • Testable : loadComposition est une fonction pure (texte → objet) — testable unitairement sans DOM

Les sujets suivants sont liés mais traités dans des ADRs dédiés :

SujetADR
Sérialisation du layout (serializeComposition)ADR-021 amendment
Bijection texte ↔ visuel — synchronisation, états, UXADR-024
Mode Code-only — CVM sans rendererADR-025
032 Accélération matérielle du rendu canvas Proposed
06/03/2026 · Axel

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 :

  1. Sous-compositions (v0.19) — une composition peut contenir d'autres compositions, potentiellement avec un aperçu visuel imbriqué. Le nombre de nœuds SVG peut exploser.
  2. Catalogue étendu (v0.22+) — des compositions réalistes pourraient atteindre 50-100 opérateurs avec des centaines de connexions.

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.

Palier 1 — CSS hints (immédiat, coût nul)

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ée
  • contain: layout style paint : isole le reflow — les modifications dans le canvas ne déclenchent pas de recalcul dans les panneaux latéraux

Gain attendu : 20-40% sur les opérations de pan/zoom. Zéro risque de régression.

Palier 2 — Canvas 2D / OffscreenCanvas (si > 100 opérateurs)

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.

Palier 3 — WebGL / WebGPU (si > 1000 nœuds)

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.


  • Le palier 1 est applicable immédiatement sans risque — il suffit d'ajouter les propriétés CSS
  • Le palier 2 est une réécriture significative (~2 semaines) mais l'API renderer est déjà découplée
  • Le palier 3 est hors scope pour le foreseeable future
  • La décision de passer au palier 2 sera prise sur la base de benchmarks réels, pas d'estimations
  • Le découplage moteur/renderer (ADR acquis depuis v0.14) rend la migration possible sans toucher au moteur CVM

Caméléon v2 — 06/03/2026

Stack & Distribution
007 Stack : HTML / JS / SVG — fichier unique auto-contenu Accepted
01/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.

  • Portabilité maximale — s'ouvre dans n'importe quel navigateur, zéro installation
  • Moteur embarquable — le même cvm.js tourne dans le browser, en CLI Deno, et dans Tauri
  • LLM-compatible — le code JS est lisible, générable et modifiable par tout modèle
  • Déploiement trivial — copier un fichier suffit
  • Aucune dépendance npm, aucun build step pour l'UI
  • React Flow écarté définitivement — impose son modèle de données, complique la bijection texte ↔ graphe, n'apporte rien que le SVG pur ne couvre pas
  • React/Vue écartés — surcharge inutile, perte du fichier unique auto-contenu
  • Au-delà de ~200 opérateurs simultanés, une réévaluation des performances SVG pourrait s'imposer — cas non anticipé pour Caméléon
009 CLI standalone via Deno compile Accepted
03/03/2026

Le 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èreDenoNode + pkg/nexeQuickJS
Binaire standalone natifNatifBricolageOui
ES modules natifsOuiPartielNon
Même code que le browserOuiOuiNon
Taille binaire~90 Mo~50 Mo~1 Mo

cameleon run pipeline.cm           # usage
deno compile --allow-read main.js  # build → binaire `cameleon`
  • L'utilisateur final n'installe rien — il télécharge un binaire et l'exécute
  • Le même cvm.js tourne dans le browser et dans le CLI — zéro duplication
  • Intégrable dans des pipelines CI/CD sans dépendance à un runtime installé
  • QuickJS reste une option si la taille du binaire devient un critère critique
010 Pas de portage Go/Rust — JS est suffisant Accepted
03/03/2026

La 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.

  • Un seul codebase — le moteur JS tourne dans le browser, le CLI Deno et Tauri. Go/Rust casserait cette unité.
  • LLM-compatible — un LLM peut lire, modifier et générer du JS directement. Pas du Go.
  • Performance suffisante — le goulot d'étranglement d'un pipeline IA est le LLM call, pas le moteur d'orchestration.
  • Portage futur facilité — si nécessaire, un moteur JS bien spécifié et testé se réécrit proprement en Go.

"Make it work, make it right, make it fast" — dans cet ordre.

  • Un seul langage pour toute la stack — réduction de la charge cognitive
  • La mention du moteur Go sur la landing page devient une vision long terme, pas une priorité
  • Point de réévaluation : si Caméléon doit orchestrer des milliers de compositions simultanées en production
013 Stratégie de distribution multi-canal depuis une seule base de code Accepted
03/03/2026

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.

PhaseCanalTechnoCible
1WebHTML auto-contenuURL, iframe, intégration plateforme
1CLIDeno compileBinaire standalone, CI/CD, scripts
2PWAmanifest + service workerInstallable sans App Store, offline
3DesktopTauri (~5 Mo)Mac / Windows / Linux natif
4MobileCapacitoriOS / Android si besoin confirmé
Packagenpm / jsrEmbarquable 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.

  • Intégrable — le moteur est embarquable via npm dans n'importe quel outil JS tiers
  • Self-hostable — un fichier HTML suffit, pas de serveur requis
  • Forkable — la bijection texte ↔ graphe permet de créer des DSL dérivés
  • LLM-friendly — le format .cm est lisible et générable par tout modèle
  • Positionnement analogue à VS Code, Obsidian, Excalidraw — base web multi-plateforme extensible
  • Chaque canal doit être validé par un besoin avéré avant d'être développé (éviter la sur-ingénierie)
Gouvernance · Documentation
000 Conventions des Architecture Decision Records Accepté
03/03/2026 · O. Cugnon de Sévricourt

Camé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

StatutSignification
ProposedRédigée, en attente de discussion
In ReviewEn cours de revue entre les auteurs
AcceptedDécision prise — fait loi pour le projet
RejectedExplicitement rejetée — conservée pour tracer le raisonnement
DeprecatedToujours valide mais plus recommandée
Superseded by ADR-NNNRemplacée — référence obligatoire vers le successeur

Règles

  • Un ADR ne se modifie pas une fois Accepted — on crée un nouvel ADR qui le Supersede
  • Un ADR Rejected est conservé — une décision rejetée avec ses raisons est aussi précieuse qu'une décision acceptée
  • Le champ Thème permet de filtrer les ADR par domaine
  • Chaque ADR existe en deux formats : adr-NNN.md (source) et intégré dans docs/adr/cameleon-adr.html (navigation)

  • Tout contributeur peut comprendre une décision sans avoir assisté à la discussion
  • L'historique des décisions est navigable et diffable dans git
  • Superseded by ADR-NNN avec référence explicite permet de suivre l'évolution d'une décision dans le temps
  • ADR-000 est lui-même soumis au cycle de vie — il peut être superseded si les conventions évoluent
Modèle d'exécution · SDK
015 Mode d'exécution des opérateurs : local vs distant In Review
03/03/2026 · O. Cugnon de Sévricourt

Les 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 :

FacteurLocalRemote
Volume de donnéesPetit — texte, prompt, JSON, CSV légerGrand — fichiers, embeddings, images
Latence acceptableFaible — traitement synchroneHaute — I/O réseau toléré
Dépendance externeAucune — pur JS dans le runtimeAPI, 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 invalide
  • remote 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 cas
  • Le signal CANCEL (WCP-19/20/25) est propagé automatiquement par le runtime aux opérateurs remote en cours d'exécution
  • Le moteur ne décide jamais du mode — c'est l'opérateur qui se déclare

  • Le runtime CVM gère deux chemins d'exécution distincts : synchrone (local) et asynchrone avec timeout/retry/cancel (remote)
  • Les opérateurs remote participent naturellement au pattern Discriminator (WCP-9) — le runtime peut annuler les appels en cours dès qu'un résultat est obtenu
  • Le SDK doit exposer un validateur qui vérifie la présence et la cohérence de executionMode à l'import
  • Le mode remote ouvre la voie au mode d'exécution concurrent (ADR-011) — plusieurs appels LLM en parallèle
  • La documentation SDK devra être explicite sur les responsabilités de l'auteur d'opérateur : déclarer le bon mode, implémenter rollback(), gérer les erreurs réseau dans run()
  • Un opérateur 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)
Stratégie produit · Licences
018 Licence open source : MIT vs LGPL Rejected — voir ADR-026
03/03/2026 · O. Cugnon de Sévricourt

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 :

  • MIT — licence permissive, usage commercial libre sans obligation de reverser les modifications. Adoption maximale, contribution non garantie.
  • LGPL — licence copyleft faible, autorise l'usage du moteur dans un produit commercial mais oblige à reverser toute modification du moteur lui-même. Équilibre entre adoption et protection.

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.


  • Adoption et contribution maximales — pas de friction juridique pour les entreprises
  • Cohérence avec les projets de référence dans l'écosystème JS (Node, Deno, VS Code)
  • Un fork commercial qui n'est pas reversé peut quand même augmenter la visibilité du projet
  • La monétisation de Caméléon passera par les services, le support et les intégrations — pas par le moteur lui-même
  • Assumé : une entreprise peut embarquer le moteur dans un produit commercial sans obligation de reverser
  • Oblige à reverser les modifications du moteur — le cœur formel reste commun et bénéficie à tous
  • Autorise l'usage libre dans tout produit sans contaminer le code propriétaire (contrairement à GPL)
  • Protège l'investissement intellectuel sur le modèle Petri et le moteur CVM
  • Compatible avec un usage commercial — ce n'est pas une licence restrictive pour les utilisateurs
  • Cohérent avec des projets académiques ou à fort socle théorique qui veulent protéger leur cœur
  • Est-ce que la protection du moteur via LGPL est une priorité face au gain d'adoption que MIT apporterait ?
  • Y a-t-il des partenaires ou intégrateurs cibles dont le choix de licence pourrait être bloquant ?
  • La monétisation future via services/support est-elle suffisante, ou faut-il garder une option de licensing B2B sur le moteur ?

Si MIT

  • Contribution et adoption maximales — pas de friction juridique
  • Pas de revenu direct sur le moteur
  • Monétisation future via services, support, intégrations ou licensing B2B — décision reportée à validation marché
  • Un acteur tiers peut embarquer le moteur dans un produit fermé sans obligation

Si LGPL

  • Le moteur reste un bien commun — toute amélioration du cœur est reversée
  • Adoption légèrement freinée dans les entreprises avec des politiques juridiques strictes sur le copyleft
  • Compatible avec une monétisation via services et support
  • Envoie un signal fort sur la nature académique et collective du projet
Architecture technique · UX
024 Bijection texte ↔ visuel Accepted
04/03/2026 · O. Cugnon de Sévricourt, Nora (UX), Mira (Architecture)

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.


États de synchronisation

La bijection a trois états, chacun avec un signal visuel distinct. L'indicateur dans le header du mode Split est le point focal.

ÉtatConditionSignal
SyncedLes deux vues sont cohérentes neutre — rien d'autre
Code pendingTexte modifié, canvas en attente amber + canvas légèrement désaturé
UpdatingCanvas modifié, code en cours de mise à jour pulse bref

Règles de déclenchement

La synchro ne se déclenche pas à chaque frappe — un .cm.js en cours d'écriture est structurellement invalide.

DéclencheurSensMode
Ctrl+Entertexte → visuelExplicite, toujours disponible
800ms d'inactivité dans l'éditeurtexte → visuelDebounce automatique
Déplacement / modification sur le canvasvisuel → texteTemps réel, sans délai

La synchro visuel → texte est toujours temps réel — elle ne génère jamais d'état invalide.

Gestion des conflits d'édition

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 ]

  • Pas de dialog modal — barre inline uniquement
  • Aucune action n'est destructive sans consentement explicite
  • Le flux n'est pas bloqué

Placement des opérateurs sans position (texte → visuel)

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.


  • L'indicateur est visible uniquement en mode Split et Code — pas en mode Visual seul
  • Le debounce 800ms évite le clignotement du canvas sur un .cm.js invalide en cours d'écriture
  • Ctrl+Enter est documenté dans la charte des raccourcis clavier (guide utilisateur section 7)
  • Le placement séquentiel est temporaire — le layout automatique topologique est prévu en backlog
  • Le placement séquentiel ne garantit pas l'absence de recouvrement avec les opérateurs existants — friction connue, documentée, acceptable en v0.17
  • La gestion du conflit d'édition est une feature requise pour que la bijection soit perçue comme fiable — sans elle, la fonctionnalité est techniquement correcte mais instable aux yeux de l'utilisateur

Caméléon v2 — 04/03/2026

025 Mode Code-only : CVM exécutable sans renderer Accepted
04/03/2026 · O. Cugnon de Sévricourt, Nora (UX), Mira (Architecture)

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.

Contrainte architecturale — renderer optionnel


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.

Implications UI

  • Les contrôles CVM (Fwd, Run, Back, Rwd, Reset) sont accessibles et fonctionnels depuis le mode Code
  • L'indicateur signale que la composition en mémoire est bien celle du code affiché — fonctionnel, pas cosmétique
  • Le panneau bas (Connexions, Opérateurs, CVM Log) reste visible en mode Code — c'est le seul feedback d'exécution disponible sans canvas

Condition de viabilité — découplage panneau bas

Le 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.


  • La CVM ne dépend d'aucun état du renderer pour s'initialiser et s'exécuter
  • Le panneau bas est découplé du renderer SVG — il lit l'état CVM en mémoire
  • Le mode Code-only est un flux d'usage de première classe, pas un mode dégradé
  • Si un couplage renderer → CVM est découvert lors de l'implémentation, il est traité comme un bug architectural bloquant — pas comme une dette acceptable
  • Les utilisateurs LLM-assisted qui génèrent des .cm.js programmatiquement peuvent exécuter sans ouvrir le canvas — cas d'usage CLI et intégration externe couvert
  • Les contrôles CVM (doForward, 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.js

Caméléon v2 — 04/03/2026

Stratégie produit · Gouvernance
026 Licence MIT pour le moteur CVM et l'ensemble du projet Accepté
04/03/2026 · O. Cugnon de Sévricourt

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 :

  • MIT — permissive, sans contrainte pour les utilisateurs commerciaux
  • LGPL — permissive pour le lien dynamique, oblige à ouvrir les modifications du moteur
  • Apache 2.0 — permissive avec clause de protection de brevets

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 :

  • Friction B2B minimale — MIT ne nécessite pas de legal review dans les grandes
  • organisations. LGPL déclenche systématiquement un ticket juridique (délai 1-3 mois).
  • En pratique : pendant ce délai, le dev a trouvé une alternative MIT ou écrit la sienne.
  • Adoption maximale — un acteur commercial peut embarquer le moteur dans un produit
  • propriétaire sans obligation de contribution ni de divulgation.
  • Cohérence avec la stratégie — la route B2B repose sur l'embeddabilité du moteur.
  • Toute friction légale nuit directement à cette stratégie.
  • Protection réelle ailleurs — la véritable protection IP est la vitesse d'exécution,
  • la communauté, et la paternité publiquement documentée depuis 2011 (arXiv 1110.4802,
  • cité dans des publications académiques — Laboratoire Navier / ENPC). Pas la licence.
  • Continuité assumée — le moteur CVM original a été publié sous MIT en 2012
  • (implémentation C++/Qt). La v2 JS est une réimplémentation du même modèle formel.
  • MIT n'est pas un choix nouveau — c'est une continuité sur 14 ans.

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

  • Mention légale : la LICENSE du repo mentionne O. Cugnon de Sévricourt comme
  • seul auteur de la v2. V. Tariel est auteur du code v1 — non concerné par cette licence.
  • Shinoe n'ayant pas de forme juridique, la mention légale ne peut pas être une entité
  • morale. Les crédits (README, footers, CLAUDE.md) peuvent utiliser "Design & built by
  • Shinoe" pour la marque — la LICENSE reste nominative.
  • Appropriation académique : MIT ne protège pas contre un acteur commercial qui
  • embarque le moteur et publie ensuite un papier sur "son" système d'orchestration
  • formel sans citer arXiv 1110.4802. La protection de la paternité académique passe
  • par une publication dans une venue à comité de lecture — pas par la licence du code.
  • Cet ADR est un argument supplémentaire pour accélérer cette soumission.
  • Ports tiers : sous MIT, un port du moteur réalisé par un tiers est librement
  • publiable sous n'importe quelle licence, y compris propriétaire. Cohérent avec
  • la stratégie d'adoption maximale — à avoir en tête.
  • Good first issue : au lancement public, identifier 3 à 5 issues labellisées
  • good first issue sur le repo GitHub — autonomes, périmètre limité, sans dépendance
  • d'architecture. À préparer avant J0, pas après. Le pic d'attention HN est court.
  • Réserve Apache 2.0 : réévaluer Apache 2.0 avant la première release publique.
  • La clause de protection de brevets peut s'avérer pertinente si le projet atteint
  • une adoption significative ou si le paysage concurrentiel évolue.
  • Relation ADR-018 : ADR-018 (licence — non tranchée) passe en Rejected avec
  • renvoi vers ADR-026. Un ADR In Review indéfiniment alors que la décision existe
  • ailleurs est de la dette documentaire.

ActionPrioritéQui
Passer ADR-018 en Rejected (renvoi → ADR-026)Avant AcceptedPO
Créer le fichier LICENSE (MIT, nominatif)Avant première releaseAxel
Mettre à jour README avec la licenceAprès AcceptedAxel
Préparer 3-5 issues good first issueAvant J0 launchPO + Nora
Réévaluer Apache 2.0 vs MITAvant première releasePO + Léa
Soumettre le paper à une venue à comitéPriorité haute — hors ADRPO + Léa

PersonaStatutDate
Mira — Architecte✅ Favorable04/03/2026
Léa — Recherche✅ Approuvé avec réserves intégrées04/03/2026
Nora — Product Design✅ Validé04/03/2026
Axel — Développeur✅ Favorable04/03/2026
O. Cugnon de Sévricourt — POAccepté04/03/2026

Décision finale — Accepté par le PO le 04/03/2026

Catalogue d'opérateurs · Architecture technique
028 Typage des plugs et validation à la connexion Accepted
04/03/2026 · O. Cugnon de Sévricourt

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 typeInput typeCompatibleRaison
TTOuiTypes identiques
TAnyOuiAny accepte tout — plug générique
AnyTOuiL'output générique est accepté — la vérification de valeur est déléguée à run()
TU (T ≠ U)NonTypes 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

TypeDescriptionExemple d'usage
DataStringChaîne de caractèresTexte, prompt, réponse LLM
DataJSONObjet JSON sérialisableDonnées structurées, config
DataBoolBooléenSignal de contrôle, condition
BinaryDonnées binaires (ArrayBuffer)Fichier, image, PDF
AnyAccepte tout typeDebug, 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 AnyT

La règle AnyT 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.


  • Le renderer valide le type au moment de la connexion — feedback immédiat (couleur du plug, message d'erreur)
  • Le runtime loader (loadComposition) valide les connexions du .cm.js au chargement — les erreurs de type sont détectées avant l'exécution
  • Les types custom sont supportés sans modification du moteur — le registre de types est ouvert
  • Pas de coercion implicite — toute conversion est un opérateur explicite dans le graphe
  • La palette de couleurs des plugs (ADR-019, TYPES) est indexée par type — les types custom utilisent une couleur par défaut (#8899aa)
  • La logique de compatibilité vit dans engine/types.js — source de vérité unique pour le renderer et le runtime loader
  • Le type Any est un opt-out explicite — son usage excessif est un signal de design smell dans un opérateur

Caméléon v2 — 04/03/2026

Architecture technique · Sécurité
029 Sécurité et sandboxing des opérateurs custom Proposed
04/03/2026 · O. Cugnon de Sévricourt

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.

NiveauSourceIsolationExemple
Trustedoperators-lib.js (opérateurs livrés avec le produit)Aucune — accès complet au contexteSource, Merge, Gate, GPT4Call
UserlandOpérateurs écrits par l'utilisateur (JS inline, .cm.js importé)Restreinte — API contrôléeCustom 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 global
  • fetch 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.log
  • L'accès au DOM est interdit — l'opérateur ne peut pas manipuler l'interface
  • L'accès au filesystem est interdit en mode browser — disponible uniquement en mode CLI (Deno)

Mécanisme d'isolation

L'isolation repose sur le contexte d'exécution passé à new Function, pas sur un Worker ou un iframe :

  • Le code userland est évalué avec un scope limité : { inputs, signal, fetch, log }
  • Les globaux dangereux (document, window, eval, Function) ne sont pas injectés dans le scope
  • Ce n'est pas une sandbox de sécurité au sens fort (CSP, VM isolée) — c'est une barrière de commodité qui empêche les erreurs accidentelles, pas les attaques délibérées

Ce qui est explicitement hors périmètre

  • Sandbox de sécurité forte (Web Worker, iframe sandboxé, Deno permissions) — à évaluer si Caméléon est utilisé comme runtime multi-tenant
  • Vérification statique du code avant exécution (AST analysis) — complexité injustifiée à ce stade
  • Signature ou certification des opérateurs — pas avant une v1 avec un écosystème d'opérateurs tiers

  • Les opérateurs trusted (operators-lib.js) n'ont aucune pénalité de performance ni restriction d'API
  • Les opérateurs userland ont une API explicite et documentée — run(inputs, { signal, fetch, log })
  • L'isolation userland est une barrière de commodité, pas de sécurité — documentée comme telle
  • Le mode CLI (Deno) bénéficie nativement du modèle de permissions Deno (--allow-net, --allow-read) — l'isolation est plus forte sans effort supplémentaire
  • La note CSP d'ADR-023 reste valide : new Function nécessite 'unsafe-eval' dans la CSP du browser — incompatible avec les politiques CSP strictes (PWA, extensions)
  • L'évolution vers une sandbox forte (Worker, Deno isolate) est possible sans casser le contrat run() — le changement est dans le runner, pas dans l'opérateur

Caméléon v2 — 04/03/2026

Catalogue d'opérateurs · Infrastructure
030 Framework de libraries d'opérateurs (ODK packaging) Accepted
05/03/2026 · O. Cugnon de Sévricourt

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.


Convention de fichier

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éfixeSourceExemple
std-Standard library livrée avec Caméléonstd-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.

Standard library — découpage par domaine

L'actuel operators-lib.js est découpé en libraries thématiques :

LibraryDomaineExemples d'opérateurs
std-struct-lib.jsContrôle de flux (structurels)Gate, Merge, Fork, Join, Loop
std-string-lib.jsManipulation de chaînesConcat, Split, Replace, Template
std-data-lib.jsDonnées structuréesJSONParse, JSONStringify, Filter, Map
std-io-lib.jsEntrées/sortiesSource, Sink, Input, FileLoader
std-connect-lib.jsConnectivité externeAPICall, SendMail, WebSocket
std-ai-lib.jsAppels LLMCallOpenAI, 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.

Metadata de library

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.

Chargement et découverte des libraries

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é.

ContenuChemin
Libraries standardengine/libs/std-*-lib.js
Libraries tiercesengine/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.

Import dans une composition .cm.js

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é.

Library tierce — convention

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-bang
  • Le catalogue UI agrège les opérateurs de toutes les libraries chargées — le domain de la library + la category de l'opérateur structurent l'arborescence
  • L'ODK (odk/README.md) documente comment créer une library tierce, pas seulement un opérateur isolé
  • Les templates ODK incluent un template de library (odk/templates/cst-example-lib.js)
  • Le runtime loader n'a pas besoin de modification — les imports JS standard suffisent
  • La découverte des libraries est par convention de dossier (engine/libs/) — zéro configuration
  • La validation de type à la connexion (ADR-028) fonctionne indépendamment de la library source — les types sont universels
  • Une library tierce a le même niveau de confiance que std-* (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 GUI

Caméléon v2 — 05/03/2026

Modèle d'exécution · SDK · Langage
033 DSL de composition : sous-langage JS contraint et mapping graphique In Review
05/03/2026 · O. Cugnon de Sévricourt

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.


Principe fondateur — paradigme deux-échelles

Le DSL Caméléon repose sur une séparation stricte entre deux niveaux :

NiveauNomLangageRôle
MacroCompositionDSL Caméléon (sous-ensemble JS)Topologie du graphe — nœuds, connexions, types
MicroComportementJS libreLogique 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.


Constructions valides du DSL

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)

Constructions invalides — rejetées par le validator

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

Mapping DSL ↔ langage graphique

Chaque construction du DSL correspond à un élément visuel précis dans le renderer graphique. La bijection est topologique — structurelle, pas comportementale.

Concept DSLConcept graphiqueNote
new Operator({ id, in, out })Nœud du grapheid = 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 plugsCouleur = 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œudADR-019
run: (inputs) => { ... }Boîte noire — non représentéMicro-niveau JS libre
import { Fork } from '...'Opérateur de la stdlib dans le catalogueADR-031
new Operator({ ... }) inlineOpérateur custom — nœud avec icône distincteOpérateur non-catalogue
config statiquePanneau de configuration du nœud (menu contextuel)ADR-020
composition.sub('Label')Nœud de sous-composition sur le canvasADR-038
rag.add(op)Opérateur interne à la sous-compositionADR-038
rag.in(op.in.plug)Bridge entrant — plug exposé sur l'orbiteADR-038
rag.out(op.out.plug)Bridge sortant — plug exposé sur l'orbiteADR-038

Opérateur inline — cas limite

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.


Sous-compositions dans le DSL

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 DSLConcept 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.


Règle de délimitation

**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 au run() — JS libre, invisible au graphe, hors du langage Caméléon au sens strict.


  • Le .cm.js est un programme JS exécutable, pas un fichier de
  • configuration — les opérateurs sont de vraies instances de classe avec
  • méthodes et attributs
  • La bijection topologique est garantie : le renderer peut toujours
  • reconstruire le graphe depuis le .cm.js en inspectant les déclarations
  • de plugs et les appels connect() — sans exécuter les run()
  • Le validator doit rejeter les constructions invalides au niveau macro
  • avec des messages d'erreur orientés utilisateur — *"Use Gate operator
  • instead of if/else at composition level"*
  • L'opérateur inline (feature roadmap) est cohérent avec ce modèle sans
  • modification du moteur — le moteur CVM ne voit que le contrat ADR-019
  • Les opérateurs de la stdlib (ADR-031) et les opérateurs inline partagent
  • le même contrat — le graphe les traite de façon identique
  • Ce modèle fonde la section "Caméléon as an Embedded DSL" du paper v2 —
  • il positionne Caméléon comme un sous-langage de composition topologique,
  • pas un CPN générique

Caméléon v2 — 05/03/2026

035 Compositions fermées et ouvertes — LiveSource Accepted
05/03/2026 · O. Cugnon de Sévricourt, Léa (Recherche)

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.


Deux modes de composition

Composition ferméeComposition ouverte
DéfinitionAucun opérateur live ou stream dans le grapheAu moins un opérateur live ou stream
Modèle formelCPN standard (fermé)Open nets (Lohmann et al., 2007)
DéterminismeAbsoluRéactif — déterministe pour une séquence d'inputs E donnée
Garanties WCPToutesConditionnelles à E
InteractivitéStatique (valeurs fixes)Temps réel (valeurs modifiables pendant le run)
DétectionStatique — à l'init du runStatique — à l'init du run
Signal UIAucunBadge ⚡ dans le header + sous le label opérateur

La nature d'une composition est dérivée de sa topologie.


Amendment 1 — Sémantique état vs sémantique événement

Les opérateurs temps réel se distinguent par la **sémantique de leur place de sortie** :

SémantiqueModèle de la placeComportementOpérateur
ÉtatValeur scalaire — la dernière valeur remplace l'anciennelast-write-winsLiveSource
ÉvénementFile FIFO — chaque occurrence est une donnée distincteaucune perteEventSource (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.


L'opérateur LiveSource

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 :

SourceLiveSourceEventSource
FireUne fois (sorties EMPTY)À chaque changement utilisateurÀ chaque événement externe
SémantiqueÉtat — last-write-winsÉvénement — FIFO, aucune perte
ValeurConfig statiqueValeur couranteProchaine valeur dans la file
CompositionFerméeOuverteOuverte
Cas d'usageParamètre fixeChamp texte, sliderTwitter, 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")))
    })
  }
}

Amendment ADR-019 — interaction

ValeurSémantiqueComportementComposition
"human"Bloque le flux — attend une validation uniqueFermée
"live"ÉtatRe-fire sur chaque événement, last-write-winsOuverte
"event"ÉvénementRe-fire en dépilant la FIFO — aucune perteOuverte

Détection et initialisation


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"
}

Signal UI

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.


Validator

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.

Panneau droit — Contrôleur

OpérateurZoneNature
HumanInputContrôleur (quand RUNNING)Bloquant — formulaire + Valider
LiveSourceContrôleur (dès que dans la composition)Continu — saisie temps réel
EventSourceContrôleur (dès que dans la composition)File entrante — indicateur de profondeur

Historique CVM en mode réactif

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.


Relation Source / HumanInput / LiveSource / EventSource

OpérateurInteractionSémantiqueModeDifférence clé
SourceFerméValeur statique, fire once
HumanInput"human"FerméBloque, attend validation unique
LiveSource"live"ÉtatOuvertRe-fire continu, last-write-wins
EventSource"event"ÉvénementOuvertFIFO, aucune perte, dépile un event/step

  • Moteur CVM : deux modes (closed / reactive) — détectés à
  • l'init via Object.values(composition.nodes)
  • Re-fire policy LiveSource : last-write-wins — sémantique état, intentionnelle
  • Re-fire policy EventSource : FIFO dépilage — sémantique événement, aucune perte (ADR-037)
  • Boucle de re-fire : orchestrée par le renderer, opérateur reste
  • RUNNING, arrêtée sur Stop/Cancel
  • Historique : cap glissant N entrées en v0.18 — Bwd/Rwd dégradé documenté
  • Validator : message informatif dans le CVM Log — pas de modal
  • ADR-019 : amendé — interaction: "live" et interaction: "event" ajoutés
  • ADR-031 : à amender — LiveSource + EventSource ajoutés au catalogue stdlib
  • ADR-037 : EventSource — sémantique événement, file FIFO

SujetADR / Backlog
Éditeur inline LiveSource sur le canvasADR-034 + backlog
Historique coalescentBacklog post-release
Compositions mixtes — plusieurs LiveSource / EventSourceMême mode réactif, comportement additif
Pause / resume du mode réactifBacklog post-release
Valeur de N pour le cap glissantÀ définir avec Axel en implémentation
Back-pressure EventSource (file pleine)ADR-037

  • Lohmann, N., Massuthe, P., & Wolf, K. (2007). *Operating Guidelines
  • for Finite-State Services.* TACAS 2007, LNCS 4424, Springer.
  • Caspi, P. et al. (1987). *Lustre: A Declarative Language for
  • Programming Synchronous Systems.* POPL 1987.
  • Kindler, E. & Wagner, R. (1997). Modules for Petri Nets. PNPM 1997.

Caméléon v2 — 05/03/2026 — révisé 06/03/2026 — Amendment 1 : 07/03/2026

037 EventSource — sémantique événement Accepted
07/03/2026 · O. Cugnon de Sévricourt, Léa (Recherche)

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.


L'opérateur EventSource

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 :

LiveSourceEventSource
Modèle de la place d_outValeur scalaireFile FIFO
Nouveau fire pendant transitÉcrase la valeur (last-write-wins)Enfile — traité après
Perte possibleOui — intentionnelle (sémantique état)Non — aucune perte
Cas d'usageChamp texte, slider, paramètreTwitter, webhook, queue, capteur

Contrat formel

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.


Back-pressure

Quand la file dépasse un seuil configurable max_queue_depth :

OptionComportement
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 newestLes nouveaux événements sont ignorés quand la file est pleine. Même format de warning.
C — ErrorLa 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.

Descripteur SDK


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")))
    })
  }
}

Boucle de re-fire

Deux sources de re-fire pour EventSource :

  1. Événement entrant : le renderer enfile et re-fire si l'opérateur
  2. n'est pas déjà RUNNING
  3. File non vide après consommation : le renderer re-fire
  4. immédiatement après chaque résolution si la file contient encore des
  5. événements — drain continu

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).


Panneau droit — Contrôleur

EventSource est affiché dans le Contrôleur avec un indicateur de profondeur de file :


┌─ Event Source ────────────────────┐
│  ⬤ Listening   Queue: 3 events   │
│  [━━━━━━━━░░░░░░░░░░░░░░] 3/100  │
└───────────────────────────────────┘
  • Indicateur de profondeur (barre de progression) — avertissement visuel
  • si la file approche max_queue_depth
  • Log warning dans le CVM Log si backpressure déclenche un drop

Historique CVM

Même règle que LiveSource — cap glissant N entrées (ADR-035). Chaque dépilage crée une entrée dans history[].


Relation complète des opérateurs d'entrée

OpérateurInteractionSémantiqueFirePerte possibleComposition
SourceOnceFermée
FileLoaderOnce au démarrageFermée
HumanInput"human"Once par validationFermée
LiveSource"live"ÉtatSur changementOui — intentionnelleOuverte
EventSource"event"ÉvénementSur événement + drain FIFONonOuverte

  • Modèle de marquage : extension localisée — aᵗ(d_out) est une FIFO
  • pour les places de sortie d'EventSource uniquement
  • Moteur CVM : boucle de drain — re-fire immédiat si file non vide
  • après consommation
  • ADR-019 : amendé — interaction: "event" ajouté
  • ADR-031 : à amender — EventSource ajouté au catalogue stdlib
  • ADR-035 : amendé — détection composition ouverte étendue à "event"
  • Taxonomy : EventSource ajouté — Comportemental, attribut live,
  • interaction: "event"
  • Paper v2 : note sur l'extension FIFO du marquage — conservative,
  • localisée, référencée dans la littérature
  • Composition Orchestra v2 : le LiveSource git de Context Keeper
  • doit être remplacé par EventSource git — chaque delta de Team
  • Collaboration est un événement discret qu'on ne peut pas perdre.
  • À tracer avant v0.23.

SujetBacklog
Adapter / transformer les événements avant enfilageBacklog — 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/RwdBacklog post-release
Drain partiel (dépiler N events par step)Backlog

  • Kindler, E. & Wagner, R. (1997). Modules for Petri Nets. PNPM 1997.
  • Lohmann, N., Massuthe, P., & Wolf, K. (2007). *Operating Guidelines
  • for Finite-State Services.* TACAS 2007, LNCS 4424, Springer.
  • Reisig, W. (1985). Petri Nets: An Introduction. Springer.

Caméléon v2 — 07/03/2026

Architecture technique · Catalogue
034 Contrat d'un control dans `controls.js` Accepted
05/03/2026 · O. Cugnon de Sévricourt, Mira (Architecture), Nora (UX)

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.


Le control — quatrième concept du modèle formel

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.

Contrat d'un control

Champs obligatoires

ChampTypeDescription
idstringIdentifiant unique (snake_case)
labelstringNom affiché
typestringType de plug associé (DataString, Binary, Any…) — doit exister dans types.js
renderFunctionAffiche une valeur dans un container DOM — voir contrat ci-dessous

Champs optionnels

ChampTypeDéfautDescription
onInputFunctionabsentBranche 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
inlinebooleanfalseSi true, le control peut être affiché dans le canvas (mode HumanInput)
descriptionstring""Description pour le catalogue ODK

Contrat render(value, container)

  • Reçoit value : la valeur courante du plug (peut être null si EMPTY)
  • Reçoit container : élément DOM dans lequel le control doit se rendre
  • Void — le control écrit dans container, ne retourne rien
  • Appelé à chaque mise à jour de la valeur — doit être idempotent (réentrant)

Contrat onInput(callback, container)

  • Reçoit callback : fonction appelée par le control quand l'utilisateur produit une valeur — callback(value)
  • Reçoit container : le même container DOM que celui passé à render() — le control y accède pour brancher ses éléments internes
  • Le control fait le wiring lui-même — il connaît les éléments qu'il a créés dans render(), le renderer ne les inspecte jamais
  • Présent uniquement sur les controls éditeurs — absent sur les viewers purs
  • Le moteur attend callback avant de transitionner (cas HumanInput)

Association control ↔ type

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.

Control et opérateur HumanInput — vue graphique

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().

Les deux cas d'usage — même moteur, renderer différent

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.inlinefalsetrue
Rendu RUNNINGCercle standard + control dans le panneau droitControl à la place du cercle dans le canvas
Connexions visibles
Bijection topologique
Moteur CVMIdentiqueIdentique

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)
    );
  }
}

Exemple complet — HumanInput éditeur de message

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"       ],
  ],
}

Découverte et registre

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.


  • Quatrième concept formel : operator, type, control, composition — le modèle est complet pour v0.18
  • Panneau d'inspection : le viewer d'un plug est le control associé à son type — cvm.observe(connectionId) fournit la valeur, le control la rend dans le panneau
  • HumanInput dans le canvas : tout opérateur interaction: "human" a un control inline: true — c'est sa vue graphique RUNNING dans le canvas
  • ODK : un développeur tiers crée un opérateur custom avec son control custom — ImageClassifier + ImageViewerControl pour le type ImagePNG — packagés ensemble dans cst-vision-lib.js
  • Extensibilité : le registre de controls est ouvert — un type custom sans control enregistré affiche une valeur brute (RawViewerControl)
  • Séparation claire : controls dans controls.js (UI), opérateurs dans operators-lib.js (logique), types dans types.js (données) — trois responsabilités, trois fichiers
  • Pas de sémantique de tir : un control n'a pas d'état CVM — il ne tire jamais, ne bloque jamais le moteur directement. C'est l'opérateur HumanInput qui porte la sémantique de tir, le control est son interface
  • control 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)
  • CANCELLED pendant saisie : si l'opérateur HumanInput passe CANCELLED pendant que l'utilisateur est en train de saisir, le renderer désactive le control dans le même cycle updateSVG(). Le control n'écoute pas signal directement — la responsabilité de désactivation appartient au renderer, pas au control

SujetADR
Signal visuel RUNNING distinct pour HumanInputADR-016 Amendement 1
État MISCONFIGURED et badge ⚙ADR-020 Amendement 1
Bijection texte ↔ visuel — synchronisation du control avec le .cm.jsADR-024
Viewers inline optionnels en mode debugBacklog post-release

Caméléon v2 — 05/03/2026

036 Engine Facade — Single Entry Point for CVM Platform
07/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);

Internal module structure remains unchanged

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 redundant

The current façade (operators-lib.js) is a subset of what engine.js provides. It will be deprecated once all consumers migrate.

Positive

  • Single import for all consumers — renderer, CLI, tests, plugins
  • Internal freedom — engine internals can be restructured without breaking consumers
  • Discoverable API — one file to read, one place to look for capabilities
  • CLI enabler — the CLI imports engine.js and gets everything it needs

Negative

  • One more layer — thin indirection between consumer and implementation
  • API surface to maintain — additions to libs must be reflected in the façade

Migration

  1. Create engine/engine.js with the high-level API
  2. Migrate renderer.js to import from engine.js only
  3. Build CLI against engine.js
  4. Deprecate operators-lib.js
  5. Update build-doc.py standalone inlining if needed

Barrel re-export (option 1)

A simple export * from './libs/...' file. Reduces import lines but provides no abstraction — consumers remain coupled to internal naming. Rejected.

Status quo — direct imports (option 0)

Works today with one consumer. Does not scale to CLI + plugins. Rejected for v0.21+.

038 Sub-compositions & Patterns
08/03/2026

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.


1. Sub-composition = visual grouping (transparent model)

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

2. Bridges — exposed plugs

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 conceptK&W (1997) concept
Sub-compositionModule
BridgeInterface place
Internal connectionArc internal to the module
Parent → bridge connectionArc 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

3. Sub-composition node — canvas representation

The sub-composition is rendered as a special node on the parent canvas.

PropertyValue
ShapeCircle, r=56
fill#1a2640 — solid, no gradient
strokeState-dependent (same as operators)
IconU+25A3 centered
PlugsBridges 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
ConditionDisplayed state
Any FAILEDFAILED 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 COMPLETEDCOMPLETED stroke
All IDLEIDLE 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.

4. Navigation

  • Enter: click on sub-composition node → canvas shows interior
  • Exit: breadcrumb (click parent level) or "up" button
  • Depth: unlimited technically, 4 levels recommended max (UX).
  • At 3+ levels, the breadcrumb collapses intermediate levels with a
  • clickable ... — standard breadcrumb truncation pattern.
  • Vignetting: circle stroke #1a2640, stroke-width 180, clipped
  • to canvas — signals "you are inside"

Right 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:

  • Root level (currentLevel = null): show root-level operators +
  • sub-composition nodes. Bridges render as connections to sub-comp plugs.
  • Internal connections of sub-compositions are hidden.
  • Inside a sub-comp (currentLevel = "sub1"): show operators
  • belonging to that sub-comp + nested sub-comp nodes. Bridges render
  • as edge plugs on the canvas border. Parent-level connections are hidden.

The filtering logic is a renderer responsibility — the CVM sees the full flat graph at all times.

5. Creating sub-compositions — 3 routes

RouteTriggerResult
A — from selectionMulti-select + right-click → "Merge into sub-composition"Selected ops encapsulated, external connections become bridges
B — emptyRight-click canvas → "New sub-composition"Empty sub-comp node, no bridges
C — drag onto bodyDrag plug → drop on sub-comp bodyBridge created automatically

Route A algorithm:

  1. Identify selected operators S
  2. Identify connections: internal (both ends in S) and external (one end in S)
  3. Create sub-composition node at centroid of S
  4. Internal connections: unchanged (hidden from parent canvas)
  5. External connections: become bridges — plug appears on sub-comp orbit

Unmerge (reverse of Route A):

  1. All operators inside are moved to the parent level
  2. Bridges are converted back to direct connections
  3. Sub-composition node is removed
  4. Nested sub-compositions inside are preserved (moved up one level)

6. Patterns — reusable sub-compositions

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)

7. Undo/Redo

Sub-composition mutations (create, unmerge, add bridge, remove bridge) are compound commands in the existing undo/redo system:

ActionCompound command
Merge into sub-compRemoveOps + RemoveConns + AddSubComp + AddBridges
UnmergeRemoveSubComp + RemoveBridges + AddOps + AddConns
Add bridge (Route C)AddConn (bridge metadata)

8. CVM engine changes

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)

9. Data model — composition format

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.


  • Engine: no changes to the CVM core — sub-compositions are transparent
  • Trijection preserved: the formal net is the same flat net,
  • regardless of UI nesting. Sub-compositions are rendering metadata —
  • they do not exist in the formalism (D, T, Op, I, O). A round-trip
  • formalism -> code -> formalism sees no sub-compositions.
  • Renderer: significant changes — navigation, rendering, bridge UI
  • Format: .cm.js extended with subCompositions and patterns
  • History: compound commands for merge/unmerge — same undo/redo system
  • Patterns: embedded in .cm.js — no external files
  • Bridge naming: auto-generated from plug label, index suffix on collision
  • Dynamic bridges: supported from both inside and outside
  • Backward/Forward: transparent — the scrubber sees all operators flat
  • Composition Orchestra: the sub-composition primitive is the natural
  • candidate for representing nodes in the orchestration canvas (use case 4).
  • Extension to independent compositions is in the backlog.

SubjectWhere
Pattern versioning / live linkBacklog
Pattern sharing across .cm.js filesBacklog — requires package format
Sub-composition as reusable operator (with own run())Rejected — transparent model chosen
Pattern categories / folders in catalogueBacklog (v0.24+)
Pattern instance "detached" visual indicatorBacklog — no signal when instance diverges from saved pattern (UX debt)
Missing operator resolution in patternsBacklog — 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

  • Nora — feature-subcomposition-v019.md (full UX spec, validated)
  • Jensen, K. (1992). Hierarchical Coloured Petri Nets. In *Advances
  • in Petri Nets 1992*, LNCS 609, Springer.
  • Kindler, E. & Wagner, R. (1997). Modules for Petri Nets. PNPM 1997.
  • van der Aalst, W.M.P. (1998). *The Application of Petri Nets to
  • Workflow Management.* Journal of Circuits, Systems, and Computers.

Cameleon v2 — 08/03/2026 — revised 08/03/2026 (reviews Lea, Nora, Mira)

039 Format de projet .cm.json et bijection sérialisation
08/03/2026

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 runtimeOPS[], 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 :

  • Les instances ont des alias uniques générés (src_1, trn_2)
  • Les configValues sont des valeurs runtime, pas des paramètres statiques
  • Les sous-compositions et bridges (ADR-038) n'existent pas dans ADR-021
  • Les patterns sont du renderer metadata, pas du format composition
  • Le compteur d'ID doit être préservé pour éviter les collisions

ADR-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.


Format .cm.json — sérialisation projet

Le 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 }
}

Bijection sérialisation ↔ état runtime

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é.

Résolution des templates

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.

Gestion des IDs à l'import (review Léa)

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.

Relation avec ADR-021 (.cm.js)

Aspect.cm.js (ADR-021).cm.json (ADR-039)
FormatModule ES (JavaScript)JSON pur
RôleFormat d'échange, écriture humaine/LLMFormat de projet, sauvegarde/restauration
TemplatesImports ES depuis les librariestemplateId string → résolu au chargement
Sous-compsPas encore supporté (ADR-033)Supporté (ADR-038)
PatternsNonOui
CompteursNon (instanciation fraîche)Oui (continuité des IDs)
Configparams statiques (ADR-020)configValues runtime
Positionslayout.plugs en radiansplugAngles + x, y, r

Relation avec ADR-033 (DSL)

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.

Rétrocompatibilité

Un .cm.json sans formatVersion est traité comme v0.19 implicite (format localStorage actuel, même structure sans l'enveloppe meta/formatVersion).


  • L'éditeur peut sauvegarder/charger des compositions complètes dès v0.20 — avec
  • sous-compositions, bridges, patterns et positions
  • Le format est identique au format localStorage existant (_saveToStorage) avec
  • un wrapper meta + formatVersion — pas de nouvelle logique de sérialisation
  • Le round-trip est garanti par construction — les tests TDD vérifient
  • deserialize(serialize(state)) ≡ state
  • Le .cm.json est un format transitoire — il sera remplacé par le .cm.js DSL
  • (ADR-033) quand celui-ci sera implémenté
  • Le .cm.json ne prétend pas être un format d'échange — il est couplé à la
  • version du catalogue. Un .cm.json créé en v0.20 ne fonctionnera avec une
  • future version que si les templateId restent stables

Caméléon v2 — 08/03/2026

040 Renderer Decomposition — Modular Editor Architecture
09/03/2026 (updated 10/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 fragilitybuild-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

Current module structure (v0.20)


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)

Audit findings

Full audit in doc/projet/etudes/architecture/audit-architecture-renderer.md. Key metrics:

  • ~40 module-level variables shared across 29 sections
  • ~150 lines of exact duplicates (operator/plug/bridge rendering copied between addOperatorSVG and buildSVG)
  • 8 repeated patterns with 100+ occurrences (find/splice, plug iteration, bridge updates)
  • Drag & Drop is the most coupled section: 1015 lines, reads/writes ~15 globals
  • Execution calls CVM directly — never goes through the undo/redo command system

createEngine() factory + 6 editor modules

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).

Engine — createEngine() factory

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.

State split — model vs UI

Only the model state lives in the engine. UI state stays editor-side.

GroupVariablesWhere
CompositionOPS, CONNS, SUB_COMPS, PATTERNS, idx, connIdx, scIdx, brIdx, currentLevelengine.state
Executioncvm, running, busy, rewinding, firingId, animEnabled, stepDelay, simModeengine.state
Geometry cachePLUG_IDX, PLUG_ANGLES, _envelopeeditor (geometry/render)
UIselectedOps, selectedSCs, drag, zoomLevel, showAnn, currentModeeditor (interact/ui)

Module responsibilities

ModuleResponsibilityDOMEngineLOC est.
system.jsBoot, opsRegistry, storage, File API (I/O only)localStoragesync + listen~500
geometry.jsPlug geometry, Bézier, hit testing, visibility, bridges~300
render.jsBuild/Update/Incr. SVG, annotations, token animationSVGsync + listen~900
interact.jsDrag & Drop, selection, zoom/pan, tooltipsDOM events~1100
commands.jsUndo/redo, command factories, snapshot, execution playbacksync (sole mutator)~1030
ui.jsPanels, catalogue, config editor, controller, menusDOM panelssync + 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.

Module absorptions

Existing moduleAbsorbed byRationale
subcomp.jsgeometry.jsSame concepts: positions, plugs, hit testing, geometry
file-io.jssystem.jsSerialization, File API, dirty state — everything persistence
menus.jsui.jsUI widgets — menu bar is a widget like the others

Communication — two patterns

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.

EventTriggerConsumers
op:addedengine.addOp()render, ui
op:removedengine.removeOp()render, ui
conn:addedengine.addConn()render
conn:removedengine.removeConn()render
topology:resetengine.loadComposition()render (rebuild), system, ui
topology:changedengine.notifyTopologyChanged()system (auto-save)
cvm:stepengine.execStep()render (animation), ui (log), CLI (trace)
cvm:resetengine.reset()render, ui
execution:changedengine.setRunning/setPausedrender, 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.

Key boundary: Render ↔ Interact

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.

Duplicate code elimination

The audit identified ~150 lines of exact duplicates and 8 repeated patterns (100+ occurrences):

#DuplicateLocationsAction
D3Operator circle renderingaddOperatorSVG / buildSVGExtract _buildOperatorGroup()
D4Plug renderingaddOperatorSVG / buildSVGExtract _buildPlugElements()
D5Bridge plug renderingaddSubCompSVG / buildSVGExtract _buildBridgePlugGroup()
P3SUB_COMPS.find(s => s.id)33 occurrencesExtract helper
X1_serializableConfigrenderer.js + file-io.jsSingle location (geometry.js)

Factored during module extraction.

Naming conventions

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:

PrefixMeaningExample
create*Factory returning new instancecreateEngine(), createCVM()
build*Construct DOM/SVG, side-effectsbuildSVG(), buildCatalogue()
find*Search, returns item or nullfindPlugAtPoint()
is / hasBoolean predicateisRef(), isCompatible()
cmd*Command factory (undo/redo)cmdAddOp(), cmdRemoveConn()
resolve*Resolve external dependencyresolveHuman(), resolveControl()
render*Render output/viewrenderView(), renderAnnotations()
notify*Emit event, no mutationnotifyTopologyChanged()
_*Private to module_buildOperatorGroup()
init*Module initializer, returns APIinitRender(), initInteract()
setup*Attach event listenerssetupDrag(), setupZoom()

Eventsnoun:verb lowercase: op:added, topology:changed, cvm:step. No editor-internal event bus.

StateUPPER_SNAKE for structural constants/collections (OPS, CONNS, PLUG_R), camelCase for mutable state (running, currentLevel).

Types ODK — PascalCase (DataString, DataTableRef). Reference types suffixed *Ref.

Standalone build compatibility

build-doc.py inlines all editor/*.js files into a single <script> block for the standalone demo. The new modules follow the same convention:

  1. _strip_imports_exports() removes ES module syntax
  2. export functionfunction (global scope after inlining)
  3. Existing auto-discovery (editor_dir.glob("*.js")) picks up new files automatically
  4. Shared const declarations are deduplicated

#QuestionResolution
~~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.
Q4geometry.js signatures — globals become 4-6 params per functionOptions: (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.

Positive

  • Monolith fully replacedrenderer.js is deleted. 6 editor modules + createEngine() factory.
  • Clear responsibilities — each module has a single concern with defined DOM/engine nature
  • Single engine instance, two faces — sync (methods) for mutations, async (events) for observation. One createEngine(), not two separate files.
  • State split — model state in engine (OPS, CONNS, CVM), UI state in editor modules (selections, drag, zoom)
  • Sole mutator pattern — only commands.js mutates the engine, all others listen or read. Traceable mutation path.
  • Testable — geometry.js is pure (testable without DOM); engine is testable without editor; commands testable with engine mocks
  • CLI-ready — CLI calls createEngine() and uses both faces. Zero editor dependency.
  • ~150 duplicate lines eliminated — SVG rendering helpers factored
  • Standalone compatible — all modules inline without changes to build-doc.py
  • No framework dependency — vanilla JS, bus is ~15 lines in closure
  • No presentation event bus — direct calls between editor modules, simpler mental model

Negative

  • Event traceability — harder to trace "who caused what" for async events vs direct calls. Mitigated by limiting events to ~10 types and keeping commands.js as sole mutator.
  • Engine instance threading — all modules share one engine instance; must be passed consistently at boot.
  • geometry.js parameter verbosity — pure functions need 4-6 explicit parameters instead of reading globals (Q4).

Risks

  • Non-regression — each extraction must preserve exact behavior. 366 automated tests + 240+ visual test plan cases serve as safety net.
  • Incremental extraction — renderer.js must continue working during extraction. Each phase produces a working editor.
  • Event/direct call boundary — if the distinction blurs, we risk event spaghetti or unclear ownership. The rule is firm: engine events for model (1→N), direct calls for UI (1→1).

Full component framework (option A)

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+.

Separate bus.js file (option B)

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.

Callback injection only — no bus (option C)

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 partially — keep renderer.js (option D)

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.

Separate commands.js and execution.js (option E)

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.

Editor-internal event bus for presentation events (option F)

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

041 EngineConfig — Contrats d'adaptation consommateur
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.


EngineConfig — quatre contrats, un seul objet

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;
  // ...
}

config.interaction — interaction humaine et live

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})`); }
};

config.edit — édition des valeurs et paramètres

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:  () => {}
};

config.display — affichage des valeurs de sortie

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: () => {}
};

config.files — sorties fichier

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: () => {}
};

Adaptateurs de référence

Éditeur visuelsystem.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);
    }
  }
});

CLIcli/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 HTTPserver/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 })
  }
});

Positive

  • Contrat formel et unique — un seul config couvre les quatre
  • niveaux d'adaptation. Chaque consommateur sait exactement ce qu'il
  • implémente
  • Noms domaineinteraction, panels, display, files
  • communiquent l'intention sans ambiguïté (P5 — Domain Language First)
  • Les libs ne bougent passtd-controls.js et les libs de
  • rendu restent dans l'engine. Les adaptateurs les appellent ou non
  • Engine headless par défautinteraction absent = erreur
  • explicite. edit, display et files absents = noop silencieux.
  • Comportement prévisible selon la criticité
  • Trois consommateurs couverts dès v0.21 — éditeur, CLI, tests.
  • Le serveur HTTP s'insère sans modifier l'engine
  • FileSaver sans DOM dans le moteurconfig.files.download sort
  • Blob et filesystem de engine/libs/. Le moteur retourne des données,
  • l'adaptateur décide de les écrire
  • Testabilité — le mock est un objet de six fonctions,
  • zéro DOM

Négative

  • Config plus large — quatre sections. Reste optionnel et partiel
  • — on ne passe que ce dont on a besoin
  • Adaptateur à charge du consommateur — c'est voulu.
  • C'est la responsabilité du consommateur, pas de l'engine

Migration

  1. Ajouter EngineConfig + defaults dans engine.js
  2. Player délègue à interaction.resolveHuman / interaction.resolveLive
  3. Les appels aux panneaux dans system.js passent par
  4. edit.control / edit.config
  5. Les affichages de sortie passent par display.renderView
  6. Player appelle files.download(data, filename) post-execStep si
  7. l'opérateur retourne { _download: true, data, filename }
  8. system.js passe le config complet à createEngine()
  9. Supprimer les callbacks implicites

Migration atomique avec Phase 1 — pas de phase séparée.


Option A — Trois ADRs séparés

Un ADR par contrat. Rejeté — même pattern, même justification, même migration. Un seul ADR est plus clair.

Option B — Events engine (bus)

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.

Option C — Statu quo — callbacks renderer

Le pattern actuel. Rejeté — ne scale pas à deux consommateurs.

Option D — Noms génériques (input, controls, view, io)

Noms initiaux. Rejetés — trop génériques, collision avec engine.widgets (registre), et ne communiquent pas l'intention (P5 — Domain Language First).

Option E — panels avec resolveControl / resolveConfig

Nommage 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

042 Transport par référence — Données volumineuses dans le CVM
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 :

  • DataFrames — tables de 10k à 10M lignes
  • Séries temporelles — streams de données indexées
  • Images — bitmaps PNG/JPEG/WebP
  • Sources SQL / API — données non matérialisées en mémoire

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.

Invariant formel à préserver

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.

Question ouverte : backward/rewind avec références mutables

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.


Transport par référence

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 ..." }
  }
}

Types de référence

TypeDescriptionKinds supportés
DataTableRefTable 2D (rows × cols)sql, api, file (CSV, Parquet)
TimeSeriesRefSérie temporelle indexéeapi, file (CSV, JSON)
ImageRefImage bitmapfile, url
ChartRefSpécification de graphiqueinline (JSON Vega-Lite)

Résolution des références

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.

Rétrocompatibilité

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;
}

Backward / Rewind — limitation acceptée

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.

Modèle formel — invariants préservés

Le transport par référence est **transparent pour le moteur Petri net** :

  • Une connexion transporte un token — scalaire ou référence,
  • le moteur ne distingue pas
  • Les états EMPTY/NEW/OLD s'appliquent de la même façon
  • Les firing policies ANY/ALL/COND s'appliquent de la même façon
  • La compatibilité de types est vérifiée au niveau du type ODK
  • (DataTableRef est compatible avec DataTableRef, pas avec
  • DataString)

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.


Positive

  • Moteur CVM inchangé — le transport par référence est transparent
  • pour le modèle de tir. Aucune modification de cvm.js
  • Mémoire maîtrisée — aucune copie de données volumineuses en
  • mémoire. Les opérateurs travaillent sur des requêtes, pas des copies
  • Rétrocompatibilité totale — les scalaires existants sont
  • inchangés. Les types *Ref sont additifs
  • ODK extensible — chaque nouveau type riche est un type ODK
  • comme les autres — viewer + éditeur dans les libs

Négative

  • Backward limité sur sources mutables — documenté et accepté
  • Résolution à charge des opérateurs — les opérateurs qui
  • consomment des références doivent implémenter resolveRef().
  • C'est leur responsabilité
  • Tests plus complexes — les tests d'opérateurs qui consomment
  • des références nécessitent des mocks de sources

Impact sur ADR-039 (.cm.json)

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.


Option A — Transport par copie avec lazy loading

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.

Option B — Shared memory / SharedArrayBuffer

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).

Option C — Statu quo — scalaires uniquement

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

043 Mode Data — Canvas d'exploration et dashboard
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 :

  • Le canvas SVG est vectoriel — pas adapté aux tables interactives
  • ou aux charts complexes
  • Le panneau Data (droite) est conçu pour des valeurs courtes —
  • debug inline, pas exploration approfondie

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.


Switcher — [Visual] [Code] [Data]

Le mode Split est supprimé. Le switcher final est :


[Visual]  [Code]  [Data]

Raccourcis clavier : Ctrl+1, Ctrl+2, Ctrl+3.

Sémantique des trois modes

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.

Mode Data — architecture

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.

Viewers — contrat ODK étendu

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 ODKViewerÉditeurSource
DataTableTableViewer — tri, filtre, paginationTableEditor — édition celluleDataTableRef
TimeSeriesTimeSeriesViewer — chart ligneTimeSeriesRef
ImageImageViewer — zoom, panImageAnnotator (backlog)ImageRef
ChartChartViewer — rendu interactifChartConfigurator — paramètres visuelsChartRef (spec JSON)
DataSourceQueryEditor — SQL / pandasDataTableRef

Renderer de charts : décision ouverte — voir section "Comparatif renderers charts" ci-dessous.

Persistance du layout

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).

Accès aux sorties d'opérateurs

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.

Limitation backward avec sources mutables

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.


Positive

  • Séparation claire — canvas SVG pour la composition, canvas
  • HTML pour les données. Pas de compromis sur l'un ou l'autre
  • Extensible — chaque nouveau type ODK riche ajoute un viewer
  • sans modifier le mode Data lui-même
  • Persisté — le layout fait partie de la composition, pas d'un
  • état éphémère. Partageable via git
  • Cohérent avec ADR-041renderView() est le seul contrat.
  • Les viewers riches ne nécessitent pas de nouveau mécanisme

Négative

  • Deux canvas à maintenir — canvas SVG (Visual) + canvas HTML
  • (Data). Complexité accrue pour le renderer
  • Vega-Lite comme dépendance — à intégrer dans le standalone.
  • build-doc.py doit l'inclure
  • Layout orphelin — si un opérateur est renommé ou supprimé,
  • les vues qui le référencent deviennent orphelines. À gérer
  • proprement (warning, pas de crash)

Impact sur le plan de release

  • v0.21 — switcher [Visual] [Code] [Data] sans canvas Data
  • (mode Data = placeholder). Raccourcis clavier uniquement.
  • v0.22 — canvas Data basique, TableViewer, panneau gauche
  • ENTRÉES/SORTIES, persistance dataLayout dans .cm.json
  • v0.23+ — ChartViewer (Vega-Lite), TimeSeriesViewer, QueryEditor
  • Backlog — ImageViewer, ImageAnnotator, ChartConfigurator

Questions ouvertes

Q3 — 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

Tableau comparatif

LibTaille minTaille gzipLicenceTypes de chartsSpec sérialisableStandalone inline
uPlot~50 KB~20 KBMITLine, area, bar, OHLCNon (impératif)✓ trivial
Chart.js~254 KB~80 KBMITBar, line, pie, scatter, radar…Non (impératif)✓ acceptable
Vega-Lite~8 MB (stack complète)~2.5 MBBSD-3Tout (grammaire complète)✓ JSON déclaratif⚠ bundle lourd
Vega-Lite + bundler~300-500 KB~120 KBBSD-3Sous-ensemble fixe✓ JSON déclaratif✓ si specs fixes
D3.js~270 KB~90 KBISCTout (bas niveau)Non (impératif)✓ acceptable
SVG natif0 KB0 KBBar, line, scatter (custom)✓ (données JSON)✓ trivial

Analyse par option

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).

Question à trancher

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.

Recommandation Mira — approche hybride par version

  • v0.22 — SVG natif pour les premiers charts (bar, line).
  • Zéro dépendance, standalone trivial. Valide le modèle
  • ChartViewer sans dette de choix de lib.
  • v0.23 — Chart.js si besoin de types supplémentaires
  • (pie, scatter). uPlot si TimeSeriesViewer est prioritaire.
  • Backlog — Vega-Lite + vega-bundler si le ChartConfigurator
  • doit produire des specs JSON sérialisables complexes.

Cette 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.


Option B — Panneau Data enrichi + popout (Nora)

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é.

Option C — Overlays HTML sur canvas SVG (Nora)

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.

Option D — Hybride B + C (Nora)

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

044 Control Rendering Separation — Contracts vs. Views
12/03/2026

engine/libs/std-controls.js (349 lines) currently mixes two concerns:

  1. Control contracts — metadata defining what a control is (id, label, input type, description)
  2. DOM renderingdocument.createElement, addEventListener, showSaveFilePicker, File API

This 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:

  • Headless execution — if any import path pulls std-controls.js at load time, CLI crashes (no document)
  • ODK extensibility — custom operator developers must write DOM code inside engine/libs/ to define a control, mixing concerns
  • Alternative frontends — a React, Electron, or terminal UI would need to rewrite all control rendering

The 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 layer — engine/libs/std-controls.js

Keeps 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 layer — 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);
}

ODK pattern

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
});

FileSaver browser logic

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).


Positive

  • Engine layer becomes truly pure — no DOM dependency
  • CLI and tests can import the full engine without browser globals
  • ODK separation is clearer: contract = engine, view = editor
  • Alternative frontends (React, terminal) only need to implement control views
  • FileSaver works headless via config adapter

Negative

  • Two files to maintain per control (contract + view) instead of one
  • Existing control code must be split — migration effort
  • ODK documentation must explain the two-layer pattern

Neutral

  • No runtime behavior change for the browser editor
  • Control registry API (registry, editors) stays the same
  • Existing operators are not affected (they reference control IDs, not DOM code)

A. Keep DOM in engine, guard with typeof document


render(container, value, onChange) {
  if (typeof document === "undefined") return;
  // DOM code
}

Rejected: band-aid that keeps the architectural violation. Still blocks alternative frontends.

B. Move all controls to editor layer

Rejected: control contracts (id, type, description) are part of the operator metadata model. They belong in engine. Only the rendering belongs in editor.


Q1 — File structure

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.

Q2 — Registration API

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) { ... } });

config.io — FileSaver adapter

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.

045 Matérialisation des données et gestion des ressources
13/03/2026

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.

Ce que la composition Twitter révèle

La composition d'exemple suivante expose les deux problèmes :


[TwitterStream] ──► [CsvConsolidate] ──► [InterestFilter]
                                               │
                                          [SqlInsert] ──► [TableViewer]
                                          [ParquetInsert] ──► [ParquetViewer]
  • TwitterStream a besoin d'un client API Twitter
  • SqlInsert a besoin d'un pool PostgreSQL
  • ParquetInsert a besoin d'un accès Databricks
  • InterestFilter produit un résultat qui alimente deux opérateurs
  • aval — faut-il résoudre deux fois ?

1 — Deux modes de transport, pas trois

ADR-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

2 — Matérialisation à la charge du plug out

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
}

3 — Cache du moteur par step

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 :

  • Forward au step N → le cache du step N est disponible
  • Backward au step N-1 → le cache du step N est invalidé
  • Le TableViewer lit le cache — pas de nouvelle requête

Le cache est en mémoire, limité à la session. Il n'est pas persisté dans .cm.json.

4 — Ressources : responsabilité du consommateur

Le moteur ne connaît pas SQL, HTTP ou Parquet. Il ne peut pas initier les connexions. Cette responsabilité appartient au consommateursystem.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.

Déclaration dans .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.

Registre de libs dans le consommateur

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
}

Init lazy au démarrage de la composition

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.

Injection dans le contexte opérateur

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.

Libération des ressources

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()
  }
})

5 — Compatibilité de types entre plugs

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.

Tableau de compatibilité des plugs

Plug out \ Plug inDataByRefDataInMemory
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.*


Positive

  • Moteur découplé — il ne connaît pas SQL, HTTP, Parquet.
  • Le ctx.connections est opaque pour lui
  • Init lazy — pas de connexion ouverte si non utilisée.
  • La CLI ne charge pas pg si la composition n'en a pas besoin
  • Backward cohérent — le cache du moteur est indexé par step,
  • invalidé avec l'historique. Le viewer lit le cache, pas la source
  • Compensation data — l'opérateur connaît ses effets de bord et
  • déclare son compensate(). Le moteur appelle mécaniquement
  • Sources mutables documentéesDataByRef vers une API ou
  • un fichier mutable porte l'icône ⚠ dans le viewer. Limitation
  • acceptée, pas ignorée
  • Matérialisation explicite — l'opérateur Materialize rend
  • visible dans le graphe une décision qui a un coût réel.
  • Pas de magie implicite du moteur
  • Compatibilité de types vérifiée — le moteur refuse une
  • connexion incompatible et guide l'utilisateur vers la solution

Négative

  • EngineConfig s'élargitconfig.data avec connections
  • et memoryThreshold. À intégrer dans ADR-041
  • Scan de composition avant exécutionscanConnections()
  • est une étape supplémentaire dans le boot du consommateur
  • Pas de connexion partagée cross-compositions — chaque
  • composition initie ses connexions. Pool sharing cross-session
  • est backlog

Limitations acceptées

  • Backward sur source mutableDataByRef vers API ou
  • fichier mutable : le handle est restauré, pas l'état de la source.
  • Icône ⚠ dans le mode Data
  • Compensation impossible — certains opérateurs (EmailSend,
  • opérations API non idempotentes) lèvent CompensationNotSupported.
  • Le moteur bloque le backward à ce step avec message explicite
  • Cache en mémoire uniquementDataInMemory est perdu
  • à la fermeture de session. Persistance sur disque est backlog

Option A — Connexion comme plug dans le graphe

SqlConnect 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.

Option B — Connexions dans l'engine

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.

Option C — Matérialisation automatique par le moteur

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.

Option D — Résolution implicite par le moteur sur incompatibilité

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

046 Inspecteur I/O — Observabilité de l'exécution
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 :

  • Où sont mes données ? — dans le cache moteur, dans la source,
  • en transit ?
  • Mes connexions sont-elles actives ? — pool ouvert, latence,
  • erreur silencieuse ?
  • Que se passe-t-il en ce moment ? — quel opérateur tourne,
  • quelle queue est saturée, quel step est en attente ?

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.

Référence marché

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.

Positionnement OSS

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.


Périmètre — trois niveaux d'information

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

Placement UI

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.

Événements bus — extension ADR-036

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.

Ce que l'inspecteur ne fait pas

  • Il n'interrompt pas l'exécution — pas de bouton stop ici
  • (rôle du panneau de contrôle CVM)
  • Il ne modifie pas le cache — lecture seule
  • Il ne gère pas les connexions — pas de reconnect, pas de
  • configuration (rôle du consommateur)
  • Il n'est pas le mode Data — il ne montre pas le contenu
  • des données, seulement leurs métadonnées (taille, type,
  • état)

Positive

  • Observabilité sans couplage — l'inspecteur écoute le bus,
  • il ne change rien au moteur ni aux opérateurs
  • Extensible par plugins — persistance multi-sessions, alerting,
  • export métriques (Prometheus, Datadog), audit trail — sont des
  • plugins OSS qui écoutent le bus. Zéro modification du moteur
  • ou de l'inspecteur de base
  • Debug data facilité — identifier immédiatement si un
  • résultat vient du cache ou d'une nouvelle résolution, si
  • une connexion est silencieusement en erreur
  • Préparation Q1 ADR-044 — les événements queue:depth
  • posent les bases pour la future gestion backpressure
  • LiveSource

Négative

  • Surface bus augmentée — ~10 événements supplémentaires.
  • Overhead négligeable mais surface de contrat à maintenir
  • Consommateur doit émettre — les événements conn:*
  • ne viennent pas du moteur mais du consommateur. system.js
  • doit émettre ces événements via engine.emit() — contrat
  • supplémentaire sur le consommateur

Dépendance de séquençage

L'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).


Option A — Onglet dédié dans le panneau droit

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.

Option B — Overlay sur les nœuds du canvas

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.

Option C — Logs texte

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

047 Amendement : `rollback` → `compensate` dans le contrat opérateur
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.

Référence marché

L'audit des standards workflow confirme que le terme dominant pour cette sémantique est compensate / compensation :

Standard / outilExécutionCompensation
Saga patternexecute / runcompensate
BPEL / BPMNinvokecompensate
Dapr Workflowactivitécompensate
Elsa Workflowsexecutecompensate / confirm
Oracle WorkflowRUN modeCANCEL mode
Airflowexecute— (idempotence recommandée)

La distinction est fondamentale :

  • rollback — annulation technique atomique. Sémantique DB.
  • Implique que l'état est restauré exactement à son état antérieur.
  • compensate — action métier inverse. Sémantique workflow/saga.
  • Produit un effet équivalent à l'annulation, sans garantie d'atomicité.

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.


Renommage dans le contrat opérateur


// 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
}

Exception renommée


// Avant
throw new Error("RollbackNotSupported")

// Après
throw new Error("CompensationNotSupported")

Sémantique clarifiée

compensate() est une action métier inverse — elle n'annule pas l'opération au sens ACID, elle produit l'effet opposé :

Opérateurrun()compensate()
SqlInsertINSERT INTO ...DELETE WHERE _cm_step = id
ApiPostPOST /resourceDELETE /resource/{id}
FileWriteécriture fichiersuppression du fichier
Materializerésolution + cache externeinvalidation cache externe (Redis, Memcached)
EmailSendenvoi emailCompensationNotSupported
TwitterDeleteDELETE tweetCompensationNotSupported

Périmètre de 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 contrat

Mê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")
  }
}

ADROccurrencesNature
ADR-0153Contrat opérateur de base
ADR-0161Contrat opérateur HumanInput
ADR-0341Contrat opérateur Control
ADR-0453Contrat opérateur data + exception

Positive

  • Alignement sémantique — le terme correspond à ce que l'opération fait réellement
  • Cohérence avec van der Aalst — les Workflow Patterns utilisent compensate
  • Clarté pour les auteurs d'opérateurscompensate() dit explicitement "fais l'inverse métier"

Négative

  • Breaking change sur le contrat opérateur — migration mécanique, aucune logique ne change
  • Impact code — Axel : grep rollback dans operators-lib et le code existant

Migration


# 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

050 Player Engine — kernel / player / engine decomposition
16/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 :

  • Dupliquée dans system.js (~280 loc) et runner.js (83 loc, CLI)
  • Divergente : system.js et runner.js ne suivent pas les mêmes règles (bug fire-all dans runner.js — violation P1)
  • Mêlée à de la logique editor (animation, human controls, localStorage) dans system.js

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().


Trois composants internes

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
ComposantFichierRôle
kernelengine/kernel.jsNoyau 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.
playerengine/player.jsOrchestration temporelle. Forward, backward, run, rewind, pause, resume, stop. Arbitrage FIFO (_arbitrate). Résolution human/live/controls. Détection deadlock/complétion. Émet player:, composition:.
engineengine/engine.jsFacade + bus owner. Instancie kernel + player + bus. Proxy pur — aucune logique, chaque méthode délègue.

Plain objects — factories (C6)

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.

Discipline facade

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.

Bus events contract

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).

Événements

GroupeÉvénementÉmetteurPayload
Petricvm:steppedkernel{ opId, oldConns, newConns, outputs }
cvm:undonekernel{ opId, step }
cvm:resetkernel
Playerplayer:state-changedplayer`{ state: idle\running\paused\waiting\error }`
player:position-changedplayer{ step, total }
player:skippedplayer{ opId, reason }
Compositioncomposition:startedplayer{ compositionId }
composition:endedplayer`{ steps, status: completed\deadlock }`
composition:erroredplayer{ opId, error }
Topologyop:addedengineop (descriptor)
op:removedengineopId
conn:addedengineconn
conn:removedengineconnId
subcomp:addedenginesc
subcomp:removedenginescId
topology:changedengine

Note : le kernel ne détecte ni deadlock ni complétion. Il retourne fireable() → []. Le player interprète : status: deadlock si des connexions sont encore NEW, status: completed sinon.

P2 — player:state-changed hors modèle formel : idle | running | paused | waiting | error sont 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.

Surface publique de createEngine()


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).

Arbitrage — FIFO topologique


_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.

Adaptation contracts (ADR-041)

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.

runner.js — supprimé

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.


Positive

  • Décomposition formalisée — kernel/player/engine sont des composants documentés avec des responsabilités claires, pas des couches implicites
  • Bus events contract — 17 événements spécifiés avec payload et consommateurs. Les consommateurs savent ce qu'ils peuvent écouter
  • Surface publique complète — ~30 méthodes documentées. L'editor ne connaît pas les modules internes
  • Duplication éliminée — une seule boucle d'exécution (player), consommée par editor, CLI, tests
  • P1 protégé — le kernel applique les règles Petri, le player orchestre. Pas de raccourci possible
  • Facade discipline — engine.js est un proxy pur, vérifiable par inspection
  • Injection cohérente_emit injecté par engine dans kernel et player (C5). Pas d'import du bus

Negative

  • Surface large — ~30 méthodes sur la facade. Atténué par la discipline "proxy pur"
  • Un composant de plus — player.js s'ajoute entre kernel et engine. Complexité organisationnelle limitée (c'est du déplacement de code, pas de l'écriture from scratch)
  • ADR-036 supersedé — perte de l'historique du raisonnement initial. Atténué par le lien "Supersedes" qui préserve la traçabilité

Risks

  • Player qui grossit — le player absorbe ~280 loc de system.js + 83 loc de runner.js. Si d'autres responsabilités s'y greffent (live streaming, data mode), il risque de devenir un nouveau monolithe. Mitigé par la règle : le player orchestre, il n'exécute pas la logique métier
  • Migration incrémentale — system.js doit continuer de fonctionner pendant l'extraction. Les 466+ tests automatisés + 240+ cas visuels servent de filet

Option A — Player dans engine.js (pas de fichier séparé)

Garder 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.

Option B — Player comme adapter (config.player = boucle)

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.

Option C — Garder cvm.js sans renommer

Ne pas renommer cvm.jskernel.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.

Option E — 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.

Option D — ADR-036 amendé au lieu de supersedé

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".


Prérequis

  • ADR-050 passe Accepted avant toute implémentation (D2)
  • ADR-041 passe Accepted (adapters consommés par player)
  • ADR-047 mis à jour (compensate = side-effects externes)

Étapes

  1. Renommer cvm.jskernel.js (aliaser l'ancien nom pendant la transition)
  2. Créer player.js — extraire la boucle de system.js (~280 loc) + runner.js (83 loc)
  3. Injecter _emit dans kernel et player depuis engine.js
  4. Ajouter les events player (player:state-changed, composition:ended, etc.)
  5. Aligner la surface publique : engine.forward()player.forward(), renommer opsRegistrycatalogue, grouper types, controls
  6. Ajouter engine.setConfig() / engine.getConfig() (ADR-020)
  7. Supprimer runner.js — CLI appelle engine.run()
  8. Mettre à jour ADR-036 → statut "Superseded by ADR-050"

Ordre de dépendance


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

051 Concurrent execution via Web Worker pool
19/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.

Quand la concurrence est-elle pertinente ?

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.

Critère d'indépendance

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.

Architecture


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)

Protocole worker

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-*.

Opérateurs non-parallélisables

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)

Historique groupe (rollback)

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 de workers


// 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)


AlternativeRaison du rejet
Promise.all sans WorkersPas de vrai parallélisme CPU — améliore l'I/O uniquement (déjà le cas)
Rust/WASM pour le CVMADR-010 (rejet porté Go/Rust tant qu'aucun problème de perf mesuré)
SharedArrayBuffer seulTrop complexe, nécessite des atomiques pour synchroniser l'état CVM
Un Worker par opérateurOverhead trop élevé pour les petites compositions

Deux phases dans la roadmap :

  1. Design (v0.22.0-design) : ADR-051 Accepted, spécification protocole worker, format historique
  2. groupe, critère d'indépendance, API WorkerPool. Tests de non-régression boucle séquentielle.
  3. Implémentation (v0.22.0-impl) : cvm/worker.js, cvm/pool.js, extension cvm/player.js
  4. (_partition, _mergeResults, group backward), mise à jour cvm/engine.js facade, tests
  5. concurrence.
Qualité · Modèle d'exécution · Recherche
048 Protocole d'évaluation WCP comme suite de tests avant v0.22 Proposed
14/03/2026 · O. Cugnon de Sévricourt

En 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 :

  • v0.22 — Refactoring éditeur (en cours)
  • v0.23 — Protocole WCP control flow + R&D libs + solidification use cases de référence
  • v0.24 — Bijection texte ↔ visuel (DSL)
  • v0.25 — Système de conception
  • v0.26 — Mode Data (canvas data), précédé d'une position formelle sur ce que le moteur absorbe

Scope v0.23 — deux volets :

Volet 1 — Protocole WCP control flow (5 patterns supportés)

WCPOpérateurRaison de priorité
WCP-2 AND-splitForkParallélisme — risque fort si canvas data crée des branches de rendu
WCP-3 AND-joinJoinSynchronisation — sensible à tout changement de timing
WCP-4 XOR-splitGateCondition — dépend de DataBool, potentiellement impacté par canvas
WCP-5 XOR-joinMergeANY policy — vérifier qu'aucun rendu ne trigger un fire parasite
WCP-21 Structured LoopLoopCycles — 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

  • Chaque pattern devient un test de non-régression formel : si v0.22 casse
  • WCP-2 ou WCP-3, c'est visible immédiatement
  • Le protocole oblige à décider avant de coder le canvas data : est-ce une
  • feature de représentation pure (les cas WCP doivent passer intacts) ou une
  • feature qui touche à la sémantique (décision consciente, documentée) ?
  • La matière produite alimente directement la section 6 du paper v2 sans
  • travail de reformatage
  • Les cas de test servent de documentation comportementale du moteur pour
  • les contributeurs

Point de vigilance

  • Le protocole couvre les patterns supportés. Les patterns partiels
  • (WCP-10) et non supportés (WCP-22) sont documentés comme tels avec
  • justification formelle — ils définissent les limites du modèle, ce qui
  • est une contribution en soi.
  • Si v0.23 révèle qu'un pattern doit être adapté pour le canvas data, l'écart
  • est documenté dans un ADR dédié avant toute modification du moteur — en
  • amont de v0.26.

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.


  • van der Aalst, W.M.P., ter Hofstede, A.H.M., Kiepuszewski, B., & Barros, A.P.
  • (2003). Workflow Patterns. Distributed and Parallel Databases, 14(1), 5–51.
  • ADR-033 — Catalogue des opérateurs stdlib
  • Note de recherche Caméléon v5, §4 — Workflow Patterns, positionnement
Catalogue · Gouvernance · Recherche
049 Protocole d'identification des opérateurs du catalogue Proposed
14/03/2026 · O. Cugnon de Sévricourt

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.

Passe 1 — Cas d'usage (ancrage terrain)

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.

Passe 2 — Mapping WCP + littérature (ancrage académique)

Chaque primitive de la passe 1 est mappée :

CasAction
Existe dans WCPNommé 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ératureDocumenté comme extension Caméléon avec justification formelle.
Existe dans la littérature mais absent des use casesBacklog académique — pas d'implémentation sans cas d'usage.

Livrable : tableau de mapping primitives → patterns, avec statut pour chaque.

Passe 3 — Validation protocole (ancrage formel)

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 catalogue v0.23 est plus petit que la liste théorique exhaustive — c'est
  • intentionnel
  • Chaque opérateur du catalogue a une justification traçable (use case +
  • pattern)
  • Les extensions Caméléon sont documentées formellement — elles alimentent
  • le paper v2
  • Les opérateurs "backlog académique" sont conservés comme référence mais
  • ne génèrent pas de travail d'implémentation
  • Ce protocole s'applique à toutes les versions suivantes — ADR-049 est la
  • référence normative pour la gouvernance du catalogue

  • van der Aalst, W.M.P. et al. (2003). Workflow Patterns. Distributed and
  • Parallel Databases, 14(1), 5–51.
  • ADR-031 — Standard Library v0.15
  • ADR-048 — Protocole avant fonctionnalité
  • Note de recherche Caméléon v5, §4 — Workflow Patterns
Langage · DSL · Recherche
052 Expressivité et ergonomie du DSL .cm.js Proposed
20/03/2026 · O. Cugnon de Sévricourt

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 :

  • Fluidité de lecture : la composition se lit-elle comme une description
  • de ce qui se passe ? Un développeur qui lit le .cm.js sans documentation
  • comprend-il la composition ?
  • Symétrie visuel/code : le nombre d'opérations pour exprimer quelque chose
  • sur le canvas et en code est-il comparable ?
  • Auto-documentation : les noms des opérateurs, plugs et connexions
  • suffisent-ils à comprendre sans commentaires ?

Passe 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.


  • ADR-033 — Catalogue des opérateurs stdlib et format .cm.js
  • ADR-049 — Protocole d'identification des opérateurs du catalogue
  • Note de recherche §17 — Caméléon est-il un langage ? Positionnement DSL
  • P12 — Design Equation (principles.md)
  • Ma et al. (2025). AFlow: Automating Agentic Workflow Generation. ICLR 2025.
  • arXiv:2410.10762. (abandon des Petri nets pour le code — argument à adresser)
Modèle d'exécution · Catalogue · ODK
053 Condition de tir déclarative : canFire config struct Proposed
21/03/2026 · O. Cugnon de Sévricourt

La 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.

Format de la config struct


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.

Application aux opérateurs existants

OpérateurfiringConditionSé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

Signature ODK mise à jour


{
  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

  • La sémantique de tir est lisible dans le descripteur sans lire le code
  • Les opérateurs custom ODK déclarent leur condition sans implémenter de
  • logique — le moteur fait l'évaluation
  • Merge peut être la pièce maîtresse des boucles comme documenté
  • Gate et Switch ont une sémantique spécifiée, pas juste "COND custom"
  • Testable directement depuis le protocole WCP (ADR-048) sans mock

Points de vigilance

  • La rétrocompatibilité est assurée par le défaut — les opérateurs sans
  • firingCondition gardent le comportement ANY strict actuel
  • Les opérateurs existants (std-io, std-data) sont à auditer : ceux qui
  • ont un seul input réel peuvent migrer vers { any: [...] } sans
  • changement de comportement observable
  • canFire() impératif reste possible comme escape hatch pour les cas
  • vraiment non-exprimables en config struct (rares)

La 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


  • taxonomy.md — firing policies et historique des décisions
  • ADR-019 — contrat canFire() original
  • ADR-031 — catalogue stdlib v0.15
  • ADR-048 — protocole WCP (bénéficiaire direct)
  • ADR-052 — expressivité DSL (lié — la config struct est un élément
  • de l'expressivité du langage opérateur)