← Articles

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 :

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 :

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

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.

Liens