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:
+132
@@ -0,0 +1,132 @@
|
||||
// Browser-safe. No node:* imports, no DOM. Imported by both server and browser.
|
||||
//
|
||||
// Learns a per-user machine profile from the account's own completed roasts, per Fable's
|
||||
// methodology review (2026-07-31): the reference MACHINE bands and the additive batch-size
|
||||
// correction in shared/reference-data.js are one operator's 14 Hottop roasts, frozen into the
|
||||
// data layer as though universal. This module recalibrates toward each account's own logged
|
||||
// history as it accumulates — a pace factor that replaces the additive batch correction with a
|
||||
// multiplicative one (see shared/ledger.js), and temperature bands that supersede the reference
|
||||
// MACHINE constants once there's enough data to trust. Below the sample thresholds it explicitly
|
||||
// falls back to the reference numbers (pace 1.0, source "reference") rather than overfitting to
|
||||
// one or two roasts.
|
||||
|
||||
import { parseDuration, parseRangeMidpoint } from "./time.js?v=__ASSET_VERSION__";
|
||||
|
||||
// Below this many valid (predicted, actual) FC pairs, trust the reference pace of 1.0 rather than
|
||||
// a ratio computed from too little evidence.
|
||||
const MIN_PACE_SAMPLES = 2;
|
||||
// A single ratio outside this range is far more likely a typo'd actual-FC time (or a plan filled
|
||||
// in for something other than an actual roast) than a real machine running that far from the
|
||||
// reference — excluded before the median rather than allowed to define it in a small sample.
|
||||
const PLAUSIBLE_RATIO = [0.5, 2];
|
||||
// Hard bound on the final learned value regardless of how many samples agreed — no single
|
||||
// account's history should be able to plan a roast at less than 60% or more than 160% of the
|
||||
// reference timing without a human noticing something is wrong first.
|
||||
const PACE_CLAMP = [0.6, 1.6];
|
||||
// Below this many recorded actual temperatures for a milestone, keep using the reference band for
|
||||
// just that milestone (bands are tracked and thresholded independently, not as a single all-or-
|
||||
// nothing switch — a user might log FC reliably long before they bother recording turning point).
|
||||
const MIN_BAND_SAMPLES = 3;
|
||||
|
||||
const MILESTONES = ["charge", "tp", "yellow", "fc", "drop"];
|
||||
|
||||
/** Predicted first-crack seconds from a plan's own fields, mirroring ledger.js's
|
||||
* l1+l2+l3+l4 — the FULL pre-pace prediction, deliberately INCLUDING the manual batch
|
||||
* correction (field 6.4). Excluding l4 here would double-count it: pace would absorb whatever
|
||||
* 6.4 was historically compensating for, and ledger.js would then add that same 6.4 back on top
|
||||
* going forward. Including it means pace is fit as a pure residual — whatever the anchor, refine,
|
||||
* bean-condition AND that historical 6.4 entry together still didn't explain — which is exactly
|
||||
* what ledger.js's `Math.round((baseA + l4) * pace)` expects when applied to a new plan's own l4.
|
||||
* Returns null if the plan has no anchor. */
|
||||
function predictedFcSeconds(fields) {
|
||||
if (!fields) return null;
|
||||
const isBlend = fields["2.1"] === "blend";
|
||||
const l1 = parseRangeMidpoint(isBlend ? fields["2.4"] : fields["1.4"]);
|
||||
if (l1 === null) return null;
|
||||
const l2 = parseDuration(fields["1.6"]) ?? 0;
|
||||
const l3 = parseDuration(fields["5.6"]) ?? 0;
|
||||
const l4 = parseDuration(fields["6.4"]) ?? 0;
|
||||
return l1 + l2 + l3 + l4;
|
||||
}
|
||||
|
||||
function median(nums) {
|
||||
if (nums.length === 0) return null;
|
||||
const sorted = [...nums].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<{fields?: object, planActual?: object}>} plans - the user's own roast_plans.plan
|
||||
* blobs (any shape/order; malformed or in-progress entries are simply skipped, not thrown on).
|
||||
* @returns {{
|
||||
* pace: { value: number, n: number, source: "learned"|"reference" },
|
||||
* bands: Record<"charge"|"tp"|"yellow"|"fc"|"drop",
|
||||
* { medianC: number|null, rangeC: [number,number]|null, n: number, source: "learned"|"reference" }>,
|
||||
* bandsSource: "learned"|"reference",
|
||||
* totalPlans: number,
|
||||
* }}
|
||||
*/
|
||||
export function computeMachineProfile(plans) {
|
||||
const list = Array.isArray(plans) ? plans : [];
|
||||
const paceRatios = [];
|
||||
const tempsByMilestone = { charge: [], tp: [], yellow: [], fc: [], drop: [] };
|
||||
|
||||
for (const plan of list) {
|
||||
const fields = plan?.fields;
|
||||
const actual = plan?.planActual;
|
||||
if (!actual || typeof actual !== "object") continue;
|
||||
|
||||
const actualFcS = parseDuration(actual.fc?.actualTime);
|
||||
if (actualFcS !== null && actualFcS > 0) {
|
||||
const predicted = predictedFcSeconds(fields);
|
||||
if (predicted !== null && predicted > 0) {
|
||||
const ratio = actualFcS / predicted;
|
||||
// A ratio this far from 1 is far likelier to be a typo than a real reading —
|
||||
// excluded entirely rather than letting one bad entry define (or count toward the
|
||||
// sample size backing) the learned pace.
|
||||
if (ratio >= PLAUSIBLE_RATIO[0] && ratio <= PLAUSIBLE_RATIO[1]) paceRatios.push(ratio);
|
||||
}
|
||||
}
|
||||
|
||||
for (const m of MILESTONES) {
|
||||
const tempC = Number.parseFloat(actual[m]?.actualBt);
|
||||
if (Number.isFinite(tempC)) tempsByMilestone[m].push(tempC);
|
||||
}
|
||||
}
|
||||
|
||||
const paceMedian = paceRatios.length >= MIN_PACE_SAMPLES ? median(paceRatios) : null;
|
||||
const clampedPace =
|
||||
paceMedian === null
|
||||
? null
|
||||
: Math.min(PACE_CLAMP[1], Math.max(PACE_CLAMP[0], paceMedian));
|
||||
const pace = {
|
||||
value: clampedPace ?? 1,
|
||||
n: paceRatios.length,
|
||||
source: clampedPace !== null ? "learned" : "reference",
|
||||
};
|
||||
|
||||
const bands = {};
|
||||
let anyBandLearned = false;
|
||||
for (const m of MILESTONES) {
|
||||
const temps = tempsByMilestone[m];
|
||||
if (temps.length >= MIN_BAND_SAMPLES) {
|
||||
bands[m] = {
|
||||
medianC: Math.round(median(temps)),
|
||||
rangeC: [Math.round(Math.min(...temps)), Math.round(Math.max(...temps))],
|
||||
n: temps.length,
|
||||
source: "learned",
|
||||
};
|
||||
anyBandLearned = true;
|
||||
} else {
|
||||
bands[m] = { medianC: null, rangeC: null, n: temps.length, source: "reference" };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pace,
|
||||
bands,
|
||||
bandsSource: anyBandLearned ? "learned" : "reference",
|
||||
totalPlans: list.length,
|
||||
};
|
||||
}
|
||||
+26
-4
@@ -1,24 +1,42 @@
|
||||
// Browser-safe. THE ONLY implementation of the worksheet's time-ledger math.
|
||||
// Mirrors manual-roast-planner.html's worksheet box 6 (lines 1-9, totals A/C/D)
|
||||
// and box 7 (the four sanity checks) exactly. Imported by both server and browser.
|
||||
//
|
||||
// Two changes from the original worksheet, made 2026-07-31 after a methodology review (see
|
||||
// shared/learn.js's header): first crack is now scaled by a multiplicative machine-pace factor —
|
||||
// the operator's own 14-roast data showed a ~13% batch-size increase stretching first crack ~40%,
|
||||
// a scaling relationship a flat added-seconds guess can't represent. Pace is applied to the WHOLE
|
||||
// pre-pace sum, INCLUDING line 4 (± batch-size correction), not just lines 1-3: shared/learn.js
|
||||
// fits pace from each historical roast's l1+l2+l3+l4 against what actually happened, so applying
|
||||
// it only to l1+l2+l3 here and adding l4 back afterwards would double-count whatever that
|
||||
// historical l4 was already compensating for. And the drying/Maillard sanity checks are now
|
||||
// marked `informational`: because yellow is DERIVED as YELLOW_RATIO × A rather than entered
|
||||
// independently, drying% and Maillard% are algebraic consequences of the DTR check, not
|
||||
// independent validations — whenever DTR passes, both are mathematically guaranteed to also pass.
|
||||
// Only DTR and the development ceiling test anything a plan could actually fail.
|
||||
|
||||
import { parseDuration, parseRangeMidpoint } from "./time.js?v=__ASSET_VERSION__";
|
||||
import { YELLOW_RATIO, SANITY_BANDS } from "./reference-data.js?v=__ASSET_VERSION__";
|
||||
|
||||
/**
|
||||
* @param {import("./fields.js").Plan} plan
|
||||
* @param {import("./learn.js").computeMachineProfile extends (...a:any)=>infer R ? R : never} [machineProfile]
|
||||
* optional learned per-user profile from shared/learn.js; omitted or a profile with no learned
|
||||
* data behaves exactly as before (pace 1.0 — the reference/original worksheet behavior).
|
||||
* @returns ledger result — every time value in seconds, null when not computable.
|
||||
*/
|
||||
export function computeLedger(plan) {
|
||||
export function computeLedger(plan, machineProfile = null) {
|
||||
const f = plan.fields ?? {};
|
||||
const isBlend = f["2.1"] === "blend";
|
||||
const warnings = [];
|
||||
const pace = machineProfile?.pace?.value ?? 1;
|
||||
|
||||
const l1 = parseRangeMidpoint(isBlend ? f["2.4"] : f["1.4"]);
|
||||
const l2 = parseDuration(f["1.6"]) ?? 0;
|
||||
const l3 = parseDuration(f["5.6"]) ?? 0;
|
||||
const l4 = parseDuration(f["6.4"]) ?? 0;
|
||||
const A = l1 === null ? null : l1 + l2 + l3 + l4;
|
||||
const baseA = l1 === null ? null : l1 + l2 + l3 + l4;
|
||||
const A = baseA === null ? null : Math.round(baseA * pace);
|
||||
|
||||
const yellow = A === null ? null : Math.round(A * YELLOW_RATIO);
|
||||
const maillard = A === null ? null : A - yellow;
|
||||
@@ -36,8 +54,8 @@ export function computeLedger(plan) {
|
||||
const pct = (part) => (D && D > 0 && part !== null ? (part / D) * 100 : null);
|
||||
|
||||
const checks = {
|
||||
drying: passCheck(pct(yellow), SANITY_BANDS.drying),
|
||||
maillard: passCheck(pct(maillard), SANITY_BANDS.maillard),
|
||||
drying: { ...passCheck(pct(yellow), SANITY_BANDS.drying), informational: true },
|
||||
maillard: { ...passCheck(pct(maillard), SANITY_BANDS.maillard), informational: true },
|
||||
dtr: passCheck(pct(C), SANITY_BANDS.dtr),
|
||||
ceiling: {
|
||||
valueS: C,
|
||||
@@ -49,6 +67,10 @@ export function computeLedger(plan) {
|
||||
return {
|
||||
isBlend,
|
||||
lines: { l1, l2, l3, l4, l7, l8, l9 },
|
||||
pace,
|
||||
subtotalA: baseA, // lines 1-4 summed, before pace — exposed so the UI/print sheet can show
|
||||
// the multiplication step explicitly instead of jumping straight to A with no visible way to
|
||||
// reconstruct it from the printed lines (see the header comment above).
|
||||
A,
|
||||
yellow,
|
||||
maillard,
|
||||
|
||||
+6
-1
@@ -1,11 +1,16 @@
|
||||
// Browser-safe. No node:* imports, no DOM. Imported by both server and browser.
|
||||
|
||||
const DASHES = /[‒–—−-]/; // -, –, —, minus sign
|
||||
// Matches only the non-ASCII members of DASHES — used to fold a leading minus/en/em dash down to
|
||||
// an ASCII hyphen before sign detection, so a string like "−0:30" (U+2212, the character every
|
||||
// signed number in this app's own reference data — SYMPTOM_FIXES, cultivar profiles — is written
|
||||
// with) parses instead of silently returning null.
|
||||
const NON_ASCII_DASHES = /[‒–—−]/g;
|
||||
|
||||
/** "8:45" -> 525, "-0:20" -> -20, "+0:15" -> 15, "0" -> 0, "90" -> 90 (bare seconds). null on junk. */
|
||||
export function parseDuration(str) {
|
||||
if (str === null || str === undefined) return null;
|
||||
const s = String(str).trim();
|
||||
const s = String(str).trim().replace(NON_ASCII_DASHES, "-");
|
||||
if (s === "") return null;
|
||||
const sign = s.startsWith("-") ? -1 : 1;
|
||||
const body = s.replace(/^[+-]/, "");
|
||||
|
||||
Reference in New Issue
Block a user