From 17e0f47411085af5cce2d22e4d188e6e2f63a839 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 11:23:32 +0200 Subject: [PATCH] refactor(insights): move the feature window arithmetic to a leaf module so signals-of-day no longer imports the extractor --- .../pulse-day-mean-reader-guard.test.ts | 2 +- src/lib/insights/feature-window.ts | 58 +++++++++++++++++++ src/lib/insights/features.ts | 58 ++----------------- src/lib/insights/signals-of-day.ts | 2 +- 4 files changed, 65 insertions(+), 55 deletions(-) create mode 100644 src/lib/insights/feature-window.ts diff --git a/src/__tests__/pulse-day-mean-reader-guard.test.ts b/src/__tests__/pulse-day-mean-reader-guard.test.ts index 0918eda6a..1250da987 100644 --- a/src/__tests__/pulse-day-mean-reader-guard.test.ts +++ b/src/__tests__/pulse-day-mean-reader-guard.test.ts @@ -128,7 +128,7 @@ const TS_READERS: Record = { /applyPayloadBudget\(\s*pulsePoints\s*,\s*\{[^}]*type\s*:\s*"PULSE"/, }, "lib/insights/signals-of-day.ts": { - via: "lib/insights/features.ts", + via: "lib/insights/feature-window.ts", passes: /byType\(\s*"PULSE"\s*\)[\s\S]{0,80}type\s*:\s*"PULSE"/, }, "lib/ai/coach/snapshot-blocks/core-metrics-block.ts": { diff --git a/src/lib/insights/feature-window.ts b/src/lib/insights/feature-window.ts new file mode 100644 index 000000000..1dac15e78 --- /dev/null +++ b/src/lib/insights/feature-window.ts @@ -0,0 +1,58 @@ +/** + * The window arithmetic the feature extractor and the signals-of-day builder + * share: a reading list as trend points, a mean over a days-ago window, and + * a spread rounded to the payload's one decimal. A leaf module, so the + * signals-of-day builder does not import the extractor that re-exports it. + */ +import type { DataPoint } from "@/lib/analytics/trends"; +import { readingsMean } from "@/lib/measurements/day-mean"; +import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic"; +import { standardDeviation, type VarianceKind } from "@/lib/stats/spread"; + +/** + * A spread rounded to the payload's one decimal, `null` below two values: a + * single reading has no spread to report, not a spread of zero. + */ +export function roundedSpread( + values: readonly number[], + kind: VarianceKind, +): number | null { + if (values.length < 2) return null; + const sd = standardDeviation(values, kind); + return sd === null ? null : Math.round(sd * 10) / 10; +} + +export function toDataPoints( + records: Array<{ value: number; measuredAt: Date }>, +): DataPoint[] { + return records.map((r) => ({ date: r.measuredAt, value: r.value })); +} + +/** + * Compute average of values within a time window (days ago from now). + * + * `day` names the type and the zone its days are read in. For a type whose + * day is the mean of its hours' means (pulse, see `day-mean.ts`) the window + * is then the mean of its day values; every other type keeps the plain mean. + */ +export function avgInWindow( + records: Array<{ value: number; measuredAt: Date }>, + now: number, + fromDaysAgo: number, + toDaysAgo: number = 0, + day?: { type: string; tz: string }, +): number | null { + const fromMs = now - fromDaysAgo * 24 * 60 * 60 * 1000; + const toMs = now - toDaysAgo * 24 * 60 * 60 * 1000; + const filtered = records.filter((r) => { + const t = r.measuredAt.getTime(); + return t >= fromMs && t <= toMs; + }); + if (filtered.length === 0) return null; + if (day && usesHourlyMeanDay(day.type)) { + const mean = readingsMean(day.type, filtered, day.tz); + return mean === null ? null : Math.round(mean * 100) / 100; + } + const sum = filtered.reduce((s, r) => s + r.value, 0); + return Math.round((sum / filtered.length) * 100) / 100; +} diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index 18a7cbc3e..9d5bbc908 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -21,7 +21,6 @@ import { } from "@/lib/tz/format"; import { readMoodDailyPoints } from "@/lib/insights/mood-daily-points"; import { readingsMean } from "@/lib/measurements/day-mean"; -import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic"; import { isCurrentForTodayClaim } from "@/lib/insights/measurement-freshness"; import { computeMoodDimensionSeries } from "@/lib/insights/mood-dimension-series"; import { computeContextMoodComparison } from "@/lib/insights/mood-context-crosstab"; @@ -35,11 +34,12 @@ import { import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { getBpTargets } from "@/lib/analytics/bp-targets"; import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target"; +import { READING_SPREAD } from "@/lib/stats/spread"; import { - READING_SPREAD, - standardDeviation, - type VarianceKind, -} from "@/lib/stats/spread"; + avgInWindow, + roundedSpread, + toDataPoints, +} from "@/lib/insights/feature-window"; import { pairByTimestamp, significantPearsonCorrelation, @@ -900,25 +900,6 @@ export class FeaturesPayloadTooLargeError extends Error { } } -/** - * A spread rounded to the payload's one decimal, `null` below two values: a - * single reading has no spread to report, not a spread of zero. - */ -export function roundedSpread( - values: readonly number[], - kind: VarianceKind, -): number | null { - if (values.length < 2) return null; - const sd = standardDeviation(values, kind); - return sd === null ? null : Math.round(sd * 10) / 10; -} - -export function toDataPoints( - records: Array<{ value: number; measuredAt: Date }>, -): DataPoint[] { - return records.map((r) => ({ date: r.measuredAt, value: r.value })); -} - function computeCoverage( records: Array<{ measuredAt: Date }>, now: number, @@ -948,35 +929,6 @@ function computeCoverage( }; } -/** - * Compute average of values within a time window (days ago from now). - * - * `day` names the type and the zone its days are read in. For a type whose - * day is the mean of its hours' means (pulse, see `day-mean.ts`) the window - * is then the mean of its day values; every other type keeps the plain mean. - */ -export function avgInWindow( - records: Array<{ value: number; measuredAt: Date }>, - now: number, - fromDaysAgo: number, - toDaysAgo: number = 0, - day?: { type: string; tz: string }, -): number | null { - const fromMs = now - fromDaysAgo * 24 * 60 * 60 * 1000; - const toMs = now - toDaysAgo * 24 * 60 * 60 * 1000; - const filtered = records.filter((r) => { - const t = r.measuredAt.getTime(); - return t >= fromMs && t <= toMs; - }); - if (filtered.length === 0) return null; - if (day && usesHourlyMeanDay(day.type)) { - const mean = readingsMean(day.type, filtered, day.tz); - return mean === null ? null : Math.round(mean * 100) / 100; - } - const sum = filtered.reduce((s, r) => s + r.value, 0); - return Math.round((sum / filtered.length) * 100) / 100; -} - // ─── v1.22 — cross-signal integration blocks (briefing-scoped) ──────────── /** diff --git a/src/lib/insights/signals-of-day.ts b/src/lib/insights/signals-of-day.ts index 3be2c7e0c..b4a52bd23 100644 --- a/src/lib/insights/signals-of-day.ts +++ b/src/lib/insights/signals-of-day.ts @@ -13,7 +13,7 @@ import { avgInWindow, roundedSpread, toDataPoints, -} from "@/lib/insights/features"; +} from "@/lib/insights/feature-window"; import { SAME_HOURS_MIN_DAYS, SAME_HOURS_TYPES,