Skip to content

Extraction · written up July 2026

ExtractLab: a shot logbook and the recommender it has to earn

Espresso is a control problem with one slow, noisy sensor. You move the grinder a click, pull a shot, and the shot itself is the entire reading. ExtractLab logs every one of them and then asks whether the log is good enough to recommend the next click.

The question

What does a grind recommendation have to survive before it counts as advice, when the only judge is the shot that follows it?

15s 20s 30s 45s 60s 1011121314 5 trusted 16 refused pooled R² 0.0009 grind setting, 1Zpresso J-Ultra
All 442 archived shots that carry both a grind setting and a shot time, 2026-02-20 to 2026-09-04, with one ordinary least-squares fit of log(time) against setting per bean, for each of the 21 beans with at least 4 shots. Solid lines clear the gate at d1Recommender.ts:378; faint lines fail it and each is drawn only across the settings that bean was pulled at. 3 shots fall outside these axes and are inside every fit. Nothing is smoothed and no shot is excluded.

The log is 448 shots, 2026-02-20 to 2026-09-04, archived as append-only YAML with corrections written as new records. No record names a second grinder or a second machine: 1 grinder is named, on 447 of the shots, and 1 machine, on 429, with the remainder simply omitting the key rather than naming another. 442 shots carry both a grind setting and a shot time, which is what a recommendation needs. That is the whole instrument: move the grinder, pull, read the clock.

A finished latte in a glass on a countertop, seen from above, with poured leaf latte art.
A finished drink.

The gate

The recommender fits log(time) against grind setting by weighted least squares and inverts it for the setting that should hit the target time. Four conditions decide whether it is allowed to speak. The settings behind the fit have to span at least 0.2, or the slope is meaningless and the function returns nothing (d1Recommender.ts:349). Then, at line 378, three more:

const trustworthy = rightSign && data.length >= 6 && r2 >= 0.3;

A finer grind has to read as a slower shot, there have to be at least 6 shots, and the fit has to explain at least 30 per cent of the variance in log time. That is the answer to what a recommendation has to survive, and it is a low bar on purpose: the comment above it says a high R² wrongly excluded models whose predicted setting was accurate to about a tenth of a click.

Run that gate over the archive and it is a strict bar anyway. 72 beans appear in the log. 19 reach 6 shots. 5 clear the gate, 26 per cent of the ones eligible to try and 7 per cent of the beans in the log:

  • Kirkland Signature, French Roast, n = 27, R² = 0.393, slope -0.173 per setting unit over a span of 3.0
  • Stolen Church, Ethiopia Medium, n = 21, R² = 0.598, slope -0.628 per setting unit over a span of 1.6
  • Devils Head Coffee, Cascadia Decaf, n = 19, R² = 0.640, slope -0.507 per setting unit over a span of 1.1
  • Kicking Horse, Decaf, n = 16, R² = 0.607, slope -0.365 per setting unit over a span of 1.8
  • Phil Sebastian, Honey Haze Espresso, n = 13, R² = 0.333, slope -0.510 per setting unit over a span of 1.0

The failures are more informative than the passes. The most-logged bean in the archive has 80 shots, almost twice any other, and an R² of 0.001. More data bought it nothing, because those shots are clustered at a setting that already works. And pooling every bean together is worse still: one fit over all 442 shots gives R² = 0.0009, which is the numerical way of saying that grind setting has no meaning across beans. Per bean is not a refinement here, it is the only form of the question that has an answer.

Two things that do not line up

Both were found by reading the sources for this page, and both are about a system that is running. Neither is resolved here.

The gate contradicts its own documentation. 48 lines above the code, at d1Recommender.ts:330, the doc comment for the same function states the rule as n≥4 and R²≥0.5. The code at line 378 uses n≥6 and R²≥0.3. This is not a harmless stale comment. Applying both rules to the archive changes the verdict on 3 of the 21 beans that have enough shots to be judged under either:

  • Kirkland Signature, French Roast, n = 27, R² = 0.393: advised by the code, refused by the comment
  • Phil Sebastian, Honey Haze Espresso, n = 13, R² = 0.333: advised by the code, refused by the comment
  • Torque, Banane A Split, n = 5, R² = 0.983: refused by the code, advised by the comment

Four places count these shots and give 3 answers. There are 448 shot files on disk and the machine state file agrees at 448. The archive's own coffee/shots/index.json says 350, and 98 files present on disk are missing from it, with none the other way round, so the index is stale rather than wrong-headed. The sealed production snapshot of 2026-08-18 holds 397 shot rows; its own earliest and latest rows are 2026-02-20 and 2026-08-17, and the archive holds 437 shots inside that same interval. So the snapshot is short by 40 rows against a window it covers at both ends, and that gap is not explained by the snapshot being older. Its manifest sha256 verifies, so the file is intact and the difference is upstream of it.

The guardrails, which are the point

Below the bar the recommender falls back to explainable priors, the same bean or the same roaster or a similar roast, and names which one it used. Confidence is only ever revised downward. A shot the log records as good holds its setting, so a single bad pull cannot send the grinder chasing noise. Channelling is scored as a puck-preparation fault rather than a grind signal. Every recommendation is written to a ledger and reconciled against the shot that followed it, which is the only mechanism here that can prove the advice wrong.

What it does not settle

These are not the running recommender's verdicts. The shipped fit is weighted, each shot carrying an evidence weight computed at request time from recency and intent. Those weights are not in the archive and cannot be reconstructed from it, so the fits above are unweighted least squares on the same functional form, judged by the same gate constants. They say what the shipped gate says about the archive. They do not reproduce what the live service returned to anyone.

The time axis is quantised and clustered. Shot time is recorded to the second against a target the barista is aiming at, and 130 of the 442 shots sit at exactly 30 seconds. Most of the variance a log-time fit would need to explain was removed before the fit ever saw it, by the person pulling the shots. That explains the low R² values above, and it is a limit of the instrument rather than of the method.

A grind setting of 1.5 is recorded on 2 shots, against a corpus whose other 440 settings run from 9.9 to 14. They look like a data-entry fault, they are kept in every fit on this page because the recommender does not drop them either, and they are the reason one bean's fitted slope has the wrong sign.

One user, which is me. Nothing here is validated multi-user behaviour, and a recommender tuned on one palate, one grinder and one machine has no claim on anybody else's.

Where it stands
One user, and a recommender that is not calibrated yet.
Next
Let the recommendation ledger accumulate; calibrate only when the linked-shot count earns it.
Tools
Cloudflare Workers · D1 · TypeScript

← All notes