betagouv / mon-entreprise

L'assistant officiel des entrepreneurs
https://mon-entreprise.urssaf.fr
MIT License
257 stars 72 forks source link

Formalisation des remplacements #1139

Closed lajarre closed 3 years ago

lajarre commented 3 years ago

Cas pathologique:

A:
  formule: B + 1
  remplace: C
B:
  formule: C + 1
C:
  formule: 0

=> Ca fait un cycle infini.

En effet, dans le code de publicodes/source/parseReference.js on a bien un safeguard sur les "applicable replacements" sous la forme de contextRuleName !== referenceNode.dottedName mais ce contextRuleName est "amnésique": il ne prend en compte que la node parente et nons la grand-parente (ni celles encore au-dessus).

Si on évalue A, alors, on évalue B (dans contexte A), puis on évalue C dans contexte B: cette dernière évaluation va donc essayer de faire le replacedBy C replaced by A (car A != B).

Solution proposée

Transformer contextRuleName en contextRuleNames: Array<dottedNames> qui garde toute la formule call stack. Cette call stack devra probablement à l'avenir être un citoyen plus respectable de l'AST et de l'interpréteur.

Justification: ceci est cohérent avec ce qu'on attend généralement d'un langage, où on définit en général les choses sous la forme:

C = 0
A = B + 1
B = C + 1

C = A

Approche alternative

Une autre approche serait de considérer que les remplace sont en fait des étapes de pré-processing, justifiant plusieurs passes du parser:

mquandalle commented 3 years ago

Pour moi l'approche sémantique la plus simple, et proche de l'implémentation, est de considérer les remplacements comme une transformation statique du code (le pré-processing de ton alternative) :

A:
  formule: B + 1
  remplace: C
B:
  formule: C + 1
C:
  formule: 0

équivaut à

B:
  formule: C + 1
C:
  formule:
    variations:
      - si: oui # pas de condition sur le remplacement
        alors: B + 1
      - sinon: 0

Il y a un effet un cycle qui doit être détecté, mais je ne suis pas convaincu que cela nécessite une logique spécifique.

mquandalle commented 3 years ago

En effet, dans le code de publicodes/source/parseReference.js on a bien un safeguard sur les "applicable replacements" sous la forme de contextRuleName !== referenceNode.dottedName mais ce contextRuleName est "amnésique": il ne prend en compte que la node parente et nons la grand-parente (ni celles encore au-dessus).

C'est un point que j'avais voulu modifier dans #970.

lajarre commented 3 years ago

J'aime bien l'approce preprocessing en effet, car celle-ci permettrait d'avoir une bien meilleure séparation dans les étapes du parsing (en fait, le preprocessing aurait son propre parser, plus simple).

Cependant le problème reste entier. Faisons l'exercice de faire le preprocessing à la mano (en oubliant les variations) sur différents cas:

Cas simple: 1 étage (version A)

# raw rules:
b:
  formule: c + 1
  remplace: c
c:
  formule: 0

# preprocessed rules:
b:
  formule: c + 1
c:
  formule: 1

