Add per-user learned machine profile; fix methodology honesty issues
Test and deploy / test-and-deploy (push) Successful in 1m26s
Test and deploy / test-and-deploy (push) Successful in 1m26s
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]>
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
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");
|
||||
});
|
||||
Reference in New Issue
Block a user