@ak--47/dungeon-master 1.5.4 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +21 -12
  2. package/.claude/skills/create-dungeon/SKILL.md +65 -5
  3. package/.claude/skills/create-project/SKILL.md +1 -1
  4. package/.claude/skills/create-project/provision.mjs +57 -10
  5. package/.claude/skills/verify-dungeon/SKILL.md +56 -25
  6. package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
  7. package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
  8. package/.claude/skills/write-hooks/SKILL.md +156 -18
  9. package/CHANGELOG.md +183 -0
  10. package/HOOKS.md +566 -112
  11. package/README.md +28 -9
  12. package/docs/guides/1.3.0-upgrade-guide.md +262 -0
  13. package/docs/guides/1.3.1-upgrade-guide.md +328 -0
  14. package/docs/guides/1.4.0-upgrade-guide.md +130 -0
  15. package/docs/guides/1.4.1-upgrade-guide.md +125 -0
  16. package/docs/guides/1.4.3-upgrade-guide.md +200 -0
  17. package/docs/guides/1.4.4-upgrade-guide.md +95 -0
  18. package/docs/guides/1.5.0-upgrade-guide.md +426 -0
  19. package/docs/guides/1.5.1-upgrade-guide.md +178 -0
  20. package/docs/guides/1.5.3-upgrade-guide.md +117 -0
  21. package/docs/guides/1.6.0-upgrade-guide.md +204 -0
  22. package/dungeons/technical/stories-verify.js +152 -0
  23. package/dungeons/vertical/README.md +154 -0
  24. package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
  25. package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
  26. package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
  27. package/dungeons/vertical/community/community.js +1589 -0
  28. package/dungeons/vertical/community/community.sql +216 -0
  29. package/dungeons/vertical/community/community.verify.mjs +72 -0
  30. package/dungeons/vertical/crypto/crypto.js +1483 -0
  31. package/dungeons/vertical/crypto/crypto.sql +194 -0
  32. package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
  33. package/dungeons/vertical/dating/dating.js +1540 -0
  34. package/dungeons/vertical/dating/dating.sql +288 -0
  35. package/dungeons/vertical/dating/dating.verify.mjs +72 -0
  36. package/dungeons/vertical/devtools/devtools.js +1524 -0
  37. package/dungeons/vertical/devtools/devtools.sql +161 -0
  38. package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
  39. package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
  40. package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
  41. package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
  42. package/dungeons/vertical/education/education.js +1763 -0
  43. package/dungeons/vertical/education/education.sql +243 -0
  44. package/dungeons/vertical/education/education.verify.mjs +75 -0
  45. package/dungeons/vertical/fintech/fintech.js +1410 -0
  46. package/dungeons/vertical/fintech/fintech.sql +175 -0
  47. package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
  48. package/dungeons/vertical/fitness/fitness.js +1495 -0
  49. package/dungeons/vertical/fitness/fitness.sql +206 -0
  50. package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
  51. package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
  52. package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
  53. package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
  54. package/dungeons/vertical/gaming/gaming.js +1741 -0
  55. package/dungeons/vertical/gaming/gaming.sql +249 -0
  56. package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
  57. package/dungeons/vertical/healthcare/healthcare.js +1488 -0
  58. package/dungeons/vertical/healthcare/healthcare.sql +193 -0
  59. package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
  60. package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
  61. package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
  62. package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
  63. package/dungeons/vertical/logistics/logistics.js +1541 -0
  64. package/dungeons/vertical/logistics/logistics.sql +205 -0
  65. package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
  66. package/dungeons/vertical/marketplace/marketplace.js +1438 -0
  67. package/dungeons/vertical/marketplace/marketplace.sql +148 -0
  68. package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
  69. package/dungeons/vertical/{media.js → media/media.js} +557 -58
  70. package/dungeons/vertical/media/media.sql +173 -0
  71. package/dungeons/vertical/media/media.verify.mjs +74 -0
  72. package/dungeons/vertical/real-estate/real-estate.js +1629 -0
  73. package/dungeons/vertical/real-estate/real-estate.sql +167 -0
  74. package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
  75. package/dungeons/vertical/sass/sass.js +1733 -0
  76. package/dungeons/vertical/sass/sass.sql +173 -0
  77. package/dungeons/vertical/sass/sass.verify.mjs +74 -0
  78. package/dungeons/vertical/social/social.js +1456 -0
  79. package/dungeons/vertical/social/social.sql +160 -0
  80. package/dungeons/vertical/social/social.verify.mjs +76 -0
  81. package/dungeons/vertical/streaming/streaming.js +677 -0
  82. package/dungeons/vertical/streaming/streaming.sql +101 -0
  83. package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
  84. package/dungeons/vertical/support-desk/support-desk.js +741 -0
  85. package/dungeons/vertical/support-desk/support-desk.sql +137 -0
  86. package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
  87. package/dungeons/vertical/travel/travel.js +1603 -0
  88. package/dungeons/vertical/travel/travel.sql +236 -0
  89. package/dungeons/vertical/travel/travel.verify.mjs +72 -0
  90. package/index.js +2 -2
  91. package/lib/core/config-validator.js +37 -2
  92. package/lib/core/context.js +4 -2
  93. package/lib/core/dungeon-loader.js +20 -15
  94. package/lib/core/dungeon-to-json.js +22 -4
  95. package/lib/core/storage.js +3 -3
  96. package/lib/generators/funnels.js +36 -9
  97. package/lib/hook-helpers/cohort.js +53 -0
  98. package/lib/hook-helpers/index.js +8 -0
  99. package/lib/hook-helpers/mutate.js +1 -1
  100. package/lib/hook-helpers/shape.js +319 -0
  101. package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
  102. package/lib/hook-patterns/attributed-by-source.js +60 -58
  103. package/lib/hook-patterns/frequency-by-frequency.js +14 -4
  104. package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
  105. package/lib/hook-patterns/index.js +1 -1
  106. package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
  107. package/lib/orchestrators/user-loop.js +41 -0
  108. package/lib/templates/story-spec.schema.json +175 -0
  109. package/lib/utils/json-evaluator.js +1 -1
  110. package/lib/utils/utils.js +5 -4
  111. package/lib/verify/coerce.js +186 -0
  112. package/lib/verify/counting.js +270 -32
  113. package/lib/verify/emulate-breakdown.js +1285 -203
  114. package/lib/verify/first-time.js +89 -0
  115. package/lib/verify/flows.js +679 -0
  116. package/lib/verify/formula.js +259 -0
  117. package/lib/verify/funnel-engine.js +697 -113
  118. package/lib/verify/index.js +32 -4
  119. package/lib/verify/sessionize.js +275 -0
  120. package/lib/verify/story-runner.js +483 -0
  121. package/lib/verify/verify-dungeon.js +59 -37
  122. package/package.json +2 -1
  123. package/scripts/extract-dungeon-schema.mjs +2 -2
  124. package/scripts/run-many.mjs +2 -2
  125. package/scripts/verify-runner.mjs +2 -2
  126. package/scripts/verify-stories.mjs +244 -0
  127. package/types.d.ts +296 -30
  128. package/dungeons/vertical/ai-platform.js +0 -818
  129. package/dungeons/vertical/community.js +0 -966
  130. package/dungeons/vertical/crypto.js +0 -895
  131. package/dungeons/vertical/dating.js +0 -830
  132. package/dungeons/vertical/devtools.js +0 -1050
  133. package/dungeons/vertical/ecommerce.js +0 -811
  134. package/dungeons/vertical/education.js +0 -964
  135. package/dungeons/vertical/fintech.js +0 -946
  136. package/dungeons/vertical/fitness.js +0 -935
  137. package/dungeons/vertical/food-delivery.js +0 -934
  138. package/dungeons/vertical/gaming.js +0 -1120
  139. package/dungeons/vertical/healthcare.js +0 -945
  140. package/dungeons/vertical/insurance-application.js +0 -1039
  141. package/dungeons/vertical/logistics.js +0 -986
  142. package/dungeons/vertical/marketplace.js +0 -1019
  143. package/dungeons/vertical/real-estate.js +0 -872
  144. package/dungeons/vertical/sass.js +0 -995
  145. package/dungeons/vertical/social.js +0 -904
  146. package/dungeons/vertical/travel.js +0 -930