Comment en suis-je arrivé à ce formule: 1? En faisant les 2 étapes de "processing" suivantes, qui sont directement calquée sur le comportement de l'évaluation dans le code:

  1. c est remplacée par b, soit "formule: c + 1", qui est elle-même décomposée, dans laquelle on retrouve c indépendamment.
    • Note: on pop (erase) le remplace: c de la rule b en même temps. Ce pop correspond bien à l'idée dans getApplicableReplacements que le contextRuleName n'a qu'une seule chance d'avoir une valeur !== b (à savoir être vide), donc on n'a besoin d'agir qu'une seule fois dessus.
  2. ce 2e c est processé: on tombe sur la formule: 0 (cf. ci-dessus: il n'y a plus de remplace).
    => en remontant la stack, on arrive sur "c est remplacée par formule: 0 + 1" qui est lui-même simplifié en c: formule: 1

😅 pas si simple en fait! D'ailleurs, il y a un truc qui me chiffonne beaucoup avec l'étape 2, cf ci-dessous.

Cas à 2 étages

Que se passe-t-il avec:

# raw rules:
a:
  formule: b + 1
  remplace: c
b:
  formule: c + 1
c:
  formule: 0

Les étapes du preprocessing seraient:

  1. c est remplacée par formule: b + 1. A partir de là 2 possibilités:
    • version X: soit on s'arrête là, car c ne se référence pas elle-même
    • version Y: soit on décompose et on process b à l'intérieur:
      1. b est processé: on tombe sur formule: c + 1
      2. c est processé une 2e fois: on tombe sur formule 0
        => on tombe sur c : formule: 2

En fait quand on raisonne preprocessor, je ne vois vraiment pas de sens logique au fait de suivre la version X et non pas la version Y, en partant du principe qu'on est d'accord sur le Cas à 1 étage décrit plus haut, à moins de faire un cas particulier assez "exotique" du cas à un étage.

Cas à 1 étage revu (version B)

En fait il y a une manière plus immédiate de faire le pre-processing

# raw rules:
b:
  formule: c + 1
  remplace: c
c:
  formule: 0

# preprocessed rules:
b:
  formule: c + 1
c:
  formule: c + 1

là pas besoin de décrire l'étape de preprocessing que j'ai faite, c'est trivial.

Le problème du cas à 1 étage version A associé au cas à 2 étages version X

La version A est clairement celle qui est utilisée et documentée actuellement. Dans ce cas, on veut la garder. En revanche, la version X est incohérente avec la version A, c'est la version Y qui se marierait bien. Or le cas actuel est la version X.

💣 C'est là que je pense qu'il faut remettre en question le status-quo et remettre l'implémentation de getApplicableReplacements sur la table.

Non seulement dans l'état actuel des choses la sémantique du cas à 1 étage (version A) est trop différente du cas à 2 étages (version X). Mais en plus le preprocessing aura besoin de parser les formule pour sauver le cas à 1 étage (version A), ce qui me semble coûteux si c'est uniquement pour un cas particulier. (en gros on aurait certains code paths complexes et très peu utilisés, bad smell)

Sinon autant rester dans l'implémentation sans preprocessor et interpéter les replacedBy en même temps que les formules.

Notons qu'intuitivement, je trouve que cette difficulté sémantique (s'entendre sur quelles sont les étapes qu'on veut réellement effectuer sur la base de ces instructions remplace) n'est pas étonnante du fait que la syntaxe du remplace est identique (donc sans doute trop proche) à la syntaxe d'une formule. Les formules sont interprétables avec une structure fonctionnelle évidente (b = ƒ(c)…), alors qu'il n'est pas attendu du remplace de ressembler à une fonction.

Solutions

Il ne me semble donc pas tenable de suivre l'étape proposée dans ton commentaire @mquandalle https://github.com/betagouv/mon-entreprise/issues/1139#issuecomment-700021047

Je reviens donc à ma proposition initiale (avec mes votes):

cc @johangirod

johangirod commented 3 years ago

https://github.com/betagouv/mon-entreprise/issues/1139

Alors, c'est du lourd cette discussion.

Je pense qu'il faut bien définir ce qu'est fondamentalement le remplacement dans publicodes. Ce n'est pas une affectation. C'est une substitution dans certaines expressions. Pourquoi cette différence ? Parce que la règle remplacée n'est pas modifiée.

Je pense aussi avec Maxime que les remplacement devraient pouvoir s'écrire comme une transformation statique du code. Cette dernière devrait aboutir à un arbre équivalent. De la même manière que "rends non applicable" peut être transformé en un "non applicable" sur la règle concernée, les remplacement gagnerait à être une transformation des règles concernée. Ce n'est pas le cas aujourd'hui.

En cela, je suis d'accord avec toi, @lajarre, le statut quo n'est pas souhaitable, et autant profiter de cette occasion pour remettre à plat ce dispositif.

Je vais proposer donc de pousser légèrement un peu plus dans la direction du cas à 1 étage version B.

Proposition de transformation statique

Ma proposition tient en un seul point : la règle n'est pas remplacée. Ses références le sont. On s'en sort par une simple transformation statique de l'arbre syntaxique.

Cas simple

# raw rules:
b:
  formule: c + 1
  remplace: c
c:
  formule: 0

Dans ce cas, il n'y a aucune référence à c, hors de la règle où est définie le remplacement. Hors la transformation ne s'applique jamais à cette dernière. Il n'y a donc pas de remplacement.

# transformed rules:
b:
  formule: c + 1
  remplace: c
c:
  formule: 0
engine.evaluate('c') // {nodeValue: 0}

Cas simple 2

# raw rules:
b:
  formule: c + 1
  remplace: c
c:
  formule: 0
d: 
  formule: c

# transformed rules:
b:
  formule: c + 1
c:
  formule: 0
d: 
  formule: b

La référence à c a été remplacée par b dans d.

engine.evaluate('c') // {nodeValue: 0}
engine.evaluate('d') // {nodeValue: 1}

Cas à 2 étages

# raw rules:
a:
  formule: b + 1
  remplace: c
b:
  formule: c + 1
c:
  formule: 0
# transformed rules:
a:
  formule: b + 1
b:
  formule: a + 1
c:
  formule: 0

Et là on a effectivement une boucle. Nous somme donc dans le cas X. Mais ce n'est pas grave, car avec notre nouvelle définition, il n'y a plus d'incohérence avec la version A. Je dirai même plus, c'est une bonne chose que la boucle soit détectée.

Car je pense que les exemple réel dans lequel ce cas à 2 étage est une expression bien formée sont extrement minoritaires. Et à l'inverse, des boucles de remplace non detectée car automatiquement résolue par l'algorithme que tu propose peuvent être très dangeureuse. Je suis d'avis d'être le plus restrictif possible, et par défaut de ne pas permettre de boucle. Si l'utilisateur veut faire appel à une valeur avant substitution quelque part, il doit expliciter son intention.

Je préfère que l'interpreteur me dise "Attention, il y a un cycle, tu fais potentiellement une erreur dans ton code" plutôt que "Il y un cycle dans le code mais je vais l'interpreter de cette manière parce que je suis trèèès intelligent"

Pour illustrer mon propos, je vais donner deux exemples:

Exemple 1

Promo spéciale: 
 remplace: total panier
 formule: total à payer * 80%

total à payer:
  formule: total panier + livraison

total panier: 
  formule: 200€

Dans cet exemple, on voit bien que le remplacement du total à payer n'a pas de sens, et nous sommes bien content que l'erreur soit repérée à la compilation.

Exemple 2 Dans ce cas, on a un remplacement cyclique qui pourrait avoir du sens :

impact tomate hors saison: 
 remplace: impact tomate
 formule: impact tomate serre chauffée * 1.5

impact tomate serre chauffée:
  formule: impact tomate + 20 kgCo2

impact tomate: 
  formule: 4kgeqCo2/kg

Le moteur lève une erreur avec un cycle détecté. On peut très facilement l'effacer en ajoutant une exception :

impact tomate hors saison: 
 remplace: 
     règle: impact tomate
    sauf dans: impact tomate serre chauffée
 formule: impact tomate serre chauffée * 1.5

impact tomate serre chauffée:
  formule: impact tomate + 20 kgCo2

impact tomate: 
  formule: 4kgeqCo2/kg

Cas avec applicabilité

La transformation doit gérer l'applicabilité de la règle parente. Autrement dit, le remplacement n'est pas effectué si la règle parente n'est pas applicable. On peut ajouter cette condition très facilement avec notre substitution.

# raw rules:
a: oui
b:
  applicable si: a
  formule: 4
  remplace: c
c: 0
d:
  formule: c + 5
# transformed rules:
a: oui
b:
  applicable si: a
  formule: 4
  remplace: c
c: 0
d:
  formule: (si b est applicable alors: b sinon c) + 5

Le mieux est d'envisager la création d'un mécanisme remplacement avec sa propre visualisation qui s'occupe de cette logique pour nous :

d:
  formule: 'remplacement(défini dans: b, original: c, nouveau: b) + 5

Liste noire, liste blanche et remplacements multiples

Dernier cas, les remplacement multiples, et la gestion des listes blanches et listes noires.

# raw rules:
b:
  remplace: 
    - règle: c
      par: 4
      dans: d1
    - règle: c
      sauf dans: d1
  formule: 3
c: 0
d1: c + 2
d2: c - 1
d1: remplacement(défini dans: b, original: c,  nouveau: 4) - 1
d2: remplacement(défini dans: b, original: c,  nouveau: b) - 1

Cas particulier de plusieurs remplacements envisageable

Si plusieurs remplacement sont définits pour la même variable que se passe-t'il ?

# raw rules:
b1:
  remplace: c
  formule: 3
b2: 
  remplace: c
  formule: 5
d: c

Quand la règle b1 est processée, elle va effectuer le remplacement suivant :

# transformed rules:
d: 
  formule:
    remplacement:
      défini dans: b1
      original: c
      nouveau: b1

Lorsque c'est au tour de b2, elle va s'ajouter au mécanisme déjà formé.

d:
  formule:
    remplacement:
    défini dans: b1
    original: 
      remplace:
        défini dans: b2
        original: c
        nouveau: b2
    nouveau: b1

On a donc b1 qui s'applique par défaut (premier remplacement définit dans l'ordre des règles). Si b1 est non applicable alors b2 s'applique. Si b2 est non applicable, la valeur est inchangée.

Cette logique est plutôt satisfaisante. Mais il faudrait tout de même ajouter un warning à l'execution lorsque plusieurs remplacement sont applicables pour une même variable.

Réponse aux points problématiques

Non seulement dans l'état actuel des choses la sémantique du cas à 1 étage (version A) est trop différente du cas à 2 étages (version X). Mais en plus le preprocessing aura besoin de parser les formule pour sauver le cas à 1 étage (version A), ce qui me semble coûteux si c'est uniquement pour un cas particulier. (en gros on aurait certains code paths complexes et très peu utilisés, bad smell)

Avec cette nouvelle solution c'est l'arbre syntaxique qui se retrouve transformé et non les règles brute. Il n'y a donc pas "deux parsings". Il n'y a pas de preprocessing. Juste une transformation statique d'un arbre vers un autre, c'est une opération usuelle dans les compilateurs, et c'est d'ailleurs une des raisons d'être de la l'utilisation de la represntation intermédiaire de l'arbre syntaxique abstrait.

Notons qu'intuitivement, je trouve que cette difficulté sémantique (s'entendre sur quelles sont les étapes qu'on veut réellement effectuer sur la base de ces instructions remplace) n'est pas étonnante du fait que la syntaxe du remplace est identique (donc sans doute trop proche) à la syntaxe d'une formule. Les formules sont interprétables avec une structure fonctionnelle évidente (b = ƒ(c)…), alors qu'il n'est pas attendu du remplace de ressembler à une fonction.

