Auditer un formulaire conditionnel — l’état que vous ne voyez pas

Recycler la documentation du projet LivresAgités (en sommeil) vers le dépôt pédagogique. Ce notebook illustre la feature AI Forms d’AI-Engine — l’une des deux fonctionnalités GenAI cœur du dossier qui n’avait, jusqu’ici, ni section de parcours ni notebook compagnon (l’autre étant le Copilot Gutenberg, Parcours 1).

Thèse. Un formulaire à logique conditionnelle — dont les champs apparaissent ou disparaissent selon les réponses antérieures — est une machine à états implicite. Le schéma se lit comme une simple liste de champs, mais le comportement réel est émergent : quels chemins existent, lesquels terminent, lesquels déclenchent un appel au LLM, ne se déduisent pas de la lecture du schéma. Ils s’énumèrent. Et leur nombre peut être exponentiel dans le nombre de conditions.

Ce notebook ne dépend d’aucun service : pas de réseau, pas de modèle, pas de clé. Le formulaire et son moteur d’énumération sont construits en mémoire, sur le fixture synthétique « Maison Valmont ». Ce qui est enseigné est la structure du comportement d’un formulaire conditionnel, pas une mesure sur une instance réelle.

Comment lire ce notebook

Le notebook suit un fil unique : un formulaire est une machine à états implicite. La cellule de setup le présente comme un dictionnaire — une liste de champs, des valeurs, des conditions — mais le comportement réel (quels champs l’utilisateur verra, combien de réponses finales existent, combien coûtent un appel de modèle) ne se lit nulle part dans cette liste : il est émergent et ne se connaît que par énumération. Chaque section déroule une question que la liste seule ne peut pas répondre : combien de chemins ? quelle distribution de longueurs ? combien d’appels de modèle ? quels champs ne seront jamais vus ? La sortie de chaque cellule est committée, et chaque chiffre cité dans les interprétations figure dans l’une de ces sorties.

Le fil rouge est la thèse des sections 4 à 6, qu’il faut garder en tête avant de commencer : les trois mesures (13 chemins, 62 % de chemins payants, un champ mort) sont toutes obtenues par une énumération mécanique du même dictionnaire — aucune estimation, aucune lecture visuelle du formulaire. Ce choix est méthodologique : un formulaire conditionnel est trop petit pour qu’on n’énumère pas, et les chiffres obtenus par comptage direct n’ont pas besoin d’être interprétés — ils se démontrent.

Enfin, noter ce que le notebook ne fait pas, sciemment : il ne mesure ni le coût monétaire par soumission (il faudrait une distribution de réponses utilisateurs), ni le temps de remplissage, ni l’expérience de navigation. Sa question est étroite et exactement délimitée : « que produit ce formulaire, indépendamment de celui qui le remplit ? » Tout le reste est laissé aux mains du lecteur, avec les instruments pour le faire — l’énumération reste la même, seules les questions varient.

Le notebook a un compagnon dans le même dossier — administrer-les-formulaires-par-l-api.ipynb. Le partage n’est pas formel mais il est cohérent : le compagnon administre (crée, met à jour, supprime des formulaires par l’API), celui-ci audite (lit la forme et le coût des formulaires existants). La couple lecture/action est le geste complet : un audit sans action est une constatation, une action sans audit est un pari — la plupart des façons de casser un formulaire de production sont des actions réussies sur une structure mal comprise.

Une question de méthode, enfin, avant de commencer : les sorties de ce notebook sont committées dans le dépôt. Elles sont des photographies prises à une date donnée, sur un formulaire donné ; le code des cellules est, lui, l’appareil rejouable. Si un lecteur ré-exécute le notebook sur un formulaire qui a changé entre-temps, les sorties vont diverger — ce n’est pas une erreur, c’est le signal attendu : la photographie a vieilli, et la différence entre le vieux cliché et le nouveau résultat est exactement la trace de la modification qu’il faut comprendre.

1. AI Forms en une phrase

AI Forms est le module d’AI-Engine qui pousse les formulaires WordPress au-delà de la simple collecte : chaque soumission peut être traitée par un LLM (synthèse, extraction, classification, traduction) avant stockage ou notification. La pièce maîtresse, pédagogiquement, n’est pas le LLM — c’est la logique conditionnelle : la visibilité d’un champ dépend des réponses données aux champs précédents. Un même formulaire présente donc des « pages » différentes selon qui le remplit et comment.

C’est cette logique conditionnelle qui transforme un formulaire en machine à états. Et comme toute machine à états, son comportement effectif (les chemins atteignables) ne se lit pas sur la définition statique : il se calcule.

Le formulaire : contenu plutôt que table

La phrase d’ouverture mérite d’être prise au sérieux avant de regarder le moindre chiffre : pour AI Engine, un formulaire est un contenu (un post, un payload éditable) et pas une table de schéma de données. Cette distinction a une conséquence directe sur ce notebook : le formulaire arrive ici sous forme de dictionnaire sérialisé — exactement comme il serait édité, passé, rechargé — et c’est cette forme-là qu’on énumère. S’il était une table de base de données, il aurait un schéma déclaré, des colonnes contraintes et un moteur de validation : on n’aurait pas besoin de l’auditer champ par champ, le système s’en chargerait.

Le cœur du piège que le notebook veut exposer tient dans cet écart : le contenu est une structure, le comportement est un calcul. Éditer le formulaire (ajouter une condition, une valeur) modifie le second sans que le premier ne le reflète. Deux formulaires au dictionnaire identique en apparence peuvent produire des machines à états très différentes — l’un fermé en quelques chemins, l’autre explosant en centaines — sans qu’aucune vue d’édition n’en montre la moindre différence. C’est pourquoi l’audit par énumération n’est pas un luxe : c’est le seul instrument qui lit le formulaire pour ce qu’il fait, pas pour ce qu’il paraît.

