Skip to content

Commit 8e00e41

Browse files
schwaaampclaude
andcommitted
Smarter dedup: trend direction, outcome-based cross-segment, metric aliases
Three dedup improvements to eliminate ~50% of duplicate discoveries: 1. Trend dedup by metric + direction (ignore change_pct): prevents the same trend from re-surfacing daily as the lookback window shifts 2. Outcome-based cross-segment dedup (±10pp): catches mirror discoveries from definitionally coupled segments (sleep_duration vs time_in_bed) 3. Metric alias groups: hrv/hrv_daily, steps/intraday_steps, sleep_duration/time_in_bed treated as equivalent during dedup Existing discovery dedup also updated with alias + trend awareness. 32 pattern-ranker tests (14 new), 21 pattern-spotter vitest passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8eb2a3a commit 8e00e41

File tree

4 files changed

+365
-20
lines changed

4 files changed

+365
-20
lines changed

docs/planning/insight-engine-v3.md

Lines changed: 195 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1591,14 +1591,208 @@ catch:
15911591
- `_shared/pattern-ranker.ts` — no changes needed
15921592
- All analyzer modules — no changes needed
15931593

1594-
**Status: DONE (2026-03-19)**
1594+
**Status: DONE (2026-03-19, batched narration live)**
15951595
- Batched AI narration live: single GPT-4o-mini call per pipeline run
15961596
- All discoveries and observations get plain-language titles and summaries
15971597
- OpenAI json_object wrapper format handled (extracts array from `{ "discoveries": [...] }`)
15981598
- Template fallback on AI failure — 21 vitest passing
15991599
- Admin dashboard updated to show v3 narration diagnostics (mode, patterns narrated, AI succeeded)
16001600
- Experiment matching NOT YET IMPLEMENTED — `suggested_experiment_id` still null (deferred to next iteration)
16011601