C'est une remarque très juste, et qui appuie dans le sens d'une écriture syntaxique différente, voir un type de règle différent pour les remplacement. Mais d'un autre côté, le langage courant met souvent côte à côte des sémantiques différentes sans que cela ne soit perturbant. Je suis partagé et je pense qu'il faudrait d'plus d'éléments avant de trancher vers une autre syntaxe (cout de modification des règles existantes).

cc @lajarre cc @mquandalle

lajarre commented 3 years ago

Top, je suis convaincu!

Le mieux est d'envisager la création d'un mécanisme remplacement avec sa propre visualisation qui s'occupe de cette logique pour nous : d: formule: 'remplacement(défini dans: b, original: c, nouveau: b) + 5

Là je ne te suis pas tout-à-fait. Ca veut dire quoi "sa propre visualisation"?

Le formule: (si b est applicable alors: b sinon c) + 5 que tu as décrit semble très suffisant, non? Il suffirait de le mettre en forme dans la syntaxe originelle.

Cas particulier de plusieurs remplacements envisageable Cette logique est plutôt satisfaisante. Mais il faudrait tout de même ajouter un warning à l'execution lorsque plusieurs remplacement sont applicables pour une même variable.

A mon très humble avis, ça ne devrait pas être permis de faire des remplacements multiples. C'est une bombe à retardement pour une nouvelle discussion longue et compliquée entre les développeurs publicode du futur, qui sera peut-être plus difficile à régler que celle-ci. Et intuitivement ça semble beaucoup moins solide que le reste de ce qui est énoncé ici.
Sans avoir formalisé le truc sur papier, il me semble que ca n'est pas nécessaire d'avoir des remplacements multiples. Il faudrait regarder combien il y en a dans la base de code actuelle, pour évaluer si c'est jouable de les deprecate facilement.

Avec cette nouvelle solution c'est l'arbre syntaxique qui se retrouve transformé et non les règles brute. Il n'y a donc pas "deux parsings". Il n'y a pas de preprocessing. Juste une transformation statique d'un arbre vers un autre, c'est une opération usuelle dans les compilateurs, et c'est d'ailleurs une des raisons d'être de la l'utilisation de la represntation intermédiaire de l'arbre syntaxique abstrait. C'est une remarque très juste, et qui appuie dans le sens d'une écriture syntaxique différente, voir un type de règle différent pour les remplacement. Mais d'un autre côté, le langage courant met souvent côte à côte des sémantiques différentes sans que cela ne soit perturbant. Je suis partagé et je pense qu'il faudrait d'plus d'éléments avant de trancher vers une autre syntaxe (cout de modification des règles existantes).

Avec ton interprétation du remplace c'est bcp plus clair, donc ces remarques sont un peu outdated.
Ceci dit pour la partie "preprocessing ou non", l'idée était simplement de dire qu'on a les traductions suivantes entre langages (je simplifie avec que les formule et les remplace):

L1: Raw rules: contient des formule et des remplace
↓
L2: Raw rules sans remplace: contient que des formule
↓
L3: ParsedRules (en tout cas qqch qu'on peut feeder à l'évaluation)

En ça c'est du preprocessing selon moi dans un sens évident. Mais peu importe les termes, la question qui reste à trancher: Est-ce qu'on veut que le langage L2 soit strictement le langage L1 sans les remplace? (et sans les applicable...)?
Je suis pour le oui, ca me semble le plus simple conceptuellement, et reste totalement faisable. Notamment, j'aime bien l'idée de pouvoir immédiatement lire le langage L2 pour le debugger, et ça va dans le sens plus large de faire des étapes de traduction vers un langage de plus en plus minimal avant de l'interpréter (on en avait déjà parlé dans le passé).

denismerigoux commented 3 years ago

Salut, discussion très intéressante, je pense que vous êtes en train de vous confronter au vrai problème du mécanisme remplace. Pour le coup en Catala c'est ce qu'on gère grâce à la logique par défaut et notre langage de scopes. La traduction de votre langage L1 à L2 ça correspond à notre passe d'élimination de la logique par défaut dans le compilateur Catala. On fait la détection de cycles avant, directement au niveau du langage de scopes.

Votre problème de départ est que vous considérez remplacement comme un mécanisme d'inlining, c'est à dire de substitution d'une référence par sa définition. Or il faut faire très attention quand on fait de l'inlining à la durée de vie des variables que l'on inline et aux durées de vie des variables dans la définition inlinée, pour justement ne pas changer la sémantique du programme quand vous inlinez. Or comme dans Publicodes toutes les variables ont une durée de vie illimitée (un seul scope global,) cela rend quasiment impossible tout inlining...

En l'état Publicodes est hautement non-standard dans sa sémantique. Ce que j'espère vous transmettre c'est l'idée que faire un langage non-standard mais "simple" est la meilleure manière de se planter lorsque l'on designe un nouveau langage de programmation. En effet en s'écartant des canons du language design, on risque de se retrouver dans des situations inconnues où l'on ne peut plus appliquer les recettes classiques. Et ce que l'on pense "simple" à première vue se révèle très souvent hyper compliqué après une analyse plus réfléchie comme celle que vous faites ici.

Ici vous ne raisonnez que sur les AST de vos programmes. Mais en réalité une transformation comme l'inlining nécessite de raisonner sur le graphe de flot de contrôle (CFG) de vos programmes, dont je ne sais même pas s'il est matérialisé quelque par dans votre compilateur.

Je pense que cette discussion fait bien apparaître le besoin d'une formalisation. En effet, vous considérez plusieurs exemples de programmes et vérifiez si vos solutions marchent bien dessus. Mais est-ce que votre solution marchera tout le temps ? Qu'est ce qu'il vous dit qu'il y a pas un exemple pathologique auquel vous avez pas pensé et qui réduit à néant votre solution ?

J'ai bien compris la manière dont vous raisonnez, et qui part d'une bonne intention : vous ne voulez pas rajouter dans votre langage de features dont vous ne pensez pas avoir besoin. Le problème c'est que lorsqu'on designe un langage de programmation, plusieurs features forment ensemble un ensemble logique cohérent, et d'autres sont incompatibles entre elles. Ces contraintes sont inhérentes à la nature mathématique des langages de programmation. En faisant abstraction de ces contraintes et en ne faisant vos choix que sur la base d'une complexité "intuitive" que vous ressentez, vous risquez de refuser d'implémenter des features essentielles et simples, ou de vous acharner sur des features incompatibles avec votre langage.

Vous avez déjà une formalisation de votre système de types pour vos valeurs. C'est un bon début, mais c'était la partie facile : c'est la partie de vote langage la plus classique et qui ressemble à ce qui se fait ailleurs. Pour formaliser vos mécanisme remplace et applicable si et votre moteur d'exécution cela va être nettement plus compliqué. Pour info, la sémantique de Catala, qui est en réalité très proche de ce que vous essayez de faire, a mobilisé plusieurs spécialistes de langages de programmation pendant 1 an, et nous allons publier un article scientifique dessus.