Le reste du notebook est la mise en œuvre méthodique de cette lecture. Les sections 3 et 4 quantifient la machine à états ; la section 5 lui accole un coût ; la section 6 cherche ce qu’elle ne visite jamais. À aucun moment on ne « essaie » le formulaire : on le calcule.

Une conséquence de gouvernance, de la distinction contenu/table : quand un formulaire vit comme contenu, le modifier n’exige pas de migration de données — c’est une écriture de post, un diff dans l’historique de révision, un événement traçable. Une plateforme qui fait vivre ses formulaires en table exige, elle, une opération de schéma, avec son risque, son créneau, son rituel. Le choix « contenu » abaisse donc le coût du changement unitaire — mais il déplace le risque ailleurs : comme ce notebook le montre de bout en bout, un diff de contenu est invisible aux yeux de l’éditeur (chaque champ s’édite séparément) alors que ses effets (treize chemins, un champ mort) sont globaux. La discipline qui compense ce trait est le versionnage : un formulaire versionné se compare comme du code ; un formulaire informel se compare par souvenir — et c’est le souvenir qui produit les formulaires plein de champs morts.

2. Le fixture — un formulaire de soumission de manuscrit

On monte la « Maison Valmont » : un formulaire de dépôt de manuscrit à sept champs, dont la visibilité est conditionnelle. Les conditions sont représentées en données (des tuples), pas en code (pas de lambda) — c’est plus long à écrire, mais cela rend le formulaire inspectable : on peut lire une condition comme on lit une donnée, sans exécuter de fonction.

Chaque champ porte : - une condition (tuple) — predicat sur les réponses antérieures ; - des valeurs possibles (l’énumération se branche sur chacune) ; - une action — None (rien) ou un identifiant d’action LLM.

Lire le dictionnaire comme un graphe, pas comme une liste

Avant les mesures, une lecture structurale de la cellule de fixture : la sortie affiche sept champs avec leur condition, leurs valeurs et leur action — mais l’information réellement portée par ce dictionnaire est un graphe de dépendances orienté. Chaque condition est une arête : nb_pages référence genre, deja_edite référence genre, nom_editeur référence deja_edite, resume_long référence nb_pages, note_lecture référence nb_pages. Le formulaire se lit alors comme un DAG : cinq champs racines (courriel, genre) et des niveaux qui se construisent dessus.

Deux propriétés de ce graphe vont déterminer tout ce qui suit. D’abord, la contrainte d’ordre de déclaration : chaque condition ne référence que des champs déclarés plus haut dans le dictionnaire. Cette contrainte est ce qui rend l’énumération de la section 3 possible — un champ qui référencerait un champ postérieur n’aurait pas de réponse stable au moment de l’évaluer. Dans une vraie plateforme de formulaires, cette garantie vient de l’ordre du DOM ou du maître de saisie ; ici elle vient de l’ordre du dictionnaire, et c’est un contrat de conception qu’il faut garder en tête : une condition qui ne le respecte pas (référence vers l’avant) donnerait un comportement dépendant de la sérialisation. Ensuite, la portée des arêtes : genre commande trois champs (nb_pages, deja_edite, et par transitivité nom_editeur), tandis que courriel ne commande rien. Le risque et la complexité ne se répartissent pas uniformément entre les champs : ils se concentrent sur les sommets à haut degré sortant, exactement comme dans tout système de dépendances.

Le graphe se décrit en deux chiffres : 7 sommets, 6 arêtes — une condition par sommet sauf les deux racines (courriel, genre n’ont pas d’arête entrante, et note_lecture a une arête entrante qui ne mène nulle part). Cette densité modérée est celle d’un formulaire simple ; dans la réalité de la plateforme, les formulaires à champs croisés produisent des graphes plus denses, et chaque arête supplémentaire est un multiplicateur des chemins.

Deux garanties structurantes, à graver : pas d’auto-référence (une condition ne porte jamais sur son propre champ — elle serait inévaluable : sa visibilité dépendrait de sa propre réponse) et pas de cycle (la contrainte d’ordre de déclaration rend tout cycle impossible — un cycle serait un état dont la réponse dépend d’elle-même, et l’énumération ne terminerait pas). La disposition du dictionnaire est donc exactement un ordre topologique du graphe, et 13 chemins sont un nombre fini et stable par construction. Un formulaire dont la sérialisation perdrait ces deux garanties (export plateforme → import manuel, fusion de branches) ne serait plus énumérable — et c’est un test de départ précieux pour toute migration de formulaire.

# Aucune dependance externe. Tout est en memoire.

# --- Formulaire de soumission Valmont (7 champs conditionnels) ---
# Condition = un tuple (operateur, champ_anterieur, valeur)
#   ("true",)                  -> toujours vrai
#   ("eq",  champ, val)        -> reponses[champ] == val
#   ("in",  champ, (v1, v2))   -> reponses[champ] dans le tuple
#   ("gt",  champ, val)        -> reponses.get(champ, 0) > val
#   ("lt",  champ, val)        -> reponses.get(champ, 0) < val
formulaire = {
    "courriel":     {"condition": ("true",),                  "valeurs": ["auteur@valmont.org"], "action": None},
    "genre":        {"condition": ("true",),                  "valeurs": ["poesie", "prose", "essai"], "action": None},
    "nb_pages":     {"condition": ("in", "genre", ("prose", "essai")), "valeurs": [40, 120, 250], "action": None},
    "resume_long":  {"condition": ("gt", "nb_pages", 200),     "valeurs": ["resume_a", "resume_b"], "action": "llm_synthese"},
    "deja_edite":   {"condition": ("eq", "genre", "prose"),    "valeurs": [True, False], "action": None},
    "nom_editeur":  {"condition": ("eq", "deja_edite", True),  "valeurs": ["editeur_x"], "action": "llm_verification"},
    "note_lecture": {"condition": ("lt", "nb_pages", 0),       "valeurs": [1, 2, 3], "action": None},
}

