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

92 lines
4.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, 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 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;
const l7 = parseRangeMidpoint(f["4.3"]);
const l8 = parseDuration(isBlend ? f["2.5"] : f["3.2"]) ?? 0;
const l9 = parseDuration(f["1.7"]) ?? 0;
const C = l7 === null ? null : l7 + l8 + l9; // rows 5 and 6 (yellow/Maillard) are NEVER summed here
const D = A === null || C === null ? null : A + C;
if (l1 === null) warnings.push("No cultivar/blend anchor (field 1.4 or 2.4) — first crack cannot be computed.");
if (l7 === null) warnings.push("No development base (field 4.3) — development cannot be computed.");
const pct = (part) => (D && D > 0 && part !== null ? (part / D) * 100 : null);
const checks = {
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,
maxS: SANITY_BANDS.ceilingS,
pass: C === null ? null : C < SANITY_BANDS.ceilingS,
},
};
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,
C,
D,
checks,
warnings,
};
}
function passCheck(pct, band) {
return {
pct,
lo: band.lo,
hi: band.hi,
pass: pct === null ? null : pct >= band.lo && pct <= band.hi,
};
}