Tout le travail sur la formalisation consiste justement à partir d'une base de langage connue (lambda calcul, langage impératif, etc.) et à rajouter un ou deux mécanismes supplémentaires que l'on cherche à décrire complètement dans toutes les situations. Ensuite, le plus important est de prouver que l'ajout de ces mécanismes ne détruit pas la sûreté du typage de votre nouveau langage, c'est à dire que tous les programmes bien typés s’exécutent. Ensuite,et c'est le cas pour vous comme pour Catala, il faut se débarasser de ces mécanismes supplémentaires en les compilant vers le langage de base. Là, il faut définir formellement l'étape de compilation puis prouver un nouveau théorème qui dit que la compilation "préserve la sémantique", c'est à dire que vos programmes se comportent de la même manière avant et après la compilation.

Je comprend que vous n'ayez pas les moyens de recruter un spécialiste des langages de programmation, ni le temps de vous former vous-même complètement en suivant un vrai cours qui reprenne les bases du domaine. Cependant, à mesure que vous débugguez votre application, je vous avertis que vous risquez de plus en plus souvent de vous confronter à des bugs comme celui-ci qui, qui loin de correspondre à des cas pathologiques, vont arriver de plus en plus souvent à mesure que vos programmes Publicodes seront de plus en plus complexes. Ces bugs vont nécessiter de plus en plus de réflexions profondes sur l'implémentation de votre compilateur, et déboucher de plus en plus sur de gros refactoring : en d'autres termes, vous allez accumuler de la dette technique. C'est exactement ce qui se passe à PayFit avec leur langage JetLang, auquel j'ai déjà eu accès.

Désolé d'être rabat-joie avec ce message, mais je prend le temps de vous écrire tout ça parce que je trouve que PubliCodes est une super initiative et j'ai envie qu'elle réussisse !

mquandalle commented 3 years ago

Salut Denis,

C'est toujours bien d'être challengé. Sur le fond j'ai un avis divergeant : je ne suis pas convaincu que la “bonne manière” de créer quelque chose de nouveau soit une approche académique qui nécessiterait d'être familier avec toute la littérature sur la conception des langages et de le formaliser avant de l'utiliser dans une application pour des utilisateurs finaux. Je sais que tu as pas mal travaillé sur Rust qui est un langage que je trouve super intéressant aussi. Quand on regarde son historique de conception il est parti du besoin de recoder une application concrète (le moteur de rendu de Firefox) et a eu un développement très itératif dans ses premières années, avec parfois des fonctionnalités retirées (Green threads) et avec une formalisation et une fiabilisation du design (MIR par exemple) qui est venue progressivement. Il n'est peut être pas utile de trop étendre le débat sur ce point, mais pour résumer mon opinion je pense qu'une approche un peu plus “bricolage” a aussi des avantages réels.

Or comme dans Publicodes toutes les variables ont une durée de vie illimitée (un seul scope global,) cela rend quasiment impossible tout inlining

Je ne comprends pas ce point, les variables ont certes une durée de vie infinie mais elles sont immuables. D'ailleurs on a un modèle d'évaluation un peu différent d'un langage classique et je ne suis pas sûr que la notion de durée de vie d'une variable s'applique à publicode, en particulier car le programmeur n'a pas de contrôle sur l'ordre d'évaluation des formules qui sont systématiquement évaluées dans l'ordre topologique (on part d'une valeur à calculer puis on remonte son arbre des dépendances). Je rate quelque chose ?

Edit: Non on a pas de CFG, mais notre modèle d'évaluation est celui d'un DAG que l'on remplit progressivement, et toutes les variables sont effectivement globales donc je ne suis pas certain qu'il s'agisse d'une représentation intermédiaire dont on a besoin (je peux me tromper !).

Je comprend que vous n'ayez pas les moyens de recruter un spécialiste des langages de programmation

En fait je pense qu'on a les moyens pécuniaires, mais pas de candidats.

mquandalle commented 3 years ago

Merci @johangirod pour la proposition détaillée. Je disais une bêtise dans mon exemple de transformation statique de la règle, au niveau du code les choses se font bien au niveau des références.

Comme @lajarre, il me paraît plus sain d'interdire les remplacements multiples de même niveau. En revanche, il faut pouvoir gérer la transitivité. Dans #970, j’identifiais que le cas suivant n'était pas géré par l'implémentation actuelle :

Je pense qu'il faut vérifier que la transitivité fonctionne bien avec la substitution remplacement(défini dans: b, original: a, nouveau: b)

denismerigoux commented 3 years ago

Je sais que tu as pas mal travaillé sur Rust qui est un langage que je trouve super intéressant aussi. Quand on regarde son historique de conception il est parti du besoin de recoder une application concrète (le moteur de rendu de Firefox) et a eu un développement très itératif dans ses premières années, avec parfois des fonctionnalités retirées (Green threads) et avec une formalisation et une fiabilisation du design (MIR par exemple) qui est venue progressivement. Il n'est peut être pas utile de trop étendre le débat sur ce point, mais pour résumer mon opinion je pense qu'une approche un peu plus “bricolage” a aussi des avantages réels.

Effectivement je prend note de nos avis divergents sur ce point. Petite précision : Graydon Hoare, le créateur de Rust, est à la base un spécialiste des langages de programmations et sa compétence professionnelle principale est l'écriture de compilateurs. Les idées de Rust ont grandement été influencées par Cyclon, langage issu de la communauté académique et mûri depuis de longues années par des chercheurs et des thésards.

D'ailleurs on a un modèle d'évaluation un peu différent d'un langage classique et je ne suis pas sûr que la notion de durée de vie d'une variable s'applique à publicode, en particulier car le programmeur n'a pas de contrôle sur l'ordre d'évaluation des formules qui sont systématiquement évaluées dans l'ordre topologique (on part d'une valeur à calculer puis on remonte son arbre des dépendances). Je rate quelque chose ?

J'ai beaucoup de mal à comprendre et à suivre vos exemples. Par exemple je prend cet exemple :

# raw rules:
a:
  formule: b + 1
  remplace: c
b:
  formule: c + 1
c:
  formule: 0

Si je le traduis dans un truc plus standard ça donne, avant remplacement :

A = B + 1 
remplace [ C = A ] 
B = C + 1
C = 0

Et après le remplacement on aurait :

# transformed rules:
a:
  formule: b + 1
b:
  formule: a + 1
c:
  formule: 0

Donc en plus standard:

A = B + 1
B = A + 1
C = 0

Vous n'avez pas remplacé la dernière ligne C = 0 par A = 0? Si je comprend bien c'est parce que pour vous, remplace ne veut pas dire "substitue". La substitution a un sens très précis en informatique, qui est celui d'une substitution complète d'un nom de la variable par une autre. Et en plus quand vous prenez en compte vos règle "applicable si", ça n'a plus rien à voir avec de la substitution. En fait votre mécanisme remplace c'est exactement de la logique par défaut, et je pense que ce serait plus clair de le voir comme ça. En fait quand vous "remplacez" C par A, ce que vous faites c'est que vous ajoutez une deuxième définition à C. Dans le cas de base, c'est formule 0, mais si la règle remplace est applicable, alors c'est A.

Si j'ai bien compris, alors ce que vous faites avec vos transformations d'AST c'est d'essayer d'émuler ce mécanisme de sélection dynamique des définitions de vos variables à coup de if b applicable then b else c. Pour le coup je vous renvoie à la formalisation de Catala, c'est effectivement à base de conditionnelles que l'on émule le mécanisme de la logique par défaut. D'ailleurs vous utilisez vous même le terme de "défaut":

On a donc b1 qui s'applique par défaut (premier remplacement définit dans l'ordre des règles). Si b1 est non applicable alors b2 s'applique. Si b2 est non applicable, la valeur est inchangée.

