@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.
- package/.claude/skills/analyze-soup/SKILL.md +21 -12
- package/.claude/skills/create-dungeon/SKILL.md +65 -5
- package/.claude/skills/create-project/SKILL.md +1 -1
- package/.claude/skills/create-project/provision.mjs +57 -10
- package/.claude/skills/verify-dungeon/SKILL.md +56 -25
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
- package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
- package/.claude/skills/write-hooks/SKILL.md +156 -18
- package/CHANGELOG.md +183 -0
- package/HOOKS.md +566 -112
- package/README.md +28 -9
- package/docs/guides/1.3.0-upgrade-guide.md +262 -0
- package/docs/guides/1.3.1-upgrade-guide.md +328 -0
- package/docs/guides/1.4.0-upgrade-guide.md +130 -0
- package/docs/guides/1.4.1-upgrade-guide.md +125 -0
- package/docs/guides/1.4.3-upgrade-guide.md +200 -0
- package/docs/guides/1.4.4-upgrade-guide.md +95 -0
- package/docs/guides/1.5.0-upgrade-guide.md +426 -0
- package/docs/guides/1.5.1-upgrade-guide.md +178 -0
- package/docs/guides/1.5.3-upgrade-guide.md +117 -0
- package/docs/guides/1.6.0-upgrade-guide.md +204 -0
- package/dungeons/technical/stories-verify.js +152 -0
- package/dungeons/vertical/README.md +154 -0
- package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
- package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
- package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
- package/dungeons/vertical/community/community.js +1589 -0
- package/dungeons/vertical/community/community.sql +216 -0
- package/dungeons/vertical/community/community.verify.mjs +72 -0
- package/dungeons/vertical/crypto/crypto.js +1483 -0
- package/dungeons/vertical/crypto/crypto.sql +194 -0
- package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
- package/dungeons/vertical/dating/dating.js +1540 -0
- package/dungeons/vertical/dating/dating.sql +288 -0
- package/dungeons/vertical/dating/dating.verify.mjs +72 -0
- package/dungeons/vertical/devtools/devtools.js +1524 -0
- package/dungeons/vertical/devtools/devtools.sql +161 -0
- package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
- package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
- package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
- package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
- package/dungeons/vertical/education/education.js +1763 -0
- package/dungeons/vertical/education/education.sql +243 -0
- package/dungeons/vertical/education/education.verify.mjs +75 -0
- package/dungeons/vertical/fintech/fintech.js +1410 -0
- package/dungeons/vertical/fintech/fintech.sql +175 -0
- package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
- package/dungeons/vertical/fitness/fitness.js +1495 -0
- package/dungeons/vertical/fitness/fitness.sql +206 -0
- package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
- package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
- package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
- package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
- package/dungeons/vertical/gaming/gaming.js +1741 -0
- package/dungeons/vertical/gaming/gaming.sql +249 -0
- package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
- package/dungeons/vertical/healthcare/healthcare.js +1488 -0
- package/dungeons/vertical/healthcare/healthcare.sql +193 -0
- package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
- package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
- package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
- package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
- package/dungeons/vertical/logistics/logistics.js +1541 -0
- package/dungeons/vertical/logistics/logistics.sql +205 -0
- package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
- package/dungeons/vertical/marketplace/marketplace.js +1438 -0
- package/dungeons/vertical/marketplace/marketplace.sql +148 -0
- package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
- package/dungeons/vertical/{media.js → media/media.js} +557 -58
- package/dungeons/vertical/media/media.sql +173 -0
- package/dungeons/vertical/media/media.verify.mjs +74 -0
- package/dungeons/vertical/real-estate/real-estate.js +1629 -0
- package/dungeons/vertical/real-estate/real-estate.sql +167 -0
- package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
- package/dungeons/vertical/sass/sass.js +1733 -0
- package/dungeons/vertical/sass/sass.sql +173 -0
- package/dungeons/vertical/sass/sass.verify.mjs +74 -0
- package/dungeons/vertical/social/social.js +1456 -0
- package/dungeons/vertical/social/social.sql +160 -0
- package/dungeons/vertical/social/social.verify.mjs +76 -0
- package/dungeons/vertical/streaming/streaming.js +677 -0
- package/dungeons/vertical/streaming/streaming.sql +101 -0
- package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
- package/dungeons/vertical/support-desk/support-desk.js +741 -0
- package/dungeons/vertical/support-desk/support-desk.sql +137 -0
- package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
- package/dungeons/vertical/travel/travel.js +1603 -0
- package/dungeons/vertical/travel/travel.sql +236 -0
- package/dungeons/vertical/travel/travel.verify.mjs +72 -0
- package/index.js +2 -2
- package/lib/core/config-validator.js +37 -2
- package/lib/core/context.js +4 -2
- package/lib/core/dungeon-loader.js +20 -15
- package/lib/core/dungeon-to-json.js +22 -4
- package/lib/core/storage.js +3 -3
- package/lib/generators/funnels.js +36 -9
- package/lib/hook-helpers/cohort.js +53 -0
- package/lib/hook-helpers/index.js +8 -0
- package/lib/hook-helpers/mutate.js +1 -1
- package/lib/hook-helpers/shape.js +319 -0
- package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
- package/lib/hook-patterns/attributed-by-source.js +60 -58
- package/lib/hook-patterns/frequency-by-frequency.js +14 -4
- package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
- package/lib/hook-patterns/index.js +1 -1
- package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
- package/lib/orchestrators/user-loop.js +41 -0
- package/lib/templates/story-spec.schema.json +175 -0
- package/lib/utils/json-evaluator.js +1 -1
- package/lib/utils/utils.js +5 -4
- package/lib/verify/coerce.js +186 -0
- package/lib/verify/counting.js +270 -32
- package/lib/verify/emulate-breakdown.js +1285 -203
- package/lib/verify/first-time.js +89 -0
- package/lib/verify/flows.js +679 -0
- package/lib/verify/formula.js +259 -0
- package/lib/verify/funnel-engine.js +697 -113
- package/lib/verify/index.js +32 -4
- package/lib/verify/sessionize.js +275 -0
- package/lib/verify/story-runner.js +483 -0
- package/lib/verify/verify-dungeon.js +59 -37
- package/package.json +2 -1
- package/scripts/extract-dungeon-schema.mjs +2 -2
- package/scripts/run-many.mjs +2 -2
- package/scripts/verify-runner.mjs +2 -2
- package/scripts/verify-stories.mjs +244 -0
- package/types.d.ts +296 -30
- package/dungeons/vertical/ai-platform.js +0 -818
- package/dungeons/vertical/community.js +0 -966
- package/dungeons/vertical/crypto.js +0 -895
- package/dungeons/vertical/dating.js +0 -830
- package/dungeons/vertical/devtools.js +0 -1050
- package/dungeons/vertical/ecommerce.js +0 -811
- package/dungeons/vertical/education.js +0 -964
- package/dungeons/vertical/fintech.js +0 -946
- package/dungeons/vertical/fitness.js +0 -935
- package/dungeons/vertical/food-delivery.js +0 -934
- package/dungeons/vertical/gaming.js +0 -1120
- package/dungeons/vertical/healthcare.js +0 -945
- package/dungeons/vertical/insurance-application.js +0 -1039
- package/dungeons/vertical/logistics.js +0 -986
- package/dungeons/vertical/marketplace.js +0 -1019
- package/dungeons/vertical/real-estate.js +0 -872
- package/dungeons/vertical/sass.js +0 -995
- package/dungeons/vertical/social.js +0 -904
- 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;
|