fix(coach): stop the numeric verifier from flagging correctly-grounded figures

Three confirmed false-positive shapes in findUnverifiedCoachNumbers/
stripUnverifiedNumbers were replacing legitimate prose with the literal
"[unverified]" placeholder even though the underlying data was grounded:

- A correctly-computed delta, midpoint average, or percent change between
  two of a payload's own headline aggregate figures (e.g. "down 3.5 kg from
  your 7-day average") was never itself a raw leaf in any tool result, so it
  was flagged as invented. The authoritative set now also carries the
  pairwise delta/average/percent-change between a SINGLE payload's own
  named aggregate fields (latest, avg7, avg30, avgSys30, allTimeAvg, ...),
  scoped per payload so a weight delta cannot ground itself against an
  unrelated metric's figure.
- The always-sent DATA INVENTORY manifest (tool-mode base context) carries a
  sample count per domain, but that count was never added to the
  authoritative set the way the pinned workout-evidence block already is —
  so a plain restatement of a domain's count ("you've logged 42 BP
  readings") was flagged whenever the model didn't also call that domain's
  retrieval tool in the same turn. The inventory entries now splice into
  toolResultPayloads exactly like workoutEvidence.
- A calendar-date ordinal ("July 21st") or a numbered-list marker ("1. Cut
  back on sodium") tokenised as a bare number and, when ungrounded, got
  soft-stripped mid-word into visibly broken text ("July [unverified]st").
  Prose is now masked for these two narrow, high-precision shapes before
  numeric extraction; the original text used for stripping is untouched.

A genuinely fabricated figure (unrelated to any grounded point, inventory
count, date, or list marker) is still caught and stripped — covered by new
adversarial cases alongside each fix.

Investigated and ruled out: multi-round tool-loop result accumulation (all
present results across every round already reach the verifier); unit
conversion (the app resolves and serves one consistent unit, so there is no
independent conversion step for the model to get wrong). The Daily
Briefing's separate withhold-a-whole-section grounding gate is unrelated to
the reported symptom (a different failure mode, whole-line omission vs.
inline replacement) and was not touched.
This commit is contained in:
Marc-André Bombeck committed 2026-07-23 09:43:54 +02:00
1 parent 62eb431d66
commit fbdeec9204
5 files changed
+341 -2

No files matched your search

@@ -3,6 +3,14 @@
* a number the model cited that no tool returned this turn (transcription
* drift), exempt structural window/ordinal integers, and no-op when no tool
* returned figures. Soft-strip replaces only the flagged tokens.
*
* v1.32.1 — closes three real-world false-positive shapes reported against
* grounded Coach replies: a correctly-computed derived delta/average/percent
* between two of a payload's own headline figures, a calendar-date ordinal
* ("July 21st") mangled mid-word into "[unverified]st", and a numbered-list
* recommendation line ("1. Cut back on sodium") whose marker got flagged as a
* bare figure. Each new case is paired with an adversarial case proving a
* genuine fabrication in the SAME shape is still caught.
*/
import { describe, expect, it } from "vitest";
@@ -68,6 +76,137 @@ describe("findUnverifiedCoachNumbers", () => {
it("returns empty on empty prose", () => {
expect(findUnverifiedCoachNumbers("", payloads)).toEqual([]);
});
describe("derived arithmetic (delta / average / percent)", () => {
// A realistic weight tool-result payload: the model was given latest,
// avg7 and avg30 — never a pre-computed delta (unlike the Daily
// Briefing's `signalsOfDay`), so a correctly-computed "down 3.5 kg from
// your 7-day average" is a legitimate derivation the checker must not
// flag.
const weightPayload = [
{
metric: "weight",
section: { aggregate: { latest: 72.4, avg7: 75.9, avg30: 74.1 } },
},
];
it("does not flag a correctly-computed delta between two grounded aggregate points", () => {
const prose =
"You're down 3.5 kg from your 7-day average of 75.9 kg, now at 72.4 kg — nice trend.";
expect(findUnverifiedCoachNumbers(prose, weightPayload)).toEqual([]);
});
it("does not flag a correctly-computed midpoint average of two grounded aggregate points", () => {
const prose =
"Your latest (72.4 kg) and 30-day average (74.1 kg) put you around 73.25 kg overall.";
expect(findUnverifiedCoachNumbers(prose, weightPayload)).toEqual([]);
});
it("does not flag a correctly-computed percent change between two grounded aggregate points", () => {
// (75.9 - 72.4) / 75.9 * 100 ≈ 4.61%
const prose = "That's roughly a 4.6% drop from your 7-day average.";
expect(findUnverifiedCoachNumbers(prose, weightPayload)).toEqual([]);
});
it("still flags a fabricated figure that is NOT a derived value of the grounded points", () => {
const prose =
"Your latest reading was 72.4 kg, and I estimate you'll hit 55.0 kg by next month.";
const findings = findUnverifiedCoachNumbers(prose, weightPayload);
expect(findings).toHaveLength(1);
expect(findings[0].value).toBe(55);
});
it("does not let a weight delta ground itself against an unrelated metric's figure (per-payload scoping)", () => {
// Blood pressure's own aggregate lives in a SEPARATE tool-result
// payload; a "difference" here must derive only from ITS OWN points,
// never pool with the unrelated weight payload above.
const bpAndWeight = [
...weightPayload,
{
metric: "bp",
section: { aggregate: { avgSys30: 128, avgDia30: 82 } },
},
];
// 46 is |128 - 82| — a legitimate BP pulse-pressure-shaped delta —
// grounded via the bp payload's own points, not a coincidence.
expect(
findUnverifiedCoachNumbers(
"Your pulse pressure is about 46.",
bpAndWeight,
),
).toEqual([]);
});
});
describe("date ordinals and numbered-list markers", () => {
const bpPayload = [
{
metric: "bp",
section: { aggregate: { latest: 138, avg7: 132, avg30: 128 } },
},
];
it("does not flag a day-of-month ordinal even though the bare day number is ungrounded", () => {
const prose =
"Your blood pressure spiked to 138 on July 21st, well above your typical range.";
expect(findUnverifiedCoachNumbers(prose, bpPayload)).toEqual([]);
});
it("does not corrupt the ordinal suffix when soft-stripping the rest of the reply", () => {
// Regression guard for the exact mangling reported: "[unverified]st".
const prose =
"Your blood pressure spiked to 138 on July 21st, well above your typical range.";
const findings = findUnverifiedCoachNumbers(prose, bpPayload);
const { prose: out } = stripUnverifiedNumbers(prose, findings);
expect(out).not.toContain("[unverified]st");
expect(out).toBe(prose);
});
it("does not flag numbered-list recommendation markers", () => {
// Item numbers run past 3 (the pre-existing small-ordinal exemption) so
// this genuinely exercises the line-start list-marker mask, not the
// unrelated small-integer rule.
const prose =
"Your systolic hit 138 today. A few ideas:\n1. Cut back on sodium\n2. Take a short walk after meals\n3. Log your next reading tomorrow\n4. Recheck in the evening\n5. Note any stress today";
expect(findUnverifiedCoachNumbers(prose, bpPayload)).toEqual([]);
});
it("still flags a fabricated figure sitting right next to a date ordinal", () => {
const prose = "On July 21st your systolic hit 199, a new high.";
const findings = findUnverifiedCoachNumbers(prose, bpPayload);
expect(findings).toHaveLength(1);
expect(findings[0].value).toBe(199);
});
it("still flags a genuine leading figure that happens to open a line (not list-shaped without the trailing space)", () => {
// "199kg" has no space after the number at all — never mistaken for a
// "N. " list marker, which requires a literal dot-then-space.
const prose = "199kg would be an alarming reading.";
const findings = findUnverifiedCoachNumbers(prose, bpPayload);
expect(findings).toHaveLength(1);
expect(findings[0].value).toBe(199);
});
it("still flags a decimal figure that opens a line (a digit, not whitespace, follows the dot)", () => {
// "199.5" at line-start is a genuine decimal reading, not a "N. " list
// marker — the masking regex only fires when whitespace follows the
// dot, so this stays fully checked.
const prose = "199.5 would be an alarming systolic reading.";
const findings = findUnverifiedCoachNumbers(prose, bpPayload);
expect(findings).toHaveLength(1);
expect(findings[0].value).toBe(199.5);
});
});
it("still flags a drifted number when the prose also derives / dates cleanly (adversarial floor)", () => {
// The classic P6 regression case must survive every new exemption.
const findings = findUnverifiedCoachNumbers(
"Your systolic averaged about 138 recently.",
payloads,
);
expect(findings).toHaveLength(1);
expect(findings[0].value).toBe(138);
});
});
describe("stripUnverifiedNumbers", () => {
+115 -2
View File
@@ -95,6 +95,114 @@ function isGrounded(
return false;
}
/**
* v1.32.1 (false-positive closure) — the small, named "central tendency"
* fields every snapshot block already exposes (`latest`, `avg7`, `avg30`,
* `avgSys30`, `allTimeAvg`, …). A model correctly narrating "down 3.5 kg from
* your 7-day average" is computing a DELTA between two of these — a
* legitimate derivation, not a fabrication — but the raw figure `3.5` is not
* itself a leaf anywhere in the payload, so the plain leaf-matching above
* flags it. Deliberately an ALLOWLIST of field names, not "every numeric
* leaf": a whole month of daily timeline samples would blow the pairwise
* derivation up combinatorially (see `deriveArithmeticValues`) and start
* matching a genuinely fabricated figure by coincidence, which is exactly the
* hole this checker exists to close. A raw sample, a count, or a day-offset
* never matches this pattern.
*/
const AGGREGATE_POINT_KEY =
/^(?:latest|mean|median|avg(?:Sys|Dia)?\d*(?:LastMonth|LastYear)?|allTime(?:Avg|Min|Max)(?:Sys|Dia)?)$/;
/**
* Defensive cap on how many aggregate points one payload may contribute to
* the pairwise derivation below. The named fields the allowlist targets are a
* handful per metric block (2-6); a payload that somehow surfaces more than
* this is not the small aggregate shape the heuristic targets, so skip
* deriving from it rather than pay an unbounded O(n^2).
*/
const MAX_AGGREGATE_POINTS = 12;
/** Collect every numeric leaf reachable under an `AGGREGATE_POINT_KEY` field. */
function collectAggregatePoints(
value: unknown,
out: Set<number>,
keyHint?: string,
): void {
if (value === null || value === undefined) return;
if (typeof value === "number") {
if (
Number.isFinite(value) &&
keyHint !== undefined &&
AGGREGATE_POINT_KEY.test(keyHint)
) {
out.add(value);
}
return;
}
if (Array.isArray(value)) {
for (const item of value) collectAggregatePoints(item, out, keyHint);
return;
}
if (typeof value === "object") {
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
collectAggregatePoints(v, out, k);
}
}
}
/**
* The delta, midpoint average, and percent-change between every PAIR of a
* SINGLE payload's own aggregate points — the shapes a model legitimately
* narrates ("down 3.5 kg from your 7-day average", "roughly a 5% drop").
* Scoped per payload (one tool result / the pinned workout block / one
* inventory row), never pooled across the whole turn's authoritative set, so
* a weight delta cannot ground itself against an unrelated blood-pressure
* figure that happens to land nearby.
*/
function deriveArithmeticValues(payload: unknown): number[] {
const points = new Set<number>();
collectAggregatePoints(payload, points);
const arr = [...points];
if (arr.length < 2 || arr.length > MAX_AGGREGATE_POINTS) return [];
const derived: number[] = [];
for (let i = 0; i < arr.length; i += 1) {
for (let j = i + 1; j < arr.length; j += 1) {
const a = arr[i];
const b = arr[j];
const diff = Math.abs(a - b);
derived.push(diff);
derived.push((a + b) / 2);
if (a !== 0) derived.push((diff / Math.abs(a)) * 100);
if (b !== 0) derived.push((diff / Math.abs(b)) * 100);
}
}
return derived;
}
/**
* v1.32.1 (false-positive closure) — mask date-ordinal and numbered-list
* markers OUT of a working copy of the prose before it is tokenised for
* numbers, so "on July 21st" or a "1. Reduce sodium" recommendation line never
* reads as a restated health figure. `extractNumbers` has no notion of
* surrounding context, so this pre-processing happens on a throwaway copy —
* the ORIGINAL prose (and hence `stripUnverifiedNumbers`, which edits the real
* text via the flagged token) is untouched. Deliberately high precision:
* - a number immediately followed by an English ordinal suffix (21st, 3rd,
* 142nd) is a day-of-month or a rank, never a restated magnitude;
* - a number at the START of a line followed by ". " (a numbered-list
* marker) is masked, but ONLY at line-start with a space right after the
* dot — "Systolic 128. Blood pressure remained stable." keeps its 128
* because that number does not open its own line, and "3.5 kg" keeps its
* figure because a digit (not whitespace) follows the dot.
*/
function extractClaimableNumbers(
prose: string,
): Array<{ value: number; raw: string }> {
const masked = prose
.replace(/\b\d{1,3}(?:st|nd|rd|th)\b/gi, "DATE")
.replace(/^(\s*)\d{1,3}\.(\s)/gm, "$1LIST$2");
return extractNumbers(masked);
}
/**
* Find every number the Coach prose asserts that does not trace to a figure
* returned by a tool this turn. Returns an empty array when there is no prose,
@@ -109,14 +217,19 @@ export function findUnverifiedCoachNumbers(
): UnverifiedCoachNumber[] {
if (!prose) return [];
const authoritative = new Set<number>();
for (const payload of toolPayloads)
for (const payload of toolPayloads) {
collectNumericLeaves(payload, authoritative);
// Widen the authoritative set with the derived figures a model may
// legitimately compute from this payload's own headline numbers, without
// pooling across unrelated payloads (see `deriveArithmeticValues`).
for (const d of deriveArithmeticValues(payload)) authoritative.add(d);
}
// No authoritative figures (a qualitative turn / no-tools path) — nothing to
// grade against. The prompt-level grounding rule remains the backstop.
if (authoritative.size === 0) return [];
const findings: UnverifiedCoachNumber[] = [];
for (const { value, raw } of extractNumbers(prose)) {
for (const { value, raw } of extractClaimableNumbers(prose)) {
if (isStructural(value, raw)) continue;
if (isGrounded(value, authoritative)) continue;
findings.push({ value, source: raw.slice(0, 32) });