@ak--47/dungeon-master 1.5.3 → 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 +96 -0
- package/.claude/skills/create-project/provision.mjs +336 -0
- 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 +211 -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 +3 -2
- 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,1540 @@
|
|
|
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: MeetCute
|
|
12
|
+
* APP: Swipe-based dating app (Hinge/Tinder-style) with profile prompts,
|
|
13
|
+
* photo verification, matchmaking, messaging, premium tiers. Users
|
|
14
|
+
* create a profile with photos and prompts, swipe on potential
|
|
15
|
+
* matches, message, exchange numbers, and schedule dates. Premium
|
|
16
|
+
* subscribers get boosts, super-likes, and see-who-liked-you.
|
|
17
|
+
* SCALE: 30,000 users, ~5.8M events, 121 days (2026-01-01 → 2026-05-01)
|
|
18
|
+
* CORE LOOP: profile created → photo uploaded → swipe right → match received → message sent → phone number exchanged → date scheduled
|
|
19
|
+
*
|
|
20
|
+
* EVENTS (17):
|
|
21
|
+
* photo uploaded (12) > swipe right (10) > swipe left (8) > app opened (8)
|
|
22
|
+
* > message sent (6) > message received (5) > profile viewed (5) > match received (4)
|
|
23
|
+
* > prompt answered (3) > bio updated (2) > boost activated (2)
|
|
24
|
+
* > profile created (1) > phone number exchanged (1) > date scheduled (1)
|
|
25
|
+
* > premium upgrade (1) > premium cancelled (1) > report user (1)
|
|
26
|
+
*
|
|
27
|
+
* FUNNELS (4):
|
|
28
|
+
* - Onboarding: profile created → photo uploaded → swipe right (75%)
|
|
29
|
+
* - Match Flow: swipe right → match received → message sent (50%, reentry)
|
|
30
|
+
* - Date Funnel: message sent → phone number exchanged → date scheduled (25%, reentry)
|
|
31
|
+
* - Monetization: app opened → boost activated → premium upgrade (20%)
|
|
32
|
+
*
|
|
33
|
+
* USER PROPS: subscription, age_range, gender, looking_for, photo_count, total_matches, total_messages_sent, profile_completeness, Platform
|
|
34
|
+
* SUPER PROPS: subscription, Platform
|
|
35
|
+
* SCD PROPS: subscription_tier (Free/Premium/Elite, monthly fuzzy, max 6)
|
|
36
|
+
* GROUPS: none
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
// ── HOOK STORIES ──
|
|
40
|
+
/*
|
|
41
|
+
* NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable via
|
|
42
|
+
* behavioral cohorts, raw-prop breakdowns, or funnel analysis.
|
|
43
|
+
*
|
|
44
|
+
* -------------------------------------------------------------------------------------
|
|
45
|
+
* 1. PHOTO MAGIC NUMBER (everything)
|
|
46
|
+
* -------------------------------------------------------------------------------------
|
|
47
|
+
* PATTERN: Sweet 2-5 photos uploaded → 2-4 extra cloned match-received events
|
|
48
|
+
* per existing match. Over 6+ photos → match_score drops 35% on match-received
|
|
49
|
+
* events (over-curated profile reads as fake; quality matches don't trust it).
|
|
50
|
+
* No flag.
|
|
51
|
+
*
|
|
52
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
53
|
+
* Report 1: Matches per User by Photo-Count Bucket
|
|
54
|
+
* - Cohort A: users with 2-5 "photo uploaded"
|
|
55
|
+
* - Cohort B: users with 0-1
|
|
56
|
+
* - Event: "match received" → Total per user
|
|
57
|
+
* - Expected: ~4x activity-normalized (matches-per-swipe); the raw
|
|
58
|
+
* per-user ratio is smaller because sweet uploaders skew less active
|
|
59
|
+
*
|
|
60
|
+
* Report 2: Avg match_score on Heavy Photo Uploaders
|
|
61
|
+
* - Cohort C: users with >= 6 "photo uploaded"
|
|
62
|
+
* - Cohort A: users with 2-5
|
|
63
|
+
* - Event: "match received" → AVG of match_score
|
|
64
|
+
* - Expected: C ~ 0.65x A (35% lower match quality)
|
|
65
|
+
*
|
|
66
|
+
* REAL-WORLD ANALOGUE: A few good photos signal authenticity; too many
|
|
67
|
+
* curated shots read as catfish or staged.
|
|
68
|
+
*
|
|
69
|
+
* -------------------------------------------------------------------------------------
|
|
70
|
+
* 2. WEEKEND SWIPE SURGE (everything)
|
|
71
|
+
* -------------------------------------------------------------------------------------
|
|
72
|
+
* PATTERN: Sunday swipes get heavy cloning (evening 5 extra clones = 6x,
|
|
73
|
+
* daytime 2 extra = 3x) to overcome soup DOW weight deficit. No flag.
|
|
74
|
+
*
|
|
75
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
76
|
+
* Report 1: Swipe Volume by Day of Week
|
|
77
|
+
* - Insights > "swipe right" → Total → Breakdown: Day of Week
|
|
78
|
+
* - Expected: Sunday taller than other days
|
|
79
|
+
*
|
|
80
|
+
* REAL-WORLD ANALOGUE: Sunday Scaries drive swipe activity.
|
|
81
|
+
*
|
|
82
|
+
* -------------------------------------------------------------------------------------
|
|
83
|
+
* 3. SUPER-LIKE EFFECT (everything)
|
|
84
|
+
* -------------------------------------------------------------------------------------
|
|
85
|
+
* PATTERN: Each is_super_like=true swipe clones 3 extra match-received events
|
|
86
|
+
* within 5-120 minutes. No flag.
|
|
87
|
+
*
|
|
88
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
89
|
+
* Report 1: Super-Like to Match Funnel
|
|
90
|
+
* - Funnels > "swipe right" (filter: is_super_like=true) → "match received"
|
|
91
|
+
* - vs same with is_super_like=false
|
|
92
|
+
* - Expected: super-like funnel ~ 3x higher conversion
|
|
93
|
+
*
|
|
94
|
+
* REAL-WORLD ANALOGUE: Super-likes dramatically lift match rates.
|
|
95
|
+
*
|
|
96
|
+
* -------------------------------------------------------------------------------------
|
|
97
|
+
* 4. PREMIUM MATCH BOOST (everything)
|
|
98
|
+
* -------------------------------------------------------------------------------------
|
|
99
|
+
* PATTERN: Premium subscribers get 2x match events; Elite get 4x + cloned
|
|
100
|
+
* profile-viewed events (see-who-liked-you). Reads subscription from profile.
|
|
101
|
+
*
|
|
102
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
103
|
+
* Report 1: Matches by Subscription Tier
|
|
104
|
+
* - Insights > "match received" → Total per user → Breakdown: subscription
|
|
105
|
+
* - Expected: Free ~ 1x, Premium ~ 2x, Elite ~ 4x
|
|
106
|
+
*
|
|
107
|
+
* REAL-WORLD ANALOGUE: Premium tiers boost visibility.
|
|
108
|
+
*
|
|
109
|
+
* -------------------------------------------------------------------------------------
|
|
110
|
+
* 5. GHOSTING CHURN (everything)
|
|
111
|
+
* -------------------------------------------------------------------------------------
|
|
112
|
+
* PATTERN: Users with match-received but no message-sent within 48 hours lose
|
|
113
|
+
* 80% of post-match events. No flag.
|
|
114
|
+
*
|
|
115
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
116
|
+
* Report 1: Retention by Messaging Behavior
|
|
117
|
+
* - Retention starting "match received"
|
|
118
|
+
* - Cohort A: users with >= 1 "message sent" within 48 hours of match
|
|
119
|
+
* - Cohort B: rest
|
|
120
|
+
* - Expected: B drops sharply
|
|
121
|
+
*
|
|
122
|
+
* REAL-WORLD ANALOGUE: Non-responders churn.
|
|
123
|
+
*
|
|
124
|
+
* -------------------------------------------------------------------------------------
|
|
125
|
+
* 6. BIO + PROMPT POWER USERS (everything)
|
|
126
|
+
* -------------------------------------------------------------------------------------
|
|
127
|
+
* PATTERN: Users with bio-updated AND 3+ prompt-answered events get 3 extra
|
|
128
|
+
* cloned date-scheduled events per existing. No flag.
|
|
129
|
+
*
|
|
130
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
131
|
+
* Report 1: Dates per User by Profile Effort
|
|
132
|
+
* - Cohort A: users with bio-updated AND >= 3 "prompt answered"
|
|
133
|
+
* - Cohort B: rest
|
|
134
|
+
* - Event: "date scheduled" → Total per user
|
|
135
|
+
* - Expected: 4x mechanism; the population-level rate ratio reads
|
|
136
|
+
* ~2x because the rest cohort is the low-activity tail whose
|
|
137
|
+
* messages co-occur with funnel dates (see story H6)
|
|
138
|
+
*
|
|
139
|
+
* REAL-WORLD ANALOGUE: Profile effort signals serious intent.
|
|
140
|
+
*
|
|
141
|
+
* -------------------------------------------------------------------------------------
|
|
142
|
+
* 7. VALENTINE'S DAY SPIKE (everything)
|
|
143
|
+
* -------------------------------------------------------------------------------------
|
|
144
|
+
* PATTERN: Days 58-63 (V-Day window): profile-created events cloned 3x and
|
|
145
|
+
* premium-upgrade events cloned 5x. No flag — discover via line chart by day.
|
|
146
|
+
*
|
|
147
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
148
|
+
* Report 1: Signup Volume Over Time
|
|
149
|
+
* - Insights > "profile created" → Total → Line by day
|
|
150
|
+
* - Expected: spike ~ 3x days 58-63
|
|
151
|
+
*
|
|
152
|
+
* REAL-WORLD ANALOGUE: Love is expensive.
|
|
153
|
+
*
|
|
154
|
+
* -------------------------------------------------------------------------------------
|
|
155
|
+
* 8. OFF-APP RETENTION (everything)
|
|
156
|
+
* -------------------------------------------------------------------------------------
|
|
157
|
+
* PATTERN: Users with phone-exchanged OR date-scheduled in first 14 days
|
|
158
|
+
* get extra cloned app-open + swipe events past day 30. Non-milestone
|
|
159
|
+
* users lose 80% of post-day-30 events. No flag.
|
|
160
|
+
*
|
|
161
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
162
|
+
* Report 1: Retention by Milestone Cohort
|
|
163
|
+
* - Cohort A: users with phone-exchanged OR date-scheduled in first 14 days
|
|
164
|
+
* - Cohort B: rest
|
|
165
|
+
* - Expected: A ~ 60% D30 retention vs B ~ 20%
|
|
166
|
+
*
|
|
167
|
+
* REAL-WORLD ANALOGUE: Once they find someone IRL, app becomes irrelevant.
|
|
168
|
+
*
|
|
169
|
+
* -------------------------------------------------------------------------------------
|
|
170
|
+
* 9. MATCH FLOW TIME-TO-CONVERT (funnel-post)
|
|
171
|
+
* -------------------------------------------------------------------------------------
|
|
172
|
+
* PATTERN: Elite users complete the MATCH FLOW funnel (swipe→match→message)
|
|
173
|
+
* 1.4x faster (factor 0.71 on inter-event gaps); Free users 1.4x slower
|
|
174
|
+
* (factor 1.4). v1.6: scoped to Match Flow only — the v1.5 hook stretched
|
|
175
|
+
* every funnel, leaking an undocumented tier-speed pattern into
|
|
176
|
+
* Onboarding/Date Funnel/Monetization.
|
|
177
|
+
*
|
|
178
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
179
|
+
* Report 1: Match Flow Median Time-to-Convert by Tier
|
|
180
|
+
* - Funnels > "swipe right" → "match received" → "message sent"
|
|
181
|
+
* - Measure: Median time to convert
|
|
182
|
+
* - Breakdown: subscription
|
|
183
|
+
* - Expected: Elite ~ 0.71x; Free ~ 1.4x (both compressed toward 1 by
|
|
184
|
+
* organic cross-instance pairings; see the H9 story narrative)
|
|
185
|
+
*
|
|
186
|
+
* NOTE (funnel-post measurement): visible via Mixpanel funnel median
|
|
187
|
+
* TTC and via emulateBreakdown's timeToConvert (the H9 story asserts
|
|
188
|
+
* the delta itself at a 33.6h conversion window = 24h generative
|
|
189
|
+
* window × 1.4 max stretch). Cross-event MIN→MIN SQL queries on raw
|
|
190
|
+
* events do NOT show this — funnel-post adjusts gaps within funnel
|
|
191
|
+
* instances, not across the user's full event history.
|
|
192
|
+
*
|
|
193
|
+
* REAL-WORLD ANALOGUE: Premium notifications + boost surface matches faster.
|
|
194
|
+
*
|
|
195
|
+
* -------------------------------------------------------------------------------------
|
|
196
|
+
* 10. AGE RANGE AFFECTS DATE CONVERSION (funnel-pre)
|
|
197
|
+
* -------------------------------------------------------------------------------------
|
|
198
|
+
* PATTERN: On the Date Funnel (message sent → phone number exchanged →
|
|
199
|
+
* date scheduled), users aged 25-34 convert at 1.3x baseline; 40+ at 0.6x.
|
|
200
|
+
* Scoped to the funnel containing "date scheduled".
|
|
201
|
+
*
|
|
202
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
203
|
+
* Report 1: Date Funnel Conversion by Age Range
|
|
204
|
+
* - Funnels > "message sent" → "phone number exchanged" → "date scheduled"
|
|
205
|
+
* - Breakdown: age_range
|
|
206
|
+
* - Expected: 25-29 / 30-34 ~ 1.3x baseline; 40+ ~ 0.6x
|
|
207
|
+
*
|
|
208
|
+
* REAL-WORLD ANALOGUE: Peak dating age ranges convert faster to in-person
|
|
209
|
+
* dates; older users are more selective.
|
|
210
|
+
*
|
|
211
|
+
* =====================================================================================
|
|
212
|
+
* EXPECTED METRICS SUMMARY (Measured = full fidelity, 30K users / 5,839,522 events)
|
|
213
|
+
* =====================================================================================
|
|
214
|
+
*
|
|
215
|
+
* Story id | Metric | Expected | Measured
|
|
216
|
+
* ----------------------------|-------------------------------------|--------------|---------
|
|
217
|
+
* H1-photo-magic-number[0] | over/low avg match_score | [0.60, 0.73] | 0.659
|
|
218
|
+
* H1-photo-magic-number[1] | sweet/low adj-match-per-swipe DD | [2.8, 5.2] | 4.10
|
|
219
|
+
* H2-sunday-swipe-surge[0] | Sunday / mean(other DOW) swipes | [2.0, 6.0] | 2.38
|
|
220
|
+
* H3-super-like-effect[0] | measured/predicted additive lift | [0.75, 1.25] | 0.881
|
|
221
|
+
* H4-premium-match-boost[0] | Elite/Free avg matches | [3.2, 4.6] | 3.99
|
|
222
|
+
* H4-premium-match-boost[1] | Premium/Free avg matches | [1.6, 2.4] | 2.01
|
|
223
|
+
* H4-premium-match-boost[2] | Elite mod-4 share (Free placebo) | ≥0.9 (≤0.4) | 0.987 (0.248)
|
|
224
|
+
* H5-ghosting-churn[0] | pre-calibrated keep / knob 0.2 | [0.6, 1.4] | 0.982
|
|
225
|
+
* H6-bio-prompt-power-users[0]| power/rest dates-per-message | [1.5, 3.4] | 2.09
|
|
226
|
+
* H6-bio-prompt-power-users[1]| power mod-4 share (rest placebo) | ≥0.9 (≤0.4) | 0.906 (0.378)
|
|
227
|
+
* H7-vday-spike[0] | V-Day window/baseline daily signups | [2.0, 3.3] | 2.30
|
|
228
|
+
* H7-vday-spike[1] | V-Day window/baseline daily upgrades| [3.5, 5.6] | 4.25
|
|
229
|
+
* H8-offapp-retention[0] | rest post-30d share / predicted | [0.7, 1.35] | 1.04
|
|
230
|
+
* H9-match-flow-ttc[0] | Elite/Premium median TTC (emulator) | [0.55, 0.92] | 0.820
|
|
231
|
+
* H9-match-flow-ttc[1] | Free/Premium median TTC (emulator) | [1.05, 1.55] | 1.11
|
|
232
|
+
* H10-age-date-conversion[0] | 25-34/base date-funnel conv ratio | [1.05, 1.45] | 1.28
|
|
233
|
+
* H10-age-date-conversion[0] | 40+/base date-funnel conv ratio | [0.45, 0.88] | 0.604
|
|
234
|
+
*/
|
|
235
|
+
|
|
236
|
+
// ── SCALE ──
|
|
237
|
+
const SEED = "meetcute";
|
|
238
|
+
const NUM_USERS = 30_000;
|
|
239
|
+
const DATASET_START = "2026-01-01T00:00:00Z";
|
|
240
|
+
const DATASET_END = "2026-05-01T23:59:59Z";
|
|
241
|
+
const EVENTS_PER_DAY = 1.5;
|
|
242
|
+
const token = process.env.MP_TOKEN || "your-mixpanel-token";
|
|
243
|
+
|
|
244
|
+
const chance = u.initChance(SEED);
|
|
245
|
+
|
|
246
|
+
// ── KNOBS (tweak these to reshape stories) ──
|
|
247
|
+
const PHOTO_SWEET_MIN = 2;
|
|
248
|
+
const PHOTO_SWEET_MAX = 5;
|
|
249
|
+
const PHOTO_OVER_THRESHOLD = 6;
|
|
250
|
+
const PHOTO_OVER_SCORE_FACTOR = 0.65;
|
|
251
|
+
|
|
252
|
+
const SUNDAY_EVENING_CLONES = 5;
|
|
253
|
+
const SUNDAY_DAYTIME_CLONES = 2;
|
|
254
|
+
|
|
255
|
+
const SUPER_LIKE_MATCH_CLONES = 3;
|
|
256
|
+
|
|
257
|
+
const PREMIUM_MATCH_MULT = 2;
|
|
258
|
+
const ELITE_MATCH_MULT = 4;
|
|
259
|
+
|
|
260
|
+
const GHOSTING_WINDOW_HOURS = 48;
|
|
261
|
+
const GHOSTING_DROP_LIKELIHOOD = 80;
|
|
262
|
+
|
|
263
|
+
const BIO_PROMPT_THRESHOLD = 3;
|
|
264
|
+
const BIO_PROMPT_DATE_CLONE_MULT = 3;
|
|
265
|
+
|
|
266
|
+
const VDAY_WINDOW_START_DAY = 58;
|
|
267
|
+
const VDAY_WINDOW_END_DAY = 63;
|
|
268
|
+
const VDAY_SIGNUP_CLONES = 2;
|
|
269
|
+
const VDAY_UPGRADE_CLONES = 4;
|
|
270
|
+
|
|
271
|
+
const MILESTONE_WINDOW_DAYS = 14;
|
|
272
|
+
const RETENTION_CUTOFF_DAYS = 30;
|
|
273
|
+
const RETENTION_TARGET_PCT = 0.3;
|
|
274
|
+
const OFFAPP_DROP_LIKELIHOOD = 80;
|
|
275
|
+
|
|
276
|
+
const FUNNEL_TTC_ELITE = 0.71;
|
|
277
|
+
const FUNNEL_TTC_FREE = 1.4;
|
|
278
|
+
|
|
279
|
+
const AGE_CONV_BOOST = 1.3;
|
|
280
|
+
const AGE_CONV_DROP = 0.6;
|
|
281
|
+
|
|
282
|
+
// ── HELPER FUNCTIONS ──
|
|
283
|
+
function handleFunnelPreHooks(record, meta) {
|
|
284
|
+
// H10: Age range affects date conversion — 25-34 +30%, 40+ -40%
|
|
285
|
+
const isDateFunnel = meta.funnel?.sequence?.includes("date scheduled");
|
|
286
|
+
if (isDateFunnel) {
|
|
287
|
+
const age = meta.profile?.age_range;
|
|
288
|
+
if (age === "25-29" || age === "30-34") {
|
|
289
|
+
record.conversionRate = Math.min(95, Math.round(record.conversionRate * AGE_CONV_BOOST));
|
|
290
|
+
} else if (age === "40+") {
|
|
291
|
+
record.conversionRate = Math.round(record.conversionRate * AGE_CONV_DROP);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return record;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function handleFunnelPostHooks(record, meta) {
|
|
298
|
+
// H9: Match Flow TTC scaled by subscription tier. Scoped to the Match
|
|
299
|
+
// Flow funnel only — the documented story (and the Mixpanel report it
|
|
300
|
+
// teaches) is Match Flow median TTC; the v1.5 hook stretched EVERY
|
|
301
|
+
// funnel, leaking an undocumented tier-speed pattern into
|
|
302
|
+
// Onboarding/Date Funnel/Monetization.
|
|
303
|
+
if (meta?.funnel?.name !== "Match Flow") return record;
|
|
304
|
+
const segment = meta?.profile?.subscription;
|
|
305
|
+
if (Array.isArray(record) && record.length > 1) {
|
|
306
|
+
const factor = (
|
|
307
|
+
segment === "Elite" ? FUNNEL_TTC_ELITE :
|
|
308
|
+
segment === "Free" ? FUNNEL_TTC_FREE :
|
|
309
|
+
1.0
|
|
310
|
+
);
|
|
311
|
+
if (factor !== 1.0) {
|
|
312
|
+
for (let i = 1; i < record.length; i++) {
|
|
313
|
+
const prev = dayjs(record[i - 1].time);
|
|
314
|
+
const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
|
|
315
|
+
record[i].time = prev.add(newGap, "milliseconds").toISOString();
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return record;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function handleEverythingHooks(record, meta) {
|
|
323
|
+
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
324
|
+
const VDAY_WINDOW_START = datasetStart.add(VDAY_WINDOW_START_DAY, "days");
|
|
325
|
+
const VDAY_WINDOW_END = datasetStart.add(VDAY_WINDOW_END_DAY, "days");
|
|
326
|
+
const events = record;
|
|
327
|
+
if (!events || events.length === 0) return record;
|
|
328
|
+
|
|
329
|
+
const profile = meta.profile || {};
|
|
330
|
+
|
|
331
|
+
events.forEach(e => {
|
|
332
|
+
if (profile.subscription) e.subscription = profile.subscription;
|
|
333
|
+
// pin Platform per-user — the engine draws super props per-event,
|
|
334
|
+
// which gives one user mixed platforms (profile key is capital-P;
|
|
335
|
+
// the v1.5 lowercase read was dead code)
|
|
336
|
+
if (profile.Platform) e.Platform = profile.Platform;
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
let photoUploadCount = 0;
|
|
340
|
+
let promptAnsweredCount = 0;
|
|
341
|
+
let hasBioUpdated = false;
|
|
342
|
+
const matchEvents = [];
|
|
343
|
+
const messageSentEvents = [];
|
|
344
|
+
const superLikeEvents = [];
|
|
345
|
+
let hasPhoneExchangedEarly = false;
|
|
346
|
+
let hasDateScheduledEarly = false;
|
|
347
|
+
let firstEventTime = null;
|
|
348
|
+
|
|
349
|
+
events.forEach(event => {
|
|
350
|
+
if (!firstEventTime || dayjs(event.time).isBefore(dayjs(firstEventTime))) {
|
|
351
|
+
firstEventTime = event.time;
|
|
352
|
+
}
|
|
353
|
+
if (event.event === "photo uploaded") photoUploadCount++;
|
|
354
|
+
if (event.event === "prompt answered") promptAnsweredCount++;
|
|
355
|
+
if (event.event === "bio updated") hasBioUpdated = true;
|
|
356
|
+
if (event.event === "match received") matchEvents.push(event);
|
|
357
|
+
if (event.event === "message sent") messageSentEvents.push(event);
|
|
358
|
+
if (event.event === "swipe right" && event.is_super_like === true) superLikeEvents.push(event);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
if (firstEventTime) {
|
|
362
|
+
const earlyWindow = dayjs(firstEventTime).add(MILESTONE_WINDOW_DAYS, "days");
|
|
363
|
+
events.forEach(event => {
|
|
364
|
+
const t = dayjs(event.time);
|
|
365
|
+
if (t.isBefore(earlyWindow)) {
|
|
366
|
+
if (event.event === "phone number exchanged") hasPhoneExchangedEarly = true;
|
|
367
|
+
if (event.event === "date scheduled") hasDateScheduledEarly = true;
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// H1: PHOTO MAGIC NUMBER (sweet 2-5 photos → clone 2-4 extra matches)
|
|
373
|
+
// Over-6 score reduction is applied AT THE END of this hook so it also
|
|
374
|
+
// affects matches injected by H4 (premium boost).
|
|
375
|
+
if (photoUploadCount >= PHOTO_SWEET_MIN && photoUploadCount <= PHOTO_SWEET_MAX && matchEvents.length > 0) {
|
|
376
|
+
const matchTemplate = matchEvents[0];
|
|
377
|
+
matchEvents.forEach(m => {
|
|
378
|
+
const extras = chance.integer({ min: 2, max: 4 });
|
|
379
|
+
for (let i = 0; i < extras; i++) {
|
|
380
|
+
events.push({
|
|
381
|
+
...matchTemplate,
|
|
382
|
+
time: dayjs(m.time).add(chance.integer({ min: 1, max: 180 }), "minutes").toISOString(),
|
|
383
|
+
user_id: m.user_id,
|
|
384
|
+
match_score: chance.integer({ min: 60, max: 98 }),
|
|
385
|
+
// engine stamps insert_id at generation — clones need fresh
|
|
386
|
+
// ids or Mixpanel's $insert_id dedupe silently eats them
|
|
387
|
+
insert_id: chance.guid(),
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// H2: WEEKEND SWIPE SURGE — Sunday swipes get heavy cloning
|
|
394
|
+
// to overcome the soup DOW weight deficit. Evening swipes (18-23)
|
|
395
|
+
// get 5 clones; daytime Sunday swipes get 2 clones.
|
|
396
|
+
// No flag — discover via day-of-week chart.
|
|
397
|
+
for (let idx = events.length - 1; idx >= 0; idx--) {
|
|
398
|
+
const event = events[idx];
|
|
399
|
+
if (event.event === "swipe right") {
|
|
400
|
+
const dow = new Date(event.time).getUTCDay();
|
|
401
|
+
if (dow === 0) {
|
|
402
|
+
const hr = new Date(event.time).getUTCHours();
|
|
403
|
+
const clones = (hr >= 18 && hr <= 23) ? SUNDAY_EVENING_CLONES : SUNDAY_DAYTIME_CLONES;
|
|
404
|
+
const etime = dayjs(event.time);
|
|
405
|
+
for (let c = 0; c < clones; c++) {
|
|
406
|
+
events.push({
|
|
407
|
+
...event,
|
|
408
|
+
time: etime.add(chance.integer({ min: 1, max: 60 }), "minutes").toISOString(),
|
|
409
|
+
user_id: event.user_id,
|
|
410
|
+
insert_id: chance.guid(),
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// H3: SUPER-LIKE EFFECT — clone 3 extra match events per
|
|
418
|
+
// super-like, near in time. No flag — discover via funnel
|
|
419
|
+
// "swipe right where is_super_like=true" → "match received".
|
|
420
|
+
if (superLikeEvents.length > 0) {
|
|
421
|
+
const matchTemplate = matchEvents[0] || events[0];
|
|
422
|
+
superLikeEvents.forEach(sle => {
|
|
423
|
+
for (let i = 0; i < SUPER_LIKE_MATCH_CLONES; i++) {
|
|
424
|
+
events.push({
|
|
425
|
+
...matchTemplate,
|
|
426
|
+
event: "match received",
|
|
427
|
+
time: dayjs(sle.time).add(chance.integer({ min: 5, max: 120 }), "minutes").toISOString(),
|
|
428
|
+
user_id: sle.user_id,
|
|
429
|
+
match_score: chance.integer({ min: 70, max: 99 }),
|
|
430
|
+
insert_id: chance.guid(),
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// H5: GHOSTING CHURN — users with match but no message within
|
|
437
|
+
// 48hrs lose 80% of post-match events. No flag.
|
|
438
|
+
// (runs BEFORE premium boost so injected premium matches survive)
|
|
439
|
+
if (matchEvents.length > 0) {
|
|
440
|
+
let hasTimely = false;
|
|
441
|
+
for (const m of matchEvents) {
|
|
442
|
+
const matchTime = dayjs(m.time);
|
|
443
|
+
const deadline = matchTime.add(GHOSTING_WINDOW_HOURS, "hours");
|
|
444
|
+
for (const msg of messageSentEvents) {
|
|
445
|
+
const msgTime = dayjs(msg.time);
|
|
446
|
+
if (msgTime.isAfter(matchTime) && msgTime.isBefore(deadline)) {
|
|
447
|
+
hasTimely = true;
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
if (hasTimely) break;
|
|
452
|
+
}
|
|
453
|
+
if (!hasTimely) {
|
|
454
|
+
const earliestMatch = matchEvents.reduce((min, m) =>
|
|
455
|
+
dayjs(m.time).isBefore(dayjs(min.time)) ? m : min
|
|
456
|
+
);
|
|
457
|
+
const churnAfter = dayjs(earliestMatch.time);
|
|
458
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
459
|
+
if (dayjs(events[i].time).isAfter(churnAfter) && chance.bool({ likelihood: GHOSTING_DROP_LIKELIHOOD })) {
|
|
460
|
+
events.splice(i, 1);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// H4: PREMIUM MATCH BOOST — Premium 2x, Elite 4x match events.
|
|
467
|
+
// Elite users also get profile-viewed events injected (see-who-liked-you).
|
|
468
|
+
// Reads subscription from profile. Runs AFTER ghosting churn so
|
|
469
|
+
// injected matches are not culled.
|
|
470
|
+
const sub = profile.subscription;
|
|
471
|
+
if ((sub === "Premium" || sub === "Elite") && matchEvents.length > 0) {
|
|
472
|
+
// Count surviving match events post-churn
|
|
473
|
+
const survivingMatches = events.filter(e => e.event === "match received");
|
|
474
|
+
const baseCount = survivingMatches.length || 1;
|
|
475
|
+
const targetMultiplier = sub === "Elite" ? ELITE_MATCH_MULT : PREMIUM_MATCH_MULT;
|
|
476
|
+
const toAdd = Math.max(0, baseCount * targetMultiplier - baseCount);
|
|
477
|
+
const matchTemplate = matchEvents[0];
|
|
478
|
+
for (let i = 0; i < toAdd; i++) {
|
|
479
|
+
const sourceMatch = survivingMatches[i % survivingMatches.length] || matchTemplate;
|
|
480
|
+
events.push({
|
|
481
|
+
...matchTemplate,
|
|
482
|
+
time: dayjs(sourceMatch.time).add(chance.integer({ min: 10, max: 240 }), "minutes").toISOString(),
|
|
483
|
+
user_id: sourceMatch.user_id,
|
|
484
|
+
match_score: chance.integer({ min: 65, max: 99 }),
|
|
485
|
+
insert_id: chance.guid(),
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
if (sub === "Elite") {
|
|
489
|
+
const viewTemplate = events.find(e => e.event === "profile viewed") || matchTemplate;
|
|
490
|
+
survivingMatches.forEach(m => {
|
|
491
|
+
events.push({
|
|
492
|
+
...viewTemplate,
|
|
493
|
+
event: "profile viewed",
|
|
494
|
+
time: dayjs(m.time).subtract(chance.integer({ min: 10, max: 120 }), "minutes").toISOString(),
|
|
495
|
+
user_id: m.user_id,
|
|
496
|
+
viewer_source: "liked_you",
|
|
497
|
+
insert_id: chance.guid(),
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// H6: BIO + PROMPT POWER USERS — bio + 3+ prompts → 3 extra
|
|
504
|
+
// cloned date events per existing. No flag.
|
|
505
|
+
if (hasBioUpdated && promptAnsweredCount >= BIO_PROMPT_THRESHOLD) {
|
|
506
|
+
const dateEvents = events.filter(e => e.event === "date scheduled");
|
|
507
|
+
if (dateEvents.length > 0) {
|
|
508
|
+
const dateTemplate = dateEvents[0];
|
|
509
|
+
const venueTypes = ["coffee", "dinner", "drinks", "activity", "virtual"];
|
|
510
|
+
for (let i = 0; i < dateEvents.length * BIO_PROMPT_DATE_CLONE_MULT; i++) {
|
|
511
|
+
const sourceDate = dateEvents[i % dateEvents.length];
|
|
512
|
+
events.push({
|
|
513
|
+
...dateTemplate,
|
|
514
|
+
time: dayjs(sourceDate.time).add(chance.integer({ min: 1, max: 72 }), "hours").toISOString(),
|
|
515
|
+
user_id: sourceDate.user_id,
|
|
516
|
+
venue_type: chance.pickone(venueTypes),
|
|
517
|
+
insert_id: chance.guid(),
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// H7: VALENTINE'S DAY SPIKE — clone profile-created events during
|
|
524
|
+
// days 58-63 (3x volume), plus clone premium-upgrade events 5x. No flag.
|
|
525
|
+
const vdaySignups = events.filter(e =>
|
|
526
|
+
e.event === "profile created" &&
|
|
527
|
+
dayjs(e.time).isAfter(VDAY_WINDOW_START) &&
|
|
528
|
+
dayjs(e.time).isBefore(VDAY_WINDOW_END)
|
|
529
|
+
);
|
|
530
|
+
vdaySignups.forEach(signup => {
|
|
531
|
+
for (let i = 0; i < VDAY_SIGNUP_CLONES; i++) {
|
|
532
|
+
events.push({
|
|
533
|
+
...signup,
|
|
534
|
+
time: dayjs(signup.time).add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
|
|
535
|
+
user_id: signup.user_id,
|
|
536
|
+
insert_id: chance.guid(),
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
const vdayUpgrades = events.filter(e =>
|
|
542
|
+
e.event === "premium upgrade" &&
|
|
543
|
+
dayjs(e.time).isAfter(VDAY_WINDOW_START) &&
|
|
544
|
+
dayjs(e.time).isBefore(VDAY_WINDOW_END)
|
|
545
|
+
);
|
|
546
|
+
if (vdayUpgrades.length > 0) {
|
|
547
|
+
const upgradeTemplate = vdayUpgrades[0];
|
|
548
|
+
vdayUpgrades.forEach(upgrade => {
|
|
549
|
+
for (let i = 0; i < VDAY_UPGRADE_CLONES; i++) {
|
|
550
|
+
events.push({
|
|
551
|
+
...upgradeTemplate,
|
|
552
|
+
time: dayjs(upgrade.time).add(chance.integer({ min: 1, max: 24 }), "hours").toISOString(),
|
|
553
|
+
user_id: upgrade.user_id,
|
|
554
|
+
plan: upgrade.plan,
|
|
555
|
+
price_usd: upgrade.price_usd,
|
|
556
|
+
insert_id: chance.guid(),
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// H1b: PHOTO MAGIC NUMBER — over-6 score reduction (applied LAST so
|
|
563
|
+
// it also affects matches injected by H4 premium boost).
|
|
564
|
+
if (photoUploadCount >= PHOTO_OVER_THRESHOLD) {
|
|
565
|
+
events.forEach(e => {
|
|
566
|
+
if (e.event === "match received" && typeof e.match_score === "number") {
|
|
567
|
+
e.match_score = Math.max(20, Math.round(e.match_score * PHOTO_OVER_SCORE_FACTOR));
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// H8: OFF-APP RETENTION — users with phone-exchanged or
|
|
573
|
+
// date-scheduled in first 14 days get extra cloned app-open + swipe
|
|
574
|
+
// events past day 30. Non-milestone users lose 80% of post-day-30
|
|
575
|
+
// events. No flag.
|
|
576
|
+
if (firstEventTime) {
|
|
577
|
+
const day30 = dayjs(firstEventTime).add(RETENTION_CUTOFF_DAYS, "days");
|
|
578
|
+
const hasEarlyMilestone = hasPhoneExchangedEarly || hasDateScheduledEarly;
|
|
579
|
+
if (hasEarlyMilestone) {
|
|
580
|
+
const appOpenedTemplate = events.find(e => e.event === "app opened") || events[0];
|
|
581
|
+
const swipeTemplate = events.find(e => e.event === "swipe right") || events[0];
|
|
582
|
+
const postDay30Events = events.filter(e => dayjs(e.time).isAfter(day30));
|
|
583
|
+
if (postDay30Events.length < events.length * RETENTION_TARGET_PCT) {
|
|
584
|
+
const retentionCount = Math.floor(events.length * RETENTION_TARGET_PCT);
|
|
585
|
+
for (let i = 0; i < retentionCount; i++) {
|
|
586
|
+
const daysAfter = chance.integer({ min: 1, max: 60 });
|
|
587
|
+
const template = chance.bool({ likelihood: 50 }) ? appOpenedTemplate : swipeTemplate;
|
|
588
|
+
events.push({
|
|
589
|
+
...template,
|
|
590
|
+
time: day30.add(daysAfter, "days").add(chance.integer({ min: 0, max: 23 }), "hours").toISOString(),
|
|
591
|
+
user_id: template.user_id,
|
|
592
|
+
insert_id: chance.guid(),
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
} else {
|
|
597
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
598
|
+
if (dayjs(events[i].time).isAfter(day30) && chance.bool({ likelihood: OFFAPP_DROP_LIKELIHOOD })) {
|
|
599
|
+
events.splice(i, 1);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return record;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ── CONFIG ──
|
|
609
|
+
/** @type {Config} */
|
|
610
|
+
const config = {
|
|
611
|
+
version: 2,
|
|
612
|
+
seed: SEED,
|
|
613
|
+
datasetStart: DATASET_START,
|
|
614
|
+
datasetEnd: DATASET_END,
|
|
615
|
+
avgEventsPerUserPerDay: EVENTS_PER_DAY,
|
|
616
|
+
numUsers: NUM_USERS,
|
|
617
|
+
format: "json",
|
|
618
|
+
gzip: true,
|
|
619
|
+
credentials: {
|
|
620
|
+
token,
|
|
621
|
+
},
|
|
622
|
+
switches: {
|
|
623
|
+
hasSessionIds: true,
|
|
624
|
+
alsoInferFunnels: false,
|
|
625
|
+
hasLocation: true,
|
|
626
|
+
hasAndroidDevices: true,
|
|
627
|
+
hasIOSDevices: true,
|
|
628
|
+
hasDesktopDevices: false,
|
|
629
|
+
hasBrowser: false,
|
|
630
|
+
hasCampaigns: false,
|
|
631
|
+
isAnonymous: false,
|
|
632
|
+
hasAdSpend: false,
|
|
633
|
+
hasAvatar: true,
|
|
634
|
+
},
|
|
635
|
+
identity: {
|
|
636
|
+
avgDevicePerUser: 2,
|
|
637
|
+
},
|
|
638
|
+
concurrency: 1,
|
|
639
|
+
writeToDisk: false,
|
|
640
|
+
soup: "growth",
|
|
641
|
+
|
|
642
|
+
events: [
|
|
643
|
+
{
|
|
644
|
+
event: "profile created",
|
|
645
|
+
weight: 1,
|
|
646
|
+
isFirstEvent: true,
|
|
647
|
+
isAuthEvent: true,
|
|
648
|
+
properties: {
|
|
649
|
+
age_range: ["18-24", "25-29", "30-34", "35-39", "40+"],
|
|
650
|
+
gender: ["Male", "Male", "Female", "Female", "Non-binary"],
|
|
651
|
+
looking_for: ["Men", "Women", "Everyone"],
|
|
652
|
+
},
|
|
653
|
+
},
|
|
654
|
+
{
|
|
655
|
+
event: "photo uploaded",
|
|
656
|
+
weight: 12,
|
|
657
|
+
isStrictEvent: false,
|
|
658
|
+
properties: {
|
|
659
|
+
photo_number: u.weighNumRange(1, 6, 0.5, 2),
|
|
660
|
+
has_face: [true, true, true, true, false],
|
|
661
|
+
},
|
|
662
|
+
},
|
|
663
|
+
{
|
|
664
|
+
event: "bio updated",
|
|
665
|
+
weight: 2,
|
|
666
|
+
properties: {
|
|
667
|
+
bio_length: u.weighNumRange(10, 500, 0.4, 120),
|
|
668
|
+
},
|
|
669
|
+
},
|
|
670
|
+
{
|
|
671
|
+
event: "prompt answered",
|
|
672
|
+
weight: 3,
|
|
673
|
+
properties: {
|
|
674
|
+
prompt_type: ["icebreaker", "opinion", "hypothetical", "personal", "creative"],
|
|
675
|
+
answer_length: u.weighNumRange(10, 300, 0.4, 80),
|
|
676
|
+
},
|
|
677
|
+
},
|
|
678
|
+
{
|
|
679
|
+
event: "swipe right",
|
|
680
|
+
weight: 10,
|
|
681
|
+
isStrictEvent: false,
|
|
682
|
+
properties: {
|
|
683
|
+
is_super_like: [false, false, false, false, false, false, false, false, false, true],
|
|
684
|
+
swipe_source: ["feed", "feed", "feed", "discover", "boost", "nearby"],
|
|
685
|
+
},
|
|
686
|
+
},
|
|
687
|
+
{
|
|
688
|
+
event: "swipe left",
|
|
689
|
+
weight: 8,
|
|
690
|
+
properties: {
|
|
691
|
+
swipe_source: ["feed", "feed", "feed", "discover", "boost", "nearby"],
|
|
692
|
+
},
|
|
693
|
+
},
|
|
694
|
+
{
|
|
695
|
+
event: "match received",
|
|
696
|
+
weight: 4,
|
|
697
|
+
isStrictEvent: false,
|
|
698
|
+
properties: {
|
|
699
|
+
match_score: u.weighNumRange(50, 100, 0.5, 75),
|
|
700
|
+
},
|
|
701
|
+
},
|
|
702
|
+
{
|
|
703
|
+
event: "message sent",
|
|
704
|
+
weight: 6,
|
|
705
|
+
isStrictEvent: false,
|
|
706
|
+
properties: {
|
|
707
|
+
message_length: u.weighNumRange(1, 500, 0.3, 40),
|
|
708
|
+
has_emoji: [false, false, true, true, true],
|
|
709
|
+
response_time_mins: u.weighNumRange(1, 1440, 0.3, 30),
|
|
710
|
+
},
|
|
711
|
+
},
|
|
712
|
+
{
|
|
713
|
+
event: "message received",
|
|
714
|
+
weight: 5,
|
|
715
|
+
properties: {
|
|
716
|
+
message_length: u.weighNumRange(1, 500, 0.3, 50),
|
|
717
|
+
},
|
|
718
|
+
},
|
|
719
|
+
{
|
|
720
|
+
event: "phone number exchanged",
|
|
721
|
+
weight: 1,
|
|
722
|
+
isStrictEvent: false,
|
|
723
|
+
properties: {
|
|
724
|
+
exchange_method: ["in_chat", "in_chat", "voice_call", "video_call"],
|
|
725
|
+
},
|
|
726
|
+
},
|
|
727
|
+
{
|
|
728
|
+
event: "date scheduled",
|
|
729
|
+
weight: 1,
|
|
730
|
+
isStrictEvent: false,
|
|
731
|
+
properties: {
|
|
732
|
+
venue_type: ["coffee", "dinner", "drinks", "activity", "virtual"],
|
|
733
|
+
},
|
|
734
|
+
},
|
|
735
|
+
{
|
|
736
|
+
event: "profile viewed",
|
|
737
|
+
weight: 5,
|
|
738
|
+
properties: {
|
|
739
|
+
viewer_source: ["feed", "discover", "liked_you", "nearby"],
|
|
740
|
+
},
|
|
741
|
+
},
|
|
742
|
+
{
|
|
743
|
+
event: "premium upgrade",
|
|
744
|
+
weight: 1,
|
|
745
|
+
isStrictEvent: false,
|
|
746
|
+
properties: {
|
|
747
|
+
plan: ["Premium", "Premium", "Premium", "Elite"],
|
|
748
|
+
price_usd: [14.99, 14.99, 14.99, 29.99],
|
|
749
|
+
},
|
|
750
|
+
},
|
|
751
|
+
{
|
|
752
|
+
event: "premium cancelled",
|
|
753
|
+
weight: 1,
|
|
754
|
+
isStrictEvent: true,
|
|
755
|
+
properties: {
|
|
756
|
+
cancel_reason: ["found_someone", "too_expensive", "not_enough_matches", "bad_experience", "taking_a_break"],
|
|
757
|
+
subscription_duration_days: u.weighNumRange(7, 365, 0.3, 30),
|
|
758
|
+
},
|
|
759
|
+
},
|
|
760
|
+
{
|
|
761
|
+
event: "boost activated",
|
|
762
|
+
weight: 2,
|
|
763
|
+
properties: {
|
|
764
|
+
boost_type: ["standard", "super"],
|
|
765
|
+
boost_duration_mins: [15, 30, 30, 60],
|
|
766
|
+
},
|
|
767
|
+
},
|
|
768
|
+
{
|
|
769
|
+
event: "report user",
|
|
770
|
+
weight: 1,
|
|
771
|
+
isStrictEvent: true,
|
|
772
|
+
properties: {
|
|
773
|
+
report_reason: ["fake_profile", "inappropriate_photos", "harassment", "spam", "underage"],
|
|
774
|
+
},
|
|
775
|
+
},
|
|
776
|
+
{
|
|
777
|
+
event: "app opened",
|
|
778
|
+
weight: 8,
|
|
779
|
+
isStrictEvent: false,
|
|
780
|
+
properties: {
|
|
781
|
+
session_duration_mins: u.weighNumRange(1, 120, 0.3, 8),
|
|
782
|
+
},
|
|
783
|
+
},
|
|
784
|
+
],
|
|
785
|
+
|
|
786
|
+
funnels: [
|
|
787
|
+
{
|
|
788
|
+
name: "Onboarding",
|
|
789
|
+
sequence: ["profile created", "photo uploaded", "swipe right"],
|
|
790
|
+
conversionRate: 75,
|
|
791
|
+
order: "sequential",
|
|
792
|
+
isFirstFunnel: true,
|
|
793
|
+
timeToConvert: 1,
|
|
794
|
+
weight: 3,
|
|
795
|
+
},
|
|
796
|
+
{
|
|
797
|
+
name: "Match Flow",
|
|
798
|
+
sequence: ["swipe right", "match received", "message sent"],
|
|
799
|
+
conversionRate: 50,
|
|
800
|
+
order: "sequential",
|
|
801
|
+
timeToConvert: 24,
|
|
802
|
+
weight: 6,
|
|
803
|
+
reentry: true,
|
|
804
|
+
},
|
|
805
|
+
{
|
|
806
|
+
name: "Date Funnel",
|
|
807
|
+
sequence: ["message sent", "phone number exchanged", "date scheduled"],
|
|
808
|
+
conversionRate: 25,
|
|
809
|
+
order: "sequential",
|
|
810
|
+
timeToConvert: 72,
|
|
811
|
+
weight: 3,
|
|
812
|
+
reentry: true,
|
|
813
|
+
},
|
|
814
|
+
{
|
|
815
|
+
name: "Monetization",
|
|
816
|
+
sequence: ["app opened", "boost activated", "premium upgrade"],
|
|
817
|
+
conversionRate: 20,
|
|
818
|
+
order: "sequential",
|
|
819
|
+
timeToConvert: 48,
|
|
820
|
+
weight: 2,
|
|
821
|
+
},
|
|
822
|
+
],
|
|
823
|
+
|
|
824
|
+
superProps: {
|
|
825
|
+
subscription: ["Free", "Free", "Free", "Premium", "Elite"],
|
|
826
|
+
Platform: ["ios", "ios", "android"],
|
|
827
|
+
},
|
|
828
|
+
|
|
829
|
+
userProps: {
|
|
830
|
+
subscription: ["Free", "Free", "Free", "Premium", "Elite"],
|
|
831
|
+
age_range: ["18-24", "25-29", "30-34", "35-39", "40+"],
|
|
832
|
+
gender: ["Male", "Male", "Female", "Female", "Non-binary"],
|
|
833
|
+
looking_for: ["Men", "Women", "Everyone"],
|
|
834
|
+
photo_count: u.weighNumRange(0, 8, 0.4, 3),
|
|
835
|
+
total_matches: u.weighNumRange(0, 200, 0.3, 15),
|
|
836
|
+
total_messages_sent: u.weighNumRange(0, 500, 0.3, 30),
|
|
837
|
+
profile_completeness: ["incomplete", "incomplete", "basic", "basic", "complete"],
|
|
838
|
+
Platform: ["ios", "ios", "android"],
|
|
839
|
+
},
|
|
840
|
+
|
|
841
|
+
scdProps: {
|
|
842
|
+
subscription_tier: {
|
|
843
|
+
values: ["Free", "Premium", "Elite"],
|
|
844
|
+
frequency: "month",
|
|
845
|
+
timing: "fuzzy",
|
|
846
|
+
max: 6,
|
|
847
|
+
},
|
|
848
|
+
},
|
|
849
|
+
|
|
850
|
+
groupKeys: [],
|
|
851
|
+
groupProps: {},
|
|
852
|
+
mirrorProps: {},
|
|
853
|
+
lookupTables: [],
|
|
854
|
+
|
|
855
|
+
hook(record, type, meta) {
|
|
856
|
+
if (type === "funnel-pre") return handleFunnelPreHooks(record, meta);
|
|
857
|
+
if (type === "funnel-post") return handleFunnelPostHooks(record, meta);
|
|
858
|
+
if (type === "everything") return handleEverythingHooks(record, meta);
|
|
859
|
+
return record;
|
|
860
|
+
},
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
// ── STORIES (v1.6 machine-checkable contract — one story per numbered hook) ──
|
|
864
|
+
// Generate: node scripts/verify-runner.mjs dungeons/vertical/dating/dating.js verify-dating
|
|
865
|
+
// Evaluate: node scripts/verify-stories.mjs dungeons/vertical/dating/dating.js --data-prefix verify-dating
|
|
866
|
+
//
|
|
867
|
+
// Measurement doctrine for this dungeon:
|
|
868
|
+
// - Deletions-only logic (H5 ghosting, H8 off-app drop, the silent future-time
|
|
869
|
+
// guard) means every hook-time cohort classification is only ONE-SIDED
|
|
870
|
+
// recoverable from output counts: hook-time count >= output count. Cohort
|
|
871
|
+
// definitions below are chosen so output-side membership IMPLIES hook-time
|
|
872
|
+
// membership (e.g. an output-visible timely match→message pair proves the
|
|
873
|
+
// user was never ghosted); the reverse-direction contamination lands in the
|
|
874
|
+
// control arm and biases every ratio TOWARD null, never away from it.
|
|
875
|
+
// - Photo count / super-like count / prompt count correlate with total user
|
|
876
|
+
// activity, so raw cohort-vs-cohort comparisons of clone-lifted events are
|
|
877
|
+
// activity-confounded BY CONSTRUCTION. Count-lift assertions therefore use
|
|
878
|
+
// activity-normalized double ratios (matches-per-swipe, dates-per-message)
|
|
879
|
+
// where the proxy event is untouched by the hook under test.
|
|
880
|
+
|
|
881
|
+
const EV = `read_json_auto('{{PREFIX}}-EVENTS*.json', sample_size=-1, union_by_name=true)`;
|
|
882
|
+
const US = `read_json_auto('{{PREFIX}}-USERS*.json', sample_size=-1, union_by_name=true)`;
|
|
883
|
+
// identity prelude: avgDevicePerUser 2 + profile created is isAuthEvent+isFirstEvent,
|
|
884
|
+
// so born users auth on their first event; the device-pool resolve is
|
|
885
|
+
// belt-and-braces for any device-only edge. ::VARCHAR casts — user_id sniffs
|
|
886
|
+
// as UUID, device_id as VARCHAR; DuckDB refuses to coalesce mixed types.
|
|
887
|
+
const ID_CTE = `
|
|
888
|
+
us AS (SELECT * FROM ${US}),
|
|
889
|
+
dm AS (SELECT unnest("anonymousIds") AS device_id, distinct_id FROM us),
|
|
890
|
+
ev AS (
|
|
891
|
+
SELECT coalesce(m.distinct_id::VARCHAR, e.user_id::VARCHAR, e.device_id::VARCHAR) AS uid,
|
|
892
|
+
e.time::TIMESTAMP AS t, e.*
|
|
893
|
+
FROM ${EV} e
|
|
894
|
+
LEFT JOIN dm m ON e.device_id = m.device_id
|
|
895
|
+
)`;
|
|
896
|
+
|
|
897
|
+
// per-user counts used by H1/H3 (photos/matches/swipes/super-likes)
|
|
898
|
+
const PU_CTE = `
|
|
899
|
+
pu AS (
|
|
900
|
+
SELECT e.uid,
|
|
901
|
+
count(*) FILTER (WHERE e.event = 'photo uploaded') AS photos,
|
|
902
|
+
count(*) FILTER (WHERE e.event = 'match received') AS matches,
|
|
903
|
+
count(*) FILTER (WHERE e.event = 'swipe right') AS swipes,
|
|
904
|
+
count(*) FILTER (WHERE e.event = 'swipe right' AND e.is_super_like = true) AS sls
|
|
905
|
+
FROM ev e GROUP BY 1
|
|
906
|
+
)`;
|
|
907
|
+
|
|
908
|
+
// output-visible timely pair (match → message within the ghosting window)
|
|
909
|
+
// proves the user was NOT ghosted at hook time: H5 deletes but never adds,
|
|
910
|
+
// so a surviving pair must have existed when H5 evaluated it.
|
|
911
|
+
const TP_CTE = `
|
|
912
|
+
tp AS (
|
|
913
|
+
SELECT DISTINCT a.uid FROM ev a
|
|
914
|
+
JOIN ev b ON b.uid = a.uid AND b.event = 'message sent'
|
|
915
|
+
WHERE a.event = 'match received'
|
|
916
|
+
AND b.t > a.t AND b.t < a.t + INTERVAL ${GHOSTING_WINDOW_HOURS} HOUR
|
|
917
|
+
)`;
|
|
918
|
+
|
|
919
|
+
// output-visible early milestone (phone/date within the first
|
|
920
|
+
// MILESTONE_WINDOW_DAYS of the user's first event) proves hook-time milestone
|
|
921
|
+
// status: H6 clones dates only FORWARD from existing dates (+1-72h), so a
|
|
922
|
+
// clone inside the early window implies its source was too.
|
|
923
|
+
const MS_CTE = `
|
|
924
|
+
fe AS (SELECT uid, min(t) AS f FROM ev GROUP BY 1),
|
|
925
|
+
ms AS (
|
|
926
|
+
SELECT DISTINCT e.uid FROM ev e JOIN fe ON fe.uid = e.uid
|
|
927
|
+
WHERE e.event IN ('phone number exchanged', 'date scheduled')
|
|
928
|
+
AND e.t < fe.f + INTERVAL ${MILESTONE_WINDOW_DAYS} DAY
|
|
929
|
+
)`;
|
|
930
|
+
|
|
931
|
+
// knob-derived timestamps (dataset starts ${DATASET_START})
|
|
932
|
+
const DS = dayjs.utc(DATASET_START);
|
|
933
|
+
const TS = (d) => d.format("YYYY-MM-DD HH:mm:ss");
|
|
934
|
+
const VDAY_START_TS = TS(DS.add(VDAY_WINDOW_START_DAY, "day"));
|
|
935
|
+
const VDAY_END_TS = TS(DS.add(VDAY_WINDOW_END_DAY, "day"));
|
|
936
|
+
const VDAY_DAYS = VDAY_WINDOW_END_DAY - VDAY_WINDOW_START_DAY;
|
|
937
|
+
// flanking baseline: 14 days before the window + 14 days after, with the
|
|
938
|
+
// post-window flank starting +3 days after window end so the 48h clone
|
|
939
|
+
// spill (V-Day clones are stamped source+1..48h) cannot inflate the baseline
|
|
940
|
+
const VDAY_BASE_PRE_TS = TS(DS.add(VDAY_WINDOW_START_DAY - 14, "day"));
|
|
941
|
+
const VDAY_BASE_POST_START_TS = TS(DS.add(VDAY_WINDOW_END_DAY + 3, "day"));
|
|
942
|
+
const VDAY_BASE_POST_END_TS = TS(DS.add(VDAY_WINDOW_END_DAY + 3 + 14, "day"));
|
|
943
|
+
// H8 clean-cohort birth cutoff: retention clones land at day30 + U[1,60] days,
|
|
944
|
+
// so only users born before day RETENTION_CUTOFF_DAYS have their entire clone
|
|
945
|
+
// support inside the 121-day window (30 + 30 + 60 < 121) — later-born
|
|
946
|
+
// milestone users lose clones to the silent future-time guard.
|
|
947
|
+
const H8_EARLYBORN_TS = TS(DS.add(RETENTION_CUTOFF_DAYS, "day"));
|
|
948
|
+
|
|
949
|
+
const cellsOf = (rows, key) => Object.fromEntries((rows || []).map((r) => [r[key], r]));
|
|
950
|
+
|
|
951
|
+
export const stories = [
|
|
952
|
+
{
|
|
953
|
+
id: "H1-photo-magic-number",
|
|
954
|
+
hook: "H1",
|
|
955
|
+
archetype: "frequency-sweet-spot",
|
|
956
|
+
narrative:
|
|
957
|
+
"Sweet-spot uploaders (2-5 photos at hook time) get 2-4 cloned matches per existing match " +
|
|
958
|
+
"(expected multiplier 1+E[U{2..4}] = 4x); heavy uploaders (6+) keep their match volume but every " +
|
|
959
|
+
"match_score is cut to 0.65x at the END of the everything hook, so the cut also covers H3/H4-injected " +
|
|
960
|
+
"matches. Score assertion compares 6+ vs 0-1 uploaders (NOT vs sweet — sweet users' H1 clones redraw " +
|
|
961
|
+
"score from U[60,98], a different pool than organic, which would confound the read): both arms carry " +
|
|
962
|
+
"organic+H3/H4 score mixtures, so the ratio reads the 0.65 knob within composition tolerance " +
|
|
963
|
+
"[0.60, 0.73]. The count assertion is an activity-normalized double ratio (matches-per-user over " +
|
|
964
|
+
"swipes-per-user, sweet vs 0-1) restricted to Free-tier users (removes the H4 tier multiplier) with " +
|
|
965
|
+
"H3's additive term subtracted arithmetically (adjusted matches = matches - 3*super_likes; " +
|
|
966
|
+
"conditioning on super_likes = 0 instead would select the near-inactive tail, P(no SL) ~ 0.9^swipes, " +
|
|
967
|
+
"and starve the sweet cell). Hook-time DD = 1+E[U{2..4}] = 4, band [2.8, 5.2] (±30% for H5/H8 " +
|
|
968
|
+
"dilution and one-sided photo-count contamination in both directions).",
|
|
969
|
+
assertions: [
|
|
970
|
+
{
|
|
971
|
+
breakdown: {
|
|
972
|
+
type: "duckdb",
|
|
973
|
+
sql: `WITH ${ID_CTE},
|
|
974
|
+
ph AS (SELECT uid, count(*) FILTER (WHERE event = 'photo uploaded') AS photos FROM ev GROUP BY 1),
|
|
975
|
+
coh AS (SELECT uid, CASE WHEN photos >= ${PHOTO_OVER_THRESHOLD} THEN 'over' WHEN photos <= 1 THEN 'low' END AS grp FROM ph)
|
|
976
|
+
SELECT c.grp, count(DISTINCT c.uid)::BIGINT AS user_count, avg(e.match_score) AS avg_score
|
|
977
|
+
FROM coh c JOIN ev e ON e.uid = c.uid AND e.event = 'match received'
|
|
978
|
+
WHERE c.grp IS NOT NULL GROUP BY 1`,
|
|
979
|
+
},
|
|
980
|
+
select: {
|
|
981
|
+
over: { where: { grp: "over" } },
|
|
982
|
+
low: { where: { grp: "low" } },
|
|
983
|
+
},
|
|
984
|
+
expect: { metric: "over.avg_score / low.avg_score", op: "between", target: [0.6, 0.73] },
|
|
985
|
+
minCohort: 250,
|
|
986
|
+
},
|
|
987
|
+
{
|
|
988
|
+
breakdown: {
|
|
989
|
+
type: "duckdb",
|
|
990
|
+
sql: `WITH ${ID_CTE},
|
|
991
|
+
${PU_CTE},
|
|
992
|
+
j AS (
|
|
993
|
+
SELECT p.*, CASE WHEN p.photos BETWEEN ${PHOTO_SWEET_MIN} AND ${PHOTO_SWEET_MAX} THEN 'sweet'
|
|
994
|
+
WHEN p.photos <= 1 THEN 'low' END AS arm
|
|
995
|
+
FROM pu p JOIN us u ON u.distinct_id::VARCHAR = p.uid
|
|
996
|
+
WHERE u.subscription = 'Free'
|
|
997
|
+
)
|
|
998
|
+
SELECT arm, count(*)::BIGINT AS user_count,
|
|
999
|
+
avg(matches - ${SUPER_LIKE_MATCH_CLONES} * sls) AS avg_adj_m, avg(swipes) AS avg_s
|
|
1000
|
+
FROM j WHERE arm IS NOT NULL AND swipes > 0 GROUP BY 1`,
|
|
1001
|
+
},
|
|
1002
|
+
assert: (rows) => {
|
|
1003
|
+
const by = cellsOf(rows, "arm");
|
|
1004
|
+
const s = by.sweet, l = by.low;
|
|
1005
|
+
if (!s || !l || Number(s.user_count) < 150 || Number(l.user_count) < 150) {
|
|
1006
|
+
return { verdict: "WEAK", detail: `cohort too small: sweet=${s?.user_count ?? 0} low=${l?.user_count ?? 0}` };
|
|
1007
|
+
}
|
|
1008
|
+
if (!(l.avg_adj_m > 0 && l.avg_s > 0 && s.avg_s > 0)) {
|
|
1009
|
+
return { verdict: "NONE", detail: "degenerate baseline (zero adjusted matches or swipes in an arm)" };
|
|
1010
|
+
}
|
|
1011
|
+
const dd = (s.avg_adj_m / l.avg_adj_m) / (s.avg_s / l.avg_s);
|
|
1012
|
+
const detail = `DD=${dd.toFixed(3)} (adj-match ratio ${(s.avg_adj_m / l.avg_adj_m).toFixed(2)} ÷ swipe ratio ${(s.avg_s / l.avg_s).toFixed(2)}; knob 1+E[2..4]=4; sweet n=${s.user_count}, low n=${l.user_count})`;
|
|
1013
|
+
if (dd >= 2.8 && dd <= 5.2) return { verdict: "NAILED", detail };
|
|
1014
|
+
if (dd >= 2.3 && dd <= 6.0) return { verdict: "STRONG", detail };
|
|
1015
|
+
return { verdict: dd > 1 ? "WEAK" : "INVERSE", detail };
|
|
1016
|
+
},
|
|
1017
|
+
},
|
|
1018
|
+
],
|
|
1019
|
+
},
|
|
1020
|
+
{
|
|
1021
|
+
id: "H2-sunday-swipe-surge",
|
|
1022
|
+
hook: "H2",
|
|
1023
|
+
archetype: "temporal-inflection",
|
|
1024
|
+
narrative:
|
|
1025
|
+
"Every Sunday swipe-right is cloned in place: evening swipes (18-23 UTC) get " +
|
|
1026
|
+
`${SUNDAY_EVENING_CLONES} clones (x${SUNDAY_EVENING_CLONES + 1}), the rest get ${SUNDAY_DAYTIME_CLONES} ` +
|
|
1027
|
+
`(x${SUNDAY_DAYTIME_CLONES + 1}), so the Sunday multiplier is a soup-hour-weighted mix in ` +
|
|
1028
|
+
"[3, 6] on top of an unknown-but-bounded soup DOW baseline (growth soup keeps per-DOW weights within " +
|
|
1029
|
+
"roughly [0.6, 1.2] of the mean, and the hook exists precisely because Sunday sits at the bottom of " +
|
|
1030
|
+
"that range). Assertion: Sunday must be the strict per-DOW maximum AND Sunday/mean(other six days) " +
|
|
1031
|
+
"must land in [2.0, 6.0] (NAILED) / [1.5, 7.0] (STRONG). H8's retention clones re-stamp swipes onto " +
|
|
1032
|
+
"uniform-random days, diluting the ratio slightly toward 1 — covered by the band floor.",
|
|
1033
|
+
assertions: [
|
|
1034
|
+
{
|
|
1035
|
+
breakdown: {
|
|
1036
|
+
type: "duckdb",
|
|
1037
|
+
sql: `WITH ${ID_CTE}
|
|
1038
|
+
SELECT dayofweek(t) AS dow, count(*)::BIGINT AS swipes
|
|
1039
|
+
FROM ev WHERE event = 'swipe right' GROUP BY 1 ORDER BY 1`,
|
|
1040
|
+
},
|
|
1041
|
+
assert: (rows) => {
|
|
1042
|
+
if (!rows || rows.length < 7) return { verdict: "NONE", detail: `expected 7 DOW rows, got ${rows?.length ?? 0}` };
|
|
1043
|
+
const sunday = rows.find((r) => Number(r.dow) === 0);
|
|
1044
|
+
const others = rows.filter((r) => Number(r.dow) !== 0).map((r) => Number(r.swipes));
|
|
1045
|
+
if (!sunday || !others.length) return { verdict: "NONE", detail: "missing DOW rows" };
|
|
1046
|
+
const sun = Number(sunday.swipes);
|
|
1047
|
+
const otherMean = others.reduce((a, b) => a + b, 0) / others.length;
|
|
1048
|
+
const ratio = sun / otherMean;
|
|
1049
|
+
const isMax = others.every((o) => sun > o);
|
|
1050
|
+
const detail = `Sunday=${sun}, other-day mean=${otherMean.toFixed(0)}, ratio=${ratio.toFixed(3)}, strict max=${isMax}`;
|
|
1051
|
+
if (isMax && ratio >= 2.0 && ratio <= 6.0) return { verdict: "NAILED", detail };
|
|
1052
|
+
if (isMax && ratio >= 1.5 && ratio <= 7.0) return { verdict: "STRONG", detail };
|
|
1053
|
+
return { verdict: ratio > 1.15 ? "WEAK" : "INVERSE", detail };
|
|
1054
|
+
},
|
|
1055
|
+
},
|
|
1056
|
+
],
|
|
1057
|
+
},
|
|
1058
|
+
{
|
|
1059
|
+
id: "H3-super-like-effect",
|
|
1060
|
+
hook: "H3",
|
|
1061
|
+
archetype: "cohort-count-scale",
|
|
1062
|
+
narrative:
|
|
1063
|
+
`Each super-like swipe injects exactly ${SUPER_LIKE_MATCH_CLONES} cloned matches, an ADDITIVE effect ` +
|
|
1064
|
+
"(hook-time matches = organic + 3*SL), so the expected lift depends on the organic match baseline and " +
|
|
1065
|
+
"cannot be a fixed ratio. The assertion is self-calibrating: within Free-tier users outside the H1 " +
|
|
1066
|
+
"sweet range (output photos NOT IN 2-5 — one-sided-safe since output>=6 implies hook>=6, and 0-1 " +
|
|
1067
|
+
"contamination lands in both arms), it activity-scales the SL=0 arm's match average by the swipe " +
|
|
1068
|
+
"ratio to estimate the SL arm's organic baseline, predicts lift = (organic + 3*avg_SL)/organic from " +
|
|
1069
|
+
"the knob, and requires measured/predicted in [0.75, 1.25] (NAILED) / [0.6, 1.45] (STRONG). H5 " +
|
|
1070
|
+
"ghosting drops post-match events multiplicatively in both arms, which the ratio-of-ratios absorbs.",
|
|
1071
|
+
assertions: [
|
|
1072
|
+
{
|
|
1073
|
+
breakdown: {
|
|
1074
|
+
type: "duckdb",
|
|
1075
|
+
sql: `WITH ${ID_CTE},
|
|
1076
|
+
${PU_CTE},
|
|
1077
|
+
j AS (
|
|
1078
|
+
SELECT p.*, CASE WHEN p.sls >= 1 THEN 'sl' ELSE 'none' END AS arm
|
|
1079
|
+
FROM pu p JOIN us u ON u.distinct_id::VARCHAR = p.uid
|
|
1080
|
+
WHERE u.subscription = 'Free' AND p.photos NOT BETWEEN ${PHOTO_SWEET_MIN} AND ${PHOTO_SWEET_MAX}
|
|
1081
|
+
)
|
|
1082
|
+
SELECT arm, count(*)::BIGINT AS user_count, avg(matches) AS avg_m, avg(swipes) AS avg_s, avg(sls) AS avg_sl
|
|
1083
|
+
FROM j WHERE swipes > 0 GROUP BY 1`,
|
|
1084
|
+
},
|
|
1085
|
+
assert: (rows) => {
|
|
1086
|
+
const by = cellsOf(rows, "arm");
|
|
1087
|
+
const t = by.sl, c = by.none;
|
|
1088
|
+
if (!t || !c || Number(t.user_count) < 100 || Number(c.user_count) < 100) {
|
|
1089
|
+
return { verdict: "WEAK", detail: `cohort too small: sl=${t?.user_count ?? 0} none=${c?.user_count ?? 0}` };
|
|
1090
|
+
}
|
|
1091
|
+
if (!(c.avg_m > 0 && c.avg_s > 0 && t.avg_s > 0)) {
|
|
1092
|
+
return { verdict: "NONE", detail: "degenerate baseline (zero matches or swipes in an arm)" };
|
|
1093
|
+
}
|
|
1094
|
+
const organicT = c.avg_m * (t.avg_s / c.avg_s);
|
|
1095
|
+
const predicted = (organicT + SUPER_LIKE_MATCH_CLONES * t.avg_sl) / organicT;
|
|
1096
|
+
const measured = t.avg_m / organicT;
|
|
1097
|
+
const r = measured / predicted;
|
|
1098
|
+
const detail = `measured lift ${measured.toFixed(3)} vs predicted ${predicted.toFixed(3)} (ratio ${r.toFixed(3)}; avg SL ${Number(t.avg_sl).toFixed(2)}, scaled organic ${organicT.toFixed(2)}; sl n=${t.user_count}, none n=${c.user_count})`;
|
|
1099
|
+
if (r >= 0.75 && r <= 1.25) return { verdict: "NAILED", detail };
|
|
1100
|
+
if (r >= 0.6 && r <= 1.45) return { verdict: "STRONG", detail };
|
|
1101
|
+
return { verdict: measured > 1 ? "WEAK" : "INVERSE", detail };
|
|
1102
|
+
},
|
|
1103
|
+
},
|
|
1104
|
+
],
|
|
1105
|
+
},
|
|
1106
|
+
{
|
|
1107
|
+
id: "H4-premium-match-boost",
|
|
1108
|
+
hook: "H4",
|
|
1109
|
+
archetype: "cohort-count-scale",
|
|
1110
|
+
narrative:
|
|
1111
|
+
"H4 runs AFTER H5's ghosting churn and multiplies each Premium/Elite user's SURVIVING match count to " +
|
|
1112
|
+
`exactly base*${PREMIUM_MATCH_MULT} / base*${ELITE_MATCH_MULT} (toAdd = base*mult - base is ` +
|
|
1113
|
+
"deterministic). Tier is drawn independently of activity, so the cross-tier avg-matches ratio reads " +
|
|
1114
|
+
"the multiplier directly; the only dilution is users with zero hook-time organic matches (H4's gate) " +
|
|
1115
|
+
"who sit in every tier equally. Bands: Elite/Free [3.2, 4.6], Premium/Free [1.6, 2.4]. The third " +
|
|
1116
|
+
"assertion is the structural signature: for Elite users who are provably non-ghosted (output-visible " +
|
|
1117
|
+
"timely pair) AND provably milestone (early phone/date, so H8 adds instead of drops), no later hook " +
|
|
1118
|
+
"touches match counts, hence output matches ≡ 0 (mod 4) except for the ~1-2% future-guard tail " +
|
|
1119
|
+
"(clones stamped source+10..240min past datasetEnd are silently dropped). Free users are the placebo " +
|
|
1120
|
+
"(uniform-ish counts give mod-4 share near 0.25).",
|
|
1121
|
+
assertions: [
|
|
1122
|
+
{
|
|
1123
|
+
breakdown: {
|
|
1124
|
+
type: "duckdb",
|
|
1125
|
+
sql: `WITH ${ID_CTE},
|
|
1126
|
+
mc AS (SELECT uid, count(*) FILTER (WHERE event = 'match received') AS matches FROM ev GROUP BY 1)
|
|
1127
|
+
SELECT u.subscription AS tier, count(*)::BIGINT AS user_count, avg(coalesce(mc.matches, 0)) AS avg_m
|
|
1128
|
+
FROM us u LEFT JOIN mc ON mc.uid = u.distinct_id::VARCHAR
|
|
1129
|
+
GROUP BY 1`,
|
|
1130
|
+
},
|
|
1131
|
+
select: {
|
|
1132
|
+
el: { where: { tier: "Elite" } },
|
|
1133
|
+
fr: { where: { tier: "Free" } },
|
|
1134
|
+
},
|
|
1135
|
+
expect: { metric: "el.avg_m / fr.avg_m", op: "between", target: [3.2, 4.6] },
|
|
1136
|
+
minCohort: 1000,
|
|
1137
|
+
},
|
|
1138
|
+
{
|
|
1139
|
+
breakdown: {
|
|
1140
|
+
type: "duckdb",
|
|
1141
|
+
sql: `WITH ${ID_CTE},
|
|
1142
|
+
mc AS (SELECT uid, count(*) FILTER (WHERE event = 'match received') AS matches FROM ev GROUP BY 1)
|
|
1143
|
+
SELECT u.subscription AS tier, count(*)::BIGINT AS user_count, avg(coalesce(mc.matches, 0)) AS avg_m
|
|
1144
|
+
FROM us u LEFT JOIN mc ON mc.uid = u.distinct_id::VARCHAR
|
|
1145
|
+
GROUP BY 1`,
|
|
1146
|
+
},
|
|
1147
|
+
select: {
|
|
1148
|
+
pr: { where: { tier: "Premium" } },
|
|
1149
|
+
fr: { where: { tier: "Free" } },
|
|
1150
|
+
},
|
|
1151
|
+
expect: { metric: "pr.avg_m / fr.avg_m", op: "between", target: [1.6, 2.4] },
|
|
1152
|
+
minCohort: 1000,
|
|
1153
|
+
},
|
|
1154
|
+
{
|
|
1155
|
+
breakdown: {
|
|
1156
|
+
type: "duckdb",
|
|
1157
|
+
sql: `WITH ${ID_CTE},
|
|
1158
|
+
${TP_CTE},
|
|
1159
|
+
${MS_CTE},
|
|
1160
|
+
mc AS (SELECT uid, count(*) FILTER (WHERE event = 'match received') AS matches FROM ev GROUP BY 1),
|
|
1161
|
+
j AS (
|
|
1162
|
+
SELECT u.subscription AS tier, mc.matches
|
|
1163
|
+
FROM us u
|
|
1164
|
+
JOIN mc ON mc.uid = u.distinct_id::VARCHAR
|
|
1165
|
+
JOIN tp ON tp.uid = mc.uid
|
|
1166
|
+
JOIN ms ON ms.uid = mc.uid
|
|
1167
|
+
WHERE mc.matches >= ${ELITE_MATCH_MULT}
|
|
1168
|
+
)
|
|
1169
|
+
SELECT tier, count(*)::BIGINT AS user_count,
|
|
1170
|
+
count(*) FILTER (WHERE matches % ${ELITE_MATCH_MULT} = 0)::DOUBLE / count(*) AS mod_share
|
|
1171
|
+
FROM j WHERE tier IN ('Elite', 'Free') GROUP BY 1`,
|
|
1172
|
+
},
|
|
1173
|
+
assert: (rows) => {
|
|
1174
|
+
const by = cellsOf(rows, "tier");
|
|
1175
|
+
const e = by.Elite, f = by.Free;
|
|
1176
|
+
if (!e || !f || Number(e.user_count) < 150 || Number(f.user_count) < 150) {
|
|
1177
|
+
return { verdict: "WEAK", detail: `cohort too small: Elite=${e?.user_count ?? 0} Free=${f?.user_count ?? 0}` };
|
|
1178
|
+
}
|
|
1179
|
+
const detail = `Elite mod-${ELITE_MATCH_MULT} share ${Number(e.mod_share).toFixed(4)} (n=${e.user_count}) vs Free placebo ${Number(f.mod_share).toFixed(4)} (n=${f.user_count})`;
|
|
1180
|
+
if (e.mod_share >= 0.9 && f.mod_share <= 0.4) return { verdict: "NAILED", detail };
|
|
1181
|
+
if (e.mod_share >= 0.75 && f.mod_share <= 0.5) return { verdict: "STRONG", detail };
|
|
1182
|
+
return { verdict: e.mod_share > f.mod_share ? "WEAK" : "INVERSE", detail };
|
|
1183
|
+
},
|
|
1184
|
+
},
|
|
1185
|
+
],
|
|
1186
|
+
},
|
|
1187
|
+
{
|
|
1188
|
+
id: "H5-ghosting-churn",
|
|
1189
|
+
hook: "H5",
|
|
1190
|
+
archetype: "retention-divergence",
|
|
1191
|
+
narrative:
|
|
1192
|
+
`Matched users with no message inside the ${GHOSTING_WINDOW_HOURS}h ghosting window lose ` +
|
|
1193
|
+
`${GHOSTING_DROP_LIKELIHOOD}% of post-first-match events (keep rate ${(1 - GHOSTING_DROP_LIKELIHOOD / 100).toFixed(2)}). ` +
|
|
1194
|
+
"Arms split on the output-visible timely pair (deletions-only ⇒ visible pair proves non-ghosted; " +
|
|
1195
|
+
"hook-timely users whose pair was later dropped land in the ghosted arm and dilute toward null). " +
|
|
1196
|
+
"Estimator is the within-user volume-normalized ρ = Σpost-first-match / Σpre-first-match per arm; " +
|
|
1197
|
+
"both arms are restricted to non-milestone users so H8's post-day-30 drop applies to BOTH and cancels " +
|
|
1198
|
+
"in the ratio (milestone users get H8 clone ADDS, which would inflate the timely arm only). " +
|
|
1199
|
+
"The raw ρ_ghosted/ρ_timely ratio is confounded DOWNWARD by activity selection — the ghosted arm is " +
|
|
1200
|
+
"the least-engaged matched tail, on an organically flatter trajectory — so the assertion " +
|
|
1201
|
+
"self-calibrates: the same ρ computed over each arm's PRE-first-match half-split (H5 never touches " +
|
|
1202
|
+
"pre-match events) estimates the organic trajectory ratio, and the selection-corrected keep estimate " +
|
|
1203
|
+
"(raw ratio / pre-trajectory ratio) must land within [0.6, 1.4] of the 0.2 knob.",
|
|
1204
|
+
assertions: [
|
|
1205
|
+
{
|
|
1206
|
+
breakdown: {
|
|
1207
|
+
type: "duckdb",
|
|
1208
|
+
sql: `WITH ${ID_CTE},
|
|
1209
|
+
${TP_CTE},
|
|
1210
|
+
${MS_CTE},
|
|
1211
|
+
fm AS (SELECT uid, min(t) AS first_match FROM ev WHERE event = 'match received' GROUP BY 1),
|
|
1212
|
+
per AS (
|
|
1213
|
+
SELECT fm.uid,
|
|
1214
|
+
count(*) FILTER (WHERE e.t <= fm.first_match) AS pre,
|
|
1215
|
+
count(*) FILTER (WHERE e.t > fm.first_match) AS post,
|
|
1216
|
+
count(*) FILTER (WHERE e.t <= to_timestamp((epoch(fe.f) + epoch(fm.first_match)) / 2)) AS pre_a,
|
|
1217
|
+
count(*) FILTER (WHERE e.t > to_timestamp((epoch(fe.f) + epoch(fm.first_match)) / 2) AND e.t <= fm.first_match) AS pre_b
|
|
1218
|
+
FROM fm JOIN fe ON fe.uid = fm.uid JOIN ev e ON e.uid = fm.uid
|
|
1219
|
+
GROUP BY 1
|
|
1220
|
+
),
|
|
1221
|
+
j AS (
|
|
1222
|
+
SELECT p.*, CASE WHEN tp.uid IS NOT NULL THEN 'timely' ELSE 'ghosted' END AS arm
|
|
1223
|
+
FROM per p
|
|
1224
|
+
LEFT JOIN tp ON tp.uid = p.uid
|
|
1225
|
+
WHERE p.uid NOT IN (SELECT uid FROM ms)
|
|
1226
|
+
)
|
|
1227
|
+
SELECT arm, count(*)::BIGINT AS user_count,
|
|
1228
|
+
sum(post)::DOUBLE / nullif(sum(pre), 0) AS rho,
|
|
1229
|
+
sum(pre_b)::DOUBLE / nullif(sum(pre_a), 0) AS rho_pre
|
|
1230
|
+
FROM j GROUP BY 1`,
|
|
1231
|
+
},
|
|
1232
|
+
assert: (rows) => {
|
|
1233
|
+
const by = cellsOf(rows, "arm");
|
|
1234
|
+
const g = by.ghosted, t = by.timely;
|
|
1235
|
+
if (!g || !t || Number(g.user_count) < 300 || Number(t.user_count) < 300) {
|
|
1236
|
+
return { verdict: "WEAK", detail: `cohort too small: ghosted=${g?.user_count ?? 0} timely=${t?.user_count ?? 0}` };
|
|
1237
|
+
}
|
|
1238
|
+
if (!(g.rho > 0 && t.rho > 0 && g.rho_pre > 0 && t.rho_pre > 0)) {
|
|
1239
|
+
return { verdict: "NONE", detail: "degenerate rho (zero pre or post volume in an arm)" };
|
|
1240
|
+
}
|
|
1241
|
+
const raw = g.rho / t.rho;
|
|
1242
|
+
const keep = 1 - GHOSTING_DROP_LIKELIHOOD / 100;
|
|
1243
|
+
const adj = raw / (g.rho_pre / t.rho_pre);
|
|
1244
|
+
const r = adj / keep;
|
|
1245
|
+
const detail = `raw rho ratio ${raw.toFixed(4)}, pre-trajectory ratio ${(g.rho_pre / t.rho_pre).toFixed(4)}, corrected keep ${adj.toFixed(4)} vs knob ${keep} (r=${r.toFixed(3)}; ghosted n=${g.user_count}, timely n=${t.user_count})`;
|
|
1246
|
+
if (raw >= 1) return { verdict: "INVERSE", detail };
|
|
1247
|
+
if (r >= 0.6 && r <= 1.4) return { verdict: "NAILED", detail };
|
|
1248
|
+
if (r >= 0.45 && r <= 1.75) return { verdict: "STRONG", detail };
|
|
1249
|
+
return { verdict: "WEAK", detail };
|
|
1250
|
+
},
|
|
1251
|
+
},
|
|
1252
|
+
],
|
|
1253
|
+
},
|
|
1254
|
+
{
|
|
1255
|
+
id: "H6-bio-prompt-power-users",
|
|
1256
|
+
hook: "H6",
|
|
1257
|
+
archetype: "cohort-count-scale",
|
|
1258
|
+
narrative:
|
|
1259
|
+
`Users with a bio update AND >=${BIO_PROMPT_THRESHOLD} prompt answers at hook time get ` +
|
|
1260
|
+
`${BIO_PROMPT_DATE_CLONE_MULT} cloned dates per existing date (x${BIO_PROMPT_DATE_CLONE_MULT + 1} exact ` +
|
|
1261
|
+
"at hook time). At this dungeon's event density (~190 events/user) the power cohort is ~80% of users " +
|
|
1262
|
+
"and the rest arm is the low-activity tail whose messages come disproportionately from Date Funnel " +
|
|
1263
|
+
"instances (which co-emit dates), so the rest arm's ORGANIC dates-per-message rate runs ~2x the power " +
|
|
1264
|
+
"arm's — the population-level rate ratio is a composite: 4x mechanism times a 0.4-0.85 composition " +
|
|
1265
|
+
"factor (per-user event density is scale-invariant, so the factor is stable across run sizes), giving " +
|
|
1266
|
+
"band [1.5, 3.4]. The mechanism itself is asserted exactly by the second check: for power users who " +
|
|
1267
|
+
"are provably non-ghosted (output-visible timely pair) AND milestone (H8 add-branch — clones app " +
|
|
1268
|
+
"opens/swipes only), no hook after H6 deletes dates, so output date counts are ≡ 0 (mod 4) except the " +
|
|
1269
|
+
"future-guard tail (clones stamped source+1..72h past datasetEnd are silently dropped, ~9%); the same " +
|
|
1270
|
+
"clean cohort's REST arm is the placebo at the ~0.25 random baseline.",
|
|
1271
|
+
assertions: [
|
|
1272
|
+
{
|
|
1273
|
+
breakdown: {
|
|
1274
|
+
type: "duckdb",
|
|
1275
|
+
sql: `WITH ${ID_CTE},
|
|
1276
|
+
pw AS (
|
|
1277
|
+
SELECT uid,
|
|
1278
|
+
count(*) FILTER (WHERE event = 'bio updated') AS bios,
|
|
1279
|
+
count(*) FILTER (WHERE event = 'prompt answered') AS prompts,
|
|
1280
|
+
count(*) FILTER (WHERE event = 'date scheduled') AS dates,
|
|
1281
|
+
count(*) FILTER (WHERE event = 'message sent') AS msgs
|
|
1282
|
+
FROM ev GROUP BY 1
|
|
1283
|
+
),
|
|
1284
|
+
j AS (
|
|
1285
|
+
SELECT CASE WHEN bios >= 1 AND prompts >= ${BIO_PROMPT_THRESHOLD} THEN 'power' ELSE 'rest' END AS arm, dates, msgs
|
|
1286
|
+
FROM pw WHERE msgs > 0
|
|
1287
|
+
)
|
|
1288
|
+
SELECT arm, count(*)::BIGINT AS user_count, sum(dates)::DOUBLE / sum(msgs) AS date_rate
|
|
1289
|
+
FROM j GROUP BY 1`,
|
|
1290
|
+
},
|
|
1291
|
+
select: {
|
|
1292
|
+
p: { where: { arm: "power" } },
|
|
1293
|
+
r: { where: { arm: "rest" } },
|
|
1294
|
+
},
|
|
1295
|
+
expect: { metric: "p.date_rate / r.date_rate", op: "between", target: [1.5, 3.4] },
|
|
1296
|
+
minCohort: 300,
|
|
1297
|
+
},
|
|
1298
|
+
{
|
|
1299
|
+
breakdown: {
|
|
1300
|
+
type: "duckdb",
|
|
1301
|
+
sql: `WITH ${ID_CTE},
|
|
1302
|
+
${TP_CTE},
|
|
1303
|
+
${MS_CTE},
|
|
1304
|
+
pw AS (
|
|
1305
|
+
SELECT uid,
|
|
1306
|
+
count(*) FILTER (WHERE event = 'bio updated') AS bios,
|
|
1307
|
+
count(*) FILTER (WHERE event = 'prompt answered') AS prompts,
|
|
1308
|
+
count(*) FILTER (WHERE event = 'date scheduled') AS dates
|
|
1309
|
+
FROM ev GROUP BY 1
|
|
1310
|
+
),
|
|
1311
|
+
j AS (
|
|
1312
|
+
SELECT CASE WHEN p.bios >= 1 AND p.prompts >= ${BIO_PROMPT_THRESHOLD} THEN 'power' ELSE 'rest' END AS arm, p.dates
|
|
1313
|
+
FROM pw p JOIN tp ON tp.uid = p.uid JOIN ms ON ms.uid = p.uid
|
|
1314
|
+
WHERE p.dates >= ${BIO_PROMPT_DATE_CLONE_MULT + 1}
|
|
1315
|
+
)
|
|
1316
|
+
SELECT arm, count(*)::BIGINT AS user_count,
|
|
1317
|
+
count(*) FILTER (WHERE dates % ${BIO_PROMPT_DATE_CLONE_MULT + 1} = 0)::DOUBLE / count(*) AS mod_share
|
|
1318
|
+
FROM j GROUP BY 1`,
|
|
1319
|
+
},
|
|
1320
|
+
assert: (rows) => {
|
|
1321
|
+
const by = cellsOf(rows, "arm");
|
|
1322
|
+
const p = by.power, r = by.rest;
|
|
1323
|
+
if (!p || !r || Number(p.user_count) < 300 || Number(r.user_count) < 100) {
|
|
1324
|
+
return { verdict: "WEAK", detail: `cohort too small: power=${p?.user_count ?? 0} rest=${r?.user_count ?? 0}` };
|
|
1325
|
+
}
|
|
1326
|
+
const detail = `power mod-${BIO_PROMPT_DATE_CLONE_MULT + 1} share ${Number(p.mod_share).toFixed(4)} (n=${p.user_count}) vs rest placebo ${Number(r.mod_share).toFixed(4)} (n=${r.user_count})`;
|
|
1327
|
+
if (p.mod_share >= 0.9 && r.mod_share <= 0.4) return { verdict: "NAILED", detail };
|
|
1328
|
+
if (p.mod_share >= 0.8 && r.mod_share <= 0.5) return { verdict: "STRONG", detail };
|
|
1329
|
+
return { verdict: p.mod_share > r.mod_share ? "WEAK" : "INVERSE", detail };
|
|
1330
|
+
},
|
|
1331
|
+
},
|
|
1332
|
+
],
|
|
1333
|
+
},
|
|
1334
|
+
{
|
|
1335
|
+
id: "H7-vday-spike",
|
|
1336
|
+
hook: "H7",
|
|
1337
|
+
archetype: "temporal-inflection",
|
|
1338
|
+
narrative:
|
|
1339
|
+
`Inside the V-Day window (days ${VDAY_WINDOW_START_DAY}-${VDAY_WINDOW_END_DAY}: ${VDAY_START_TS} → ` +
|
|
1340
|
+
`${VDAY_END_TS}), signups are cloned x${VDAY_SIGNUP_CLONES} extra and premium upgrades ` +
|
|
1341
|
+
`x${VDAY_UPGRADE_CLONES} extra. Clones are stamped source+U[1,48]h (signups) / +U[1,24]h (upgrades), ` +
|
|
1342
|
+
"so a computable share leaks past the window edge: E[leak] = (48/2)/120h = 20% of signup clones and " +
|
|
1343
|
+
"(24/2)/120h = 10% of upgrade clones, giving in-window daily multipliers of 1+2*0.8 = 2.6 and " +
|
|
1344
|
+
"1+4*0.9 = 4.6. Baseline = symmetric 14-day flanks, with the post flank starting +3 days after window " +
|
|
1345
|
+
"end so leaked clones cannot inflate it; symmetric flanks cancel the growth-soup trend to first order " +
|
|
1346
|
+
"(convexity bias ~1% at this window size). Bands: signups [2.0, 3.3], upgrades [3.5, 5.6].",
|
|
1347
|
+
assertions: [
|
|
1348
|
+
{
|
|
1349
|
+
breakdown: {
|
|
1350
|
+
type: "duckdb",
|
|
1351
|
+
sql: `WITH ${ID_CTE}
|
|
1352
|
+
SELECT 'all' AS tag,
|
|
1353
|
+
count(*) FILTER (WHERE t >= TIMESTAMP '${VDAY_START_TS}' AND t < TIMESTAMP '${VDAY_END_TS}')::BIGINT AS user_count,
|
|
1354
|
+
count(*) FILTER (WHERE t >= TIMESTAMP '${VDAY_START_TS}' AND t < TIMESTAMP '${VDAY_END_TS}') / ${VDAY_DAYS}.0 AS w_daily,
|
|
1355
|
+
count(*) FILTER (WHERE (t >= TIMESTAMP '${VDAY_BASE_PRE_TS}' AND t < TIMESTAMP '${VDAY_START_TS}')
|
|
1356
|
+
OR (t >= TIMESTAMP '${VDAY_BASE_POST_START_TS}' AND t < TIMESTAMP '${VDAY_BASE_POST_END_TS}')) / 28.0 AS b_daily
|
|
1357
|
+
FROM ev WHERE event = 'profile created'`,
|
|
1358
|
+
},
|
|
1359
|
+
select: { r: { where: { tag: "all" } } },
|
|
1360
|
+
expect: { metric: "r.w_daily / r.b_daily", op: "between", target: [2.0, 3.3] },
|
|
1361
|
+
minCohort: 100,
|
|
1362
|
+
},
|
|
1363
|
+
{
|
|
1364
|
+
breakdown: {
|
|
1365
|
+
type: "duckdb",
|
|
1366
|
+
sql: `WITH ${ID_CTE}
|
|
1367
|
+
SELECT 'all' AS tag,
|
|
1368
|
+
count(*) FILTER (WHERE t >= TIMESTAMP '${VDAY_START_TS}' AND t < TIMESTAMP '${VDAY_END_TS}')::BIGINT AS user_count,
|
|
1369
|
+
count(*) FILTER (WHERE t >= TIMESTAMP '${VDAY_START_TS}' AND t < TIMESTAMP '${VDAY_END_TS}') / ${VDAY_DAYS}.0 AS w_daily,
|
|
1370
|
+
count(*) FILTER (WHERE (t >= TIMESTAMP '${VDAY_BASE_PRE_TS}' AND t < TIMESTAMP '${VDAY_START_TS}')
|
|
1371
|
+
OR (t >= TIMESTAMP '${VDAY_BASE_POST_START_TS}' AND t < TIMESTAMP '${VDAY_BASE_POST_END_TS}')) / 28.0 AS b_daily
|
|
1372
|
+
FROM ev WHERE event = 'premium upgrade'`,
|
|
1373
|
+
},
|
|
1374
|
+
select: { r: { where: { tag: "all" } } },
|
|
1375
|
+
expect: { metric: "r.w_daily / r.b_daily", op: "between", target: [3.5, 5.6] },
|
|
1376
|
+
minCohort: 60,
|
|
1377
|
+
},
|
|
1378
|
+
],
|
|
1379
|
+
},
|
|
1380
|
+
{
|
|
1381
|
+
id: "H8-offapp-retention",
|
|
1382
|
+
hook: "H8",
|
|
1383
|
+
archetype: "retention-divergence",
|
|
1384
|
+
narrative:
|
|
1385
|
+
`Non-milestone users lose ${OFFAPP_DROP_LIKELIHOOD}% of post-day-${RETENTION_CUTOFF_DAYS} events; ` +
|
|
1386
|
+
"milestone users (early phone/date) instead get app-open/swipe clones topping post-30 volume toward " +
|
|
1387
|
+
`${RETENTION_TARGET_PCT * 100}% of total — but for early-born users the organic post-30 share already ` +
|
|
1388
|
+
"exceeds 30% (growth soup back-loads events), so the milestone arm is approximately organic and the " +
|
|
1389
|
+
"measurable signal is the drop. Cohort: born before day 30 (retention clones land day30+U[1,60], so " +
|
|
1390
|
+
"the whole clone support fits the window — later-born users lose clones to the future guard), AND " +
|
|
1391
|
+
"provably non-ghosted (timely pair) or match-free, so H5's post-match drop cannot masquerade as H8. " +
|
|
1392
|
+
"Self-calibrating check: with keep k=0.2 and milestone-arm share s as the organic estimate, predicted " +
|
|
1393
|
+
"rest-arm share = k*s/(1-(1-k)*s); measured/predicted in [0.7, 1.35] (NAILED) / [0.55, 1.6] (STRONG).",
|
|
1394
|
+
assertions: [
|
|
1395
|
+
{
|
|
1396
|
+
breakdown: {
|
|
1397
|
+
type: "duckdb",
|
|
1398
|
+
sql: `WITH ${ID_CTE},
|
|
1399
|
+
${TP_CTE},
|
|
1400
|
+
${MS_CTE},
|
|
1401
|
+
mc AS (SELECT uid, count(*) FILTER (WHERE event = 'match received') AS matches FROM ev GROUP BY 1),
|
|
1402
|
+
per AS (
|
|
1403
|
+
SELECT fe.uid,
|
|
1404
|
+
count(*) FILTER (WHERE e.t > fe.f + INTERVAL ${RETENTION_CUTOFF_DAYS} DAY) AS post30,
|
|
1405
|
+
count(*) AS total
|
|
1406
|
+
FROM fe JOIN ev e ON e.uid = fe.uid
|
|
1407
|
+
WHERE fe.f < TIMESTAMP '${H8_EARLYBORN_TS}'
|
|
1408
|
+
GROUP BY 1
|
|
1409
|
+
),
|
|
1410
|
+
j AS (
|
|
1411
|
+
SELECT p.*, CASE WHEN ms.uid IS NOT NULL THEN 'milestone' ELSE 'rest' END AS arm
|
|
1412
|
+
FROM per p
|
|
1413
|
+
LEFT JOIN ms ON ms.uid = p.uid
|
|
1414
|
+
LEFT JOIN tp ON tp.uid = p.uid
|
|
1415
|
+
LEFT JOIN mc ON mc.uid = p.uid
|
|
1416
|
+
WHERE tp.uid IS NOT NULL OR coalesce(mc.matches, 0) = 0
|
|
1417
|
+
)
|
|
1418
|
+
SELECT arm, count(*)::BIGINT AS user_count, sum(post30)::DOUBLE / sum(total) AS post30_share
|
|
1419
|
+
FROM j GROUP BY 1`,
|
|
1420
|
+
},
|
|
1421
|
+
assert: (rows) => {
|
|
1422
|
+
const by = cellsOf(rows, "arm");
|
|
1423
|
+
const m = by.milestone, r = by.rest;
|
|
1424
|
+
if (!m || !r || Number(m.user_count) < 200 || Number(r.user_count) < 200) {
|
|
1425
|
+
return { verdict: "WEAK", detail: `cohort too small: milestone=${m?.user_count ?? 0} rest=${r?.user_count ?? 0}` };
|
|
1426
|
+
}
|
|
1427
|
+
const keep = 1 - OFFAPP_DROP_LIKELIHOOD / 100;
|
|
1428
|
+
const sM = Number(m.post30_share), sR = Number(r.post30_share);
|
|
1429
|
+
if (!(sM > 0 && sM < 1)) return { verdict: "NONE", detail: `degenerate milestone share ${sM}` };
|
|
1430
|
+
const pred = (keep * sM) / (1 - (1 - keep) * sM);
|
|
1431
|
+
const ratio = sR / pred;
|
|
1432
|
+
const detail = `milestone share ${sM.toFixed(4)} (n=${m.user_count}), rest share ${sR.toFixed(4)} (n=${r.user_count}), predicted rest ${pred.toFixed(4)}, measured/predicted ${ratio.toFixed(3)}`;
|
|
1433
|
+
if (!(sM > sR)) return { verdict: "INVERSE", detail };
|
|
1434
|
+
if (ratio >= 0.7 && ratio <= 1.35) return { verdict: "NAILED", detail };
|
|
1435
|
+
if (ratio >= 0.55 && ratio <= 1.6) return { verdict: "STRONG", detail };
|
|
1436
|
+
return { verdict: "WEAK", detail };
|
|
1437
|
+
},
|
|
1438
|
+
},
|
|
1439
|
+
],
|
|
1440
|
+
},
|
|
1441
|
+
{
|
|
1442
|
+
id: "H9-match-flow-ttc",
|
|
1443
|
+
hook: "H9",
|
|
1444
|
+
archetype: "funnel-ttc-by-segment",
|
|
1445
|
+
narrative:
|
|
1446
|
+
`funnel-post stretches Match Flow inter-step gaps by tier: Elite x${FUNNEL_TTC_ELITE}, Free ` +
|
|
1447
|
+
`x${FUNNEL_TTC_FREE}, Premium untouched (the v1.6 hook is scoped to Match Flow only). Cross-event SQL ` +
|
|
1448
|
+
"cannot see this (greedy single-pass pairing across the full history — the documented v1.5 " +
|
|
1449
|
+
"limitation), so the assertion goes through the Mixpanel-aligned emulator's timeToConvert at a " +
|
|
1450
|
+
`conversion window of 24h * ${FUNNEL_TTC_FREE} = 33.6h — the generative Match Flow window times the ` +
|
|
1451
|
+
"max stretch factor, covering the stretched support so Free conversions are not right-censored (the " +
|
|
1452
|
+
"ai-platform lesson: censoring inverts the measured direction). Median ratios vs the untouched " +
|
|
1453
|
+
"Premium tier read the knobs, compressed toward 1 by cross-instance organic pairings — and this " +
|
|
1454
|
+
"dungeon's clone traffic (H2 Sunday swipes at ~2.4x daily volume, H3/H4 match injections) makes the " +
|
|
1455
|
+
"organic mixture heavier than ai-platform's. Compression is asymmetric: the Free stretch pushes " +
|
|
1456
|
+
"true-instance completions LATER, so a competing organic pairing more often lands first and masks " +
|
|
1457
|
+
"them (only ~25-30% of the 1.4 knob distance survives), while the Elite compression pulls completions " +
|
|
1458
|
+
"EARLIER, which organic events can rarely preempt (~65% survives). Bands: Elite/Premium in " +
|
|
1459
|
+
"[0.55, 0.92], Free/Premium in [1.05, 1.55].",
|
|
1460
|
+
assertions: [
|
|
1461
|
+
{
|
|
1462
|
+
breakdown: {
|
|
1463
|
+
type: "timeToConvert",
|
|
1464
|
+
steps: ["swipe right", "match received", "message sent"],
|
|
1465
|
+
breakdownByUserProperty: "subscription",
|
|
1466
|
+
conversionWindowMs: Math.round(24 * FUNNEL_TTC_FREE * 3600 * 1000),
|
|
1467
|
+
},
|
|
1468
|
+
select: {
|
|
1469
|
+
el: { where: { segment_value: "Elite" } },
|
|
1470
|
+
pr: { where: { segment_value: "Premium" } },
|
|
1471
|
+
},
|
|
1472
|
+
expect: { metric: "el.median_ttc_ms / pr.median_ttc_ms", op: "between", target: [0.55, 0.92] },
|
|
1473
|
+
minCohort: 400,
|
|
1474
|
+
},
|
|
1475
|
+
{
|
|
1476
|
+
breakdown: {
|
|
1477
|
+
type: "timeToConvert",
|
|
1478
|
+
steps: ["swipe right", "match received", "message sent"],
|
|
1479
|
+
breakdownByUserProperty: "subscription",
|
|
1480
|
+
conversionWindowMs: Math.round(24 * FUNNEL_TTC_FREE * 3600 * 1000),
|
|
1481
|
+
},
|
|
1482
|
+
select: {
|
|
1483
|
+
fr: { where: { segment_value: "Free" } },
|
|
1484
|
+
pr: { where: { segment_value: "Premium" } },
|
|
1485
|
+
},
|
|
1486
|
+
expect: { metric: "fr.median_ttc_ms / pr.median_ttc_ms", op: "between", target: [1.05, 1.55] },
|
|
1487
|
+
minCohort: 400,
|
|
1488
|
+
},
|
|
1489
|
+
],
|
|
1490
|
+
},
|
|
1491
|
+
{
|
|
1492
|
+
id: "H10-age-date-conversion",
|
|
1493
|
+
hook: "H10",
|
|
1494
|
+
archetype: "funnel-conversion-by-segment",
|
|
1495
|
+
narrative:
|
|
1496
|
+
`funnel-pre scales Date Funnel completion probability by age: 25-29/30-34 x${AGE_CONV_BOOST} (capped ` +
|
|
1497
|
+
`at 95), 40+ x${AGE_CONV_DROP}. Measured through the emulator's Date Funnel step counts per age_range ` +
|
|
1498
|
+
"at the 72h generative window (H9 no longer stretches this funnel — it is Match Flow-scoped in v1.6). " +
|
|
1499
|
+
"Organic weight-drawn phone/date events add age-independent conversions on top of the funnel-driven " +
|
|
1500
|
+
"ones, compressing both ratios toward 1, so bands sit inside the knobs: boost/base in [1.05, 1.45], " +
|
|
1501
|
+
"drop/base in [0.45, 0.88], where base pools 18-24 and 35-39 (the x1.0 segments) with attempt-weighted " +
|
|
1502
|
+
"conversion.",
|
|
1503
|
+
assertions: [
|
|
1504
|
+
{
|
|
1505
|
+
breakdown: {
|
|
1506
|
+
type: "timeToConvert",
|
|
1507
|
+
steps: ["message sent", "phone number exchanged", "date scheduled"],
|
|
1508
|
+
breakdownByUserProperty: "age_range",
|
|
1509
|
+
conversionWindowMs: 72 * 3600 * 1000,
|
|
1510
|
+
},
|
|
1511
|
+
assert: (rows) => {
|
|
1512
|
+
const by = cellsOf(rows, "segment_value");
|
|
1513
|
+
const cells = (names) => names.map((n) => by[n]).filter(Boolean);
|
|
1514
|
+
const conv = (names) => {
|
|
1515
|
+
const cs = cells(names);
|
|
1516
|
+
const entered = cs.reduce((s, c) => s + (c.step_counts?.[0] ?? 0), 0);
|
|
1517
|
+
const done = cs.reduce((s, c) => s + (c.step_counts?.[2] ?? 0), 0);
|
|
1518
|
+
return entered > 0 ? { rate: done / entered, entered } : null;
|
|
1519
|
+
};
|
|
1520
|
+
const boost = conv(["25-29", "30-34"]);
|
|
1521
|
+
const base = conv(["18-24", "35-39"]);
|
|
1522
|
+
const drop = conv(["40+"]);
|
|
1523
|
+
if (!boost || !base || !drop) return { verdict: "NONE", detail: "missing age segments in emulator rows" };
|
|
1524
|
+
if (boost.entered < 500 || base.entered < 500 || drop.entered < 250) {
|
|
1525
|
+
return { verdict: "WEAK", detail: `attempts too few: boost=${boost.entered} base=${base.entered} drop=${drop.entered}` };
|
|
1526
|
+
}
|
|
1527
|
+
const rb = boost.rate / base.rate;
|
|
1528
|
+
const rd = drop.rate / base.rate;
|
|
1529
|
+
const detail = `boost/base=${rb.toFixed(3)}, drop/base=${rd.toFixed(3)} (rates ${boost.rate.toFixed(4)}/${base.rate.toFixed(4)}/${drop.rate.toFixed(4)}; attempts ${boost.entered}/${base.entered}/${drop.entered})`;
|
|
1530
|
+
if (rb >= 1.05 && rb <= 1.45 && rd >= 0.45 && rd <= 0.88) return { verdict: "NAILED", detail };
|
|
1531
|
+
if (rb >= 1.02 && rd <= 0.94) return { verdict: "STRONG", detail };
|
|
1532
|
+
if (rb > 1 && rd < 1) return { verdict: "WEAK", detail };
|
|
1533
|
+
return { verdict: "INVERSE", detail };
|
|
1534
|
+
},
|
|
1535
|
+
},
|
|
1536
|
+
],
|
|
1537
|
+
},
|
|
1538
|
+
];
|
|
1539
|
+
|
|
1540
|
+
export default config;
|