From db5e07a944b7304cba159bc38cc7d3402e012c6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 16:46:03 +0200 Subject: [PATCH] fix(source-priority): resolve device-type ladder per row in mixed-type buckets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../__tests__/source-priority.test.ts | 129 ++++++++++++++++++ src/lib/analytics/source-priority.ts | 91 ++++++++---- 2 files changed, 194 insertions(+), 26 deletions(-) diff --git a/src/lib/analytics/__tests__/source-priority.test.ts b/src/lib/analytics/__tests__/source-priority.test.ts index e3295f142..a64f16a62 100644 --- a/src/lib/analytics/__tests__/source-priority.test.ts +++ b/src/lib/analytics/__tests__/source-priority.test.ts @@ -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 diff --git a/src/lib/analytics/source-priority.ts b/src/lib/analytics/source-priority.ts index 8dc249aab..dd4613659 100644 --- a/src/lib/analytics/source-priority.ts +++ b/src/lib/analytics/source-priority.ts @@ -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( 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(); - 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 + >(); + for (const row of pickedRows) { + const typeKey = (row.type ?? "__default__") as + | MeasurementType + | "__default__"; + const set = presentByType.get(typeKey) ?? new Set(); + 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); } }