Add per-user learned machine profile; fix methodology honesty issues
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:
2026-07-31 12:40:03 -04:00
co-authored by Claude Sonnet 5
parent 0a0356b86e
commit 2237c199c1
14 changed files with 999 additions and 83 deletions
+26 -4
View File
@@ -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,