skip to content

In Ruby, a franchise report totals Float sale amounts with inject(:+) in one service and sum in another, and the totals differ in the last digits; why, and what do you change?

level: seniorimportance: should knowfreq 35%

answer

  1. same data, different algorithm
  2. sum compensates rounding error
  3. Kahan-Babuska summation
  4. 0.6 vs 0.6000000000000001
  5. cents as Integer, not Float

basics

~20 s

sum adds Floats with Kahan-Babuska compensated summation, which tracks and corrects rounding error; inject(:+) adds naively, so errors accumulate. Use sum everywhere for consistency, and store money as Integer cents or Rational so no Float rounding enters the total.

solid answer

~40 s

`Array#sum` and `Enumerable#sum` switch to the **Kahan-Babuska** compensated summation algorithm once they meet a `Float`: they carry a running correction for the low-order bits lost at each addition. `inject(:+)` simply calls `Float#+` step by step, so the rounding error of each addition accumulates. That is why `[0.1, 0.2, 0.3].sum` is `0.6` while `inject(:+)` gives `0.6000000000000001`. The immediate fix is to use `sum` (with a block: `sales.sum(&:amount)`) in both services so they agree. The real fix is not to total money in `Float` at all: keep amounts as **Integer cents**, or `Rational`, and format at the edge. `BigDecimal` also works, but in Ruby 4.0 it is a bundled gem, so under Bundler it must be listed in the Gemfile.

code

ruby · 13 lines
ruby
amounts = [0.1, 0.2, 0.3]
amounts.inject(:+)       # => 0.6000000000000001
amounts.sum              # => 0.6

([0.1] * 10).inject(:+)  # => 0.9999999999999999
([0.1] * 10).sum         # => 1.0

cents = ["12.30", "7.15"].map { |s| (s.to_r * 100).to_i }  # => [1230, 715]
cents.sum                # => 1945
format("%.2f", cents.sum / 100r)  # => "19.45"

["a", "b"].sum            # TypeError: String can't be coerced into Integer
["a", "b"].sum("")        # => "ab"

go deeper

for a junior

Recall that sum adds up numbers, returns 0 for an empty list, and that Float amounts are approximations.

for a middle

Explain that sum uses compensated summation for floats while inject(:+) accumulates rounding error, and how the initial value affects types.

for a senior

Diagnose reconciliation drift by comparing summation algorithms and where floats enter, then move money to Integer cents or Rational.

for a principal

Set a money-representation policy across services, since consistent algorithms only mask the problem while Float amounts cross boundaries.

## The symptom Two services produce the same franchise report from the same sales rows. One totals with `amounts.inject(:+)`, the other with `amounts.sum`. The totals agree for most regions, but for some they differ in the last printed digit, and reconciliation flags them. Nothing is "wrong" with either line of code in isolation; they use **different addition algorithms**. ## What `inject(:+)` does `inject(:+)` calls `Float#+` once per element, left to right. Each `Float` is an IEEE 754 double, and most decimal amounts, such as `0.1`, have no exact binary representation. Every addition rounds its result to the nearest double, and those small rounding errors **accumulate** across thousands of rows. ```ruby [0.1, 0.2, 0.3].inject(:+) # => 0.6000000000000001 ([0.1] * 10).inject(:+) # => 0.9999999999999999 ``` ## What `sum` does differently `sum` is not `inject(:+)` with a default. In Ruby's `array.c` and `enum.c`, once the running total meets a `Float`, `sum` switches to the **Kahan-Babuska balancing compensated summation** algorithm: 1. It keeps the running total and a separate **compensation** term. 2. After each addition it computes how much precision the rounding lost. 3. It carries that lost amount forward and adds it back at the end. The result is much closer to the exact sum of the given doubles: ```ruby [0.1, 0.2, 0.3].sum # => 0.6 ([0.1] * 10).sum # => 1.0 ``` The block form, `sales.sum(&:amount)`, uses the same algorithm on the block's results. The rdoc also warns that `sum` may not call a redefined `+` on core numeric classes, because it uses these optimised paths. ## What to change The fix has two levels: - **Consistency now.** Use `sum` in both services so they use one algorithm and agree. Where the list may be empty, `sum` also returns `0` rather than the `nil` that `inject(:+)` gives. - **Correctness properly.** Compensated summation reduces error; it does **not** make `Float` exact. Money should not be a `Float` in the first place. | Representation | Exact for cents? | Notes | |---|---|---| | `Float` | no | fast, but every decimal amount is approximated | | `Integer` cents | yes | the usual choice; format as currency only for display | | `Rational` | yes | core class, exact arithmetic, slower | | `BigDecimal` | yes, decimal | bundled gem in Ruby 4.0; needs a Gemfile entry under Bundler | ## The initial value `sum(init = 0)` starts from its argument. That matters in two ways: - The type of the result follows the arithmetic: `[1, 2].sum(0.0)` is `3.0`. - For non-numeric elements an initial value is required: `["a", "b"].sum` raises `TypeError` (String can't be coerced into Integer), while `["a", "b"].sum("")` works. The rdoc notes that `join` is faster for strings and `flatten` for arrays of arrays. ## Diagnosing it in production - Compare the two code paths for the **algorithm**, not only the inputs; `inject(:+)`, a manual `each` with `+=`, and `sum` can all disagree on floats. - Look for **where** Floats enter: parsing `"12.30"` with `to_f` is the usual source, and converting at parse time to integer cents removes the problem downstream. - Add a reconciliation test that totals a known list of awkward amounts, such as ten `0.1`s, through each service. ## Integer cents in practice Moving a report to integer cents is mostly a change at the **boundaries**: - **Parse exactly.** Convert the incoming decimal string without passing through `Float`: `"12.30".to_r` is the exact Rational `123/10`, and multiplying by 100 gives exactly `1230`. - **Add integers.** `cents.sum` is exact, however many rows there are, and every service computes the same result. - **Format at the edge.** Divide only for display, for example `format("%.2f", cents / 100r)`, or split with `cents.divmod(100)` into units and cents. The reconciliation drift disappears because there is no rounding left to accumulate, not because a cleverer algorithm hides it. ## What the interviewer wants They want the mechanism, compensated summation in `sum` versus naive accumulation in `inject(:+)`, and the judgement that the lasting fix is a money representation, not a cleverer float loop.

  • Does compensated summation make Float totals exact?
    No. It removes most of the error that accumulates across many additions, but each Float input is still an approximation of its decimal amount, and the final result is still a double. For money, store exact values, such as Integer cents or Rational, and treat Float only as a display or statistics format.
  • Why does ["a", "b"].sum raise but ["a", "b"].sum("") work?
    `sum` starts from its initial value, which defaults to the Integer 0, and `0 + "a"` raises `TypeError` because a String cannot be coerced into an Integer. Passing `""` makes the first addition `"" + "a"`. For strings, `join` is the clearer and faster choice.

saying these in an interview costs you the question

  • sum is just inject(:+) with a starting value of 0
  • Kahan summation makes Float money totals exact
  • The two services must be reading different input data
  • BigDecimal can be required in a Bundler app without a Gemfile entry
  • ["a", "b"].sum concatenates the strings