mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(cycle): scope symptothermal to the current cycle, unify luteal clamp, widen fertile + crosstab coverage
The symptothermal layer ran against the full day-log history: detectTempShift returned the earliest 3-over-6 rise and detectMucusPeak the last egg-white day across all cycles, so a multi-cycle user with historical BBT confirmed a months-old ovulation and the predicted next-period start landed in the past. Scope the temperature/mucus detectors to the current cycle window [lastConfirmedStart − BBT_WINDOW, today], take the latest qualifying temp shift, and reject any confirmation whose next-start is already in the past. Resolve luteal length through a single clamp. The predictor clamped to [10,16] while the calendar adapter and phase mapper used the raw stored value, so the ovulation dot and the OVULATORY band could land on different days for an out-of-clamp profile. Export resolveLuteal/clampLuteal, route buildCalendar, buildPhaseCycles and phaseForDay through it, and tighten the profile-prefs Zod bound to the same [10,16] range. Show the fertile window for AVOID_PREGNANCY as well as TRYING_TO_CONCEIVE per the algorithm spec; it stays hidden for GENERAL_HEALTH/PERIMENOPAUSE. Bound the day-log reads on the calendar and insights routes to the rendered span plus the symptothermal lookback instead of the full history; cycle-length stats already run off the MenstrualCycle rows. Add mood and blood glucose as phase-crosstab outcome metrics under the same FDR and per-group day floors. Mood is read from MoodEntry and injected as a synthetic channel since it is not a measurement row. Add multi-cycle, luteal-clamp and fertile-window fixtures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
ac13dd28ed
commit
15bece9849
13 files changed
+354
-36
No files matched your search
@@ -703,8 +703,8 @@ paths:
|
||||
lutealPhaseLength:
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: 8
|
||||
maximum: 20
|
||||
minimum: 10
|
||||
maximum: 16
|
||||
- type: "null"
|
||||
responses:
|
||||
"200":
|
||||
|
||||
@@ -28,7 +28,8 @@ import {
|
||||
goalAllowsFertileWindow,
|
||||
} from "@/lib/cycle/dto";
|
||||
import { persistPredictionCache } from "@/lib/cycle/prediction-cache";
|
||||
import { addDays } from "@/lib/cycle/day-math";
|
||||
import { addDays, dayDiff } from "@/lib/cycle/day-math";
|
||||
import { BBT_WINDOW } from "@/lib/cycle/types";
|
||||
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
@@ -75,8 +76,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { startDate: "asc" },
|
||||
}),
|
||||
// Bound the day-log read to the rendered span plus the symptothermal
|
||||
// lookback — the earlier of `from` and (today − BBT_WINDOW). Cycle-length
|
||||
// stats run off MenstrualCycle rows, so day-logs can be windowed (QA: perf
|
||||
// — unbounded full-history read on a hot, per-navigation route).
|
||||
prisma.cycleDayLog.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
date: {
|
||||
gte:
|
||||
dayDiff(from, addDays(today, -BBT_WINDOW)) <= 0
|
||||
? from
|
||||
: addDays(today, -BBT_WINDOW),
|
||||
},
|
||||
},
|
||||
orderBy: { date: "asc" },
|
||||
select: {
|
||||
date: true,
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
discoverPhaseCorrelations,
|
||||
selectHeadlinePhaseRow,
|
||||
PHASE_CROSSTAB_METRIC_TYPES,
|
||||
MOOD_CHANNEL_KEY,
|
||||
} from "@/lib/cycle/phase-crosstab";
|
||||
import type { CrossMetricMeasurement } from "@/lib/insights/mood-aggregates";
|
||||
import { addDays } from "@/lib/cycle/day-math";
|
||||
@@ -56,14 +57,24 @@ export const GET = apiHandler(async () => {
|
||||
const today = moodDateKey(new Date(), tz);
|
||||
const from = addDays(today, -WINDOW_DAYS);
|
||||
|
||||
const [cycles, dayLogRows, nightlyTempRows, measurementRows, prefsRow] =
|
||||
await Promise.all([
|
||||
const [
|
||||
cycles,
|
||||
dayLogRows,
|
||||
nightlyTempRows,
|
||||
measurementRows,
|
||||
prefsRow,
|
||||
moodRows,
|
||||
] = await Promise.all([
|
||||
prisma.menstrualCycle.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { startDate: "asc" },
|
||||
}),
|
||||
// Bound the day-log read to the rendered window (`from` = today − 365d),
|
||||
// which is already earlier than the symptothermal lookback. Cycle-length
|
||||
// stats run off MenstrualCycle rows so the day-logs can be windowed (QA:
|
||||
// perf — unbounded full-history read).
|
||||
prisma.cycleDayLog.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
where: { userId: user.id, deletedAt: null, date: { gte: from } },
|
||||
orderBy: { date: "asc" },
|
||||
select: {
|
||||
date: true,
|
||||
@@ -112,6 +123,18 @@ export const GET = apiHandler(async () => {
|
||||
where: { id: user.id },
|
||||
select: { sourcePriorityJson: true },
|
||||
}),
|
||||
// MOOD outcome (QA HIGH): mood lives in MoodEntry (1–5 score), not a
|
||||
// Measurement row, so read it here and inject it into the crosstab as a
|
||||
// synthetic MOOD_CHANNEL_KEY measurement (same FDR / day-floor guards).
|
||||
prisma.moodEntry.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) },
|
||||
},
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
select: { score: true, moodLoggedAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
// The latest open cycle runs to the predicted next-period start so the
|
||||
@@ -146,6 +169,18 @@ export const GET = apiHandler(async () => {
|
||||
source: m.source,
|
||||
deviceType: m.deviceType,
|
||||
}));
|
||||
// Inject mood as a synthetic MOOD_CHANNEL_KEY series (no source ladder — the
|
||||
// metricDayMap pass-through keeps every row, averaging multiple same-day
|
||||
// entries like every other channel).
|
||||
for (const m of moodRows) {
|
||||
measurements.push({
|
||||
type: MOOD_CHANNEL_KEY,
|
||||
value: m.score,
|
||||
measuredAt: m.moodLoggedAt,
|
||||
source: null,
|
||||
deviceType: null,
|
||||
});
|
||||
}
|
||||
|
||||
const userPriorityJson = prefsRow?.sourcePriorityJson ?? null;
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
selectHeadlinePhaseRow,
|
||||
PHASE_ORDINAL,
|
||||
PHASE_CROSSTAB_METRIC_TYPES,
|
||||
PHASE_CROSSTAB_METRICS,
|
||||
CYCLE_PHASE_CHANNEL_KEY,
|
||||
MOOD_CHANNEL_KEY,
|
||||
type PhaseMetricCrosstabRow,
|
||||
} from "../phase-crosstab";
|
||||
import type { CrossMetricMeasurement } from "@/lib/insights/mood-aggregates";
|
||||
@@ -81,6 +83,76 @@ describe("PHASE_CROSSTAB_METRIC_TYPES", () => {
|
||||
expect(PHASE_CROSSTAB_METRIC_TYPES).toContain("BODY_TEMPERATURE");
|
||||
expect(PHASE_CROSSTAB_METRIC_TYPES).toContain("RESTING_HEART_RATE");
|
||||
});
|
||||
|
||||
it("includes GLUCOSE but NOT mood (mood is not a Measurement row)", () => {
|
||||
expect(PHASE_CROSSTAB_METRIC_TYPES).toContain("BLOOD_GLUCOSE");
|
||||
expect(PHASE_CROSSTAB_METRIC_TYPES).not.toContain(MOOD_CHANNEL_KEY);
|
||||
});
|
||||
|
||||
it("declares mood + glucose as outcome metrics (QA HIGH)", () => {
|
||||
expect(PHASE_CROSSTAB_METRICS.mood.type).toBe(MOOD_CHANNEL_KEY);
|
||||
expect(PHASE_CROSSTAB_METRICS.bloodGlucose.type).toBe("BLOOD_GLUCOSE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computePhaseMetricCrosstab — MOOD + GLUCOSE outcomes (QA HIGH)", () => {
|
||||
/** Build a luteal-vs-follicular contrast for an arbitrary outcome `type`. */
|
||||
function contrastFor(type: string, lutealVal: number, follicularVal: number) {
|
||||
const phaseByDay = new Map<string, CyclePhase>();
|
||||
const measurements: CrossMetricMeasurement[] = [];
|
||||
let idx = 0;
|
||||
const dayKey = (i: number) => {
|
||||
const d = new Date(Date.UTC(2026, 2, 1));
|
||||
d.setUTCDate(d.getUTCDate() + i);
|
||||
return d.toISOString().slice(0, 10);
|
||||
};
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const k = dayKey(idx++);
|
||||
phaseByDay.set(k, "LUTEAL");
|
||||
measurements.push(m(type, k, lutealVal + (i % 3) * 0.5));
|
||||
}
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const k = dayKey(idx++);
|
||||
phaseByDay.set(k, "FOLLICULAR");
|
||||
measurements.push(m(type, k, follicularVal + (i % 3) * 0.5));
|
||||
}
|
||||
return { phaseByDay, measurements };
|
||||
}
|
||||
|
||||
it("surfaces a luteal-vs-follicular GLUCOSE contrast", () => {
|
||||
const { phaseByDay, measurements } = contrastFor("BLOOD_GLUCOSE", 105, 92);
|
||||
const rows = computePhaseMetricCrosstab({ phaseByDay, measurements });
|
||||
const glucose = rows.find((r) => r.metricKey === "bloodGlucose");
|
||||
expect(glucose).toBeDefined();
|
||||
expect(glucose!.display).toBe("glucose");
|
||||
expect(glucose!.delta).toBeGreaterThan(0);
|
||||
expect(glucose!.pValue).toBeLessThan(0.05);
|
||||
});
|
||||
|
||||
it("surfaces a luteal-vs-follicular MOOD contrast (injected synthetic channel)", () => {
|
||||
const { phaseByDay, measurements } = contrastFor(MOOD_CHANNEL_KEY, 4.4, 2.6);
|
||||
const rows = computePhaseMetricCrosstab({ phaseByDay, measurements });
|
||||
const mood = rows.find((r) => r.metricKey === "mood");
|
||||
expect(mood).toBeDefined();
|
||||
expect(mood!.display).toBe("mood");
|
||||
expect(mood!.delta).toBeGreaterThan(0);
|
||||
expect(mood!.pValue).toBeLessThan(0.05);
|
||||
});
|
||||
});
|
||||
|
||||
describe("goalAllowsFertileWindow (QA MEDIUM)", () => {
|
||||
it("shows the fertile window for TRYING_TO_CONCEIVE and AVOID_PREGNANCY", async () => {
|
||||
const { goalAllowsFertileWindow } = await import("../dto");
|
||||
expect(goalAllowsFertileWindow("TRYING_TO_CONCEIVE")).toBe(true);
|
||||
expect(goalAllowsFertileWindow("AVOID_PREGNANCY")).toBe(true);
|
||||
});
|
||||
|
||||
it("hides it for GENERAL_HEALTH / PERIMENOPAUSE / OFF", async () => {
|
||||
const { goalAllowsFertileWindow } = await import("../dto");
|
||||
expect(goalAllowsFertileWindow("GENERAL_HEALTH")).toBe(false);
|
||||
expect(goalAllowsFertileWindow("PERIMENOPAUSE")).toBe(false);
|
||||
expect(goalAllowsFertileWindow("OFF")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computePhaseMetricCrosstab", () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { addDays } from "../day-math";
|
||||
import {
|
||||
clampLuteal,
|
||||
confirmSymptothermal,
|
||||
detectTempShift,
|
||||
detectTemperatureTrend,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
median,
|
||||
observedPeriodLength,
|
||||
predictCycle,
|
||||
resolveLuteal,
|
||||
} from "../prediction";
|
||||
import type {
|
||||
CycleInput,
|
||||
@@ -401,3 +403,114 @@ describe("cycle/prediction — temperature-trend retrospective ovulation (§4.3)
|
||||
expect(result.predictedOvulation).toBe(addDays(start, 5));
|
||||
});
|
||||
});
|
||||
|
||||
describe("cycle/prediction — multi-cycle window scoping (QA HIGH)", () => {
|
||||
function bbtDay(date: string, t: number, mucus: DayLogInput["cervicalMucus"] = null): DayLogInput {
|
||||
return { date, flow: null, basalBodyTempC: t, ovulationTest: null, cervicalMucus: mucus };
|
||||
}
|
||||
|
||||
/** Build a confirmable symptothermal shift (temp + agreeing mucus) on `ovDay`. */
|
||||
function symptothermalAround(ovDay: string): DayLogInput[] {
|
||||
const logs: DayLogInput[] = [];
|
||||
const riseStart = addDays(ovDay, 1);
|
||||
const baselineStart = addDays(riseStart, -6);
|
||||
[36.4, 36.41, 36.39, 36.4, 36.42, 36.4].forEach((t, i) =>
|
||||
logs.push(bbtDay(addDays(baselineStart, i), t)),
|
||||
);
|
||||
logs.push(bbtDay(riseStart, 36.6));
|
||||
logs.push(bbtDay(addDays(riseStart, 1), 36.62));
|
||||
logs.push(bbtDay(addDays(riseStart, 2), 36.63));
|
||||
logs.push({
|
||||
date: ovDay,
|
||||
flow: null,
|
||||
basalBodyTempC: null,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: "EGG_WHITE",
|
||||
});
|
||||
return logs;
|
||||
}
|
||||
|
||||
it("does not confirm a STALE prior-cycle ovulation for a multi-cycle user", () => {
|
||||
// Three confirmed cycles; last start 2024-03-25, today well into the
|
||||
// current cycle. The ONLY BBT/mucus data is a complete shift back in the
|
||||
// FIRST cycle (months ago). Pre-fix, detectTempShift returned that stale
|
||||
// shift and nextPeriodStart landed in the past.
|
||||
const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]);
|
||||
const lastStart = cycles[cycles.length - 1].startDate;
|
||||
expect(lastStart).toBe("2024-03-25");
|
||||
|
||||
const staleOv = addDays("2024-01-01", 13); // 2024-01-14, two cycles back
|
||||
const logs = symptothermalAround(staleOv);
|
||||
|
||||
const today = addDays(lastStart, 16);
|
||||
const result = predictCycle(cycles, logs, BASE_PROFILE, today);
|
||||
|
||||
// Stale signal is outside [lastStart − BBT_WINDOW, today] → ignored.
|
||||
expect(result.ovulationConfirmed).toBe(false);
|
||||
expect(result.method).toBe("CALENDAR");
|
||||
// Calendar next-start is in the future, never in the past.
|
||||
expect(result.nextPeriodStart >= today).toBe(true);
|
||||
});
|
||||
|
||||
it("confirms the CURRENT-cycle shift even when a prior-cycle shift also exists", () => {
|
||||
const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]);
|
||||
const lastStart = cycles[cycles.length - 1].startDate; // 2024-03-25
|
||||
|
||||
const staleOv = addDays("2024-01-01", 13); // months ago
|
||||
const currentOv = addDays(lastStart, 13); // current cycle
|
||||
const logs = [...symptothermalAround(staleOv), ...symptothermalAround(currentOv)];
|
||||
|
||||
const result = predictCycle(cycles, logs, BASE_PROFILE, addDays(lastStart, 16));
|
||||
expect(result.ovulationConfirmed).toBe(true);
|
||||
expect(result.predictedOvulation).toBe(currentOv);
|
||||
expect(result.nextPeriodStart).toBe(addDays(currentOv, 14));
|
||||
});
|
||||
|
||||
it("detectTempShift returns the LATEST qualifying shift, not the earliest", () => {
|
||||
const logs: DayLogInput[] = [];
|
||||
// First shift around 2024-01-07.
|
||||
const start = "2024-01-01";
|
||||
[36.4, 36.42, 36.38, 36.41, 36.4, 36.39].forEach((t, i) =>
|
||||
logs.push(bbtDay(addDays(start, i), t)),
|
||||
);
|
||||
logs.push(bbtDay(addDays(start, 6), 36.6));
|
||||
logs.push(bbtDay(addDays(start, 7), 36.61));
|
||||
logs.push(bbtDay(addDays(start, 8), 36.62));
|
||||
// Drop back to baseline, then a SECOND shift a month later.
|
||||
const start2 = "2024-02-01";
|
||||
[36.4, 36.42, 36.38, 36.41, 36.4, 36.39].forEach((t, i) =>
|
||||
logs.push(bbtDay(addDays(start2, i), t)),
|
||||
);
|
||||
logs.push(bbtDay(addDays(start2, 6), 36.6));
|
||||
logs.push(bbtDay(addDays(start2, 7), 36.61));
|
||||
logs.push(bbtDay(addDays(start2, 8), 36.62));
|
||||
|
||||
const shift = detectTempShift(logs, 0.2);
|
||||
// Latest match → day before the second rise onset (2024-02-07 → 2024-02-06).
|
||||
expect(shift?.ovulationDate).toBe(addDays(start2, 5));
|
||||
});
|
||||
});
|
||||
|
||||
describe("cycle/prediction — luteal clamp single source of truth (QA HIGH)", () => {
|
||||
it("clampLuteal pins a raw value to [10, 16]", () => {
|
||||
expect(clampLuteal(8)).toBe(10);
|
||||
expect(clampLuteal(20)).toBe(16);
|
||||
expect(clampLuteal(13)).toBe(13);
|
||||
});
|
||||
|
||||
it("resolveLuteal applies the default then the clamp", () => {
|
||||
expect(resolveLuteal({ lutealPhaseLength: null })).toBe(14);
|
||||
expect(resolveLuteal({ lutealPhaseLength: 8 })).toBe(10);
|
||||
expect(resolveLuteal({ lutealPhaseLength: 19 })).toBe(16);
|
||||
});
|
||||
|
||||
it("predictCycle ovulation uses the CLAMPED luteal for an out-of-clamp profile", () => {
|
||||
const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]);
|
||||
const lastStart = cycles[cycles.length - 1].startDate;
|
||||
// Profile asks for luteal 8 → engine clamps to 10.
|
||||
const profile: CycleProfileInput = { ...BASE_PROFILE, lutealPhaseLength: 8 };
|
||||
const result = predictCycle(cycles, [], profile, addDays(lastStart, 5));
|
||||
// predictedOvulation = nextStart − clampedLuteal(10).
|
||||
expect(result.predictedOvulation).toBe(addDays(result.nextPeriodStart, -10));
|
||||
});
|
||||
});
|
||||
@@ -236,11 +236,15 @@ type MenstrualCycleProfileRow = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a goal surfaces the fertile window. Per the v1.15 contract the
|
||||
* window is gated to the conception goal only — GENERAL_HEALTH /
|
||||
* AVOID_PREGNANCY / PERIMENOPAUSE / OFF suppress it (the suppression is
|
||||
* always server-side, never iOS).
|
||||
* Whether a goal surfaces the fertile window. Per algorithm.md §4 the window is
|
||||
* shown for TRYING_TO_CONCEIVE and AVOID_PREGNANCY (AVOID_PREGNANCY carries a
|
||||
* stronger "estimate, not a contraceptive method" caveat in the copy), and
|
||||
* suppressed for GENERAL_HEALTH / PERIMENOPAUSE / OFF (the suppression is always
|
||||
* server-side, never iOS).
|
||||
*/
|
||||
export function goalAllowsFertileWindow(goal: string): boolean {
|
||||
return goal === "TRYING_TO_CONCEIVE";
|
||||
// algorithm.md §4: shown for TRYING_TO_CONCEIVE AND AVOID_PREGNANCY (the
|
||||
// latter carries a stronger "not a contraceptive method" caveat in copy),
|
||||
// hidden for GENERAL_HEALTH / PERIMENOPAUSE / OFF.
|
||||
return goal === "TRYING_TO_CONCEIVE" || goal === "AVOID_PREGNANCY";
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type CyclePredictionResult,
|
||||
type CyclePhase,
|
||||
type PhaseCycle,
|
||||
LUTEAL_DEFAULT,
|
||||
resolveLuteal,
|
||||
} from "@/lib/cycle";
|
||||
import type {
|
||||
CycleProfile,
|
||||
@@ -219,7 +219,10 @@ export function buildCalendar(
|
||||
today: string,
|
||||
goalAllowsFertile: boolean,
|
||||
): CalendarBuildResult {
|
||||
const lutealLength = profile.lutealPhaseLength ?? LUTEAL_DEFAULT;
|
||||
// Clamp identically to the engine's resolveLuteal so the predicted-ovulation
|
||||
// dot (engine-clamped) and the OVULATORY phase band (this value) never
|
||||
// diverge for an out-of-clamp stored luteal length (QA HIGH).
|
||||
const lutealLength = resolveLuteal(profile);
|
||||
|
||||
// Forecast (only when prediction is on + not raw-chart mode).
|
||||
let prediction: CyclePredictionResult | null = null;
|
||||
|
||||
@@ -19,5 +19,7 @@ export {
|
||||
detectTemperatureTrend,
|
||||
median,
|
||||
mad,
|
||||
resolveLuteal,
|
||||
clampLuteal,
|
||||
} from "./prediction";
|
||||
export { phaseForDay, phaseSeries, type PhaseCycle } from "./phase";
|
||||
@@ -75,7 +75,20 @@ export type PhaseCrosstabDisplay =
|
||||
| "bpm"
|
||||
| "ms"
|
||||
| "kg"
|
||||
| "celsius";
|
||||
| "celsius"
|
||||
| "glucose"
|
||||
| "mood";
|
||||
|
||||
/**
|
||||
* Synthetic non-MeasurementType channel key for the MOOD outcome. Mood lives in
|
||||
* the separate `MoodEntry` model (1–5 score), not a Measurement row, so the
|
||||
* caller injects it into the crosstab `measurements` array tagged with this key
|
||||
* and `metricDayMap` groups it like any other type (its pass-through fallback
|
||||
* keeps every row — there is no source ladder for mood). It is deliberately NOT
|
||||
* a member of `PHASE_CROSSTAB_METRIC_TYPES`, so it never enters the Measurement
|
||||
* read query.
|
||||
*/
|
||||
export const MOOD_CHANNEL_KEY = "MOOD";
|
||||
|
||||
/**
|
||||
* The outcome metric grid the phase contrast walks. Mirrors
|
||||
@@ -86,7 +99,7 @@ export type PhaseCrosstabDisplay =
|
||||
*/
|
||||
export const PHASE_CROSSTAB_METRICS: Record<
|
||||
string,
|
||||
{ type: MeasurementType; display: PhaseCrosstabDisplay }
|
||||
{ type: MeasurementType | typeof MOOD_CHANNEL_KEY; display: PhaseCrosstabDisplay }
|
||||
> = {
|
||||
restingHeartRate: { type: "RESTING_HEART_RATE", display: "bpm" },
|
||||
heartRateVariability: { type: "HEART_RATE_VARIABILITY", display: "ms" },
|
||||
@@ -96,13 +109,26 @@ export const PHASE_CROSSTAB_METRICS: Record<
|
||||
basalBodyTemp: { type: "BODY_TEMPERATURE", display: "celsius" },
|
||||
wristTemperature: { type: "WRIST_TEMPERATURE", display: "celsius" },
|
||||
skinTemperature: { type: "SKIN_TEMPERATURE", display: "celsius" },
|
||||
// QA HIGH (features): the crosstab must cover MOOD + GLUCOSE as outcomes,
|
||||
// under the identical FDR / day-floor guards. Glucose is a MeasurementType;
|
||||
// mood is the synthetic MOOD_CHANNEL_KEY the route injects (see above).
|
||||
bloodGlucose: { type: "BLOOD_GLUCOSE", display: "glucose" },
|
||||
mood: { type: MOOD_CHANNEL_KEY, display: "mood" },
|
||||
} as const;
|
||||
|
||||
export type PhaseCrosstabMetricKey = keyof typeof PHASE_CROSSTAB_METRICS;
|
||||
|
||||
/** Distinct measurement types the phase crosstab reads — single-sourced. */
|
||||
/**
|
||||
* Distinct MeasurementType values the phase crosstab reads — single-sourced.
|
||||
* MOOD_CHANNEL_KEY is excluded: it is not a Measurement row (the route reads it
|
||||
* from MoodEntry and injects it), so it must never enter the Measurement query.
|
||||
*/
|
||||
export const PHASE_CROSSTAB_METRIC_TYPES: MeasurementType[] = Array.from(
|
||||
new Set(Object.values(PHASE_CROSSTAB_METRICS).map((m) => m.type)),
|
||||
new Set(
|
||||
Object.values(PHASE_CROSSTAB_METRICS)
|
||||
.map((m) => m.type)
|
||||
.filter((t): t is MeasurementType => t !== MOOD_CHANNEL_KEY),
|
||||
),
|
||||
);
|
||||
|
||||
/** The two phases the categorical contrast compares (largest contrast). */
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { addDays, dayDiff } from "./day-math";
|
||||
import { clampLuteal } from "./prediction";
|
||||
import { LUTEAL_DEFAULT, POPULATION_DEFAULT_PERIOD, type CyclePhase } from "./types";
|
||||
|
||||
/** A resolved cycle window the phase mapper needs. */
|
||||
@@ -54,7 +55,8 @@ export function phaseForDay(
|
||||
const dayOfCycle = offsetFromStart + 1; // 1-based
|
||||
|
||||
const periodLength = cycle.periodLength ?? POPULATION_DEFAULT_PERIOD;
|
||||
const lutealLength = cycle.lutealLength ?? LUTEAL_DEFAULT;
|
||||
// Clamp identically to the prediction engine (QA HIGH: one source of truth).
|
||||
const lutealLength = clampLuteal(cycle.lutealLength ?? LUTEAL_DEFAULT);
|
||||
|
||||
// Ovulation: confirmed/estimated date, or back-calculated from length.
|
||||
const ovulationDate =
|
||||
|
||||
+52
-16
@@ -16,6 +16,7 @@ import { addDays, dayDiff, roundHalf } from "./day-math";
|
||||
import {
|
||||
ADHERENCE_FLOOR,
|
||||
ADHERENCE_SLOPE,
|
||||
BBT_WINDOW,
|
||||
COLD_START_BAND_BONUS,
|
||||
CONFIDENCE_LABEL_HIGH_MIN,
|
||||
CONFIDENCE_LABEL_LOW_MAX,
|
||||
@@ -272,8 +273,11 @@ export function detectTempShift(
|
||||
.map((l) => ({ date: l.date, t: l.basalBodyTempC as number }))
|
||||
.sort((a, b) => dayDiff(a.date, b.date));
|
||||
|
||||
// Need 6 baseline + 3 elevated = 9 readings minimum.
|
||||
for (let i = 6; i + 2 < temps.length; i++) {
|
||||
// Need 6 baseline + 3 elevated = 9 readings minimum. Scan from the END and
|
||||
// return the LATEST qualifying shift — for a multi-cycle BBT series the most
|
||||
// recent rise is the one that belongs to the current cycle; the earliest
|
||||
// match would confirm a months-old ovulation (QA: window scoping).
|
||||
for (let i = temps.length - 3; i >= 6; i--) {
|
||||
const baseline = temps.slice(i - 6, i);
|
||||
const sixMax = Math.max(...baseline.map((x) => x.t));
|
||||
const rise = [temps[i], temps[i + 1], temps[i + 2]];
|
||||
@@ -413,11 +417,21 @@ function clamp(x: number, lo: number, hi: number): number {
|
||||
return Math.min(hi, Math.max(lo, x));
|
||||
}
|
||||
|
||||
function resolveLuteal(profile: CycleProfileInput): number {
|
||||
const raw = profile.lutealPhaseLength ?? LUTEAL_DEFAULT;
|
||||
/**
|
||||
* Clamp a raw luteal length to the physiological range [LUTEAL_MIN, LUTEAL_MAX]
|
||||
* (§4). The ONE source of truth for resolving luteal length — the engine,
|
||||
* calendar adapter, and phase mapper all route through this so the
|
||||
* predicted-ovulation dot and the OVULATORY band can never diverge for a user
|
||||
* whose stored value is out of clamp (QA HIGH: luteal clamp divergence).
|
||||
*/
|
||||
export function clampLuteal(raw: number): number {
|
||||
return clamp(raw, LUTEAL_MIN, LUTEAL_MAX);
|
||||
}
|
||||
|
||||
export function resolveLuteal(profile: Pick<CycleProfileInput, "lutealPhaseLength">): number {
|
||||
return clampLuteal(profile.lutealPhaseLength ?? LUTEAL_DEFAULT);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* main entry */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -490,21 +504,43 @@ export function predictCycle(
|
||||
// Precedence: confirmed-symptothermal > temperature-trend > calendar. A
|
||||
// confirmed signal overrides the CURRENT cycle's ovulation/next-start; the
|
||||
// calendar predictor still fills all FUTURE cycles (BLENDED records that mix).
|
||||
const symptoOvulation = confirmSymptothermal(dayLogs);
|
||||
const trendOvulation = symptoOvulation ? null : detectTemperatureTrend(nights);
|
||||
//
|
||||
// Window scoping (QA HIGH): the symptothermal/temperature-trend detectors
|
||||
// must only see the CURRENT cycle's signal, otherwise a multi-cycle user's
|
||||
// stale prior-cycle BBT/mucus confirms a months-old ovulation and lands
|
||||
// nextPeriodStart in the past. Scope to [lastConfirmedStart − BBT_WINDOW,
|
||||
// today]; the BBT_WINDOW reach-back keeps the 6-reading baseline intact for a
|
||||
// young current cycle. detectTempShift returns the LATEST qualifying shift.
|
||||
const windowStart = addDays(lastConfirmedStart, -BBT_WINDOW);
|
||||
const isInCurrentWindow = (date: string) =>
|
||||
dayDiff(date, windowStart) >= 0 && dayDiff(today, date) >= 0;
|
||||
const currentDayLogs = dayLogs.filter((l) => isInCurrentWindow(l.date));
|
||||
const currentNights = nights.filter((n) => isInCurrentWindow(n.date));
|
||||
const symptoOvulation = confirmSymptothermal(currentDayLogs);
|
||||
const trendOvulation = symptoOvulation
|
||||
? null
|
||||
: detectTemperatureTrend(currentNights);
|
||||
|
||||
if (symptoOvulation) {
|
||||
predictedOvulation = symptoOvulation;
|
||||
nextStart = addDays(symptoOvulation, lutealLength);
|
||||
ovulationConfirmed = true;
|
||||
confirmMultiplier = HALF_WIDTH_MULT_SYMPTOTHERMAL;
|
||||
method = lengths.length > 0 ? "BLENDED" : "SYMPTOTHERMAL";
|
||||
const confirmedNextStart = addDays(symptoOvulation, lutealLength);
|
||||
// Reject a confirmation whose next-start is already in the past (a stale
|
||||
// signal that slipped through the window) — fall back to the calendar.
|
||||
if (dayDiff(confirmedNextStart, today) >= 0) {
|
||||
predictedOvulation = symptoOvulation;
|
||||
nextStart = confirmedNextStart;
|
||||
ovulationConfirmed = true;
|
||||
confirmMultiplier = HALF_WIDTH_MULT_SYMPTOTHERMAL;
|
||||
method = lengths.length > 0 ? "BLENDED" : "SYMPTOTHERMAL";
|
||||
}
|
||||
} else if (trendOvulation) {
|
||||
predictedOvulation = trendOvulation;
|
||||
nextStart = addDays(trendOvulation, lutealLength);
|
||||
ovulationConfirmed = true;
|
||||
confirmMultiplier = HALF_WIDTH_MULT_TEMP_TREND;
|
||||
method = lengths.length > 0 ? "BLENDED" : "TEMPERATURE_TREND";
|
||||
const confirmedNextStart = addDays(trendOvulation, lutealLength);
|
||||
if (dayDiff(confirmedNextStart, today) >= 0) {
|
||||
predictedOvulation = trendOvulation;
|
||||
nextStart = confirmedNextStart;
|
||||
ovulationConfirmed = true;
|
||||
confirmMultiplier = HALF_WIDTH_MULT_TEMP_TREND;
|
||||
method = lengths.length > 0 ? "BLENDED" : "TEMPERATURE_TREND";
|
||||
}
|
||||
}
|
||||
|
||||
// -------- §3 band half-width --------
|
||||
|
||||
@@ -120,6 +120,15 @@ export const HALF_WIDTH_MULT_TEMP_TREND = 0.75;
|
||||
/** §4 — symptothermal agreement tolerance between temp-shift and mucus peak (days). */
|
||||
export const SYMPTOTHERMAL_AGREE_DAYS = 2;
|
||||
|
||||
/**
|
||||
* §4 — trailing lookback (days) for the symptothermal / temperature-trend
|
||||
* detectors. The 3-over-6 BBT rule needs 6 baseline readings before the rise,
|
||||
* so a current-cycle scan starting at lastConfirmedStart must reach back this
|
||||
* far to keep the baseline window intact when the cycle is still young. Also
|
||||
* the floor for windowing the day-log read on the hot calendar/insights routes.
|
||||
*/
|
||||
export const BBT_WINDOW = 40;
|
||||
|
||||
/** §3 — confidence scalar clamp (never certain, never zero). */
|
||||
export const CONFIDENCE_MIN = 0.05;
|
||||
export const CONFIDENCE_MAX = 0.98;
|
||||
|
||||
@@ -176,7 +176,9 @@ export const cyclePrefsSchema = z
|
||||
sensitiveCategoryEncryption: z.boolean().optional(),
|
||||
typicalCycleLength: z.number().int().min(15).max(60).nullable().optional(),
|
||||
typicalPeriodLength: z.number().int().min(1).max(15).nullable().optional(),
|
||||
lutealPhaseLength: z.number().int().min(8).max(20).nullable().optional(),
|
||||
// Bound matches the engine clamp [LUTEAL_MIN, LUTEAL_MAX] (§4) so the
|
||||
// stored value never silently snaps in the predictor (QA HIGH).
|
||||
lutealPhaseLength: z.number().int().min(10).max(16).nullable().optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, {
|
||||
message: "At least one preference field is required",
|
||||
|
||||
Reference in new issue
Block a user