print("Formulaire de " + str(len(formulaire)) + " champs.")
for nom, c in formulaire.items():
    act = c["action"] if c["action"] else "-"
    print("  " + nom + " | cond=" + str(c["condition"]) + " | " + str(len(c["valeurs"])) + " valeurs | action=" + str(act))
Formulaire de 7 champs.
  courriel | cond=('true',) | 1 valeurs | action=-
  genre | cond=('true',) | 3 valeurs | action=-
  nb_pages | cond=('in', 'genre', ('prose', 'essai')) | 3 valeurs | action=-
  resume_long | cond=('gt', 'nb_pages', 200) | 2 valeurs | action=llm_synthese
  deja_edite | cond=('eq', 'genre', 'prose') | 2 valeurs | action=-
  nom_editeur | cond=('eq', 'deja_edite', True) | 1 valeurs | action=llm_verification
  note_lecture | cond=('lt', 'nb_pages', 0) | 3 valeurs | action=-

Conditions en données : pourquoi le tuple plutôt que le code

La cellule de fixture est rédigée en une ligne de commentaire qui vaut la peine d’être explicite : « Condition = un tuple (opérateur, champ_antérieur, valeur) ». Choisir une représentation en données tuples plutôt qu’en code (une lambda par champ) est la décision de conception la plus importante du notebook, et elle commande tout le reste.

Une condition en lambda serait incroyablement plus expressive — mais inénumérable : pour connaître les champs référencés par une lambda, il faudrait la lire, et pour énumérer les chemins il faudrait l’exécuter dans toutes les directions. Des données, en revanche, se parcourent : le moteur n’a jamais besoin de savoir quoi que ce soit sur la signification des opérateurs pour les appliquer, et les sections suivantes peuvent inspecter chaque condition pour y trouver les champs référencés, la direction de la comparaison, la constante de seuil. C’est la même distinction que celle entre une fonction et une table : la table est inspectable, traçable, rejouable ; la fonction ne l’est que par l’exécution.

La palette des cinq opérateurs (true, eq, ne, gt, lt, in) est volontairement pauvre : pas de ET, pas de OU, pas de combinaisons. Et pourtant, comme les sections suivantes le montreront, cette pauvreté ne limite pas la complexité émergente — treize chemins, deux à double appel, un champ mort. Retenir cette leçon pour la transposition : dans une vraie plateforme, la tentation d’enrichir le langage de conditions (opérateurs composites, regex, formules) est permanente ; le notebook démontre que le langage le plus pauvre suffit à produire des formes déjà illisibles à l’œil. Chaque opérateur ajouté multiplie les formes possibles — et les angles morts.

Le prix de la forme tuple est visible dans la sortie de la cellule suivante (le sanity check) : le langage n’exprime pas de ET composé. L’exigence métier « prose et plus de 200 pages » se décompose en chaîne — resume_long ne dépend pas de genre, il dépend de nb_pages, qui dépend de genre. La chaîne coûte des niveaux : le sanity check exécute quatre cas, mais la machine à états de la section 3 a besoin d’une étape par champ de la chaîne, et chaque étape multiplie les états. C’est l’arbitrage de conception éternel des langages de conditions : plus d’expressivité (ET, OU, regex, formules) réduirait le nombre de niveaux, mais rendrait chaque condition ininspectable et — surtout — ferait exploser le nombre de cas du sanity check. Le choix du tuple pauvre est le choix de la mesure : c’est parce que chaque condition est atomique qu’elle peut être énumérée, comptée, et finalement condamnée (section 6). Une plateforme qui enrichit son langage de conditions doit ré-examiner ce contrat : elle gagne de l’expressivité d’édition et perd la lisibilité des effets.

Lire le schéma

Sept champs, à l’œil un formulaire modeste. Déjà, à la lecture, on devine que resume_long n’apparaît que pour les manuscrits de plus de 200 pages, et que nom_editeur ne sort que pour la prose déjà éditée. Mais combien de chemins distincts ce formulaire génère-t-il ? La lecture ne le dit pas. C’est la question que ce notebook pose — et résout par énumération.

3. Le moteur — évaluer une condition, énumérer les chemins

Deux fonctions suffisent. La première évalue une condition (le tuple) contre un ensemble de réponses. La seconde énumère tous les chemins terminaux : pour chaque champ, si sa condition est satisfaite on se ramifie sur chaque valeur possible, sinon on passe (champ masqué).

def condition_satisfaite(cond, reponses):
    """Evalue un tuple-condition contre les reponses anterieures."""
    op = cond[0]
    if op == "true":
        return True
    champ, val = cond[1], cond[2]
    if op == "eq":
        return reponses.get(champ) == val
    if op == "ne":
        return reponses.get(champ) != val
    if op == "gt":
        return reponses.get(champ, 0) > val
    if op == "lt":
        return reponses.get(champ, 0) < val
    if op == "in":
        return reponses.get(champ) in val
    return False


