Kumi: logica di calcolo in Ruby che gira anche in JavaScript
Molti dei miei progetti sono webshop e sistemi di fatturazione. In ognuno di essi si ripresenta lo stesso problema: la logica dei prezzi vive sul server, ma il cliente vuole vedere il totale nel browser mentre modifica le quantità. Così le regole vengono scritte due volte, una in Ruby e una in JavaScript, e prima o poi le due versioni divergono.
Kumi di André Muta è una gem Ruby che prende sul serio questo problema. È un DSL dichiarativo per la logica di calcolo, come regole fiscali, prezzi e punteggi, che da un unico schema compila in Ruby semplice e in JavaScript semplice. L’ho provato, e quello che vedo mi piace molto.
Cosa fa Kumi
Descrivete la forma dei dati in ingresso e i valori che volete calcolare. Non scrivete cicli né decidete l’ordine di valutazione. Il compilatore ricava le dipendenze, controlla i tipi e genera per ogni target codice lineare, senza dipendenze.
Due idee lo distinguono dallo scrivere semplici metodi Ruby:
- Broadcasting a partire dalla forma dei dati. Se un input è un array di articoli, un’espressione come
quantity * unit_priceviene calcolata automaticamente per ogni articolo. Una funzione comefn(:sum, ...)la riduce di nuovo a un singolo valore. Funziona anche con array annidati. - Controlli alla definizione dello schema. Errori di tipo, cicli di dipendenze e condizioni che non possono mai essere vere vengono segnalati al caricamento dello schema, non quando un cliente percorre quel ramo di codice in produzione.
Si installa con:
gem install kumi
Richiede Ruby 3.1 o successivo ed è sotto licenza MIT. La versione attuale è la 0.1.0 del giugno 2026, e il README descrive il progetto come sperimentale: l’API pubblica può ancora cambiare.
Un esempio: una fattura svizzera
Per provarlo ho scritto una piccola fattura. Gli articoli con quantità pari o superiore a 10 ricevono uno sconto quantità del 10%, si aggiunge l’IVA svizzera dell’8,1% e il totale viene arrotondato ai 5 centesimi, come d’uso per gli importi in contanti in Svizzera.
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
Alcune cose da notare:
traitdichiara una condizione booleana. Poiché si riferisce ainput.items.item.quantity, viene valutata per ogni articolo.letè un valore intermedio,valueè un output che potete leggere dal risultato.select(condition, if_true, if_false)sceglie per ogni articolo. Per più casi esiste una cascatavalue ... do on ... base ... end.- Non c’è alcun ciclo, eppure
line_totalsè un array con una voce per articolo, esubtotalè un singolo numero.
La stessa logica in JavaScript
Questa è la parte che più mi interessa per i webshop. Una sola chiamata scrive un modulo JavaScript:
Invoice.write_source("invoice.mjs", platform: :javascript)
Ogni value, let e trait diventa una funzione esportata. Questa è la _total generata, senza modifiche:
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;
}
È esattamente ciò che voglio dal codice generato: un unico ciclo che calcola sconto, somma, IVA e arrotondamento, senza librerie né interpreti dietro. Potete leggerlo, seguirlo passo passo nel debugger e distribuirlo così com’è. Ho eseguito il modulo con Node.js sullo stesso input e ho ottenuto esattamente gli stessi risultati di Ruby: [100, 108], 208, 16.848 e 224.85.
Kumi in ruby.wasm
Lavoro molto con ruby.wasm, per esempio per i Ruby Koans nel browser, quindi volevo sapere se anche Kumi stesso gira dentro Ruby compilato in WebAssembly. Sì, con poche righe di configurazione:
# 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 protegge ogni passo del compilatore con un limite di tempo basato su Timeout, che avvia un thread, e ruby.wasm non ha thread. La variabile d’ambiente disattiva il limite. Kumi inoltre scrive il codice Ruby generato in una directory di cache prima di caricarlo, e nel file system in memoria del browser Ruby non trova da solo una directory temporanea.
Con questa configurazione, l’esempio di fattura qui sopra dà esattamente gli stessi risultati in ruby.wasm, fino al 224.85. L’ho testato con ruby.wasm 2.10.1 (Ruby 4.0) in Node.js, usando lo stesso file system in memoria che ruby.wasm usa nel browser. Kumi è puro Ruby, e le sue dipendenze zeitwerk e mutex_m si caricano senza alcuna modifica.
Avete quindi due modi per portare le vostre regole nel browser: esportarle in JavaScript, l’opzione più leggera, oppure, se il vostro frontend esegue già Ruby in WebAssembly, definire ed eseguire gli schemi Kumi direttamente lì.
Gli errori vengono individuati presto
Kumi analizza le condizioni alla definizione dello schema. Se dichiarate che un’età è compresa tra 0 e 150 e poi scrivete una condizione che non può mai essere vera, il caricamento dello schema fallisce:
schema do
input { integer :age, domain: 0..150 }
trait :impossible_age, input.age == 200
end
# Kumi::Core::Errors::SemanticError: conjunction `impossible_age` is impossible
Nelle regole di business, una condizione del genere è di solito un refuso o un malinteso sui requisiti. Kumi ve lo dice nel momento in cui il codice viene caricato, invece di farvi scoprire mesi dopo che uno sconto non è mai stato applicato. Già solo questo vale uno sguardo.
Comporre le regole
Gli schemi possono importare altri schemi con import :tax, from: TaxPolicy. Una regola che conosce un solo importo può così essere applicata a ogni articolo di un ordine, e il compilatore la integra inline nel codice generato. La documentazione sugli import di schemi mostra come funziona.
Buono a sapersi
- Giovane e in rapida evoluzione. Kumi è alla versione 0.1.0 e il README indica l’API come sperimentale, quindi fissate la versione nel Gemfile. Il changelog mostra progressi costanti: le release recenti hanno portato una nuova pipeline del compilatore con loop fusion e un parser riscritto con messaggi d’errore chiari.
- Arrotondamento. Non esiste ancora una funzione
round, ma il trucco conto_integerqui sopra fa il suo lavoro per importi positivi e si comporta allo stesso modo in Ruby e JavaScript. - Denaro. I risultati sono numeri in virgola mobile, quindi per i prezzi arrotondo alla fine, come nell’esempio.
Provatelo nel browser
Il modo più rapido per farsi un’idea di Kumi è il playground interattivo. Contiene un’introduzione al linguaggio ed esempi più ampi, come un calcolatore delle imposte USA e un calcolo dei salari su 10’000 dipendenti.
Kumi è perfetto ovunque lo stesso calcolo debba girare sul server e nel browser: calcolatori di prezzo, configuratori di offerte, spese di spedizione. Uno schema, due linguaggi, risultati identici. Non vedo l’ora di usarlo nei miei progetti e di vedere dove andrà.