From 0aeb63cbeb1ccb523a2c2a06cad64cc903c3dea8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 11:15:00 +0200 Subject: [PATCH] feat(mood): the minimum-n ladder, as constants with boundary tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five rungs — nothing at all below fifteen entries, descriptive hints to thirty, a provisional forecast to sixty, a plain one to ninety, weekday and seasonal comparisons above it — plus the occurrence floor a value must clear before it may be named at all. Every floor is an exported constant and every one has a test on both sides of its own boundary, because the failure here is invisible: a forecast built from eight days renders exactly like one built from eighty, and nobody reading the screen can tell. Each assertion names the threshold it is about, so a red says which floor moved. Absence carries its reason and the count rather than being a bare false, so a surface can say how far along the account is instead of showing an error. Two numbers are deliberately not minted here: the paired-value floor stays `MIN_PAIRED_N` in the correlations module, and the occurrence floor is the crosstab's own, taken by assignment. A test reads the file back and fails if either turns into a local copy. --- .../mood-prognosis/__tests__/gates.test.ts | 224 ++++++++++++++++++ .../analytics/mood-prognosis/thresholds.ts | 166 +++++++++++++ 2 files changed, 390 insertions(+) create mode 100644 src/lib/analytics/mood-prognosis/__tests__/gates.test.ts create mode 100644 src/lib/analytics/mood-prognosis/thresholds.ts diff --git a/src/lib/analytics/mood-prognosis/__tests__/gates.test.ts b/src/lib/analytics/mood-prognosis/__tests__/gates.test.ts new file mode 100644 index 000000000..40be4d6b7 --- /dev/null +++ b/src/lib/analytics/mood-prognosis/__tests__/gates.test.ts @@ -0,0 +1,224 @@ +/** + * The minimum-n ladder, at every boundary, from both sides. + * + * Each case names the threshold it is about, so a red here says which floor + * moved rather than "expected false to be true". That matters more than usual: + * the thing these gates prevent is invisible on screen, so the test output is + * the only place the failure is ever legible. + * + * Every one of these has been watched failing — the comparison flipped from + * `>=` to `>`, the red read, the file restored and the hash checked — and one + * check was deleted outright to prove the absence of a gate fails too. The log + * is in the phase's execution notes. + * + * The assertions name the REASON, not just the absence: "no forecast" and "no + * forecast because there are only eleven entries and fifteen is where anything + * starts" are different answers, and only the second one can be shown to + * somebody. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + MIN_ENTRIES_FOR_FORECAST, + MIN_ENTRIES_FOR_REGULAR_FORECAST, + MIN_ENTRIES_FOR_SEASONAL, + MIN_ENTRIES_LEARNING_PHASE, + MIN_TAG_OCCURRENCES, + MIN_WEEKDAY_OBSERVATIONS, + forecastGate, + occurrencesSuffice, + prognosisStage, + seasonalComparisonsUnlocked, + weekdayObservationsSuffice, +} from "../thresholds"; +import { CONTEXT_MIN_PRESENT_DAYS } from "@/lib/insights/mood-context-crosstab"; + +describe("the ladder is ordered and its rungs are the documented numbers", () => { + it("holds the values the concept asks for", () => { + expect(MIN_ENTRIES_LEARNING_PHASE).toBe(15); + expect(MIN_ENTRIES_FOR_FORECAST).toBe(30); + expect(MIN_ENTRIES_FOR_REGULAR_FORECAST).toBe(60); + expect(MIN_ENTRIES_FOR_SEASONAL).toBe(90); + expect(MIN_WEEKDAY_OBSERVATIONS).toBe(4); + }); + + it("rises strictly, so no rung can be reached before the one below it", () => { + expect(MIN_ENTRIES_LEARNING_PHASE).toBeLessThan(MIN_ENTRIES_FOR_FORECAST); + expect(MIN_ENTRIES_FOR_FORECAST).toBeLessThan( + MIN_ENTRIES_FOR_REGULAR_FORECAST, + ); + expect(MIN_ENTRIES_FOR_REGULAR_FORECAST).toBeLessThan( + MIN_ENTRIES_FOR_SEASONAL, + ); + }); +}); + +describe("MIN_ENTRIES_LEARNING_PHASE — below it, nothing at all", () => { + it(`refuses at ${MIN_ENTRIES_LEARNING_PHASE - 1}, and says why`, () => { + const gate = forecastGate(MIN_ENTRIES_LEARNING_PHASE - 1); + expect(gate.present, "MIN_ENTRIES_LEARNING_PHASE let n-1 through").toBe( + false, + ); + expect(gate).toEqual({ + present: false, + reason: "no-output-yet", + stage: "insufficient", + entries: MIN_ENTRIES_LEARNING_PHASE - 1, + nextThreshold: MIN_ENTRIES_LEARNING_PHASE, + }); + }); + + it(`reaches the learning phase at exactly ${MIN_ENTRIES_LEARNING_PHASE}`, () => { + const gate = forecastGate(MIN_ENTRIES_LEARNING_PHASE); + expect( + gate, + "MIN_ENTRIES_LEARNING_PHASE did not admit its own value", + ).toEqual({ + present: false, + reason: "learning-phase", + stage: "learning", + entries: MIN_ENTRIES_LEARNING_PHASE, + nextThreshold: MIN_ENTRIES_FOR_FORECAST, + }); + }); + + it("an empty account is absence, never a zero", () => { + const gate = forecastGate(0); + expect(gate.present).toBe(false); + expect(gate).not.toHaveProperty("predicted"); + if (!gate.present) expect(gate.entries).toBe(0); + }); +}); + +describe("MIN_ENTRIES_FOR_FORECAST — below it, no forecast", () => { + it(`refuses at ${MIN_ENTRIES_FOR_FORECAST - 1}, naming the learning phase`, () => { + const gate = forecastGate(MIN_ENTRIES_FOR_FORECAST - 1); + expect(gate.present, "MIN_ENTRIES_FOR_FORECAST let n-1 through").toBe( + false, + ); + if (!gate.present) { + expect(gate.reason).toBe("learning-phase"); + expect(gate.nextThreshold).toBe(MIN_ENTRIES_FOR_FORECAST); + expect(gate.entries).toBe(MIN_ENTRIES_FOR_FORECAST - 1); + } + }); + + it(`forecasts at exactly ${MIN_ENTRIES_FOR_FORECAST}, labelled provisional`, () => { + const gate = forecastGate(MIN_ENTRIES_FOR_FORECAST); + expect(gate.present, "MIN_ENTRIES_FOR_FORECAST refused its own value").toBe( + true, + ); + if (gate.present) { + expect(gate.stage).toBe("provisional"); + expect(gate.provisional).toBe(true); + } + }); +}); + +describe("MIN_ENTRIES_FOR_REGULAR_FORECAST — below it, provisional", () => { + it(`is still provisional at ${MIN_ENTRIES_FOR_REGULAR_FORECAST - 1}`, () => { + const gate = forecastGate(MIN_ENTRIES_FOR_REGULAR_FORECAST - 1); + expect(gate.present).toBe(true); + if (gate.present) { + expect( + gate.provisional, + "MIN_ENTRIES_FOR_REGULAR_FORECAST dropped the provisional label at n-1", + ).toBe(true); + expect(gate.stage).toBe("provisional"); + } + }); + + it(`drops the label at exactly ${MIN_ENTRIES_FOR_REGULAR_FORECAST}`, () => { + const gate = forecastGate(MIN_ENTRIES_FOR_REGULAR_FORECAST); + expect(gate.present).toBe(true); + if (gate.present) { + expect( + gate.provisional, + "MIN_ENTRIES_FOR_REGULAR_FORECAST kept the provisional label at its own value", + ).toBe(false); + expect(gate.stage).toBe("regular"); + } + }); +}); + +describe("MIN_ENTRIES_FOR_SEASONAL — below it, no weekday or seasonal view", () => { + it(`refuses the comparison at ${MIN_ENTRIES_FOR_SEASONAL - 1}`, () => { + expect( + seasonalComparisonsUnlocked(MIN_ENTRIES_FOR_SEASONAL - 1), + "MIN_ENTRIES_FOR_SEASONAL unlocked the comparison at n-1", + ).toBe(false); + expect(prognosisStage(MIN_ENTRIES_FOR_SEASONAL - 1)).toBe("regular"); + }); + + it(`unlocks it at exactly ${MIN_ENTRIES_FOR_SEASONAL}`, () => { + expect( + seasonalComparisonsUnlocked(MIN_ENTRIES_FOR_SEASONAL), + "MIN_ENTRIES_FOR_SEASONAL refused its own value", + ).toBe(true); + expect(prognosisStage(MIN_ENTRIES_FOR_SEASONAL)).toBe("seasonal"); + }); +}); + +describe("MIN_WEEKDAY_OBSERVATIONS — a weekday needs a month of itself", () => { + it(`refuses at ${MIN_WEEKDAY_OBSERVATIONS - 1}`, () => { + expect( + weekdayObservationsSuffice(MIN_WEEKDAY_OBSERVATIONS - 1), + "MIN_WEEKDAY_OBSERVATIONS admitted n-1 observations of one weekday", + ).toBe(false); + }); + + it(`admits at exactly ${MIN_WEEKDAY_OBSERVATIONS}`, () => { + expect( + weekdayObservationsSuffice(MIN_WEEKDAY_OBSERVATIONS), + "MIN_WEEKDAY_OBSERVATIONS refused its own value", + ).toBe(true); + }); +}); + +describe("MIN_TAG_OCCURRENCES — a value seen four times is not evidence", () => { + it(`refuses at ${MIN_TAG_OCCURRENCES - 1}`, () => { + expect( + occurrencesSuffice(MIN_TAG_OCCURRENCES - 1), + "MIN_TAG_OCCURRENCES admitted a value seen n-1 times", + ).toBe(false); + }); + + it(`admits at exactly ${MIN_TAG_OCCURRENCES}`, () => { + expect( + occurrencesSuffice(MIN_TAG_OCCURRENCES), + "MIN_TAG_OCCURRENCES refused its own value", + ).toBe(true); + }); + + it("is the crosstab's floor rather than a second opinion about it", () => { + expect(MIN_TAG_OCCURRENCES).toBe(CONTEXT_MIN_PRESENT_DAYS); + }); +}); + +describe("no second number is minted for an idea that already has one", () => { + const THRESHOLDS = readFileSync(join(__dirname, "../thresholds.ts"), "utf8"); + + it("the file really is the one under test", () => { + // The positive control: the two checks below pass on an empty read, so + // each needs proof the file was found and holds what it claims to. + expect(THRESHOLDS.length).toBeGreaterThan(1000); + expect(THRESHOLDS).toContain("MIN_ENTRIES_FOR_SEASONAL"); + }); + + it("declares no paired-observation floor of its own", () => { + // `MIN_PAIRED_N` lives in `correlations.ts` and the screening imports it + // from there. A constant here named for the same idea would be a second + // answer to one question, and the two would drift. + expect(THRESHOLDS).not.toMatch(/export\s+const\s+MIN_PAIRED/); + expect(THRESHOLDS).not.toMatch(/export\s+const\s+MIN_PAIRS/); + }); + + it("takes the occurrence floor from the crosstab rather than restating it", () => { + expect(THRESHOLDS).toMatch( + /MIN_TAG_OCCURRENCES\s*=\s*CONTEXT_MIN_PRESENT_DAYS/, + ); + }); +}); diff --git a/src/lib/analytics/mood-prognosis/thresholds.ts b/src/lib/analytics/mood-prognosis/thresholds.ts new file mode 100644 index 000000000..1d0d8e973 --- /dev/null +++ b/src/lib/analytics/mood-prognosis/thresholds.ts @@ -0,0 +1,166 @@ +/** + * How much history a prognosis needs before it is allowed to say anything. + * + * This is the most dangerous file in the feature, and it is dangerous for one + * reason: **the failure mode is silent.** A forecast built from eight days + * renders exactly like a forecast built from eighty. Nobody looking at the + * screen can tell the difference, and the person reading it is being told + * something about their own health record. So every floor is a named constant + * with a boundary test, and every one of those tests has been watched failing + * — a gate nobody has seen go red is a decoration, not a gate. + * + * The ladder has five rungs, and each rung says what the surface may claim: + * + * | entries | stage | what may be shown | + * |---------|---------------|------------------------------------------------| + * | 0-14 | `insufficient`| nothing beyond the self-assessment trend | + * | 15-29 | `learning` | descriptive hints, labelled as a learning phase | + * | 30-59 | `provisional` | a forecast, labelled provisional | + * | 60-89 | `regular` | a forecast without the provisional label | + * | 90+ | `seasonal` | plus weekday and seasonal comparisons | + * + * Absence is `{ present: false }` with a reason and the count, never `0` and + * never an empty forecast object with a null inside it. That shape is a + * contract elsewhere in the product already — the MCP surface means exactly + * "honestly not recorded" by it — and a surface that answered zero here would + * be claiming a bad day where the truth is that nothing can be said yet. + * + * **Two numbers are deliberately NOT minted here.** The paired-observation + * floor for a correlation is `MIN_PAIRED_N` in + * `src/lib/insights/correlations.ts`, and the screening in `features.ts` + * imports it from there; a second constant for the same idea is how two + * surfaces start disagreeing about what counts as enough. The occurrence floor + * below is the crosstab's own floor by assignment rather than by coincidence, + * for the same reason — Phase 2's context comparison says at its declaration + * that this file would import it, and this file does. + */ +import { CONTEXT_MIN_PRESENT_DAYS } from "@/lib/insights/mood-context-crosstab"; + +/** + * Below this, no output at all — not even a descriptive hint. + * + * Fifteen days of a person's life is not a pattern, and a surface that + * described one would be describing noise back to somebody who would + * reasonably believe it. + */ +export const MIN_ENTRIES_LEARNING_PHASE = 15; + +/** + * Below this, descriptive hints only, labelled as a learning phase. At or + * above it, a fitted forecast is allowed. + */ +export const MIN_ENTRIES_FOR_FORECAST = 30; + +/** Below this, a forecast is shown but labelled provisional. */ +export const MIN_ENTRIES_FOR_REGULAR_FORECAST = 60; + +/** Below this, no weekday and no seasonal comparison. */ +export const MIN_ENTRIES_FOR_SEASONAL = 90; + +/** + * How often a tag or a context value must have been recorded before it may + * appear in an explanation at all. + * + * The crosstab's floor, taken rather than re-declared: it is the same + * question ("has this been seen often enough to be worth a sentence") asked + * by a different surface, and two answers to it would drift. + */ +export const MIN_TAG_OCCURRENCES = CONTEXT_MIN_PRESENT_DAYS; + +/** + * Observations of one weekday before that weekday may be compared to the + * others. Four is a month of Mondays. + */ +export const MIN_WEEKDAY_OBSERVATIONS = 4; + +/** Where an account sits on the ladder. */ +export type PrognosisStage = + "insufficient" | "learning" | "provisional" | "regular" | "seasonal"; + +/** + * Why there is no forecast. The reason travels with the count and with the + * next rung, so a surface can say "eleven of fifteen" instead of an error. + */ +export type PrognosisAbsenceReason = "no-output-yet" | "learning-phase"; + +export interface PrognosisAbsent { + present: false; + reason: PrognosisAbsenceReason; + stage: Extract; + /** Qualifying entries the account has. */ + entries: number; + /** Entries at which the next thing becomes available. */ + nextThreshold: number; +} + +export interface PrognosisAvailable { + present: true; + stage: Extract; + entries: number; + /** The forecast carries a provisional label below the regular threshold. */ + provisional: boolean; +} + +export type PrognosisGate = PrognosisAbsent | PrognosisAvailable; + +/** + * Which rung this many qualifying entries reaches. + * + * Every comparison is `>=`, so the constant names the first value that + * qualifies rather than the last that does not. The boundary tests pin both + * sides of each one. + */ +export function prognosisStage(entries: number): PrognosisStage { + if (entries >= MIN_ENTRIES_FOR_SEASONAL) return "seasonal"; + if (entries >= MIN_ENTRIES_FOR_REGULAR_FORECAST) return "regular"; + if (entries >= MIN_ENTRIES_FOR_FORECAST) return "provisional"; + if (entries >= MIN_ENTRIES_LEARNING_PHASE) return "learning"; + return "insufficient"; +} + +/** + * Whether a forecast may be shown for an account with this many qualifying + * entries, and if not, why not and what would change it. + */ +export function forecastGate(entries: number): PrognosisGate { + const stage = prognosisStage(entries); + if (stage === "insufficient") { + return { + present: false, + reason: "no-output-yet", + stage, + entries, + nextThreshold: MIN_ENTRIES_LEARNING_PHASE, + }; + } + if (stage === "learning") { + return { + present: false, + reason: "learning-phase", + stage, + entries, + nextThreshold: MIN_ENTRIES_FOR_FORECAST, + }; + } + return { + present: true, + stage, + entries, + provisional: stage === "provisional", + }; +} + +/** Whether weekday and seasonal comparisons have unlocked for this account. */ +export function seasonalComparisonsUnlocked(entries: number): boolean { + return entries >= MIN_ENTRIES_FOR_SEASONAL; +} + +/** Whether one weekday has been seen often enough to be compared. */ +export function weekdayObservationsSuffice(observations: number): boolean { + return observations >= MIN_WEEKDAY_OBSERVATIONS; +} + +/** Whether a tag or context value has been recorded often enough to name. */ +export function occurrencesSuffice(occurrences: number): boolean { + return occurrences >= MIN_TAG_OCCURRENCES; +}