// 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, }; }