1602+
### 8.5 Deduplication Improvements (Step 9: FILTER)
1603+
1604+
#### 8.5.1 The Problem
1605+
1606+
Empirical review of user 3597587c's 67 active discoveries (2026-03-23) revealed ~34 are duplicates or near-duplicates that the current dedup fails to catch. Three root causes:
1607+
1608+
**Problem 1: Trends re-surface daily with different change_pct values**
1609+
1610+
The same trend (e.g., "workout frequency increasing") appears 4 times across 4 days because the lookback window shifts and the change_pct swings (79% → 2700% → 1300% → 833%). The dedup requires `change_pct` within ±5 percentage points — but these are 10-100x apart.
1611+
1612+
Examples from real data:
1613+
- "Workout Frequency Has Increased" — 4 copies (March 19-22)
1614+
- "HRV Decreasing" — 2 copies per metric variant
1615+
- "Steps Increasing" — 4 copies across steps/intraday_steps
1616+
- "High Activity Increasing" — 3 copies
1617+
1618+
**Problem 2: Coupled segment metrics produce mirror discoveries**
1619+
1620+
`sleep_duration` and `time_in_bed` segment days nearly identically (r≈0.95). Every discovery from one is duplicated by the other — same outcome, same change_pct, different segment name. The dedup requires matching `segment_metric_key`, so it treats them as distinct.
1621+
1622+
7 mirror pairs found:
1623+
- "Wider Glucose Range on Low Sleep Days" / "...on Low Time in Bed Days" (+37%)
1624+
- "Higher Max Glucose on Low Sleep Days" / "...on Low Time in Bed Days" (+20.8%)
1625+
- "More Sedentary on Low Sleep Days" / "...on Low Time in Bed Days" (+16.3%)
1626+
- etc.
1627+
1628+
**Problem 3: Metric aliases produce identical discoveries**
1629+
1630+
`hrv` is literally `hrv_daily` for WHOOP users (the derived metric falls back). `steps` equals `intraday_steps` (daily sum of same data). Each discovery appears twice — once per metric variant.
1631+
1632+
8 alias duplicates found:
1633+
- "HRV Decreasing" / "Daily HRV Decreasing" (both -7.6%)
1634+
- "Improved HRV After Low Activity" / "Higher Daily HRV After Low Activity" (both +15.3%)
1635+
- "Steps Increasing" / "Intraday Steps Increasing" (both ~30-35%)
1636+
1637+
#### 8.5.2 The Fixes
1638+
1639+
All three fixes are changes to `areDuplicates()` and `deduplicatePatterns()` in `pattern-ranker.ts`. No pipeline or analyzer changes needed.
1640+
1641+
**Fix 1: Trend dedup by metric_key + direction (ignore change_pct)**
1642+
1643+
For trend candidates, the current rule `metric_key + segment_metric_key + change_pct ±5%` is too narrow. Change_pct for trends varies wildly as the lookback window shifts.
1644+
1645+
New rule for trends: Two trend candidates are duplicates if:
1646+
- Same `metric_key` (or aliases — see Fix 3)
1647+
- Same `direction` (both increasing or both decreasing)
1648+
- `change_pct` is ignored for trends
1649+
1650+
This also applies to existing discovery dedup: if an active discovery with the same metric_key and direction already exists, the new trend is a duplicate regardless of change_pct.
1651+
1652+
**Fix 2: Outcome-based dedup for segment comparisons**
1653+
1654+
Two segment comparison candidates are duplicates if:
1655+
- Same `metric_key` (outcome)
1656+
- `change_pct` within ±10% (wider tolerance for cross-segment dedup)
1657+
- `segment_metric_key` can differ (this is the key relaxation)
1658+
1659+
This catches the sleep_duration/time_in_bed mirrors. When "Low Sleep Days → glucose_range +37%" and "Low Time in Bed Days → glucose_range +37%" both survive BH, the second is deduped because the outcome and change match.
1660+
1661+
The wider ±10% tolerance (vs the current ±5%) accounts for slight differences when two correlated segment metrics don't segment days exactly the same way.
1662+
1663+
**Fix 3: Metric alias groups**
1664+
1665+
Define alias sets where metrics produce identical or near-identical values for a given user:
1666+
1667+
```typescript
1668+
const METRIC_ALIAS_GROUPS: string[][] = [
1669+
['hrv', 'hrv_daily'], // hrv = hrv_daily ?? hrv_sleep (WHOOP users: always hrv_daily)
1670+
['steps', 'intraday_steps'], // daily total = sum of intraday
1671+
['sleep_duration', 'time_in_bed'], // definitionally coupled (r≈0.95)
1672+
];
1673+
```
1674+
1675+
During dedup, two metric keys are considered equivalent if they belong to the same alias group. This means:
1676+
- "HRV Decreasing" and "Daily HRV Decreasing" → same finding
1677+
- "Steps Increasing" and "Intraday Steps Increasing" → same finding
1678+
- Any discovery with outcome `sleep_duration` matches against one with `time_in_bed`
1679+
- Any segment using `sleep_duration` is equivalent to one using `time_in_bed`
1680+
1681+
The alias check is used in BOTH within-batch dedup AND existing-discovery dedup.
1682+
1683+
#### 8.5.3 Updated `areDuplicates` Logic
1684+
1685+
```typescript
1686+
const METRIC_ALIAS_GROUPS: string[][] = [
1687+
['hrv', 'hrv_daily'],
1688+
['steps', 'intraday_steps'],
1689+
['sleep_duration', 'time_in_bed'],
1690+
];
1691+
1692+
// Pre-computed lookup: metric_key → canonical representative
1693+
const METRIC_CANONICAL: Map<string, string> = new Map();
1694+
for (const group of METRIC_ALIAS_GROUPS) {
1695+
const canonical = group[0]; // first entry is the canonical
1696+
for (const key of group) {
1697+
METRIC_CANONICAL.set(key, canonical);
1698+
}
1699+
}
1700+
1701+
function canonicalKey(metricKey: string): string {
1702+
return METRIC_CANONICAL.get(metricKey) ?? metricKey;
1703+
}
1704+
1705+
function areDuplicates(a: PatternCandidate, b: PatternCandidate): boolean {
1706+
const aOutcome = canonicalKey(a.metric_key);
1707+
const bOutcome = canonicalKey(b.metric_key);
1708+
1709+
// Rule 1: Trend dedup — same metric + same direction (ignore change_pct)
1710+
if (a.type === 'trend' && b.type === 'trend') {
1711+
return aOutcome === bOutcome && a.direction === b.direction;
1712+
}
1713+
1714+
// Rule 2: Outcome-based dedup — same outcome + similar change (segment can differ)
1715+
if (aOutcome === bOutcome) {
1716+
// If segments are also aliases, use tighter threshold
1717+
const aSegment = canonicalKey(a.segment_metric_key ?? '');
1718+
const bSegment = canonicalKey(b.segment_metric_key ?? '');
1719+
const threshold = aSegment === bSegment ? 5 : 10;
1720+
if (Math.abs(a.change_pct - b.change_pct) <= threshold) {
1721+
return true;
1722+
}
1723+
}
1724+
1725+
return false;
1726+
}
1727+
```
1728+
1729+
#### 8.5.4 Existing Discovery Dedup Enhancement
1730+
1731+
The existing discovery dedup also needs to understand aliases and trend direction. The `existingForDedup` data currently only carries `metric_key` and `change_pct`. To support trend dedup, it needs the `pattern_type` and `direction` (if trend) from `metrics_impact`.
1732+
1733+
Update the existing discovery query to include `pattern_type` from metrics_impact:
1734+
1735+
```typescript
1736+
const existingForDedup = allExisting.map(d => ({
1737+
metrics_impact: d.metrics_impact as Array<{
1738+
metric_key: string;
1739+
change_pct: number;
1740+
pattern_type?: string;
1741+
}> | null,
1742+
discovery_type: d.discovery_type,
1743+
title: d.title, // title contains direction hint for trends
1744+
}));
1745+
```
1746+
1747+
For trend matching against existing: if the existing discovery's `pattern_type === 'trend'` and the canonical metric_key matches, treat as duplicate regardless of change_pct.
1748+
1749+
#### 8.5.5 Impact Estimate
1750+
1751+
For user 3597587c (67 discoveries):
1752+
- Fix 1 (trend dedup): eliminates ~12 repeat trends
1753+
- Fix 2 (outcome-based dedup): eliminates ~14 mirror segment discoveries
1754+
- Fix 3 (metric aliases): eliminates ~8 alias duplicates
1755+
1756+
Total: ~34 eliminations → **67 → ~33 unique discoveries**
1757+
1758+
For user 73f1a17e (7 discoveries):
1759+
- 1 duplicate removed (repeat lagged effect)
1760+
- **7 → 6 unique discoveries**
1761+
1762+
#### 8.5.6 Data Cleanup
1763+
1764+
After deploying the fix, existing duplicate discoveries need to be cleaned. Two approaches:
1765+
1766+
**Option A: Delete all and re-run**
1767+
```sql
1768+
DELETE FROM user_discoveries
1769+
WHERE discovery_type IN ('unenrolled_pattern', 'observation')
1770+
AND status IN ('new', 'viewed');
1771+
-- Then invoke spot-patterns-cron to regenerate
1772+
```
1773+
1774+
**Option B: Keep highest-ranked of each duplicate set** (preserves viewed status)
1775+
More complex — requires a script to identify duplicate groups and delete all but the best.
1776+
1777+
Recommendation: Option A (delete + re-run). The AI narration will regenerate fresh text, and the new dedup logic will prevent duplicates from returning.
1778+
1779+
#### 8.5.7 Implementation Scope
1780+
1781+
**Files to modify:**
1782+
- `_shared/pattern-ranker.ts` — rewrite `areDuplicates()`, add alias groups, update existing dedup
1783+
- `_shared/pattern-ranker.test.ts` — new tests for trend dedup, outcome-based dedup, alias groups
1784+
- `ai-engine/engines/pattern-spotter.ts` — update `existingForDedup` to include pattern_type
1785+
1786+
**Files unchanged:**
1787+
- All analyzers, metric-discovery, blacklist, BH families — no changes needed
1788+
1789+
**Status: DONE (2026-03-23)**
1790+
- `areDuplicates()` rewritten with three rules: trend direction dedup, outcome-based cross-segment dedup, metric aliases
1791+
- `canonicalKey()` exported for alias resolution (hrv↔hrv_daily, steps↔intraday_steps, sleep_duration↔time_in_bed)
1792+
- `deduplicatePatterns()` updated: existing discovery matching uses aliases + trend direction awareness
1793+
- 32 pattern-ranker tests passing (14 new), 21 pattern-spotter vitest passing
1794+
- Expected reduction: ~67 → ~33 unique discoveries for user 3597587c after re-run
1795+
16021796
---
16031797

