Roger Mendoza

The timer is the source of truth — designing deterministic solve statistics

Why SolveBlock never re-times a solve, how we compute averages with integer math, and what "deterministic" really means for a tiny embedded device.

SolveBlock's round-complete screen showing five solves, the trims and the Ao5

There's one design rule in SolveBlock that everything else bends around: the external timer is the source of truth.

Speedcubing timers are dedicated, well-understood devices. Competitors trust them. If our device measured the solve itself — starting its own clock when the timer starts — we would eventually disagree with the timer by a few milliseconds. And the moment a user sees two different numbers for the same solve, they stop trusting both.

So SolveBlock listens. It reads the time the timer reports and treats that value as final.

Single source of truth is an old idea

In enterprise systems we called this "system of record." The EMR at a hospital owns the patient chart; everything else is a view. Directory services own identities; applications ask. The pattern is the same on a tiny board: decide who owns the data, and make everyone else a reader.

For SolveBlock, that gives us a clean split:

  • The timer owns the time of each solve.
  • SolveBlock owns penalties the user applies, session grouping and statistics.

What "deterministic" means here

Given the same list of solves and penalties, SolveBlock must always produce exactly the same statistics — on any unit, after any reboot, in any order of recomputation. That rules out a few tempting shortcuts.

Integer time, always

Times are stored as integer milliseconds. Floating-point math is fine for graphics; it's a bad idea for numbers people will compare digit by digit. 0.1 + 0.2 is not 0.3 in binary floating point, and you don't want that argument with a speedcuber.

Define the rules before writing code

Averages in cubing follow specific conventions. SolveBlock supports Ao5, Ao12, Mo3 and rolling Ao50 and Ao100, and there's no "precision" setting, because there's only one right answer. For an average of 5 (ao5):

  1. Take the last five solves, with penalties applied (+2 adds two seconds).
  2. Drop the single best and single worst.
  3. Take the mean of the middle three.

A DNF (did not finish) counts as the worst result. One DNF gets dropped; two DNFs in an ao5 make the whole average a DNF. Writing those rules down as plain English first made the code nearly mechanical:

// times in ms; DNF encoded as INT32_MAX
int32_t ao5(const int32_t t[5]) {
    int32_t s[5];
    memcpy(s, t, sizeof s);
    sort_ascending(s, 5);
    if (s[3] == INT32_MAX) return INT32_MAX;   // two or more DNFs
    int64_t sum = (int64_t)s[1] + s[2] + s[3];
    return (int32_t)((sum + 1) / 3);           // round to nearest, once
}

Truncate singles, round averages once

The rulebook treats the two differently, and so does SolveBlock. A single is recorded truncated to the hundredth: when the timer says 4.473, the official single is 4.47 — not 4.48, and not 4.473. An average is computed from those values and rounded to the nearest hundredth.

Rounding in the middle of a calculation is how two implementations drift apart. We keep full integer precision through every step and round exactly once, at the point where the average is produced.

Recompute instead of patch

When a user edits a penalty on an old solve, it's tempting to "adjust" the running statistics. We don't. We recompute from the stored solves. On a microcontroller that sounds expensive, but session sizes are small and the math is integer adds. Recomputing is fast, and it's always correct, which matters more than being clever.

Test against a reference

The most useful tool for this is a tiny Python reference implementation: it takes a list of solves and prints the expected stats. Generate lots of random sessions — including nasty ones full of DNFs and +2s — and compare the firmware's output to the reference over serial. Any mismatch is a bug in one of them, and the diff tells you exactly which solve caused it.

That's also where AI-assisted tooling earned its keep: generating edge-case test sessions and harness code quickly. But the rules came from the conventions, and the truth came from running both implementations side by side.

The takeaway

Trust is a feature. Users never see "integer milliseconds" or "round once," but they feel it: the numbers always match, and they never change on their own. On an embedded device, determinism is one of the cheapest features you can ship — as long as you decide on it before the first line of code.

Some links in these notes are affiliate links. If you buy through one, I may earn a commission at no extra cost to you. I only link to tools I use or would recommend.