@ak--47/dungeon-master 1.3.0 → 1.4.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/CHANGELOG.md +58 -0
- package/dungeons/technical/array-of-object-lookup.js +0 -2
- package/dungeons/technical/hook-helpers-verify.js +89 -0
- package/dungeons/technical/identity-model-verify.js +47 -0
- package/dungeons/technical/pattern-aggregate-by-bin.js +41 -0
- package/dungeons/technical/pattern-attributed-by-source.js +42 -0
- package/dungeons/technical/pattern-frequency-by-frequency.js +40 -0
- package/dungeons/technical/pattern-funnel-frequency.js +54 -0
- package/dungeons/technical/pattern-ttc-by-segment.js +45 -0
- package/dungeons/technical/simple.js +3 -4
- package/dungeons/technical/simplest-schema.json +5 -0
- package/dungeons/technical/text-generation.js +1 -1
- package/dungeons/vertical/ai-platform.js +216 -164
- package/dungeons/vertical/community.js +74 -41
- package/dungeons/vertical/crypto.js +343 -233
- package/dungeons/vertical/dating.js +246 -292
- package/dungeons/vertical/devtools.js +179 -70
- package/dungeons/vertical/ecommerce.js +275 -122
- package/dungeons/vertical/education.js +345 -312
- package/dungeons/vertical/fintech.js +445 -314
- package/dungeons/vertical/fitness.js +199 -165
- package/dungeons/vertical/food-delivery.js +309 -330
- package/dungeons/vertical/gaming.js +387 -241
- package/dungeons/vertical/healthcare.js +149 -66
- package/dungeons/vertical/insurance-application.js +173 -75
- package/dungeons/vertical/logistics.js +107 -12
- package/dungeons/vertical/marketplace.js +171 -76
- package/dungeons/vertical/media.js +259 -402
- package/dungeons/vertical/real-estate.js +336 -318
- package/dungeons/vertical/sass.js +294 -288
- package/dungeons/vertical/social.js +289 -234
- package/dungeons/vertical/travel.js +66 -20
- package/index.js +17 -17
- package/lib/core/config-validator.js +226 -175
- package/lib/core/context.js +10 -24
- package/lib/core/storage.js +6 -1
- package/lib/generators/events.js +57 -104
- package/lib/generators/funnels.js +212 -93
- package/lib/generators/mirror.js +3 -2
- package/lib/generators/product-names.js +1 -1
- package/lib/generators/scd.js +2 -1
- package/lib/generators/text.js +1 -1
- package/lib/hook-helpers/_internal.js +23 -0
- package/lib/hook-helpers/cohort.js +124 -0
- package/lib/hook-helpers/identity.js +56 -0
- package/lib/hook-helpers/index.js +44 -0
- package/lib/hook-helpers/inject.js +99 -0
- package/lib/hook-helpers/mutate.js +151 -0
- package/lib/hook-helpers/timing.js +99 -0
- package/lib/hook-patterns/aggregate-per-user-by-bin.js +38 -0
- package/lib/hook-patterns/attributed-by-source.js +72 -0
- package/lib/hook-patterns/frequency-by-frequency.js +46 -0
- package/lib/hook-patterns/funnel-frequency-breakdown.js +73 -0
- package/lib/hook-patterns/index.js +14 -0
- package/lib/hook-patterns/time-to-convert-by-segment.js +41 -0
- package/lib/orchestrators/user-loop.js +143 -293
- package/lib/utils/utils.js +97 -52
- package/lib/verify/emulate-breakdown.js +281 -0
- package/lib/verify/index.js +12 -0
- package/lib/verify/verify-dungeon.js +61 -0
- package/package.json +6 -4
- package/scripts/smoke-test-all.mjs +162 -0
- package/scripts/verify-runner.mjs +72 -24
- package/types.d.ts +434 -225
- package/dungeons/technical/ad-spend-schema.json +0 -128
- package/dungeons/technical/anonymous-users-schema.json +0 -92
- package/dungeons/technical/array-of-object-lookup-schema.json +0 -191
- package/dungeons/technical/experiments-schema.json +0 -203
- package/dungeons/technical/foobar-schema.json +0 -362
- package/dungeons/technical/group-analytics-schema.json +0 -241
- package/dungeons/technical/mirror-strategies-schema.json +0 -84
- package/dungeons/technical/nested-objects-schema.json +0 -145
- package/dungeons/technical/retention-cadence-schema.json +0 -37
- package/dungeons/technical/sanity-schema.json +0 -185
- package/dungeons/technical/scale-test-schema.json +0 -70
- package/dungeons/technical/scd-schema.json +0 -467
- package/dungeons/technical/simple-schema.json +0 -362
- package/dungeons/technical/text-generation-schema.json +0 -1062
- package/dungeons/vertical/ai-platform-schema.json +0 -617
- package/dungeons/vertical/community-schema.json +0 -579
- package/dungeons/vertical/crypto-schema.json +0 -546
- package/dungeons/vertical/dating-schema.json +0 -401
- package/dungeons/vertical/devtools-schema.json +0 -601
- package/dungeons/vertical/ecommerce-schema.json +0 -604
- package/dungeons/vertical/education-schema.json +0 -5686
- package/dungeons/vertical/fintech-schema.json +0 -630
- package/dungeons/vertical/fitness-schema.json +0 -530
- package/dungeons/vertical/food-delivery-schema.json +0 -36728
- package/dungeons/vertical/gaming-schema.json +0 -2703
- package/dungeons/vertical/healthcare-schema.json +0 -549
- package/dungeons/vertical/insurance-application-schema.json +0 -485
- package/dungeons/vertical/logistics-schema.json +0 -574
- package/dungeons/vertical/marketplace-schema.json +0 -533
- package/dungeons/vertical/media-schema.json +0 -4749
- package/dungeons/vertical/real-estate-schema.json +0 -527
- package/dungeons/vertical/sass-schema.json +0 -3128
- package/dungeons/vertical/social-schema.json +0 -620
- package/dungeons/vertical/travel-schema.json +0 -580
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// ── TWEAK THESE ──
|
|
2
2
|
const SEED = "meetcute";
|
|
3
3
|
const num_days = 120;
|
|
4
|
-
const num_users =
|
|
5
|
-
const avg_events_per_user_per_day =
|
|
4
|
+
const num_users = 30_000;
|
|
5
|
+
const avg_events_per_user_per_day = 1.5;
|
|
6
6
|
let token = "your-mixpanel-token";
|
|
7
7
|
|
|
8
8
|
// ── env overrides ──
|
|
@@ -16,9 +16,6 @@ import * as v from "ak-tools";
|
|
|
16
16
|
|
|
17
17
|
dayjs.extend(utc);
|
|
18
18
|
const chance = u.initChance(SEED);
|
|
19
|
-
const NOW = dayjs();
|
|
20
|
-
const DATASET_START = NOW.subtract(num_days, "days");
|
|
21
|
-
|
|
22
19
|
/** @typedef {import("../../types").Dungeon} Config */
|
|
23
20
|
|
|
24
21
|
/*
|
|
@@ -51,183 +48,184 @@ const DATASET_START = NOW.subtract(num_days, "days");
|
|
|
51
48
|
|
|
52
49
|
/*
|
|
53
50
|
* =====================================================================================
|
|
54
|
-
* ANALYTICS HOOKS (
|
|
51
|
+
* ANALYTICS HOOKS (9 hooks)
|
|
55
52
|
* =====================================================================================
|
|
56
53
|
*
|
|
54
|
+
* NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable via
|
|
55
|
+
* behavioral cohorts, raw-prop breakdowns, or funnel analysis.
|
|
56
|
+
*
|
|
57
57
|
* -------------------------------------------------------------------------------------
|
|
58
|
-
* 1. PHOTO
|
|
58
|
+
* 1. PHOTO MAGIC NUMBER (everything)
|
|
59
59
|
* -------------------------------------------------------------------------------------
|
|
60
|
-
* PATTERN:
|
|
61
|
-
*
|
|
62
|
-
*
|
|
60
|
+
* PATTERN: Sweet 2-5 photos uploaded → 2-4 extra cloned match-received events
|
|
61
|
+
* per existing match. Over 6+ photos → match_score drops 35% on match-received
|
|
62
|
+
* events (over-curated profile reads as fake; quality matches don't trust it).
|
|
63
|
+
* No flag.
|
|
63
64
|
*
|
|
64
65
|
* HOW TO FIND IT IN MIXPANEL:
|
|
65
|
-
* Report 1:
|
|
66
|
-
* -
|
|
67
|
-
* -
|
|
66
|
+
* Report 1: Matches per User by Photo-Count Bucket
|
|
67
|
+
* - Cohort A: users with 2-5 "photo uploaded"
|
|
68
|
+
* - Cohort B: users with 0-1
|
|
69
|
+
* - Event: "match received" → Total per user
|
|
70
|
+
* - Expected: A ~ 2-3x B
|
|
68
71
|
*
|
|
69
|
-
* Report 2: Photo
|
|
70
|
-
* -
|
|
71
|
-
* -
|
|
72
|
+
* Report 2: Avg match_score on Heavy Photo Uploaders
|
|
73
|
+
* - Cohort C: users with >= 6 "photo uploaded"
|
|
74
|
+
* - Cohort A: users with 2-5
|
|
75
|
+
* - Event: "match received" → AVG of match_score
|
|
76
|
+
* - Expected: C ~ 0.65x A (35% lower match quality)
|
|
77
|
+
*
|
|
78
|
+
* REAL-WORLD ANALOGUE: A few good photos signal authenticity; too many
|
|
79
|
+
* curated shots read as catfish or staged.
|
|
72
80
|
*
|
|
73
81
|
* -------------------------------------------------------------------------------------
|
|
74
|
-
* 2. WEEKEND SWIPE SURGE (
|
|
82
|
+
* 2. WEEKEND SWIPE SURGE (everything)
|
|
75
83
|
* -------------------------------------------------------------------------------------
|
|
76
|
-
* PATTERN:
|
|
77
|
-
*
|
|
84
|
+
* PATTERN: Sunday swipes get heavy cloning (evening 4x, daytime 2x)
|
|
85
|
+
* to overcome soup DOW weight deficit. No flag.
|
|
78
86
|
*
|
|
79
87
|
* HOW TO FIND IT IN MIXPANEL:
|
|
80
88
|
* Report 1: Swipe Volume by Day of Week
|
|
81
|
-
* - Insights
|
|
82
|
-
* - Expected: Sunday
|
|
89
|
+
* - Insights > "swipe right" → Total → Breakdown: Day of Week
|
|
90
|
+
* - Expected: Sunday taller than other days
|
|
83
91
|
*
|
|
84
|
-
*
|
|
85
|
-
* - Insights (line) > "swipe right" > Total > Breakdown: "weekend_surge"
|
|
86
|
-
* - Expected: weekend_surge=true events clustered on Sunday evenings
|
|
92
|
+
* REAL-WORLD ANALOGUE: Sunday Scaries drive swipe activity.
|
|
87
93
|
*
|
|
88
94
|
* -------------------------------------------------------------------------------------
|
|
89
|
-
* 3. SUPER-LIKE EFFECT (everything
|
|
95
|
+
* 3. SUPER-LIKE EFFECT (everything)
|
|
90
96
|
* -------------------------------------------------------------------------------------
|
|
91
|
-
* PATTERN:
|
|
92
|
-
*
|
|
93
|
-
* matches. Super-likes dramatically improve match rates.
|
|
97
|
+
* PATTERN: Each is_super_like=true swipe clones 3 extra match-received events
|
|
98
|
+
* within 5-120 minutes. No flag.
|
|
94
99
|
*
|
|
95
100
|
* HOW TO FIND IT IN MIXPANEL:
|
|
96
|
-
* Report 1:
|
|
97
|
-
* -
|
|
98
|
-
* -
|
|
101
|
+
* Report 1: Super-Like to Match Funnel
|
|
102
|
+
* - Funnels > "swipe right" (filter: is_super_like=true) → "match received"
|
|
103
|
+
* - vs same with is_super_like=false
|
|
104
|
+
* - Expected: super-like funnel ~ 3x higher conversion
|
|
99
105
|
*
|
|
100
|
-
*
|
|
101
|
-
* - Funnels > "swipe right" (filter: is_super_like=true) > "match received"
|
|
102
|
-
* - Compare to: "swipe right" (filter: is_super_like=false) > "match received"
|
|
103
|
-
* - Expected: super-like funnel ~3x higher conversion
|
|
106
|
+
* REAL-WORLD ANALOGUE: Super-likes dramatically lift match rates.
|
|
104
107
|
*
|
|
105
108
|
* -------------------------------------------------------------------------------------
|
|
106
|
-
* 4. PREMIUM MATCH BOOST (everything
|
|
109
|
+
* 4. PREMIUM MATCH BOOST (everything)
|
|
107
110
|
* -------------------------------------------------------------------------------------
|
|
108
|
-
* PATTERN: Premium subscribers get 2x
|
|
109
|
-
*
|
|
110
|
-
* premium_boost=true on injected events. Subscription pays off.
|
|
111
|
+
* PATTERN: Premium subscribers get 2x match events; Elite get 4x + cloned
|
|
112
|
+
* profile-viewed events (see-who-liked-you). Reads subscription from profile.
|
|
111
113
|
*
|
|
112
114
|
* HOW TO FIND IT IN MIXPANEL:
|
|
113
115
|
* Report 1: Matches by Subscription Tier
|
|
114
|
-
* - Insights > "match received"
|
|
115
|
-
* - Expected: Free ~
|
|
116
|
+
* - Insights > "match received" → Total per user → Breakdown: subscription
|
|
117
|
+
* - Expected: Free ~ 1x, Premium ~ 2x, Elite ~ 4x
|
|
116
118
|
*
|
|
117
|
-
*
|
|
118
|
-
* - Insights > "profile viewed" > Total > Breakdown: "premium_boost"
|
|
119
|
-
* - Expected: premium_boost=true events only from Elite subscribers
|
|
119
|
+
* REAL-WORLD ANALOGUE: Premium tiers boost visibility.
|
|
120
120
|
*
|
|
121
121
|
* -------------------------------------------------------------------------------------
|
|
122
|
-
* 5. GHOSTING CHURN (everything
|
|
122
|
+
* 5. GHOSTING CHURN (everything)
|
|
123
123
|
* -------------------------------------------------------------------------------------
|
|
124
|
-
* PATTERN: Users
|
|
125
|
-
*
|
|
126
|
-
* remaining "app opened" events. Ghosters churn.
|
|
124
|
+
* PATTERN: Users with match-received but no message-sent within 48 hours lose
|
|
125
|
+
* 80% of post-match events. No flag.
|
|
127
126
|
*
|
|
128
127
|
* HOW TO FIND IT IN MIXPANEL:
|
|
129
128
|
* Report 1: Retention by Messaging Behavior
|
|
130
|
-
* - Retention
|
|
131
|
-
* -
|
|
132
|
-
* -
|
|
129
|
+
* - Retention starting "match received"
|
|
130
|
+
* - Cohort A: users with >= 1 "message sent" within 48 hours of match
|
|
131
|
+
* - Cohort B: rest
|
|
132
|
+
* - Expected: B drops sharply
|
|
133
133
|
*
|
|
134
|
-
*
|
|
135
|
-
* - Insights (line) > "app opened" > Total > Breakdown: "ghosted"
|
|
136
|
-
* - Expected: ghosted=true users trail off sharply after match date
|
|
134
|
+
* REAL-WORLD ANALOGUE: Non-responders churn.
|
|
137
135
|
*
|
|
138
136
|
* -------------------------------------------------------------------------------------
|
|
139
|
-
* 6. BIO + PROMPT POWER USERS (everything
|
|
137
|
+
* 6. BIO + PROMPT POWER USERS (everything)
|
|
140
138
|
* -------------------------------------------------------------------------------------
|
|
141
|
-
* PATTERN: Users with
|
|
142
|
-
*
|
|
143
|
-
* dates. Effort on profile = real dates.
|
|
139
|
+
* PATTERN: Users with bio-updated AND 3+ prompt-answered events get 3 extra
|
|
140
|
+
* cloned date-scheduled events per existing. No flag.
|
|
144
141
|
*
|
|
145
142
|
* HOW TO FIND IT IN MIXPANEL:
|
|
146
|
-
* Report 1: Dates by Profile Effort
|
|
147
|
-
* -
|
|
148
|
-
* -
|
|
143
|
+
* Report 1: Dates per User by Profile Effort
|
|
144
|
+
* - Cohort A: users with bio-updated AND >= 3 "prompt answered"
|
|
145
|
+
* - Cohort B: rest
|
|
146
|
+
* - Event: "date scheduled" → Total per user
|
|
147
|
+
* - Expected: A ~ 4x B
|
|
149
148
|
*
|
|
150
|
-
*
|
|
151
|
-
* - Funnels > "bio updated" > "prompt answered" > "date scheduled"
|
|
152
|
-
* - Expected: users completing all 3 have dramatically higher date rates
|
|
149
|
+
* REAL-WORLD ANALOGUE: Profile effort signals serious intent.
|
|
153
150
|
*
|
|
154
151
|
* -------------------------------------------------------------------------------------
|
|
155
|
-
* 7. VALENTINE'S DAY SPIKE (
|
|
152
|
+
* 7. VALENTINE'S DAY SPIKE (everything)
|
|
156
153
|
* -------------------------------------------------------------------------------------
|
|
157
|
-
* PATTERN:
|
|
158
|
-
*
|
|
159
|
-
* events 5x for users active during V-Day window. Love is expensive.
|
|
154
|
+
* PATTERN: Days 58-63 (V-Day window): profile-created events cloned 3x and
|
|
155
|
+
* premium-upgrade events cloned 5x. No flag — discover via line chart by day.
|
|
160
156
|
*
|
|
161
157
|
* HOW TO FIND IT IN MIXPANEL:
|
|
162
|
-
* Report 1: Signup
|
|
163
|
-
* - Insights
|
|
164
|
-
* - Expected:
|
|
158
|
+
* Report 1: Signup Volume Over Time
|
|
159
|
+
* - Insights > "profile created" → Total → Line by day
|
|
160
|
+
* - Expected: spike ~ 3x days 58-63
|
|
165
161
|
*
|
|
166
|
-
*
|
|
167
|
-
* - Insights (line) > "premium upgrade" > Total > Breakdown: "valentines"
|
|
168
|
-
* - Expected: 5x premium upgrades during V-Day window
|
|
162
|
+
* REAL-WORLD ANALOGUE: Love is expensive.
|
|
169
163
|
*
|
|
170
164
|
* -------------------------------------------------------------------------------------
|
|
171
|
-
* 8. OFF-APP RETENTION (everything
|
|
165
|
+
* 8. OFF-APP RETENTION (everything)
|
|
172
166
|
* -------------------------------------------------------------------------------------
|
|
173
|
-
* PATTERN: Users with
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
* once they find someone IRL.
|
|
167
|
+
* PATTERN: Users with phone-exchanged OR date-scheduled in first 14 days
|
|
168
|
+
* get extra cloned app-open + swipe events past day 30. Non-milestone
|
|
169
|
+
* users lose 80% of post-day-30 events. No flag.
|
|
177
170
|
*
|
|
178
171
|
* HOW TO FIND IT IN MIXPANEL:
|
|
179
|
-
* Report 1: Retention by Milestone
|
|
180
|
-
* -
|
|
181
|
-
* -
|
|
182
|
-
* - Expected:
|
|
172
|
+
* Report 1: Retention by Milestone Cohort
|
|
173
|
+
* - Cohort A: users with phone-exchanged OR date-scheduled in first 14 days
|
|
174
|
+
* - Cohort B: rest
|
|
175
|
+
* - Expected: A ~ 60% D30 retention vs B ~ 20%
|
|
183
176
|
*
|
|
184
|
-
*
|
|
185
|
-
* - Insights (line) > "app opened" > Total per user > Weekly > Breakdown: user prop "subscription"
|
|
186
|
-
* - Expected: after week 4, non-milestone users nearly disappear
|
|
177
|
+
* REAL-WORLD ANALOGUE: Once they find someone IRL, app becomes irrelevant.
|
|
187
178
|
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
179
|
+
* -------------------------------------------------------------------------------------
|
|
180
|
+
* 9. MATCH FLOW TIME-TO-CONVERT (funnel-post)
|
|
181
|
+
* -------------------------------------------------------------------------------------
|
|
182
|
+
* PATTERN: Elite users complete swipe→match→message funnel 1.4x faster
|
|
183
|
+
* (factor 0.71 on inter-event gaps); Free users 1.4x slower (factor 1.4).
|
|
191
184
|
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
185
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
186
|
+
* Report 1: Match Flow Median Time-to-Convert by Tier
|
|
187
|
+
* - Funnels > "swipe right" → "match received" → "message sent"
|
|
188
|
+
* - Measure: Median time to convert
|
|
189
|
+
* - Breakdown: subscription
|
|
190
|
+
* - Expected: Elite ~ 0.71x; Free ~ 1.4x
|
|
191
|
+
*
|
|
192
|
+
* NOTE (funnel-post measurement): visible only via Mixpanel funnel
|
|
193
|
+
* median TTC. Cross-event MIN→MIN SQL queries on raw events do NOT
|
|
194
|
+
* show this — funnel-post adjusts gaps within funnel instances, not
|
|
195
|
+
* across the user's full event history.
|
|
196
|
+
*
|
|
197
|
+
* REAL-WORLD ANALOGUE: Premium notifications + boost surface matches faster.
|
|
202
198
|
*
|
|
203
199
|
* =====================================================================================
|
|
204
|
-
*
|
|
200
|
+
* EXPECTED METRICS SUMMARY
|
|
205
201
|
* =====================================================================================
|
|
206
202
|
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
* -
|
|
218
|
-
*
|
|
219
|
-
* date funnel by profile completeness.
|
|
220
|
-
* =====================================================================================
|
|
203
|
+
* Hook | Metric | Baseline | Effect | Ratio
|
|
204
|
+
* ----------------------------|----------------------|----------|----------|------
|
|
205
|
+
* Photo Magic Number | sweet matches/user | 1x | ~ 1.7x | 1.7x
|
|
206
|
+
* Photo Magic Number | over matches/user | 1x | 0.7x | -30%
|
|
207
|
+
* Weekend Swipe Surge | Sunday swipes | 1x | ~ 1.5x | 1.5x
|
|
208
|
+
* Super-Like Effect | matches from super | 1x | 3x | 3x
|
|
209
|
+
* Premium Match Boost | matches (Elite) | 1x | 4x | 4x
|
|
210
|
+
* Ghosting Churn | post-match retention | 100% | 20% | 0.2x
|
|
211
|
+
* Bio + Prompt Power Users | dates/user | 1x | 4x | 4x
|
|
212
|
+
* Valentine's Day Spike | signups days 58-63 | 1x | 3x | 3x
|
|
213
|
+
* Off-App Retention | D30 retention | ~ 20% | ~ 60% | 3x
|
|
214
|
+
* Match Flow T2C | median min by tier | 1x | 0.71x/1.4x| ~ 2x range
|
|
221
215
|
*/
|
|
222
216
|
|
|
223
217
|
/** @type {Config} */
|
|
224
218
|
const config = {
|
|
219
|
+
version: 2,
|
|
225
220
|
token,
|
|
226
221
|
seed: SEED,
|
|
227
|
-
|
|
222
|
+
datasetStart: "2026-01-01T00:00:00Z",
|
|
223
|
+
datasetEnd: "2026-05-01T23:59:59Z",
|
|
224
|
+
// numDays: num_days,
|
|
228
225
|
avgEventsPerUserPerDay: avg_events_per_user_per_day,
|
|
229
226
|
numUsers: num_users,
|
|
230
|
-
hasAnonIds:
|
|
227
|
+
hasAnonIds: true,
|
|
228
|
+
avgDevicePerUser: 2,
|
|
231
229
|
hasSessionIds: true,
|
|
232
230
|
format: "json",
|
|
233
231
|
gzip: true,
|
|
@@ -251,16 +249,16 @@ const config = {
|
|
|
251
249
|
event: "profile created",
|
|
252
250
|
weight: 1,
|
|
253
251
|
isFirstEvent: true,
|
|
252
|
+
isAuthEvent: true,
|
|
254
253
|
properties: {
|
|
255
254
|
age_range: ["18-24", "25-29", "30-34", "35-39", "40+"],
|
|
256
255
|
gender: ["Male", "Male", "Female", "Female", "Non-binary"],
|
|
257
256
|
looking_for: ["Men", "Women", "Everyone"],
|
|
258
|
-
valentines: [false],
|
|
259
257
|
},
|
|
260
258
|
},
|
|
261
259
|
{
|
|
262
260
|
event: "photo uploaded",
|
|
263
|
-
weight:
|
|
261
|
+
weight: 12,
|
|
264
262
|
properties: {
|
|
265
263
|
photo_number: u.weighNumRange(1, 6, 0.5, 2),
|
|
266
264
|
has_face: [true, true, true, true, false],
|
|
@@ -286,7 +284,6 @@ const config = {
|
|
|
286
284
|
weight: 10,
|
|
287
285
|
properties: {
|
|
288
286
|
is_super_like: [false, false, false, false, false, false, false, false, false, true],
|
|
289
|
-
weekend_surge: [false],
|
|
290
287
|
swipe_source: ["feed", "feed", "feed", "discover", "boost", "nearby"],
|
|
291
288
|
},
|
|
292
289
|
},
|
|
@@ -302,9 +299,6 @@ const config = {
|
|
|
302
299
|
weight: 4,
|
|
303
300
|
properties: {
|
|
304
301
|
match_score: u.weighNumRange(50, 100, 0.5, 75),
|
|
305
|
-
complete_profile: [false],
|
|
306
|
-
super_like_match: [false],
|
|
307
|
-
premium_boost: [false],
|
|
308
302
|
},
|
|
309
303
|
},
|
|
310
304
|
{
|
|
@@ -335,7 +329,6 @@ const config = {
|
|
|
335
329
|
weight: 1,
|
|
336
330
|
properties: {
|
|
337
331
|
venue_type: ["coffee", "dinner", "drinks", "activity", "virtual"],
|
|
338
|
-
complete_profile_power: [false],
|
|
339
332
|
},
|
|
340
333
|
},
|
|
341
334
|
{
|
|
@@ -343,7 +336,6 @@ const config = {
|
|
|
343
336
|
weight: 5,
|
|
344
337
|
properties: {
|
|
345
338
|
viewer_source: ["feed", "discover", "liked_you", "nearby"],
|
|
346
|
-
premium_boost: [false],
|
|
347
339
|
},
|
|
348
340
|
},
|
|
349
341
|
{
|
|
@@ -352,7 +344,6 @@ const config = {
|
|
|
352
344
|
properties: {
|
|
353
345
|
plan: ["Premium", "Premium", "Premium", "Elite"],
|
|
354
346
|
price_usd: [14.99, 14.99, 14.99, 29.99],
|
|
355
|
-
valentines: [false],
|
|
356
347
|
},
|
|
357
348
|
},
|
|
358
349
|
{
|
|
@@ -385,7 +376,6 @@ const config = {
|
|
|
385
376
|
weight: 8,
|
|
386
377
|
properties: {
|
|
387
378
|
session_duration_mins: u.weighNumRange(1, 120, 0.3, 8),
|
|
388
|
-
ghosted: [false],
|
|
389
379
|
},
|
|
390
380
|
},
|
|
391
381
|
],
|
|
@@ -430,7 +420,7 @@ const config = {
|
|
|
430
420
|
// ── SuperProps ──────────────────────────────────────────
|
|
431
421
|
superProps: {
|
|
432
422
|
subscription: ["Free", "Free", "Free", "Premium", "Elite"],
|
|
433
|
-
|
|
423
|
+
Platform: ["ios", "ios", "android"],
|
|
434
424
|
},
|
|
435
425
|
|
|
436
426
|
// ── UserProps ──────────────────────────────────────────
|
|
@@ -443,7 +433,7 @@ const config = {
|
|
|
443
433
|
total_matches: u.weighNumRange(0, 200, 0.3, 15),
|
|
444
434
|
total_messages_sent: u.weighNumRange(0, 500, 0.3, 30),
|
|
445
435
|
profile_completeness: ["incomplete", "incomplete", "basic", "basic", "complete"],
|
|
446
|
-
|
|
436
|
+
Platform: ["ios", "ios", "android"],
|
|
447
437
|
},
|
|
448
438
|
|
|
449
439
|
// ── SCD Props ──────────────────────────────────────────
|
|
@@ -461,25 +451,25 @@ const config = {
|
|
|
461
451
|
mirrorProps: {},
|
|
462
452
|
lookupTables: [],
|
|
463
453
|
|
|
464
|
-
// ── Hook Function ──────────────────────────────────────
|
|
465
454
|
hook: function (record, type, meta) {
|
|
466
|
-
const VALENTINES_DAY = DATASET_START.add(60, "days");
|
|
467
|
-
const VDAY_WINDOW_START = DATASET_START.add(58, "days");
|
|
468
|
-
const VDAY_WINDOW_END = DATASET_START.add(63, "days");
|
|
469
|
-
|
|
470
|
-
// ─── EVENT-LEVEL HOOKS ───────────────────────────────────────────
|
|
471
|
-
|
|
472
|
-
if (type === "event") {
|
|
473
|
-
const EVENT_TIME = dayjs(record.time);
|
|
474
455
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
if (record.
|
|
481
|
-
|
|
482
|
-
|
|
456
|
+
// HOOK 9 (T2C): MATCH FLOW TIME-TO-CONVERT (funnel-post)
|
|
457
|
+
// Elite users complete swipe→match→message funnel 1.4x faster
|
|
458
|
+
// (factor 0.71); Free users 1.4x slower (factor 1.4).
|
|
459
|
+
if (type === "funnel-post") {
|
|
460
|
+
const segment = meta?.profile?.subscription;
|
|
461
|
+
if (Array.isArray(record) && record.length > 1) {
|
|
462
|
+
const factor = (
|
|
463
|
+
segment === "Elite" ? 0.71 :
|
|
464
|
+
segment === "Free" ? 1.4 :
|
|
465
|
+
1.0
|
|
466
|
+
);
|
|
467
|
+
if (factor !== 1.0) {
|
|
468
|
+
for (let i = 1; i < record.length; i++) {
|
|
469
|
+
const prev = dayjs(record[i - 1].time);
|
|
470
|
+
const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
|
|
471
|
+
record[i].time = prev.add(newGap, "milliseconds").toISOString();
|
|
472
|
+
}
|
|
483
473
|
}
|
|
484
474
|
}
|
|
485
475
|
}
|
|
@@ -487,25 +477,25 @@ const config = {
|
|
|
487
477
|
// ─── EVERYTHING-LEVEL HOOKS ──────────────────────────────────────
|
|
488
478
|
|
|
489
479
|
if (type === "everything") {
|
|
480
|
+
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
481
|
+
const VDAY_WINDOW_START = datasetStart.add(58, "days");
|
|
482
|
+
const VDAY_WINDOW_END = datasetStart.add(63, "days");
|
|
490
483
|
const events = record;
|
|
491
484
|
if (!events || events.length === 0) return record;
|
|
492
485
|
|
|
493
486
|
const profile = meta.profile || {};
|
|
494
487
|
|
|
495
|
-
// Stamp superProps from profile for consistency
|
|
496
488
|
events.forEach(e => {
|
|
497
489
|
if (profile.subscription) e.subscription = profile.subscription;
|
|
498
490
|
if (profile.platform) e.platform = profile.platform;
|
|
499
491
|
});
|
|
500
492
|
|
|
501
|
-
// ── First pass: scan user patterns ──
|
|
502
493
|
let photoUploadCount = 0;
|
|
503
494
|
let promptAnsweredCount = 0;
|
|
504
495
|
let hasBioUpdated = false;
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
let superLikeEvents = [];
|
|
496
|
+
const matchEvents = [];
|
|
497
|
+
const messageSentEvents = [];
|
|
498
|
+
const superLikeEvents = [];
|
|
509
499
|
let hasPhoneExchangedEarly = false;
|
|
510
500
|
let hasDateScheduledEarly = false;
|
|
511
501
|
let firstEventTime = null;
|
|
@@ -519,13 +509,9 @@ const config = {
|
|
|
519
509
|
if (event.event === "bio updated") hasBioUpdated = true;
|
|
520
510
|
if (event.event === "match received") matchEvents.push(event);
|
|
521
511
|
if (event.event === "message sent") messageSentEvents.push(event);
|
|
522
|
-
if (event.event === "swipe right" && event.is_super_like === true)
|
|
523
|
-
hasSuperLike = true;
|
|
524
|
-
superLikeEvents.push(event);
|
|
525
|
-
}
|
|
512
|
+
if (event.event === "swipe right" && event.is_super_like === true) superLikeEvents.push(event);
|
|
526
513
|
});
|
|
527
514
|
|
|
528
|
-
// Check early milestones (first 14 days)
|
|
529
515
|
if (firstEventTime) {
|
|
530
516
|
const earlyWindow = dayjs(firstEventTime).add(14, "days");
|
|
531
517
|
events.forEach(event => {
|
|
@@ -537,65 +523,59 @@ const config = {
|
|
|
537
523
|
});
|
|
538
524
|
}
|
|
539
525
|
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
|
|
526
|
+
// HOOK 1 + HOOK 9: PHOTO MAGIC NUMBER (no flags)
|
|
527
|
+
// Sweet 2-5 photos → clone 2-4 extra match events per existing.
|
|
528
|
+
// Over 6+ photos → drop match_score by 35% on match received events
|
|
529
|
+
// (over-curated profile reads as fake/staged).
|
|
530
|
+
if (photoUploadCount >= 2 && photoUploadCount <= 5 && matchEvents.length > 0) {
|
|
543
531
|
const matchTemplate = matchEvents[0];
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
532
|
+
matchEvents.forEach(m => {
|
|
533
|
+
const extras = chance.integer({ min: 2, max: 4 });
|
|
534
|
+
for (let i = 0; i < extras; i++) {
|
|
535
|
+
events.push({
|
|
536
|
+
...matchTemplate,
|
|
537
|
+
time: dayjs(m.time).add(chance.integer({ min: 1, max: 180 }), "minutes").toISOString(),
|
|
538
|
+
user_id: m.user_id,
|
|
539
|
+
match_score: chance.integer({ min: 60, max: 98 }),
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
} else if (photoUploadCount >= 6) {
|
|
544
|
+
events.forEach(e => {
|
|
545
|
+
if (e.event === "match received" && typeof e.match_score === "number") {
|
|
546
|
+
e.match_score = Math.max(20, Math.round(e.match_score * 0.65));
|
|
547
|
+
}
|
|
548
|
+
});
|
|
558
549
|
}
|
|
559
550
|
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
const dow = new Date(event.time).getUTCDay();
|
|
565
|
-
const hr = new Date(event.time).getUTCHours();
|
|
566
|
-
if (dow === 0 && hr >= 18 && hr <= 23) {
|
|
567
|
-
event.weekend_surge = true;
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
});
|
|
571
|
-
// Duplicate weekend_surge=true swipes to create 2.5x volume
|
|
551
|
+
// HOOK 2: WEEKEND SWIPE SURGE — Sunday swipes get heavy cloning
|
|
552
|
+
// to overcome the soup DOW weight deficit. Evening swipes (18-23)
|
|
553
|
+
// get 4 clones; daytime Sunday swipes get 2 clones.
|
|
554
|
+
// No flag — discover via day-of-week chart.
|
|
572
555
|
for (let idx = events.length - 1; idx >= 0; idx--) {
|
|
573
556
|
const event = events[idx];
|
|
574
|
-
if (event.event === "swipe right"
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
});
|
|
557
|
+
if (event.event === "swipe right") {
|
|
558
|
+
const dow = new Date(event.time).getUTCDay();
|
|
559
|
+
if (dow === 0) {
|
|
560
|
+
const hr = new Date(event.time).getUTCHours();
|
|
561
|
+
const clones = (hr >= 18 && hr <= 23) ? 4 : 2;
|
|
562
|
+
const etime = dayjs(event.time);
|
|
563
|
+
for (let c = 0; c < clones; c++) {
|
|
564
|
+
events.push({
|
|
565
|
+
...event,
|
|
566
|
+
time: etime.add(chance.integer({ min: 1, max: 60 }), "minutes").toISOString(),
|
|
567
|
+
user_id: event.user_id,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
588
570
|
}
|
|
589
571
|
}
|
|
590
572
|
}
|
|
591
573
|
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
: events.find(e => e.event === "swipe right") || events[0];
|
|
598
|
-
|
|
574
|
+
// HOOK 3: SUPER-LIKE EFFECT — clone 3 extra match events per
|
|
575
|
+
// super-like, near in time. No flag — discover via funnel
|
|
576
|
+
// "swipe right where is_super_like=true" → "match received".
|
|
577
|
+
if (superLikeEvents.length > 0) {
|
|
578
|
+
const matchTemplate = matchEvents[0] || events[0];
|
|
599
579
|
superLikeEvents.forEach(sle => {
|
|
600
580
|
for (let i = 0; i < 3; i++) {
|
|
601
581
|
events.push({
|
|
@@ -604,88 +584,112 @@ const config = {
|
|
|
604
584
|
time: dayjs(sle.time).add(chance.integer({ min: 5, max: 120 }), "minutes").toISOString(),
|
|
605
585
|
user_id: sle.user_id,
|
|
606
586
|
match_score: chance.integer({ min: 70, max: 99 }),
|
|
607
|
-
complete_profile: false,
|
|
608
|
-
super_like_match: true,
|
|
609
|
-
premium_boost: false,
|
|
610
587
|
});
|
|
611
588
|
}
|
|
612
589
|
});
|
|
613
590
|
}
|
|
614
591
|
|
|
615
|
-
//
|
|
616
|
-
//
|
|
592
|
+
// HOOK 5: GHOSTING CHURN — users with match but no message within
|
|
593
|
+
// 48hrs lose 80% of post-match events. No flag.
|
|
594
|
+
// (runs BEFORE premium boost so injected premium matches survive)
|
|
595
|
+
if (matchEvents.length > 0) {
|
|
596
|
+
let hasTimely = false;
|
|
597
|
+
for (const m of matchEvents) {
|
|
598
|
+
const matchTime = dayjs(m.time);
|
|
599
|
+
const deadline = matchTime.add(48, "hours");
|
|
600
|
+
for (const msg of messageSentEvents) {
|
|
601
|
+
const msgTime = dayjs(msg.time);
|
|
602
|
+
if (msgTime.isAfter(matchTime) && msgTime.isBefore(deadline)) {
|
|
603
|
+
hasTimely = true;
|
|
604
|
+
break;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
if (hasTimely) break;
|
|
608
|
+
}
|
|
609
|
+
if (!hasTimely) {
|
|
610
|
+
const earliestMatch = matchEvents.reduce((min, m) =>
|
|
611
|
+
dayjs(m.time).isBefore(dayjs(min.time)) ? m : min
|
|
612
|
+
);
|
|
613
|
+
const churnAfter = dayjs(earliestMatch.time);
|
|
614
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
615
|
+
if (dayjs(events[i].time).isAfter(churnAfter) && chance.bool({ likelihood: 80 })) {
|
|
616
|
+
events.splice(i, 1);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// HOOK 4: PREMIUM MATCH BOOST — Premium 2x, Elite 4x match events.
|
|
623
|
+
// Elite users also get profile-viewed events injected (see-who-liked-you).
|
|
624
|
+
// Reads subscription from profile. Runs AFTER ghosting churn so
|
|
625
|
+
// injected matches are not culled.
|
|
617
626
|
const sub = profile.subscription;
|
|
618
627
|
if ((sub === "Premium" || sub === "Elite") && matchEvents.length > 0) {
|
|
619
|
-
|
|
628
|
+
// Count surviving match events post-churn
|
|
629
|
+
const survivingMatches = events.filter(e => e.event === "match received");
|
|
630
|
+
const baseCount = survivingMatches.length || 1;
|
|
631
|
+
const targetMultiplier = sub === "Elite" ? 4 : 2;
|
|
632
|
+
const toAdd = Math.max(0, baseCount * targetMultiplier - baseCount);
|
|
620
633
|
const matchTemplate = matchEvents[0];
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
const sourceMatch = matchEvents[i % matchEvents.length];
|
|
634
|
+
for (let i = 0; i < toAdd; i++) {
|
|
635
|
+
const sourceMatch = survivingMatches[i % survivingMatches.length] || matchTemplate;
|
|
624
636
|
events.push({
|
|
625
637
|
...matchTemplate,
|
|
626
|
-
event: "match received",
|
|
627
638
|
time: dayjs(sourceMatch.time).add(chance.integer({ min: 10, max: 240 }), "minutes").toISOString(),
|
|
628
639
|
user_id: sourceMatch.user_id,
|
|
629
640
|
match_score: chance.integer({ min: 65, max: 99 }),
|
|
630
|
-
complete_profile: false,
|
|
631
|
-
super_like_match: false,
|
|
632
|
-
premium_boost: true,
|
|
633
641
|
});
|
|
634
642
|
}
|
|
635
|
-
|
|
636
|
-
// Elite exclusive: inject "profile viewed" events (see-who-liked-you)
|
|
637
643
|
if (sub === "Elite") {
|
|
638
644
|
const viewTemplate = events.find(e => e.event === "profile viewed") || matchTemplate;
|
|
639
|
-
|
|
645
|
+
survivingMatches.forEach(m => {
|
|
640
646
|
events.push({
|
|
641
647
|
...viewTemplate,
|
|
642
648
|
event: "profile viewed",
|
|
643
649
|
time: dayjs(m.time).subtract(chance.integer({ min: 10, max: 120 }), "minutes").toISOString(),
|
|
644
650
|
user_id: m.user_id,
|
|
645
651
|
viewer_source: "liked_you",
|
|
646
|
-
premium_boost: true,
|
|
647
652
|
});
|
|
648
653
|
});
|
|
649
654
|
}
|
|
650
655
|
}
|
|
651
656
|
|
|
652
|
-
//
|
|
653
|
-
//
|
|
657
|
+
// HOOK 6: BIO + PROMPT POWER USERS — bio + 3+ prompts → 3 extra
|
|
658
|
+
// cloned date events per existing. No flag.
|
|
654
659
|
if (hasBioUpdated && promptAnsweredCount >= 3) {
|
|
655
660
|
const dateEvents = events.filter(e => e.event === "date scheduled");
|
|
656
661
|
if (dateEvents.length > 0) {
|
|
657
662
|
const dateTemplate = dateEvents[0];
|
|
658
|
-
const extraDates = dateEvents.length * 3; // +3 = 4x total
|
|
659
663
|
const venueTypes = ["coffee", "dinner", "drinks", "activity", "virtual"];
|
|
660
|
-
for (let i = 0; i <
|
|
664
|
+
for (let i = 0; i < dateEvents.length * 3; i++) {
|
|
661
665
|
const sourceDate = dateEvents[i % dateEvents.length];
|
|
662
666
|
events.push({
|
|
663
667
|
...dateTemplate,
|
|
664
|
-
event: "date scheduled",
|
|
665
668
|
time: dayjs(sourceDate.time).add(chance.integer({ min: 1, max: 72 }), "hours").toISOString(),
|
|
666
669
|
user_id: sourceDate.user_id,
|
|
667
670
|
venue_type: chance.pickone(venueTypes),
|
|
668
|
-
complete_profile_power: true,
|
|
669
671
|
});
|
|
670
672
|
}
|
|
671
673
|
}
|
|
672
674
|
}
|
|
673
675
|
|
|
674
|
-
//
|
|
675
|
-
// 3x
|
|
676
|
-
const vdaySignups = events.filter(e =>
|
|
676
|
+
// HOOK 7: VALENTINE'S DAY SPIKE — clone profile-created events during
|
|
677
|
+
// days 58-63 (3x volume), plus clone premium-upgrade events 5x. No flag.
|
|
678
|
+
const vdaySignups = events.filter(e =>
|
|
679
|
+
e.event === "profile created" &&
|
|
680
|
+
dayjs(e.time).isAfter(VDAY_WINDOW_START) &&
|
|
681
|
+
dayjs(e.time).isBefore(VDAY_WINDOW_END)
|
|
682
|
+
);
|
|
677
683
|
vdaySignups.forEach(signup => {
|
|
678
|
-
for (let i = 0; i < 2; i++) {
|
|
684
|
+
for (let i = 0; i < 2; i++) {
|
|
679
685
|
events.push({
|
|
680
686
|
...signup,
|
|
681
687
|
time: dayjs(signup.time).add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
|
|
682
688
|
user_id: signup.user_id,
|
|
683
|
-
valentines: true,
|
|
684
689
|
});
|
|
685
690
|
}
|
|
686
691
|
});
|
|
687
692
|
|
|
688
|
-
// 5x premium upgrade events for users active during V-Day window
|
|
689
693
|
const vdayUpgrades = events.filter(e =>
|
|
690
694
|
e.event === "premium upgrade" &&
|
|
691
695
|
dayjs(e.time).isAfter(VDAY_WINDOW_START) &&
|
|
@@ -694,74 +698,29 @@ const config = {
|
|
|
694
698
|
if (vdayUpgrades.length > 0) {
|
|
695
699
|
const upgradeTemplate = vdayUpgrades[0];
|
|
696
700
|
vdayUpgrades.forEach(upgrade => {
|
|
697
|
-
for (let i = 0; i < 4; i++) {
|
|
701
|
+
for (let i = 0; i < 4; i++) {
|
|
698
702
|
events.push({
|
|
699
703
|
...upgradeTemplate,
|
|
700
|
-
event: "premium upgrade",
|
|
701
704
|
time: dayjs(upgrade.time).add(chance.integer({ min: 1, max: 24 }), "hours").toISOString(),
|
|
702
705
|
user_id: upgrade.user_id,
|
|
703
706
|
plan: upgrade.plan,
|
|
704
707
|
price_usd: upgrade.price_usd,
|
|
705
|
-
valentines: true,
|
|
706
708
|
});
|
|
707
709
|
}
|
|
708
710
|
});
|
|
709
711
|
}
|
|
710
712
|
|
|
711
|
-
//
|
|
712
|
-
//
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
// Check if any match has a message within 48 hours
|
|
716
|
-
let hasTimely = false;
|
|
717
|
-
for (const m of matchEvents) {
|
|
718
|
-
const matchTime = dayjs(m.time);
|
|
719
|
-
const deadline = matchTime.add(48, "hours");
|
|
720
|
-
for (const msg of messageSentEvents) {
|
|
721
|
-
const msgTime = dayjs(msg.time);
|
|
722
|
-
if (msgTime.isAfter(matchTime) && msgTime.isBefore(deadline)) {
|
|
723
|
-
hasTimely = true;
|
|
724
|
-
break;
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
if (hasTimely) break;
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
if (!hasTimely) {
|
|
731
|
-
isGhoster = true;
|
|
732
|
-
// Find earliest match time as churn reference point
|
|
733
|
-
const earliestMatch = matchEvents.reduce((min, m) =>
|
|
734
|
-
dayjs(m.time).isBefore(dayjs(min.time)) ? m : min
|
|
735
|
-
);
|
|
736
|
-
const churnAfter = dayjs(earliestMatch.time);
|
|
737
|
-
|
|
738
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
739
|
-
const evt = events[i];
|
|
740
|
-
if (dayjs(evt.time).isAfter(churnAfter)) {
|
|
741
|
-
if (chance.bool({ likelihood: 70 })) {
|
|
742
|
-
events.splice(i, 1);
|
|
743
|
-
} else if (evt.event === "app opened") {
|
|
744
|
-
evt.ghosted = true;
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
// ── HOOK 8: OFF-APP RETENTION ───────────────────
|
|
752
|
-
// Users with phone exchange or date in first 14 days: 60% D30 retention
|
|
753
|
-
// Others: lose events after day 30
|
|
713
|
+
// HOOK 8: OFF-APP RETENTION — users with phone-exchanged or
|
|
714
|
+
// date-scheduled in first 14 days get extra cloned app-open + swipe
|
|
715
|
+
// events past day 30. Non-milestone users lose 80% of post-day-30
|
|
716
|
+
// events. No flag.
|
|
754
717
|
if (firstEventTime) {
|
|
755
718
|
const day30 = dayjs(firstEventTime).add(30, "days");
|
|
756
719
|
const hasEarlyMilestone = hasPhoneExchangedEarly || hasDateScheduledEarly;
|
|
757
|
-
|
|
758
720
|
if (hasEarlyMilestone) {
|
|
759
|
-
// Good retention: inject extra events after day 30 to sustain engagement
|
|
760
721
|
const appOpenedTemplate = events.find(e => e.event === "app opened") || events[0];
|
|
761
722
|
const swipeTemplate = events.find(e => e.event === "swipe right") || events[0];
|
|
762
723
|
const postDay30Events = events.filter(e => dayjs(e.time).isAfter(day30));
|
|
763
|
-
|
|
764
|
-
// If they don't have enough post-day30 events, inject some
|
|
765
724
|
if (postDay30Events.length < events.length * 0.3) {
|
|
766
725
|
const retentionCount = Math.floor(events.length * 0.3);
|
|
767
726
|
for (let i = 0; i < retentionCount; i++) {
|
|
@@ -775,14 +734,9 @@ const config = {
|
|
|
775
734
|
}
|
|
776
735
|
}
|
|
777
736
|
} else {
|
|
778
|
-
// No milestone: churn after day 30
|
|
779
737
|
for (let i = events.length - 1; i >= 0; i--) {
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
// Keep ~20% of events (remove ~80%) to simulate natural drop-off
|
|
783
|
-
if (chance.bool({ likelihood: 80 })) {
|
|
784
|
-
events.splice(i, 1);
|
|
785
|
-
}
|
|
738
|
+
if (dayjs(events[i].time).isAfter(day30) && chance.bool({ likelihood: 80 })) {
|
|
739
|
+
events.splice(i, 1);
|
|
786
740
|
}
|
|
787
741
|
}
|
|
788
742
|
}
|