# Sanity check : la condition "nb_pages > 200" sur un manuscrit de 40 pages.
print("nb_pages=40  > 200 ? " + str(condition_satisfaite(("gt", "nb_pages", 200), {"nb_pages": 40})))
print("nb_pages=250 > 200 ? " + str(condition_satisfaite(("gt", "nb_pages", 200), {"nb_pages": 250})))
print("genre=prose in (prose,essai) ? " + str(condition_satisfaite(("in", "genre", ("prose", "essai")), {"genre": "prose"})))
print("genre=poesie in (prose,essai) ? " + str(condition_satisfaite(("in", "genre", ("prose", "essai")), {"genre": "poesie"})))
nb_pages=40  > 200 ? False
nb_pages=250 > 200 ? True
genre=prose in (prose,essai) ? True
genre=poesie in (prose,essai) ? False
def enumerer_chemins(formulaire):
    """Enumere tous les chemins terminaux atteignables du formulaire.

    Un chemin = un dict d'affectations des champs VISIBLES sur ce chemin.
    Pour chaque champ : si sa condition est satisfaite, on se ramifie sur
    chacune de ses valeurs ; sinon le champ est masque et on continue.
    """
    chemins = [{}]
    for nom, champ in formulaire.items():
        suivants = []
        for reponses in chemins:
            if condition_satisfaite(champ["condition"], reponses):
                for valeur in champ["valeurs"]:
                    r2 = dict(reponses)
                    r2[nom] = valeur
                    suivants.append(r2)
            else:
                suivants.append(reponses)
        chemins = suivants
    return chemins


chemins = enumerer_chemins(formulaire)
print("Chemins terminaux atteignables : " + str(len(chemins)))
Chemins terminaux atteignables : 13

Pourquoi 13 : l’énumération n’est pas une simulation

La sortie dit « Chemins terminaux atteignables : 13 ». Le chiffre s’impose par une mécanique précise, qu’il faut distinguer de deux fausses voisines avant de la commenter. Ce n’est pas la simulation d’un utilisateur (personne ne « remplit » ici le formulaire) ; ce n’est pas non plus le produit des valeurs (108). C’est le parcours de la machine à états que le dictionnaire définit, une étape par champ déclaré : on part d’un monde vide, chaque champ propose soit ses valeurs (si sa condition est satisfaite par les affectations déjà produites), soit une continuation inchangée (s’il est masqué), et on applique la règle à toutes les affectations en cours en parallèle. L’algorithme est exactement la sémantique du formulaire : il ne devine rien, il ne tire rien au hasard, il énumère l’ensemble des états finaux possibles — et l’ensemble est fini parce que chaque champ a une valeur parmi un ensemble fini et que les conditions ne portent que sur des champs antérieurs.

La conséquence logique de cette mécanique — et c’est le point le plus contre-intuitif de la section — est que 13 est à la fois le nombre d’états finaux et le nombre de « feuilles » de l’arbre d’affichage : chaque chemin terminal correspond à une combinaison de choix que l’utilisateur peut réellement faire face à l’écran, avec les champs masqués omis. Un chemin de deux champs (courriel, genre) est donc bien un chemin complet : l’utilisateur qui choisit poésie ne voit que ces deux champs, et le formulaire se termine là. La machine à états, elle, continue d’exister pour les autres branches — mais pour celui qui a choisi la poésie, elle est finie dès la seconde question.

Une distinction de vocabulaire, utile pour ne pas lire 13 de travers : l’énumération compte les feuilles de la machine à états, pas ses états internes. Chaque niveau intermédiaire (génre choisi, nb_pages encore masqué) est un état que le moteur doit savoir représenter ; les états internes sont donc plus nombreux que les feuilles. Les feuilles, elles, correspondent une à une aux soumissions réellement possibles — c’est le chiffre utile au support (« quel chemin l’utilisateur a-t-il pris ? ») et à la facturation (une soumission aboutit à exactement une feuille).

La conséquence pour la lecture de toute la suite : 13 est le nombre de fins possibles, pas le nombre de questions que le moteur sait traiter — ni le nombre d’écrans que l’utilisateur peut traverser. Un formulaire à 40 champs pourrait avoir des milliers d’états internes et seulement quelques centaines de feuilles ; l’implémentation paie les états, la facturation paie les feuilles. Quand la section 5 accole un coût modèle aux chemins, elle accole le coût aux feuilles — le bon endroit.

4. L’explosion — combien de chemins pour sept champs ?

Sept champs, et pourtant le formulaire n’engendre pas sept états. Il en engendre bien plus, parce que chaque condition crée un point de branchement. Mesurons.

from collections import Counter

print("Nombre de chemins terminaux : " + str(len(chemins)))
print()

# Distribution des longueurs (nombre de champs visibles par chemin).
longueurs = Counter(len(c) for c in chemins)
print("Distribution des longueurs (champs visibles par chemin) :")
for k in sorted(longueurs):
    print("  " + str(k) + " champs : " + str(longueurs[k]) + " chemin(s)")
print()

# A titre de comparaison : le produit cartesien brut de toutes les valeurs.
import math
produit_brut = 1
for champ in formulaire.values():
    produit_brut *= len(champ["valeurs"])
print("Produit cartesien brut (si AUCUNE condition) : " + str(produit_brut))
print("Avec conditions : " + str(len(chemins)) + " -> " + str(round(100 * len(chemins) / produit_brut)) + "% du brut.")
Nombre de chemins terminaux : 13

Distribution des longueurs (champs visibles par chemin) :
  2 champs : 1 chemin(s)
  3 champs : 2 chemin(s)
  4 champs : 4 chemin(s)
  5 champs : 4 chemin(s)
  6 champs : 2 chemin(s)

Produit cartesien brut (si AUCUNE condition) : 108
Avec conditions : 13 -> 12% du brut.

Lecture — treize formulaires derrière sept champs

