mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
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;
|
||||
}
|
||||
Reference in new issue
Block a user