feat(mood): the minimum-n ladder, as constants with boundary tests

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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 11:15:00 +02:00
1 parent a118d4810d
commit 0aeb63cbeb
2 files changed
+390

No files matched your search

@@ -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/,
);
});
});
@@ -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<PrognosisStage, "insufficient" | "learning">;
/** Qualifying entries the account has. */
entries: number;
/** Entries at which the next thing becomes available. */
nextThreshold: number;
}
export interface PrognosisAvailable {
present: true;
stage: Extract<PrognosisStage, "provisional" | "regular" | "seasonal">;
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;
}