Merge pull request #825 from MBombeck/verify/coach-grounding

Grade the Coach turn that went looking and found nothing
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-22 16:38:12 +02:00
commit fefda545ae
18 files changed
+1030 -52

No files matched your search

@@ -0,0 +1,409 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* REGRESSION — when the Coach's numeric grounding guard is armed, and when it
* deliberately is not.
*
* The guard rewrites any figure in the reply that the turn's Grounding Ledger
* cannot account for. Its activation used to key solely off `loop.toolResults`,
* and a tool that found nothing resolves to `{ present: false, reason: "none" }`
* with no `available` payload, which `loop.ts` drops (pinned in
* `tools-loop-miss-payloads.test.ts`). So the guard disarmed itself exactly
* when the record was empty, and a fabricated figure shipped verbatim with no
* withheld-figure marker.
*
* `toolTrace` now discriminates the two cases the old condition conflated:
*
* - tools RAN and every one missed -> grade. An empty ledger is the finding,
* not a reason to skip, so every magnitude is unreconciled. A reply that
* loses a figure this way is replaced with the honest "nothing recorded"
* answer rather than shipping elided prose that still asserts a series.
* - the model called NO tool -> stay dormant. This is the v1.32.1
* contract: the tool-mode base prompt deliberately carries no pre-computed
* figures, and flagging a legitimately recalled one was a real regression.
*/
const SNAPSHOT_JSON = '{"bp":{"aggregate":{"mean":128}}}';
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
HttpError: class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
},
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
isModuleEnabled: vi.fn(async () => true),
}));
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn(async () => undefined),
}));
vi.mock("@/lib/api-response", () => ({
apiError: (error: string, status: number) => ({ data: null, error, status }),
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(),
}));
vi.mock("@/lib/logging/redact", () => ({
redactSecrets: (s: string) => s,
redactOptional: (s: unknown) => s,
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn(async () => ({ coachPrefsJson: null })) },
coachConversation: { findFirst: vi.fn(async () => ({ id: "c1" })) },
},
}));
const { checkRateLimit } = vi.hoisted(() => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit }));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn(async () => "en"),
}));
const { runStreamingRawCompletionWithFallback } = vi.hoisted(() => ({
runStreamingRawCompletionWithFallback: vi.fn(),
}));
vi.mock("@/lib/ai/provider-runner", () => ({
AllProvidersFailedError: class extends Error {},
runStreamingRawCompletionWithFallback,
}));
const { resolveProviderChain } = vi.hoisted(() => ({
resolveProviderChain: vi.fn(),
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain,
resolveProvider: vi.fn(),
}));
const { assertConsentForChain } = vi.hoisted(() => ({
assertConsentForChain: vi.fn(async () => undefined),
}));
vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain }));
vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" }));
vi.mock("@/lib/ai/ai-budgets", () => ({
AI_BUDGETS: { coach: { maxTokens: 1500, temperature: 0.4 } },
}));
vi.mock("@/lib/ai/coach/types", async () => {
const actual = await vi.importActual<typeof import("@/lib/ai/coach/types")>(
"@/lib/ai/coach/types",
);
return actual;
});
const { fetchConversationWithMessages } = vi.hoisted(() => ({
fetchConversationWithMessages: vi.fn(),
}));
vi.mock("@/lib/ai/coach/persistence", () => ({
appendMessage: vi.fn(async () => ({ id: "m1" })),
createConversation: vi.fn(async () => ({ id: "c1" })),
fetchConversationWithMessages,
listConversations: vi.fn(),
}));
vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({
enqueueCoachMemoryRefresh: vi.fn(),
}));
vi.mock("@/lib/ai/coach/facts", () => ({
storeDeterministicFacts: vi.fn(async () => undefined),
}));
// Guard II — the schedule read. Empty here; the schedule-gated dose rule is
// unit-tested in the outbound-screen suite.
vi.mock("@/lib/medications/scheduled-doses", () => ({
getScheduledDoseValues: vi.fn(async () => []),
}));
const { reserveBudget, reconcileSpend } = vi.hoisted(() => ({
reserveBudget: vi.fn(async () => ({ allowed: true, reserved: 3000 })),
reconcileSpend: vi.fn(async () => undefined),
}));
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-07-23"),
reserveBudget,
reconcileSpend,
resolveDailyCap: vi.fn(() => 2_000_000),
}));
const { detectRefusal } = vi.hoisted(() => ({
detectRefusal: vi.fn(() => ({ refuse: false })),
}));
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal }));
vi.mock("@/lib/ai/coach/outbound-guard", () => ({
screenCoachReply: vi.fn(() => ({ block: false })),
coachOutboundFallback: vi.fn(() => "fallback"),
}));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(async () => null),
}));
vi.mock("@/lib/ai/coach/snapshot", () => ({
buildCoachSnapshot: vi.fn(async () => ({
snapshotJson: SNAPSHOT_JSON,
sections: { bloodPressure: { aggregate: { mean: 128 } } },
provenance: { windows: ["last30days"], metrics: ["bp"] },
referenceGrounding: "REFERENCE RANGES",
})),
}));
vi.mock("@/lib/workouts/hr-series", () => ({
buildWorkoutHrSeries: vi.fn(async () => null),
}));
vi.mock("@/lib/workouts/zones", () => ({
computeZones: vi.fn(() => null),
hrMaxFromAge: vi.fn(() => 185),
parseWhoopZoneDurations: vi.fn(() => null),
}));
vi.mock("@/lib/workouts/sport-context", () => ({
buildSportContext: vi.fn(async () => null),
}));
const {
buildCoachDataInventory,
renderDataInventory,
renderFocusHint,
runCoachToolLoop,
} = vi.hoisted(() => ({
buildCoachDataInventory: vi.fn(async () => ({
entries: [],
restMode: false,
cycleEnabled: false,
window: "last30days",
probeScope: { sources: ["bp"], window: "last30days" },
})),
renderDataInventory: vi.fn(() => "DATA INVENTORY\n- blood pressure: present"),
renderFocusHint: vi.fn(() => ""),
runCoachToolLoop: vi.fn(),
}));
vi.mock("@/lib/ai/coach/tools", () => ({
COACH_TOOL_DEFS: [{ name: "get_metric_series" }],
MAX_ROUNDS: 3,
buildCoachDataInventory,
renderDataInventory,
renderFocusHint,
buildToolModeAddendum: vi.fn(() => "TOOL ADDENDUM"),
runCoachToolLoop,
}));
const { parseKeyValuesSentinel } = vi.hoisted(() => ({
parseKeyValuesSentinel: vi.fn(),
}));
vi.mock("@/lib/ai/coach/keyvalues", () => ({ parseKeyValuesSentinel }));
const { parseSuggestReminder } = vi.hoisted(() => ({
parseSuggestReminder: vi.fn(),
}));
vi.mock("@/lib/ai/coach/suggest-reminder", () => ({ parseSuggestReminder }));
vi.mock("@/lib/ai/coach/suggest-gate", () => ({ gateSuggestion: vi.fn() }));
vi.mock("@/lib/validations/coach-prefs", () => ({
parseCoachPrefs: vi.fn(() => ({ defaultWindow: undefined })),
DEFAULT_REMINDER_SUGGESTION_PREFS: {},
}));
const { appendMessage } = await import("@/lib/ai/coach/persistence");
const sse = vi.hoisted(() => ({ done: Promise.resolve() as Promise<unknown> }));
vi.mock("@/lib/sse/create-stream", () => ({
createSseStream: (
producer: (c: {
signal: { aborted: boolean };
enqueue: () => void;
}) => void | Promise<void>,
) => {
sse.done = Promise.resolve(
producer({ signal: { aborted: false }, enqueue: () => {} }),
);
return new ReadableStream();
},
}));
import { POST } from "../route";
const post = POST as unknown as (req: Request) => Promise<Response>;
function chatReq(body: Record<string, unknown>): Request {
return new Request("http://localhost/api/insights/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
/** Force the model's assembled prose through the sentinel/suggest parsers. */
function stubReply(prose: string, toolResults: unknown[]): void {
parseKeyValuesSentinel.mockReturnValue({
prose,
keyValues: [],
malformed: false,
malformedEntries: [],
});
parseSuggestReminder.mockReturnValue({ prose });
runCoachToolLoop.mockImplementation(async () => ({
result: { content: prose, tokensUsed: 80, model: "m" },
workingProviderType: "anthropic",
totalTokens: 80,
rounds: 1,
toolTrace: [{ name: "get_metric_series", present: true }],
toolResults,
}));
}
function assistantContent(): string {
const calls = (appendMessage as ReturnType<typeof vi.fn>).mock.calls;
const assistant = calls.find(
(c) => (c[0] as { role: string }).role === "assistant",
);
return (assistant?.[0] as { content: string }).content;
}
function assistantProvenance(): Record<string, unknown> | null | undefined {
const calls = (appendMessage as ReturnType<typeof vi.fn>).mock.calls;
const assistant = calls.find(
(c) => (c[0] as { role: string }).role === "assistant",
);
return (
assistant?.[0] as {
metricSource?: Record<string, unknown> | null;
}
).metricSource;
}
describe("coach chat — grounding-guard activation", () => {
beforeEach(() => {
vi.clearAllMocks();
reserveBudget.mockResolvedValue({ allowed: true, reserved: 3000 });
detectRefusal.mockReturnValue({ refuse: false });
checkRateLimit.mockResolvedValue({ allowed: true });
assertConsentForChain.mockResolvedValue(undefined);
resolveProviderChain.mockResolvedValue([
{ providerType: "anthropic", instance: {} },
]);
fetchConversationWithMessages.mockResolvedValue({
id: "c1",
attachmentCount: 0,
summary: null,
messages: [],
});
});
/** Drive the loop with an explicit trace so a MISS is modelled faithfully. */
function stubMissedTurn(prose: string): void {
parseKeyValuesSentinel.mockReturnValue({
prose,
keyValues: [],
malformed: false,
malformedEntries: [],
});
parseSuggestReminder.mockReturnValue({ prose });
runCoachToolLoop.mockImplementation(async () => ({
result: { content: prose, tokensUsed: 80, model: "m" },
workingProviderType: "anthropic",
totalTokens: 80,
rounds: 1,
// The tool ran and found nothing; `loop.ts` drops the pure miss from
// `toolResults` but the trace still records the call.
toolTrace: [{ name: "get_sleep", present: false }],
toolResults: [],
}));
}
it("CONTROL — one present tool result arms the guard: the invented figure is stripped", async () => {
stubReply("Your sleep averaged 432 minutes last week.", [
{ present: true, data: { metric: "bp", aggregate: { avgSys30: 128 } } },
]);
await post(chatReq({ conversationId: "c1", message: "how did I sleep?" }));
await sse.done;
expect(assistantContent()).not.toContain("432");
expect(assistantContent()).toContain("[…]");
expect(assistantProvenance()?.unverifiedFigures).toBe(1);
});
it("every tool missed: the fabricated figure is caught and the turn is replaced", async () => {
stubMissedTurn("Your sleep averaged 432 minutes last week.");
await post(chatReq({ conversationId: "c1", message: "how did I sleep?" }));
await sse.done;
const content = assistantContent();
expect(content).not.toContain("432");
// Not the elided prose either — the sentence asserted a series the record
// does not hold, so the whole turn is replaced with the honest answer.
expect(content).not.toContain("[…]");
expect(content).toContain("recorded readings");
// The replacement copy says it in words, so the count-based notice is off.
expect(assistantProvenance()?.unverifiedFigures).toBeUndefined();
});
it("every tool missed but the figure is grounded by a prior turn: left alone", async () => {
// Turn 1 fetched systolic 128 and persisted it as a tool figure. This turn
// every tool misses, but recalling 128 still reconciles, so nothing is
// stripped and the reply is NOT replaced.
fetchConversationWithMessages.mockResolvedValue({
id: "c1",
attachmentCount: 0,
summary: null,
messages: [
{ role: "user", content: "How is my BP?" },
{
role: "assistant",
content: "Your systolic averaged 128.",
metricSource: { groundedFigures: [128] },
},
],
});
stubMissedTurn(
"No sleep readings yet. Your systolic 128 average is unrelated to that.",
);
await post(chatReq({ conversationId: "c1", message: "and my sleep?" }));
await sse.done;
const content = assistantContent();
expect(content).toContain("128");
expect(content).not.toContain("[…]");
expect(content).not.toContain("recorded readings");
});
it("the model called no tool at all: the guard stays dormant (v1.32.1)", async () => {
// Deliberately unchanged behaviour. The tool-mode base prompt carries no
// pre-computed figures, so a number here came from the transcript or the
// model's own recall, and grading it off the counts-only inventory flagged
// legitimate figures as ungrounded. `toolTrace` is empty, so the widened
// activation above does not reach this case.
const prose = "Your resting heart rate has been averaging 58 bpm.";
parseKeyValuesSentinel.mockReturnValue({
prose,
keyValues: [],
malformed: false,
malformedEntries: [],
});
parseSuggestReminder.mockReturnValue({ prose });
runCoachToolLoop.mockImplementation(async () => ({
result: { content: prose, tokensUsed: 80, model: "m" },
workingProviderType: "anthropic",
totalTokens: 80,
rounds: 1,
toolTrace: [],
toolResults: [],
}));
await post(chatReq({ conversationId: "c1", message: "how is my RHR?" }));
await sse.done;
expect(assistantContent()).toBe(prose);
expect(assistantProvenance()?.unverifiedFigures).toBeUndefined();
});
});
+76 -2
View File
@@ -639,6 +639,13 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
// delivered this turn (`includeFullSnapshot`); on a cheap follow-up the block
// was not re-sent, so there is no fresh authoritative set to grade against.
let noToolsSnapshotPayloads: unknown[] = [];
// The DATA INVENTORY manifest (per-domain sample counts) that rode this
// turn's tool-mode prompt. Hoisted out of the tool branch because the
// all-missed activation below needs it as a WIDENER: the counts were in
// front of the model, so a plain count restatement ("you've logged 42 BP
// readings") must stay grounded even on a turn whose every tool missed.
// Never an ACTIVATOR on its own — see the v1.32.1 note below.
let inventoryPayloads: unknown[] = [];
let totalTokensSpent: number;
// v1.21.0 (F3) — cached-input tokens to subtract at reconcile (prompt-cached
// input the user did not re-pay for must not be billed to the daily meter).
@@ -705,6 +712,7 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
// out-of-window aggregate rule 3 lets the model cite. Ground it.
...(loop.toolResults ?? []).map((r) => r.data ?? r.available),
];
inventoryPayloads = [inventory.entries];
toolResultPayloads =
presentToolPayloads.length > 0
? [...presentToolPayloads, inventory.entries]
@@ -993,17 +1001,43 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
// active grading — they never ACTIVATE it, so a snapshot figure the model
// cited on a no-tool turn is still left alone (the v1.32.1 regression guard
// holds). Assistant prose is never a ledger source (D3).
//
// The turn that CALLED tools and got nothing back is graded too. It used to
// be the one turn the verifier sat out: a pure miss carries no `data` and no
// `available`, `runCoachToolLoop` drops that shape from `toolResults`, and
// the no-tools snapshot fallback below is populated only in the non-tool
// branch — so `activatingPayloads` came out empty and every figure in the
// reply shipped unchecked. That is backwards. A turn whose every tool
// reported `{ present: false }` is not an absence of evidence about the
// reply, it is evidence that the record holds nothing to cite, which is
// exactly when a fabricated figure is both likeliest and most harmful.
//
// `toolTrace` is the discriminant, because it records every tool that ran
// INCLUDING the pure misses. It separates the two cases the old condition
// conflated: tools ran and found nothing (grade — the model was told the
// record is empty and answered with numbers anyway), versus the model
// answered without calling a tool at all (stay dormant — the v1.32.1
// regression, where the base prompt deliberately carries no pre-computed
// figures and flagging a legitimately recalled one was a real defect).
const missedEveryTool =
toolMode && toolTrace.length > 0 && toolResultPayloads.length === 0;
const activatingPayloads =
toolResultPayloads.length > 0
? toolResultPayloads
: noToolsSnapshotPayloads;
: missedEveryTool
? inventoryPayloads
: noToolsSnapshotPayloads;
let groundedFigures: number[] = [];
// v1.32.14 — count of figures withheld from THIS reply (each rewritten to the
// elision mark). Hoisted out of the guard block so it can ride the provenance
// envelope and drive the quiet per-message notice. Stays 0 on a blocked turn
// (the guard is skipped, its fallback prose carries no figures).
let unverifiedStripped = 0;
if (activatingPayloads.length > 0) {
// True when the guard stripped a figure on a turn whose every tool missed —
// i.e. the model put a number on a record that holds none. Drives the
// honest-replacement below.
let fabricatedOnEmptyRecord = false;
if (activatingPayloads.length > 0 || missedEveryTool) {
const ledger = buildGroundingLedger({
toolPayloads: activatingPayloads,
priorToolFigures,
@@ -1026,6 +1060,9 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
replyText,
ledger,
locale,
// On an all-missed turn an EMPTY ledger is the finding, not a reason
// to skip: nothing was retrievable, so nothing can reconcile.
{ gradeAgainstEmptyLedger: missedEveryTool },
);
if (unverified.length > 0) {
const { prose: corrected, stripped } = stripUnverifiedNumbers(
@@ -1034,6 +1071,7 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
);
replyText = corrected;
unverifiedStripped = stripped;
fabricatedOnEmptyRecord = missedEveryTool && stripped > 0;
annotate({
action: { name: "coach.prose.number_unverified" },
meta: {
@@ -1048,6 +1086,42 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
}
}
// A stripped reply on an empty record is not worth sending. Eliding the
// digits leaves the sentences that carried them — "your sleep averaged […]
// minutes, up from […]" still asserts a series, an average and a direction
// for a metric the tools just reported has no readings at all. The false
// claim survives the strip; only its precision goes. So replace the turn
// with the honest answer instead of shipping the elided mush.
//
// Scoped tightly: this fires only when every tool missed AND the guard
// actually stripped something. A reply that cited a legitimately grounded
// figure (a prior turn's tool result, an inventory count) reconciles
// against the ledger, strips nothing, and is left alone.
//
// REPLACE, not withhold — the same policy the outbound screen uses on this
// surface, for the same reason: the user is waiting on a synchronous answer
// and silence reads as a failure.
//
// Accepted trade-off: a reply mixing one grounded recall with one
// fabrication is replaced wholesale, losing the good half. That is the
// right way round — the alternative leaves an invented figure's sentence
// on screen.
if (fabricatedOnEmptyRecord) {
replyText = getServerTranslator(locale).t("coach.noRecordedData");
// The reply now carries no figures at all, so the withheld-figure notice
// would be describing prose the reader can no longer see. The replacement
// copy states the same thing in plain words.
unverifiedStripped = 0;
annotate({
action: { name: "coach.prose.empty_record_replaced" },
meta: { promptVersion: PROMPT_VERSION },
});
await auditLog("insights.coach.empty_record_replaced", {
userId,
details: { conversationId: workingConversationId },
});
}
// v1.21.0 (NEW-C C-3) — Learn-link post-filter. The prompt instructs the
// model to only link a published `/learn/<slug>`, but that is guidance, not
// enforcement: a fabricated `/learn/<invented-slug>` would otherwise ship as
@@ -241,12 +241,35 @@ describe("<HeroStrip>", () => {
);
});
it("renders the personal-baseline meta line", () => {
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
it("renders the personal-baseline meta line under a real briefing", () => {
const html = render(
<HeroStrip briefing={sampleBriefing} now={morningLocal} />,
);
expect(html).toMatch(/data-slot="insights-hero-strip-baseline"/);
expect(html).toContain("Based on your last 90 days");
});
/**
* Inverted from the previous assertion, which rendered with
* `briefing={null}` and still expected the baseline line.
*
* That pinned the defect. The line claims the subtitle above it was drawn
* from the reader's own last 90 days, but with no briefing the subtitle is
* `heroFallbackSubtitle` — fixed copy computed from nothing. Both siblings in
* the same meta row (the freshness caption, the no-provider hint) already
* gate on their own data; this one did not, so an account with no briefing at
* all was told its generic sentence rested on a personal baseline.
*/
it("withholds the personal-baseline meta line when there is no briefing", () => {
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
expect(html).not.toMatch(/data-slot="insights-hero-strip-baseline"/);
expect(html).not.toContain("Based on your last 90 days");
// The fallback subtitle still renders — only the provenance claim goes.
expect(html).toContain(
"A daily read of your trends, drawn straight from the numbers you&#x27;ve logged.",
);
});
it("renders the freshness caption when updatedAt is supplied", () => {
// v1.22 — the hero freshness line now uses `formatUpdatedLabel` for parity
// with the briefing + per-metric cards: a same-day timestamp reads
+16 -6
View File
@@ -199,14 +199,24 @@ export function HeroStrip({
<ProseBlocks text={subtitle} />
</div>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span data-slot="insights-hero-strip-baseline">
{t("insights.heroPersonalBaseline")}
</span>
{/* The baseline line describes where the SUBTITLE came from, so it
may only show when the subtitle IS a briefing. Without one the
subtitle falls back to generic copy derived from nothing, and
this line went on claiming a personal baseline underneath it —
the one part of the row that was ungated while both its
siblings already checked for their own data first. */}
{briefing && (
<span data-slot="insights-hero-strip-baseline">
{t("insights.heroPersonalBaseline")}
</span>
)}
{generatedLine && (
<>
<span aria-hidden="true" className="opacity-50">
·
</span>
{briefing && (
<span aria-hidden="true" className="opacity-50">
·
</span>
)}
<span data-slot="insights-hero-strip-generated">
{generatedLine}
</span>
@@ -28,8 +28,8 @@ import { apiGet } from "@/lib/api/api-fetch";
* nothing and a cold sub-page warms the cache its siblings reuse. We
* never recompute the ranges here: the route is the single source of
* truth for the age-based ESH blood-pressure band, the WHO weight / BMI
* band, the Karvonen pulse band, the AASM sleep band, the ADA / DDG
* glucose bands, and the medication / mood targets.
* band, the CDC/NCHS percentile pulse band, the AASM sleep band, the
* ADA / DDG glucose bands, and the medication / mood targets.
*
* The panel is a compact reference panel carrying the full target
* context:
@@ -0,0 +1,125 @@
/**
* CHARACTERISATION — which tool results reach `toolResults`, and why it matters.
*
* `toolResults` is not just a trace. The chat route derives
* `activatingPayloads` from it, and the numeric grounding guard runs ONLY when
* that array is non-empty (`insights/chat/route.ts`, the
* `activatingPayloads.length > 0` gate). So whatever the loop drops here, the
* guard never sees.
*
* Two shapes of empty read, from `availability.ts` `resolveEmptyRead`:
* - nothing recorded at all -> `{ present: false, reason: "none" }`, no `available`
* - rows exist out of window -> `{ present: false, reason: "unavailable_in_scope", available }`
*
* Only the second survives `loop.ts`'s push condition. The first is dropped,
* which disarms the grounding guard for that turn. This test pins the drop so
* the consequence is visible at the seam where it happens.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
const executeCoachTool = vi.fn<(args?: unknown) => Promise<CoachToolResult>>();
vi.mock("@/lib/ai/coach/tools/executor", () => ({
executeCoachTool: (args: unknown) => executeCoachTool(args),
}));
const runRawCompletionWithFallback = vi.fn();
vi.mock("@/lib/ai/provider-runner", () => ({
runRawCompletionWithFallback: (args: unknown) =>
runRawCompletionWithFallback(args),
}));
import { runCoachToolLoop } from "@/lib/ai/coach/tools/loop";
import { COACH_TOOL_DEFS } from "@/lib/ai/coach/tools/definitions";
const baseArgs = {
userId: "u1",
providers: [],
system: "sys",
messages: [{ role: "user" as const, content: "how did I sleep?" }],
tools: COACH_TOOL_DEFS,
};
describe("coach tool loop — which results reach the grounding guard", () => {
beforeEach(() => {
executeCoachTool.mockReset();
runRawCompletionWithFallback.mockReset();
});
it("a pure miss (present:false, no available) is dropped from toolResults", async () => {
executeCoachTool.mockResolvedValue({ present: false, reason: "none" });
runRawCompletionWithFallback
.mockResolvedValueOnce({
result: {
content: "",
tokensUsed: 30,
model: "mock",
providerType: "anthropic" as const,
toolCalls: [
{ id: "t1", name: "get_sleep", arguments: JSON.stringify({}) },
],
finishReason: "tool_calls",
},
workingProvider: { providerType: "anthropic" },
fallbackHops: [],
})
.mockResolvedValueOnce({
result: {
content: "You averaged 7 h 12 min of sleep.",
tokensUsed: 20,
model: "mock",
providerType: "anthropic" as const,
finishReason: "stop",
},
workingProvider: { providerType: "anthropic" },
fallbackHops: [],
});
const loop = await runCoachToolLoop(baseArgs);
// The trace records that the tool ran and found nothing…
expect(loop.toolTrace).toEqual([{ name: "get_sleep", present: false }]);
// …but the payload set the route grades against is empty, so the route's
// `activatingPayloads.length > 0` gate is false and the numeric grounding
// guard never runs for this turn.
expect(loop.toolResults).toEqual([]);
});
it("an out-of-window miss WITH `available` is kept, so the guard stays armed", async () => {
executeCoachTool.mockResolvedValue({
present: false,
reason: "unavailable_in_scope",
available: { count: 12, reachableWithWindow: "lastYear" },
} as unknown as CoachToolResult);
runRawCompletionWithFallback
.mockResolvedValueOnce({
result: {
content: "",
tokensUsed: 30,
model: "mock",
providerType: "anthropic" as const,
toolCalls: [
{ id: "t1", name: "get_sleep", arguments: JSON.stringify({}) },
],
finishReason: "tool_calls",
},
workingProvider: { providerType: "anthropic" },
fallbackHops: [],
})
.mockResolvedValueOnce({
result: {
content: "Nothing in the last 30 days.",
tokensUsed: 20,
model: "mock",
providerType: "anthropic" as const,
finishReason: "stop",
},
workingProvider: { providerType: "anthropic" },
fallbackHops: [],
});
const loop = await runCoachToolLoop(baseArgs);
expect(loop.toolResults).toHaveLength(1);
});
});
+15 -3
View File
@@ -739,11 +739,23 @@ export function findUnverifiedCoachNumbersInLedger(
prose: string,
ledger: ReadonlyArray<LedgerEntry>,
locale: Locale = "en",
opts: { gradeAgainstEmptyLedger?: boolean } = {},
): UnverifiedCoachNumber[] {
if (!prose) return [];
// No authoritative figures (a qualitative turn / no-tools path) — nothing to
// grade against. The prompt-level grounding rule remains the backstop.
if (ledger.length === 0) return [];
// An empty ledger is ambiguous on its own, so the caller resolves it.
//
// Default (`false`): no authoritative figures were delivered — a qualitative
// turn, or the no-tools path on a cheap follow-up. Nothing to grade against,
// and the prompt-level grounding rule remains the backstop.
//
// `gradeAgainstEmptyLedger: true`: the caller KNOWS this turn went looking
// and came back with nothing — every retrieval tool it called reported
// `{ present: false }`. Then an empty ledger is not "no information", it is
// the positive statement that the record holds nothing to cite, so every
// magnitude in the reply is unreconciled by construction and must be graded
// as such. Only the chat route passes this; the fenced-chat and eval callers
// keep the default.
if (ledger.length === 0 && !opts.gradeAgainstEmptyLedger) return [];
const spans = sentenceSpans(prose);
const sentenceAt = (index: number): string => {
+111 -7
View File
@@ -16,6 +16,14 @@
* directly, so the answer arrives with the data instead of depending on the
* reader working it out.
*
* The same pass reconciles each block's coarse (MONTH / YEAR) timeline, served
* from the rollup tier, against the all-time extremes read live beside it. The
* read-swap falls back to live SQL only when a band is EMPTY, so a bucket whose
* rows were hard-deleted with no invalidation keeps being served as current —
* and the block then states two different things about the same record with
* nothing saying which is right. A bucket mean cannot lie outside its own rows'
* extremes, so when it does the band is dropped rather than narrated.
*
* Pure and in-place — the snapshot is a plain record by the time it gets here.
*/
import {
@@ -35,6 +43,12 @@ export interface SnapshotAsOf {
* the day they were taken on, and must be stated with that day.
*/
currentForTodayClaims: boolean;
/**
* Set when the block's coarse (MONTH / YEAR) timeline was dropped because it
* could not be reconciled with the live record beside it. Present only in
* that case, so its absence is the normal state and costs no prompt budget.
*/
coarseHistoryWithheld?: true;
}
function readNewestDaysAgo(block: unknown): number | null {
@@ -61,27 +75,117 @@ export function asOfFromDaysAgo(daysAgo: number): SnapshotAsOf {
};
}
/** Coarse-band bucket rows are `[bucketStart, mean, min, max]`. */
type CoarseBucket = [string, number, number, number];
/** Rounding headroom, so a float artefact is never read as a contradiction. */
const RECONCILE_EPSILON = 1e-6;
function readAllTimeExtremes(
block: unknown,
): { min: number; max: number } | null {
if (typeof block !== "object" || block === null) return null;
const aggregate = (block as { aggregate?: unknown }).aggregate;
if (typeof aggregate !== "object" || aggregate === null) return null;
const min = (aggregate as { allTimeMin?: unknown }).allTimeMin;
const max = (aggregate as { allTimeMax?: unknown }).allTimeMax;
if (typeof min !== "number" || !Number.isFinite(min)) return null;
if (typeof max !== "number" || !Number.isFinite(max)) return null;
return { min, max };
}
function readCoarse(block: unknown): Record<string, unknown> | null {
if (typeof block !== "object" || block === null) return null;
const timeline = (block as { timeline?: unknown }).timeline;
if (typeof timeline !== "object" || timeline === null) return null;
const coarse = (timeline as { coarse?: unknown }).coarse;
if (typeof coarse !== "object" || coarse === null) return null;
return coarse as Record<string, unknown>;
}
function bucketsOf(coarse: Record<string, unknown>, band: string): number[] {
const rows = coarse[band];
if (!Array.isArray(rows)) return [];
return (rows as CoarseBucket[])
.map((row) => (Array.isArray(row) ? row[1] : Number.NaN))
.filter((v): v is number => typeof v === "number" && Number.isFinite(v));
}
/**
* Decide whether a block's coarse timeline can be reconciled with the live
* record it sits beside.
*
* The coarse MONTH / YEAR bands are served from the rollup tier; the aggregate's
* all-time extremes are read live. A bucket mean is an average of real rows, so
* it MUST lie within the all-time min/max of those same rows. When it does not,
* the rollup is describing readings the live read cannot see — the bucket
* outlived the rows it was folded from.
*
* This is an invariant, not a heuristic: no arrangement of existing rows can
* average to a value outside their own extremes.
*/
function coarseContradictsRecord(block: unknown): boolean {
const extremes = readAllTimeExtremes(block);
if (extremes === null) return false;
const coarse = readCoarse(block);
if (coarse === null) return false;
const means = [
...bucketsOf(coarse, "monthly"),
...bucketsOf(coarse, "yearly"),
];
if (means.length === 0) return false;
return means.some(
(mean) =>
mean < extremes.min - RECONCILE_EPSILON ||
mean > extremes.max + RECONCILE_EPSILON,
);
}
/**
* Attach `asOf` to every snapshot block whose aggregate reports a freshest
* reading. Blocks with no coverage (narrative memory, plans, the reference
* grounding table) are untouched — they carry no measurement to date.
*
* Returns the metric keys that were stamped stale, so the caller can annotate
* them: a briefing narrated off a week-old series is worth seeing in the wide
* event, not just in the reader's confusion.
* Also drops a coarse timeline that contradicts the live record beside it. The
* rollup tier's read-swap falls back to live SQL only when a band is EMPTY, so
* a bucket that is merely WRONG — the rows behind it deleted, and no
* invalidation fired — is served as though it were current. The block then
* carried two irreconcilable accounts of the same record (a coarse tail saying
* one thing, all-time extremes saying another) with nothing marking which to
* believe, under an `asOf` stamped from raw reading age that knows nothing
* about rollup age. Narrating a ghost is worse than narrating less, so the
* disputed tail goes and `coarseHistoryWithheld` says it went.
*
* `currentForTodayClaims` stays keyed to raw recency, which is what it means:
* the freshest READING is still as fresh as it was, and suppressing it would
* silence a true present-tense statement to punish a stale history band.
*
* Returns the metric keys stamped stale and the keys whose coarse tail was
* withheld, so the caller can annotate both: a briefing narrated off a week-old
* series is worth seeing in the wide event, and so is a rollup that has drifted
* away from the rows underneath it.
*/
export function annotateSnapshotFreshness(
snapshot: Record<string, unknown>,
): string[] {
export function annotateSnapshotFreshness(snapshot: Record<string, unknown>): {
stale: string[];
coarseWithheld: string[];
} {
const stale: string[] = [];
const coarseWithheld: string[] = [];
for (const [key, block] of Object.entries(snapshot)) {
const daysAgo = readNewestDaysAgo(block);
if (daysAgo === null) continue;
const asOf = asOfFromDaysAgo(daysAgo);
if (coarseContradictsRecord(block)) {
const timeline = (block as { timeline?: Record<string, unknown> })
.timeline;
if (timeline) delete timeline.coarse;
asOf.coarseHistoryWithheld = true;
coarseWithheld.push(key);
}
(block as Record<string, unknown>).asOf = asOf;
if (!asOf.currentForTodayClaims) stale.push(key);
}
return stale;
return { stale, coarseWithheld };
}
export { TODAY_CLAIM_MAX_AGE_DAYS };
+12 -3
View File
@@ -1238,11 +1238,20 @@ async function buildCoachSnapshotImpl(
// hero line said "today" about a metric last measured five days earlier. The
// stamp sits on the block itself so the age travels with the numbers into
// every surface the snapshot feeds — hero, briefing, Coach reply, tools.
const staleBlocks = annotateSnapshotFreshness(compactSnapshot);
if (staleBlocks.length > 0) {
const freshness = annotateSnapshotFreshness(compactSnapshot);
if (freshness.stale.length > 0) {
annotate({
action: { name: "coach.snapshot.stale_blocks" },
meta: { blocks: staleBlocks.sort() },
meta: { blocks: freshness.stale.sort() },
});
}
// A rollup band that no longer reconciles with the rows underneath it is an
// operator-visible defect, not just a narration problem — the bucket is wrong
// on disk and stays wrong until something recomputes it.
if (freshness.coarseWithheld.length > 0) {
annotate({
action: { name: "coach.snapshot.coarse_withheld" },
meta: { blocks: freshness.coarseWithheld.sort() },
});
}
+7 -2
View File
@@ -68,14 +68,19 @@ export function getPersonalizedPulseTarget(
age: number | null,
gender: PulseGender,
): PersonalizedPulseTarget {
// AHA fallback for adults when profile context is missing.
// No age to personalise against (missing profile, or under 20 where the
// percentile table does not apply), so this arm returns a fixed band that is
// the same for everyone. The source label says so in as many words: the
// surrounding UI describes the pulse band as age- and sex-based, and a reader
// who falls into this arm needs to know theirs is not.
if (age == null || age < 20) {
return {
greenMin: 60,
greenMax: 100,
orangeMin: 55,
orangeMax: 105,
source: "AHA (adults, resting pulse 60-100 bpm)",
source:
"AHA general adult range (resting pulse 60-100 bpm) — not personalised",
};
}
@@ -119,9 +119,11 @@ describe("the Coach snapshot stamp", () => {
scope: { sources: ["pulse", "weight"] },
};
const stale = annotateSnapshotFreshness(snapshot);
const { stale, coarseWithheld } = annotateSnapshotFreshness(snapshot);
expect(stale).toEqual(["pulse"]);
// Neither block carries a coarse band, so nothing is disputed.
expect(coarseWithheld).toEqual([]);
expect((snapshot.pulse as { asOf: unknown }).asOf).toEqual({
daysAgo: 5,
isToday: false,
@@ -135,4 +137,63 @@ describe("the Coach snapshot stamp", () => {
expect(snapshot.memory).toEqual({ headline: "steady month" });
expect(snapshot.scope).toEqual({ sources: ["pulse", "weight"] });
});
/**
* A coarse bucket mean is an average of real rows, so it cannot sit outside
* those rows' own all-time extremes. When it does, the rollup band is
* describing readings the live read cannot see and must not be narrated.
*/
it("drops a coarse band whose bucket mean the live extremes cannot account for", () => {
const snapshot: Record<string, unknown> = {
weight: {
aggregate: {
allTimeMin: 80,
allTimeMax: 80,
coverage: { count: 6, newestDaysAgo: 0 },
},
timeline: {
recent: [],
coarse: { monthly: [["2026-01-01", 95, 95, 95]], yearly: [] },
},
},
};
const { coarseWithheld } = annotateSnapshotFreshness(snapshot);
expect(coarseWithheld).toEqual(["weight"]);
const weight = snapshot.weight as {
timeline: { coarse?: unknown };
asOf: { coarseHistoryWithheld?: true; currentForTodayClaims: boolean };
};
expect(weight.timeline.coarse).toBeUndefined();
expect(weight.asOf.coarseHistoryWithheld).toBe(true);
// Raw recency is untouched — the surviving readings are still from today.
expect(weight.asOf.currentForTodayClaims).toBe(true);
});
it("keeps a coarse band that reconciles with the live extremes", () => {
const snapshot: Record<string, unknown> = {
weight: {
aggregate: {
allTimeMin: 78,
allTimeMax: 96,
coverage: { count: 40, newestDaysAgo: 0 },
},
timeline: {
recent: [],
coarse: { monthly: [["2026-01-01", 95, 94, 96]], yearly: [] },
},
},
};
const { coarseWithheld } = annotateSnapshotFreshness(snapshot);
expect(coarseWithheld).toEqual([]);
const weight = snapshot.weight as {
timeline: { coarse?: unknown };
asOf: { coarseHistoryWithheld?: true };
};
expect(weight.timeline.coarse).toBeDefined();
expect(weight.asOf.coarseHistoryWithheld).toBeUndefined();
});
});