Files
snowspeederandClaude Sonnet 5 2237c199c1
Test and deploy / test-and-deploy (push) Successful in 1m26s
Add per-user learned machine profile; fix methodology honesty issues
Replaces the flat additive batch-size correction with a per-user
multiplicative pace factor learned from each account's own logged
roasts (shared/learn.js, GET /api/machine-profile), plus a lot-scoped
"last refine" auto-suggestion. Also fixes the reference data being
framed as "your own roasts" on a now-multi-user product, and
reclassifies the drying/Maillard sanity checks as informational since
they're algebraically derived from the DTR check rather than
independent (yellow = 0.56 x first crack, not entered separately).

Bug fixes found by an adversarial Opus review of the first pass:
- Unicode minus sign (U+2212) broke duration parsing against the
  app's own generated refine-suggestion text
- Printed sanity-checks table still showed a bare pass/fail glyph for
  the now-informational drying/Maillard rows
- Printed time-ledger box didn't show the pace multiplication step,
  so it stopped reconciling by hand once pace != 1
- Field 6.4 (manual batch correction) was double-counted: excluded
  from the learned-pace fit but added back after the multiplication
- Learned pace had no outlier rejection or hard clamp
- computeLedger had no test coverage
- Batch-size help copy overstated what the pace factor models (it's
  a single blanket ratio, not conditioned on batch weight)

A follow-up Opus pass also caught the per-user profile cache
surviving logout/account-switch in a shared browser; fixed by
sweeping it alongside the existing plan-draft cleanup.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-31 12:40:03 -04:00

