Kumi : une logique de calcul en Ruby qui tourne aussi en JavaScript
Beaucoup de mes projets sont des boutiques en ligne et des systèmes de facturation. Le même problème revient dans chacun d’eux : la logique de prix vit sur le serveur, mais le client veut voir le total dans le navigateur pendant qu’il modifie les quantités. Les règles sont donc écrites deux fois, une fois en Ruby et une fois en JavaScript, et tôt ou tard les deux versions divergent.
Kumi, d’André Muta, est un gem Ruby qui prend ce problème au sérieux. C’est un DSL déclaratif pour la logique de calcul, comme les règles fiscales, la tarification et le scoring, qui compile un seul schéma en Ruby simple et en JavaScript simple. Je l’ai essayé, et ce que je vois me plaît vraiment.
Ce que fait Kumi
Vous décrivez la forme de vos données d’entrée et les valeurs que vous voulez calculer. Vous n’écrivez pas de boucles et ne décidez pas de l’ordre d’évaluation. Le compilateur détermine les dépendances, vérifie les types et génère pour chaque cible du code linéaire, sans dépendances.
Deux idées le distinguent de simples méthodes Ruby :
- Broadcasting à partir de la forme des données. Si une entrée est un tableau d’articles, une expression comme
quantity * unit_priceest automatiquement calculée par article. Une fonction commefn(:sum, ...)la ramène à une seule valeur. Cela fonctionne aussi à travers des tableaux imbriqués. - Vérifications dès la définition du schéma. Les erreurs de type, les cycles de dépendances et les conditions qui ne peuvent jamais être vraies sont signalés au chargement du schéma, et non quand un client tombe sur ce chemin de code en production.
Installation :
gem install kumi
Il nécessite Ruby 3.1 ou plus récent et est sous licence MIT. La version actuelle est la 0.1.0 de juin 2026, et le README décrit le projet comme expérimental : l’API publique peut encore changer.
Un exemple : une facture suisse
Pour l’essayer, j’ai écrit une petite facture. Les articles commandés à 10 exemplaires ou plus bénéficient d’un rabais de quantité de 10 %, la TVA suisse de 8,1 % est ajoutée, et le total est arrondi à 5 centimes, comme d’habitude pour les montants en espèces en Suisse.
require "kumi"
module Invoice
extend Kumi::Schema
schema do
input do
array :items do
hash :item do
integer :quantity
decimal :unit_price
end
end
end
trait :bulk, input.items.item.quantity >= 10
let :gross, input.items.item.quantity * input.items.item.unit_price
value :line_totals, select(bulk, gross * 0.9, gross)
value :subtotal, fn(:sum, line_totals)
value :vat, subtotal * 0.081
# no round() yet: scale to 5 Rappen, add 0.5, truncate
value :total, fn(:to_integer, (subtotal + vat) * 20 + 0.5) / 20.0
end
end
result = Invoice.from(items: [
{ quantity: 2, unit_price: 50.0 },
{ quantity: 12, unit_price: 10.0 }
])
result[:line_totals] # => [100.0, 108.0]
result[:subtotal] # => 208.0
result[:vat] # => 16.848
result[:total] # => 224.85
Quelques points à remarquer :
traitdéclare une condition booléenne. Comme elle fait référence àinput.items.item.quantity, elle est évaluée par article.letest une valeur intermédiaire,valueune sortie que vous pouvez lire dans le résultat.select(condition, if_true, if_false)choisit par article. Pour davantage de cas, il existe une cascadevalue ... do on ... base ... end.- Il n’y a aucune boucle, et pourtant
line_totalsest un tableau avec une entrée par article, etsubtotalun seul nombre.
La même logique en JavaScript
C’est la partie qui m’intéresse le plus pour les boutiques en ligne. Un seul appel écrit un module JavaScript :
Invoice.write_source("invoice.mjs", platform: :javascript)
Chaque value, let et trait devient une fonction exportée. Voici la fonction _total générée, telle quelle :
export function _total(input) {
let t88 = input["items"];
let acc95 = 0;
for (let items_i90 = 0; items_i90 < t88.length; items_i90++) {
let items_el89 = t88[items_i90];
let t91 = items_el89["quantity"];
let t92 = 10;
let t41 = t91 >= t92;
let t93 = items_el89["unit_price"];
let t48 = t91 * t93;
let t94 = 0.9;
let t51 = t48 * t94;
let t59 = t41 ? t51 : t48;
acc95 += t59;
}
let t96 = 0.081;
let t87 = acc95 * t96;
let t28 = acc95 + t87;
let t97 = 20;
let t30 = t28 * t97;
let t98 = 0.5;
let t32 = t30 + t98;
let t33 = __core_to_integer(t32);
let t99 = 20.0;
let t35 = t33 / t99;
return t35;
}
C’est exactement ce que j’attends d’un code généré : une seule boucle qui fait le rabais, la somme, la TVA et l’arrondi, sans bibliothèque ni interpréteur derrière. Vous pouvez le lire, le parcourir pas à pas dans le débogueur et le livrer tel quel. J’ai exécuté le module avec Node.js sur les mêmes données et obtenu exactement les mêmes résultats qu’en Ruby : [100, 108], 208, 16.848 et 224.85.
Kumi dans ruby.wasm
Je travaille beaucoup avec ruby.wasm, par exemple pour les Ruby Koans dans le navigateur ; je voulais donc savoir si Kumi lui-même tourne aussi dans Ruby compilé en WebAssembly. C’est le cas, avec quelques lignes de configuration :
# before require "kumi"
ENV["KUMI_PASS_BUDGET_MS"] = "0" # no threads in WebAssembly: no compile time limit
require "tmpdir"
Dir.mkdir("/tmp") unless Dir.exist?("/tmp")
def Dir.tmpdir = "/tmp" # a place for Kumi's code cache
require "kumi"
Kumi protège chaque passe du compilateur par une limite de temps basée sur Timeout, qui démarre un thread, et ruby.wasm n’a pas de threads. La variable d’environnement désactive cette limite. Kumi écrit aussi le code Ruby généré dans un répertoire de cache avant de le charger, et dans le système de fichiers en mémoire du navigateur, Ruby ne trouve pas tout seul de répertoire temporaire.
Avec cette configuration, l’exemple de facture ci-dessus donne exactement les mêmes résultats dans ruby.wasm, jusqu’au 224.85. Je l’ai testé avec ruby.wasm 2.10.1 (Ruby 4.0) dans Node.js, avec le même système de fichiers en mémoire que celui qu’utilise ruby.wasm dans le navigateur. Kumi est du pur Ruby, et ses dépendances zeitwerk et mutex_m se chargent sans aucune modification.
Vous avez donc deux façons d’amener vos règles dans le navigateur : les exporter en JavaScript, l’option la plus légère, ou, si votre frontend exécute déjà Ruby en WebAssembly, définir et exécuter les schémas Kumi directement là.
Les erreurs sont détectées tôt
Kumi analyse les conditions dès la définition du schéma. Si vous déclarez qu’un âge est compris entre 0 et 150, puis écrivez une condition qui ne peut jamais être vraie, le chargement du schéma échoue :
schema do
input { integer :age, domain: 0..150 }
trait :impossible_age, input.age == 200
end
# Kumi::Core::Errors::SemanticError: conjunction `impossible_age` is impossible
Dans les règles métier, une telle condition est généralement une faute de frappe ou un malentendu sur les exigences. Kumi vous le signale dès le chargement du code, au lieu que vous découvriez des mois plus tard qu’un rabais ne s’est jamais appliqué. Rien que pour cela, il vaut le détour.
Composer des règles
Les schémas peuvent importer d’autres schémas avec import :tax, from: TaxPolicy. Une règle qui ne connaît qu’un seul montant peut alors être appliquée à chaque article d’une commande, et le compilateur l’intègre directement dans le code généré. La documentation sur les imports de schémas montre comment cela fonctionne.
Bon à savoir
- Jeune et en évolution rapide. Kumi en est à la version 0.1.0 et le README qualifie l’API d’expérimentale : fixez donc la version dans votre Gemfile. Le changelog montre des progrès réguliers : les versions récentes ont apporté un nouveau pipeline de compilation avec fusion de boucles et un parseur réécrit avec des messages d’erreur clairs.
- Arrondi. Il n’existe pas encore de fonction
round, mais l’astuceto_integerci-dessus fait l’affaire pour les montants positifs et se comporte de la même façon en Ruby et en JavaScript. - Argent. Les résultats sont des nombres à virgule flottante ; pour les prix, j’arrondis donc à la fin, comme dans l’exemple.
L’essayer dans le navigateur
Le moyen le plus rapide de se faire une idée de Kumi est le playground interactif. Il propose une introduction au langage et des exemples plus conséquents, comme un calculateur d’impôts américain et un calcul de salaires sur 10 000 employés.
Kumi convient parfaitement partout où le même calcul doit tourner sur le serveur et dans le navigateur : calculateurs de prix, configurateurs d’offres, frais de port. Un schéma, deux langages, des résultats identiques. Je me réjouis de l’utiliser dans mes propres projets et de voir comment il évoluera.