Sept champs engendrent 13 chemins terminaux atteignables (contre un produit cartésien brut de 108 si aucune condition ne masquait jamais rien). Le conditionnel réduit l’espace — c’est sa raison d’être — mais il le rend surtout non lisible : aucune des 13 combinaisons n’est visible sur le schéma. On lit « sept champs » ; l’utilisateur rencontre l’un des treize formulaires différents que ce dispositif produit réellement.

C’est le premier enseignement : la taille apparente du formulaire (le nombre de champs déclarés) ne dit rien de sa taille effective (le nombre d’états distincts). Pour un petit formulaire c’est une curiosité ; pour un formulaire administratif de cinquante champs conditionnels, c’est une explosion combinatoire que personne n’audite.

5. Les chemins qui invoquent le LLM — le coût caché

AI Forms peut déclencher une action LLM à la soumission. Ici, deux champs portent une action : resume_long (synthèse) et nom_editeur (vérification de l’éditeur). Mais un champ ne s’exécute que s’il est visible — donc l’action LLM ne se déclenche que sur les chemins où le champ apparaît. Le coût en appels LLM est, lui aussi, émergent.

Le coût caché : corrélations, pas moyennes

La sortie de la section 5 donne une répartition exacte : 5 chemins sans appel, 6 à un appel, 2 à deux appels — soit 8 chemins sur 13 (62 %) qui déclenchent au moins un appel de modèle. Avant de commenter, regardons ce que les deux chemins à double appel ont en commun : ce sont les deux variantes (résumé A ou B) du même chemin de prose longue avec éditeur — les deux seules branches où resume_long (llm_synthese) et nom_editeur (llm_verification) sont tous deux visibles. La corrélation est directe : le chemin le plus long est aussi le plus cher, et c’est structurel — les deux appels sont portés par des champs dont les conditions exigent la prose et l’éditeur, c’est-à-dire exactement les choix qui prolongent la branche. Le coût n’est pas réparti au hasard sur les chemins : il suit la complexité de la soumission.

La seconde lecture est la plus rentable de toute la section : la cellule ne peut pas produire « le coût moyen par soumission », et elle ne le prétend pas. Un tel nombre exigerait une distribution de réponses utilisateurs (quelle proportion choisit la poésie ? la prose longue ?) que le formulaire seul ne contient pas. 62 % est une propriété de la machine à états ; le coût réel attendu est une propriété de l’usage. C’est exactement la frontière que le notebook tient partout ailleurs : mesurer ce que le système DECLARE, et signaler — sans la combler — ce qui exigerait des hypothèses d’usage. Le lecteur tenté de « coûter » le formulaire doit construire sa distribution lui-même : la cellule lui en donne le cadre (5 gratuits, 6 à un appel, 2 à deux).

Une précision structurelle sur le facteur d’échelle du coût : chaque valeur du champ resume_long coûte un appel de synthèse aux chemins qui le voient, et il a deux valeurs (deux variantes) — c’est ce qui fait des deux chemins prose-longue des chemins à double appel. Les 62 % ne sont donc pas un chiffre donné une fois pour toutes : dans ce formulaire, le prix est porté par les valeurs de resume_long et rien d’autre — les 6 chemins de la section 5 qui coûtent un appel sont les 6 combinaisons qui voient resume_long (2 variantes × 3 porteurs), les 2 qui coûtent deux sont celles qui voient en plus nom_editeur. Deux leviers de réduction se laissent lire : réduire les variantes de résumé (une seule variante ferait tomber le coût max de 2 à 1) ou restreindre les porteurs de resume_long (une condition plus stricte sur nb_pages couperait les branches épaisses). L’énumération ne décide pas du levier — elle montre où il agit.

def cout_llm(chemin, formulaire):
    """Nombre d'appels LLM declenches par un chemin = nombre de champs
    visibles portant une action non-None."""
    return sum(1 for nom in chemin if formulaire[nom]["action"] is not None)


coûts = Counter(cout_llm(c, formulaire) for c in chemins)
print("Répartition des chemins par nombre d'appels LLM :")
for k in sorted(coûts):
    print("  " + str(k) + " appel(s) LLM : " + str(coûts[k]) + " chemin(s)")
print()

nb_avec_llm = sum(1 for c in chemins if cout_llm(c, formulaire) > 0)
print("Chemins invoquant AU MOINS un appel LLM : " + str(nb_avec_llm) + " / " + str(len(chemins)))
print("  -> " + str(round(100 * nb_avec_llm / len(chemins))) + " % des chemins coûtent un appel LLM.")
print()

# Detail des chemins a 2 appels (les plus couteux).
double = [c for c in chemins if cout_llm(c, formulaire) == 2]
print("Chemins à double appel LLM (" + str(len(double)) + ") :")
for c in double:
    print("  " + str(c))
Répartition des chemins par nombre d'appels LLM :
  0 appel(s) LLM : 5 chemin(s)
  1 appel(s) LLM : 6 chemin(s)
  2 appel(s) LLM : 2 chemin(s)

Chemins invoquant AU MOINS un appel LLM : 8 / 13
  -> 62 % des chemins coûtent un appel LLM.

Chemins à double appel LLM (2) :
  {'courriel': 'auteur@valmont.org', 'genre': 'prose', 'nb_pages': 250, 'resume_long': 'resume_a', 'deja_edite': True, 'nom_editeur': 'editeur_x'}
  {'courriel': 'auteur@valmont.org', 'genre': 'prose', 'nb_pages': 250, 'resume_long': 'resume_b', 'deja_edite': True, 'nom_editeur': 'editeur_x'}

Lecture — le coût LLM est une propriété des chemins

