refactor(insights): move the feature window arithmetic to a leaf module so signals-of-day no longer imports the extractor

This commit is contained in:
Marc-André Bombeck committed 2026-10-23 11:23:32 +02:00
1 parent daaa44fd82
commit 17e0f47411
4 files changed
+65 -55

No files matched your search

@@ -128,7 +128,7 @@ const TS_READERS: Record<string, Entry> = {
/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": {
+58
View File
@@ -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;
}
+5 -53
View File
@@ -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) ────────────
/**
+1 -1
View File
@@ -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,