16041798
## 9. Migration Strategy

supabase/functions/_shared/pattern-ranker.test.ts

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { assertEquals } from 'https://deno.land/std@0.224.0/assert/mod.ts';
22
import {
33
areDuplicates,
4+
canonicalKey,
45
classifyCandidate,
56
compositeScore,
67
deduplicatePatterns,
@@ -101,6 +102,24 @@ Deno.test('rankCandidates: single candidate → rank 1', () => {
101102
assertEquals(ranked[0].rank, 1);
102103
});
103104

105+
// ── canonicalKey ────────────────────────────────────────────────────────────
106+
107+
Deno.test('canonicalKey: hrv_daily → hrv (alias)', () => {
108+
assertEquals(canonicalKey('hrv_daily'), 'hrv');
109+
});
110+
111+
Deno.test('canonicalKey: intraday_steps → steps (alias)', () => {
112+
assertEquals(canonicalKey('intraday_steps'), 'steps');
113+
});
114+
115+
Deno.test('canonicalKey: time_in_bed → sleep_duration (alias)', () => {
116+
assertEquals(canonicalKey('time_in_bed'), 'sleep_duration');
117+
});
118+
119+
Deno.test('canonicalKey: resting_hr → resting_hr (no alias, returns self)', () => {
120+
assertEquals(canonicalKey('resting_hr'), 'resting_hr');
121+
});
122+
104123
// ── areDuplicates ───────────────────────────────────────────────────────────
105124

106125
Deno.test('areDuplicates: same metric_key + segment_metric_key + similar change → true', () => {
@@ -115,12 +134,80 @@ Deno.test('areDuplicates: different metric_key → false', () => {
115134
assertEquals(areDuplicates(a, b), false);
116135
});
117136

118-
Deno.test('areDuplicates: same metric_key but change_pct differs by > 5 → false', () => {
137+
Deno.test('areDuplicates: same metric_key but change_pct differs by > 5 with same segment → false', () => {
119138
const a = makeCandidate({ change_pct: 10.0 });
120-
const b = makeCandidate({ change_pct: 20.0 }); // 10 pp difference
139+
const b = makeCandidate({ change_pct: 20.0 }); // 10 pp difference, same segment
140+
assertEquals(areDuplicates(a, b), false);
141+
});
142+
143+
// ── Fix 1: Trend dedup by metric + direction (ignore change_pct) ───────────
144+
145+
Deno.test('areDuplicates: two trends, same metric, same direction, wildly different change_pct → true', () => {
146+
const a = makeCandidate({ type: 'trend', metric_key: 'has_workout', direction: 'increasing', change_pct: 79.7 });
147+
const b = makeCandidate({ type: 'trend', metric_key: 'has_workout', direction: 'increasing', change_pct: 2700 });
148+
assertEquals(areDuplicates(a, b), true);
149+
});
150+
151+
Deno.test('areDuplicates: two trends, same metric, different direction → false', () => {
152+
const a = makeCandidate({ type: 'trend', metric_key: 'hrv', direction: 'increasing', change_pct: 10 });
153+
const b = makeCandidate({ type: 'trend', metric_key: 'hrv', direction: 'decreasing', change_pct: -10 });
154+
assertEquals(areDuplicates(a, b), false);
155+
});
156+
157+
Deno.test('areDuplicates: trend vs segment_comparison same metric → false (different types)', () => {
158+
const a = makeCandidate({ type: 'trend', metric_key: 'hrv', direction: 'decreasing', change_pct: -14 });
159+
const b = makeCandidate({ type: 'segment_comparison', metric_key: 'hrv', change_pct: -14 });
160+
assertEquals(areDuplicates(a, b), false);
161+
});
162+
163+
// ── Fix 2: Outcome-based dedup (different segment, same outcome + change) ──
164+
165+
Deno.test('areDuplicates: same outcome, similar change, different segment → true (±10%)', () => {
166+
const a = makeCandidate({ metric_key: 'glucose_range', segment_metric_key: 'sleep_duration', change_pct: 37 });
167+
const b = makeCandidate({ metric_key: 'glucose_range', segment_metric_key: 'time_in_bed', change_pct: 37 });
168+
assertEquals(areDuplicates(a, b), true);
169+
});
170+
171+
Deno.test('areDuplicates: same outcome, change differs by 8% (within ±10), different segment → true', () => {
172+
const a = makeCandidate({ metric_key: 'sedentary_min', segment_metric_key: 'sleep_duration', change_pct: -20.9 });
173+
const b = makeCandidate({ metric_key: 'sedentary_min', segment_metric_key: 'time_in_bed', change_pct: -16.3 });
174+
// Difference is 4.6 pp, segments are aliases → uses 5 pp threshold
175+
assertEquals(areDuplicates(a, b), true);
176+
});
177+
178+
Deno.test('areDuplicates: same outcome, change differs by 12%, unrelated segments → false', () => {
179+
const a = makeCandidate({ metric_key: 'sedentary_min', segment_metric_key: 'avg_glucose', change_pct: 20 });
180+
const b = makeCandidate({ metric_key: 'sedentary_min', segment_metric_key: 'strain', change_pct: 32 });
181+
// Difference is 12 pp, segments are NOT aliases → uses 10 pp threshold → 12 > 10 → false
121182
assertEquals(areDuplicates(a, b), false);
122183
});
123184

185+
// ── Fix 3: Metric aliases ──────────────────────────────────────────────────
186+
187+
Deno.test('areDuplicates: hrv and hrv_daily trends with same direction → true (aliases)', () => {
188+
const a = makeCandidate({ type: 'trend', metric_key: 'hrv', direction: 'decreasing', change_pct: -7.6 });
189+
const b = makeCandidate({ type: 'trend', metric_key: 'hrv_daily', direction: 'decreasing', change_pct: -7.6 });
190+
assertEquals(areDuplicates(a, b), true);
191+
});
192+
193+
Deno.test('areDuplicates: steps and intraday_steps trends → true (aliases)', () => {
194+
const a = makeCandidate({ type: 'trend', metric_key: 'steps', direction: 'increasing', change_pct: 30.8 });
195+
const b = makeCandidate({ type: 'trend', metric_key: 'intraday_steps', direction: 'increasing', change_pct: 33.5 });
196+
assertEquals(areDuplicates(a, b), true);
197+
});
198+
199+
Deno.test('areDuplicates: sleep_duration segment vs time_in_bed segment, same outcome → true (segment aliases)', () => {
200+
const a = makeCandidate({ metric_key: 'glucose_max', segment_metric_key: 'sleep_duration', change_pct: 20.8 });
201+
const b = makeCandidate({ metric_key: 'glucose_max', segment_metric_key: 'time_in_bed', change_pct: 20.8 });
202+
assertEquals(areDuplicates(a, b), true);
203+
});
204+
205+
Deno.test('areDuplicates: hrv outcome and hrv_daily outcome, same segment → true (outcome aliases)', () => {
206+
const a = makeCandidate({ type: 'lagged_effect', metric_key: 'hrv', segment_metric_key: 'wake_hour', change_pct: 14.8 });
207+
const b = makeCandidate({ type: 'lagged_effect', metric_key: 'hrv_daily', segment_metric_key: 'wake_hour', change_pct: 14.8 });
208+
assertEquals(areDuplicates(a, b), true);
209+
});
210+
124211
// ── deduplicatePatterns ─────────────────────────────────────────────────────
125212

126213
Deno.test('deduplicatePatterns: removes candidates matching existing discoveries', () => {

0 commit comments

Comments
 (0)