Alors ça demanderait pas mal de réflexion et de vérifications mais je pense que ce que vous proposez va généralement faire ce que Catala fait avec sa logique par défaut. Par contre attention je soupçonne fortement que votre truc ne marchera pas justement pour la transitivité de la substitution (mais je peux me tromper).

La manière dont je considère ça abstraitement c'est que chaque variable a une liste de définitions, chacune préfixée par une pré-condition. Lors de l'exécution, le moteur va regarder pour quelles définitions les préconditions sont vraies. S'il y en a exactement une, il la choisit. S'il y en a 0 ou plusieurs incompatibles, il lève une erreur.

Désolé pour le gros pavé et pour l'incompréhension initiale, je pense que ce que vous proposez va globalement dans le bon sens. Par contre il faudrait vraiment passer de l'explication par exemples à une explication où vous expliquez symboliquement en quoi consiste votre transformation, pour être sûr qu'elle fait bien ce que vous voulez dans tous les cas.

Je comprend que vous n'ayez pas les moyens de recruter un spécialiste des langages de programmation

En fait je pense qu'on a les moyens pécuniaires, mais pas de candidats.

Ah pour les candidats je peux aider :) Cela dépend ce que vous pouvez recruter : stagiaire, CDD, CDI ? Si vous me donnez une offre je peux la faire circuler dans les milieux des langages de programmation. Vous pouvez aussi recruter un doctorant pour une mission de conseil de 32 jours par an pour environ 5500 € TTC, je pense que ça suffirait pour formaliser Publicodes.

johangirod commented 3 years ago

Bonjour à tous, et merci pour la qualité des participations, cette discussion est définitivement très passionante. Je vais tacher de m'insérer dans le débat "philosophique" sur la formalisation avant de répondre aux éléments plus terre à terre sur la proposition d'inlining.

Votre problème de départ est que vous considérez remplacement comme un mécanisme d'inlining, c'est à dire de substitution d'une référence par sa définition.

Notre problème était l'analyse statique des cycles dans la définitions des variables. L'inlining des remplacement était une solution. Il faut savoir qu'actuellement, notre implémentation ressemble en effet plus à celle que tu décris de logique par défaut, avec un certains nombre de définitions pour les variables et des préconditions associées.

La limite de cette formalisation est que dans l'état actuel du langage, nous ne pouvons pas définir des préconditions sur le contexte d'appel, hors c'est une fonctionalités que nous utilisons pour effectuer des remplacement uniquement dans certains contextes. D'où l'idée d'aller vers de l'inlining et de définir le remplacement comme une fonctionalités d'inlining.

Or il faut faire très attention quand on fait de l'inlining à la durée de vie des variables que l'on inline et aux durées de vie des variables dans la définition inlinée, pour justement ne pas changer la sémantique du programme quand vous inlinez. Or comme dans Publicodes toutes les variables ont une durée de vie illimitée (un seul scope global,) cela rend quasiment impossible tout inlining...

Comme @mquandalle j'aurais eu tendance à penser l'inverse : vu que dans publicodes toutes les variables sont globales et ont une valeur immutable toujours-déjà affectée, on peut opérer des transformations sans soucis de scope et de cycle de vie.

Tout le travail sur la formalisation consiste justement à partir d'une base de langage connue (lambda calcul, langage impératif, etc.) et à rajouter un ou deux mécanismes supplémentaires que l'on cherche à décrire complètement dans toutes les situations. Ensuite, le plus important est de prouver que l'ajout de ces mécanismes ne détruit pas la sûreté du typage de votre nouveau langage, c'est à dire que tous les programmes bien typés s’exécutent. Ensuite,et c'est le cas pour vous comme pour Catala, il faut se débarasser de ces mécanismes supplémentaires en les compilant vers le langage de base. Là, il faut définir formellement l'étape de compilation puis prouver un nouveau théorème qui dit que la compilation "préserve la sémantique", c'est à dire que vos programmes se comportent de la même manière avant et après la compilation.

Dans cette remarque, il y a quelque chose qui m'échappe. Si la définition du mécanisme remplace est celle d'un inlining tel que nous l'avons défini, il n'y a pas lieu de prouver que la sémantique est inchangée, puisque par construction, elle est définie comme celle de l'inlining. Plus généralement, si un mécanisme est décrit comme la transformation qu'il opère à la compilation, le problème n'a plus lieu d'être non ? Mais peut-être y a t'il une incohérence logique dans ce raisonnement.

Et cela m'amène à ouvrir sur tes remarques sur besoin de formalisation et sur la nécessité d'une representation symbolique de publicodes. Comme tu le fais remarquer, l'argumentation à base d'exemple a clairement ses limites, et les discussions manquent d'une assise qui éviterait les divergence d'interprétations et qui permettrait un raisonemment sur le cas général plutôt que sur des cas particuliers. Nous commencons un travail très important et relativement complexe, et nous n'avons pas l'outil approprié.

Tu as donc raison, il nous faut donc être accompagné, et je rejoint @mquandalle : nous avons le budget mais pas de candidats. Il faut voir quel forme cela prendrait mais la mission de conseil par un doctorant semble être la plus approprié à notre organisation actuelle.

En l'état Publicodes est hautement non-standard dans sa sémantique. Ce que j'espère vous transmettre c'est l'idée que faire un langage non-standard mais "simple" est la meilleure manière de se planter lorsque l'on designe un nouveau langage de programmation. En effet en s'écartant des canons du language design, on risque de se retrouver dans des situations inconnues où l'on ne peut plus appliquer les recettes classiques. Et ce que l'on pense "simple" à première vue se révèle très souvent hyper compliqué après une analyse plus réfléchie comme celle que vous faites ici.

Peut-être que la sémantique de publicodes est hautement non-standard. Quoi qu'il en soit elle a une caractéristique inaliénable, c'est qu'elle part d'un usage, et qu'elle réponds à un besoin et qu'elle existe déjà.

Je rejoint ici @mquandalle en reformulant : la formalisation de publicodes et sa rigueur sémantique ne sont pas des prérequis à son existence. Publicodes est, et c'est qui fait sa force (et la raison pour laquelle nous discutons tous ici). Il est peut-être mal défini, ambigü, buggué, mais il existe. Aussi tout effort de formalisation ne peut être vu que comme un processus accompagnant son évolution, et non comme un travail de design avant conception. C'est un changement de paradigme pour quiconque qui travaille à la conception de langage.

Pour résumé mon point de vue : il faut effectivement formaliser publicodes. Nous n'avons pas les compétences pour le faire. Il faut nous faire accompagner. Ce travail devra être fait en balançant entre la difficulté de formalisation une sémantique non standard et la difficulté de modifier tout une base de code et introduire des breakings change dans le langage.

Passons maintenant aux différentes remarques sur la solution proposée.

Remplacement multiples.

Malheureusement, les remplacement multiples sont aujourd'hui très utilisés, en particulier dans une logique de bouche-trou dans une somme (placeholder). Par exemple, la somme de la listes des cotisations contient un placeholder pour les cotisations conventionnelles. Chaque convention collective défini un remplacement de cette dernière avec ses cotisations spécifique (caisse de congé payé, etc...). Par construction, on sait qu'il n'y a jamais plusieurs conventions applicable (une possibilité). Par conséquent il y aura toujours 0 ou 1 remplacement applicable. Mais plusieurs remplacements définis pour la même variables.