Sur 13 chemins, 8 déclenchent au moins un appel LLM — près des deux tiers. Et 2 chemins en déclenchent deux (synthèse et vérification : la prose de plus de 200 pages, déjà éditée). Lu sur le schéma, on voit « deux champs LLM ». Mesuré sur les chemins, on découvre que le formulaire le plus coûteux paie deux appels là où le moins coûteux n’en paie aucun — pour un écart que rien, dans la définition statique, ne signalait.

C’est le second enseignement : le coût d’exploitation d’un formulaire AI Forms est une propriété des chemins, pas des champs. Un audit qui se borne à lister les champs à action LLM sous-estime le coût réel (il ignore les chemins à appels multiples) et le surévalue (il compte un champ qui n’est visible sur aucun chemin).

6. Les champs morts — ce que personne ne verra jamais

Dernière catégorie, la plus sournoise : un champ peut être déclaré mais jamais visible, parce que sa condition n’est jamais satisfaite quel que soit le chemin. Le champ note_lecture (condition nb_pages < 0) en est l’exemple : aucun manuscrit n’a un nombre de pages négatif, donc le champ n’apparaît jamais. Il vit dans le schéma, pas dans le formulaire effectif.

Un champ mort est une condition, pas une faute de frappe

La sortie de la section 6 est sans ambiguïté : note_lecture n’est présent dans aucun des 13 chemins — marqueur « CHAMP MORT » — et le reste des champs forme un spectre de visibilité bien étagé (13/13, 13/13, 12/13, 8/13, 6/13, 4/13, 0/13). Regardons la cause : la condition de note_lecture est ("lt", "nb_pages", 0) — un comparateur « inférieur à zéro » appliqué à un champ dont les valeurs sont {40, 120, 250}. La condition est insatisfaisable, pas erronée : elle est bien formée, du bon opérateur, du bon champ référencé, et pourtant aucune valeur de nb_pages ne pourra jamais la rendre vraie. Le champ meurt donc d’une **constante hors domaine : 0 n’appartient pas au domaine de nb_pages ({40, 120, 250}), et le formulaire ne s’en aperçoit pas.

Et c’est le cœur de la leçon : la mort de note_lecture est silencieuse. Aucune erreur d’exécution, aucun avertissement, aucune trace dans l’interface : le formulaire « marche », les soumissions partent, la machine à états est parfaitement bien formée — le champ n’est simplement jamais affiché. Un test fonctionnel (je remplis le formulaire, il s’envoie) ne peut pas attraper ce défaut : il n’existe aucune action à déclencher pour le voir. La seule instrumentation qui le révèle est le comptage de visibilité — le 0/13 — et c’est précisément la mesure que le notebook a construite depuis le début. La section 6 est la justification a posteriori de tout le dispositif : c’est pour attraper le champ mort, que personne ne peut voir en remplissant le formulaire, que l’énumération existe.

La géographie du champ mort mérite un arrêt de plus : toute constante inférieure au minimum du domaine — 0, mais aussi 1, 39 ou 40 — produit exactement la même mort, car aucune valeur de nb_pages ne descend sous 40. Le couple (lt, 0) n’est donc pas « une erreur de seuil » au sens d’une valeur proche de la vraie : c’est une constante hors domaine, choisie dans une région où la condition est insatisfaisable en bloc, par construction. À l’inverse, une constante supérieure ou égale au maximum du domaine (250 ou plus) rendrait le champ visible sur tous les chemins, et une constante intermédiaire le ferait apparaître sur une frange. La bonne question pour corriger n’est donc pas « quelle valeur ? » mais « sur quelles branches la note doit-elle apparaître ? » — une décision de produit que la section 6 rend enfin visible.

C’est la dernière leçon de cette section, et la plus générale : un champ mort n’est jamais annoncé. La cellule 6 l’a montré sans médecin : aucune erreur, aucun avertissement, un formulaire qui fonctionne. Le champ mort se découvre par un comptage — sa visibilité est 0/13 — ou par rien du tout. Tout l’intérêt des sondes (comptages, audits, observabilité) tient dans cette asymétrie : les défaillances silencieuses sont exactement celles que personne ne vient chercher.

# Verifions a la main : note_lecture apparait-il dans un seul chemin ?
present = any("note_lecture" in c for c in chemins)
print("note_lecture present dans au moins un chemin : " + str(present))
print()

# Plus generalement : pour chaque champ, dans combien de chemins apparait-il ?
print("Visibilite de chaque champ :")
for nom in formulaire:
    nb = sum(1 for c in chemins if nom in c)
    marqueur = "  <-- CHAMP MORT" if nb == 0 else ""
    print("  " + nom + " : " + str(nb) + " / " + str(len(chemins)) + " chemins" + marqueur)
note_lecture present dans au moins un chemin : False

Visibilite de chaque champ :
  courriel : 13 / 13 chemins
  genre : 13 / 13 chemins
  nb_pages : 12 / 13 chemins
  resume_long : 6 / 13 chemins
  deja_edite : 8 / 13 chemins
  nom_editeur : 4 / 13 chemins
  note_lecture : 0 / 13 chemins  <-- CHAMP MORT

Lecture — le schéma n’est pas le formulaire

note_lecture apparaît dans 0 chemin sur 12 : c’est un champ mort. Il ne consomme rien (pas d’action LLM), mais il trompe l’audit : un lecteur du schéma le comptera comme une fonctionnalité live, alors qu’il est inaccessible. Dans un formulaire administratif réel, les champs morts s’accumulent avec les évolutions — une condition durcie ici, un champ renommé là — et transforment le schéma en palimpseste où l’effective et l’inerte cohabitent sans distinction.

