diff --git a/package.json b/package.json index 03388dcd6..30988b416 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "yaml-loader": "^0.5.0" }, "devDependencies": { + "akh": "^3.1.2", "autoprefixer": "^7.1.1", "babel-cli": "^6.24.1", "babel-core": "^6.24.1", @@ -54,8 +55,12 @@ "core-js": "^2.4.1", "css-loader": "^0.28.1", "eslint": "^4.4.1", + "daggy": "^1.1.0", "eslint-plugin-react": "^7.0.1", "express": "^4.15.3", + "fantasy-combinators": "0.0.1", + "fantasy-land": "^3.3.0", + "fantasy-tuples": "^1.0.0", "file-loader": "^0.11.1", "html-loader": "^0.5.1", "img-loader": "^2.0.0", @@ -65,6 +70,7 @@ "mocha-webpack": "^0.7.0", "nearley-loader": "0.0.2", "postcss-loader": "^2.0.5", + "ramda-fantasy": "^0.8.0", "react-hot-loader": "^3.0.0-beta.6", "redux-devtools": "^3.4.0", "redux-devtools-dock-monitor": "^1.1.2", diff --git a/test/tree.test.js b/test/tree.test.js new file mode 100644 index 000000000..788ffa917 --- /dev/null +++ b/test/tree.test.js @@ -0,0 +1,428 @@ +import R from 'ramda' +import {expect} from 'chai' +import daggy from 'daggy' +import {Maybe as M} from 'ramda-fantasy' +import {StateT, Writer} from 'akh' + +describe('simplified tree walks', function() { + + // Notre domaine peut se simplifier à une liste d'équations à trous: + // a: 45 + // b: a + c + // d: a + 4 + // e: b + d + // Disons que je veux connaitre "e", alors il va me manquer "c" + // Si je connais "c", alors je peux calculer "e" + // Et mon ambition est aussi de pouvoir visualiser le calcul en HTML + // Donc j'ai une structure plate que je transforme en arbre (ce n'est pas + // le focus de la présente exploration), je veux pouvoir demander des choses + // diverses à cet arbre: l'évaluer, repérer les trous, le transformer en HTML + + // Plus tard je vais avoir des trucs plus sophistiqués, par exemple: + // b: a + (bleu: b, vert: c) + // qui est équivalent à: + // b: b-bleu + b-vert + // b-bleu: a + b + // b-vert: a + c + // Le but du jeu est de pouvoir le représenter de façon compacte, mais + // d'avoir un arbre simple à manipuler + + // Pour intégrer dans le simulateur, il faut remplir les exigences + // suivantes: + // X décorer l'arbre avec une valeur à chaque noeud + // X réaliser le calcul de façon efficiente (1 fois par variable) + // - savoir "court-circuiter" le calcul de variables manquantes dans les conditionnelles + // - avoir un moyen de gérer les composantes et filtrage + + // Ce qu'on décrit est un framework de programmation déclarative: on stipule des + // définitions (salaire net = brut - cotisations) mais on les donne sans ordre + // impératif, on laisse au moteur le soin de calculer les dépendances + + // Chaque élément de notre base de règles est une définition: + + const Def = daggy.taggedSum('Def', { + Assign: ['name', 'expr'] + }) + const {Assign} = Def + + // Par contre, à l'exécution, il faut bien calculer des "effets de bord" + // pour rester performant: chaque évaluation d'une définition doit mettre + // à jour le 'dictionnaire' des valeurs connues, puis le mettre à disposition + // de la suite du calcul - on verra comment au Chapitre 3 + + // La partie droite d'une définition est une expression: + + const Expr = daggy.taggedSum('Expr',{ + Num: ['x'], + Add: ['x', 'y'], + Var: ['name'] +// NotIf: ['condition','formule'], +// OnlyIf: ['condition','formule'], +// AnyOf: ['conditions'], +// AllOf: ['conditions'], + }) + const {Num, Add, Var} = Expr + + // Chapitre 1... + + // Le type Expr est la traduction en JS du type suivant en Haskell, + // "naivement récursif": + // data Expr = Num Int | Var String | Add Expr Expr + + // Il se trouve qu'on peut gagner beaucoup en introduisant une petite + // complexité: on va exprimer la récursion avec un niveau d'indirection, + // la première étape étant de rendre le type polymorphique sur ce qui + // est récursif: + + // data ExprF r = Num Int | Var String | Add r r + + // Par exemple, une addition de deux additions c'est de type ExprF (ExprF r), + // et si je veux décrire des imbrications plus poussées d'additions dans + // des additions il me faudra un ExprF (ExprF (ExprF r)) et ainsi de + // suite: on a "déroulé" la récursion dans le type d'origine. + + // On peut alors retrouver le type d'origine en introduisant un + // "constructeur de point fixe de type", appelé Fx, et en introduisant + // ce qu'on appelle un "functor type" (c'est le suffixe F) + + // data Expr = Fx ExprF + + // Le point fixe de f est une solution à l'équation x = f x - on + // peut l'appliquer à des fonctions récursives, voir par exemple: + // https://www.vex.net/~trebla/haskell/fix.xhtml + + // En JS ça ne marche pas parce que JS est strict et non lazy... + + // Quand au point fixe d'un type, c'est le point fixe de son + // constructeur: une solution à l'équation T = Fx T + + // En JS c'est juste une fonction qui emballe et une qui déballe: + + const Fx = daggy.tagged('Fx',['x']) + Fx.prototype.project = function() { return this.x } + const unFix = fx => fx.project() + + // Les helpers suivants rendent moins pénible la construction de valeurs + // notamment pour les tests + + let num = x => Fx(Num(x)) + let add = (x, y) => Fx(Add(x,y)) + let ref = (name) => Fx(Var(name)) + + // Une application de la théorie des catégories permet de dériver + // la fonction "fold" suivante, qui généralise aux structures récursives + // la notion de "reduction" (comme pour les listes), on l'appelle aussi + // un catamorphisme + + // fold :: Functor f => (f a -> a) -> Fix f -> a + const fold = R.curry((algebra, x) => R.compose(algebra, R.map(fold(algebra)), unFix)(x)) + + // Cf. https://www.schoolofhaskell.com/user/bartosz/understanding-algebras + + // Dans ce contexte, un "algebre" est une fonction qui nous dit comment calculer + // la réduction pour un noeud à partir des valeurs calculées pour les noeuds fils + + // Cette fonction fournit la traversée + Expr.prototype.map = function(f) { + return this.cata({ + Num: (x) => this, // fixed + Add: (x, y) => Add(f(x), f(y)), + Var: (name) => this + }) + } + + // Celle-ci l'évaluation + const evaluator = state => a => { + return a.cata({ + Num: (x) => M.Just(x), + Add: (x, y) => R.lift(R.add)(x,y), + Var: (name) => M.toMaybe(state[name]) // Doesn't typecheck + }) + } + + let evaluate = (expr, state={}) => + fold(evaluator(state), expr) + .getOrElse(null) // for convenience + + // Voici donc l'évaluation d'un arbre... + + it('should provide a protocol for evaluation', function() { + let tree = num(45), + result = evaluate(tree) + expect(result).to.equal(45) + }); + + it('should evaluate expressions', function() { + let tree = add(num(45),num(25)), + result = evaluate(tree) + expect(result).to.equal(70) + }); + + it('should evaluate nested expressions', function() { + let tree = add(num(45),add(num(15),num(10))), + result = evaluate(tree) + expect(result).to.equal(70) + }); + + // Problème: on évalue l'arbre tout entier d'un seul coup; mais + // peut-on aussi "décorer" l'arbre pendant sa traversée avec les + // valeurs intermédiaires ? On verra que oui, au Chapitre 2; en + // attendant on voudrait aussi savoir quelles sont les variables + // manquantes... + + const collector = state => a => { + return a.cata({ + Num: (x) => [], + Add: (x, y) => R.concat(x,y), + Var: (name) => state[name] ? [] : [name] + }) + } + + let missing = (expr, state={}) => + fold(collector(state), expr) + + it('should evaluate expressions involving variables', function() { + let tree = add(num(45),ref("a")), + result = evaluate(tree,{a:25}) + expect(result).to.equal(70) + }); + + it('should evaluate expressions involving missing variables', function() { + let tree = add(num(45),ref("b")), + result = evaluate(tree,{a:25}) + expect(result).to.equal(null) + }); + + it('should provide a protocol for missing variables', function() { + let tree = ref("a"), + result = missing(tree) + expect(result).to.deep.equal(["a"]) + }); + + it('should locate missing variables in expressions', function() { + let tree = add(num(45),ref("a")), + result = missing(tree) + expect(result).to.deep.equal(["a"]) + }); + + it('should locate missing variables in nested expressions', function() { + let tree = add(add(num(35),ref("a")),num(25)), + result = missing(tree) + expect(result).to.deep.equal(["a"]) + }); + + it('should locate missing variables in nested expressions', function() { + let tree = add(add(num(35),ref("a")),num(25)), + result = missing(tree,{a:25}) + expect(result).to.deep.equal([]) + }); + + // Chapitre 2... + + // Pour annoter l'arbre avec les valeurs intermédiaires on utilise un + // type "Cofree Comonad": ce sont des paires (fst,snd) dont la première + // valeur est un noeud de l'arbre et la seconde l'annotation; on a un + // constructeur ann et une fonction de lecture + + // Cf https://github.com/willtim/recursion-schemes/ + // or http://www.timphilipwilliams.com/slides/HaskellAtBarclays.pdf + + const AnnF = daggy.tagged('AnnF',['fr','a']) + let ann = ({fst, snd}) => Fx(AnnF(fst,snd)) + let nodeValue = annf => { + let {fr, a} = unFix(annf) + return a + } + + // fork est l'opérateur "&&&" de Haskell: (f &&& g) x = Pair(f(x),g(x)) + let fork = (f, g) => x => ({fst:f(x), snd:g(x)}) + + // synthesize combine l'application d'un algèbre fourni f et de l'annotation + let synthesize = f => { + let algebra = f => R.compose(ann, fork(R.identity, R.compose(f, R.map(nodeValue)))) + return fold(algebra(f)) + } + + let annotate = (state, tree) => synthesize(evaluator(state))(tree) + + it('should annotate tree with evaluation results', function() { + let tree = add(num(45),add(num(15),num(10))), + result = nodeValue(annotate({},tree)).getOrElse(null) + expect(result).to.equal(70) + }); + + // Chapitre 3 + + // On sait evaluer des expressions, il faut aussi être capable de + // gérer les règles définissant les variables appelées dans ces + // expressions; voyons ce que ça donne avec un algèbre plus simple: + + let calculate = R.curry((rules, name) => { + let find = (rules, name) => R.find(x => R.prop("name",x) == name,rules).expr, + expr = find(rules, name) + return fold(evaluator2(calculate(rules)), expr) + }) + + const evaluator2 = calculate => a => { + return a.cata({ + Num: (x) => x, + Add: (x, y) => x+y, + Var: (name) => calculate(name) + }) + } + + it('should resolve variable dependencies', function() { + let rule1 = Assign("a",add(ref("b"),ref("b"))), + rule2 = Assign("b",num(15)), + rules = [rule1,rule2], + result = calculate(rules,"a") + expect(result).to.equal(30) + }); + + // Utilisons un Writer (un idiome fonctionnel pour par exemple écrire des logs) + // pour examiner le calcul de plus près. + + const Str = daggy.tagged("Str",['s']) + Str.zero = Str("") + Str.prototype.zero = Str.zero + Str.prototype.concat = function(b) { return Str(this.s+b.s)} + + let trace = R.curry((rules, name) => { + let find = (rules, name) => R.find(x => R.prop("name",x) == name,rules).expr, + expr = find(rules, name) + return fold(tracer(trace(rules)), expr) + }) + + const tracer = recurse => a => { + let log = (x, s) => Writer.tell(Str(s)).map(_ => x) + return a.cata({ + Num: (x) => log(x, x+","), + Add: (x, y) => x.chain(xx => y.chain(yy => log(xx+yy,"+,"))), + Var: (name) => recurse(name).chain(x => log(x,name+",")) + }) + } + + // On voit qu'on a calculé la valeur de b 2 fois! Ce n'est pas utile, + // puisque cette valeur ne changera pas au cours du calcul; et comme on + // répète le calcul autant de fois qu'il y a de références à une variable + // donnée, si l'arbre est un tant soit peu complexe les performances seront + // très mauvaises. + + it('should trace the shape of the computation', function() { + let rule1 = Assign("a",add(ref("b"),ref("b"))), + rule2 = Assign("b",num(15)), + rules = [rule1,rule2], + result = trace(rules,"a").run(Str.zero) + expect(result.value).to.equal(30) + expect(result.output.s).to.equal("15,b,15,b,+,") + }); + + // Pour corriger ce problème on va avoir besoin de formuler une version + // "monadique" du catamorphisme, c'est-à-dire qu'on va pouvoir l'associer + // à un contexte (ou monade) dans lequel tout le calcul va se dérouler, + // et qui va pouvoir accumuler des informations au fur et à mesure, par + // exemple un cache des variables déjà calculées. + + // On a déjà vu un exemple de monade, c'était Writer: voyons comment on + // reformule le catamorphisme pour qu'il se déroule dans la monade Writer. + // L'implémentation de cataM est inspirée de + // https://github.com/DrBoolean/excursion/ + // D'abord on ajoute de la plomberie: + + const cataM = (of, algM) => m => + m.project() + .traverse(of, x => x.cataM(of, algM)) + .chain(algM) + + const traverse = function(of, f) { + return this.cata({ + Num: (x) => of(this), + Add: (x, y) => f(x).chain(xx => f(y).chain(yy => of(Add(xx,yy)))), + Var: (name) => of(this) + }) + } + Expr.prototype.traverse = traverse + Fx.prototype.cataM = function(of, alg) { return cataM(of, alg)(this) } + + // Maintenant que c'est fait on voit qu'on a simplifié l'expression du + // catamorphisme: on n'a plus à expliciter l'enchaînement (sauf pour la + // récursion de plus haut niveau dans les variables) + + let trace2 = R.curry((rules, name) => { + let find = (rules, name) => R.find(x => R.prop("name",x) == name,rules).expr, + expr = find(rules, name) + return cataM(Writer.of, tracer2(trace2(rules)))(expr) + }) + + const tracer2 = recurse => a => { + let log = (x, s) => Writer.tell(Str(s)).map(_ => x) + return a.cata({ + Num: (x) => log(x,x+","), + Add: (x, y) => log(x+y,"+,"), + Var: (name) => recurse(name).chain(x => log(x,name+",")) + }) + } + + it('should trace the shape of the computation, showing two passes through b', function() { + let rule1 = Assign("a",add(ref("b"),ref("c"))), + rule2 = Assign("b",num(15)), + rule3 = Assign("c",num(10)), + rules = [rule1,rule2,rule3], + result = trace2(rules,"a").run(Str.zero) + expect(result.value).to.equal(25) + expect(result.output.s).to.equal("15,b,10,c,+,") + }); + + // On a la possibilité "d'encapsuler" une monade dans une autre: + // on va se doter d'un State, une monade qui permet de stocker un + // état et de le modifier en le propageant dans tout le calcul, et + // conserver Writer à l'intérieur (on utilise la variante StateT, + // le T veut dire "transformation de monade") + + // On peut aller plus loin et mémoiser le catamorphisme: + // https://idontgetoutmuch.wordpress.com/2011/05/15/monadic-caching-folds/ + // ça ne semble pas nécessaire ici puisque tout se passe au niveau de + // la récursion sur "Var" + + const S = StateT(Writer) + const log = (x, s) => S.lift(S.inner.tell(Str(s)).map(_ => x)) + + let trace3 = R.curry((rules, name) => { + let find = (rules, name) => R.find(x => R.prop("name",x) == name,rules).expr, + expr = find(rules, name) + return cataM(S.of, tracer3(trace3(rules)))(expr) + }) + + const memoize = f => name => { + let cache = result => + result + .chain(x => result.modify(state => R.assoc(name,run(result),state)) + .chain(z => S.of(x))) + + return S.get.chain(state => { + let cached = state[name] + return cached ? + S.of(cached.value.value) : cache(f(name)) + }) + } + + const tracer3 = recurse => a => { + return a.cata({ + Num: (x) => log(x,x+","), + Add: (x, y) => log(x+y,"+,"), + Var: memoize ((name) => recurse(name).chain(x => log(x,name+","))) + }) + } + + const run = (c, state) => Writer.run(StateT.run(c, state),Str.zero) + + it('should trace the shape of the computation, showing one pass through b', function() { + let rule1 = Assign("a",add(ref("b"),ref("b"))), + rule2 = Assign("b",num(15)), + rules = [rule1,rule2], + result = run(trace3(rules,"a"),{}) + expect(result.value.value).to.equal(30) + expect(result.output.s).to.equal("15,b,+,") + }); + +}); diff --git a/test/trees.md b/test/trees.md new file mode 100644 index 000000000..e69de29bb