diff --git a/src/components/__tests__/failed-read-never-empty.test.tsx b/src/components/__tests__/failed-read-never-empty.test.tsx index ef60997ce..8b4eaf6ff 100644 --- a/src/components/__tests__/failed-read-never-empty.test.tsx +++ b/src/components/__tests__/failed-read-never-empty.test.tsx @@ -155,6 +155,10 @@ describe("read-failure branch is wired on every swept surface", () => { "src/components/custom-metrics/custom-metric-values-list.tsx", "src/app/achievements/page.tsx", "src/components/insights/nutrients/micronutrients-card.tsx", + // A failed comprehensive read used to fall through to "you haven't added + // any medications yet" — with a CTA inviting someone on a full daily + // regimen to re-enter what the app already holds. + "src/app/insights/medications/page.tsx", // The three sleep-rhythm cards share one read, so the PAGE owns the single // error notice rather than each card painting its own copy of it. "src/app/insights/sleep/page.tsx", diff --git a/src/lib/ai/coach/__tests__/coach-availability-window-honesty.test.ts b/src/lib/ai/coach/__tests__/coach-availability-window-honesty.test.ts new file mode 100644 index 000000000..63d849ec5 --- /dev/null +++ b/src/lib/ai/coach/__tests__/coach-availability-window-honesty.test.ts @@ -0,0 +1,556 @@ +/** + * A history the retrieval window cannot reach is not an absent metric (#648). + * + * The reported case is the fixture: a substantial glucose history imported from + * a fortnight in April 2024, on an account whose glucose surfaces display it + * correctly, asked about through the Coach. Every assertion here exists to keep + * the two situations distinguishable end to end — the tool result, the DATA + * INVENTORY line the model reads before it decides whether to fetch at all, and + * the two instructions that used to tell it to report absence and move on. + * + * Dates are fixed and `now` is injected, so the fixture cannot slide out of its + * window as the calendar moves. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot"; + +const buildCoachSnapshot = + vi.fn<(userId: string, scope?: unknown) => Promise>(); +vi.mock("@/lib/ai/coach/snapshot", () => ({ + buildCoachSnapshot: (userId: string, scope?: unknown) => + buildCoachSnapshot(userId, scope), +})); + +vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({ + readCoachCorrelations: () => Promise.resolve({ present: false }), +})); +vi.mock("@/lib/ai/coach/illness-snapshot", () => ({ + buildIllnessScores: () => Promise.resolve(null), +})); +vi.mock("@/lib/cycle/gate", () => ({ + isCycleAvailableForUser: () => Promise.resolve(false), +})); + +// The probe is the point of the fix, so it runs for real against a stubbed +// Prisma. Every aggregate the probe can issue is scripted here. +interface GroupRow { + type: string; + unit: string | null; + _count: { _all: number }; + _min: { measuredAt: Date | null; value: number | null }; + _max: { measuredAt: Date | null; value: number | null }; + _avg: { value: number | null }; +} +let measurementRows: GroupRow[] = []; +let measurementThrows = false; +const workoutAggregate = vi.fn(); +const moodAggregate = vi.fn(); +const intakeAggregate = vi.fn(); +const labAggregate = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { + groupBy: (args: { where: { type: { in: string[] } } }) => { + if (measurementThrows) { + return Promise.reject(new Error("probe down")); + } + const wanted = new Set(args.where.type.in); + return Promise.resolve( + measurementRows.filter((r) => wanted.has(r.type)), + ); + }, + }, + workout: { aggregate: (a: unknown) => workoutAggregate(a) }, + moodEntry: { aggregate: (a: unknown) => moodAggregate(a) }, + medicationIntakeEvent: { aggregate: (a: unknown) => intakeAggregate(a) }, + labResult: { aggregate: (a: unknown) => labAggregate(a) }, + }, +})); + +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: () => Promise.resolve("UTC"), +})); + +import { executeCoachTool } from "@/lib/ai/coach/tools/executor"; +import { + buildCoachDataInventory, + renderDataInventory, +} from "@/lib/ai/coach/tools/inventory"; +import { buildToolModeAddendum } from "@/lib/ai/coach/tools/system-addendum"; +import { + classifyAvailability, + probeCoachAvailability, +} from "@/lib/ai/coach/tools/availability"; + +/** The reported import: a fortnight of readings, well over a year old. */ +const HISTORIC_GLUCOSE: GroupRow = { + type: "BLOOD_GLUCOSE", + unit: "mg/dL", + _count: { _all: 1597 }, + _min: { measuredAt: new Date("2024-04-03T06:12:00Z"), value: 61 }, + _max: { measuredAt: new Date("2024-04-17T21:40:00Z"), value: 243 }, + _avg: { value: 118.4 }, +}; + +/** A reading day inside the searched window, for the in-scope branch. */ +function recentRow(type: string, daysAgo: number, now: Date): GroupRow { + const at = new Date(now.getTime() - daysAgo * 86_400_000); + return { + type, + unit: "bpm", + _count: { _all: 42 }, + _min: { measuredAt: at, value: 52 }, + _max: { measuredAt: at, value: 71 }, + _avg: { value: 61 }, + }; +} + +const NOW = new Date("2026-07-26T09:00:00Z"); + +function snapshot(sections: Record): CoachSnapshotResult { + return { + snapshotJson: JSON.stringify(sections), + sections, + provenance: { windows: [], metrics: [] }, + referenceGrounding: null, + }; +} + +function emptyAggregate() { + return { + _count: { _all: 0 }, + _min: { + startedAt: null, + moodLoggedAt: null, + scheduledFor: null, + takenAt: null, + }, + _max: { + startedAt: null, + moodLoggedAt: null, + scheduledFor: null, + takenAt: null, + }, + }; +} + +beforeEach(() => { + // The clock is pinned so the fixture cannot age out of its window: the + // executor's probe reads `new Date()` (there is no request-scoped clock on + // that path), and a relative-date fixture that drifts past 365 days would + // start asserting the opposite of what it was written to prove. + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(NOW); + buildCoachSnapshot.mockReset(); + measurementRows = []; + measurementThrows = false; + for (const fn of [ + workoutAggregate, + moodAggregate, + intakeAggregate, + labAggregate, + ]) { + fn.mockReset(); + fn.mockResolvedValue(emptyAggregate()); + } +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("the reported case: a metric whose whole history is outside the window", () => { + it("reports outside_window with the range and a bounded aggregate, NOT no_data", async () => { + measurementRows = [HISTORIC_GLUCOSE]; + buildCoachSnapshot.mockResolvedValue(snapshot({})); + + const result = await executeCoachTool({ + userId: "u1", + name: "get_glucose_panel", + rawArguments: JSON.stringify({ window: "allTime" }), + }); + + expect(result.present).toBe(false); + expect(result.reason).toBe("outside_window"); + expect(result.searchedWindow).toBe("allTime"); + expect(result.available).toMatchObject({ + count: 1597, + firstDate: "2024-04-03", + lastDate: "2024-04-17", + // Older than any window reaches — so there is nothing to re-call with, + // and the honest sentence is the whole answer. + reachableWithWindow: null, + }); + expect(result.available?.series).toEqual([ + { + series: "BLOOD_GLUCOSE", + unit: "mg/dL", + count: 1597, + mean: 118.4, + min: 61, + max: 243, + }, + ]); + }); + + it("keeps a never-recorded metric distinguishable from the same run", async () => { + measurementRows = [HISTORIC_GLUCOSE]; + buildCoachSnapshot.mockResolvedValue(snapshot({})); + + const historic = await executeCoachTool({ + userId: "u1", + name: "get_glucose_panel", + rawArguments: JSON.stringify({ window: "allTime" }), + }); + const neverRecorded = await executeCoachTool({ + userId: "u1", + name: "get_metric_series", + rawArguments: JSON.stringify({ metric: "hrv", window: "allTime" }), + }); + + // Both are misses; they are NOT the same miss. Collapsing the two reasons + // back into one literal is the defect, and this is the assertion that + // catches it. + expect(historic.present).toBe(false); + expect(neverRecorded.present).toBe(false); + expect(neverRecorded.reason).toBe("no_data"); + expect(neverRecorded.available).toBeUndefined(); + expect(historic.reason).not.toBe(neverRecorded.reason); + }); + + it("names the window to re-call with when one would reach the rows", async () => { + measurementRows = [ + { + type: "WEIGHT", + unit: "kg", + _count: { _all: 240 }, + _min: { measuredAt: new Date("2025-09-01T07:00:00Z"), value: 79.1 }, + _max: { measuredAt: new Date("2026-01-07T07:00:00Z"), value: 84.6 }, + _avg: { value: 82 }, + }, + ]; + buildCoachSnapshot.mockResolvedValue(snapshot({})); + + const result = await executeCoachTool({ + userId: "u1", + name: "get_metric_series", + rawArguments: JSON.stringify({ metric: "weight", window: "last30days" }), + }); + + expect(result.reason).toBe("outside_window"); + expect(result.available?.reachableWithWindow).toBe("lastYear"); + }); + + it("does not call rows inside the window 'outside' it", async () => { + measurementRows = [recentRow("PULSE", 2, NOW)]; + buildCoachSnapshot.mockResolvedValue(snapshot({})); + + const result = await executeCoachTool({ + userId: "u1", + name: "get_metric_series", + rawArguments: JSON.stringify({ metric: "pulse", window: "last30days" }), + }); + + // Rows exist, recent, and the domain still produced no block — a module is + // off or a floor was not met. Widening would not help, and neither would + // telling the person they have no pulse readings. + expect(result.reason).toBe("unavailable_in_scope"); + expect(result.available?.count).toBe(42); + }); + + it("withholds a mean it cannot compute honestly across mixed units", async () => { + measurementRows = [ + HISTORIC_GLUCOSE, + { + type: "BLOOD_GLUCOSE", + unit: "mmol/L", + _count: { _all: 120 }, + _min: { measuredAt: new Date("2024-03-01T06:00:00Z"), value: 3.9 }, + _max: { measuredAt: new Date("2024-03-20T21:00:00Z"), value: 12.1 }, + _avg: { value: 6.4 }, + }, + ]; + buildCoachSnapshot.mockResolvedValue(snapshot({})); + + const result = await executeCoachTool({ + userId: "u1", + name: "get_glucose_panel", + rawArguments: JSON.stringify({ window: "allTime" }), + }); + + expect(result.reason).toBe("outside_window"); + expect(result.available?.count).toBe(1717); + expect(result.available?.firstDate).toBe("2024-03-01"); + expect(result.available?.series).toBeUndefined(); + }); + + it("does not assert an absence it could not establish", async () => { + measurementThrows = true; + buildCoachSnapshot.mockResolvedValue(snapshot({})); + + const result = await executeCoachTool({ + userId: "u1", + name: "get_glucose_panel", + rawArguments: "{}", + }); + + expect(result.present).toBe(false); + expect(result.reason).toBe("no_data_unconfirmed"); + expect(result.available).toBeUndefined(); + }); + + it("probes the workouts domain against its own table, not Measurement", async () => { + workoutAggregate.mockResolvedValue({ + _count: { _all: 88 }, + _min: { startedAt: new Date("2023-01-09T17:00:00Z") }, + _max: { startedAt: new Date("2023-11-30T18:30:00Z") }, + }); + buildCoachSnapshot.mockResolvedValue(snapshot({})); + + const result = await executeCoachTool({ + userId: "u1", + name: "get_workouts", + rawArguments: JSON.stringify({ window: "last90days" }), + }); + + expect(result.reason).toBe("outside_window"); + expect(result.available).toMatchObject({ + count: 88, + firstDate: "2023-01-09", + lastDate: "2023-11-30", + }); + // No value column on a workout row, so no fabricated series aggregate. + expect(result.available?.series).toBeUndefined(); + }); + + it("leaves a derived domain's honest absence alone (nothing to probe)", async () => { + buildCoachSnapshot.mockResolvedValue(snapshot({})); + const result = await executeCoachTool({ + userId: "u1", + name: "get_cycle", + rawArguments: "{}", + }); + expect(result.reason).toBe("no_data"); + expect(result.available).toBeUndefined(); + }); +}); + +describe("no empty window read can bypass the probe again", () => { + it("the executor returns no hand-written no_data for a missing section", () => { + const source = readFileSync( + join(process.cwd(), "src/lib/ai/coach/tools/executor.ts"), + "utf8", + ); + // Every miss goes through `emptyRead(...)`. A hand-written literal is how + // the defect got in: four sites, one reason, two meanings. + expect(source).not.toMatch( + /return \{ present: false, reason: "no_data" \}/, + ); + expect(source).not.toMatch(/reason: "no_data",\s*\}/); + }); +}); + +describe("classifyAvailability", () => { + it("splits on whether the searched window already covered the rows", () => { + const base = { count: 10, firstDate: "2024-01-01", lastDate: "2024-01-31" }; + expect( + classifyAvailability({ ...base, reachableWithWindow: null }, "allTime"), + ).toBe("outside_window"); + expect( + classifyAvailability( + { ...base, reachableWithWindow: "lastYear" }, + "last30days", + ), + ).toBe("outside_window"); + expect( + classifyAvailability( + { ...base, reachableWithWindow: "last7days" }, + "last30days", + ), + ).toBe("unavailable_in_scope"); + // `allTime` is capped at a year, so `lastYear` rows are already inside it. + expect( + classifyAvailability( + { ...base, reachableWithWindow: "lastYear" }, + "allTime", + ), + ).toBe("unavailable_in_scope"); + }); +}); + +describe("probeCoachAvailability", () => { + it("returns no entry for a domain the record is empty for", async () => { + measurementRows = []; + const probed = await probeCoachAvailability( + "u1", + new Map([["glucose", { kind: "measurement", types: ["BLOOD_GLUCOSE"] }]]), + { now: NOW }, + ); + expect(probed.size).toBe(0); + }); + + it("merges the two blood-pressure series into one domain answer", async () => { + measurementRows = [ + { + type: "BLOOD_PRESSURE_SYS", + unit: "mmHg", + _count: { _all: 300 }, + _min: { measuredAt: new Date("2022-02-01T06:00:00Z"), value: 108 }, + _max: { measuredAt: new Date("2022-12-20T06:00:00Z"), value: 162 }, + _avg: { value: 131 }, + }, + { + type: "BLOOD_PRESSURE_DIA", + unit: "mmHg", + _count: { _all: 298 }, + _min: { measuredAt: new Date("2022-02-03T06:00:00Z"), value: 64 }, + _max: { measuredAt: new Date("2022-12-19T06:00:00Z"), value: 101 }, + _avg: { value: 84 }, + }, + ]; + const probed = await probeCoachAvailability( + "u1", + new Map([ + [ + "bp", + { + kind: "measurement", + types: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"], + }, + ], + ]), + { now: NOW }, + ); + const bp = probed.get("bp"); + expect(bp?.count).toBe(598); + expect(bp?.firstDate).toBe("2022-02-01"); + expect(bp?.lastDate).toBe("2022-12-20"); + // Two series, each with its own aggregate — never one merged mean. + expect(bp?.series?.map((s) => s.series)).toEqual([ + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + ]); + }); +}); + +describe("DATA INVENTORY — the manifest the model reads before it fetches", () => { + it("advertises the stored history instead of calling the domain absent", async () => { + measurementRows = [HISTORIC_GLUCOSE]; + buildCoachSnapshot.mockResolvedValue(snapshot({})); + + const inventory = await buildCoachDataInventory("u1", { + sources: [], + window: "allTime", + }); + const glucose = inventory.entries.find((e) => e.domain === "glucose"); + expect(glucose?.present).toBe(false); + expect(glucose?.availability).toMatchObject({ + state: "outside_window", + count: 1597, + firstDate: "2024-04-03", + lastDate: "2024-04-17", + reachableWithWindow: null, + }); + + const rendered = renderDataInventory(inventory); + expect(rendered).toContain("glucose: OUTSIDE WINDOW"); + expect(rendered).toContain("1597 readings, 2024-04-03 to 2024-04-17"); + // A metric the record genuinely lacks still reads plainly absent. + expect(rendered).toMatch(/heart-rate variability: absent/); + }); + + it("leaves a genuinely empty account's manifest unchanged", async () => { + measurementRows = []; + buildCoachSnapshot.mockResolvedValue(snapshot({})); + const inventory = await buildCoachDataInventory("u1", { + sources: [], + window: "last30days", + }); + expect(inventory.entries.every((e) => e.availability === undefined)).toBe( + true, + ); + expect(renderDataInventory(inventory)).not.toContain("OUTSIDE WINDOW"); + }); +}); + +describe("the two instructions that told the model to move on", () => { + it("the inventory preamble no longer equates present:false with no data", () => { + const rendered = renderDataInventory({ + entries: [], + restMode: false, + cycleEnabled: false, + window: "last30days", + probeScope: { sources: [] }, + }); + expect(rendered).not.toMatch( + /say plainly you have no data for it and pivot/, + ); + expect(rendered).toContain("outside window"); + expect(rendered).toContain("that is not absence"); + }); + + for (const locale of ["en", "de"] as const) { + it(`the tool-mode addendum (${locale}) branches on the reason code`, () => { + const addendum = buildToolModeAddendum(locale); + expect(addendum).toContain("outside_window"); + expect(addendum).toContain("no_data_unconfirmed"); + expect(addendum).toContain("unavailable_in_scope"); + expect(addendum).toContain("reachableWithWindow"); + }); + + it(`the tool-mode addendum (${locale}) forbids swapping the metric`, () => { + const addendum = buildToolModeAddendum(locale); + const forbids = + locale === "en" + ? /never answer a question about one metric by reporting a different one/i + : /beantworte eine Frage zu einer Metrik niemals/i; + expect(addendum).toMatch(forbids); + }); + } +}); + +/** + * The other half of the same missing distinction, from the builder side: three + * cross-cutting narration blocks that failed to `null` indistinguishably from a + * user who honestly has none. `signalTrust` is the block that tells the model how + * much to trust the numbers it is about to narrate, so a silent failure means the + * Coach reasons at full confidence over data it was supposed to caveat. + * + * Structural, because the builder's own reads are not drivable from a unit test: + * the assertion is that no cross-cutting block swallows its failure into a bare + * `null` any more. + */ +describe("a failed narration block is counted, not silently absent", () => { + const source = readFileSync( + join(process.cwd(), "src/lib/ai/coach/snapshot.ts"), + "utf8", + ); + + it("routes every cross-cutting block failure through the annotator", () => { + for (const block of [ + "buildAdherenceStoryline", + "buildChangepointSignals", + "buildSignalTrust", + "buildExperimentOutcomeBlock", + ]) { + const at = source.indexOf(block + "("); + expect(at, `${block} call site`).toBeGreaterThan(-1); + const tail = source.slice(at, at + 260); + expect(tail, `${block} swallows its failure`).toContain("blockFailed("); + } + }); + + it("keeps no bare catch-to-null on those blocks", () => { + expect(source).not.toMatch( + /buildSignalTrust\([^)]*\)\.catch\(\(\) => null\)/, + ); + expect(source).not.toMatch( + /buildChangepointSignals\([^)]*\)\.catch\(\(\) => null\)/, + ); + }); +}); diff --git a/src/lib/ai/coach/__tests__/tools-executor.test.ts b/src/lib/ai/coach/__tests__/tools-executor.test.ts index 2c6c93551..c4fdd18e9 100644 --- a/src/lib/ai/coach/__tests__/tools-executor.test.ts +++ b/src/lib/ai/coach/__tests__/tools-executor.test.ts @@ -16,6 +16,44 @@ vi.mock("@/lib/ai/coach/snapshot", () => ({ buildCoachSnapshot(userId, scope), })); +// v1.32.40 (#648) — an empty window read is no longer reported as absence until +// the availability probe has confirmed the record is empty. Stub the probe's +// reads to "nothing stored", which is what these dispatch assertions mean by an +// absent section; `coach-availability-window-honesty.test.ts` owns the other +// three states. +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { groupBy: () => Promise.resolve([]) }, + workout: { aggregate: () => Promise.resolve(emptyAggregate()) }, + moodEntry: { aggregate: () => Promise.resolve(emptyAggregate()) }, + medicationIntakeEvent: { + aggregate: () => Promise.resolve(emptyAggregate()), + }, + labResult: { aggregate: () => Promise.resolve(emptyAggregate()) }, + }, +})); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: () => Promise.resolve("UTC"), +})); + +function emptyAggregate() { + return { + _count: { _all: 0 }, + _min: { + startedAt: null, + moodLoggedAt: null, + scheduledFor: null, + takenAt: null, + }, + _max: { + startedAt: null, + moodLoggedAt: null, + scheduledFor: null, + takenAt: null, + }, + }; +} + const readCoachCorrelations = vi.fn(); vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({ readCoachCorrelations: (userId: string) => readCoachCorrelations(userId), diff --git a/src/lib/ai/coach/__tests__/tools-loop.test.ts b/src/lib/ai/coach/__tests__/tools-loop.test.ts index 91cf0fbe6..1d1e2c899 100644 --- a/src/lib/ai/coach/__tests__/tools-loop.test.ts +++ b/src/lib/ai/coach/__tests__/tools-loop.test.ts @@ -248,6 +248,58 @@ describe("round bounds (D5)", () => { // v1.21.0 (D5-1) — the loop threads the turn's shared snapshot scope to every // tool so each per-tool read lands one cache key (one snapshot build per turn). describe("shared snapshot scope threading (D5-1)", () => { + it("retains an out-of-window miss's availability payload for grounding", async () => { + // A miss carries no `data`, so the pre-#648 filter (`if (present)`) dropped + // it — and the figures the model is now told it MAY cite would have been + // absent from the authoritative set, which is how a true sentence gets + // elided as a hallucination. The miss rides `toolResults` when, and only + // when, it actually delivered figures. + executeCoachTool.mockResolvedValueOnce({ + present: false, + reason: "outside_window", + searchedWindow: "allTime", + available: { + count: 1597, + firstDate: "2024-04-03", + lastDate: "2024-04-17", + reachableWithWindow: null, + }, + }); + runRawCompletionWithFallback + .mockResolvedValueOnce( + completion({ + content: "", + finishReason: "tool_calls", + toolCalls: [{ id: "c1", name: "get_glucose_panel", arguments: "{}" }], + }), + ) + .mockResolvedValueOnce(completion({ content: "done" })); + const out = await runCoachToolLoop(baseArgs); + expect(out.toolResults).toHaveLength(1); + expect(out.toolResults[0].available).toMatchObject({ count: 1597 }); + expect(out.toolTrace).toEqual([ + { name: "get_glucose_panel", present: false }, + ]); + }); + + it("still drops a miss that delivered no figures", async () => { + executeCoachTool.mockResolvedValueOnce({ + present: false, + reason: "no_data", + }); + runRawCompletionWithFallback + .mockResolvedValueOnce( + completion({ + content: "", + finishReason: "tool_calls", + toolCalls: [{ id: "c1", name: "get_glucose_panel", arguments: "{}" }], + }), + ) + .mockResolvedValueOnce(completion({ content: "done" })); + const out = await runCoachToolLoop(baseArgs); + expect(out.toolResults).toEqual([]); + }); + it("passes sharedScope through to executeCoachTool", async () => { executeCoachTool.mockResolvedValue({ present: true, data: { x: 1 } }); const shared = { diff --git a/src/lib/ai/coach/tools/availability.ts b/src/lib/ai/coach/tools/availability.ts index 36f767010..5051a2fbb 100644 --- a/src/lib/ai/coach/tools/availability.ts +++ b/src/lib/ai/coach/tools/availability.ts @@ -63,7 +63,7 @@ export type CoachAvailabilitySubject = | { kind: "lab" }; /** Per-series aggregate over the whole recorded history of one series. */ -export interface CoachAvailabilitySeries { +interface CoachAvailabilitySeries { /** The measurement type, verbatim — unambiguous for the model. */ series: string; /** The unit every row of this series carries, when they all agree. */ @@ -347,7 +347,7 @@ export async function probeCoachAvailability( * an empty window read. Frozen so a future reader (a test, a dashboard) can * assert the three states stay three. */ -export const EMPTY_READ_REASONS = { +const EMPTY_READ_REASONS = { /** Probe confirmed: the record holds nothing for this domain, ever. */ none: "no_data", /** Rows exist, all of them outside the window that was searched. */ @@ -382,7 +382,7 @@ export function classifyAvailability( } /** A `present: false` result with the window/history discriminant resolved. */ -export interface EmptyReadResult { +interface EmptyReadResult { present: false; reason: string; /** The window the empty read searched, so the miss is self-describing. */ @@ -453,7 +453,7 @@ export async function resolveEmptyRead(args: { * The subject a `get_metric_series` metric is checked against. `mood` reads * MoodEntry; every other series-backed source reads `Measurement`. */ -export function subjectForMetricSource( +function subjectForMetricSource( metric: CoachScopeSource, ): CoachAvailabilitySubject | null { if (metric === "mood") return { kind: "mood" };