@@ -0,0 +1,1495 @@
1
+ // ── IMPORTS ──
2
+ import dayjs from "dayjs";
3
+ import utc from "dayjs/plugin/utc.js";
4
+ dayjs.extend(utc);
5
+ import "dotenv/config";
6
+ import * as u from "@ak--47/dungeon-master/utils";
7
+ /** @typedef {import("../../../types").Dungeon} Config */
8
+
9
+ // ── OVERVIEW ──
10
+ /*
11
+ * NAME: FitQuest
12
+ * APP: Fitness & wellness app for workout tracking, meal planning,
13
+ * social fitness challenges, and AI coaching. Core loop: sign
14
+ * up → plan workout → complete workout → track progress.
15
+ * Revenue: free / monthly ($12.99, 7-day trial) / annual
16
+ * ($99.99) / family ($149.99).
17
+ * SCALE: 10,000 users, ~1.4M events, 121 days (2026-01-01 → 2026-05-01)
18
+ * CORE LOOP: account created → workout planned → workout completed → progress checked
19
+ *
20
+ * EVENTS (18):
21
+ * workout completed (8) > app session (8) > meal logged (7) > workout planned (6)
22
+ * > progress checked (5) > notification received (5) > leaderboard viewed (4)
23
+ * > nutrition plan viewed (4) > challenge joined (3) > coach session (3)
24
+ * > heart rate recorded (3) > achievement unlocked (2) > friend added (2)
25
+ * > challenge completed (2) > subscription managed (2) > profile updated (2)
26
+ * > account created (1) > account deactivated (1)
27
+ *
28
+ * FUNNELS (5):
29
+ * - Onboarding: account created → profile updated → workout planned → workout completed (45%)
30
+ * - Workout Loop: workout planned → workout completed → progress checked (45%, reentry)
31
+ * - Social Engagement: friend added → leaderboard viewed → challenge joined (35%)
32
+ * - Challenge Completion: challenge joined → workout completed → challenge completed → achievement unlocked (30%)
33
+ * - Coaching Path: coach session → workout planned → workout completed → progress checked (50%)
34
+ *
35
+ * USER PROPS: fitness_level, segment, streak_days, total_workouts, preferred_workout, goal, Platform, workout_type, subscription_tier
36
+ * SUPER PROPS: Platform, workout_type, subscription_tier
37
+ * SCD PROPS: fitness_level (beginner/intermediate/advanced/elite, monthly fuzzy, max 8)
38
+ * GROUPS: none
39
+ */
40
+
41
+ // ── HOOK STORIES ──
42
+ /*
43
+ * NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable
44
+ * via raw-prop breakdowns (HOD, day, segment) or behavioral cohorts.
45
+ *
46
+ * ───────────────────────────────────────────────────────────────
47
+ * 1. MORNING WORKOUT BOOST (everything)
48
+ * ───────────────────────────────────────────────────────────────
49
+ *
50
+ * PATTERN: Workouts in 5-9 UTC get calories_burned 1.3x. Mutates
51
+ * raw prop. No flag.
52
+ *
53
+ * HOW TO FIND IT IN MIXPANEL:
54
+ *
55
+ * Report 1: Avg Calories Burned by Hour of Day
56
+ * - Event: "workout completed"
57
+ * - Measure: Average of "calories_burned"
58
+ * - Breakdown: hour of day
59
+ * - Expected: 5-9 hours show ~ 1.3x baseline
60
+ *
61
+ * REAL-WORLD ANALOGUE: Morning workouts get a metabolic boost.
62
+ *
63
+ * ───────────────────────────────────────────────────────────────
64
+ * 2. POST-LAUNCH AI COACHING LIFT (everything hook)
65
+ * ───────────────────────────────────────────────────────────────
66
+ *
67
+ * PATTERN: After day 35 (ai_coach feature launch), workouts
68
+ * with coaching_mode="ai_assisted" get 1.2x duration_minutes.
69
+ * AI coaching helps users push through longer sessions.
70
+ *
71
+ * HOW TO FIND IT IN MIXPANEL:
72
+ *
73
+ * Report 1: AI Coaching Duration Lift
74
+ * • Report type: Insights
75
+ * • Event: "workout completed"
76
+ * • Measure: Average of "duration_minutes"
77
+ * • Breakdown: "coaching_mode"
78
+ * • Filter: time after day 35
79
+ * • Expected: ai_assisted ≈ 48 min vs self_guided ≈ 40 min
80
+ *
81
+ * Report 2: AI Coaching Adoption Over Time
82
+ * • Report type: Insights
83
+ * • Event: "workout completed"
84
+ * • Measure: Total
85
+ * • Breakdown: "coaching_mode"
86
+ * • Line chart by week
87
+ * • Expected: ai_assisted grows from 0 after day 35
88
+ *
89
+ * REAL-WORLD ANALOGUE: AI-powered coaching features increase
90
+ * session duration as users get real-time form and pacing guidance.
91
+ *
92
+ * ───────────────────────────────────────────────────────────────
93
+ * 3. STREAK RETENTION (everything hook)
94
+ * ───────────────────────────────────────────────────────────────
95
+ *
96
+ * PATTERN: Users with >=2 workout events get streak_days set
97
+ * to their actual workout count on their profile (this OVERWRITES
98
+ * H7's coach initialization for any coach with >=2 workouts), and
99
+ * receive achievement clones with super-linear scaling:
100
+ * C(w) = min(w-1, 3) + 4*max(w-4, 0) — 1 per workout for
101
+ * workouts 2-4, then 4 per workout beyond that. This amplifies
102
+ * the gap so athlete/casual ratio reaches 2x+.
103
+ *
104
+ * HOW TO FIND IT IN MIXPANEL:
105
+ *
106
+ * Report 1: Achievement Events by Workout Volume
107
+ * • Report type: Insights
108
+ * • Event: "achievement unlocked"
109
+ * • Measure: Total per user
110
+ * • Breakdown: user property "segment"
111
+ * • Expected: athlete and coach segments show 2-3x more
112
+ * achievements than casual and resolver segments
113
+ *
114
+ * Report 2: Streak Days Distribution
115
+ * • Report type: Insights
116
+ * • Event: "profile updated"
117
+ * • Measure: Average of user property "streak_days"
118
+ * • Breakdown: user property "segment"
119
+ * • Expected: athlete/coach ≈ 30-50 streaks, casual ≈ 10-15
120
+ *
121
+ * REAL-WORLD ANALOGUE: Gamification streaks are the #1 retention
122
+ * driver in fitness apps — users who hit milestones stay longer.
123
+ *
124
+ * ───────────────────────────────────────────────────────────────
125
+ * 4. SOCIAL CHALLENGE COMPLETION (everything hook)
126
+ * ───────────────────────────────────────────────────────────────
127
+ *
128
+ * PATTERN: Users with >=3 "friend added" events receive
129
+ * max(1, floor(0.5 × completions)) cloned "challenge completed"
130
+ * events (≈1.5x total). Social users are more accountable.
131
+ *
132
+ * HOW TO FIND IT IN MIXPANEL:
133
+ *
134
+ * Report 1: Challenge Completion Rate by Social Activity
135
+ * • Report type: Insights
136
+ * • Event: "challenge completed"
137
+ * • Measure: Total per user
138
+ * • Filter: users who did "friend added" at least once
139
+ * • Compare to: users who never did "friend added"
140
+ * • Expected: social users ≈ 1.5x more challenge completions
141
+ *
142
+ * REAL-WORLD ANALOGUE: Social accountability is a proven
143
+ * motivator — users with friends complete more challenges.
144
+ *
145
+ * ───────────────────────────────────────────────────────────────
146
+ * 5. RESOLVER CHURN CLIFF (everything hook)
147
+ * ───────────────────────────────────────────────────────────────
148
+ *
149
+ * PATTERN: Users in the "resolver" segment with <30 events
150
+ * (at hook time) lose 70% of their post-day-14 events — the classic
151
+ * New Year's resolution drop-off. The cliff is engineered entirely
152
+ * by this hook (persona churnRate/activeWindow fields are deprecated
153
+ * engine no-ops). Because deletions are the only mutation, the
154
+ * treated cohort is output-identifiable: eligible ⟺ output events
155
+ * < 30 (treated users can only shrink; untreated keep ≥ 30).
156
+ *
157
+ * HOW TO FIND IT IN MIXPANEL:
158
+ *
159
+ * Report 1: Resolver Retention Drop
160
+ * • Report type: Insights
161
+ * • Event: All events
162
+ * • Measure: Total
163
+ * • Filter: segment = "resolver"
164
+ * • Line chart by week
165
+ * • Expected: Sharp cliff after week 2, ~70% drop in volume
166
+ *
167
+ * Report 2: Segment Retention Comparison
168
+ * • Report type: Retention
169
+ * • Starting event: "account created"
170
+ * • Return event: Any active event
171
+ * • Breakdown: user property "segment"
172
+ * • Expected: resolver retention drops to <30% by week 3
173
+ *
174
+ * REAL-WORLD ANALOGUE: 80% of New Year's gym memberships are
175
+ * abandoned by mid-February — the "resolution cliff."
176
+ *
177
+ * ───────────────────────────────────────────────────────────────
178
+ * 6. COACH SESSION QUALITY (everything hook)
179
+ * ───────────────────────────────────────────────────────────────
180
+ *
181
+ * PATTERN: Users with "coach session" events get higher
182
+ * satisfaction_score (4.0-5.0) on those events. Coached users
183
+ * rate their experience higher.
184
+ *
185
+ * HOW TO FIND IT IN MIXPANEL:
186
+ *
187
+ * Report 1: Satisfaction by Session Type
188
+ * • Report type: Insights
189
+ * • Event: "coach session"
190
+ * • Measure: Average of "satisfaction_score"
191
+ * • Expected: ≈ 4.5 avg (uniform [4.0, 5.0] redraw)
192
+ *
193
+ * REAL-WORLD ANALOGUE: Personal coaching sessions have higher
194
+ * satisfaction scores because of personalized attention.
195
+ *
196
+ * ───────────────────────────────────────────────────────────────
197
+ * 7. COACH PROFILE ENRICHMENT (user hook)
198
+ * ───────────────────────────────────────────────────────────────
199
+ *
200
+ * PATTERN: Users in the "coach" segment get total_workouts
201
+ * boosted to 200-500 (uniform, avg 350). Coaches are power users
202
+ * who lead by example. (The hook also seeds streak_days 60-365,
203
+ * but H3 overwrites streak_days with the actual workout count for
204
+ * any user with >=2 workouts — which is nearly every coach — so
205
+ * total_workouts is the durable coach signature.)
206
+ *
207
+ * HOW TO FIND IT IN MIXPANEL:
208
+ *
209
+ * Report 1: Workout Volume by Segment
210
+ * • Report type: Insights
211
+ * • Event: "workout completed"
212
+ * • Measure: Average of user property "total_workouts"
213
+ * • Breakdown: user property "segment"
214
+ * • Expected: coach ≈ 350 vs athlete ≈ 0 (default) vs casual ≈ 0
215
+ *
216
+ * REAL-WORLD ANALOGUE: Fitness coaches maintain extreme workout
217
+ * consistency to build credibility with their clients.
218
+ *
219
+ * ───────────────────────────────────────────────────────────────
220
+ * 8. ANNUAL SUBSCRIBER WORKOUT FUNNEL LIFT (everything hook)
221
+ * ───────────────────────────────────────────────────────────────
222
+ *
223
+ * PATTERN: Free/monthly-tier users lose ~30% of "progress checked"
224
+ * events (last step of the Workout Loop funnel), simulating lower
225
+ * follow-through. Annual/family subscribers retain all events.
226
+ *
227
+ * HOW TO FIND IT IN MIXPANEL:
228
+ *
229
+ * Report 1: Workout Funnel Conversion by Tier
230
+ * • Report type: Funnels
231
+ * • Steps: "workout planned" → "workout completed" → "progress checked"
232
+ * • Breakdown: "subscription_tier" (superProp)
233
+ * • Expected: annual ≈ 63% vs free ≈ 45% conversion
234
+ *
235
+ * REAL-WORLD ANALOGUE: Annual gym memberships have higher
236
+ * utilization — sunk cost + commitment drives consistency.
237
+ *
238
+ * ───────────────────────────────────────────────────────────────
239
+ * 9. WORKOUT LOOP TIME-TO-CONVERT (funnel-post)
240
+ * ───────────────────────────────────────────────────────────────
241
+ *
242
+ * PATTERN: Annual + family subscribers complete funnels 1.3x
243
+ * faster (factor 0.77); Free users 1.25x slower (factor 1.25).
244
+ * The funnel-post hook scales EVERY funnel instance's gaps by the
245
+ * tier factor (not only Workout Loop) — the story is measured on
246
+ * the Workout Loop funnel, where volume is highest.
247
+ *
248
+ * HOW TO FIND IT IN MIXPANEL:
249
+ *
250
+ * Report 1: Workout Loop Median Time-to-Convert by Subscription
251
+ * - Funnels > "workout planned" -> "workout completed" -> "progress checked"
252
+ * - Measure: Median time to convert
253
+ * - Breakdown: subscription_tier
254
+ * - Expected: annual ~ 0.77x; free ~ 1.25x (vs monthly = 1.0)
255
+ *
256
+ * NOTE (funnel-post measurement): visible only via Mixpanel funnel
257
+ * median TTC. Cross-event MIN→MIN SQL queries on raw events do NOT
258
+ * show this — funnel-post adjusts gaps within funnel instances, not
259
+ * across the user's full event history.
260
+ *
261
+ * ───────────────────────────────────────────────────────────────
262
+ * 10. WORKOUT-COUNT MAGIC NUMBER (everything)
263
+ * ───────────────────────────────────────────────────────────────
264
+ *
265
+ * PATTERN: Sweet 12-14 workouts/user → +35% on workout
266
+ * duration_minutes (peak progression). Over 15+ → drop 65% of
267
+ * post-day-30 non-workout, non-progress events (overtraining
268
+ * churn). Preserves workout + progress events so H8 funnel
269
+ * lift isn't diluted. No flag.
270
+ *
271
+ * HOW TO FIND IT IN MIXPANEL:
272
+ *
273
+ * Report 1: Avg Workout Duration by Workout-Count Bucket
274
+ * - Cohort A: users with 12-14 "workout completed"
275
+ * - Cohort B: users with 0-11
276
+ * - Event: "workout completed"
277
+ * - Measure: Average of "duration_minutes"
278
+ * - Expected: A ~ 1.35x B
279
+ *
280
+ * Report 2: D30+ Activity on Heavy Workout Cohort
281
+ * - Cohort C: users with >= 15 "workout completed"
282
+ * - Cohort A: users with 12-14
283
+ * - Event: any event
284
+ * - Measure: post-d30/pre-d30 ratio per user
285
+ * - Expected: C ~ 70% lower post/pre ratio than A (overtraining churn)
286
+ *
287
+ * REAL-WORLD ANALOGUE: Sweet-spot training drives progression;
288
+ * over-training causes injury and burnout.
289
+ *
290
+ * ═══════════════════════════════════════════════════════════════
291
+ * EXPECTED METRICS SUMMARY
292
+ * ═══════════════════════════════════════════════════════════════
293
+ *
294
+ * Hook | Metric | Expected | Measured (10K full fidelity)
295
+ * ----------------------------|------------------------------|---------------|------------------------------
296
+ * Morning Workout Boost | calories 5-9h / other | 1.30 | 1.304 avg / 1.303 med
297
+ * AI Coaching Lift | post-launch dur ai/self | 1.20 | 1.192 (pre-launch ai rows: 0)
298
+ * AI Coaching Lift | post-launch ai share | 0.40 | 0.4004
299
+ * Streak Retention | streak_days ≥ workouts (1-s) | 0 violations | 0 (6602 streak users)
300
+ * Streak Retention | streak_days == workouts share| ≥0.99 | 0.9961
301
+ * Streak Retention | ach − C(w) ≥ 1 share | ≥0.995 | 0.9967 (med organic = 1)
302
+ * Social Challenge Completion | count-fingerprint gap hits | ~0 | 0 of 1681
303
+ * Social Challenge Completion | social hi/lo challenges/user | 1.5-4x | 3.348
304
+ * Resolver Churn Cliff | birth-pinned DD (lo/hi ÷ cas)| 0.30 keep-rate| 0.2968
305
+ * Resolver Churn Cliff | casual lo/hi placebo | ~1 (sel. only)| 1.370
306
+ * Coach Session Quality | satisfaction avg (median) | 4.5 | 4.498 (4.500); sub-4.0 rows: 0
307
+ * Coach Profile Enrichment | coach total_workouts | [200,500]/350 | [200, 500] exact / avg 350
308
+ * Annual Follow-Through | std zero-share cliff | 0.30 | 0.3003
309
+ * Annual Follow-Through | std survivor ratio | 1.0 | 0.9360
310
+ * Workout Loop T2C | median TTC free/monthly | ≤1.25 | 1.079
311
+ * Workout Loop T2C | median TTC annual/monthly | ≥0.77 | 0.8847
312
+ * Workout Magic Number | sweet/low pre-d35 avg dur | 1.35 | 1.362
313
+ * Workout Magic Number | over/sweet d30 post-pre | ~0.35×τ | 0.3710
314
+ */
315
+
316
+ // ── SCALE ──
317
+ const SEED = "dm4-fitness";
318
+ const NUM_USERS = 10_000;
319
+ const DATASET_START = "2026-01-01T00:00:00Z";
320
+ const DATASET_END = "2026-05-01T23:59:59Z";
321
+ const EVENTS_PER_DAY = 1.2;
322
+ const token = process.env.MP_TOKEN || "your-mixpanel-token";
323
+
324
+ const chance = u.initChance(SEED);
325
+
326
+ // ── KNOBS (tweak these to reshape stories) ──
327
+ const MORNING_HOUR_START = 5;
328
+ const MORNING_HOUR_END = 9;
329
+ const MORNING_CALORIE_MULT = 1.3;
330
+
331
+ const AI_LAUNCH_DAY = 35;
332
+ const AI_ADOPTION_LIKELIHOOD = 40;
333
+ const AI_DURATION_MULT = 1.2;
334
+
335
+ const GROUP_LAUNCH_DAY = 55;
336
+ const GROUP_ADOPTION_LIKELIHOOD = 30;
337
+
338
+ const STREAK_MIN_WORKOUTS = 2;
339
+ const STREAK_LINEAR_CAP = 3; // workouts 2-4 (count - 1 capped to 3) → 1 achievement each
340
+ const STREAK_SUPER_LINEAR_MULT = 4; // workouts 5+ → 4 achievements each
341
+
342
+ const SOCIAL_FRIEND_THRESHOLD = 3;
343
+ const SOCIAL_CHALLENGE_CLONE_FACTOR = 0.5;
344
+
345
+ const RESOLVER_EVENT_THRESHOLD = 30;
346
+ const RESOLVER_CLIFF_DAYS = 14;
347
+ const RESOLVER_DROP_LIKELIHOOD = 70;
348
+
349
+ const COACH_SESSION_SATISFACTION_MIN = 4.0;
350
+ const COACH_SESSION_SATISFACTION_MAX = 5.0;
351
+
352
+ const COACH_TOTAL_WORKOUTS_MIN = 200;
353
+ const COACH_TOTAL_WORKOUTS_MAX = 500;
354
+ const COACH_STREAK_DAYS_MIN = 60;
355
+ const COACH_STREAK_DAYS_MAX = 365;
356
+
357
+ const ANNUAL_FUNNEL_FREE_DROP_LIKELIHOOD = 30;
358
+
359
+ const TTC_ANNUAL_FACTOR = 0.77;
360
+ const TTC_FREE_FACTOR = 1.25;
361
+
362
+ const WORKOUT_SWEET_MIN = 12;
363
+ const WORKOUT_SWEET_MAX = 14;
364
+ const WORKOUT_OVER_THRESHOLD = 15;
365
+ const WORKOUT_DURATION_BOOST = 1.35;
366
+ const WORKOUT_OVER_CUTOFF_DAYS = 30;
367
+ const WORKOUT_OVER_DROP_LIKELIHOOD = 65;
368
+
369
+ // ── HELPER FUNCTIONS ──
370
+ function handleUserHooks(record) {
371
+ // H7: COACH PROFILE ENRICHMENT — coach segment users get high
372
+ // total_workouts + streak_days. Also assign subscription_tier by segment.
373
+ if (record.segment === "coach") {
374
+ record.total_workouts = chance.integer({ min: COACH_TOTAL_WORKOUTS_MIN, max: COACH_TOTAL_WORKOUTS_MAX });
375
+ record.streak_days = chance.integer({ min: COACH_STREAK_DAYS_MIN, max: COACH_STREAK_DAYS_MAX });
376
+ }
377
+ // Subscription tier: athletes/coaches → annual/family; social → monthly; casual/resolver → mostly free
378
+ if (record.segment === "athlete") {
379
+ record.subscription_tier = chance.pickone(["annual", "annual", "family", "monthly"]);
380
+ } else if (record.segment === "coach") {
381
+ record.subscription_tier = chance.pickone(["annual", "family", "family"]);
382
+ } else if (record.segment === "social") {
383
+ record.subscription_tier = chance.pickone(["monthly", "monthly", "annual", "free"]);
384
+ } else if (record.segment === "resolver") {
385
+ record.subscription_tier = chance.pickone(["free", "free", "free", "monthly"]);
386
+ } else {
387
+ record.subscription_tier = chance.pickone(["free", "free", "monthly"]);
388
+ }
389
+ return record;
390
+ }
391
+
392
+ function handleFunnelPostHooks(record, meta) {
393
+ // H9: WORKOUT LOOP TIME-TO-CONVERT — Annual/family complete 1.3x faster
394
+ // (factor 0.77); Free 1.25x slower (factor 1.25).
395
+ const tier = meta?.profile?.subscription_tier;
396
+ if (Array.isArray(record) && record.length > 1) {
397
+ const factor = (
398
+ tier === "annual" || tier === "family" ? TTC_ANNUAL_FACTOR :
399
+ tier === "free" ? TTC_FREE_FACTOR :
400
+ 1.0
401
+ );
402
+ if (factor !== 1.0) {
403
+ for (let i = 1; i < record.length; i++) {
404
+ const prev = dayjs(record[i - 1].time);
405
+ const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
406
+ record[i].time = prev.add(newGap, "milliseconds").toISOString();
407
+ }
408
+ }
409
+ }
410
+ return record;
411
+ }
412
+
413
+ function handleEverythingHooks(record, meta) {
414
+ // UTC anchor: the day-offset cutoffs below (day 35/55/14/30) must not
415
+ // depend on the generating machine's timezone/DST rules
416
+ const datasetStart = dayjs.unix(meta.datasetStart).utc();
417
+ let events = record;
418
+ if (!events.length) return record;
419
+
420
+ // ── SUPERPROP STAMPING ──────────────────────────
421
+ // Stamp superProps from profile so they are consistent per user.
422
+ if (meta && meta.profile) {
423
+ const p = meta.profile;
424
+ events.forEach(e => {
425
+ if (p.Platform) e.Platform = p.Platform;
426
+ if (p.workout_type) e.workout_type = p.workout_type;
427
+ if (p.subscription_tier) e.subscription_tier = p.subscription_tier;
428
+ });
429
+ }
430
+
431
+ // HOOK 1: MORNING WORKOUT BOOST — 5AM-9AM UTC workouts get
432
+ // calories_burned 1.3x. No flag — analyst breaks down by HOD.
433
+ events.forEach(e => {
434
+ if (e.event === "workout completed") {
435
+ const hour = new Date(e.time).getUTCHours();
436
+ if (hour >= MORNING_HOUR_START && hour < MORNING_HOUR_END && e.calories_burned) {
437
+ e.calories_burned = Math.floor(e.calories_burned * MORNING_CALORIE_MULT);
438
+ }
439
+ }
440
+ });
441
+
442
+ // ── HOOK 2: POST-LAUNCH AI COACHING LIFT ────────────
443
+ // After day 35, ~40% of workouts switch to ai_assisted coaching,
444
+ // then ai_assisted workouts get 1.2x duration.
445
+ const AI_LAUNCH = datasetStart.add(AI_LAUNCH_DAY, "days");
446
+ events.forEach(e => {
447
+ if ((e.event === "workout completed" || e.event === "workout planned") &&
448
+ dayjs(e.time).isAfter(AI_LAUNCH)) {
449
+ // Adopt ai_assisted for ~40% of post-launch workouts
450
+ if (chance.bool({ likelihood: AI_ADOPTION_LIKELIHOOD })) {
451
+ e.coaching_mode = "ai_assisted";
452
+ }
453
+ // AI-assisted workouts get 1.2x duration
454
+ if (e.coaching_mode === "ai_assisted") {
455
+ if (e.duration_minutes) {
456
+ e.duration_minutes = Math.floor(e.duration_minutes * AI_DURATION_MULT);
457
+ }
458
+ if (e.planned_duration_minutes) {
459
+ e.planned_duration_minutes = Math.floor(e.planned_duration_minutes * AI_DURATION_MULT);
460
+ }
461
+ }
462
+ }
463
+ });
464
+
465
+ // ── GROUP CHALLENGES ADOPTION ────────────────────
466
+ // After day 55, ~30% of challenge events switch to group mode.
467
+ const GROUP_LAUNCH = datasetStart.add(GROUP_LAUNCH_DAY, "days");
468
+ events.forEach(e => {
469
+ if ((e.event === "challenge joined" || e.event === "workout completed") &&
470
+ dayjs(e.time).isAfter(GROUP_LAUNCH) &&
471
+ chance.bool({ likelihood: GROUP_ADOPTION_LIKELIHOOD })) {
472
+ e.challenge_mode = "group";
473
+ }
474
+ });
475
+
476
+ // ── HOOK 8: ANNUAL SUBSCRIBER CONVERSION FILTER ─
477
+ // Free/monthly-tier users drop ~30% of "progress checked"
478
+ // (last step of Workout Loop funnel) to simulate lower conversion.
479
+ if (meta && meta.profile) {
480
+ const tier = meta.profile.subscription_tier;
481
+ if (tier !== "annual" && tier !== "family" && chance.bool({ likelihood: ANNUAL_FUNNEL_FREE_DROP_LIKELIHOOD })) {
482
+ record = record.filter(e => e.event !== "progress checked");
483
+ events = record;
484
+ }
485
+ }
486
+
487
+ // ── HOOK 3: STREAK RETENTION ─────────────────────
488
+ // Users with >=2 workouts get streak_days updated and
489
+ // cloned achievement events. Achievements scale super-
490
+ // linearly: 1 per workout for workouts 2-4, then 4 per
491
+ // workout beyond that.
492
+ const workoutEvents = events.filter(e => e.event === "workout completed");
493
+ if (workoutEvents.length >= STREAK_MIN_WORKOUTS) {
494
+ // Update profile streak_days via a profile update event
495
+ if (meta && meta.profile) {
496
+ meta.profile.streak_days = workoutEvents.length;
497
+ }
498
+
499
+ // Super-linear achievement scaling:
500
+ // workouts 2-4: 1 achievement each
501
+ // workouts 5+: 4 achievements each
502
+ const templateAchievement = events.find(e => e.event === "achievement unlocked");
503
+ if (templateAchievement) {
504
+ let achievementCount = Math.min(workoutEvents.length - 1, STREAK_LINEAR_CAP); // 1 each for workouts 2-4
505
+ if (workoutEvents.length > 4) {
506
+ achievementCount += (workoutEvents.length - 4) * STREAK_SUPER_LINEAR_MULT; // 4 each for workouts 5+
507
+ }
508
+ for (let a = 0; a < achievementCount; a++) {
509
+ const srcIdx = Math.min(a, workoutEvents.length - 1);
510
+ const sourceEvent = workoutEvents[srcIdx];
511
+ events.push({
512
+ ...templateAchievement,
513
+ time: dayjs(sourceEvent.time).add(chance.integer({ min: 1, max: 60 }), "minutes").toISOString(),
514
+ user_id: sourceEvent.user_id,
515
+ // engine stamps insert_id at generation — clones need fresh
516
+ // ids or Mixpanel dedups them against the template
517
+ insert_id: chance.guid(),
518
+ achievement_type: "streak_milestone",
519
+ streak_days_at_unlock: a + 2,
520
+ });
521
+ }
522
+ }
523
+ }
524
+
525
+ // ── HOOK 4: SOCIAL CHALLENGE COMPLETION ──────────
526
+ // Users with >=3 friend_added events get 1.5x challenge completions.
527
+ const friendCount = events.filter(e => e.event === "friend added").length;
528
+ if (friendCount >= SOCIAL_FRIEND_THRESHOLD) {
529
+ const templateChallenge = events.find(e => e.event === "challenge completed");
530
+ if (templateChallenge) {
531
+ const challengeCompletions = events.filter(e => e.event === "challenge completed");
532
+ const extraCount = Math.max(1, Math.floor(challengeCompletions.length * SOCIAL_CHALLENGE_CLONE_FACTOR));
533
+ for (let i = 0; i < extraCount; i++) {
534
+ const source = challengeCompletions[i % challengeCompletions.length];
535
+ events.push({
536
+ ...templateChallenge,
537
+ time: dayjs(source.time).add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
538
+ user_id: source.user_id,
539
+ insert_id: chance.guid(),
540
+ challenge_type: source.challenge_type,
541
+ completion_pct: chance.integer({ min: 80, max: 100 }),
542
+ });
543
+ }
544
+ }
545
+ }
546
+
547
+ // ── HOOK 5: RESOLVER CHURN CLIFF ─────────────────
548
+ // Resolver segment users with <30 events lose 70% after day 14.
549
+ if (meta && meta.profile && meta.profile.segment === "resolver" && events.length < RESOLVER_EVENT_THRESHOLD) {
550
+ const CHURN_CLIFF = datasetStart.add(RESOLVER_CLIFF_DAYS, "days");
551
+ for (let i = events.length - 1; i >= 0; i--) {
552
+ const eventTime = dayjs(events[i].time);
553
+ if (eventTime.isAfter(CHURN_CLIFF) && chance.bool({ likelihood: RESOLVER_DROP_LIKELIHOOD })) {
554
+ events.splice(i, 1);
555
+ }
556
+ }
557
+ }
558
+
559
+ // HOOK 6: COACH SESSION QUALITY — coach-session satisfaction 4-5.
560
+ const hasCoachSessions = events.some(e => e.event === "coach session");
561
+ if (hasCoachSessions) {
562
+ events.forEach(e => {
563
+ if (e.event === "coach session") {
564
+ e.satisfaction_score = chance.floating({ min: COACH_SESSION_SATISFACTION_MIN, max: COACH_SESSION_SATISFACTION_MAX, fixed: 1 });
565
+ }
566
+ });
567
+ }
568
+
569
+ // HOOK 10: WORKOUT-COUNT MAGIC NUMBER (no flags)
570
+ // Sweet 12-14 workouts → +35% on workout duration_minutes (peak
571
+ // progression). Over 15+ → drop 65% of post-day-30 non-workout
572
+ // events (overtraining → churn). Workout events are preserved
573
+ // so the bucket categorization stays consistent.
574
+ const workoutCount = events.filter(e => e.event === "workout completed").length;
575
+ if (workoutCount >= WORKOUT_SWEET_MIN && workoutCount <= WORKOUT_SWEET_MAX) {
576
+ events.forEach(e => {
577
+ if (e.event === "workout completed" && typeof e.duration_minutes === "number") {
578
+ e.duration_minutes = Math.round(e.duration_minutes * WORKOUT_DURATION_BOOST);
579
+ }
580
+ });
581
+ } else if (workoutCount >= WORKOUT_OVER_THRESHOLD) {
582
+ const day30 = datasetStart.add(WORKOUT_OVER_CUTOFF_DAYS, "days");
583
+ const preserveEvents = new Set(["workout completed", "progress checked"]);
584
+ for (let i = events.length - 1; i >= 0; i--) {
585
+ if (!preserveEvents.has(events[i].event) &&
586
+ dayjs(events[i].time).isAfter(day30) && chance.bool({ likelihood: WORKOUT_OVER_DROP_LIKELIHOOD })) {
587
+ events.splice(i, 1);
588
+ }
589
+ }
590
+ }
591
+
592
+ return record;
593
+ }
594
+
595
+ // ── CONFIG ──
596
+ /** @type {Config} */
597
+ const config = {
598
+ version: 2,
599
+ seed: SEED,
600
+ datasetStart: DATASET_START,
601
+ datasetEnd: DATASET_END,
602
+ avgEventsPerUserPerDay: EVENTS_PER_DAY,
603
+ numUsers: NUM_USERS,
604
+ format: "json",
605
+ gzip: true,
606
+ credentials: {
607
+ token,
608
+ },
609
+ switches: {
610
+ hasSessionIds: true,
611
+ alsoInferFunnels: false,
612
+ hasLocation: true,
613
+ hasAndroidDevices: true,
614
+ hasIOSDevices: true,
615
+ hasDesktopDevices: false,
616
+ hasBrowser: false,
617
+ hasCampaigns: false,
618
+ isAnonymous: false,
619
+ hasAdSpend: false,
620
+ hasAvatar: true,
621
+ },
622
+ identity: {
623
+ avgDevicePerUser: 3,
624
+ },
625
+ concurrency: 1,
626
+ writeToDisk: false,
627
+ scdProps: {
628
+ fitness_level: {
629
+ values: ["beginner", "intermediate", "advanced", "elite"],
630
+ frequency: "month",
631
+ timing: "fuzzy",
632
+ max: 8
633
+ }
634
+ },
635
+ mirrorProps: {},
636
+ lookupTables: [],
637
+
638
+ // ── Events (18) ──────────────────────────────────────────
639
+ events: [
640
+ {
641
+ event: "account created",
642
+ weight: 1,
643
+ isFirstEvent: true,
644
+ isAuthEvent: true,
645
+ properties: {
646
+ referral_source: ["organic", "friend_invite", "app_store", "social_media", "search"],
647
+ },
648
+ },
649
+ {
650
+ event: "workout completed",
651
+ weight: 8,
652
+ isStrictEvent: false,
653
+ properties: {
654
+ duration_minutes: u.weighNumRange(10, 90, 0.5, 40),
655
+ calories_burned: u.weighNumRange(50, 800, 0.4, 300),
656
+ heart_rate_avg: u.weighNumRange(80, 185, 0.5, 130),
657
+ satisfaction_score: u.weighNumRange(1, 5, 0.7, 3),
658
+ coaching_mode: ["self_guided"],
659
+ challenge_mode: ["solo"],
660
+ },
661
+ },
662
+ {
663
+ event: "workout planned",
664
+ weight: 6,
665
+ isStrictEvent: false,
666
+ properties: {
667
+ planned_duration_minutes: u.weighNumRange(15, 90, 0.5, 45),
668
+ day_of_week: ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"],
669
+ coaching_mode: ["self_guided"],
670
+ },
671
+ },
672
+ {
673
+ event: "meal logged",
674
+ weight: 7,
675
+ properties: {
676
+ meal_type: ["breakfast", "lunch", "dinner", "snack"],
677
+ calories: u.weighNumRange(50, 1200, 0.5, 400),
678
+ protein_g: u.weighNumRange(0, 60, 0.5, 20),
679
+ meal_quality: ["healthy", "healthy", "balanced", "balanced", "indulgent"],
680
+ },
681
+ },
682
+ {
683
+ event: "challenge joined",
684
+ weight: 3,
685
+ isStrictEvent: false,
686
+ properties: {
687
+ challenge_type: ["steps", "calories", "streak", "strength", "team_relay"],
688
+ duration_days: [7, 14, 21, 30],
689
+ participants: u.weighNumRange(2, 50, 0.3, 10),
690
+ challenge_mode: ["solo"],
691
+ },
692
+ },
693
+ {
694
+ event: "challenge completed",
695
+ weight: 2,
696
+ isStrictEvent: false,
697
+ properties: {
698
+ challenge_type: ["steps", "calories", "streak", "strength", "team_relay"],
699
+ final_rank: u.weighNumRange(1, 50, 0.3, 10),
700
+ completion_pct: u.weighNumRange(50, 100, 0.7, 85),
701
+ challenge_mode: ["solo"],
702
+ },
703
+ },
704
+ {
705
+ event: "achievement unlocked",
706
+ weight: 2,
707
+ isStrictEvent: false,
708
+ properties: {
709
+ achievement_type: ["streak_milestone", "weight_goal", "distance_record", "calories_target", "social_champion", "first_workout"],
710
+ streak_days_at_unlock: u.weighNumRange(1, 100, 0.3, 10),
711
+ },
712
+ },
713
+ {
714
+ event: "friend added",
715
+ weight: 2,
716
+ isStrictEvent: false,
717
+ properties: {
718
+ source: ["search", "contacts", "challenge", "suggestion", "qr_code"],
719
+ },
720
+ },
721
+ {
722
+ event: "leaderboard viewed",
723
+ weight: 4,
724
+ properties: {
725
+ leaderboard_type: ["friends", "global", "challenge", "local"],
726
+ user_rank: u.weighNumRange(1, 500, 0.3, 50),
727
+ },
728
+ },
729
+ {
730
+ event: "progress checked",
731
+ weight: 5,
732
+ isStrictEvent: false,
733
+ properties: {
734
+ metric_viewed: ["weight", "body_fat", "steps", "calories_burned", "workout_count", "streaks"],
735
+ trend_direction: ["improving", "improving", "stable", "declining"],
736
+ time_range: ["week", "month", "3_months", "year"],
737
+ },
738
+ },
739
+ {
740
+ event: "coach session",
741
+ weight: 3,
742
+ isStrictEvent: false,
743
+ properties: {
744
+ session_type: ["live_video", "chat", "plan_review", "form_check"],
745
+ duration_minutes: u.weighNumRange(10, 60, 0.5, 30),
746
+ satisfaction_score: u.weighNumRange(1, 5, 0.6, 3),
747
+ coach_speciality: ["strength", "cardio", "nutrition", "yoga", "general"],
748
+ },
749
+ },
750
+ {
751
+ event: "nutrition plan viewed",
752
+ weight: 4,
753
+ properties: {
754
+ plan_type: ["weight_loss", "muscle_gain", "maintenance", "custom"],
755
+ adherence_pct: u.weighNumRange(0, 100, 0.5, 60),
756
+ },
757
+ },
758
+ {
759
+ event: "heart rate recorded",
760
+ weight: 3,
761
+ properties: {
762
+ bpm: u.weighNumRange(50, 200, 0.5, 110),
763
+ activity_state: ["resting", "warmup", "active", "peak", "cooldown"],
764
+ device: ["watch", "chest_strap", "phone_sensor"],
765
+ },
766
+ },
767
+ {
768
+ event: "app session",
769
+ weight: 8,
770
+ properties: {
771
+ session_duration_sec: u.weighNumRange(10, 1800, 0.4, 120),
772
+ pages_viewed: u.weighNumRange(1, 15, 0.5, 3),
773
+ },
774
+ },
775
+ {
776
+ event: "notification received",
777
+ weight: 5,
778
+ properties: {
779
+ notification_type: ["workout_reminder", "workout_reminder", "challenge_update", "friend_activity", "streak_warning", "coaching_tip"],
780
+ channel: ["push", "push", "email", "sms"],
781
+ opened: [true, true, true, false],
782
+ },
783
+ },
784
+ {
785
+ event: "subscription managed",
786
+ weight: 2,
787
+ properties: {
788
+ action: ["viewed_plans", "started_trial", "upgraded", "downgraded", "cancelled", "renewed"],
789
+ plan_viewed: ["free", "monthly", "annual", "family"],
790
+ },
791
+ },
792
+ {
793
+ event: "profile updated",
794
+ weight: 2,
795
+ isStrictEvent: false,
796
+ properties: {
797
+ field_updated: ["weight", "height", "goal", "avatar", "workout_preferences", "notification_settings"],
798
+ },
799
+ },
800
+ {
801
+ event: "account deactivated",
802
+ weight: 1,
803
+ isChurnEvent: true,
804
+ returnLikelihood: 0.15,
805
+ isStrictEvent: true,
806
+ properties: {
807
+ reason: ["lost_motivation", "cost", "switched_app", "injury", "achieved_goal"],
808
+ },
809
+ },
810
+ ],
811
+
812
+ // ── Funnels (5) ──────────────────────────────────────────
813
+ funnels: [
814
+ {
815
+ name: "Onboarding",
816
+ sequence: ["account created", "profile updated", "workout planned", "workout completed"],
817
+ conversionRate: 45,
818
+ order: "sequential",
819
+ isFirstFunnel: true,
820
+ timeToConvert: 72,
821
+ weight: 3,
822
+ },
823
+ {
824
+ name: "Workout Loop",
825
+ sequence: ["workout planned", "workout completed", "progress checked"],
826
+ conversionRate: 45,
827
+ order: "sequential",
828
+ timeToConvert: 48,
829
+ weight: 5,
830
+ reentry: true,
831
+ },
832
+ {
833
+ name: "Social Engagement",
834
+ sequence: ["friend added", "leaderboard viewed", "challenge joined"],
835
+ conversionRate: 35,
836
+ order: "sequential",
837
+ timeToConvert: 96,
838
+ weight: 3,
839
+ },
840
+ {
841
+ name: "Challenge Completion",
842
+ sequence: ["challenge joined", "workout completed", "challenge completed", "achievement unlocked"],
843
+ conversionRate: 30,
844
+ order: "sequential",
845
+ timeToConvert: 336,
846
+ weight: 2,
847
+ },
848
+ {
849
+ name: "Coaching Path",
850
+ sequence: ["coach session", "workout planned", "workout completed", "progress checked"],
851
+ conversionRate: 50,
852
+ order: "sequential",
853
+ timeToConvert: 72,
854
+ weight: 2,
855
+ },
856
+ ],
857
+
858
+ // ── SuperProps ──────────────────────────────────────────
859
+ superProps: {
860
+ Platform: ["ios", "ios", "android"],
861
+ workout_type: ["strength", "cardio", "yoga", "hiit", "running", "cycling"],
862
+ subscription_tier: ["free"],
863
+ },
864
+
865
+ // ── UserProps ──────────────────────────────────────────
866
+ userProps: {
867
+ fitness_level: ["beginner"],
868
+ segment: ["casual"],
869
+ streak_days: [0],
870
+ total_workouts: u.weighNumRange(0, 0, 0.5),
871
+ preferred_workout: ["strength", "cardio", "yoga", "hiit", "running", "cycling"],
872
+ goal: ["weight_loss", "muscle_gain", "endurance", "flexibility", "general_health"],
873
+ Platform: ["ios", "ios", "android"],
874
+ workout_type: ["strength", "cardio", "yoga", "hiit", "running", "cycling"],
875
+ subscription_tier: ["free"],
876
+ },
877
+
878
+ // ── Personas ──────────────────────────────────
879
+ personas: [
880
+ {
881
+ name: "athlete",
882
+ weight: 10,
883
+ eventMultiplier: 4.0,
884
+ conversionModifier: 1.5,
885
+ properties: {
886
+ fitness_level: "advanced",
887
+ segment: "athlete",
888
+ },
889
+ },
890
+ {
891
+ name: "casual_exerciser",
892
+ weight: 40,
893
+ eventMultiplier: 1.0,
894
+ conversionModifier: 0.8,
895
+ properties: {
896
+ fitness_level: "intermediate",
897
+ segment: "casual",
898
+ },
899
+ },
900
+ {
901
+ name: "new_year_resolver",
902
+ weight: 25,
903
+ eventMultiplier: 0.6,
904
+ conversionModifier: 0.5,
905
+ // churn/activeWindow persona fields are deprecated no-ops in the
906
+ // engine — the resolver cliff is engineered entirely by hook H5
907
+ properties: {
908
+ fitness_level: "beginner",
909
+ segment: "resolver",
910
+ },
911
+ },
912
+ {
913
+ name: "social_motivator",
914
+ weight: 15,
915
+ eventMultiplier: 2.0,
916
+ conversionModifier: 1.2,
917
+ properties: {
918
+ fitness_level: "intermediate",
919
+ segment: "social",
920
+ },
921
+ },
922
+ {
923
+ name: "coach",
924
+ weight: 10,
925
+ eventMultiplier: 3.0,
926
+ conversionModifier: 1.5,
927
+ properties: {
928
+ fitness_level: "expert",
929
+ segment: "coach",
930
+ },
931
+ },
932
+ ],
933
+
934
+ // ── Engagement Decay ──────────────────────────
935
+ engagementDecay: {
936
+ model: "step",
937
+ halfLife: 30,
938
+ floor: 0.1,
939
+ reactivationChance: 0.02,
940
+ },
941
+
942
+ hook(record, type, meta) {
943
+ if (type === "user") return handleUserHooks(record);
944
+ if (type === "funnel-post") return handleFunnelPostHooks(record, meta);
945
+ if (type === "everything") return handleEverythingHooks(record, meta);
946
+ return record;
947
+ },
948
+ };
949
+
950
+ // ── STORIES ──────────────────────────────────────────────────────────────
951
+ // Machine-checkable contract for the 10 numbered hooks. Evaluate with:
952
+ // node scripts/verify-stories.mjs dungeons/vertical/fitness/fitness.js --data-prefix verify-fitness
953
+
954
+ const EV = `read_json_auto('{{PREFIX}}-EVENTS*.json', sample_size=-1, union_by_name=true)`;
955
+ const US = `read_json_auto('{{PREFIX}}-USERS*.json', sample_size=-1, union_by_name=true)`;
956
+
957
+ // Identity prelude. account created is both isAuthEvent and isFirstEvent, so
958
+ // born users auth on their very first event and user_id should be present on
959
+ // every record; the prelude still resolves through the device pool
960
+ // (avgDevicePerUser: 3, "anonymousIds" is the legacy USERS-shard key) as
961
+ // belt-and-braces for any device-only edge.
962
+ const ID_CTE = `dmap AS (SELECT unnest("anonymousIds") AS device_id, distinct_id FROM ${US}),
963
+ ev AS (SELECT coalesce(m.distinct_id::VARCHAR, e.user_id::VARCHAR, e.device_id::VARCHAR) AS uid,
964
+ e.time::TIMESTAMP AS t, e.* FROM ${EV} e LEFT JOIN dmap m ON e.device_id = m.device_id)`;
965
+
966
+ // Temporal boundaries computed from the same knobs the hook uses (the hook
967
+ // anchors day offsets in UTC, so these UTC timestamps are exact cutoffs)
968
+ const AI_LAUNCH_TS = dayjs.utc(DATASET_START).add(AI_LAUNCH_DAY, "day").format("YYYY-MM-DD HH:mm:ss");
969
+ const D14_TS = dayjs.utc(DATASET_START).add(RESOLVER_CLIFF_DAYS, "day").format("YYYY-MM-DD HH:mm:ss");
970
+ const D30_TS = dayjs.utc(DATASET_START).add(WORKOUT_OVER_CUTOFF_DAYS, "day").format("YYYY-MM-DD HH:mm:ss");
971
+ // birth-pin cutoff for H5's double-difference: users whose first event lands in
972
+ // the window's first two days (mostly pre-existing users; birth ⊥ persona)
973
+ const D2_TS = dayjs.utc(DATASET_START).add(2, "day").format("YYYY-MM-DD HH:mm:ss");
974
+
975
+ // Per-user workout counts. H3/H4/H10 classify on counts taken after H8's
976
+ // progress-checked drop and H5's resolver thinning; for NON-resolver users no
977
+ // later hook deletes "workout completed" (H10's over-drop preserves it), so
978
+ // output-side counts rebuild those hook cohorts exactly. Resolver users can
979
+ // lose workouts to H5 AFTER H3 classified them — resolver-sensitive stories
980
+ // exclude that segment.
981
+ const WORKOUT_CTE = `wc AS (SELECT uid, count(*) AS w FROM ev WHERE event = 'workout completed' GROUP BY 1)`;
982
+
983
+ /** @type {import("../../../types").DungeonStory[]} */
984
+ export const stories = [
985
+ {
986
+ id: "H1-morning-calorie-boost",
987
+ hook: "H1",
988
+ archetype: "temporal-inflection",
989
+ narrative: `workouts between ${MORNING_HOUR_START}:00 and ${MORNING_HOUR_END}:00 UTC carry calories_burned × ${MORNING_CALORIE_MULT}. calories_burned is an iid per-event draw independent of the event's hour, and no other hook touches it (H2/H10 scale duration_minutes), so both the avg and the median morning/other ratios read the ${MORNING_CALORIE_MULT} knob directly (Math.floor bias < 1%). TimeSoup's hour-of-day volume shape cancels: it moves event COUNTS across bins, not the property distribution within a bin`,
990
+ assertions: [
991
+ {
992
+ breakdown: {
993
+ type: "duckdb",
994
+ sql: `WITH ${ID_CTE}
995
+ SELECT CASE WHEN extract(hour FROM t) >= ${MORNING_HOUR_START} AND extract(hour FROM t) < ${MORNING_HOUR_END} THEN 'morning' ELSE 'other' END AS grp,
996
+ count(*) AS event_count, count(DISTINCT uid) AS user_count,
997
+ avg(calories_burned) AS avg_cal, median(calories_burned) AS med_cal
998
+ FROM ev WHERE event = 'workout completed' GROUP BY 1`,
999
+ },
1000
+ select: { m: { where: { grp: "morning" } }, o: { where: { grp: "other" } } },
1001
+ expect: { metric: "m.avg_cal / o.avg_cal", op: "between", target: [1.22, 1.38] },
1002
+ minCohort: 400,
1003
+ },
1004
+ {
1005
+ breakdown: {
1006
+ type: "duckdb",
1007
+ sql: `WITH ${ID_CTE}
1008
+ SELECT CASE WHEN extract(hour FROM t) >= ${MORNING_HOUR_START} AND extract(hour FROM t) < ${MORNING_HOUR_END} THEN 'morning' ELSE 'other' END AS grp,
1009
+ count(*) AS event_count, count(DISTINCT uid) AS user_count,
1010
+ avg(calories_burned) AS avg_cal, median(calories_burned) AS med_cal
1011
+ FROM ev WHERE event = 'workout completed' GROUP BY 1`,
1012
+ },
1013
+ select: { m: { where: { grp: "morning" } }, o: { where: { grp: "other" } } },
1014
+ // scaling a whole bin scales every quantile: median ratio = knob too
1015
+ expect: { metric: "m.med_cal / o.med_cal", op: "between", target: [1.2, 1.4] },
1016
+ minCohort: 400,
1017
+ },
1018
+ ],
1019
+ },
1020
+ {
1021
+ id: "H2-ai-coaching-lift",
1022
+ hook: "H2",
1023
+ archetype: "temporal-inflection",
1024
+ narrative: `after day ${AI_LAUNCH_DAY}, each workout (planned or completed) flips to coaching_mode='ai_assisted' at ${AI_ADOPTION_LIKELIHOOD}% (per-event Bernoulli — the post-launch ai share reads the knob), and ai_assisted workouts get duration × ${AI_DURATION_MULT}. Purity is exact: the declared coaching_mode pool is the single value 'self_guided' and the hook only stamps strictly after the launch instant, so ANY pre-launch ai_assisted row is a hook bug. H10's sweet-spot ×${WORKOUT_DURATION_BOOST} boost is mode-blind (applies to all of a sweet user's workouts), so it cancels in the ai/self avg ratio in expectation`,
1025
+ assertions: [
1026
+ {
1027
+ breakdown: {
1028
+ type: "duckdb",
1029
+ sql: `WITH ${ID_CTE}
1030
+ SELECT count(*) FILTER (WHERE coaching_mode = 'ai_assisted' AND t <= TIMESTAMP '${AI_LAUNCH_TS}') AS pre_launch_ai,
1031
+ count(*) FILTER (WHERE coaching_mode = 'ai_assisted') AS ai_total,
1032
+ count(DISTINCT uid) AS user_count
1033
+ FROM ev WHERE event IN ('workout completed', 'workout planned')`,
1034
+ },
1035
+ assert: (rows) => {
1036
+ const r = (rows || [])[0];
1037
+ if (!r || Number(r.ai_total) === 0) return { pass: false, verdict: "NONE", detail: "no ai_assisted workouts at all" };
1038
+ const clean = Number(r.pre_launch_ai) === 0;
1039
+ return {
1040
+ pass: clean,
1041
+ verdict: clean ? "NAILED" : "INVERSE",
1042
+ detail: `pre-launch ai_assisted rows=${r.pre_launch_ai} of ${r.ai_total} total (must be 0)`,
1043
+ };
1044
+ },
1045
+ },
1046
+ {
1047
+ breakdown: {
1048
+ type: "duckdb",
1049
+ sql: `WITH ${ID_CTE}
1050
+ SELECT coaching_mode AS grp, count(*) AS event_count, count(DISTINCT uid) AS user_count, avg(duration_minutes) AS avg_dur
1051
+ FROM ev WHERE event = 'workout completed' AND t > TIMESTAMP '${AI_LAUNCH_TS}' GROUP BY 1`,
1052
+ },
1053
+ select: { a: { where: { grp: "ai_assisted" } }, s: { where: { grp: "self_guided" } } },
1054
+ expect: { metric: "a.avg_dur / s.avg_dur", op: "between", target: [1.1, 1.32] },
1055
+ minCohort: 300,
1056
+ },
1057
+ {
1058
+ breakdown: {
1059
+ type: "duckdb",
1060
+ sql: `WITH ${ID_CTE}
1061
+ SELECT 'post' AS grp, count(*) AS event_count, count(DISTINCT uid) AS user_count,
1062
+ count(*) FILTER (WHERE coaching_mode = 'ai_assisted')::DOUBLE / count(*) AS ai_share
1063
+ FROM ev WHERE event = 'workout completed' AND t > TIMESTAMP '${AI_LAUNCH_TS}'`,
1064
+ },
1065
+ select: { p: { where: { grp: "post" } } },
1066
+ // per-event Bernoulli at 40% over tens of thousands of draws
1067
+ expect: { metric: "p.ai_share", op: "between", target: [0.35, 0.45] },
1068
+ minCohort: 300,
1069
+ },
1070
+ ],
1071
+ },
1072
+ {
1073
+ id: "H3-streak-achievements",
1074
+ hook: "H3",
1075
+ archetype: "cohort-count-scale",
1076
+ narrative: `users with ≥${STREAK_MIN_WORKOUTS} workouts get profile streak_days OVERWRITTEN to their exact workout count (the everything hook mutates meta.profile before storage pushes it), plus C(w) = min(w−1, ${STREAK_LINEAR_CAP}) + ${STREAK_SUPER_LINEAR_MULT}·max(w−4, 0) cloned achievements (template = the user's first organic achievement — no organic achievement, no clones). The streak_days contract for non-resolvers is ONE-SIDED EXACT: after H3 runs, nothing ever ADDS a workout, so output w ≤ hook-time w = streak_days — a single sd < w row (or an unreachable sd = 1 on a non-coach) is a hook bug. Full equality is NOT exact: the future-time guard runs after the everything hook and silently deletes events past datasetEnd (engine end-of-window funnel spillover is filtered at storage by design, and H9's free-tier 1.25× stretch pushes borderline steps out), so ~0.5% of users lose a counted workout post-classification — equality share floor 0.99 (measured 0.6% violators at iter scale, all sd = w+1, all free-tier). The structural check total_ach − C(w) ≥ 1 holds for every non-resolver user with 2 ≤ w ≤ 14 and ≥1 achievement (w ≤ 14 excludes H10's over-drop, which deletes achievements): organic ≥ 1 forced the template, clones are exactly C(w), and only the same future-guard (clone lands past datasetEnd, or its source workout was guard-dropped) can break it — hence the 99.5% floor. The median of total_ach − C(w) sandwiches from above: it recovers the ORGANIC achievement count (weight 2 of 68 ≈ 3% of a user's events → median ∈ [1, 6]); a drifted clone formula would push it negative or huge`,
1077
+ assertions: [
1078
+ {
1079
+ // one-sided purity — deletions-only pipeline makes sd < w
1080
+ // impossible; sd = 1 is unreachable (H3 assigns ≥2, default 0,
1081
+ // H7 coaches 60-365)
1082
+ breakdown: {
1083
+ type: "duckdb",
1084
+ sql: `WITH ${ID_CTE}, ${WORKOUT_CTE},
1085
+ j AS (SELECT u.distinct_id::VARCHAR AS uid, u.segment AS seg, u.streak_days AS sd, coalesce(w.w, 0) AS w
1086
+ FROM ${US} u LEFT JOIN wc w ON w.uid = u.distinct_id::VARCHAR
1087
+ WHERE u.segment <> 'resolver')
1088
+ SELECT count(*) FILTER (WHERE w >= ${STREAK_MIN_WORKOUTS} AND sd < w) AS below_w,
1089
+ count(*) FILTER (WHERE seg <> 'coach' AND sd = 1) AS unreachable_one,
1090
+ count(*) FILTER (WHERE w >= ${STREAK_MIN_WORKOUTS}) AS streak_users, count(*) AS user_count
1091
+ FROM j`,
1092
+ },
1093
+ assert: (rows) => {
1094
+ const r = (rows || [])[0];
1095
+ if (!r || Number(r.streak_users) === 0) return { pass: false, verdict: "NONE", detail: "no ≥2-workout users" };
1096
+ const bad = Number(r.below_w) + Number(r.unreachable_one);
1097
+ return {
1098
+ pass: bad === 0,
1099
+ verdict: bad === 0 ? "NAILED" : "INVERSE",
1100
+ detail: `streak_days < workout-count rows: ${r.below_w}; unreachable sd=1 rows: ${r.unreachable_one} (both must be 0; ${r.streak_users} streak users of ${r.user_count} non-resolvers)`,
1101
+ };
1102
+ },
1103
+ },
1104
+ {
1105
+ // equality share — bounded below by the silent future-guard
1106
+ // drop rate (post-hook deletions of counted workouts)
1107
+ breakdown: {
1108
+ type: "duckdb",
1109
+ sql: `WITH ${ID_CTE}, ${WORKOUT_CTE},
1110
+ j AS (SELECT u.distinct_id::VARCHAR AS uid, u.streak_days AS sd, coalesce(w.w, 0) AS w
1111
+ FROM ${US} u LEFT JOIN wc w ON w.uid = u.distinct_id::VARCHAR
1112
+ WHERE u.segment <> 'resolver')
1113
+ SELECT 'all' AS grp, count(*) AS user_count,
1114
+ count(*) FILTER (WHERE sd = w)::DOUBLE / count(*) AS eq_share
1115
+ FROM j WHERE w >= ${STREAK_MIN_WORKOUTS}`,
1116
+ },
1117
+ select: { all: { where: { grp: "all" } } },
1118
+ expect: { metric: "all.eq_share", op: "between", target: [0.99, 1.0] },
1119
+ minCohort: 200,
1120
+ },
1121
+ {
1122
+ breakdown: {
1123
+ type: "duckdb",
1124
+ sql: `WITH ${ID_CTE}, ${WORKOUT_CTE},
1125
+ ac AS (SELECT uid, count(*) AS a FROM ev WHERE event = 'achievement unlocked' GROUP BY 1),
1126
+ j AS (SELECT u.distinct_id::VARCHAR AS uid, coalesce(w.w, 0) AS w, coalesce(a.a, 0) AS a
1127
+ FROM ${US} u LEFT JOIN wc w ON w.uid = u.distinct_id::VARCHAR LEFT JOIN ac a ON a.uid = u.distinct_id::VARCHAR
1128
+ WHERE u.segment <> 'resolver'),
1129
+ coh AS (SELECT *, LEAST(w - 1, ${STREAK_LINEAR_CAP}) + GREATEST(w - 4, 0) * ${STREAK_SUPER_LINEAR_MULT} AS clones
1130
+ FROM j WHERE w BETWEEN ${STREAK_MIN_WORKOUTS} AND ${WORKOUT_OVER_THRESHOLD - 1} AND a >= 1)
1131
+ SELECT 'all' AS grp, count(*) AS user_count,
1132
+ count(*) FILTER (WHERE a - clones >= 1)::DOUBLE / count(*) AS ok_share,
1133
+ median(a - clones) AS med_organic
1134
+ FROM coh`,
1135
+ },
1136
+ select: { all: { where: { grp: "all" } } },
1137
+ expect: { metric: "all.ok_share", op: "between", target: [0.995, 1.0] },
1138
+ minCohort: 200,
1139
+ },
1140
+ {
1141
+ breakdown: {
1142
+ type: "duckdb",
1143
+ sql: `WITH ${ID_CTE}, ${WORKOUT_CTE},
1144
+ ac AS (SELECT uid, count(*) AS a FROM ev WHERE event = 'achievement unlocked' GROUP BY 1),
1145
+ j AS (SELECT u.distinct_id::VARCHAR AS uid, coalesce(w.w, 0) AS w, coalesce(a.a, 0) AS a
1146
+ FROM ${US} u LEFT JOIN wc w ON w.uid = u.distinct_id::VARCHAR LEFT JOIN ac a ON a.uid = u.distinct_id::VARCHAR
1147
+ WHERE u.segment <> 'resolver'),
1148
+ coh AS (SELECT *, LEAST(w - 1, ${STREAK_LINEAR_CAP}) + GREATEST(w - 4, 0) * ${STREAK_SUPER_LINEAR_MULT} AS clones
1149
+ FROM j WHERE w BETWEEN ${STREAK_MIN_WORKOUTS} AND ${WORKOUT_OVER_THRESHOLD - 1} AND a >= 1)
1150
+ SELECT 'all' AS grp, count(*) AS user_count, median(a - clones) AS med_organic
1151
+ FROM coh`,
1152
+ },
1153
+ select: { all: { where: { grp: "all" } } },
1154
+ // implied organic achievements — clone over-injection would blow this up
1155
+ expect: { metric: "all.med_organic", op: "between", target: [1, 6] },
1156
+ minCohort: 200,
1157
+ },
1158
+ ],
1159
+ },
1160
+ {
1161
+ id: "H4-social-challenge-completion",
1162
+ hook: "H4",
1163
+ archetype: "cohort-count-scale",
1164
+ narrative: `users with ≥${SOCIAL_FRIEND_THRESHOLD} friend-added events get max(1, floor(cc × ${SOCIAL_CHALLENGE_CLONE_FACTOR})) cloned challenge completions (cc = organic count; no organic completion → no template → no clones). The output total is then out = cc + max(1, floor(cc/2)), whose image skips exactly {5, 8, 11, …} = {n ≥ 5 : n ≡ 2 (mod 3)} — a count FINGERPRINT: for the clean cohort (non-resolver, ≤14 workouts so H10's over-drop never fires, output friends ≥ ${SOCIAL_FRIEND_THRESHOLD}, ≥2 completions) no later hook deletes challenge events, so landing in a gap is impossible except via the future-time guard (clone lands ≤48h past datasetEnd), bounded ≲2% of the cohort. The companion gradient (friend-heavy vs friend-light challenge counts WITHIN the social segment) is a composite: the ≥1.5× clone lift compounds with organic activity correlation (more friends ⇒ more events ⇒ more completions), so its band is wide and bounded away from 1 rather than pinned`,
1165
+ assertions: [
1166
+ {
1167
+ breakdown: {
1168
+ type: "duckdb",
1169
+ sql: `WITH ${ID_CTE}, ${WORKOUT_CTE},
1170
+ fr AS (SELECT uid, count(*) AS f FROM ev WHERE event = 'friend added' GROUP BY 1),
1171
+ ch AS (SELECT uid, count(*) AS c FROM ev WHERE event = 'challenge completed' GROUP BY 1),
1172
+ j AS (SELECT u.distinct_id::VARCHAR AS uid, coalesce(w.w, 0) AS w, coalesce(f.f, 0) AS f, coalesce(c.c, 0) AS c
1173
+ FROM ${US} u LEFT JOIN wc w ON w.uid = u.distinct_id::VARCHAR
1174
+ LEFT JOIN fr f ON f.uid = u.distinct_id::VARCHAR LEFT JOIN ch c ON c.uid = u.distinct_id::VARCHAR
1175
+ WHERE u.segment <> 'resolver'),
1176
+ coh AS (SELECT * FROM j WHERE w <= ${WORKOUT_OVER_THRESHOLD - 1} AND f >= ${SOCIAL_FRIEND_THRESHOLD} AND c >= 2)
1177
+ SELECT 'all' AS grp, count(*) AS user_count,
1178
+ count(*) FILTER (WHERE c >= 5 AND c % 3 = 2) AS gap_hits
1179
+ FROM coh`,
1180
+ },
1181
+ assert: (rows) => {
1182
+ const r = (rows || [])[0];
1183
+ const n = Number(r?.user_count || 0);
1184
+ if (n < 50) return { pass: false, verdict: "NONE", detail: `clean cohort too small (${n})` };
1185
+ const share = Number(r.gap_hits) / n;
1186
+ const pass = share <= 0.02;
1187
+ return {
1188
+ pass,
1189
+ verdict: pass ? (share <= 0.005 ? "NAILED" : "STRONG") : "INVERSE",
1190
+ detail: `unreachable challenge totals (n≥5, n≡2 mod 3): ${r.gap_hits} of ${n} clean-cohort users (${(share * 100).toFixed(2)}% — future-guard bound ~2%)`,
1191
+ };
1192
+ },
1193
+ },
1194
+ {
1195
+ breakdown: {
1196
+ type: "duckdb",
1197
+ sql: `WITH ${ID_CTE},
1198
+ fr AS (SELECT uid, count(*) AS f FROM ev WHERE event = 'friend added' GROUP BY 1),
1199
+ ch AS (SELECT uid, count(*) AS c FROM ev WHERE event = 'challenge completed' GROUP BY 1),
1200
+ j AS (SELECT u.distinct_id::VARCHAR AS uid, coalesce(f.f, 0) AS f, coalesce(c.c, 0) AS c
1201
+ FROM ${US} u LEFT JOIN fr f ON f.uid = u.distinct_id::VARCHAR LEFT JOIN ch c ON c.uid = u.distinct_id::VARCHAR
1202
+ WHERE u.segment = 'social')
1203
+ SELECT CASE WHEN f >= ${SOCIAL_FRIEND_THRESHOLD} THEN 'hi' ELSE 'lo' END AS grp,
1204
+ count(*) AS user_count, avg(c) AS avg_cc
1205
+ FROM j WHERE f >= ${SOCIAL_FRIEND_THRESHOLD} OR f <= 1 GROUP BY 1`,
1206
+ },
1207
+ select: { h: { where: { grp: "hi" } }, l: { where: { grp: "lo" } } },
1208
+ expect: { metric: "h.avg_cc / l.avg_cc", op: "between", target: [1.35, 4.5] },
1209
+ minCohort: 100,
1210
+ },
1211
+ ],
1212
+ },
1213
+ {
1214
+ id: "H5-resolver-churn-cliff",
1215
+ hook: "H5",
1216
+ archetype: "cohort-count-scale",
1217
+ narrative: `resolver-segment users with <${RESOLVER_EVENT_THRESHOLD} events (at hook time) lose ${RESOLVER_DROP_LIKELIHOOD}% of post-day-${RESOLVER_CLIFF_DAYS} events. The cliff is engineered ENTIRELY by this hook — the persona's churnRate/activeWindow fields are deprecated engine no-ops (the engine warns so at generation), so the estimator targets the ${(100 - RESOLVER_DROP_LIKELIHOOD) / 100} keep-rate directly. Deletions-only pipeline makes the treated cohort output-identifiable: eligible ⟺ output events < ${RESOLVER_EVENT_THRESHOLD} (treated users only shrink below the threshold they were already under; untreated resolvers keep their ≥${RESOLVER_EVENT_THRESHOLD} count). Two composition traps force the double-difference design: (1) birth time dominates raw post/pre mass, so both cells pin birth to the window's first two days (first event < day 2 — mostly pre-existing users, and birth ⊥ persona); (2) splitting on total volume tilts post/pre by itself (low-n users' realized timing differs — measured 1.22 inside casual where NO hook fires), so the resolver lo/hi contrast is normalized by the identical lo/hi split inside casual, which measures pure selection. DD = (ρ_res_lo/ρ_res_hi) ÷ (ρ_cas_lo/ρ_cas_hi) then reads the keep-rate: iter-scale measured 0.314 vs knob 0.30. The placebo asserts the casual split itself sits near 1 — nowhere near the 0.3 keep-rate — or the normalizer would be absorbing treatment`,
1218
+ assertions: [
1219
+ {
1220
+ breakdown: {
1221
+ type: "duckdb",
1222
+ sql: `WITH ${ID_CTE},
1223
+ tot AS (SELECT uid, count(*) AS n, min(t) AS first_t,
1224
+ count(*) FILTER (WHERE t < TIMESTAMP '${D14_TS}') AS pre,
1225
+ count(*) FILTER (WHERE t >= TIMESTAMP '${D14_TS}') AS post
1226
+ FROM ev GROUP BY 1),
1227
+ j AS (SELECT u.segment AS seg, CASE WHEN t.n < ${RESOLVER_EVENT_THRESHOLD} THEN 'lo' ELSE 'hi' END AS arm, t.pre, t.post
1228
+ FROM ${US} u JOIN tot t ON t.uid = u.distinct_id::VARCHAR
1229
+ WHERE t.first_t < TIMESTAMP '${D2_TS}' AND u.segment IN ('resolver', 'casual')),
1230
+ g AS (SELECT seg, arm, count(*)::BIGINT AS user_count, sum(post)::DOUBLE / nullif(sum(pre), 0) AS rho FROM j GROUP BY 1, 2)
1231
+ SELECT seg || '_' || arm AS grp, user_count, rho FROM g`,
1232
+ },
1233
+ // DD is a 4-cell statistic (two ops) — parseMetric caps at one, so
1234
+ // the ratio-of-ratios is computed in a custom assert. Band [0.2,
1235
+ // 0.42]: knob 0.30 + headroom for the second-order multiplier-depth
1236
+ // mismatch (0.6× res vs 1.0× cas at the same n=30 cut); STRONG
1237
+ // buffer [0.15, 0.5] absorbs smallest-cell sampling noise
1238
+ // (resolver_hi ≈ 30 at 1500 users, ≈ 200 at 10K).
1239
+ assert: (rows) => {
1240
+ const cell = (g) => (rows || []).find((r) => r.grp === g);
1241
+ const rl = cell("resolver_lo"), rh = cell("resolver_hi"), cl = cell("casual_lo"), ch = cell("casual_hi");
1242
+ const cells = { rl, rh, cl, ch };
1243
+ for (const [k, c] of Object.entries(cells)) {
1244
+ if (!c || Number(c.user_count) < 15 || !Number(c.rho)) {
1245
+ return { pass: false, verdict: "NONE", detail: `cell ${k} missing or too small (${c ? c.user_count : 0} users)` };
1246
+ }
1247
+ }
1248
+ const dd = (Number(rl.rho) / Number(rh.rho)) / (Number(cl.rho) / Number(ch.rho));
1249
+ const inBand = dd >= 0.2 && dd <= 0.42;
1250
+ const inBuffer = dd >= 0.15 && dd <= 0.5;
1251
+ return {
1252
+ pass: inBuffer,
1253
+ verdict: inBand ? "NAILED" : inBuffer ? "STRONG" : "INVERSE",
1254
+ detail: `DD = (${Number(rl.rho).toFixed(4)}/${Number(rh.rho).toFixed(4)}) ÷ (${Number(cl.rho).toFixed(4)}/${Number(ch.rho).toFixed(4)}) = ${dd.toFixed(4)} (keep-rate knob 0.30, band [0.2, 0.42]; cells rl=${rl.user_count} rh=${rh.user_count} cl=${cl.user_count} ch=${ch.user_count})`,
1255
+ };
1256
+ },
1257
+ },
1258
+ {
1259
+ breakdown: {
1260
+ type: "duckdb",
1261
+ sql: `WITH ${ID_CTE},
1262
+ tot AS (SELECT uid, count(*) AS n, min(t) AS first_t,
1263
+ count(*) FILTER (WHERE t < TIMESTAMP '${D14_TS}') AS pre,
1264
+ count(*) FILTER (WHERE t >= TIMESTAMP '${D14_TS}') AS post
1265
+ FROM ev GROUP BY 1),
1266
+ j AS (SELECT CASE WHEN t.n < ${RESOLVER_EVENT_THRESHOLD} THEN 'lo' ELSE 'hi' END AS arm, t.pre, t.post
1267
+ FROM ${US} u JOIN tot t ON t.uid = u.distinct_id::VARCHAR
1268
+ WHERE t.first_t < TIMESTAMP '${D2_TS}' AND u.segment = 'casual')
1269
+ SELECT arm AS grp, count(*)::BIGINT AS user_count, sum(post)::DOUBLE / nullif(sum(pre), 0) AS rho
1270
+ FROM j GROUP BY 1`,
1271
+ },
1272
+ select: { l: { where: { grp: "lo" } }, h: { where: { grp: "hi" } } },
1273
+ // placebo: no hook fires on casual — volume split alone must stay
1274
+ // near 1 (measured 1.22 at iter scale), far from the 0.3 keep-rate
1275
+ expect: { metric: "l.rho / h.rho", op: "between", target: [0.7, 1.8] },
1276
+ minCohort: 150,
1277
+ },
1278
+ ],
1279
+ },
1280
+ {
1281
+ id: "H6-coach-session-quality",
1282
+ hook: "H6",
1283
+ archetype: "cohort-prop-scale",
1284
+ narrative: `every coach-session event gets satisfaction_score redrawn uniform [${COACH_SESSION_SATISFACTION_MIN}, ${COACH_SESSION_SATISFACTION_MAX}] (fixed to 1 decimal — avg AND median 4.5, both quantile reads of the uniform). The redraw is unconditional on all coach sessions, so purity is exact: a single sub-${COACH_SESSION_SATISFACTION_MIN} score is a hook bug. No ratio-vs-baseline assertion: the declared weighNumRange(1, 5, 0.6, 3) baseline is a 3-value seeded pool (the 4th arg is POOL SIZE, not mode), so the organic mean is not derivable from the schema`,
1285
+ assertions: [
1286
+ {
1287
+ breakdown: {
1288
+ type: "duckdb",
1289
+ sql: `WITH ${ID_CTE}
1290
+ SELECT count(*) FILTER (WHERE satisfaction_score < ${COACH_SESSION_SATISFACTION_MIN}) AS below_min,
1291
+ count(*) AS scores, count(DISTINCT uid) AS user_count
1292
+ FROM ev WHERE event = 'coach session'`,
1293
+ },
1294
+ assert: (rows) => {
1295
+ const r = (rows || [])[0];
1296
+ if (!r || Number(r.scores) === 0) return { pass: false, verdict: "NONE", detail: "no coach sessions" };
1297
+ const clean = Number(r.below_min) === 0;
1298
+ return {
1299
+ pass: clean,
1300
+ verdict: clean ? "NAILED" : "INVERSE",
1301
+ detail: `below-${COACH_SESSION_SATISFACTION_MIN} scores=${r.below_min} of ${r.scores} coach sessions (must be 0)`,
1302
+ };
1303
+ },
1304
+ },
1305
+ {
1306
+ breakdown: {
1307
+ type: "duckdb",
1308
+ sql: `WITH ${ID_CTE}
1309
+ SELECT 'all' AS grp, count(*) AS event_count, count(DISTINCT uid) AS user_count,
1310
+ avg(satisfaction_score) AS avg_sat, median(satisfaction_score) AS med_sat
1311
+ FROM ev WHERE event = 'coach session'`,
1312
+ },
1313
+ select: { x: { where: { grp: "all" } } },
1314
+ expect: { metric: "x.avg_sat", op: "between", target: [4.4, 4.6] },
1315
+ minCohort: 200,
1316
+ },
1317
+ {
1318
+ breakdown: {
1319
+ type: "duckdb",
1320
+ sql: `WITH ${ID_CTE}
1321
+ SELECT 'all' AS grp, count(*) AS event_count, count(DISTINCT uid) AS user_count,
1322
+ avg(satisfaction_score) AS avg_sat, median(satisfaction_score) AS med_sat
1323
+ FROM ev WHERE event = 'coach session'`,
1324
+ },
1325
+ select: { x: { where: { grp: "all" } } },
1326
+ expect: { metric: "x.med_sat", op: "between", target: [4.4, 4.6] },
1327
+ minCohort: 200,
1328
+ },
1329
+ ],
1330
+ },
1331
+ {
1332
+ id: "H7-coach-profile-enrichment",
1333
+ hook: "H7",
1334
+ archetype: "cohort-prop-scale",
1335
+ narrative: `user hook: coach-segment users get total_workouts uniform [${COACH_TOTAL_WORKOUTS_MIN}, ${COACH_TOTAL_WORKOUTS_MAX}] (avg 350); every other segment keeps the declared default 0. Deterministic ranges — violations are hook bugs, not noise. (The hook also seeds streak_days 60-365, but H3 overwrites streak_days for ≥2-workout users, so total_workouts is the durable signature — see the H3 story for the streak_days contract)`,
1336
+ assertions: [
1337
+ {
1338
+ breakdown: {
1339
+ type: "duckdb",
1340
+ sql: `SELECT CASE WHEN segment = 'coach' THEN 'coach' ELSE 'other' END AS grp,
1341
+ count(*) AS user_count, min(total_workouts) AS min_tw, max(total_workouts) AS max_tw, avg(total_workouts) AS avg_tw
1342
+ FROM ${US} GROUP BY 1`,
1343
+ },
1344
+ assert: (rows) => {
1345
+ const by = Object.fromEntries((rows || []).map(r => [r.grp, r]));
1346
+ const c = by.coach, o = by.other;
1347
+ if (!c || !o) return { pass: false, verdict: "NONE", detail: `missing segment rows (${(rows || []).map(r => r.grp).join(",")})` };
1348
+ const bad = [];
1349
+ if (Number(c.min_tw) < COACH_TOTAL_WORKOUTS_MIN || Number(c.max_tw) > COACH_TOTAL_WORKOUTS_MAX) bad.push(`coach total_workouts [${c.min_tw}, ${c.max_tw}] outside [${COACH_TOTAL_WORKOUTS_MIN}, ${COACH_TOTAL_WORKOUTS_MAX}]`);
1350
+ if (Number(o.min_tw) !== 0 || Number(o.max_tw) !== 0) bad.push(`non-coach total_workouts [${o.min_tw}, ${o.max_tw}] not pinned to 0`);
1351
+ return {
1352
+ pass: bad.length === 0,
1353
+ verdict: bad.length === 0 ? "NAILED" : "INVERSE",
1354
+ detail: bad.length ? bad.join("; ") : `ranges exact: coach [${c.min_tw}, ${c.max_tw}], non-coach pinned 0 (${c.user_count}/${o.user_count} users)`,
1355
+ };
1356
+ },
1357
+ },
1358
+ {
1359
+ breakdown: {
1360
+ type: "duckdb",
1361
+ sql: `SELECT CASE WHEN segment = 'coach' THEN 'coach' ELSE 'other' END AS grp,
1362
+ count(*) AS user_count, avg(total_workouts) AS avg_tw
1363
+ FROM ${US} GROUP BY 1`,
1364
+ },
1365
+ select: { c: { where: { grp: "coach" } } },
1366
+ // uniform [200, 500] → 350
1367
+ expect: { metric: "c.avg_tw", op: "between", target: [330, 370] },
1368
+ minCohort: 400,
1369
+ },
1370
+ ],
1371
+ },
1372
+ {
1373
+ id: "H8-annual-follow-through",
1374
+ hook: "H8",
1375
+ archetype: "funnel-conversion-by-segment",
1376
+ narrative: `free/monthly users have a ${ANNUAL_FUNNEL_FREE_DROP_LIKELIHOOD}% chance to lose ALL progress-checked events (per-user cliff; annual/family untouched). Tier is assigned BY segment in the user hook, and personas' eventMultiplier/conversionModifier drive volume — so any raw cross-tier comparison is confounded by composition BY CONSTRUCTION. Both estimators are SEGMENT-STANDARDIZED over the two segments that contain both an affected and a control tier (athlete: monthly vs annual+family; social: free+monthly vs annual). Within a segment, tier is an independent pickone draw, so the natural-zero baseline and volume distribution are tier-blind: (z_aff − z_ctl)/(1 − z_ctl) reads the 0.30 knob, and SURVIVING affected users' progress-checked counts must match controls (ratio ≈ 1.0 — per-user cliff, not per-event thinning; thinning would read ~0.7 in every segment). H10's over-drop preserves progress checked and H5 only touches resolvers, so no other hook moves this event for these segments. The doc's funnel-conversion read (annual 63% vs free 45%) is the analyst-facing composite of this cliff plus persona conversionModifier — deliberately not machine-asserted, since no knob-derived band exists for the composite`,
1377
+ assertions: [
1378
+ {
1379
+ breakdown: {
1380
+ type: "duckdb",
1381
+ sql: `WITH ${ID_CTE},
1382
+ pc AS (SELECT uid, count(*) AS ct FROM ev WHERE event = 'progress checked' GROUP BY 1),
1383
+ u AS (SELECT u.distinct_id::VARCHAR AS uid, u.segment AS seg, u.subscription_tier AS tier
1384
+ FROM ${US} u WHERE u.segment IN ('athlete', 'social')),
1385
+ j AS (SELECT u.seg, CASE WHEN u.tier IN ('annual', 'family') THEN 'ctl' ELSE 'aff' END AS arm, coalesce(p.ct, 0) AS ct
1386
+ FROM u LEFT JOIN pc p ON p.uid = u.uid),
1387
+ seg AS (SELECT seg,
1388
+ count(*) FILTER (WHERE arm = 'aff') AS n_aff, count(*) FILTER (WHERE arm = 'ctl') AS n_ctl,
1389
+ count(*) FILTER (WHERE arm = 'aff' AND ct = 0)::DOUBLE / nullif(count(*) FILTER (WHERE arm = 'aff'), 0) AS z_aff,
1390
+ count(*) FILTER (WHERE arm = 'ctl' AND ct = 0)::DOUBLE / nullif(count(*) FILTER (WHERE arm = 'ctl'), 0) AS z_ctl
1391
+ FROM j GROUP BY 1)
1392
+ SELECT 'all' AS grp, sum(n_aff + n_ctl)::BIGINT AS user_count,
1393
+ sum(n_aff * (z_aff - z_ctl) / nullif(1 - z_ctl, 0)) / sum(n_aff) AS cliff_share
1394
+ FROM seg WHERE n_ctl >= 25`,
1395
+ },
1396
+ select: { all: { where: { grp: "all" } } },
1397
+ expect: { metric: "all.cliff_share", op: "between", target: [0.22, 0.38] },
1398
+ minCohort: 500,
1399
+ },
1400
+ {
1401
+ breakdown: {
1402
+ type: "duckdb",
1403
+ sql: `WITH ${ID_CTE},
1404
+ pc AS (SELECT uid, count(*) AS ct FROM ev WHERE event = 'progress checked' GROUP BY 1),
1405
+ surv AS (SELECT u.segment AS seg, CASE WHEN u.subscription_tier IN ('annual', 'family') THEN 'ctl' ELSE 'aff' END AS arm, p.ct
1406
+ FROM ${US} u JOIN pc p ON p.uid = u.distinct_id::VARCHAR
1407
+ WHERE u.segment IN ('athlete', 'social')),
1408
+ g AS (SELECT seg,
1409
+ avg(ct) FILTER (WHERE arm = 'aff') AS aff_avg, count(*) FILTER (WHERE arm = 'aff') AS aff_n,
1410
+ avg(ct) FILTER (WHERE arm = 'ctl') AS ctl_avg, count(*) FILTER (WHERE arm = 'ctl') AS ctl_n
1411
+ FROM surv GROUP BY 1)
1412
+ SELECT 'all' AS grp, sum(aff_n + ctl_n)::BIGINT AS user_count,
1413
+ sum(aff_n * aff_avg / ctl_avg) / sum(aff_n) AS std_ratio
1414
+ FROM g WHERE ctl_n >= 25 AND aff_avg IS NOT NULL AND ctl_avg IS NOT NULL`,
1415
+ },
1416
+ select: { all: { where: { grp: "all" } } },
1417
+ // per-user cliff, not thinning: survivors untouched → ratio ≈ 1.0
1418
+ expect: { metric: "all.std_ratio", op: "between", target: [0.85, 1.15] },
1419
+ minCohort: 500,
1420
+ },
1421
+ ],
1422
+ },
1423
+ {
1424
+ id: "H9-workout-loop-ttc",
1425
+ hook: "H9",
1426
+ archetype: "funnel-ttc-by-segment",
1427
+ narrative: `funnel-post scales every funnel instance's inter-step gaps by tier: annual/family × ${TTC_ANNUAL_FACTOR}, free × ${TTC_FREE_FACTOR}, monthly = 1.0 control. Measured on the Workout Loop through the Mixpanel-aligned emulator at a ${Math.round(48 * TTC_FREE_FACTOR)}h conversion window = the funnel's 48h generative window × the max stretch ${TTC_FREE_FACTOR} (the window must cover the stretched support or censoring dilutes the free tier — the ai-platform H9 lesson). Every instance of an affected user is scaled (the hook fires per funnel-post record), but the emulator's greedy matching can pair steps across neighboring instances of high-frequency events, diluting the measured ratio toward 1 — bands assume ≥25% of the effect survives on the slow side and cap attenuation at ~90% on the fast side`,
1428
+ assertions: [
1429
+ {
1430
+ breakdown: {
1431
+ type: "timeToConvert",
1432
+ steps: ["workout planned", "workout completed", "progress checked"],
1433
+ breakdownByUserProperty: "subscription_tier",
1434
+ // 60h = 48h generative window × 1.25 max stretch
1435
+ conversionWindowMs: Math.round(48 * TTC_FREE_FACTOR * 3600 * 1000),
1436
+ },
1437
+ select: { f: { where: { segment_value: "free" } }, m: { where: { segment_value: "monthly" } } },
1438
+ expect: { metric: "f.median_ttc_ms / m.median_ttc_ms", op: "between", target: [1.05, 1.35] },
1439
+ minCohort: 100,
1440
+ },
1441
+ {
1442
+ breakdown: {
1443
+ type: "timeToConvert",
1444
+ steps: ["workout planned", "workout completed", "progress checked"],
1445
+ breakdownByUserProperty: "subscription_tier",
1446
+ conversionWindowMs: Math.round(48 * TTC_FREE_FACTOR * 3600 * 1000),
1447
+ },
1448
+ select: { a: { where: { segment_value: "annual" } }, m: { where: { segment_value: "monthly" } } },
1449
+ expect: { metric: "a.median_ttc_ms / m.median_ttc_ms", op: "between", target: [0.7, 0.97] },
1450
+ minCohort: 100,
1451
+ },
1452
+ ],
1453
+ },
1454
+ {
1455
+ id: "H10-workout-magic-number",
1456
+ hook: "H10",
1457
+ archetype: "frequency-sweet-spot",
1458
+ narrative: `sweet ${WORKOUT_SWEET_MIN}-${WORKOUT_SWEET_MAX} workouts → ALL the user's workout duration_minutes × ${WORKOUT_DURATION_BOOST}; over ${WORKOUT_OVER_THRESHOLD}+ → ${WORKOUT_OVER_DROP_LIKELIHOOD}% of post-day-${WORKOUT_OVER_CUTOFF_DAYS} non-workout non-progress events dropped. Cohort counts are output-exact for every user (H10 runs after H5's resolver thinning and preserves workouts itself). The duration read restricts to PRE-day-${AI_LAUNCH_DAY} workouts, where H2's ai_assisted ×${AI_DURATION_MULT} never fired — the AVG ratio reads the ${WORKOUT_DURATION_BOOST} knob exactly (whole-cohort scaling: E[kX]/E[X] = k for any pool). Median deliberately NOT used: duration_minutes draws from a discrete weighNumRange atom pool, so the scaled cohort's median snaps to an atom quotient, not the knob (iter-scale median read 1.476 while avg read 1.395). The over-drop read is a within-user-normalized double ratio: (over post-d30/pre-d30 non-preserved volume) ÷ (sweet same) = 0.35 × τ, where τ captures residual timing composition (over-users' longer lifetimes skew τ ≥ 1) — band [0.25, 0.6]`,
1459
+ assertions: [
1460
+ {
1461
+ breakdown: {
1462
+ type: "duckdb",
1463
+ sql: `WITH ${ID_CTE}, ${WORKOUT_CTE},
1464
+ coh AS (SELECT uid, CASE WHEN w BETWEEN ${WORKOUT_SWEET_MIN} AND ${WORKOUT_SWEET_MAX} THEN 'sweet'
1465
+ WHEN w BETWEEN ${STREAK_MIN_WORKOUTS} AND ${WORKOUT_SWEET_MIN - 1} THEN 'low' END AS grp FROM wc)
1466
+ SELECT c.grp, count(DISTINCT c.uid) AS user_count, count(*) AS event_count, avg(e.duration_minutes) AS avg_dur
1467
+ FROM coh c JOIN ev e ON e.uid = c.uid AND e.event = 'workout completed' AND e.t <= TIMESTAMP '${AI_LAUNCH_TS}'
1468
+ WHERE c.grp IS NOT NULL GROUP BY 1`,
1469
+ },
1470
+ select: { s: { where: { grp: "sweet" } }, l: { where: { grp: "low" } } },
1471
+ expect: { metric: "s.avg_dur / l.avg_dur", op: "between", target: [1.25, 1.45] },
1472
+ minCohort: 40,
1473
+ },
1474
+ {
1475
+ breakdown: {
1476
+ type: "duckdb",
1477
+ sql: `WITH ${ID_CTE}, ${WORKOUT_CTE},
1478
+ coh AS (SELECT uid, CASE WHEN w >= ${WORKOUT_OVER_THRESHOLD} THEN 'over'
1479
+ WHEN w BETWEEN ${WORKOUT_SWEET_MIN} AND ${WORKOUT_SWEET_MAX} THEN 'sweet' END AS grp FROM wc WHERE w >= ${WORKOUT_SWEET_MIN}),
1480
+ per AS (SELECT c.grp, c.uid,
1481
+ count(*) FILTER (WHERE e.event NOT IN ('workout completed', 'progress checked') AND e.t < TIMESTAMP '${D30_TS}') AS pre,
1482
+ count(*) FILTER (WHERE e.event NOT IN ('workout completed', 'progress checked') AND e.t >= TIMESTAMP '${D30_TS}') AS post
1483
+ FROM coh c JOIN ev e ON e.uid = c.uid GROUP BY 1, 2)
1484
+ SELECT grp, count(*) AS user_count, sum(post)::DOUBLE / nullif(sum(pre), 0) AS post_pre
1485
+ FROM per GROUP BY 1`,
1486
+ },
1487
+ select: { o: { where: { grp: "over" } }, s: { where: { grp: "sweet" } } },
1488
+ expect: { metric: "o.post_pre / s.post_pre", op: "between", target: [0.25, 0.6] },
1489
+ minCohort: 40,
1490
+ },
1491
+ ],
1492
+ },
1493
+ ];
1494
+
1495
+ export default config;