Calculations in Ruby
Define calculations, rule sets and steps in plain Ruby, load and run blueprints from code, and slice time with Interval.
Licensed on request
The engine (the Ruby gem that runs blueprints in your own process, with its local CLI, Studio and the Rails integration) is not published. It is licensed on request: email rhys117@gmail.com to ask about a license.
After reading this guide, you will know:
- How to declare a
Calculationand aRuleSetin a class body, or build one at run time. - What a
Resultholds, and how to read its trace. - How to load, check, run and explain a blueprint from Ruby.
- How
Reckoning::Intervalsplits time.
require 'reckoning' loads the engine, blueprints and CLI. It is plain Ruby with no Rails.
Calculations
Subclass Reckoning::Calculation and declare steps in the class body. Steps run in the order they are
declared, and each one sees the facts and every earlier step by name:
class Conditions < Reckoning::RuleSet
pick :all
rule 'Major wound', condition: 'damage >= max_hp / 2', value: '"major_wound"'
rule 'Down', condition: 'hp_after = 0'
end
class Wound < Reckoning::Calculation
step :hp_after, formula('MAX(0, hp - damage)')
step :conditions, Conditions.new
end
result = Wound.call(hp: 12, max_hp: 12, damage: 7)
result[:hp_after] # => 5
result[:conditions] # => {"Major wound" => "major_wound"}
result.slice(:hp_after, :conditions) # => {hp_after: 5, conditions: {"Major wound" => "major_wound"}}
A rule without a value yields its name, as in a blueprint.
Building at Run Time
new yields a builder, which keeps the block’s own self, so rules and steps can come from the
caller’s data:
conditions = Reckoning::RuleSet.new(pick: :all) do |rules|
house_rules.each { |house_rule| rules.rule house_rule.name, condition: house_rule.condition, value: house_rule.label }
end
conditions.call(damage: 7).value # => {"Bleeding" => "bleeding"}
A builder on a subclass starts from the steps or rules the class declares.
The step helpers are the blueprint step types in Ruby:
line = Reckoning::Calculation.new do |c|
c.step :total, c.formula('price * qty')
end
order = Reckoning::Calculation.new do |c|
c.step :price, c.lookup(prices, where: {plan: :plan}, value: :price, on: :renews_on)
c.step :lines, c.run(line, over: :items)
c.step :sum_of_lines, c.sum(:total, of: :lines)
c.step :best, c.compare(:price, :sum_of_lines, pick: :highest)
end
result = order.call(plan: 'basic', renews_on: Date.new(2026, 11, 2), items: [{qty: 2}, {qty: 3}])
result[:price] # => 12.5
result[:sum_of_lines] # => 62.5
result[:lines].map { _1[:total] } # => [25.0, 37.5]
| Helper | Step |
|---|---|
formula(expression) |
the expression’s value |
a RuleSet |
pick :first, :highest or :all |
lookup(rows, where:, value:, on:, band:) |
a row of a table, or of the step named by a symbol |
run(calculation), run(calculation, over:, with:, position:, carry:, start:, earlier:, update:) |
a nested Result, or one per item |
sum(output, of:) |
one output added up across a run |
group(of, by:, sum:) |
one row per distinct by combination |
compare(*steps, pick:) |
the picked step’s value |
A step is anything that answers call(values) with a Reckoning::Outcome.new(value:, detail:), so a
pack can add its own in plain Ruby.
Results and the Trace
A Result holds every value and the trace. result[name] fetches a value, slice takes several, and
to_hash gives them all. Each trace entry has the step, its value, and a detail that says how it was
reached:
result = Wound.call(hp: 12, max_hp: 12, damage: 7)
result.trace.each { |entry| puts "#{entry.step}: #{entry.value.inspect} (#{entry.detail.class})" }
# hp_after: 5 (String)
# conditions: {"Major wound" => "major_wound"} (Reckoning::Pick::Choice)
result.trace.last.detail.chosen # => ["Major wound"]
| Step | Detail |
|---|---|
| formula | the expression |
RuleSet |
Pick::Choice with the chosen rule or rules, every match, and checks: each rule’s condition, whether it matched and the facts it read |
| lookup | the matching row |
| run over a list, sum, group | the step or list it worked over |
| compare | Pick::Choice |
Blueprints from Ruby
blueprint = Reckoning::Blueprint.load(<<~YAML)
calculations:
double:
inputs: {x: {type: number}}
steps:
- {name: y, formula: x * 2}
outputs: [y]
YAML
blueprint.call({x: 2.5}) # => {y: 5.0}
blueprint.explain({x: 2.5}) # => {outputs: {y: 5.0}, steps: [{step: :y, value: 5.0, formula: "x * 2"}]}
blueprint.inputs # => {"double" => {"x" => {type: "number"}}}
blueprint.fact_problems({x: 'two'}) # => ["fact x should be a number, not \"two\""]
| Method | Does |
|---|---|
Blueprint.load_file(path, resolve:) |
loads a file; imports by path are read relative to it |
Blueprint.load(yaml, resolve:) |
loads text; imports by name only |
Blueprint.check_file(path), Blueprint.check(yaml) |
the problems reckoning check prints, as a list |
Blueprint.shipped(:name) |
a shipped blueprint, from any shelf |
Blueprint.shipped_names |
the names on every shelf |
Blueprint.read(yaml) |
YAML read the way blueprints are, such as a cases file |
#call(facts, calculation:) |
the outputs |
#explain(facts, calculation:) |
{outputs:, steps:}, the trace the CLI prints with --trace |
#fact_problems(facts, calculation:) |
the problems with some facts, without running |
#inputs |
each calculation’s inputs, for building a form |
Blueprint::Tester.new(blueprint).call(cases) |
{passed:, failed:, cases:}, as reckoning test |
call and explain check the facts first and raise Reckoning::Blueprint::Refused with their
problems. A step that fails while running raises Reckoning::Blueprint::StepFailed, naming the path of
steps. Both are a Reckoning::Blueprint::Failure with problems:
begin
blueprint.call({x: 'two'})
rescue Reckoning::Blueprint::Failure => e
e.class # => Reckoning::Blueprint::Refused
e.problems # => ["fact x should be a number, not \"two\""]
end
resolve: is a lambda from an import name to a Reckoning::Blueprint::Source (or nil when it does
not know the name). The default resolves shipped blueprints, Reckoning::Blueprint::SHIPPED. A host
that stores blueprints passes its own, as the hosted service does to import a workspace’s definitions.
Time
Reckoning::Interval slices time, for rules that change at clock hours, rests and caps:
at = -> (hour, minute = 0) { Time.new(2026, 7, 1, hour, minute, 0, '+10:00') }
Reckoning::Interval.new(at.call(16), at.call(22)).split_daily_at([0, 7, 18, 21])
# 16:00-18:00 120m, 18:00-21:00 180m, 21:00-22:00 60m
Reckoning::Interval.new(at.call(11), at.call(21)).without([Reckoning::Interval.new(at.call(13), at.call(13, 30))])
# 11:00-13:00, 13:30-21:00
before, after = Reckoning::Interval.split_after(
[Reckoning::Interval.new(at.call(7), at.call(9)), Reckoning::Interval.new(at.call(9), at.call(18)), Reckoning::Interval.new(at.call(18), at.call(20))],
9 * 60
)
# before: 07:00-09:00, 09:00-16:00
# after: 16:00-18:00, 18:00-20:00
#split_at(times)and#split_daily_at(hours)cut at fixed times, or the same clock hours every day.#without(intervals)removes covered parts, such as rests.Interval.split_after(intervals, minutes)splits a run of intervals once their minutes reach a limit, returning[before, after]. Daily and weekly caps, and tiers past them, are built from it.#minutesis the elapsed minutes. A daylight saving change inside an interval is not adjusted for.
Blueprints cannot split time yet. Wrap an interval in a custom step to use it from one.
Failures in Ruby
| Failure | Raised as | Seen as |
|---|---|---|
| A blueprint with problems | Blueprint.check / check_file return them |
reckoning check, exit 2 |
| Facts that do not match the inputs | Blueprint::Refused#problems, the same list as Blueprint#fact_problems |
reckoning run, exit 1 |
| A step failing, including a bad row of a list | Blueprint::StepFailed, with the path of steps |
"fight > rounds > round > exchanged: ...", exit 1 |
| No matching table row | Reckoning::Lookup::NotFound, inside a StepFailed from a blueprint |
the step’s error |
| A case that fails | an entry in Tester#call’s cases |
reckoning test, exit 3 |
| A pack definition with problems | Reckoning::InvalidDefinition#errors |
Studio’s problem list |
Refused and StepFailed are both a Reckoning::Blueprint::Failure with problems, so one rescue
covers a run:
begin
blueprint.call(facts)
rescue Reckoning::Blueprint::Failure => e
e.problems
end
A formula that divides by zero fails its step: d > y: divides by zero. Guard divisors that
can be zero with IF.
Cases in Your Own Specs
Keep the worked examples as the contract in an application’s specs, so a change to the rules that breaks a hand-worked case fails the build:
it 'passes its check and every worked example' do
directory = Reckoning::Blueprint.shipped_directory('sales_commission')
path = File.join(directory, 'blueprint.yml')
cases = Reckoning::Blueprint.read(File.read(File.join(directory, 'cases.yml')))
expect(Reckoning::Blueprint.check_file(path)).to eq([])
expect(Reckoning::Blueprint::Tester.new(Reckoning::Blueprint.load_file(path)).call(cases)).to include(failed: 0)
end
Tester#call returns the same {passed:, failed:, cases:} the CLI prints.
Crossing Between Ruby and a Blueprint
A chain can cross between Ruby and a blueprint wherever one side’s output is the other’s input. A blueprint’s outputs are plain hashes any Ruby can read, and a pack’s Ruby mechanics can hand rows to a shipped blueprint as facts. The sample app’s keeper’s screen stores what happened at the table and hands it to the shipped blueprints on every page.
Next Steps
- Custom Steps - Add a step type blueprints can use.
- Running the Engine in a Rails App - Run blueprints from your models and services.
- Exact Numbers, Dates and Errors - What comes back, and what can fail.