@ak--47/dungeon-master 1.2.3 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +51 -13
  3. package/dungeons/technical/ad-spend.js +2 -2
  4. package/dungeons/technical/anonymous-users.js +2 -2
  5. package/dungeons/technical/array-of-object-lookup.js +2 -4
  6. package/dungeons/technical/experiments.js +2 -2
  7. package/dungeons/technical/foobar.js +2 -2
  8. package/dungeons/technical/group-analytics.js +2 -2
  9. package/dungeons/technical/mirror-strategies.js +2 -2
  10. package/dungeons/technical/nested-objects.js +2 -2
  11. package/dungeons/technical/retention-cadence.js +2 -3
  12. package/dungeons/technical/sanity.js +2 -2
  13. package/dungeons/technical/scale-test.js +2 -2
  14. package/dungeons/technical/scd.js +2 -2
  15. package/dungeons/technical/simple.js +5 -6
  16. package/dungeons/technical/simplest-schema.json +5 -0
  17. package/dungeons/technical/simplest.js +2 -2
  18. package/dungeons/technical/text-generation.js +3 -3
  19. package/dungeons/vertical/ai-platform.js +858 -0
  20. package/dungeons/vertical/community.js +84 -40
  21. package/dungeons/vertical/crypto.js +830 -0
  22. package/dungeons/vertical/dating.js +744 -0
  23. package/dungeons/vertical/devtools.js +175 -69
  24. package/dungeons/vertical/ecommerce.js +242 -94
  25. package/dungeons/vertical/education.js +330 -313
  26. package/dungeons/vertical/fintech.js +442 -313
  27. package/dungeons/vertical/fitness.js +143 -61
  28. package/dungeons/vertical/food-delivery.js +327 -353
  29. package/dungeons/vertical/gaming.js +912 -382
  30. package/dungeons/vertical/healthcare.js +142 -63
  31. package/dungeons/vertical/insurance-application.js +170 -76
  32. package/dungeons/vertical/logistics.js +115 -20
  33. package/dungeons/vertical/marketplace.js +152 -58
  34. package/dungeons/vertical/media.js +248 -384
  35. package/dungeons/vertical/real-estate.js +781 -0
  36. package/dungeons/vertical/sass.js +255 -266
  37. package/dungeons/vertical/social.js +264 -206
  38. package/dungeons/vertical/travel.js +117 -41
  39. package/index.js +17 -17
  40. package/lib/core/config-validator.js +159 -31
  41. package/lib/core/context.js +10 -24
  42. package/lib/core/storage.js +6 -1
  43. package/lib/generators/events.js +11 -14
  44. package/lib/generators/funnels.js +12 -4
  45. package/lib/generators/mirror.js +3 -2
  46. package/lib/generators/product-names.js +1 -1
  47. package/lib/generators/scd.js +2 -1
  48. package/lib/generators/text.js +1 -1
  49. package/lib/orchestrators/user-loop.js +81 -47
  50. package/lib/templates/macro-presets.js +111 -0
  51. package/lib/templates/soup-presets.js +19 -36
  52. package/lib/utils/utils.js +71 -39
  53. package/package.json +8 -2
  54. package/scripts/smoke-test-all.mjs +162 -0
  55. package/scripts/verify-runner.mjs +72 -24
  56. package/types.d.ts +251 -51
  57. package/dungeons/technical/ad-spend-schema.json +0 -128
  58. package/dungeons/technical/anonymous-users-schema.json +0 -92
  59. package/dungeons/technical/array-of-object-lookup-schema.json +0 -191
  60. package/dungeons/technical/experiments-schema.json +0 -203
  61. package/dungeons/technical/foobar-schema.json +0 -362
  62. package/dungeons/technical/group-analytics-schema.json +0 -241
  63. package/dungeons/technical/mirror-strategies-schema.json +0 -84
  64. package/dungeons/technical/nested-objects-schema.json +0 -145
  65. package/dungeons/technical/retention-cadence-schema.json +0 -37
  66. package/dungeons/technical/sanity-schema.json +0 -185
  67. package/dungeons/technical/scale-test-schema.json +0 -70
  68. package/dungeons/technical/scd-schema.json +0 -467
  69. package/dungeons/technical/simple-schema.json +0 -362
  70. package/dungeons/technical/text-generation-schema.json +0 -1062
  71. package/dungeons/user/.gitkeep +0 -0
  72. package/dungeons/vertical/community-schema.json +0 -579
  73. package/dungeons/vertical/devtools-schema.json +0 -601
  74. package/dungeons/vertical/ecommerce-schema.json +0 -604
  75. package/dungeons/vertical/education-schema.json +0 -5686
  76. package/dungeons/vertical/fintech-schema.json +0 -630
  77. package/dungeons/vertical/fitness-schema.json +0 -530
  78. package/dungeons/vertical/food-delivery-schema.json +0 -36728
  79. package/dungeons/vertical/gaming-schema.json +0 -438
  80. package/dungeons/vertical/healthcare-schema.json +0 -549
  81. package/dungeons/vertical/insurance-application-schema.json +0 -485
  82. package/dungeons/vertical/logistics-schema.json +0 -574
  83. package/dungeons/vertical/marketplace-schema.json +0 -533
  84. package/dungeons/vertical/media-schema.json +0 -4749
  85. package/dungeons/vertical/rpg-schema.json +0 -2491
  86. package/dungeons/vertical/rpg.js +0 -976
  87. package/dungeons/vertical/sass-schema.json +0 -3128
  88. package/dungeons/vertical/social-schema.json +0 -620
  89. package/dungeons/vertical/travel-schema.json +0 -580
