Implement decision engine override handling (P0.3)

Add getDecisionWithOverrides() function that checks manual overrides
before algorithmic rules. Overrides are applied in priority order:
flare > stress > sleep > pms, and all force REST status.

Includes comprehensive test suite with 24 tests covering:
- All 8 algorithmic priority rules
- Override type behaviors
- Override priority enforcement
- Empty override fallthrough to algorithmic rules

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-10 18:30:57 +00:00
parent d7ecc2944d
commit 03f1bde24c
3 changed files with 768 additions and 1 deletions

View File

@@ -0,0 +1,242 @@
// ABOUTME: Unit tests for the training decision engine.
// ABOUTME: Tests all 8 priority rules and 4 override scenarios.
import { describe, expect, it } from "vitest";
import type { DailyData, OverrideType } from "@/types";
import {
getDecisionWithOverrides,
getTrainingDecision,
} from "./decision-engine";
// Helper to create baseline "healthy" data where TRAIN would be the decision
function createHealthyData(): DailyData {
return {
hrvStatus: "Balanced",
bbYesterdayLow: 50, // above 30
phase: "FOLLICULAR", // not LATE_LUTEAL or MENSTRUAL
weekIntensity: 0, // well below limit
phaseLimit: 120,
bbCurrent: 90, // above 85
};
}
describe("getTrainingDecision (algorithmic rules)", () => {
describe("Priority 1: HRV Unbalanced", () => {
it("forces REST when HRV is Unbalanced", () => {
const data = createHealthyData();
data.hrvStatus = "Unbalanced";
const result = getTrainingDecision(data);
expect(result.status).toBe("REST");
expect(result.reason).toContain("HRV");
});
});
describe("Priority 2: Body Battery Yesterday Low", () => {
it("forces REST when BB yesterday low is below 30", () => {
const data = createHealthyData();
data.bbYesterdayLow = 25;
const result = getTrainingDecision(data);
expect(result.status).toBe("REST");
expect(result.reason).toContain("BB");
});
});
describe("Priority 3: Late Luteal Phase", () => {
it("forces GENTLE during late luteal phase", () => {
const data = createHealthyData();
data.phase = "LATE_LUTEAL";
const result = getTrainingDecision(data);
expect(result.status).toBe("GENTLE");
expect(result.reason).toContain("rebounding");
});
});
describe("Priority 4: Menstrual Phase", () => {
it("forces GENTLE during menstrual phase", () => {
const data = createHealthyData();
data.phase = "MENSTRUAL";
const result = getTrainingDecision(data);
expect(result.status).toBe("GENTLE");
expect(result.reason).toContain("rebounding");
});
});
describe("Priority 5: Weekly Intensity Limit", () => {
it("forces REST when weekly intensity meets limit", () => {
const data = createHealthyData();
data.weekIntensity = 120; // equals phaseLimit
const result = getTrainingDecision(data);
expect(result.status).toBe("REST");
expect(result.reason).toContain("LIMIT");
});
it("forces REST when weekly intensity exceeds limit", () => {
const data = createHealthyData();
data.weekIntensity = 150; // exceeds phaseLimit
const result = getTrainingDecision(data);
expect(result.status).toBe("REST");
});
});
describe("Priority 6: Body Battery Current Low", () => {
it("forces LIGHT when current BB is below 75", () => {
const data = createHealthyData();
data.bbCurrent = 70;
const result = getTrainingDecision(data);
expect(result.status).toBe("LIGHT");
expect(result.reason).toContain("BB");
});
});
describe("Priority 7: Body Battery Current Medium", () => {
it("forces REDUCED when current BB is below 85 but above 74", () => {
const data = createHealthyData();
data.bbCurrent = 80;
const result = getTrainingDecision(data);
expect(result.status).toBe("REDUCED");
expect(result.reason).toContain("25%");
});
});
describe("Priority 8: Default", () => {
it("returns TRAIN when all conditions are favorable", () => {
const data = createHealthyData();
const result = getTrainingDecision(data);
expect(result.status).toBe("TRAIN");
expect(result.reason).toContain("OK to train");
});
});
describe("Priority enforcement", () => {
it("HRV Unbalanced takes precedence over low BB yesterday", () => {
const data = createHealthyData();
data.hrvStatus = "Unbalanced";
data.bbYesterdayLow = 25;
const result = getTrainingDecision(data);
expect(result.reason).toContain("HRV");
});
it("low BB yesterday takes precedence over phase-based rules", () => {
const data = createHealthyData();
data.bbYesterdayLow = 25;
data.phase = "LATE_LUTEAL";
const result = getTrainingDecision(data);
expect(result.status).toBe("REST");
expect(result.reason).toContain("BB");
});
});
});
describe("getDecisionWithOverrides", () => {
describe("override types force REST", () => {
it("flare override forces REST", () => {
const data = createHealthyData();
const overrides: OverrideType[] = ["flare"];
const result = getDecisionWithOverrides(data, overrides);
expect(result.status).toBe("REST");
expect(result.reason).toContain("flare");
});
it("stress override forces REST", () => {
const data = createHealthyData();
const overrides: OverrideType[] = ["stress"];
const result = getDecisionWithOverrides(data, overrides);
expect(result.status).toBe("REST");
expect(result.reason).toContain("stress");
});
it("sleep override forces REST", () => {
const data = createHealthyData();
const overrides: OverrideType[] = ["sleep"];
const result = getDecisionWithOverrides(data, overrides);
expect(result.status).toBe("REST");
expect(result.reason).toContain("sleep");
});
it("pms override forces REST", () => {
const data = createHealthyData();
const overrides: OverrideType[] = ["pms"];
const result = getDecisionWithOverrides(data, overrides);
expect(result.status).toBe("REST");
expect(result.reason).toContain("pms");
});
});
describe("override priority (flare > stress > sleep > pms)", () => {
it("flare takes precedence over stress", () => {
const data = createHealthyData();
const overrides: OverrideType[] = ["stress", "flare"];
const result = getDecisionWithOverrides(data, overrides);
expect(result.reason).toContain("flare");
expect(result.reason).not.toContain("stress");
});
it("stress takes precedence over sleep", () => {
const data = createHealthyData();
const overrides: OverrideType[] = ["sleep", "stress"];
const result = getDecisionWithOverrides(data, overrides);
expect(result.reason).toContain("stress");
expect(result.reason).not.toContain("sleep");
});
it("sleep takes precedence over pms", () => {
const data = createHealthyData();
const overrides: OverrideType[] = ["pms", "sleep"];
const result = getDecisionWithOverrides(data, overrides);
expect(result.reason).toContain("sleep");
expect(result.reason).not.toContain("pms");
});
it("respects full priority chain with all overrides", () => {
const data = createHealthyData();
const overrides: OverrideType[] = ["pms", "sleep", "stress", "flare"];
const result = getDecisionWithOverrides(data, overrides);
expect(result.reason).toContain("flare");
});
});
describe("overrides bypass algorithmic rules", () => {
it("flare override bypasses favorable conditions", () => {
const data = createHealthyData();
// Normally this would return TRAIN
const overrides: OverrideType[] = ["flare"];
const result = getDecisionWithOverrides(data, overrides);
expect(result.status).toBe("REST");
});
it("stress override works even with perfect biometrics", () => {
const data = createHealthyData();
data.bbCurrent = 100;
data.bbYesterdayLow = 80;
const overrides: OverrideType[] = ["stress"];
const result = getDecisionWithOverrides(data, overrides);
expect(result.status).toBe("REST");
});
});
describe("empty overrides fall through to algorithmic rules", () => {
it("returns TRAIN with no overrides and healthy data", () => {
const data = createHealthyData();
const overrides: OverrideType[] = [];
const result = getDecisionWithOverrides(data, overrides);
expect(result.status).toBe("TRAIN");
});
it("returns REST for HRV Unbalanced with no overrides", () => {
const data = createHealthyData();
data.hrvStatus = "Unbalanced";
const overrides: OverrideType[] = [];
const result = getDecisionWithOverrides(data, overrides);
expect(result.status).toBe("REST");
expect(result.reason).toContain("HRV");
});
it("algorithmic rules apply when overrides array is empty", () => {
const data = createHealthyData();
data.bbCurrent = 70;
const overrides: OverrideType[] = [];
const result = getDecisionWithOverrides(data, overrides);
expect(result.status).toBe("LIGHT");
});
});
});