C’est le troisième enseignement : le schéma n’est pas le formulaire. Trois grandeurs mesurées ce notebook (chemins, coût LLM, champs morts) sont toutes des propriétés émergentes qu’aucune lecture statique ne donne.

7. Provenance et limites

Ce que ce notebook mesure. La structure du comportement d’un formulaire conditionnel : étant donné un schéma (champs + conditions + actions), combien d’états terminaux il produit, combien coûtent ces états en appels LLM, et quels champs déclarés ne servent jamais. Tout est déterministe sur fixture synthétique.

Ce qu’il ne mesure pas. L’ergonomie réelle du formulaire vu par l’utilisateur (un chemin peut être rare mais jamais emprunté en pratique), la latence des appels LLM, les validations croisées entre champs. La représentation des conditions par tuples est volontairement simpliste — un vrai moteur de conditions supporte des expressions booléennes imbriquées ; le principe (énumérer pour auditer) est identique.

La limite du procédé. L’énumération exhaustive a un coût : un formulaire de \(N\) champs conditionnels peut produire un nombre de chemins exponentiel en \(N\). Auditer en énumérant devient prohibitif sur les gros formulaires — c’est précisément pourquoi cette auditabilité est rarement faite, et pourquoi les champs morts et les chemins coûteux s’y cachent.

Pour aller plus loin. - auditer-un-serveur-mcp.ipynb — même esprit d’audit (mesurer une propriété non-lisible sur la définition statique) appliqué à un catalogue d’outils MCP. - consommer-vs-exposer-le-mcp.ipynb — l’autre face MCP : comparer deux catalogues. - livresagites-parcours.md Parcours 4 — la prose dont ce notebook est l’illustration exécutable.

Trois limites à garder avant la transposition

La cellule de provenance pose honnêtement le cahier des charges de l’énumération, et il faut en tirer trois limites concrètes avant de l’appliquer ailleurs.

Premièrement, l’énumération est béhaviorale, pas expérientielle. 13 chemins décrivent la machine à états du formulaire — ce qu’un utilisateur peut répondre — mais rien de ce qu’un remplisseur réellement ressent (désordre apparent, cohérence, fatigue des questions). Deux formulaires aux 13 chemins identiques peuvent se vivre très différemment : l’ordre de présentation, la formulation, le groupement visuel sont hors du périmètre du dictionnaire. La mesure structurelle est une condition nécessaire de la qualité — ou plutôt son absence est un signal de défaut certain — mais elle n’est pas la qualité.

Deuxièmement, le dictionnaire est la seule source de vérité. Tout ce qui n’y est pas représenté est invisible : l’ordre d’affichage des champs, la règle qui déciderait d’un saut en arrière (« revenir si la réponse change »), les options d’exclusion mutuelle entre valeurs, les formats. Un formulaire de production a souvent plus de logique que son schéma n’en déclare — et l’énumération ne mesure alors que la partie déclarée, en laissant le reste devenir ce que la section 6 a montré pour une constante : silencieux.

Troisièmement, la complexité est algorithmique, pas pédagogique. L’énumération de 7 champs est instantanée ; celle d’un formulaire réel à 40 champs et conditions croisées peut croître rapidement — chaque levier conditionnel multiplie les états. C’est sans doute la limite la plus utile à retenir : le jour où l’énumération devient trop coûteuse, ce n’est pas un problème d’outillage, c’est un symptôme — le formulaire a dépassé la taille pour laquelle le comportement se laisse penser, et c’est lui, pas la mesure, qu’il faut reconsidérer.

Une quatrième limite, la plus discrète, complète le tableau : l’énumération est sans mémoire du temps. Elle construit chaque chemin par choix successifs et n’a aucune flèche de retour — un utilisateur qui, à l’écran, revient en arrière pour changer une réponse déjà donnée (et provoquer des re-calculs de visibilité) n’est pas modélisé par elle. Ce retour arrière est le domaine de la plateforme d’édition, où la machine à états doit se re-brancher à chaque édition : les états de la section 3 ne montrent que des machines progressives. La limite est salutaire à connaître : elle délimite exactement ce que le notebook affirme (les fins possibles, pour un remplissage linéaire) de ce qu’il laisse à la plateforme (les remplissages en zigzag, l’expérience de correction). Un audit de formulaire qui devrait couvrir les allers-retours devra étendre l’énumération d’une dimension — coûteuse — et c’est une décision à prendre en connaissance de cause, pas par défaut.

8. Exercices

Les trois exercices suivants manipulent le formulaire synthétique et son moteur (enumerer_chemins, condition_satisfaite). Les stub sont à compléter — return None ou pass.

Le programme des trois exercices

Les trois exercices prolongent les trois mesures de la section centrale, chacun avec une compétence différente : reprendre la structure, élargir la mesure, ajouter une sonde.

L’exercice 1 demande de recomposer la distribution des longueurs à partir d’un autre formulaire — la compétence est la reproduction mécanique : savoir ré-écrire l’énumération sur des données différentes sans recopier la cellule centrale. Elle valide que le mécanisme est compris, pas seulement lu.

L’exercice 2 demande de regrouper les chemins par coût — la compétence est l’agrégation significative : le coût d’un chemin est déjà calculé par cout_llm ; le travail est de choisir comment le résumer (comme la répartition 5/6/2 de la section 5) sans mentir (une moyenne sans distribution d’usage). L’exercice est une invitation à refaire le geste de la section 5 sur de nouvelles données — et à apprécier, par contraste, pourquoi la cellule courante s’est arrêtée avant la moyenne.