Je pense que ma proposition n'était pas claire. Je préfère en changer et proposer une modification de la traduction allant dans le sens de la logique par défaut.

# raw rules:
b1:
  remplace: c
  formule: 3
b2: 
  remplace: c
  formule: 5
d: c

Donne la transformation suivante :

# transformed rules:
d: 
  formule:
    variations: 
      - si: b1
        alors: b1
      - si: b2 
        alors: b2
      - sinon: c

Transitivité

Je remet ici le cas d'usage pour comprendre de quoi il en retourne :

assiette des cotisations:

déduction forfaitaire:
  remplace: 
    règle: assiette des cotisations 
  formule: assiette des cotisations * 50%

déduction forfaitaire exception chômage des journalistes:
  remplace:
    règle: déduction forfaitaire
    dans: chômage
    par: non applicable

(on peut imaginer d'utiliser le mécanisme rends non applicable plus facile à lire, et qui est du sucre syntaxique pour remplace par non applicable).

Comme on le voit, le cas d'usage est très largement justifié. Il faut donc "le faire marcher" et trouver comment l'insérer dans notre définition.

Pour moi, cela peut être réglé en continuant à transformer la formule tant qu'il existe des remplacements applicables.

Remplacements, Règles
RemplacementsAppliqués = []
Tant qu'il existe un RemplacementApplicable dans Remplacements:
  Règle = AppliqueRemplacement(RemplacementApplicable, Règle)

Mais la on tombe potentiellement sur un os : que se passe-t'il en cas de cycle de remplacement applicable ?

a: 0
b: a
y:
  remplace: a
  par: a + 1

ou encore :

a: 0
b: 1
c: a + b
y:
  - remplace: b
    par: a + 1
  - remplace: a
    par: b
  dans: c

Dans b, on peut continuer à appliquer indéfiniement le remplacement définit dans y. On retombe sur notre problème de détection de cycle. Tout ça pour ça ? Oui... et non. Car c'est un problème qu'on peut maintenant résoudre facilement car il s'agit uniquement de cycle dans la définition des remplacement (et non une combinaison remplacement / formule). Il faut de construire un graphe des remplacements définis sur la règle (pour chaque règle), et ça devrait le faire.

C'est effectivement la limite de la solution. Mais ce n'est pas indépassable.

L1, L2, L3

Je proposais de faire cette étape d'inlining à partir des parsedRules, pour éviter de parser deux fois les formules des règles. Mais je n'ai pas d'avis tranché sur la question. En revanche, je veux revenir sur le point de la visualisation.

Là je ne te suis pas tout-à-fait. Ca veut dire quoi "sa propre visualisation"? La formule: (si b est applicable alors: b sinon c) + 5 que tu as décrit semble très suffisant, non? Il suffirait de le mettre en forme dans la syntaxe originelle.

Je ne suis pas d'accord. Pour moi le remplacement devrait être présenté à l'utilisateur comme un remplacement et non comme une formule un peu cryptique de si b applicable alors b sinon c. Je préférerai qu'il y ait une référence au remplacement de la variable d'une manière ou d'une autre, afin que l'on puisse différencier les deux écriture et se rapprocher plus de la sémantique haut-niveau originale. Pour moi, la visualisation doit être le plus proche possible de l'écriture de base. D'où ce besoin d'avoir un mécanisme de retrouver la source de la partie transpilé, un peu comme pour les sourcemap dans un debuggeur js.

C'est pour ça que je proposait un mécanisme à part entière. Mais on peut imaginer une autre implémentation. Garder la liste des remplacement, ou tagguer le noeud comme un noeud issu d'un remplacement. Je n'ai pas d'idée très arrêtée.

Prochaine étapes

J'identifie les prochaines étapes suivantes :

denismerigoux commented 3 years ago

Tout d'abord merci à vous de mettre vos débats en ligne et d'être ouverts aux remarques, je pense que c'est la bonne manière de progresser. Au delà d'éventuelles différences philosophiques, j'essaie de vous apporter des éléments factuels qui vous permettront de mieux planifier l'avenir du côté langage de programmation de Publicodes :)

Notre problème était l'analyse statique des cycles dans la définitions des variables. L'inlining des remplacement était une solution. Il faut savoir qu'actuellement, notre implémentation ressemble en effet plus à celle que tu décris de logique par défaut, avec un certains nombre de définitions pour les variables et des préconditions associées.

Alors pour le coup j'avais pas compris ça du tout. Pourquoi vous pouvez pas analyser les cycles dans les définitions des variables avec votre implémentation actuelle en mode "logique par défaut" ? Ça vaudrait peut-être le coup d'en discuter dans une autre issue, parce que théoriquement y devrait pas y avoir de problème, en Catala je fais le check des cycles directement sur le langage où la logique par défaut est encore présente.

La limite de cette formalisation est que dans l'état actuel du langage, nous ne pouvons pas définir des préconditions sur le contexte d'appel, hors c'est une fonctionalités que nous utilisons pour effectuer des remplacement uniquement dans certains contextes. D'où l'idée d'aller vers de l'inlining et de définir le remplacement comme une fonctionalités d'inlining.

Ah oui ? J'ai pas ce problème dans Catala. On est d'accord que définir des préconditions sur le contexte d'appel c'est genre :

a: 
  remplace: b si c = 0
  formule: 0

Je sais pas comment vous exécutez vos programmes en logique par défaut mais en tout cas la sémantique de Catala gère ça sans problèmes.

Plus généralement, si un mécanisme est décrit comme la transformation qu'il opère à la compilation, le problème n'a plus lieu d'être non ? Mais peut-être y a t'il une incohérence logique dans ce raisonnement.

Tu as tout à fait raison, on peut tout à fait définir la formalisation d'un langage en décrivant formellement comment il se traduit vers un autre langage dont on connaît la formalisation. Mais le but d'une formalisation c'est avant tout de décrire clairement et simplement comment se comportent les programmes, donc pour choisir entre faire la formalisation directement ou en décrivant une traduction, il faut choisir ce qui rend l'explication la plus claire :) Après de manière interne à votre compilateur vous pouvez éliminer la logique par défaut en faisant cette sorte d'inlining oui, c'est ce qu'on fait dans Catala aussi pour éviter d'avoir à recourir à un runtime à la fin.

Aussi tout effort de formalisation ne peut être vu que comme un processus accompagnant son évolution, et non comme un travail de design avant conception. C'est un changement de paradigme pour quiconque qui travaille à la conception de langage.

