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
+145
View File
@@ -0,0 +1,145 @@
import test from "node:test";
import assert from "node:assert/strict";
import request from "supertest";
import { setup, signup } from "./helpers.js";
function planBody(overrides = {}) {
return {
fields: { "2.1": "single", "1.4": "7:30", "1.6": "0", "5.6": "" },
planActual: {
charge: { actualBt: "" },
tp: { actualBt: "" },
yellow: { actualBt: "" },
fc: { actualTime: "", actualBt: "" },
drop: { actualBt: "" },
},
afterRoast: { oneChange: "" },
inventory: { lotId: "", lotLabel: "", consumed: null },
...overrides,
};
}
test("machine-profile: requires auth", async () => {
const { app } = await setup();
const anon = request.agent(app);
assert.equal((await anon.get("/api/machine-profile")).status, 401);
});
test("machine-profile: a brand-new account gets the reference defaults, not an error", async () => {
const { app } = await setup();
const agent = request.agent(app);
await signup(agent, "[email protected]");
const r = await agent.get("/api/machine-profile");
assert.equal(r.status, 200);
assert.equal(r.body.ok, true);
assert.equal(r.body.profile.pace.value, 1);
assert.equal(r.body.profile.pace.source, "reference");
assert.equal(r.body.profile.totalPlans, 0);
});
test("machine-profile: learns a pace factor from this account's own completed roasts, and only this account's", async () => {
const { app } = await setup();
const owner = request.agent(app);
const { csrf } = await signup(owner, "[email protected]");
// Every roast here ran 20% slower than its own anchor (7:30=450s -> actual 9:00=540s, ratio 1.2).
await owner
.post("/api/plans")
.set("x-csrf-token", csrf)
.send({ plan: planBody({ planActual: { ...planBody().planActual, fc: { actualTime: "9:00", actualBt: "" } } }) });
await owner
.post("/api/plans")
.set("x-csrf-token", csrf)
.send({
plan: planBody({
fields: { "2.1": "single", "1.4": "8:00", "1.6": "0", "5.6": "" }, // 480s
planActual: { ...planBody().planActual, fc: { actualTime: "9:36", actualBt: "" } }, // 576s, ratio 1.2
}),
});
const other = request.agent(app);
await signup(other, "[email protected]");
const ownerProfile = (await owner.get("/api/machine-profile")).body.profile;
assert.equal(ownerProfile.pace.n, 2);
assert.equal(ownerProfile.pace.source, "learned");
assert.ok(Math.abs(ownerProfile.pace.value - 1.2) < 0.001, `expected ~1.2, got ${ownerProfile.pace.value}`);
// The other account's history is empty — it must not see the owner's learned pace.
const otherProfile = (await other.get("/api/machine-profile")).body.profile;
assert.equal(otherProfile.pace.source, "reference");
assert.equal(otherProfile.pace.value, 1);
});
test("last-refine: requires auth and 404s on a lot that doesn't belong to the caller", async () => {
const { app } = await setup();
const ownerAgent = request.agent(app);
const { csrf } = await signup(ownerAgent, "[email protected]");
const lot = (
await ownerAgent
.post("/api/inventory")
.set("x-csrf-token", csrf)
.send({ origin: "Huila", initialWeightG: 1000 })
).body.lot;
const anon = request.agent(app);
assert.equal((await anon.get(`/api/inventory/${lot.id}/last-refine`)).status, 401);
const otherAgent = request.agent(app);
await signup(otherAgent, "[email protected]");
assert.equal((await otherAgent.get(`/api/inventory/${lot.id}/last-refine`)).status, 404);
});
test("last-refine: null when no past roast against the lot has a filled-in one-change note", async () => {
const { app } = await setup();
const agent = request.agent(app);
const { csrf } = await signup(agent, "[email protected]");
const lot = (
await agent
.post("/api/inventory")
.set("x-csrf-token", csrf)
.send({ origin: "Huila", initialWeightG: 1000 })
).body.lot;
await agent
.post("/api/plans")
.set("x-csrf-token", csrf)
.send({ plan: planBody({ inventory: { lotId: lot.id, lotLabel: "Huila", consumed: null } }) });
const r = await agent.get(`/api/inventory/${lot.id}/last-refine`);
assert.equal(r.status, 200);
assert.equal(r.body.refine, null);
});
test("last-refine: returns the most recent filled-in note for that lot, ignoring blank ones and other lots", async () => {
const { app } = await setup();
const agent = request.agent(app);
const { csrf } = await signup(agent, "[email protected]");
const lotA = (
await agent.post("/api/inventory").set("x-csrf-token", csrf).send({ origin: "Lot A", initialWeightG: 1000 })
).body.lot;
const lotB = (
await agent.post("/api/inventory").set("x-csrf-token", csrf).send({ origin: "Lot B", initialWeightG: 1000 })
).body.lot;
// Older roast on lot A, with a note.
await agent.post("/api/plans").set("x-csrf-token", csrf).send({
plan: planBody({
inventory: { lotId: lotA.id, lotLabel: "Lot A", consumed: null },
afterRoast: { oneChange: "First crack +0:30" },
}),
});
// A blank-note roast on lot A (should never win over a real note, regardless of recency).
await agent.post("/api/plans").set("x-csrf-token", csrf).send({
plan: planBody({ inventory: { lotId: lotA.id, lotLabel: "Lot A", consumed: null } }),
});
// A roast on the OTHER lot with a note — must not leak into lot A's answer.
await agent.post("/api/plans").set("x-csrf-token", csrf).send({
plan: planBody({
inventory: { lotId: lotB.id, lotLabel: "Lot B", consumed: null },
afterRoast: { oneChange: "Development 0:15" },
}),
});
const r = await agent.get(`/api/inventory/${lotA.id}/last-refine`);
assert.equal(r.status, 200);
assert.equal(r.body.refine.oneChange, "First crack +0:30");
});