L’exercice 3 demande de détecter les champs morts — la compétence est l’ajout d’une sonde : le champ mort, on l’a vu à la section 6, ne se voit pas en remplissant le formulaire ; il ne se voit que par comptage. L’énoncé donne la signature (compter la présence de chaque champ dans les chemins) et l’attendu (une liste nommée), mais la décision de seuil — le « 0 présence » du champ mort — est laissée à l’étudiant : c’est elle qui fait la différence entre recompter et auditer.

Ces exercices peuvent (et doivent) s’auto-corriger — c’est ce qui les distingue de trois devoirs : ils travaillent sur des données embarquées, exécutables, et leurs attendus se vérifient par le notebook lui-même. L’exercice 1 donne sa propre clé : la distribution de longueurs doit former une arche cohérente avec le nombre de chemins (la somme des multiplicités retombe sur le total de l’énumération — le même contrôle que la section 4).

L’exercice 2 s’évalue par contraste : comparer son regroupement au 5/6/2 de la section 5 — deux regroupements différents sont permis, une moyenne non étayée par une distribution d’usage ne l’est pas. L’exercice 3 enfin a son modèle exact dans la section 6 (le comptage 13/13, 13/13, 12/13, 8/13, 6/13, 4/13, 0/13) ; le piège à éviter est de ré-inventer la définition du champ mort au lieu de la prendre telle quelle : « 0 présence dans les chemins ». Un étudiant qui hésite sur le seuil relit la section 6 — c’est l’objectif de la boucle.

Exercice 1 — distribution des longueurs

Écrire une fonction compter_par_longueur(formulaire) qui renvoie un dict {longueur: nombre_de_chemins} — pour chaque longueur possible (nombre de champs visibles), combien de chemins terminaux ont exactement cette longueur.

def compter_par_longueur(formulaire):
    """Renvoie {longueur: nb_chemins} -- distribution du nombre de champs
    visibles par chemin terminal.
    """
    # TODO : utiliser enumerer_chemins() et regrouper par len(chemin).
    return None

Exercice 2 — regrouper les chemins par coût LLM

Écrire une fonction regrouper_par_cout(formulaire) qui renvoie un dict {cout: [chemins]} — les chemins terminaux groupés par nombre d’appels LLM (0, 1, 2…). Permet d’isoler les chemins les plus coûteux.

def regrouper_par_cout(formulaire):
    """Renvoie {cout_llm: [chemins]} -- chemins terminaux groupes par
    nombre d'appels LLM qu'ils declenchent.
    """
    # TODO : utiliser enumerer_chemins() et cout_llm().
    return None

Exercice 3 — détecter les champs morts

Écrire une fonction champs_jamais_visibles(formulaire) qui renvoie la liste des champs déclarés mais absents de tous les chemins terminaux (leurs conditions ne sont jamais satisfaites). note_lecture doit y figurer.

def champs_jamais_visibles(formulaire):
    """Renvoie la liste des champs declares mais presents dans aucun
    chemin terminal atteignable.
    """
    # TODO : enumerer les chemins, puis chercher les champs absents de tous.
    return None

9. Ce que ce notebook enseigne, en une ligne

Un formulaire conditionnel n’est pas une liste de champs, c’est un graphe d’états. Le lire comme une liste fait rater trois choses : combien d’états existent, combien ils coûtent, et lesquels sont morts. Trois réponses qui ne viennent que de l’énumération.

D’où vient cette mesure

Une dernière note de provenance, pour situer ce que la section précédente n’en dit pas : ce notebook est né d’un constat mesuré en conditions réelles sur des formulaires édités par une plateforme — le schéma s’affiche comme une liste de champs, mais le comportement s’écarte de cette liste à chaque condition, et personne ne regarde ce que la liste devient. La première version du notebook affirmait des nombres avant de les avoir mesurés : 12 chemins, 6 appels de modèle, 72 états bruts. L’exécution a rendu 13, 8 (62 %) et 108 — et ces trois écarts n’étaient pas des erreurs de saisie : ils signalaient que l’estimation à la main d’un formulaire conditionnel dérape dès qu’on dépasse trois ou quatre champs. La leçon est devenue l’article de foi du notebook : pour un formulaire conditionnel, on n’estime pas, on énumère — et le chiffre énuméré n’a pas besoin d’être favorable pour être retenu, il a besoin d’être le résultat du compte.

Cette honnêteté a une conséquence d’écriture que le lecteur attentif aura remarquée : les récits de ce notebook suivent les sorties, et jamais l’inverse. Quand le compte rend 13 chemins et pas 12, c’est le récit qui s’ajuste au compte. C’est la règle qui distingue une démonstration d’une démonstration de façade : une sortie committée que le texte ne contredit jamais, parce que le texte est l’élève de la sortie, pas son maître.

Un mot, enfin, sur les numéros de cellules que les récits précédents citent (« la cellule 5 », « la section 6 ») : ils renvoient aux cellules de la version originale de ce notebook, telles que numérotées lors de sa première exécution. Les éditions successives (dont celle-ci, qui fait partie de la série du dossier) peuvent décaler les indices — les extraits de sortie recopiés dans le texte, eux, ne bougent pas. Si une référence semble sauter une cellule, suivre les extraits, pas les numéros : la convention de ce notebook est que les chiffres cités doivent toujours se retrouver dans une sortie visible, une ou deux cellules plus haut — c’est la règle d’ancrage que le lecteur critique peut appliquer à son tour.

Et pour finir, la règle d’auto-correction dont la section 9 témoigne : quand un récit et une sortie se contredisent, c’est le récit qui cède. La version publiée de ce notebook a subi cette épreuve au moins une fois — les nombres de la première version ont été remplacés par les comptages, sans transaction. C’est la seule forme d’honnêteté qui tienne pour un notebook : le lecteur peut rejouer chaque cellule et retrouver chaque chiffre.

Retour au sommet