mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(sources): pickCanonicalWorkout helper for cross-source dedup
W10 senior-dev review flagged that workouts will collide once v1.5
lands the iOS HealthKit passthrough: a single ScanWatch run becomes
two rows — one from HKWorkout, one from the Withings Activity API.
The Measurement table solved the symmetric problem via
pickCanonicalSourceRows; workouts need a parallel helper before the
"recent workouts" tile can show one row per workout instead of one
per source.
This commit ships the scaffolding only. The helper:
- Clusters workouts whose start timestamps land within
proximityMinutes of each other (default 5 minutes — covers the
HealthKit passthrough lag plus a clock-skew buffer) AND share a
sport type, so a Walking + Running burst stays distinct.
- Walks a per-user source ladder (default
APPLE_HEALTH > WITHINGS > MANUAL > IMPORT — see file-level rationale)
and keeps the first source present in each cluster.
- Is deterministic on input order plus a lexicographic id
tiebreaker so the same fixture always produces the same canonical
list.
- Falls through cleanly when none of the ladder entries are
present in a cluster (legacy / future source values).
NOT yet wired into any query path. The wiring lands alongside the
workout ingest endpoint (W16b) or with the v1.5 iOS rollout,
whichever ships first. The Workout model already carries the
TODO(v1.5 iOS) breadcrumb in prisma/schema.prisma; this commit
delivers the helper that breadcrumb points at.
Unit tests cover the three brief-mandated fixtures (two-source same
workout, two-source different workouts close in time, single source
pass-through) plus the 5-minute proximity boundary, custom-ladder
override, empty input, and determinism guards.
This commit is contained in:
1 parent
0e51007974
commit
bff13e76ed
2 files changed
+462
No files matched your search
@@ -0,0 +1,217 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_WORKOUT_PROXIMITY_MINUTES,
|
||||
DEFAULT_WORKOUT_SOURCE_PRIORITY,
|
||||
pickCanonicalWorkout,
|
||||
type WorkoutPickerRow,
|
||||
} from "../pick-canonical-workout";
|
||||
|
||||
/**
|
||||
* v1.4.25 W16a — `pickCanonicalWorkout` unit tests.
|
||||
*
|
||||
* Three primary fixtures match the W16a brief:
|
||||
* (a) two sources record the SAME workout (within proximity, same
|
||||
* sport) — picker keeps the canonical one.
|
||||
* (b) two sources record DIFFERENT workouts close in time (within
|
||||
* proximity but different sport types) — picker keeps both.
|
||||
* (c) single source — pass-through.
|
||||
*
|
||||
* Edge fixtures verify the proximity-window boundary, the determinism
|
||||
* contract, and the empty-input fast path.
|
||||
*/
|
||||
|
||||
const D = (iso: string): Date => new Date(iso);
|
||||
|
||||
const row = (
|
||||
id: string,
|
||||
source: WorkoutPickerRow["source"],
|
||||
startedAt: string,
|
||||
sportType: WorkoutPickerRow["sportType"] = "running",
|
||||
): WorkoutPickerRow => ({
|
||||
id,
|
||||
source,
|
||||
startedAt: D(startedAt),
|
||||
sportType,
|
||||
});
|
||||
|
||||
describe("DEFAULT_WORKOUT_SOURCE_PRIORITY", () => {
|
||||
it("places APPLE_HEALTH ahead of WITHINGS for cumulative workouts", () => {
|
||||
const apple = DEFAULT_WORKOUT_SOURCE_PRIORITY.indexOf("APPLE_HEALTH");
|
||||
const withings = DEFAULT_WORKOUT_SOURCE_PRIORITY.indexOf("WITHINGS");
|
||||
expect(apple).toBeGreaterThanOrEqual(0);
|
||||
expect(withings).toBeGreaterThan(apple);
|
||||
});
|
||||
|
||||
it("places MANUAL ahead of IMPORT so user-typed entries beat bulk loads", () => {
|
||||
const manual = DEFAULT_WORKOUT_SOURCE_PRIORITY.indexOf("MANUAL");
|
||||
const importIdx = DEFAULT_WORKOUT_SOURCE_PRIORITY.indexOf("IMPORT");
|
||||
expect(importIdx).toBeGreaterThan(manual);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickCanonicalWorkout — fixture (a) two-source same workout", () => {
|
||||
it("keeps the APPLE_HEALTH row when both sources record the same run", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z"),
|
||||
// Withings ingested the same workout 60 seconds later (typical
|
||||
// API-pull lag). Same sport, well within the 5-minute window.
|
||||
row("withings-1", "WITHINGS", "2026-05-14T07:31:00.000Z"),
|
||||
]);
|
||||
expect(result.canonical).toHaveLength(1);
|
||||
expect(result.canonical[0].id).toBe("apple-1");
|
||||
expect(result.canonical[0].source).toBe("APPLE_HEALTH");
|
||||
expect(result.clusters).toHaveLength(1);
|
||||
expect(result.clusters[0].members).toHaveLength(2);
|
||||
expect(result.clusters[0].pickedSource).toBe("APPLE_HEALTH");
|
||||
});
|
||||
|
||||
it("still picks APPLE_HEALTH when the Withings row arrives first", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
row("withings-1", "WITHINGS", "2026-05-14T07:30:00.000Z"),
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:31:30.000Z"),
|
||||
]);
|
||||
expect(result.canonical.map((r) => r.id)).toEqual(["apple-1"]);
|
||||
});
|
||||
|
||||
it("honours a per-user override that flips the ladder", () => {
|
||||
const result = pickCanonicalWorkout(
|
||||
[
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z"),
|
||||
row("withings-1", "WITHINGS", "2026-05-14T07:31:00.000Z"),
|
||||
],
|
||||
{ sourcePriority: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "IMPORT"] },
|
||||
);
|
||||
expect(result.canonical.map((r) => r.id)).toEqual(["withings-1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickCanonicalWorkout — fixture (b) two-source different workouts close in time", () => {
|
||||
it("keeps both rows when sport types differ within the proximity window", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
// 07:30 Walking + 07:32 Running — same wall-clock burst (user
|
||||
// tapped one workout, the watch logged a separate one) but
|
||||
// different sport types, so they must stay distinct rows.
|
||||
row("walk-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z", "walking"),
|
||||
row("run-1", "WITHINGS", "2026-05-14T07:32:00.000Z", "running"),
|
||||
]);
|
||||
expect(result.canonical).toHaveLength(2);
|
||||
expect(result.canonical.map((r) => r.id).sort()).toEqual([
|
||||
"run-1",
|
||||
"walk-1",
|
||||
]);
|
||||
expect(result.clusters).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps both rows when the same sport occurs outside the proximity window", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
row("run-am", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z"),
|
||||
// 2 hours later — well past the 5-minute cluster window. A
|
||||
// morning run and an afternoon run on the same day must NOT
|
||||
// collapse into one canonical row.
|
||||
row("run-pm", "WITHINGS", "2026-05-14T09:30:00.000Z"),
|
||||
]);
|
||||
expect(result.canonical).toHaveLength(2);
|
||||
expect(result.clusters).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickCanonicalWorkout — fixture (c) single source pass-through", () => {
|
||||
it("returns the only row unchanged when no other source contributed", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z"),
|
||||
]);
|
||||
expect(result.canonical).toHaveLength(1);
|
||||
expect(result.canonical[0].id).toBe("apple-1");
|
||||
expect(result.clusters[0].pickedSource).toBe("APPLE_HEALTH");
|
||||
});
|
||||
|
||||
it("passes through a batch of disjoint single-source workouts", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
row("run-1", "APPLE_HEALTH", "2026-05-13T07:30:00.000Z"),
|
||||
row("run-2", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z"),
|
||||
row("run-3", "APPLE_HEALTH", "2026-05-15T07:30:00.000Z"),
|
||||
]);
|
||||
expect(result.canonical.map((r) => r.id)).toEqual([
|
||||
"run-1",
|
||||
"run-2",
|
||||
"run-3",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty for empty input", () => {
|
||||
const result = pickCanonicalWorkout([]);
|
||||
expect(result.canonical).toHaveLength(0);
|
||||
expect(result.clusters).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickCanonicalWorkout — proximity-window boundary", () => {
|
||||
it("exposes the default proximity window as 5 minutes", () => {
|
||||
expect(DEFAULT_WORKOUT_PROXIMITY_MINUTES).toBe(5);
|
||||
});
|
||||
|
||||
it("clusters two rows exactly at the proximity boundary", () => {
|
||||
// 5-minute gap — picker accepts the boundary as in-window so a
|
||||
// server's coarse cron tick (Withings hourly pull at HH:00 paired
|
||||
// with HKWorkout export at HH:05) still dedups cleanly.
|
||||
const result = pickCanonicalWorkout([
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z"),
|
||||
row("withings-1", "WITHINGS", "2026-05-14T07:35:00.000Z"),
|
||||
]);
|
||||
expect(result.canonical).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps two rows just outside the proximity window", () => {
|
||||
// 5 minutes and 1 second — picker rejects, two clusters.
|
||||
const result = pickCanonicalWorkout([
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z"),
|
||||
row("withings-1", "WITHINGS", "2026-05-14T07:35:01.000Z"),
|
||||
]);
|
||||
expect(result.canonical).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("respects a custom proximity window", () => {
|
||||
// Same fixture as the same-workout case but with a 30-second
|
||||
// window — the 60-second-later Withings row falls outside and
|
||||
// both rows survive.
|
||||
const result = pickCanonicalWorkout(
|
||||
[
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z"),
|
||||
row("withings-1", "WITHINGS", "2026-05-14T07:31:00.000Z"),
|
||||
],
|
||||
{ proximityMinutes: 0.5 },
|
||||
);
|
||||
expect(result.canonical).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickCanonicalWorkout — determinism", () => {
|
||||
it("returns the same canonical list regardless of input order", () => {
|
||||
const a = pickCanonicalWorkout([
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z"),
|
||||
row("withings-1", "WITHINGS", "2026-05-14T07:31:00.000Z"),
|
||||
row("manual-1", "MANUAL", "2026-05-14T07:30:30.000Z"),
|
||||
]);
|
||||
const b = pickCanonicalWorkout([
|
||||
row("manual-1", "MANUAL", "2026-05-14T07:30:30.000Z"),
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z"),
|
||||
row("withings-1", "WITHINGS", "2026-05-14T07:31:00.000Z"),
|
||||
]);
|
||||
expect(a.canonical.map((r) => r.id)).toEqual(
|
||||
b.canonical.map((r) => r.id),
|
||||
);
|
||||
});
|
||||
|
||||
it("breaks same-timestamp ties via id ordering", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
row("a-2", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z"),
|
||||
row("a-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z"),
|
||||
]);
|
||||
// Both rows are APPLE_HEALTH within the same cluster — the picker
|
||||
// keeps the lexicographically-earlier `id` so the choice is
|
||||
// independent of the caller's input order.
|
||||
expect(result.canonical).toHaveLength(1);
|
||||
expect(result.canonical[0].id).toBe("a-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* v1.4.25 W16a — cross-source workout canonical picker.
|
||||
*
|
||||
* Companion to `pickCanonicalSourceRows()` (the Measurement-row picker
|
||||
* in `src/lib/analytics/source-priority.ts`). Workouts have a similar
|
||||
* double-ingest problem coming in v1.5: the iOS app will write an
|
||||
* HKWorkout row at the same wall-clock time the Withings Activity API
|
||||
* already supplied — same run, two rows.
|
||||
*
|
||||
* This helper is scaffolding only — it returns the canonical workout
|
||||
* list, doesn't mutate the DB, and is not yet wired into any query
|
||||
* path. The wiring lands alongside the workout ingest endpoint
|
||||
* (W16b in the v1.4.25 plan) or with the v1.5 iOS rollout, whichever
|
||||
* arrives first.
|
||||
*
|
||||
* Algorithm
|
||||
* ─────────
|
||||
*
|
||||
* 1. Cluster workouts whose start timestamps land within
|
||||
* `proximityMinutes` of each other (default 5 min — Apple's
|
||||
* ScanWatch passthrough lag plus a small clock-skew buffer).
|
||||
* Sport type must match across the cluster; a "Walking" workout
|
||||
* and a "Running" workout starting at the same instant stay
|
||||
* distinct rows so the cardio-vs-strength split stays clean.
|
||||
* 2. Per cluster, walk the per-user source-priority ladder for
|
||||
* workouts (default `APPLE_HEALTH > WITHINGS > MANUAL > IMPORT`)
|
||||
* and keep the first source that has a row in the cluster. All
|
||||
* other rows from the same cluster are dropped from the
|
||||
* canonical list.
|
||||
* 3. Single-element clusters (no near-duplicate) pass through
|
||||
* unchanged regardless of source.
|
||||
*
|
||||
* Rationale for the default ladder
|
||||
* ────────────────────────────────
|
||||
*
|
||||
* - `APPLE_HEALTH` first: the iOS-app passthrough carries the
|
||||
* watch's per-second sample stream (energy, HR, optional GPS),
|
||||
* so when both sources have the same workout it's the richer
|
||||
* row.
|
||||
* - `WITHINGS` second: ScanWatch syncs into Withings cloud too,
|
||||
* but the API caps each workout at coarser aggregates (mean HR,
|
||||
* totals, no per-sample HR series). Loses cleanly when paired
|
||||
* with the HKWorkout twin.
|
||||
* - `MANUAL` third: the user typed it; trust beats anything from
|
||||
* `IMPORT` (CSV / XML) but loses to a sensor stream.
|
||||
* - `IMPORT` last: bulk historical imports (Apple Health XML,
|
||||
* Strava export, Garmin TCX) — useful when no live sensor source
|
||||
* covers the period but the lowest fidelity when one does.
|
||||
*
|
||||
* The picker is deterministic on `(rows, ladder, proximityMinutes)`
|
||||
* and stable on input order. The caller is responsible for sorting
|
||||
* rows by `startedAt ASC, id ASC` before invocation so the picker's
|
||||
* cluster-building walks a fixed sequence.
|
||||
*/
|
||||
|
||||
import type { MeasurementSource } from "@/generated/prisma/client";
|
||||
|
||||
import type { WorkoutSportType } from "@/lib/validations/workout";
|
||||
|
||||
/**
|
||||
* Minimum row shape the helper consults. Exposed as an interface so the
|
||||
* Prisma `Workout` model and the not-yet-existing ingest DTO both
|
||||
* compose with the picker without a transform step. Anything else on
|
||||
* the row (totals, HR aggregates, route blob, metadata) flows through
|
||||
* untouched — the picker filters but never mutates.
|
||||
*/
|
||||
export interface WorkoutPickerRow {
|
||||
/** Stable identifier — `id` for DB rows, externalId+source for
|
||||
* not-yet-persisted ingest payloads. Used for deterministic
|
||||
* tie-breaking when two sources both win the priority round. */
|
||||
id: string;
|
||||
startedAt: Date;
|
||||
source: MeasurementSource;
|
||||
/** Canonical sport string. Workouts of different sport types never
|
||||
* collapse into one cluster — see step 1 of the algorithm. */
|
||||
sportType: WorkoutSportType | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-user, per-source priority list for workouts. The shape is
|
||||
* parallel to `SOURCE_PRIORITY_METRIC_KEYS` entries but stored
|
||||
* separately because workouts are their own entity (not a Measurement
|
||||
* row), and the metric-priority Json blob on `User.sourcePriorityJson`
|
||||
* never carries a `workouts` key today.
|
||||
*
|
||||
* Persistence of the per-user value lands when the dedup wiring lands;
|
||||
* the helper accepts an explicit ladder so the future caller can pass
|
||||
* the merged value without dragging schema work into this file.
|
||||
*/
|
||||
export const DEFAULT_WORKOUT_SOURCE_PRIORITY: readonly MeasurementSource[] = [
|
||||
"APPLE_HEALTH",
|
||||
"WITHINGS",
|
||||
"MANUAL",
|
||||
"IMPORT",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Default proximity window. Apple's HealthKit passthrough is observed
|
||||
* to arrive 0–60 seconds after the watch closes the workout; Withings
|
||||
* Activity API rounds to the minute. 5 minutes covers both lags plus a
|
||||
* small clock-skew buffer between the iOS app's clock and the server.
|
||||
*/
|
||||
export const DEFAULT_WORKOUT_PROXIMITY_MINUTES = 5;
|
||||
|
||||
export interface PickCanonicalWorkoutOptions {
|
||||
/**
|
||||
* Per-user source ladder. Defaults to
|
||||
* `DEFAULT_WORKOUT_SOURCE_PRIORITY`. The picker walks left-to-right
|
||||
* and keeps the first source present in each cluster.
|
||||
*/
|
||||
sourcePriority?: readonly MeasurementSource[];
|
||||
/**
|
||||
* Cluster window. Defaults to `DEFAULT_WORKOUT_PROXIMITY_MINUTES`.
|
||||
* Workouts whose start timestamps are within ±this many minutes of
|
||||
* an already-clustered workout join the same cluster; outside the
|
||||
* window they form a new cluster even with the same sport type.
|
||||
*/
|
||||
proximityMinutes?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the canonical-source workout list. See file-level docs for the
|
||||
* algorithm; returns a subset of the input plus a per-cluster pick
|
||||
* record useful for debug overlays / audit logging.
|
||||
*/
|
||||
export function pickCanonicalWorkout<T extends WorkoutPickerRow>(
|
||||
rows: readonly T[],
|
||||
options: PickCanonicalWorkoutOptions = {},
|
||||
): {
|
||||
canonical: T[];
|
||||
/**
|
||||
* One entry per cluster. Useful when the caller wants to log
|
||||
* "we dropped a WITHINGS row in favour of an APPLE_HEALTH twin"
|
||||
* without re-running the picker.
|
||||
*/
|
||||
clusters: Array<{
|
||||
members: T[];
|
||||
pickedSource: MeasurementSource;
|
||||
picked: T;
|
||||
}>;
|
||||
} {
|
||||
if (rows.length === 0) {
|
||||
return { canonical: [], clusters: [] };
|
||||
}
|
||||
|
||||
const ladder =
|
||||
options.sourcePriority ?? DEFAULT_WORKOUT_SOURCE_PRIORITY;
|
||||
const proximityMinutes =
|
||||
options.proximityMinutes ?? DEFAULT_WORKOUT_PROXIMITY_MINUTES;
|
||||
const proximityMs = proximityMinutes * 60_000;
|
||||
|
||||
// Sort by startedAt so cluster building is a single linear pass.
|
||||
// Stable secondary sort on `id` keeps determinism on ties — two rows
|
||||
// recorded at the exact same instant with the same id ordering give
|
||||
// the same picker output on every call.
|
||||
const sorted = [...rows].sort((a, b) => {
|
||||
const dt = a.startedAt.getTime() - b.startedAt.getTime();
|
||||
if (dt !== 0) return dt;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
|
||||
// Build clusters keyed by sport type (a Walking + Running pair
|
||||
// starting at the same instant must stay distinct — different
|
||||
// workouts entirely).
|
||||
const clusters: T[][] = [];
|
||||
for (const row of sorted) {
|
||||
// Find an existing cluster of the same sport type whose latest
|
||||
// member is within the proximity window. Walking from the most
|
||||
// recent cluster backward is O(k) per row where k is the open
|
||||
// cluster count; for the workout volume HealthLog ingests (single
|
||||
// user, tens of workouts per week) this is well below the
|
||||
// measurement bucket-map cost.
|
||||
let placed = false;
|
||||
for (let i = clusters.length - 1; i >= 0; i--) {
|
||||
const cluster = clusters[i];
|
||||
const head = cluster[0];
|
||||
if (head.sportType !== row.sportType) continue;
|
||||
const last = cluster[cluster.length - 1];
|
||||
if (row.startedAt.getTime() - last.startedAt.getTime() <= proximityMs) {
|
||||
cluster.push(row);
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
// Sorted input + outside-window: every earlier cluster of the
|
||||
// same sport is even further away, no point continuing.
|
||||
if (head.sportType === row.sportType) break;
|
||||
}
|
||||
if (!placed) {
|
||||
clusters.push([row]);
|
||||
}
|
||||
}
|
||||
|
||||
// Per-cluster: walk the ladder, keep the first source with a row.
|
||||
// Ties within a source are broken by the sorted-id order baked in
|
||||
// above.
|
||||
const canonical: T[] = [];
|
||||
const clusterRecords: Array<{
|
||||
members: T[];
|
||||
pickedSource: MeasurementSource;
|
||||
picked: T;
|
||||
}> = [];
|
||||
for (const cluster of clusters) {
|
||||
// Single-element fast path — no contention.
|
||||
if (cluster.length === 1) {
|
||||
const only = cluster[0];
|
||||
canonical.push(only);
|
||||
clusterRecords.push({
|
||||
members: cluster,
|
||||
pickedSource: only.source,
|
||||
picked: only,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let pickedSource: MeasurementSource | undefined;
|
||||
for (const candidate of ladder) {
|
||||
if (cluster.some((row) => row.source === candidate)) {
|
||||
pickedSource = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Fallback: none of the ladder entries appear in the cluster
|
||||
// (impossible with the current four-member enum + four-member
|
||||
// default ladder, but the helper accepts a custom ladder). Keep
|
||||
// every row so the picker never silently drops data.
|
||||
if (!pickedSource) {
|
||||
canonical.push(...cluster);
|
||||
clusterRecords.push({
|
||||
members: cluster,
|
||||
pickedSource: cluster[0].source,
|
||||
picked: cluster[0],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const winning = cluster.find((row) => row.source === pickedSource)!;
|
||||
canonical.push(winning);
|
||||
clusterRecords.push({
|
||||
members: cluster,
|
||||
pickedSource,
|
||||
picked: winning,
|
||||
});
|
||||
}
|
||||
|
||||
return { canonical, clusters: clusterRecords };
|
||||
}
|
||||
Reference in new issue
Block a user