test(units): sweep the AI and Coach producers for unit literals

The unit sweep covered the display trees and the insight library; the
prompt and snapshot builders, the text-writing jobs and the target and
digest producers were outside it, which is where a band in mg/dL, a
threshold in kg/week and a distance in metres reached the model. The
sweep now walks those trees too and matches prose units ("kg/week",
"5 metres") as well as symbols. The four allowlist entries for prompts
that stated weight in kilograms are gone with the literals; the new
entries each name why the literal is not a reader-facing unit.

A line scanner cannot see a canonical number printed with no unit at
all, so the guard also names every producer that carries a
preference-dependent figure into a prompt, a snapshot or a stored
sentence and requires each to call a reader-unit resolver. The canonical
weight signal of the day loses its kilogram label; the reader's unit is
attached where the signal is converted.

Refs #1067
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 13:23:17 +02:00
1 parent 0028290117
commit 615f5a8070
2 files changed
+109 -16

No files matched your search

@@ -226,10 +226,19 @@ describe("unit-preference display guard", () => {
* fails. A listed file that drops a line fails too, so the budget follows
* the code down instead of leaving headroom for the next literal.
*
* The text the AI and the Coach write is swept the same way: the prompt and
* snapshot builders (`lib/ai/`), the jobs that write stored text
* (`lib/jobs/`), the target and daily-digest producers. A prompt that
* states a threshold or a band in kilograms, °C or mg/dL hands a reader on
* pounds, °F or mmol/L two units for one quantity, and the model mixes
* them. Prose units ("5 metres", "kg/week") are matched as well, because a
* prompt is prose.
*
* The limit, stated: this is a line scanner. A unit spelled through a
* variable named after it, or a literal split across lines, passes. The
* message bundles are not swept either (the fever and cycle-glucose labels
* lived there).
* lived there). The producer check below covers what a scanner cannot: a
* text producer that prints a canonical number with no unit at all.
*/
const WIDE_SWEEP_ROOTS = [
"components/",
@@ -237,6 +246,10 @@ const WIDE_SWEEP_ROOTS = [
"lib/insights/",
"lib/doctor-report",
"lib/export",
"lib/ai/",
"lib/jobs/",
"lib/targets/",
"lib/daily/",
];
const UNIT_LITERAL_PATTERNS: readonly RegExp[] = [
@@ -252,6 +265,10 @@ const UNIT_LITERAL_PATTERNS: readonly RegExp[] = [
/\s(?:kg|km|°C)(?=["'`<])/,
// A per-kilometre pace.
/\/km\b/,
// A rate per kilogram written into prose: "kg/week" (BMI's kg/m² excepted).
/\bkg\/(?!m²)/,
// A unit spelled out after a number or an interpolation: "5 metres".
/(?:\d|\})\s?(?:metres?|kilograms?)\b/,
];
/**
@@ -346,22 +363,48 @@ const UNIT_LITERAL_ALLOWLIST: Record<
lines: 2,
reason: "prose naming both glucose units the app offers",
},
// ── Still canonical; the prompt-unit pass for these is open (#1067) ──
"lib/insights/comprehensive-generate.ts": {
// ── The AI and Coach producers ──
"lib/ai/insight-interpretation.ts": {
lines: 1,
reason: "comprehensive prompt still states weight in kg",
reason:
"canonical band registry; interpretation-block prints the band in the reader's unit",
},
"lib/insights/signals-of-day.ts": {
lines: 1,
reason: "briefing signal still states weight in kg",
"lib/ai/prompts/shared-contracts.ts": {
lines: 3,
reason:
"the acute red-flag floors, stated in both units on purpose so the rule fires on what the person writes",
},
"lib/insights/weight-status.ts": {
lines: 1,
reason: "weight status snapshot still states kg",
"lib/ai/coach/snapshot-blocks/glucose-block.ts": {
lines: 4,
reason:
"branches on the resolved glucose unit; a mg/dL reader's block stays byte-identical",
},
"lib/insights/glp1-plateau.ts": {
"lib/ai/coach/results/chart-spec.ts": {
lines: 2,
reason: "GLP-1 plateau prompt context still states kg",
reason:
"bin widths keyed to a column unit, applied only when the table is in that unit",
},
"lib/ai/coach/coach-prose-grounding.ts": {
lines: 1,
reason:
"the reply checker's list of unit tokens it recognises in model prose",
},
"lib/ai/coach/eval/golden-cases.ts": {
lines: 3,
reason: "evaluation fixtures: scripted replies and ideal answers",
},
"lib/ai/coach/eval/red-team.ts": {
lines: 2,
reason: "adversarial evaluation inputs, written as a person would type",
},
"lib/targets/target-unit-display.ts": {
lines: 4,
reason: "the target adapter itself: picks the glucose branch",
},
"lib/targets/vitals-builder.ts": {
lines: 1,
reason:
"canonical targets DTO; every client converts through the target adapter",
},
};
@@ -372,7 +415,7 @@ function unitLiteralLines(): Map<string, string[]> {
!rel.includes("__tests__") &&
!/\.test\.tsx?$/.test(rel),
);
if (files.length < 1200) {
if (files.length < 1700) {
throw new Error(
`unit sweep narrowed to ${files.length} files; a sweep this small is a broken filter`,
);
@@ -424,3 +467,51 @@ describe("unit-preference display guard — the wide sweep", () => {
expect(stale).toEqual([]);
});
});
/**
* The producers of text a model reads or writes from. A line scanner sees a
* unit literal; it cannot see a canonical number printed with no unit at
* all, which is how the Coach's weight block and the briefing's glucose
* signal reached readers on pounds and mmol/L. So each producer that carries
* a mass, length, temperature, speed, distance or glucose figure into a
* prompt, a snapshot or a stored sentence is named here and must call one of
* the resolvers that state a figure in the reader's units. Dropping the call
* (or the file) fails.
*/
const READER_UNIT_PRODUCERS = [
"lib/ai/coach/reference-grounding.ts",
"lib/ai/coach/snapshot-blocks/core-metrics-block.ts",
"lib/ai/coach/snapshot-blocks/value-series-blocks.ts",
"lib/ai/coach/snapshot-blocks/workouts-block.ts",
"lib/ai/coach/cycle-snapshot.ts",
"lib/ai/coach/results/metric-table-tool.ts",
"lib/ai/prompts/weight.ts",
"lib/insights/features-units.ts",
"lib/insights/comprehensive-generate.ts",
"app/api/insights/generate/route.ts",
"lib/insights/weight-status.ts",
"lib/insights/metric-status.ts",
"lib/insights/narrative/period-narrative.ts",
"lib/insights/glp1-plateau.ts",
"lib/jobs/reaction-line.ts",
"lib/targets/weight-trend.ts",
];
const READER_UNIT_RESOLVER =
/\b(?:getReadingTransform|getQuantityTransform|getDisplayTransform|featuresInReaderUnits|weightFeatureInReaderUnits)\(/;
describe("unit-preference display guard — AI and Coach producers", () => {
it("names enough producers to be a check (a list that shrinks proves nothing)", () => {
expect(READER_UNIT_PRODUCERS.length).toBeGreaterThanOrEqual(16);
});
it("every producer states its figures through a reader-unit resolver", () => {
const offenders = READER_UNIT_PRODUCERS.filter(
(rel) =>
!READER_UNIT_RESOLVER.test(
stripComments(readFileSync(join(SRC, rel), "utf8")),
),
);
expect(offenders).toEqual([]);
});
});
+5 -3
View File
@@ -202,8 +202,7 @@ export function computeSignalsOfDay(
"mmHg",
),
);
// Glucose uses the canonical stored value (the model reads the snapshot,
// not display units); absent data simply produces no signal.
// Absent data simply produces no signal.
push(buildSignal("glucose", "blood glucose", byType("BLOOD_GLUCOSE"), now));
push(
buildSignal(
@@ -215,7 +214,10 @@ export function computeSignalsOfDay(
),
);
push(buildSignal("pulse", "pulse", byType("PULSE"), now, "bpm"));
push(buildSignal("weight", "weight", byType("WEIGHT"), now, "kg"));
// Weight and glucose are canonical here and carry no unit: the reader's
// unit is attached where the signal is converted for the prompt
// (`featuresInReaderUnits`), so no canonical symbol can leak into one.
push(buildSignal("weight", "weight", byType("WEIGHT"), now));
// Sleep is stored one row per stage per night, so a raw "latest" point
// would mis-sum; the sleep aggregates carry that signal already. Steps
// ingest as many intraday `stats:`-prefixed samples, so the newest raw row