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

133 lines
5.8 KiB
JavaScript

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