mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(mood): floor the fit on complete rows, not only on rated days
The ladder gate counts days carrying a rating. But a day missing one of the fit's selected features is dropped rather than imputed, so a sparse-context account can clear thirty rated days and be fitted on far fewer complete rows — a forecast "built from twenty days" past a floor meant to say thirty. n beside the value and a wide band on a small fit keep it from being dishonest, but the floor should still hold. The number of complete rows the fit actually used now has to clear the same threshold as the rated-day count; below it the account gets the absence shape, and the read path then reports "not enough of a pattern yet" rather than a value resting on a handful of days. Watched red with a fixture of forty rated days carrying the driving feature on only the twenty-five most recent — enough to screen the feature in and to score the recent days, so the refusal is about the complete-row count and nothing else. Before the floor it wrote n=25.
This commit is contained in:
1 parent
9a47b4db49
commit
d58edc5deb
2 files changed
+84
-1
No files matched your search
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* `computeForecast` — the two floors a fit has to clear, and the refusals.
|
||||
*
|
||||
* The first floor is the ladder, on rated days. The second is the one this
|
||||
* file exists for: a complete-case matrix can shrink well below the rated-day
|
||||
* count, because a day missing one of the fit's inputs is dropped rather than
|
||||
* imputed. An account with thirty rated days but a feature recorded on only
|
||||
* two thirds of them fits on twenty complete rows, and a forecast "built from
|
||||
* twenty days" past a floor that was supposed to mean thirty is a floor that
|
||||
* does not hold. The fit is refused below `MIN_ENTRIES_FOR_FORECAST` complete
|
||||
* rows, not only below thirty rated days.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { computeForecast } from "../forecast";
|
||||
import type { PrognosisDayInput } from "../features";
|
||||
import { MIN_ENTRIES_FOR_FORECAST } from "../thresholds";
|
||||
|
||||
const EMPTY_LINKED = {
|
||||
sleepAsleep: null,
|
||||
steps: null,
|
||||
activeEnergy: null,
|
||||
restingHeartRate: null,
|
||||
heartRateVariability: null,
|
||||
};
|
||||
|
||||
function dayKey(index: number): string {
|
||||
return new Date(Date.UTC(2026, 0, 1 + index)).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* `rated` days carrying a rating driven by A2, of which the most recent
|
||||
* `withFeature` actually carry the A2 slider. The rest are older face-only
|
||||
* days: rated, but with no input the fit can use, so they drop out of the
|
||||
* complete-case matrix. The feature sits on the RECENT days on purpose, so the
|
||||
* days the write window would score are scorable — otherwise the refusal is
|
||||
* "no recent day to score" rather than the "too few complete rows" this file
|
||||
* is about.
|
||||
*/
|
||||
function sparseDays(rated: number, withFeature: number): PrognosisDayInput[] {
|
||||
return Array.from({ length: rated }, (_, i) => {
|
||||
const stress = i % 11;
|
||||
const hasFeature = i >= rated - withFeature;
|
||||
return {
|
||||
day: dayKey(i),
|
||||
a1: Math.round(0.8 * (10 - stress) + 1),
|
||||
dimensions: hasFeature ? { a2: stress } : {},
|
||||
context: null,
|
||||
linked: { ...EMPTY_LINKED },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
describe("computeForecast complete-row floor", () => {
|
||||
it("refuses a fit that clears the rated-day floor but not the complete-row one", () => {
|
||||
// 40 rated days, A2 on 25 of them: coverage 62.5% clears the candidate
|
||||
// floor, so A2 is screened in, but the complete-case matrix is 25 rows —
|
||||
// under the 30-row floor.
|
||||
const result = computeForecast(sparseDays(40, 25));
|
||||
expect(result.status, JSON.stringify(result)).toBe("refused");
|
||||
});
|
||||
|
||||
it("writes once the complete rows themselves reach the floor", () => {
|
||||
// Every rated day carries the feature, so complete rows equal rated days.
|
||||
const result = computeForecast(sparseDays(40, 40));
|
||||
expect(result.status).toBe("written");
|
||||
if (result.status === "written") {
|
||||
expect(result.n).toBeGreaterThanOrEqual(MIN_ENTRIES_FOR_FORECAST);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
} from "./features";
|
||||
import { PROGNOSIS_WRITE_WINDOW_DAYS } from "./load";
|
||||
import { featureContributions, predictWithFit } from "./ridge";
|
||||
import { forecastGate } from "./thresholds";
|
||||
import { MIN_ENTRIES_FOR_FORECAST, forecastGate } from "./thresholds";
|
||||
import { fitPrognosisModel } from "./validate";
|
||||
|
||||
/** The lowest and highest value the target scale can express. */
|
||||
@@ -101,6 +101,18 @@ export function computeForecast(
|
||||
const model = matrix === null ? null : fitPrognosisModel(matrix);
|
||||
if (model === null) return { status: "refused", reason: "no-pattern" };
|
||||
|
||||
// The second floor, and the one the rated-day gate above cannot see. A day
|
||||
// missing one of the selected features is dropped rather than imputed, so a
|
||||
// sparse-context account can clear thirty rated days and still fit on a
|
||||
// handful of complete rows. `n` shown beside the value and a wide band on a
|
||||
// small fit keep it from being dishonest, but a forecast "built from twelve
|
||||
// days" past a floor that was supposed to mean thirty is a floor that does
|
||||
// not hold. The number of days the fit ACTUALLY used has to clear the same
|
||||
// threshold, not only the number of days that carried a rating.
|
||||
if (model.n < MIN_ENTRIES_FOR_FORECAST) {
|
||||
return { status: "refused", reason: "no-pattern" };
|
||||
}
|
||||
|
||||
const rows: ForecastRow[] = [];
|
||||
for (const day of rated.slice(-writeWindowDays)) {
|
||||
const standardised = standardiseDay(day, model.standardisation);
|
||||
|
||||
Reference in new issue
Block a user