fix(source-priority): resolve device-type ladder per row in mixed-type buckets

The W8c picker pinned the device-type ladder to `pickedRows[0].type` for
the entire bucket and walked one ladder for every row. The inline
comment promised per-row resolution against the cached ladder map; the
implementation never delivered it. Today's only caller — the
`SLEEP_DURATION` aggregation in `/api/analytics` — feeds single-type
rows so the gap is latent. Future callers (Coach evidence rollup,
doctor-PDF section, correlations engine) that batch multiple
MeasurementTypes through one picker call would have tested non-matching
rows against the wrong ladder.

Group present device-types per row-type, resolve each type's ladder
from the cache, pick the winning device-type independently per type.
Per-type fall-through covers the case where the user's custom ladder
doesn't enumerate any device-type present for a given row-type.

Cover the multi-type path with two new tests:
  - per-row ladder resolution preserves each type's winner
  - per-type fall-through when a custom ladder is unreachable
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 16:46:03 +02:00
1 parent bd1cb2c109
commit db5e07a944
2 files changed
+194 -26

No files matched your search

@@ -374,6 +374,135 @@ describe("pickCanonicalSourceRows — v1.4.25 W8c two-axis device-type picker",
expect(out.canonicalRows).toHaveLength(2);
});
it("resolves the device-type ladder per row when the bucket carries mixed MeasurementTypes", () => {
// v1.4.25 W10 reconcile (Sr-H2) — when a caller batches more than
// one MeasurementType through a single picker call (Coach evidence
// rollup, doctor-PDF section, correlations engine), each row-type
// must resolve against its OWN ladder. The pre-W10 picker pinned
// the ladder to `pickedRows[0].type` for the whole bucket, so a
// bucket with WEIGHT-prefers-scale + ACTIVITY_STEPS-prefers-watch
// rows would test the ACTIVITY_STEPS rows against the WEIGHT
// ladder. Verify each type's winner survives independently.
const rows = [
{
measuredAt: new Date("2026-05-12T07:00:00Z"),
source: "APPLE_HEALTH" as const,
deviceType: "scale",
type: "WEIGHT" as const,
value: 82.4,
},
{
measuredAt: new Date("2026-05-12T07:05:00Z"),
source: "APPLE_HEALTH" as const,
deviceType: "watch",
type: "WEIGHT" as const,
value: 82.5,
},
{
measuredAt: new Date("2026-05-12T07:30:00Z"),
source: "APPLE_HEALTH" as const,
deviceType: "watch",
type: "ACTIVITY_STEPS" as const,
value: 1500,
},
{
measuredAt: new Date("2026-05-12T07:35:00Z"),
source: "APPLE_HEALTH" as const,
deviceType: "phone",
type: "ACTIVITY_STEPS" as const,
value: 4100,
},
];
const userPriority = {
deviceTypePriority: {
// Per-type override: scale wins for WEIGHT (not watch — that's
// the bucket's first row, which the pre-W10 picker would have
// used to resolve the ladder for every row).
WEIGHT: ["scale", "watch"],
// ACTIVITY_STEPS keeps watch on top so its winner stays watch
// and the bucket survives the per-row ladder walk with both
// types' canonical rows preserved.
ACTIVITY_STEPS: ["watch", "phone"],
},
};
const out = pickCanonicalSourceRows(
rows,
"steps",
userPriority,
isoDayKey,
);
// Expect: WEIGHT's scale row + ACTIVITY_STEPS' watch row. Both
// types' losers (WEIGHT's watch row, ACTIVITY_STEPS' phone row)
// are dropped against their OWN ladder, not the bucket's first
// row's ladder.
expect(out.canonicalRows).toHaveLength(2);
const byType = new Map(
out.canonicalRows.map((r) => [r.type, r.deviceType]),
);
expect(byType.get("WEIGHT")).toBe("scale");
expect(byType.get("ACTIVITY_STEPS")).toBe("watch");
});
it("falls through per row-type when a custom ladder doesn't enumerate that type's present device-types", () => {
// Mixed-type bucket where ONE row-type's user ladder doesn't
// include any present device-type. Per-type fall-through: keep
// every row of that type, drop the other type's losers normally.
const rows = [
// WEIGHT — user's ladder says `ring` wins, but only `scale`
// present → fall through, keep both WEIGHT rows.
{
measuredAt: new Date("2026-05-12T07:00:00Z"),
source: "APPLE_HEALTH" as const,
deviceType: "scale",
type: "WEIGHT" as const,
value: 82.4,
},
{
measuredAt: new Date("2026-05-12T07:05:00Z"),
source: "APPLE_HEALTH" as const,
deviceType: "scale",
type: "WEIGHT" as const,
value: 82.5,
},
// ACTIVITY_STEPS — normal default ladder, watch wins over phone.
{
measuredAt: new Date("2026-05-12T07:30:00Z"),
source: "APPLE_HEALTH" as const,
deviceType: "watch",
type: "ACTIVITY_STEPS" as const,
value: 1500,
},
{
measuredAt: new Date("2026-05-12T07:35:00Z"),
source: "APPLE_HEALTH" as const,
deviceType: "phone",
type: "ACTIVITY_STEPS" as const,
value: 4100,
},
];
const userPriority = {
deviceTypePriority: {
WEIGHT: ["ring"],
},
};
const out = pickCanonicalSourceRows(
rows,
"steps",
userPriority,
isoDayKey,
);
// 2 WEIGHT rows kept (per-type fall-through) + 1 ACTIVITY_STEPS
// watch row (phone dropped against default ladder).
expect(out.canonicalRows).toHaveLength(3);
const weightRows = out.canonicalRows.filter((r) => r.type === "WEIGHT");
const stepRows = out.canonicalRows.filter(
(r) => r.type === "ACTIVITY_STEPS",
);
expect(weightRows).toHaveLength(2);
expect(stepRows).toHaveLength(1);
expect(stepRows[0].deviceType).toBe("watch");
});
it("two-axis lookup order: per-metric override > metricPriority > flat default", () => {
// Full-stack test: nested W8c metricPriority pins WITHINGS first
// for weight even though the constant default ladder has WITHINGS
+65 -26
View File
@@ -90,6 +90,12 @@ export interface SourcePickerRow {
* paths (timezone-aware) and tests (deterministic ISO date) can share
* code without dragging a TZ runtime into the helper.
*
* Determinism precondition: input rows MUST be in a deterministic
* order (typically `ORDER BY measuredAt ASC, id ASC` from the caller's
* Prisma read). The picker's bucket map preserves insertion order, so
* the output's stability — including tie-breaking on first-seen
* device-type — depends on the caller honouring this contract.
*
* @returns the filtered row list (subset of input) plus the
* per-day picked source — useful for debug overlays / audit
* logging downstream.
@@ -194,36 +200,69 @@ export function pickCanonicalSourceRows<T extends SourcePickerRow>(
continue;
}
// Resolve the ladder from the first row's type — buckets are
// typically same-type so this only resolves once per bucket. If
// the bucket carries mixed types, `resolveLadder` is cached and
// walked per row inside the device-type walk below.
const sampleLadder = resolveLadder(pickedRows[0].type);
const presentDeviceTypes = new Set<DeviceType>();
for (const row of pickedRows)
presentDeviceTypes.add(normalizeDeviceType(row.deviceType));
let pickedDeviceType: DeviceType | undefined;
for (const dt of sampleLadder) {
if (presentDeviceTypes.has(dt)) {
pickedDeviceType = dt;
break;
}
// Per-row ladder resolution. Single-type buckets (today's only
// call site — `SLEEP_DURATION` aggregation in `/api/analytics`)
// hit the `ladderCache` once and walk an O(rows) loop. Mixed-type
// buckets (future Coach evidence rollup, doctor-PDF section,
// correlations engine) resolve their per-row ladders from the
// cache and pick the winning device-type independently per type
// — every MeasurementType in the bucket keeps its own winner so
// a callsite that batches WEIGHT + BODY_FAT through one picker
// call doesn't drop one type's rows against the wrong ladder.
//
// Group present device-types per row-type so each type's winning
// device-type is resolved against the right ladder.
const presentByType = new Map<
MeasurementType | "__default__",
Set<DeviceType>
>();
for (const row of pickedRows) {
const typeKey = (row.type ?? "__default__") as
| MeasurementType
| "__default__";
const set = presentByType.get(typeKey) ?? new Set<DeviceType>();
set.add(normalizeDeviceType(row.deviceType));
presentByType.set(typeKey, set);
}
// Edge case: the user's custom ladder doesn't enumerate the
// device-types we have in the bucket (e.g. user typed
// `["watch","phone"]`, the bucket has `["scale"]` rows). Fall
// through to keep every row — same intent as the source-axis
// fallback. The `DEFAULT_DEVICE_TYPE_PRIORITY` constant covers
// every enum slot so this only fires when the user typed a custom
// ladder that's missing one of the present types.
if (!pickedDeviceType) {
canonicalRows.push(...pickedRows);
continue;
// Pick the winning device-type per row-type. `__default__` covers
// legacy rows that arrive without a `type` field — they fall back
// to the user-default ladder so the picker never silently drops
// them.
const winningDeviceTypeByRowType = new Map<
MeasurementType | "__default__",
DeviceType | "__fallback__"
>();
for (const [typeKey, presentSet] of presentByType) {
const ladder = resolveLadder(
typeKey === "__default__" ? null : (typeKey as MeasurementType),
);
let winner: DeviceType | undefined;
for (const dt of ladder) {
if (presentSet.has(dt)) {
winner = dt;
break;
}
}
// Edge case: the user's custom ladder for THIS row-type doesn't
// enumerate any device-type in the bucket. Fall through to keep
// every row of that type — same intent as the source-axis
// fallback (never silently drop data when the picker has no
// ladder signal to filter on).
winningDeviceTypeByRowType.set(typeKey, winner ?? "__fallback__");
}
for (const row of pickedRows) {
if (normalizeDeviceType(row.deviceType) === pickedDeviceType) {
const typeKey = (row.type ?? "__default__") as
| MeasurementType
| "__default__";
const winner = winningDeviceTypeByRowType.get(typeKey);
if (winner === "__fallback__") {
// Per-type fallback: keep every row of this type.
canonicalRows.push(row);
continue;
}
if (normalizeDeviceType(row.deviceType) === winner) {
canonicalRows.push(row);
}
}