Ah mais je te rejoins totalement. Les bons DSL répondent à un besoin utilisateur et il faut d'abord écrire les programmes avant d'écrire le compilateur. Par contre ce que j'essaie de vous transmettre, c'est qu'il faut mieux écrire la formalisation avant d'écrire le compilateur (ou de s'engager dans un gros refactoring) :)

# raw rules:
b1:
  remplace: c
  formule: 3
b2: 
  remplace: c
  formule: 5
d: c

Donne la transformation suivante :

# transformed rules:
d: 
  formule:
    variations: 
      - si: b1
        alors: b1
      - si: b2 
        alors: b2
      - sinon: c

Complètement d'accord. Par contre j'ajouterai la question suivante : comment vous choissisez l'ordre entre b1 et b2 de celui qui est testé en premier ? Par ordre de déclaration dans le code source ? Un truc en plus qu'on a dans Catala c'est qu'on peut explicitement signaler si deux remplacements sont incompatibles entre eux. C'est une sorte de sanity check fait au runtime qui permet de vérifier que deux cas exceptionnels ne se chevauchent pas.

Rédiger une offre en collaboration avec @denismerigoux pour le travail de formalisation.

À votre disposition. Pour info voilà l'offre de stage que j'avais rédigée pour Catala l'an dernier, vous pouvez prendre ça comme modèle : https://gitlab.inria.fr/verifisc/mlang/-/blob/master/documents/InternshipProsecco2020.pdf.Des infos sur le doctorant conseil : https://www.sciencesmaths-paris.fr/upload/Contenu/Doctorants_Conseil/Doctorant_conseil.pdf

johangirod commented 3 years ago

Ah oui ? J'ai pas ce problème dans Catala. On est d'accord que définir des préconditions sur le contexte d'appel c'est genre :

remplace: b si c = 0
formule: 0

Pas vraiment, ce sont des préconditions pour spécifier un remplacement uniquement dans un certain contexte (certaines règles).

c: 
  remplace: 
     - a dans x
     - b sauf dans y
  formule: 0
x:
  formule: a + b
y:
  formule: a + b

Qui donne :

c: 
  remplace: 
     - a dans x
     - b sauf dans x
  formule: 0
x:
  formule: c + b
y:
  formule: a + c

D'où l'idée d'inliner pour pouvoir détecter les cycles en prenant en compte le contexte d'appel.

Par contre j'ajouterai la question suivante : comment vous choisissez l'ordre entre b1 et b2 de celui qui est testé en premier ? Par ordre de déclaration dans le code source ? Un truc en plus qu'on a dans Catala c'est qu'on peut explicitement signaler si deux remplacements sont incompatibles entre eux. C'est une sorte de sanity check fait au runtime qui permet de vérifier que deux cas exceptionnels ne se chevauchent pas.

Il faudrait effectivement avoir la même chose, et lever un warning au runtime dès que deux remplacement sont applicables pour la même variable (tout en choisissant arbitrairement le premier ou le dernier dans l'ordre de définition).

Pour l'offre, on peut continuer l'échange sur le canal email, sans doute plus approprié.

denismerigoux commented 3 years ago
c: 
  remplace: 
     - a dans x
     - b sauf dans y
  formule: 0
x:
  formule: a + b
y:
  formule: a + b

En Catala ça donnerait :

scope Foo:

  # Définitions originelles
  def x := a + b
  def y := a + b
  def c := 0

  # Remplacements
  def a := c  # Remplacement global
  # Pas d'équivalent direct pour  "remplace a dans x" ou "remplace b sauf dans y"
  # il faudrait passer la définition d'un nouveau scope pour factorer x et y d'une 
  # variable booléenne "do_replace" 

Le cas de la blacklist et de la whitelist de remplacements est très tricky à gérer en fait. Ça c'est typiquement une feature qui rajoute beaucoup de complication au langage, genre beaucoup plus que des fonctions définies par l'utilisateur (que vous ne voulez pas mettre par peur de rendre le langage trop compliqué). Pour gérer correctement ce mécanisme je sais même pas si l'inlinling suffira, j'ai plutôt l'impression qu'il faudra instrumenter votre runtime pour être au courant de toutes les utilisations des variables... Pas clair.

lajarre commented 3 years ago

Remarques sur les questions "philo":

En fait pour moi c'est proche d'une question "d'architecture" comme on dit. Parmis les objectifs d'une bonne architecture: être facilement compréhensible "par un junior", être modifiable. Comme déjà fait remarquer par ailleurs, je trouve que sur ces points on est encore plutôt bof, en cours. Au niveau du langage, il me semble en effet essentiel qu'on ait une sémantique facile à comprendre et des bases qui soient aussi classiques que possible.

Pour l'instant, je vois un certain overfit au problème métier. Il y a un push à faire dans le sens de la formalisation, donc. Cela me semble objectif.

En tout cas ravi de voir que la discussion avance pour trouver les bonnes prochaines étapes.

Réponses à Johan:

Remplacement multiple

Je crois comprendre le besoin, mais cela semble pas suffisant en l'état et ce de manière très pratique, pas seulement théorique. En gros le développeur de règles doit compter sur lui-même pour s'assurer que son mécanisme "une possibilité" est cohérent avec les remplaces multiples, et sinon il doit regarder les warning runtime (un peu dommage pour un développeur de règles). Pas sûr de trouver ça acceptable sur le long terme.

L'idée que j'avais en tête dans mon précédent commentaire quand je disais que ce n'était pas nécessaire, c'était simplement de faire idem que ce que j'aurais fait dans du code "normal" si j'avais le problème:

b1:
  remplace: c_b1
  formule: 3
b2:
  remplace: c_b2
  formule: 5
d:
  formule: b1 ? c_b1 : b2 ? c_b2 : c

Le travail de désambiguation est renvoyé dans d.formule, à lui de se débrouiller (ou de déléguer à un autre). Là au moins c'est clair qu'il faut faire qqch si par ex on rajoute un autre b3 qui est mutuellement exclusif avec les autres bx (via Une Possibilité).

Je trouve cela vraiment gênant de dire qu'il faudrait prendre en compte l'ordre des règles (lexico ou autre) dans la traduction du remplace, donc de manière implicite pour le développeur de règles. Est-ce qu'on a déjà affaire à ça autre part dans la base code? Je me demande si on ne pourrait pas réussir à totalement éviter.

Transitivité

L'exemple cas concret est convaincant. Ainsi tu proposes de "réduire totalement" les remplace en pacrourant le graphe des remplace, qui devra donc être non cyclique. Ceci ne permettra pas de garder les remplace du type a -> a + 1, est-ce ok selon toi?

L1, L2, L3

Ok pour la visualisation, en fait je crois comprendre. Ceci dit je suis très gêné quand tu écris formule: remplace(). Formule m'avait l'air d'être une fonction pure.

Réponse à Denis

On fait la détection de cycles avant, directement au niveau du langage de scopes.

C'est ce que j'étais en cours de faire, et qui a provoqué cette discussion. Pour l'instant j'ai gardé uniquement la détection de cycles dans les formule (https://github.com/betagouv/mon-entreprise/pull/1144). J'imaginais en effet qu'il serait plus simple de faire le L1 -> L2 (je me réfère à mon précédent commentaire) avant de lancer la même détection de cycles avec l'inlining déjà fait, mais c'était juste une idée à remettre sur la table après s'être entendus sur la sémantique de remplace. En fait le problème que tu soulèves, je suppose, c'est que l'étape d'inlining n'est pas qqch de garanti?

denismerigoux commented 3 years ago

C'est ce que j'étais en cours de faire, et qui a provoqué cette discussion. Pour l'instant j'ai gardé uniquement la détection de cycles dans les formule (#1144). J'imaginais en effet qu'il serait plus simple de faire le L1 -> L2 (je me réfère à mon précédent commentaire) avant de lancer la même détection de cycles avec l'inlining déjà fait, mais c'était juste une idée à remettre sur la table après s'être entendus sur la sémantique de remplace. En fait le problème que tu soulèves, je suppose, c'est que l'étape d'inlining n'est pas qqch de garanti?

En fait le problème que je soulève est le suivant : il faut être sûr que l'inlining que vous faites pour passer de L1 à L2 va préserver la sémantique du langage, c'est à dire qu'un programme L2 après inlining va se comporter comme vous voudriez que se comporte le programme L1 avant inlining. Et c'est ça qui n'est pas clair et l'objectif de cette issue et de cette longue discussion je pense.

mquandalle commented 3 years ago

En fait je ne suis pas sûr que nous faisons vraiment de l'inlining ici, en tout cas au sens classique du terme ou c'est une optimisation d'un compilateur qui doit préserver la sémantique du programme, ici c'est plutôt la définition même du mécanisme “remplace” qui fait intervenir des substitutions dans certains nœuds du graphe.

Effectivement @johangirod, on utilise a plusieurs endroits des remplacements multiples qui sont mutuellement exclusifs, en particulier avec les conventions collectives. En fait c'est un peu l'équivalent d'une méthode abstract dans certain langages objets pour laquelle les objets, ici par exemple les différentes conventions collectives, doivent fournir une implémentation (avec une implémentation par défaut qui dit prime conventionnelle = 0 €/an). C'est une utilisation des remplacements un peu différente de ce que l'on fait ailleurs. Sans avoir creusé je me dis que les deux solutions possibles sont soit d'avoir un validateur statique suffisamment intelligent pour détecter que deux remplacements de la même règle ne peuvent pas être actifs en même temps (raisonnable si on se base sur un type somme dans les conditions d'applicabilité du remplacement), soit d'avoir un mécanisme dédié pour ce type de “règle abstraite” dont l'implémentation est fournie pour plusieurs objets “de même type”, mais j'ai du mal à imaginer à quoi ça pourrait ressembler dans notre langage.

mquandalle commented 3 years ago

Dans le message ci-dessus j'évoquais deux possibilités pour gérer les primes, soit supporter les remplacements multiples soit utiliser un autre mécanisme.

Après réflexion il me semble qu'il est préférable de bloquer strictement les remplacements multiples de même niveau, sans avoir de logique “intelligente” qui vérifie des contraintes sur les conditions d'applicabilité. Le mécanisme évoqué ci-dessus (une notion de règle abstract) ne me semble en revanche pas être le bon.

Une des choses qui nous manque dans le langage aujourd'hui c'est la possibilité de gérer des listes, ce qui nous oblige à “déplier” les listes manuellement en créant une règle . 1, règle . 2, etc. (exemple) avec les limitations évidentes qui cela induit. Si l'on avait ce type liste, les primes pourraient être une liste comprenant un nombre arbitraire de primes:

> Publicode.evaluate("rémunération . primes d'activité")
[]
ou
[{nom: "prime conventionnelle", nodeValue: "200", unit: "€/mois"}]
ou
[{nom: "prime conventionnelle", nodeValue: "200", unit: "€/mois"}, {nom: "prime d'ancienneté", nodeValue: "200", unit: "€/mois"}]

L'idée serait ensuite d'avoir un mécanisme qui permette d'ajouter un élément à une liste depuis l'extérieur.

convention collective . bâtiment . prime d'ancienneté:
  ajoute à: rémunération . primes d'activité
  formule: 200 €/mois
  applicable si: ancienneté > 2 ans

Cela me semble un fonctionnement plus proche de la manière dont ces primes sont définies, elle ne viennent pas “remplacer” une prime conventionnelle pré-existante, mais s'ajouter comme élément de rémunération. Autre avantage, nous ne sommes pas obligés de multiplier les “placeholder” pour gérer chaque type de prime imaginable (activité, ancienneté, pénibilité, etc.).

Il faudra ouvrir un ticket dédié sur ce sujet des listes et sur le mécanisme d'amendement par ajout associé, mais je l'évoque ici car ça me semble être une solution pour ne plus utiliser de remplace:. rémunération . primes d'activité . prime conventionnelle, et donc plus de remplacement multiples de même niveau. Reste à voir si les autres cas d'usages, en particulier l'assiette forfaitaire, peut aussi être exprimée autrement qu'avec un remplace.

johangirod commented 3 years ago

Après réflexion il me semble qu'il est préférable de bloquer strictement les remplacements multiples de même niveau, sans avoir de logique “intelligente” qui vérifie des contraintes sur les conditions d'applicabilité.

Je ne suis pas sûr de te suivre. Pour moi, la notion de remplacement multiple est indissociable de celle du remplacement. Par définition, le remplacement permet, dans un contexte donnée, de changer le sens de certaines variables du cas général. Il n'y a donc aucune contrainte qui puisse affirmer que les contexte spécifiques opéreront toujours sur des variables différentes.

Si on ajoute cette contrainte, cela revient à ajouter une dépendance du cas général vers l'ensemble des contexte (via la définition de variables spécifiques par contexte de remplacement).

Imaginons un dispositif qui remplace l'assiette des cotisations par un montant forfaitaire pour le contexte C1. Et imaginons un dispositif qui fasse exactement la même chose pour un autre contexte C2.

En empêchant les remplacement multiple, la seule manière de l'écrire revient à définir deux variables assiette C1 et assiette C2 directement dans le cas général et de régler la priorisation dans une variations. Autant ne plus utiliser remplacement.

Il faut donc garder les remplacements multiples, c'est le coeur du mécanisme.

En revanche, il est nécessaire de pouvoir définir une priorisation ou des conditions d'exclusivité.

Pour moi, c'est le contexte qui a la plus grande priorité qui doit connaître les contextes "concurrents" qui opère sur les même remplacement que lui. L'avantage c'est qu'on peut fournir au développeur la liste des contexte concurrents pour une même variable, il peut donc faire attention à bien définir les applicabilités pour que le cas ou C1 et C2 remplace la même variable ne puisse pas se produire.

C1 . assiette: 
  remplace: assiette
C2:
  rends non applicable: C1
C2 . assiette: 
  remplace: assiette

Il est possible de vérifier cela via un validateur statique 'suffisement intelligent' comme tu dis.

Une des choses qui nous manque dans le langage aujourd'hui c'est la possibilité de gérer des listes, ce qui nous oblige à “déplier” les listes manuellement en créant une règle . 1, règle . 2, etc. (exemple) avec les limitations évidentes qui cela induit. Si l'on avait ce type liste, les primes pourraient être une liste comprenant un nombre arbitraire de primes.

Je suis d'accord sur ce point et sur ta proposition, qui, dans le cas des primes, a d'avantage de sens. Il faut se garder ça dans un coin de la tête pour l'ajout des listes / entités.

mquandalle commented 3 years ago

Il faut donc garder les remplacements multiples, c'est le coeur du mécanisme.

En fait oui je suis d'accord

lajarre commented 3 years ago

En empêchant les remplacement multiple, la seule manière de l'écrire revient à définir deux variables assiette C1 et assiette C2 directement dans le cas général et de régler la priorisation dans une variations. Autant ne plus utiliser remplacement.

Ca rejoint en effet ce que je proposais plus haut, mais je retire cette proposition suite à tes remarques @johangirod .

Pour conclure sur ce point, à mon avis il faut juste être bien clair dans la documentation (et peut–être la syntaxe?) et en effet avoir de la validation statique autant que possible. Sans ça, le dev qui se retrouve à ajouter un remplacement risque de se faire autant de noeuds au cerveau que moi lorsqu'il devrai comprendre ou ajouter un remplacement existant. C'est de cette frustration qu'est née ce ticket etc.

Il faudrait réfléchir à faire des sessions de user tests (dans le sens UX du terme) pour recueillir des feedbacks et vérifier qu'on fait des choses compréhensibles pour les développeurs de règles.