171 lines
7.9 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import { computeMachineProfile } from "../shared/learn.js";
function plan({
fcAnchor = "7:30",
refine = "0",
condition = "",
batchCorrection = "",
actualFc,
actualBt = {},
} = {}) {
return {
fields: { "2.1": "single", "1.4": fcAnchor, "1.6": refine, "5.6": condition, "6.4": batchCorrection },
planActual: {
charge: { actualBt: actualBt.charge ?? "" },
tp: { actualBt: actualBt.tp ?? "" },
yellow: { actualBt: actualBt.yellow ?? "" },
fc: { actualTime: actualFc ?? "", actualBt: actualBt.fc ?? "" },
drop: { actualBt: actualBt.drop ?? "" },
},
};
}
test("learn: empty history stays at reference defaults", () => {
const profile = computeMachineProfile([]);
assert.equal(profile.pace.value, 1);
assert.equal(profile.pace.n, 0);
assert.equal(profile.pace.source, "reference");
assert.equal(profile.bandsSource, "reference");
assert.equal(profile.totalPlans, 0);
for (const m of ["charge", "tp", "yellow", "fc", "drop"]) {
assert.equal(profile.bands[m].source, "reference");
assert.equal(profile.bands[m].medianC, null);
}
});
test("learn: non-array input is treated as no history, not a throw", () => {
assert.doesNotThrow(() => computeMachineProfile(null));
assert.doesNotThrow(() => computeMachineProfile(undefined));
assert.equal(computeMachineProfile(null).totalPlans, 0);
});
test("learn: a single completed roast is not enough to trust — pace stays 1.0", () => {
// anchor 7:30 = 450s, actual FC 8:15 = 495s (ratio 1.1) — only one data point.
const profile = computeMachineProfile([plan({ fcAnchor: "7:30", actualFc: "8:15" })]);
assert.equal(profile.pace.value, 1);
assert.equal(profile.pace.n, 1);
assert.equal(profile.pace.source, "reference");
});
test("learn: two or more roasts running consistently slower learn a >1 pace factor", () => {
// Every roast ran 10% slower than its own anchor predicted.
const plans = [
plan({ fcAnchor: "7:30", actualFc: "8:15" }), // 450 -> 495, ratio 1.1
plan({ fcAnchor: "8:00", actualFc: "8:48" }), // 480 -> 528, ratio 1.1
plan({ fcAnchor: "9:00", actualFc: "9:54" }), // 540 -> 594, ratio 1.1
];
const profile = computeMachineProfile(plans);
assert.equal(profile.pace.n, 3);
assert.equal(profile.pace.source, "learned");
assert.ok(Math.abs(profile.pace.value - 1.1) < 0.001, `expected ~1.1, got ${profile.pace.value}`);
});
test("learn: refine and bean-condition corrections are folded into the predicted anchor before pacing", () => {
// anchor 7:30 (450s) + refine +0:15 (15s) + condition -0:05 (-5s) = predicted 460s.
// Actual exactly matches the corrected prediction, so pace should read as 1.0 even though
// naively comparing actual against the raw uncorrected anchor would suggest otherwise.
const plans = [
plan({ fcAnchor: "7:30", refine: "+0:15", condition: "-0:05", actualFc: "7:40" }), // 460s
plan({ fcAnchor: "8:00", refine: "+0:15", condition: "-0:05", actualFc: "8:10" }), // 490s
];
const profile = computeMachineProfile(plans);
assert.equal(profile.pace.source, "learned");
assert.ok(Math.abs(profile.pace.value - 1) < 0.001, `expected ~1.0, got ${profile.pace.value}`);
});
test("learn: a plan with no anchor or no actual FC time is silently skipped, not counted or thrown on", () => {
const plans = [
plan({ fcAnchor: "", actualFc: "8:15" }), // no anchor -> unpredictable, skipped
plan({ fcAnchor: "7:30", actualFc: "" }), // never roasted -> skipped
{ fields: {}, planActual: null }, // malformed -> skipped
{}, // completely empty -> skipped
];
assert.doesNotThrow(() => computeMachineProfile(plans));
const profile = computeMachineProfile(plans);
assert.equal(profile.pace.n, 0);
assert.equal(profile.pace.source, "reference");
assert.equal(profile.totalPlans, 4);
});
test("learn: temperature bands require 3+ readings per milestone, independently per milestone", () => {
const plans = [
plan({ actualBt: { charge: "160", fc: "180" } }),
plan({ actualBt: { charge: "165", fc: "185" } }),
plan({ actualBt: { charge: "170", fc: "190" } }), // charge and fc now have 3 each
plan({ actualBt: { tp: "85" } }), // tp only has 1 reading
];
const profile = computeMachineProfile(plans);
assert.equal(profile.bands.charge.source, "learned");
assert.equal(profile.bands.charge.n, 3);
assert.equal(profile.bands.charge.medianC, 165);
assert.deepEqual(profile.bands.charge.rangeC, [160, 170]);
assert.equal(profile.bands.fc.source, "learned");
assert.equal(profile.bands.fc.medianC, 185);
// Below the 3-sample threshold — stays reference, not a false "learned" reading from 1 point.
assert.equal(profile.bands.tp.source, "reference");
assert.equal(profile.bands.tp.medianC, null);
assert.equal(profile.bands.tp.n, 1);
assert.equal(profile.bandsSource, "learned"); // true because AT LEAST ONE milestone learned
});
test("learn: non-numeric actualBt values are ignored rather than poisoning the median", () => {
const plans = [
plan({ actualBt: { charge: "160" } }),
plan({ actualBt: { charge: "not a number" } }),
plan({ actualBt: { charge: "" } }),
plan({ actualBt: { charge: "170" } }),
plan({ actualBt: { charge: "180" } }),
];
const profile = computeMachineProfile(plans);
assert.equal(profile.bands.charge.n, 3); // only the 3 valid numeric readings counted
assert.equal(profile.bands.charge.medianC, 170);
});
test("learn: field 6.4 is included in the fitted prediction, so it isn't double-counted once pace is learned", () => {
// anchor 8:00 (480s) + 6.4 +1:00 (60s) = 540s predicted; actual FC lands exactly there, every
// time, so a correct fit reads pace as 1.0 — NOT ~1.125, which is what a fit that excluded 6.4
// (comparing 540s actual against a 480s prediction) would wrongly report.
const plans = [
plan({ fcAnchor: "8:00", batchCorrection: "+1:00", actualFc: "9:00" }),
plan({ fcAnchor: "8:30", batchCorrection: "+1:00", actualFc: "9:30" }),
plan({ fcAnchor: "9:00", batchCorrection: "+1:00", actualFc: "10:00" }),
];
const profile = computeMachineProfile(plans);
assert.equal(profile.pace.source, "learned");
assert.ok(Math.abs(profile.pace.value - 1) < 0.001, `expected ~1.0, got ${profile.pace.value}`);
});
test("learn: a single wildly-off actual-FC entry is excluded as an outlier, not allowed to define the pace", () => {
// Second roast's actual FC (0:05) against an 8:00 anchor is a near-certain typo, not a real
// 96% speed-up — the ratio (0.0104) sits far outside the plausible band and must be dropped
// before the median, not clamped into it as if it were real signal from a small sample.
const plans = [
plan({ fcAnchor: "8:00", actualFc: "8:10" }), // ratio ~1.02, plausible
plan({ fcAnchor: "8:00", actualFc: "0:05" }), // ratio ~0.01, implausible — excluded
];
const profile = computeMachineProfile(plans);
// Only one plausible sample remains, below MIN_PACE_SAMPLES — falls back to reference rather
// than "learning" a pace from what's actually just the one plausible reading.
assert.equal(profile.pace.n, 1);
assert.equal(profile.pace.source, "reference");
assert.equal(profile.pace.value, 1);
});
test("learn: the learned pace is clamped to a defensible band even with several agreeing samples", () => {
// Three roasts all reading a ~0.55 ratio — each one individually within the per-sample
// plausible band (so none get excluded as an outlier), but a median that far below 1 is still
// implausible for a real machine. The hard clamp exists for exactly this shape of history,
// where per-sample filtering alone wouldn't catch it.
const plans = [
plan({ fcAnchor: "8:00", actualFc: "4:20" }), // 260/480 = 0.542
plan({ fcAnchor: "8:00", actualFc: "4:24" }), // 264/480 = 0.55
plan({ fcAnchor: "8:00", actualFc: "4:28" }), // 268/480 = 0.558
];
const profile = computeMachineProfile(plans);
assert.equal(profile.pace.n, 3); // all three were plausible per-sample, none excluded
assert.equal(profile.pace.source, "learned");
assert.equal(profile.pace.value, 0.6, "median ~0.55 should be clamped up to the 0.6 floor");
});