@@ -0,0 +1,744 @@
1
+ // ── TWEAK THESE ──
2
+ const SEED = "meetcute";
3
+ const num_days = 120;
4
+ const num_users = 30_000;
5
+ const avg_events_per_user_per_day = 1.5;
6
+ let token = "your-mixpanel-token";
7
+
8
+ // ── env overrides ──
9
+ if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
10
+
11
+ import dayjs from "dayjs";
12
+ import utc from "dayjs/plugin/utc.js";
13
+ import "dotenv/config";
14
+ import * as u from "../../lib/utils/utils.js";
15
+ import * as v from "ak-tools";
16
+
17
+ dayjs.extend(utc);
18
+ const chance = u.initChance(SEED);
19
+ /** @typedef {import("../../types").Dungeon} Config */
20
+
21
+ /*
22
+ * =====================================================================================
23
+ * DATASET OVERVIEW
24
+ * =====================================================================================
25
+ *
26
+ * MeetCute — a swipe-based dating app (Hinge/Tinder-style) with profile
27
+ * prompts, photo verification, matchmaking, messaging, and premium tiers.
28
+ *
29
+ * CORE LOOP:
30
+ * Users create a profile with photos and prompts, swipe on potential
31
+ * matches, receive matches, message matches, exchange phone numbers,
32
+ * and schedule dates. Premium subscribers get boosts, super-likes,
33
+ * and see-who-liked-you.
34
+ *
35
+ * - 8,000 users over 120 days
36
+ * - ~720,000 base events across 17 event types
37
+ * - 4 funnels (onboarding, match flow, date funnel, monetization)
38
+ * - 3 subscription tiers: Free, Premium, Elite
39
+ *
40
+ * Key entities:
41
+ * - swipe_source: feed / discover / boost / nearby
42
+ * - subscription: Free / Premium / Elite
43
+ * - venue_type: coffee / dinner / drinks / activity / virtual
44
+ * - prompt_type: icebreaker / opinion / hypothetical / personal / creative
45
+ *
46
+ * =====================================================================================
47
+ */
48
+
49
+ /*
50
+ * =====================================================================================
51
+ * ANALYTICS HOOKS (9 hooks)
52
+ * =====================================================================================
53
+ *
54
+ * NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable via
55
+ * behavioral cohorts, raw-prop breakdowns, or funnel analysis.
56
+ *
57
+ * -------------------------------------------------------------------------------------
58
+ * 1. PHOTO MAGIC NUMBER (everything)
59
+ * -------------------------------------------------------------------------------------
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.
64
+ *
65
+ * HOW TO FIND IT IN MIXPANEL:
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
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.
80
+ *
81
+ * -------------------------------------------------------------------------------------
82
+ * 2. WEEKEND SWIPE SURGE (everything)
83
+ * -------------------------------------------------------------------------------------
84
+ * PATTERN: Sunday 18-23 UTC swipes get 1.5x duplication. No flag.
85
+ *
86
+ * HOW TO FIND IT IN MIXPANEL:
87
+ * Report 1: Swipe Volume by Day of Week
88
+ * - Insights > "swipe right" → Total → Breakdown: Day of Week
89
+ * - Expected: Sunday taller than other days
90
+ *
91
+ * REAL-WORLD ANALOGUE: Sunday Scaries drive swipe activity.
92
+ *
93
+ * -------------------------------------------------------------------------------------
94
+ * 3. SUPER-LIKE EFFECT (everything)
95
+ * -------------------------------------------------------------------------------------
96
+ * PATTERN: Each is_super_like=true swipe clones 3 extra match-received events
97
+ * within 5-120 minutes. No flag.
98
+ *
99
+ * HOW TO FIND IT IN MIXPANEL:
100
+ * Report 1: Super-Like to Match Funnel
101
+ * - Funnels > "swipe right" (filter: is_super_like=true) → "match received"
102
+ * - vs same with is_super_like=false
103
+ * - Expected: super-like funnel ~ 3x higher conversion
104
+ *
105
+ * REAL-WORLD ANALOGUE: Super-likes dramatically lift match rates.
106
+ *
107
+ * -------------------------------------------------------------------------------------
108
+ * 4. PREMIUM MATCH BOOST (everything)
109
+ * -------------------------------------------------------------------------------------
110
+ * PATTERN: Premium subscribers get 2x match events; Elite get 4x + cloned
111
+ * profile-viewed events (see-who-liked-you). Reads subscription from profile.
112
+ *
113
+ * HOW TO FIND IT IN MIXPANEL:
114
+ * Report 1: Matches by Subscription Tier
115
+ * - Insights > "match received" → Total per user → Breakdown: subscription
116
+ * - Expected: Free ~ 1x, Premium ~ 2x, Elite ~ 4x
117
+ *
118
+ * REAL-WORLD ANALOGUE: Premium tiers boost visibility.
119
+ *
120
+ * -------------------------------------------------------------------------------------
121
+ * 5. GHOSTING CHURN (everything)
122
+ * -------------------------------------------------------------------------------------
123
+ * PATTERN: Users with match-received but no message-sent within 48 hours lose
124
+ * 80% of post-match events. No flag.
125
+ *
126
+ * HOW TO FIND IT IN MIXPANEL:
127
+ * Report 1: Retention by Messaging Behavior
128
+ * - Retention starting "match received"
129
+ * - Cohort A: users with >= 1 "message sent" within 48 hours of match
130
+ * - Cohort B: rest
131
+ * - Expected: B drops sharply
132
+ *
133
+ * REAL-WORLD ANALOGUE: Non-responders churn.
134
+ *
135
+ * -------------------------------------------------------------------------------------
136
+ * 6. BIO + PROMPT POWER USERS (everything)
137
+ * -------------------------------------------------------------------------------------
138
+ * PATTERN: Users with bio-updated AND 3+ prompt-answered events get 3 extra
139
+ * cloned date-scheduled events per existing. No flag.
140
+ *
141
+ * HOW TO FIND IT IN MIXPANEL:
142
+ * Report 1: Dates per User by Profile Effort
143
+ * - Cohort A: users with bio-updated AND >= 3 "prompt answered"
144
+ * - Cohort B: rest
145
+ * - Event: "date scheduled" → Total per user
146
+ * - Expected: A ~ 4x B
147
+ *
148
+ * REAL-WORLD ANALOGUE: Profile effort signals serious intent.
149
+ *
150
+ * -------------------------------------------------------------------------------------
151
+ * 7. VALENTINE'S DAY SPIKE (everything)
152
+ * -------------------------------------------------------------------------------------
153
+ * PATTERN: Days 58-63 (V-Day window): profile-created events cloned 3x and
154
+ * premium-upgrade events cloned 5x. No flag — discover via line chart by day.
155
+ *
156
+ * HOW TO FIND IT IN MIXPANEL:
157
+ * Report 1: Signup Volume Over Time
158
+ * - Insights > "profile created" → Total → Line by day
159
+ * - Expected: spike ~ 3x days 58-63
160
+ *
161
+ * REAL-WORLD ANALOGUE: Love is expensive.
162
+ *
163
+ * -------------------------------------------------------------------------------------
164
+ * 8. OFF-APP RETENTION (everything)
165
+ * -------------------------------------------------------------------------------------
166
+ * PATTERN: Users with phone-exchanged OR date-scheduled in first 14 days
167
+ * get extra cloned app-open + swipe events past day 30. Non-milestone
168
+ * users lose 80% of post-day-30 events. No flag.
169
+ *
170
+ * HOW TO FIND IT IN MIXPANEL:
171
+ * Report 1: Retention by Milestone Cohort
172
+ * - Cohort A: users with phone-exchanged OR date-scheduled in first 14 days
173
+ * - Cohort B: rest
174
+ * - Expected: A ~ 60% D30 retention vs B ~ 20%
175
+ *
176
+ * REAL-WORLD ANALOGUE: Once they find someone IRL, app becomes irrelevant.
177
+ *
178
+ * -------------------------------------------------------------------------------------
179
+ * 9. MATCH FLOW TIME-TO-CONVERT (funnel-post)
180
+ * -------------------------------------------------------------------------------------
181
+ * PATTERN: Elite users complete swipe→match→message funnel 1.4x faster
182
+ * (factor 0.71 on inter-event gaps); Free users 1.4x slower (factor 1.4).
183
+ *
184
+ * HOW TO FIND IT IN MIXPANEL:
185
+ * Report 1: Match Flow Median Time-to-Convert by Tier
186
+ * - Funnels > "swipe right" → "match received" → "message sent"
187
+ * - Measure: Median time to convert
188
+ * - Breakdown: subscription
189
+ * - Expected: Elite ~ 0.71x; Free ~ 1.4x
190
+ *
191
+ * NOTE (funnel-post measurement): visible only via Mixpanel funnel
192
+ * median TTC. Cross-event MIN→MIN SQL queries on raw events do NOT
193
+ * show this — funnel-post adjusts gaps within funnel instances, not
194
+ * across the user's full event history.
195
+ *
196
+ * REAL-WORLD ANALOGUE: Premium notifications + boost surface matches faster.
197
+ *
198
+ * =====================================================================================
199
+ * EXPECTED METRICS SUMMARY
200
+ * =====================================================================================
201
+ *
202
+ * Hook | Metric | Baseline | Effect | Ratio
203
+ * ----------------------------|----------------------|----------|----------|------
204
+ * Photo Magic Number | sweet matches/user | 1x | ~ 1.7x | 1.7x
205
+ * Photo Magic Number | over matches/user | 1x | 0.7x | -30%
206
+ * Weekend Swipe Surge | Sunday swipes | 1x | ~ 1.5x | 1.5x
207
+ * Super-Like Effect | matches from super | 1x | 3x | 3x
208
+ * Premium Match Boost | matches (Elite) | 1x | 4x | 4x
209
+ * Ghosting Churn | post-match retention | 100% | 20% | 0.2x
210
+ * Bio + Prompt Power Users | dates/user | 1x | 4x | 4x
211
+ * Valentine's Day Spike | signups days 58-63 | 1x | 3x | 3x
212
+ * Off-App Retention | D30 retention | ~ 20% | ~ 60% | 3x
213
+ * Match Flow T2C | median min by tier | 1x | 0.71x/1.4x| ~ 2x range
214
+ */
215
+
216
+ /** @type {Config} */
217
+ const config = {
218
+ token,
219
+ seed: SEED,
220
+ datasetStart: "2026-01-01T00:00:00Z",
221
+ datasetEnd: "2026-04-28T23:59:59Z",
222
+ // numDays: num_days,
223
+ avgEventsPerUserPerDay: avg_events_per_user_per_day,
224
+ numUsers: num_users,
225
+ hasAnonIds: false,
226
+ hasSessionIds: true,
227
+ format: "json",
228
+ gzip: true,
229
+ alsoInferFunnels: false,
230
+ hasLocation: true,
231
+ hasAndroidDevices: true,
232
+ hasIOSDevices: true,
233
+ hasDesktopDevices: false,
234
+ hasBrowser: false,
235
+ hasCampaigns: false,
236
+ isAnonymous: false,
237
+ hasAdSpend: false,
238
+ hasAvatar: true,
239
+ concurrency: 1,
240
+ writeToDisk: false,
241
+ soup: "growth",
242
+
243
+ // ── Events (17) ──────────────────────────────────────────
244
+ events: [
245
+ {
246
+ event: "profile created",
247
+ weight: 1,
248
+ isFirstEvent: true,
249
+ properties: {
250
+ age_range: ["18-24", "25-29", "30-34", "35-39", "40+"],
251
+ gender: ["Male", "Male", "Female", "Female", "Non-binary"],
252
+ looking_for: ["Men", "Women", "Everyone"],
253
+ },
254
+ },
255
+ {
256
+ event: "photo uploaded",
257
+ weight: 12,
258
+ properties: {
259
+ photo_number: u.weighNumRange(1, 6, 0.5, 2),
260
+ has_face: [true, true, true, true, false],
261
+ },
262
+ },
263
+ {
264
+ event: "bio updated",
265
+ weight: 2,
266
+ properties: {
267
+ bio_length: u.weighNumRange(10, 500, 0.4, 120),
268
+ },
269
+ },
270
+ {
271
+ event: "prompt answered",
272
+ weight: 3,
273
+ properties: {
274
+ prompt_type: ["icebreaker", "opinion", "hypothetical", "personal", "creative"],
275
+ answer_length: u.weighNumRange(10, 300, 0.4, 80),
276
+ },
277
+ },
278
+ {
279
+ event: "swipe right",
280
+ weight: 10,
281
+ properties: {
282
+ is_super_like: [false, false, false, false, false, false, false, false, false, true],
283
+ swipe_source: ["feed", "feed", "feed", "discover", "boost", "nearby"],
284
+ },
285
+ },
286
+ {
287
+ event: "swipe left",
288
+ weight: 8,
289
+ properties: {
290
+ swipe_source: ["feed", "feed", "feed", "discover", "boost", "nearby"],
291
+ },
292
+ },
293
+ {
294
+ event: "match received",
295
+ weight: 4,
296
+ properties: {
297
+ match_score: u.weighNumRange(50, 100, 0.5, 75),
298
+ },
299
+ },
300
+ {
301
+ event: "message sent",
302
+ weight: 6,
303
+ properties: {
304
+ message_length: u.weighNumRange(1, 500, 0.3, 40),
305
+ has_emoji: [false, false, true, true, true],
306
+ response_time_mins: u.weighNumRange(1, 1440, 0.3, 30),
307
+ },
308
+ },
309
+ {
310
+ event: "message received",
311
+ weight: 5,
312
+ properties: {
313
+ message_length: u.weighNumRange(1, 500, 0.3, 50),
314
+ },
315
+ },
316
+ {
317
+ event: "phone number exchanged",
318
+ weight: 1,
319
+ properties: {
320
+ exchange_method: ["in_chat", "in_chat", "voice_call", "video_call"],
321
+ },
322
+ },
323
+ {
324
+ event: "date scheduled",
325
+ weight: 1,
326
+ properties: {
327
+ venue_type: ["coffee", "dinner", "drinks", "activity", "virtual"],
328
+ },
329
+ },
330
+ {
331
+ event: "profile viewed",
332
+ weight: 5,
333
+ properties: {
334
+ viewer_source: ["feed", "discover", "liked_you", "nearby"],
335
+ },
336
+ },
337
+ {
338
+ event: "premium upgrade",
339
+ weight: 1,
340
+ properties: {
341
+ plan: ["Premium", "Premium", "Premium", "Elite"],
342
+ price_usd: [14.99, 14.99, 14.99, 29.99],
343
+ },
344
+ },
345
+ {
346
+ event: "premium cancelled",
347
+ weight: 1,
348
+ isStrictEvent: true,
349
+ properties: {
350
+ cancel_reason: ["found_someone", "too_expensive", "not_enough_matches", "bad_experience", "taking_a_break"],
351
+ subscription_duration_days: u.weighNumRange(7, 365, 0.3, 30),
352
+ },
353
+ },
354
+ {
355
+ event: "boost activated",
356
+ weight: 2,
357
+ properties: {
358
+ boost_type: ["standard", "super"],
359
+ boost_duration_mins: [15, 30, 30, 60],
360
+ },
361
+ },
362
+ {
363
+ event: "report user",
364
+ weight: 1,
365
+ isStrictEvent: true,
366
+ properties: {
367
+ report_reason: ["fake_profile", "inappropriate_photos", "harassment", "spam", "underage"],
368
+ },
369
+ },
370
+ {
371
+ event: "app opened",
372
+ weight: 8,
373
+ properties: {
374
+ session_duration_mins: u.weighNumRange(1, 120, 0.3, 8),
375
+ },
376
+ },
377
+ ],
378
+
379
+ // ── Funnels (4) ──────────────────────────────────────────
380
+ funnels: [
381
+ {
382
+ name: "Onboarding",
383
+ sequence: ["profile created", "photo uploaded", "swipe right"],
384
+ conversionRate: 75,
385
+ order: "sequential",
386
+ isFirstFunnel: true,
387
+ timeToConvert: 1,
388
+ weight: 3,
389
+ },
390
+ {
391
+ name: "Match Flow",
392
+ sequence: ["swipe right", "match received", "message sent"],
393
+ conversionRate: 50,
394
+ order: "sequential",
395
+ timeToConvert: 24,
396
+ weight: 6,
397
+ },
398
+ {
399
+ name: "Date Funnel",
400
+ sequence: ["message sent", "phone number exchanged", "date scheduled"],
401
+ conversionRate: 25,
402
+ order: "sequential",
403
+ timeToConvert: 72,
404
+ weight: 3,
405
+ },
406
+ {
407
+ name: "Monetization",
408
+ sequence: ["app opened", "boost activated", "premium upgrade"],
409
+ conversionRate: 20,
410
+ order: "sequential",
411
+ timeToConvert: 48,
412
+ weight: 2,
413
+ },
414
+ ],
415
+
416
+ // ── SuperProps ──────────────────────────────────────────
417
+ superProps: {
418
+ subscription: ["Free", "Free", "Free", "Premium", "Elite"],
419
+ Platform: ["ios", "ios", "android"],
420
+ },
421
+
422
+ // ── UserProps ──────────────────────────────────────────
423
+ userProps: {
424
+ subscription: ["Free", "Free", "Free", "Premium", "Elite"],
425
+ age_range: ["18-24", "25-29", "30-34", "35-39", "40+"],
426
+ gender: ["Male", "Male", "Female", "Female", "Non-binary"],
427
+ looking_for: ["Men", "Women", "Everyone"],
428
+ photo_count: u.weighNumRange(0, 8, 0.4, 3),
429
+ total_matches: u.weighNumRange(0, 200, 0.3, 15),
430
+ total_messages_sent: u.weighNumRange(0, 500, 0.3, 30),
431
+ profile_completeness: ["incomplete", "incomplete", "basic", "basic", "complete"],
432
+ Platform: ["ios", "ios", "android"],
433
+ },
434
+
435
+ // ── SCD Props ──────────────────────────────────────────
436
+ scdProps: {
437
+ subscription_tier: {
438
+ values: ["Free", "Premium", "Elite"],
439
+ frequency: "month",
440
+ timing: "fuzzy",
441
+ max: 6,
442
+ },
443
+ },
444
+
445
+ groupKeys: [],
446
+ groupProps: {},
447
+ mirrorProps: {},
448
+ lookupTables: [],
449
+
450
+ hook: function (record, type, meta) {
451
+
452
+ // HOOK 9 (T2C): MATCH FLOW TIME-TO-CONVERT (funnel-post)
453
+ // Elite users complete swipe→match→message funnel 1.4x faster
454
+ // (factor 0.71); Free users 1.4x slower (factor 1.4).
455
+ if (type === "funnel-post") {
456
+ const segment = meta?.profile?.subscription;
457
+ if (Array.isArray(record) && record.length > 1) {
458
+ const factor = (
459
+ segment === "Elite" ? 0.71 :
460
+ segment === "Free" ? 1.4 :
461
+ 1.0
462
+ );
463
+ if (factor !== 1.0) {
464
+ for (let i = 1; i < record.length; i++) {
465
+ const prev = dayjs(record[i - 1].time);
466
+ const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
467
+ record[i].time = prev.add(newGap, "milliseconds").toISOString();
468
+ }
469
+ }
470
+ }
471
+ }
472
+
473
+ // ─── EVERYTHING-LEVEL HOOKS ──────────────────────────────────────
474
+
475
+ if (type === "everything") {
476
+ const datasetStart = dayjs.unix(meta.datasetStart);
477
+ const VDAY_WINDOW_START = datasetStart.add(58, "days");
478
+ const VDAY_WINDOW_END = datasetStart.add(63, "days");
479
+ const events = record;
480
+ if (!events || events.length === 0) return record;
481
+
482
+ const profile = meta.profile || {};
483
+
484
+ events.forEach(e => {
485
+ if (profile.subscription) e.subscription = profile.subscription;
486
+ if (profile.platform) e.platform = profile.platform;
487
+ });
488
+
489
+ let photoUploadCount = 0;
490
+ let promptAnsweredCount = 0;
491
+ let hasBioUpdated = false;
492
+ const matchEvents = [];
493
+ const messageSentEvents = [];
494
+ const superLikeEvents = [];
495
+ let hasPhoneExchangedEarly = false;
496
+ let hasDateScheduledEarly = false;
497
+ let firstEventTime = null;
498
+
499
+ events.forEach(event => {
500
+ if (!firstEventTime || dayjs(event.time).isBefore(dayjs(firstEventTime))) {
501
+ firstEventTime = event.time;
502
+ }
503
+ if (event.event === "photo uploaded") photoUploadCount++;
504
+ if (event.event === "prompt answered") promptAnsweredCount++;
505
+ if (event.event === "bio updated") hasBioUpdated = true;
506
+ if (event.event === "match received") matchEvents.push(event);
507
+ if (event.event === "message sent") messageSentEvents.push(event);
508
+ if (event.event === "swipe right" && event.is_super_like === true) superLikeEvents.push(event);
509
+ });
510
+
511
+ if (firstEventTime) {
512
+ const earlyWindow = dayjs(firstEventTime).add(14, "days");
513
+ events.forEach(event => {
514
+ const t = dayjs(event.time);
515
+ if (t.isBefore(earlyWindow)) {
516
+ if (event.event === "phone number exchanged") hasPhoneExchangedEarly = true;
517
+ if (event.event === "date scheduled") hasDateScheduledEarly = true;
518
+ }
519
+ });
520
+ }
521
+
522
+ // HOOK 1 + HOOK 9: PHOTO MAGIC NUMBER (no flags)
523
+ // Sweet 2-5 photos → clone 2-4 extra match events per existing.
524
+ // Over 6+ photos → drop match_score by 35% on match received events
525
+ // (over-curated profile reads as fake/staged).
526
+ if (photoUploadCount >= 2 && photoUploadCount <= 5 && matchEvents.length > 0) {
527
+ const matchTemplate = matchEvents[0];
528
+ matchEvents.forEach(m => {
529
+ const extras = chance.integer({ min: 2, max: 4 });
530
+ for (let i = 0; i < extras; i++) {
531
+ events.push({
532
+ ...matchTemplate,
533
+ time: dayjs(m.time).add(chance.integer({ min: 1, max: 180 }), "minutes").toISOString(),
534
+ user_id: m.user_id,
535
+ match_score: chance.integer({ min: 60, max: 98 }),
536
+ });
537
+ }
538
+ });
539
+ } else if (photoUploadCount >= 6) {
540
+ events.forEach(e => {
541
+ if (e.event === "match received" && typeof e.match_score === "number") {
542
+ e.match_score = Math.max(20, Math.round(e.match_score * 0.65));
543
+ }
544
+ });
545
+ }
546
+
547
+ // HOOK 2: WEEKEND SWIPE SURGE — Sunday 18-23 UTC swipes get cloned
548
+ // 1.5x. No flag — discover via day-of-week chart.
549
+ for (let idx = events.length - 1; idx >= 0; idx--) {
550
+ const event = events[idx];
551
+ if (event.event === "swipe right") {
552
+ const dow = new Date(event.time).getUTCDay();
553
+ const hr = new Date(event.time).getUTCHours();
554
+ if (dow === 0 && hr >= 18 && hr <= 23) {
555
+ const etime = dayjs(event.time);
556
+ events.push({
557
+ ...event,
558
+ time: etime.add(chance.integer({ min: 1, max: 30 }), "minutes").toISOString(),
559
+ user_id: event.user_id,
560
+ });
561
+ if (chance.bool({ likelihood: 50 })) {
562
+ events.push({
563
+ ...event,
564
+ time: etime.add(chance.integer({ min: 5, max: 60 }), "minutes").toISOString(),
565
+ user_id: event.user_id,
566
+ });
567
+ }
568
+ }
569
+ }
570
+ }
571
+
572
+ // HOOK 3: SUPER-LIKE EFFECT — clone 3 extra match events per
573
+ // super-like, near in time. No flag — discover via funnel
574
+ // "swipe right where is_super_like=true" → "match received".
575
+ if (superLikeEvents.length > 0) {
576
+ const matchTemplate = matchEvents[0] || events[0];
577
+ superLikeEvents.forEach(sle => {
578
+ for (let i = 0; i < 3; i++) {
579
+ events.push({
580
+ ...matchTemplate,
581
+ event: "match received",
582
+ time: dayjs(sle.time).add(chance.integer({ min: 5, max: 120 }), "minutes").toISOString(),
583
+ user_id: sle.user_id,
584
+ match_score: chance.integer({ min: 70, max: 99 }),
585
+ });
586
+ }
587
+ });
588
+ }
589
+
590
+ // HOOK 4: PREMIUM MATCH BOOST — Premium 2x, Elite 4x match events.
591
+ // Elite users also get profile-viewed events injected (see-who-liked-you).
592
+ // Reads subscription from profile.
593
+ const sub = profile.subscription;
594
+ if ((sub === "Premium" || sub === "Elite") && matchEvents.length > 0) {
595
+ const multiplier = sub === "Elite" ? 3 : 1;
596
+ const matchTemplate = matchEvents[0];
597
+ for (let i = 0; i < matchEvents.length * multiplier; i++) {
598
+ const sourceMatch = matchEvents[i % matchEvents.length];
599
+ events.push({
600
+ ...matchTemplate,
601
+ time: dayjs(sourceMatch.time).add(chance.integer({ min: 10, max: 240 }), "minutes").toISOString(),
602
+ user_id: sourceMatch.user_id,
603
+ match_score: chance.integer({ min: 65, max: 99 }),
604
+ });
605
+ }
606
+ if (sub === "Elite") {
607
+ const viewTemplate = events.find(e => e.event === "profile viewed") || matchTemplate;
608
+ matchEvents.forEach(m => {
609
+ events.push({
610
+ ...viewTemplate,
611
+ event: "profile viewed",
612
+ time: dayjs(m.time).subtract(chance.integer({ min: 10, max: 120 }), "minutes").toISOString(),
613
+ user_id: m.user_id,
614
+ viewer_source: "liked_you",
615
+ });
616
+ });
617
+ }
618
+ }
619
+
620
+ // HOOK 6: BIO + PROMPT POWER USERS — bio + 3+ prompts → 3 extra
621
+ // cloned date events per existing. No flag.
622
+ if (hasBioUpdated && promptAnsweredCount >= 3) {
623
+ const dateEvents = events.filter(e => e.event === "date scheduled");
624
+ if (dateEvents.length > 0) {
625
+ const dateTemplate = dateEvents[0];
626
+ const venueTypes = ["coffee", "dinner", "drinks", "activity", "virtual"];
627
+ for (let i = 0; i < dateEvents.length * 3; i++) {
628
+ const sourceDate = dateEvents[i % dateEvents.length];
629
+ events.push({
630
+ ...dateTemplate,
631
+ time: dayjs(sourceDate.time).add(chance.integer({ min: 1, max: 72 }), "hours").toISOString(),
632
+ user_id: sourceDate.user_id,
633
+ venue_type: chance.pickone(venueTypes),
634
+ });
635
+ }
636
+ }
637
+ }
638
+
639
+ // HOOK 7: VALENTINE'S DAY SPIKE — clone profile-created events during
640
+ // days 58-63 (3x volume), plus clone premium-upgrade events 5x. No flag.
641
+ const vdaySignups = events.filter(e =>
642
+ e.event === "profile created" &&
643
+ dayjs(e.time).isAfter(VDAY_WINDOW_START) &&
644
+ dayjs(e.time).isBefore(VDAY_WINDOW_END)
645
+ );
646
+ vdaySignups.forEach(signup => {
647
+ for (let i = 0; i < 2; i++) {
648
+ events.push({
649
+ ...signup,
650
+ time: dayjs(signup.time).add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
651
+ user_id: signup.user_id,
652
+ });
653
+ }
654
+ });
655
+
656
+ const vdayUpgrades = events.filter(e =>
657
+ e.event === "premium upgrade" &&
658
+ dayjs(e.time).isAfter(VDAY_WINDOW_START) &&
659
+ dayjs(e.time).isBefore(VDAY_WINDOW_END)
660
+ );
661
+ if (vdayUpgrades.length > 0) {
662
+ const upgradeTemplate = vdayUpgrades[0];
663
+ vdayUpgrades.forEach(upgrade => {
664
+ for (let i = 0; i < 4; i++) {
665
+ events.push({
666
+ ...upgradeTemplate,
667
+ time: dayjs(upgrade.time).add(chance.integer({ min: 1, max: 24 }), "hours").toISOString(),
668
+ user_id: upgrade.user_id,
669
+ plan: upgrade.plan,
670
+ price_usd: upgrade.price_usd,
671
+ });
672
+ }
673
+ });
674
+ }
675
+
676
+ // HOOK 8: OFF-APP RETENTION — users with phone-exchanged or
677
+ // date-scheduled in first 14 days get extra cloned app-open + swipe
678
+ // events past day 30. Non-milestone users lose 80% of post-day-30
679
+ // events. No flag.
680
+ if (firstEventTime) {
681
+ const day30 = dayjs(firstEventTime).add(30, "days");
682
+ const hasEarlyMilestone = hasPhoneExchangedEarly || hasDateScheduledEarly;
683
+ if (hasEarlyMilestone) {
684
+ const appOpenedTemplate = events.find(e => e.event === "app opened") || events[0];
685
+ const swipeTemplate = events.find(e => e.event === "swipe right") || events[0];
686
+ const postDay30Events = events.filter(e => dayjs(e.time).isAfter(day30));
687
+ if (postDay30Events.length < events.length * 0.3) {
688
+ const retentionCount = Math.floor(events.length * 0.3);
689
+ for (let i = 0; i < retentionCount; i++) {
690
+ const daysAfter = chance.integer({ min: 1, max: 60 });
691
+ const template = chance.bool({ likelihood: 50 }) ? appOpenedTemplate : swipeTemplate;
692
+ events.push({
693
+ ...template,
694
+ time: day30.add(daysAfter, "days").add(chance.integer({ min: 0, max: 23 }), "hours").toISOString(),
695
+ user_id: template.user_id,
696
+ });
697
+ }
698
+ }
699
+ } else {
700
+ for (let i = events.length - 1; i >= 0; i--) {
701
+ if (dayjs(events[i].time).isAfter(day30) && chance.bool({ likelihood: 80 })) {
702
+ events.splice(i, 1);
703
+ }
704
+ }
705
+ }
706
+ }
707
+
708
+ // HOOK 5: GHOSTING CHURN — users with match but no message within
709
+ // 48hrs lose 80% of post-match events. No flag.
710
+ if (matchEvents.length > 0) {
711
+ let hasTimely = false;
712
+ for (const m of matchEvents) {
713
+ const matchTime = dayjs(m.time);
714
+ const deadline = matchTime.add(48, "hours");
715
+ for (const msg of messageSentEvents) {
716
+ const msgTime = dayjs(msg.time);
717
+ if (msgTime.isAfter(matchTime) && msgTime.isBefore(deadline)) {
718
+ hasTimely = true;
719
+ break;
720
+ }
721
+ }
722
+ if (hasTimely) break;
723
+ }
724
+ if (!hasTimely) {
725
+ const earliestMatch = matchEvents.reduce((min, m) =>
726
+ dayjs(m.time).isBefore(dayjs(min.time)) ? m : min
727
+ );
728
+ const churnAfter = dayjs(earliestMatch.time);
729
+ for (let i = events.length - 1; i >= 0; i--) {
730
+ if (dayjs(events[i].time).isAfter(churnAfter) && chance.bool({ likelihood: 80 })) {
731
+ events.splice(i, 1);
732
+ }
733
+ }
734
+ }
735
+ }
736
+
737
+ return record;
738
+ }
739
+
740
+ return record;
741
+ },
742
+ };
743
+
744
+ export default config;