Kumi: Berechnungslogik in Ruby, die auch in JavaScript läuft
Viele meiner Projekte sind Webshops und Abrechnungssysteme. Dabei taucht immer wieder dasselbe Problem auf: Die Preislogik lebt auf dem Server, aber die Kundin möchte den Gesamtbetrag schon im Browser sehen, während sie die Mengen ändert. Also werden die Regeln zweimal geschrieben, einmal in Ruby und einmal in JavaScript, und früher oder später rechnen die beiden Versionen unterschiedlich.
Kumi von André Muta ist ein Ruby-Gem, das dieses Problem ernst nimmt. Es ist eine deklarative DSL für Berechnungslogik wie Steuerregeln, Preise und Bewertungen, die aus einem einzigen Schema gewöhnlichen Ruby- und JavaScript-Code erzeugt. Ich habe es ausprobiert, und es gefällt mir sehr.
Was Kumi macht
Sie beschreiben die Form Ihrer Eingabedaten und die Werte, die berechnet werden sollen. Schleifen und die Reihenfolge der Berechnung schreiben Sie nicht selbst. Der Compiler ermittelt die Abhängigkeiten, prüft die Typen und erzeugt für jede Zielsprache geradlinigen Code ohne Abhängigkeiten.
Zwei Ideen unterscheiden Kumi von gewöhnlichen Ruby-Methoden:
- Broadcasting anhand der Datenform. Ist eine Eingabe ein Array von Positionen, wird ein Ausdruck wie
quantity * unit_priceautomatisch pro Position berechnet. Eine Funktion wiefn(:sum, ...)fasst das Ergebnis wieder zu einem einzelnen Wert zusammen. Das funktioniert auch mit verschachtelten Arrays. - Prüfungen beim Definieren des Schemas. Typfehler, zirkuläre Abhängigkeiten und Bedingungen, die nie wahr werden können, werden gemeldet, wenn das Schema geladen wird, und nicht erst, wenn eine Kundin in der Produktion diesen Fall auslöst.
Installation:
gem install kumi
Kumi braucht Ruby 3.1 oder neuer und steht unter der MIT-Lizenz. Die aktuelle Version ist 0.1.0 vom Juni 2026. Das README bezeichnet das Projekt als experimentell: Die öffentliche API kann sich noch ändern.
Ein Beispiel: eine Schweizer Rechnung
Zum Ausprobieren habe ich eine kleine Rechnung geschrieben. Positionen ab 10 Stück erhalten 10% Mengenrabatt, dazu kommen 8,1% Mehrwertsteuer, und der Gesamtbetrag wird auf 5 Rappen gerundet, wie in der Schweiz bei Barbeträgen üblich.
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
Ein paar Dinge fallen auf:
traitdeklariert eine Bedingung. Weil sie sich aufinput.items.item.quantitybezieht, wird sie pro Position ausgewertet.letist ein Zwischenwert,valueein Ergebnis, das Sie aus dem Resultat lesen können.select(bedingung, wenn_wahr, wenn_falsch)wählt pro Position aus. Für mehr Fälle gibt es eine Kaskade mitvalue ... do on ... base ... end.- Es gibt nirgends eine Schleife, trotzdem ist
line_totalsein Array mit einem Eintrag pro Position undsubtotaleine einzelne Zahl.
Dieselbe Logik in JavaScript
Dieser Teil interessiert mich für Webshops am meisten. Ein einziger Aufruf schreibt ein JavaScript-Modul:
Invoice.write_source("invoice.mjs", platform: :javascript)
Jedes value, let und trait wird zu einer exportierten Funktion. So sieht das erzeugte _total aus, unverändert:
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;
}
Genau so wünsche ich mir generierten Code: eine einzige Schleife mit Rabatt, Summe, Mehrwertsteuer und Rundung, ohne Bibliothek und ohne Interpreter dahinter. Man kann ihn lesen, im Debugger durchgehen und so ausliefern, wie er ist. Ich habe das Modul mit Node.js auf denselben Daten ausgeführt und genau dieselben Ergebnisse wie in Ruby erhalten: [100, 108], 208, 16.848 und 224.85.
Kumi in ruby.wasm
Ich arbeite viel mit ruby.wasm, zum Beispiel für die Ruby Koans im Browser. Deshalb wollte ich wissen, ob Kumi selbst auch in einem nach WebAssembly kompilierten Ruby läuft. Das tut es, mit ein paar Zeilen Vorbereitung:
# 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 begrenzt jeden Compiler-Durchgang mit einem Zeitlimit über Timeout. Das startet einen Thread, und ruby.wasm kennt keine Threads. Die Umgebungsvariable schaltet das Limit ab. Ausserdem schreibt Kumi den erzeugten Ruby-Code in ein Cache-Verzeichnis, bevor es ihn lädt, und im In-Memory-Dateisystem des Browsers findet Ruby von selbst kein temporäres Verzeichnis.
Mit dieser Vorbereitung liefert das Rechnungsbeispiel oben in ruby.wasm genau dieselben Ergebnisse, bis zu den 224.85. Getestet habe ich das mit ruby.wasm 2.10.1 (Ruby 4.0) in Node.js, mit demselben In-Memory-Dateisystem, das ruby.wasm im Browser verwendet. Kumi ist reines Ruby, und seine Abhängigkeiten zeitwerk und mutex_m laden ohne Änderungen.
Damit gibt es zwei Wege, Ihre Regeln in den Browser zu bringen: der Export nach JavaScript, der schlankste Weg, oder, wenn Ihr Frontend ohnehin schon Ruby in WebAssembly ausführt, Kumi-Schemas direkt dort definieren und ausführen.
Fehler werden früh erkannt
Kumi analysiert Bedingungen schon beim Definieren des Schemas. Wenn Sie festlegen, dass ein Alter zwischen 0 und 150 liegt, und danach eine Bedingung schreiben, die nie wahr sein kann, schlägt das Laden des Schemas fehl:
schema do
input { integer :age, domain: 0..150 }
trait :impossible_age, input.age == 200
end
# Kumi::Core::Errors::SemanticError: conjunction `impossible_age` is impossible
Bei Geschäftsregeln ist so eine Bedingung meist ein Tippfehler oder ein Missverständnis der Anforderungen. Kumi meldet das sofort beim Laden des Codes, statt dass Sie Monate später merken, dass ein Rabatt nie gegriffen hat. Schon das allein macht Kumi interessant.
Regeln zusammensetzen
Schemas können andere Schemas mit import :tax, from: TaxPolicy einbinden. Eine Regel, die nur einen einzelnen Betrag kennt, lässt sich dann auf jede Position einer Bestellung anwenden, und der Compiler baut sie direkt in den erzeugten Code ein. Die Dokumentation zu Schema-Imports (Englisch) zeigt, wie das funktioniert.
Gut zu wissen
- Jung und in schneller Entwicklung. Kumi steht bei Version 0.1.0, und das README bezeichnet die API als experimentell. Fixieren Sie deshalb die Version im Gemfile. Das Changelog zeigt stetige Fortschritte: Die letzten Versionen brachten eine neue Compiler-Pipeline mit Loop Fusion und einen neu geschriebenen Parser mit klaren Fehlermeldungen.
- Runden. Eine
round-Funktion gibt es noch nicht, aber der Trick mitto_integeroben erledigt das für positive Beträge und verhält sich in Ruby und JavaScript gleich. - Geldbeträge. Die Ergebnisse sind Gleitkommazahlen. Bei Preisen runde ich deshalb am Schluss, wie im Beispiel.
Im Browser ausprobieren
Am schnellsten bekommen Sie ein Gefühl für Kumi im interaktiven Playground. Dort gibt es eine Einführung in die Sprache und grössere Beispiele wie einen US-Steuerrechner und eine Lohnabrechnung über 10’000 Angestellte.
Kumi passt perfekt überall dort, wo dieselbe Berechnung auf dem Server und im Browser laufen muss: Preisrechner, Offertkonfiguratoren, Versandkosten. Ein Schema, zwei Sprachen, identische Ergebnisse. Ich freue mich darauf, es in eigenen Projekten einzusetzen, und bin gespannt, wie es weitergeht.