View File

@@ -1,6 +1,16 @@
// ABOUTME: Training decision engine based on biometric and cycle data.
// ABOUTME: Implements priority-based rules for daily training recommendations.
import type { DailyData, Decision } from "@/types";
import type { DailyData, Decision, OverrideType } from "@/types";
// Override priority order - checked before algorithmic rules
const OVERRIDE_PRIORITY: OverrideType[] = ["flare", "stress", "sleep", "pms"];
const OVERRIDE_REASONS: Record<OverrideType, string> = {
flare: "Hashimoto's flare - rest required",
stress: "High stress override - rest required",
sleep: "Poor sleep override - rest required",
pms: "pms override - rest required",
};
export function getTrainingDecision(data: DailyData): Decision {
const {
@@ -62,3 +72,22 @@ export function getTrainingDecision(data: DailyData): Decision {
icon: "✅",
};
}
export function getDecisionWithOverrides(
data: DailyData,
overrides: OverrideType[],
): Decision {
// Check overrides first, in priority order: flare > stress > sleep > pms
for (const override of OVERRIDE_PRIORITY) {
if (overrides.includes(override)) {
return {
status: "REST",
reason: OVERRIDE_REASONS[override],
icon: "🛑",
};
}
}
// No active overrides - fall through to algorithmic rules
return getTrainingDecision(data);
}