@ak--47/dungeon-master 1.4.5 → 1.5.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 (78) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +158 -0
  2. package/.claude/skills/create-dungeon/SKILL.md +464 -0
  3. package/.claude/skills/verify-dungeon/SKILL.md +157 -0
  4. package/.claude/skills/verify-dungeon/references/counting-semantics.md +161 -0
  5. package/.claude/skills/verify-dungeon/references/report-format.md +216 -0
  6. package/.claude/skills/verify-dungeon/references/sql-recipes.md +857 -0
  7. package/.claude/skills/write-hooks/SKILL.md +468 -0
  8. package/CHANGELOG.md +182 -0
  9. package/HOOKS.md +1256 -597
  10. package/README.md +140 -5
  11. package/dungeons/technical/ad-spend.js +41 -49
  12. package/dungeons/technical/anonymous-users.js +38 -36
  13. package/dungeons/technical/array-of-object-lookup.js +136 -153
  14. package/dungeons/technical/datagen-v15-verify.js +87 -0
  15. package/dungeons/technical/experiments.js +42 -40
  16. package/dungeons/technical/foobar.js +114 -118
  17. package/dungeons/technical/group-analytics.js +42 -40
  18. package/dungeons/technical/hook-helpers-verify.js +69 -50
  19. package/dungeons/technical/identity-model-verify.js +22 -12
  20. package/dungeons/technical/mirror-strategies.js +37 -39
  21. package/dungeons/technical/nested-objects.js +119 -118
  22. package/dungeons/technical/pattern-aggregate-by-bin.js +21 -8
  23. package/dungeons/technical/pattern-attributed-by-source.js +23 -9
  24. package/dungeons/technical/pattern-frequency-by-frequency.js +21 -8
  25. package/dungeons/technical/pattern-funnel-frequency.js +30 -15
  26. package/dungeons/technical/pattern-ttc-by-segment.js +21 -8
  27. package/dungeons/technical/retention-cadence.js +115 -112
  28. package/dungeons/technical/sanity.js +86 -80
  29. package/dungeons/technical/scale-test.js +34 -38
  30. package/dungeons/technical/scd.js +111 -128
  31. package/dungeons/technical/simple.js +134 -141
  32. package/dungeons/technical/simplest.js +111 -65
  33. package/dungeons/technical/text-generation.js +110 -146
  34. package/dungeons/vertical/ai-platform.js +300 -333
  35. package/dungeons/vertical/community.js +290 -255
  36. package/dungeons/vertical/crypto.js +400 -391
  37. package/dungeons/vertical/dating.js +421 -375
  38. package/dungeons/vertical/devtools.js +346 -298
  39. package/dungeons/vertical/ecommerce.js +322 -394
  40. package/dungeons/vertical/education.js +380 -325
  41. package/dungeons/vertical/fintech.js +371 -325
  42. package/dungeons/vertical/fitness.js +345 -291
  43. package/dungeons/vertical/food-delivery.js +352 -307
  44. package/dungeons/vertical/gaming.js +490 -444
  45. package/dungeons/vertical/healthcare.js +311 -262
  46. package/dungeons/vertical/insurance-application.js +437 -409
  47. package/dungeons/vertical/logistics.js +278 -252
  48. package/dungeons/vertical/marketplace.js +340 -323
  49. package/dungeons/vertical/media.js +390 -335
  50. package/dungeons/vertical/real-estate.js +402 -347
  51. package/dungeons/vertical/sass.js +331 -333
  52. package/dungeons/vertical/social.js +377 -316
  53. package/dungeons/vertical/travel.js +302 -295
  54. package/index.js +64 -7
  55. package/lib/core/config-validator.js +378 -17
  56. package/lib/core/dungeon-loader.js +2 -5
  57. package/lib/generators/events.js +12 -13
  58. package/lib/generators/funnels.js +76 -2
  59. package/lib/hook-helpers/index.js +1 -0
  60. package/lib/hook-helpers/inject.js +95 -0
  61. package/lib/orchestrators/mixpanel-sender.js +7 -0
  62. package/lib/orchestrators/user-loop.js +598 -48
  63. package/lib/templates/defaults.js +59 -59
  64. package/lib/templates/macro-presets.js +53 -11
  65. package/lib/utils/dataset-context.js +103 -0
  66. package/lib/utils/retention-curve.js +140 -0
  67. package/lib/utils/utils.js +157 -109
  68. package/lib/verify/counting.js +360 -0
  69. package/lib/verify/emulate-breakdown.js +531 -108
  70. package/lib/verify/funnel-engine.js +539 -0
  71. package/lib/verify/identity.js +78 -0
  72. package/lib/verify/index.js +20 -0
  73. package/lib/verify/schema-validator.js +3 -1
  74. package/lib/verify/verify-dungeon.js +58 -0
  75. package/package.json +14 -3
  76. package/scripts/run-dungeon.mjs +12 -1
  77. package/types.d.ts +353 -4
  78. package/scripts/smoke-test-all.mjs +0 -162
@@ -1,56 +1,43 @@
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
-
1
+ // ── IMPORTS ──
11
2
  import dayjs from "dayjs";
12
3
  import utc from "dayjs/plugin/utc.js";
4
+ dayjs.extend(utc);
13
5
  import "dotenv/config";
14
6
  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
7
  /** @typedef {import("../../types").Dungeon} Config */
20
8
 
9
+ // ── OVERVIEW ──
21
10
  /*
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
- * =====================================================================================
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, ~720K 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
47
37
  */
48
38
 
39
+ // ── HOOK STORIES ──
49
40
  /*
50
- * =====================================================================================
51
- * ANALYTICS HOOKS (10 hooks)
52
- * =====================================================================================
53
- *
54
41
  * NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable via
55
42
  * behavioral cohorts, raw-prop breakdowns, or funnel analysis.
56
43
  *
@@ -232,36 +219,393 @@ const chance = u.initChance(SEED);
232
219
  * Age Range Date Conv | date funnel 40+ | 1x | 0.6x | 0.6x
233
220
  */
234
221
 
222
+ // ── SCALE ──
223
+ const SEED = "meetcute";
224
+ const NUM_USERS = 30_000;
225
+ const DATASET_START = "2026-01-01T00:00:00Z";
226
+ const DATASET_END = "2026-05-01T23:59:59Z";
227
+ const EVENTS_PER_DAY = 1.5;
228
+ const token = process.env.MP_TOKEN || "your-mixpanel-token";
229
+
230
+ const chance = u.initChance(SEED);
231
+
232
+ // ── KNOBS (tweak these to reshape stories) ──
233
+ const PHOTO_SWEET_MIN = 2;
234
+ const PHOTO_SWEET_MAX = 5;
235
+ const PHOTO_OVER_THRESHOLD = 6;
236
+ const PHOTO_OVER_SCORE_FACTOR = 0.65;
237
+
238
+ const SUNDAY_EVENING_CLONES = 5;
239
+ const SUNDAY_DAYTIME_CLONES = 2;
240
+
241
+ const SUPER_LIKE_MATCH_CLONES = 3;
242
+
243
+ const PREMIUM_MATCH_MULT = 2;
244
+ const ELITE_MATCH_MULT = 4;
245
+
246
+ const GHOSTING_WINDOW_HOURS = 48;
247
+ const GHOSTING_DROP_LIKELIHOOD = 80;
248
+
249
+ const BIO_PROMPT_THRESHOLD = 3;
250
+ const BIO_PROMPT_DATE_CLONE_MULT = 3;
251
+
252
+ const VDAY_WINDOW_START_DAY = 58;
253
+ const VDAY_WINDOW_END_DAY = 63;
254
+ const VDAY_SIGNUP_CLONES = 2;
255
+ const VDAY_UPGRADE_CLONES = 4;
256
+
257
+ const MILESTONE_WINDOW_DAYS = 14;
258
+ const RETENTION_CUTOFF_DAYS = 30;
259
+ const RETENTION_TARGET_PCT = 0.3;
260
+ const OFFAPP_DROP_LIKELIHOOD = 80;
261
+
262
+ const FUNNEL_TTC_ELITE = 0.71;
263
+ const FUNNEL_TTC_FREE = 1.4;
264
+
265
+ const AGE_CONV_BOOST = 1.3;
266
+ const AGE_CONV_DROP = 0.6;
267
+
268
+ // ── HELPER FUNCTIONS ──
269
+ function handleFunnelPreHooks(record, meta) {
270
+ // H10: Age range affects date conversion — 25-34 +30%, 40+ -40%
271
+ const isDateFunnel = meta.funnel?.sequence?.includes("date scheduled");
272
+ if (isDateFunnel) {
273
+ const age = meta.profile?.age_range;
274
+ if (age === "25-29" || age === "30-34") {
275
+ record.conversionRate = Math.min(95, Math.round(record.conversionRate * AGE_CONV_BOOST));
276
+ } else if (age === "40+") {
277
+ record.conversionRate = Math.round(record.conversionRate * AGE_CONV_DROP);
278
+ }
279
+ }
280
+ return record;
281
+ }
282
+
283
+ function handleFunnelPostHooks(record, meta) {
284
+ // H9: Match Flow TTC scaled by subscription tier
285
+ const segment = meta?.profile?.subscription;
286
+ if (Array.isArray(record) && record.length > 1) {
287
+ const factor = (
288
+ segment === "Elite" ? FUNNEL_TTC_ELITE :
289
+ segment === "Free" ? FUNNEL_TTC_FREE :
290
+ 1.0
291
+ );
292
+ if (factor !== 1.0) {
293
+ for (let i = 1; i < record.length; i++) {
294
+ const prev = dayjs(record[i - 1].time);
295
+ const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
296
+ record[i].time = prev.add(newGap, "milliseconds").toISOString();
297
+ }
298
+ }
299
+ }
300
+ return record;
301
+ }
302
+
303
+ function handleEverythingHooks(record, meta) {
304
+ const datasetStart = dayjs.unix(meta.datasetStart);
305
+ const VDAY_WINDOW_START = datasetStart.add(VDAY_WINDOW_START_DAY, "days");
306
+ const VDAY_WINDOW_END = datasetStart.add(VDAY_WINDOW_END_DAY, "days");
307
+ const events = record;
308
+ if (!events || events.length === 0) return record;
309
+
310
+ const profile = meta.profile || {};
311
+
312
+ events.forEach(e => {
313
+ if (profile.subscription) e.subscription = profile.subscription;
314
+ if (profile.platform) e.platform = profile.platform;
315
+ });
316
+
317
+ let photoUploadCount = 0;
318
+ let promptAnsweredCount = 0;
319
+ let hasBioUpdated = false;
320
+ const matchEvents = [];
321
+ const messageSentEvents = [];
322
+ const superLikeEvents = [];
323
+ let hasPhoneExchangedEarly = false;
324
+ let hasDateScheduledEarly = false;
325
+ let firstEventTime = null;
326
+
327
+ events.forEach(event => {
328
+ if (!firstEventTime || dayjs(event.time).isBefore(dayjs(firstEventTime))) {
329
+ firstEventTime = event.time;
330
+ }
331
+ if (event.event === "photo uploaded") photoUploadCount++;
332
+ if (event.event === "prompt answered") promptAnsweredCount++;
333
+ if (event.event === "bio updated") hasBioUpdated = true;
334
+ if (event.event === "match received") matchEvents.push(event);
335
+ if (event.event === "message sent") messageSentEvents.push(event);
336
+ if (event.event === "swipe right" && event.is_super_like === true) superLikeEvents.push(event);
337
+ });
338
+
339
+ if (firstEventTime) {
340
+ const earlyWindow = dayjs(firstEventTime).add(MILESTONE_WINDOW_DAYS, "days");
341
+ events.forEach(event => {
342
+ const t = dayjs(event.time);
343
+ if (t.isBefore(earlyWindow)) {
344
+ if (event.event === "phone number exchanged") hasPhoneExchangedEarly = true;
345
+ if (event.event === "date scheduled") hasDateScheduledEarly = true;
346
+ }
347
+ });
348
+ }
349
+
350
+ // H1: PHOTO MAGIC NUMBER (sweet 2-5 photos → clone 2-4 extra matches)
351
+ // Over-6 score reduction is applied AT THE END of this hook so it also
352
+ // affects matches injected by H4 (premium boost).
353
+ if (photoUploadCount >= PHOTO_SWEET_MIN && photoUploadCount <= PHOTO_SWEET_MAX && matchEvents.length > 0) {
354
+ const matchTemplate = matchEvents[0];
355
+ matchEvents.forEach(m => {
356
+ const extras = chance.integer({ min: 2, max: 4 });
357
+ for (let i = 0; i < extras; i++) {
358
+ events.push({
359
+ ...matchTemplate,
360
+ time: dayjs(m.time).add(chance.integer({ min: 1, max: 180 }), "minutes").toISOString(),
361
+ user_id: m.user_id,
362
+ match_score: chance.integer({ min: 60, max: 98 }),
363
+ });
364
+ }
365
+ });
366
+ }
367
+
368
+ // H2: WEEKEND SWIPE SURGE — Sunday swipes get heavy cloning
369
+ // to overcome the soup DOW weight deficit. Evening swipes (18-23)
370
+ // get 5 clones; daytime Sunday swipes get 2 clones.
371
+ // No flag — discover via day-of-week chart.
372
+ for (let idx = events.length - 1; idx >= 0; idx--) {
373
+ const event = events[idx];
374
+ if (event.event === "swipe right") {
375
+ const dow = new Date(event.time).getUTCDay();
376
+ if (dow === 0) {
377
+ const hr = new Date(event.time).getUTCHours();
378
+ const clones = (hr >= 18 && hr <= 23) ? SUNDAY_EVENING_CLONES : SUNDAY_DAYTIME_CLONES;
379
+ const etime = dayjs(event.time);
380
+ for (let c = 0; c < clones; c++) {
381
+ events.push({
382
+ ...event,
383
+ time: etime.add(chance.integer({ min: 1, max: 60 }), "minutes").toISOString(),
384
+ user_id: event.user_id,
385
+ });
386
+ }
387
+ }
388
+ }
389
+ }
390
+
391
+ // H3: SUPER-LIKE EFFECT — clone 3 extra match events per
392
+ // super-like, near in time. No flag — discover via funnel
393
+ // "swipe right where is_super_like=true" → "match received".
394
+ if (superLikeEvents.length > 0) {
395
+ const matchTemplate = matchEvents[0] || events[0];
396
+ superLikeEvents.forEach(sle => {
397
+ for (let i = 0; i < SUPER_LIKE_MATCH_CLONES; i++) {
398
+ events.push({
399
+ ...matchTemplate,
400
+ event: "match received",
401
+ time: dayjs(sle.time).add(chance.integer({ min: 5, max: 120 }), "minutes").toISOString(),
402
+ user_id: sle.user_id,
403
+ match_score: chance.integer({ min: 70, max: 99 }),
404
+ });
405
+ }
406
+ });
407
+ }
408
+
409
+ // H5: GHOSTING CHURN — users with match but no message within
410
+ // 48hrs lose 80% of post-match events. No flag.
411
+ // (runs BEFORE premium boost so injected premium matches survive)
412
+ if (matchEvents.length > 0) {
413
+ let hasTimely = false;
414
+ for (const m of matchEvents) {
415
+ const matchTime = dayjs(m.time);
416
+ const deadline = matchTime.add(GHOSTING_WINDOW_HOURS, "hours");
417
+ for (const msg of messageSentEvents) {
418
+ const msgTime = dayjs(msg.time);
419
+ if (msgTime.isAfter(matchTime) && msgTime.isBefore(deadline)) {
420
+ hasTimely = true;
421
+ break;
422
+ }
423
+ }
424
+ if (hasTimely) break;
425
+ }
426
+ if (!hasTimely) {
427
+ const earliestMatch = matchEvents.reduce((min, m) =>
428
+ dayjs(m.time).isBefore(dayjs(min.time)) ? m : min
429
+ );
430
+ const churnAfter = dayjs(earliestMatch.time);
431
+ for (let i = events.length - 1; i >= 0; i--) {
432
+ if (dayjs(events[i].time).isAfter(churnAfter) && chance.bool({ likelihood: GHOSTING_DROP_LIKELIHOOD })) {
433
+ events.splice(i, 1);
434
+ }
435
+ }
436
+ }
437
+ }
438
+
439
+ // H4: PREMIUM MATCH BOOST — Premium 2x, Elite 4x match events.
440
+ // Elite users also get profile-viewed events injected (see-who-liked-you).
441
+ // Reads subscription from profile. Runs AFTER ghosting churn so
442
+ // injected matches are not culled.
443
+ const sub = profile.subscription;
444
+ if ((sub === "Premium" || sub === "Elite") && matchEvents.length > 0) {
445
+ // Count surviving match events post-churn
446
+ const survivingMatches = events.filter(e => e.event === "match received");
447
+ const baseCount = survivingMatches.length || 1;
448
+ const targetMultiplier = sub === "Elite" ? ELITE_MATCH_MULT : PREMIUM_MATCH_MULT;
449
+ const toAdd = Math.max(0, baseCount * targetMultiplier - baseCount);
450
+ const matchTemplate = matchEvents[0];
451
+ for (let i = 0; i < toAdd; i++) {
452
+ const sourceMatch = survivingMatches[i % survivingMatches.length] || matchTemplate;
453
+ events.push({
454
+ ...matchTemplate,
455
+ time: dayjs(sourceMatch.time).add(chance.integer({ min: 10, max: 240 }), "minutes").toISOString(),
456
+ user_id: sourceMatch.user_id,
457
+ match_score: chance.integer({ min: 65, max: 99 }),
458
+ });
459
+ }
460
+ if (sub === "Elite") {
461
+ const viewTemplate = events.find(e => e.event === "profile viewed") || matchTemplate;
462
+ survivingMatches.forEach(m => {
463
+ events.push({
464
+ ...viewTemplate,
465
+ event: "profile viewed",
466
+ time: dayjs(m.time).subtract(chance.integer({ min: 10, max: 120 }), "minutes").toISOString(),
467
+ user_id: m.user_id,
468
+ viewer_source: "liked_you",
469
+ });
470
+ });
471
+ }
472
+ }
473
+
474
+ // H6: BIO + PROMPT POWER USERS — bio + 3+ prompts → 3 extra
475
+ // cloned date events per existing. No flag.
476
+ if (hasBioUpdated && promptAnsweredCount >= BIO_PROMPT_THRESHOLD) {
477
+ const dateEvents = events.filter(e => e.event === "date scheduled");
478
+ if (dateEvents.length > 0) {
479
+ const dateTemplate = dateEvents[0];
480
+ const venueTypes = ["coffee", "dinner", "drinks", "activity", "virtual"];
481
+ for (let i = 0; i < dateEvents.length * BIO_PROMPT_DATE_CLONE_MULT; i++) {
482
+ const sourceDate = dateEvents[i % dateEvents.length];
483
+ events.push({
484
+ ...dateTemplate,
485
+ time: dayjs(sourceDate.time).add(chance.integer({ min: 1, max: 72 }), "hours").toISOString(),
486
+ user_id: sourceDate.user_id,
487
+ venue_type: chance.pickone(venueTypes),
488
+ });
489
+ }
490
+ }
491
+ }
492
+
493
+ // H7: VALENTINE'S DAY SPIKE — clone profile-created events during
494
+ // days 58-63 (3x volume), plus clone premium-upgrade events 5x. No flag.
495
+ const vdaySignups = events.filter(e =>
496
+ e.event === "profile created" &&
497
+ dayjs(e.time).isAfter(VDAY_WINDOW_START) &&
498
+ dayjs(e.time).isBefore(VDAY_WINDOW_END)
499
+ );
500
+ vdaySignups.forEach(signup => {
501
+ for (let i = 0; i < VDAY_SIGNUP_CLONES; i++) {
502
+ events.push({
503
+ ...signup,
504
+ time: dayjs(signup.time).add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
505
+ user_id: signup.user_id,
506
+ });
507
+ }
508
+ });
509
+
510
+ const vdayUpgrades = events.filter(e =>
511
+ e.event === "premium upgrade" &&
512
+ dayjs(e.time).isAfter(VDAY_WINDOW_START) &&
513
+ dayjs(e.time).isBefore(VDAY_WINDOW_END)
514
+ );
515
+ if (vdayUpgrades.length > 0) {
516
+ const upgradeTemplate = vdayUpgrades[0];
517
+ vdayUpgrades.forEach(upgrade => {
518
+ for (let i = 0; i < VDAY_UPGRADE_CLONES; i++) {
519
+ events.push({
520
+ ...upgradeTemplate,
521
+ time: dayjs(upgrade.time).add(chance.integer({ min: 1, max: 24 }), "hours").toISOString(),
522
+ user_id: upgrade.user_id,
523
+ plan: upgrade.plan,
524
+ price_usd: upgrade.price_usd,
525
+ });
526
+ }
527
+ });
528
+ }
529
+
530
+ // H1b: PHOTO MAGIC NUMBER — over-6 score reduction (applied LAST so
531
+ // it also affects matches injected by H4 premium boost).
532
+ if (photoUploadCount >= PHOTO_OVER_THRESHOLD) {
533
+ events.forEach(e => {
534
+ if (e.event === "match received" && typeof e.match_score === "number") {
535
+ e.match_score = Math.max(20, Math.round(e.match_score * PHOTO_OVER_SCORE_FACTOR));
536
+ }
537
+ });
538
+ }
539
+
540
+ // H8: OFF-APP RETENTION — users with phone-exchanged or
541
+ // date-scheduled in first 14 days get extra cloned app-open + swipe
542
+ // events past day 30. Non-milestone users lose 80% of post-day-30
543
+ // events. No flag.
544
+ if (firstEventTime) {
545
+ const day30 = dayjs(firstEventTime).add(RETENTION_CUTOFF_DAYS, "days");
546
+ const hasEarlyMilestone = hasPhoneExchangedEarly || hasDateScheduledEarly;
547
+ if (hasEarlyMilestone) {
548
+ const appOpenedTemplate = events.find(e => e.event === "app opened") || events[0];
549
+ const swipeTemplate = events.find(e => e.event === "swipe right") || events[0];
550
+ const postDay30Events = events.filter(e => dayjs(e.time).isAfter(day30));
551
+ if (postDay30Events.length < events.length * RETENTION_TARGET_PCT) {
552
+ const retentionCount = Math.floor(events.length * RETENTION_TARGET_PCT);
553
+ for (let i = 0; i < retentionCount; i++) {
554
+ const daysAfter = chance.integer({ min: 1, max: 60 });
555
+ const template = chance.bool({ likelihood: 50 }) ? appOpenedTemplate : swipeTemplate;
556
+ events.push({
557
+ ...template,
558
+ time: day30.add(daysAfter, "days").add(chance.integer({ min: 0, max: 23 }), "hours").toISOString(),
559
+ user_id: template.user_id,
560
+ });
561
+ }
562
+ }
563
+ } else {
564
+ for (let i = events.length - 1; i >= 0; i--) {
565
+ if (dayjs(events[i].time).isAfter(day30) && chance.bool({ likelihood: OFFAPP_DROP_LIKELIHOOD })) {
566
+ events.splice(i, 1);
567
+ }
568
+ }
569
+ }
570
+ }
571
+
572
+ return record;
573
+ }
574
+
575
+ // ── CONFIG ──
235
576
  /** @type {Config} */
236
577
  const config = {
237
578
  version: 2,
238
- token,
239
579
  seed: SEED,
240
- datasetStart: "2026-01-01T00:00:00Z",
241
- datasetEnd: "2026-05-01T23:59:59Z",
242
- // numDays: num_days,
243
- avgEventsPerUserPerDay: avg_events_per_user_per_day,
244
- numUsers: num_users,
245
- hasAnonIds: true,
246
- avgDevicePerUser: 2,
247
- hasSessionIds: true,
580
+ datasetStart: DATASET_START,
581
+ datasetEnd: DATASET_END,
582
+ avgEventsPerUserPerDay: EVENTS_PER_DAY,
583
+ numUsers: NUM_USERS,
248
584
  format: "json",
249
585
  gzip: true,
250
- alsoInferFunnels: false,
251
- hasLocation: true,
252
- hasAndroidDevices: true,
253
- hasIOSDevices: true,
254
- hasDesktopDevices: false,
255
- hasBrowser: false,
256
- hasCampaigns: false,
257
- isAnonymous: false,
258
- hasAdSpend: false,
259
- hasAvatar: true,
586
+ credentials: {
587
+ token,
588
+ },
589
+ switches: {
590
+ hasSessionIds: true,
591
+ alsoInferFunnels: false,
592
+ hasLocation: true,
593
+ hasAndroidDevices: true,
594
+ hasIOSDevices: true,
595
+ hasDesktopDevices: false,
596
+ hasBrowser: false,
597
+ hasCampaigns: false,
598
+ isAnonymous: false,
599
+ hasAdSpend: false,
600
+ hasAvatar: true,
601
+ },
602
+ identity: {
603
+ avgDevicePerUser: 2,
604
+ },
260
605
  concurrency: 1,
261
606
  writeToDisk: false,
262
607
  soup: "growth",
263
608
 
264
- // ── Events (17) ──────────────────────────────────────────
265
609
  events: [
266
610
  {
267
611
  event: "profile created",
@@ -277,6 +621,7 @@ const config = {
277
621
  {
278
622
  event: "photo uploaded",
279
623
  weight: 12,
624
+ isStrictEvent: false,
280
625
  properties: {
281
626
  photo_number: u.weighNumRange(1, 6, 0.5, 2),
282
627
  has_face: [true, true, true, true, false],
@@ -300,6 +645,7 @@ const config = {
300
645
  {
301
646
  event: "swipe right",
302
647
  weight: 10,
648
+ isStrictEvent: false,
303
649
  properties: {
304
650
  is_super_like: [false, false, false, false, false, false, false, false, false, true],
305
651
  swipe_source: ["feed", "feed", "feed", "discover", "boost", "nearby"],
@@ -315,6 +661,7 @@ const config = {
315
661
  {
316
662
  event: "match received",
317
663
  weight: 4,
664
+ isStrictEvent: false,
318
665
  properties: {
319
666
  match_score: u.weighNumRange(50, 100, 0.5, 75),
320
667
  },
@@ -322,6 +669,7 @@ const config = {
322
669
  {
323
670
  event: "message sent",
324
671
  weight: 6,
672
+ isStrictEvent: false,
325
673
  properties: {
326
674
  message_length: u.weighNumRange(1, 500, 0.3, 40),
327
675
  has_emoji: [false, false, true, true, true],
@@ -338,6 +686,7 @@ const config = {
338
686
  {
339
687
  event: "phone number exchanged",
340
688
  weight: 1,
689
+ isStrictEvent: false,
341
690
  properties: {
342
691
  exchange_method: ["in_chat", "in_chat", "voice_call", "video_call"],
343
692
  },
@@ -345,6 +694,7 @@ const config = {
345
694
  {
346
695
  event: "date scheduled",
347
696
  weight: 1,
697
+ isStrictEvent: false,
348
698
  properties: {
349
699
  venue_type: ["coffee", "dinner", "drinks", "activity", "virtual"],
350
700
  },
@@ -359,6 +709,7 @@ const config = {
359
709
  {
360
710
  event: "premium upgrade",
361
711
  weight: 1,
712
+ isStrictEvent: false,
362
713
  properties: {
363
714
  plan: ["Premium", "Premium", "Premium", "Elite"],
364
715
  price_usd: [14.99, 14.99, 14.99, 29.99],
@@ -392,13 +743,13 @@ const config = {
392
743
  {
393
744
  event: "app opened",
394
745
  weight: 8,
746
+ isStrictEvent: false,
395
747
  properties: {
396
748
  session_duration_mins: u.weighNumRange(1, 120, 0.3, 8),
397
749
  },
398
750
  },
399
751
  ],
400
752
 
401
- // ── Funnels (4) ──────────────────────────────────────────
402
753
  funnels: [
403
754
  {
404
755
  name: "Onboarding",
@@ -416,6 +767,7 @@ const config = {
416
767
  order: "sequential",
417
768
  timeToConvert: 24,
418
769
  weight: 6,
770
+ reentry: true,
419
771
  },
420
772
  {
421
773
  name: "Date Funnel",
@@ -424,6 +776,7 @@ const config = {
424
776
  order: "sequential",
425
777
  timeToConvert: 72,
426
778
  weight: 3,
779
+ reentry: true,
427
780
  },
428
781
  {
429
782
  name: "Monetization",
@@ -435,13 +788,11 @@ const config = {
435
788
  },
436
789
  ],
437
790
 
438
- // ── SuperProps ──────────────────────────────────────────
439
791
  superProps: {
440
792
  subscription: ["Free", "Free", "Free", "Premium", "Elite"],
441
793
  Platform: ["ios", "ios", "android"],
442
794
  },
443
795
 
444
- // ── UserProps ──────────────────────────────────────────
445
796
  userProps: {
446
797
  subscription: ["Free", "Free", "Free", "Premium", "Elite"],
447
798
  age_range: ["18-24", "25-29", "30-34", "35-39", "40+"],
@@ -454,7 +805,6 @@ const config = {
454
805
  Platform: ["ios", "ios", "android"],
455
806
  },
456
807
 
457
- // ── SCD Props ──────────────────────────────────────────
458
808
  scdProps: {
459
809
  subscription_tier: {
460
810
  values: ["Free", "Premium", "Elite"],
@@ -469,314 +819,10 @@ const config = {
469
819
  mirrorProps: {},
470
820
  lookupTables: [],
471
821
 
472
- hook: function (record, type, meta) {
473
-
474
- // HOOK 10: AGE RANGE AFFECTS DATE CONVERSION (funnel-pre)
475
- // 25-29 / 30-34 convert 1.3x on the date funnel; 40+ at 0.6x.
476
- if (type === "funnel-pre") {
477
- const isDateFunnel = meta.funnel?.sequence?.includes("date scheduled");
478
- if (isDateFunnel) {
479
- const age = meta.profile?.age_range;
480
- if (age === "25-29" || age === "30-34") {
481
- record.conversionRate = Math.min(95, Math.round(record.conversionRate * 1.3));
482
- } else if (age === "40+") {
483
- record.conversionRate = Math.round(record.conversionRate * 0.6);
484
- }
485
- }
486
- }
487
-
488
- // HOOK 9 (T2C): MATCH FLOW TIME-TO-CONVERT (funnel-post)
489
- // Elite users complete swipe→match→message funnel 1.4x faster
490
- // (factor 0.71); Free users 1.4x slower (factor 1.4).
491
- if (type === "funnel-post") {
492
- const segment = meta?.profile?.subscription;
493
- if (Array.isArray(record) && record.length > 1) {
494
- const factor = (
495
- segment === "Elite" ? 0.71 :
496
- segment === "Free" ? 1.4 :
497
- 1.0
498
- );
499
- if (factor !== 1.0) {
500
- for (let i = 1; i < record.length; i++) {
501
- const prev = dayjs(record[i - 1].time);
502
- const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
503
- record[i].time = prev.add(newGap, "milliseconds").toISOString();
504
- }
505
- }
506
- }
507
- }
508
-
509
- // ─── EVERYTHING-LEVEL HOOKS ──────────────────────────────────────
510
-
511
- if (type === "everything") {
512
- const datasetStart = dayjs.unix(meta.datasetStart);
513
- const VDAY_WINDOW_START = datasetStart.add(58, "days");
514
- const VDAY_WINDOW_END = datasetStart.add(63, "days");
515
- const events = record;
516
- if (!events || events.length === 0) return record;
517
-
518
- const profile = meta.profile || {};
519
-
520
- events.forEach(e => {
521
- if (profile.subscription) e.subscription = profile.subscription;
522
- if (profile.platform) e.platform = profile.platform;
523
- });
524
-
525
- let photoUploadCount = 0;
526
- let promptAnsweredCount = 0;
527
- let hasBioUpdated = false;
528
- const matchEvents = [];
529
- const messageSentEvents = [];
530
- const superLikeEvents = [];
531
- let hasPhoneExchangedEarly = false;
532
- let hasDateScheduledEarly = false;
533
- let firstEventTime = null;
534
-
535
- events.forEach(event => {
536
- if (!firstEventTime || dayjs(event.time).isBefore(dayjs(firstEventTime))) {
537
- firstEventTime = event.time;
538
- }
539
- if (event.event === "photo uploaded") photoUploadCount++;
540
- if (event.event === "prompt answered") promptAnsweredCount++;
541
- if (event.event === "bio updated") hasBioUpdated = true;
542
- if (event.event === "match received") matchEvents.push(event);
543
- if (event.event === "message sent") messageSentEvents.push(event);
544
- if (event.event === "swipe right" && event.is_super_like === true) superLikeEvents.push(event);
545
- });
546
-
547
- if (firstEventTime) {
548
- const earlyWindow = dayjs(firstEventTime).add(14, "days");
549
- events.forEach(event => {
550
- const t = dayjs(event.time);
551
- if (t.isBefore(earlyWindow)) {
552
- if (event.event === "phone number exchanged") hasPhoneExchangedEarly = true;
553
- if (event.event === "date scheduled") hasDateScheduledEarly = true;
554
- }
555
- });
556
- }
557
-
558
- // HOOK 1 + HOOK 9: PHOTO MAGIC NUMBER (no flags)
559
- // Sweet 2-5 photos → clone 2-4 extra match events per existing.
560
- // Over 6+ photos → drop match_score by 35% on match received events
561
- // (over-curated profile reads as fake/staged).
562
- if (photoUploadCount >= 2 && photoUploadCount <= 5 && matchEvents.length > 0) {
563
- const matchTemplate = matchEvents[0];
564
- matchEvents.forEach(m => {
565
- const extras = chance.integer({ min: 2, max: 4 });
566
- for (let i = 0; i < extras; i++) {
567
- events.push({
568
- ...matchTemplate,
569
- time: dayjs(m.time).add(chance.integer({ min: 1, max: 180 }), "minutes").toISOString(),
570
- user_id: m.user_id,
571
- match_score: chance.integer({ min: 60, max: 98 }),
572
- });
573
- }
574
- });
575
- } else if (photoUploadCount >= 6) {
576
- events.forEach(e => {
577
- if (e.event === "match received" && typeof e.match_score === "number") {
578
- e.match_score = Math.max(20, Math.round(e.match_score * 0.65));
579
- }
580
- });
581
- }
582
-
583
- // HOOK 2: WEEKEND SWIPE SURGE — Sunday swipes get heavy cloning
584
- // to overcome the soup DOW weight deficit. Evening swipes (18-23)
585
- // get 5 clones; daytime Sunday swipes get 2 clones.
586
- // No flag — discover via day-of-week chart.
587
- for (let idx = events.length - 1; idx >= 0; idx--) {
588
- const event = events[idx];
589
- if (event.event === "swipe right") {
590
- const dow = new Date(event.time).getUTCDay();
591
- if (dow === 0) {
592
- const hr = new Date(event.time).getUTCHours();
593
- const clones = (hr >= 18 && hr <= 23) ? 5 : 2;
594
- const etime = dayjs(event.time);
595
- for (let c = 0; c < clones; c++) {
596
- events.push({
597
- ...event,
598
- time: etime.add(chance.integer({ min: 1, max: 60 }), "minutes").toISOString(),
599
- user_id: event.user_id,
600
- });
601
- }
602
- }
603
- }
604
- }
605
-
606
- // HOOK 3: SUPER-LIKE EFFECT — clone 3 extra match events per
607
- // super-like, near in time. No flag — discover via funnel
608
- // "swipe right where is_super_like=true" → "match received".
609
- if (superLikeEvents.length > 0) {
610
- const matchTemplate = matchEvents[0] || events[0];
611
- superLikeEvents.forEach(sle => {
612
- for (let i = 0; i < 3; i++) {
613
- events.push({
614
- ...matchTemplate,
615
- event: "match received",
616
- time: dayjs(sle.time).add(chance.integer({ min: 5, max: 120 }), "minutes").toISOString(),
617
- user_id: sle.user_id,
618
- match_score: chance.integer({ min: 70, max: 99 }),
619
- });
620
- }
621
- });
622
- }
623
-
624
- // HOOK 5: GHOSTING CHURN — users with match but no message within
625
- // 48hrs lose 80% of post-match events. No flag.
626
- // (runs BEFORE premium boost so injected premium matches survive)
627
- if (matchEvents.length > 0) {
628
- let hasTimely = false;
629
- for (const m of matchEvents) {
630
- const matchTime = dayjs(m.time);
631
- const deadline = matchTime.add(48, "hours");
632
- for (const msg of messageSentEvents) {
633
- const msgTime = dayjs(msg.time);
634
- if (msgTime.isAfter(matchTime) && msgTime.isBefore(deadline)) {
635
- hasTimely = true;
636
- break;
637
- }
638
- }
639
- if (hasTimely) break;
640
- }
641
- if (!hasTimely) {
642
- const earliestMatch = matchEvents.reduce((min, m) =>
643
- dayjs(m.time).isBefore(dayjs(min.time)) ? m : min
644
- );
645
- const churnAfter = dayjs(earliestMatch.time);
646
- for (let i = events.length - 1; i >= 0; i--) {
647
- if (dayjs(events[i].time).isAfter(churnAfter) && chance.bool({ likelihood: 80 })) {
648
- events.splice(i, 1);
649
- }
650
- }
651
- }
652
- }
653
-
654
- // HOOK 4: PREMIUM MATCH BOOST — Premium 2x, Elite 4x match events.
655
- // Elite users also get profile-viewed events injected (see-who-liked-you).
656
- // Reads subscription from profile. Runs AFTER ghosting churn so
657
- // injected matches are not culled.
658
- const sub = profile.subscription;
659
- if ((sub === "Premium" || sub === "Elite") && matchEvents.length > 0) {
660
- // Count surviving match events post-churn
661
- const survivingMatches = events.filter(e => e.event === "match received");
662
- const baseCount = survivingMatches.length || 1;
663
- const targetMultiplier = sub === "Elite" ? 4 : 2;
664
- const toAdd = Math.max(0, baseCount * targetMultiplier - baseCount);
665
- const matchTemplate = matchEvents[0];
666
- for (let i = 0; i < toAdd; i++) {
667
- const sourceMatch = survivingMatches[i % survivingMatches.length] || matchTemplate;
668
- events.push({
669
- ...matchTemplate,
670
- time: dayjs(sourceMatch.time).add(chance.integer({ min: 10, max: 240 }), "minutes").toISOString(),
671
- user_id: sourceMatch.user_id,
672
- match_score: chance.integer({ min: 65, max: 99 }),
673
- });
674
- }
675
- if (sub === "Elite") {
676
- const viewTemplate = events.find(e => e.event === "profile viewed") || matchTemplate;
677
- survivingMatches.forEach(m => {
678
- events.push({
679
- ...viewTemplate,
680
- event: "profile viewed",
681
- time: dayjs(m.time).subtract(chance.integer({ min: 10, max: 120 }), "minutes").toISOString(),
682
- user_id: m.user_id,
683
- viewer_source: "liked_you",
684
- });
685
- });
686
- }
687
- }
688
-
689
- // HOOK 6: BIO + PROMPT POWER USERS — bio + 3+ prompts → 3 extra
690
- // cloned date events per existing. No flag.
691
- if (hasBioUpdated && promptAnsweredCount >= 3) {
692
- const dateEvents = events.filter(e => e.event === "date scheduled");
693
- if (dateEvents.length > 0) {
694
- const dateTemplate = dateEvents[0];
695
- const venueTypes = ["coffee", "dinner", "drinks", "activity", "virtual"];
696
- for (let i = 0; i < dateEvents.length * 3; i++) {
697
- const sourceDate = dateEvents[i % dateEvents.length];
698
- events.push({
699
- ...dateTemplate,
700
- time: dayjs(sourceDate.time).add(chance.integer({ min: 1, max: 72 }), "hours").toISOString(),
701
- user_id: sourceDate.user_id,
702
- venue_type: chance.pickone(venueTypes),
703
- });
704
- }
705
- }
706
- }
707
-
708
- // HOOK 7: VALENTINE'S DAY SPIKE — clone profile-created events during
709
- // days 58-63 (3x volume), plus clone premium-upgrade events 5x. No flag.
710
- const vdaySignups = events.filter(e =>
711
- e.event === "profile created" &&
712
- dayjs(e.time).isAfter(VDAY_WINDOW_START) &&
713
- dayjs(e.time).isBefore(VDAY_WINDOW_END)
714
- );
715
- vdaySignups.forEach(signup => {
716
- for (let i = 0; i < 2; i++) {
717
- events.push({
718
- ...signup,
719
- time: dayjs(signup.time).add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
720
- user_id: signup.user_id,
721
- });
722
- }
723
- });
724
-
725
- const vdayUpgrades = events.filter(e =>
726
- e.event === "premium upgrade" &&
727
- dayjs(e.time).isAfter(VDAY_WINDOW_START) &&
728
- dayjs(e.time).isBefore(VDAY_WINDOW_END)
729
- );
730
- if (vdayUpgrades.length > 0) {
731
- const upgradeTemplate = vdayUpgrades[0];
732
- vdayUpgrades.forEach(upgrade => {
733
- for (let i = 0; i < 4; i++) {
734
- events.push({
735
- ...upgradeTemplate,
736
- time: dayjs(upgrade.time).add(chance.integer({ min: 1, max: 24 }), "hours").toISOString(),
737
- user_id: upgrade.user_id,
738
- plan: upgrade.plan,
739
- price_usd: upgrade.price_usd,
740
- });
741
- }
742
- });
743
- }
744
-
745
- // HOOK 8: OFF-APP RETENTION — users with phone-exchanged or
746
- // date-scheduled in first 14 days get extra cloned app-open + swipe
747
- // events past day 30. Non-milestone users lose 80% of post-day-30
748
- // events. No flag.
749
- if (firstEventTime) {
750
- const day30 = dayjs(firstEventTime).add(30, "days");
751
- const hasEarlyMilestone = hasPhoneExchangedEarly || hasDateScheduledEarly;
752
- if (hasEarlyMilestone) {
753
- const appOpenedTemplate = events.find(e => e.event === "app opened") || events[0];
754
- const swipeTemplate = events.find(e => e.event === "swipe right") || events[0];
755
- const postDay30Events = events.filter(e => dayjs(e.time).isAfter(day30));
756
- if (postDay30Events.length < events.length * 0.3) {
757
- const retentionCount = Math.floor(events.length * 0.3);
758
- for (let i = 0; i < retentionCount; i++) {
759
- const daysAfter = chance.integer({ min: 1, max: 60 });
760
- const template = chance.bool({ likelihood: 50 }) ? appOpenedTemplate : swipeTemplate;
761
- events.push({
762
- ...template,
763
- time: day30.add(daysAfter, "days").add(chance.integer({ min: 0, max: 23 }), "hours").toISOString(),
764
- user_id: template.user_id,
765
- });
766
- }
767
- }
768
- } else {
769
- for (let i = events.length - 1; i >= 0; i--) {
770
- if (dayjs(events[i].time).isAfter(day30) && chance.bool({ likelihood: 80 })) {
771
- events.splice(i, 1);
772
- }
773
- }
774
- }
775
- }
776
-
777
- return record;
778
- }
779
-
822
+ hook(record, type, meta) {
823
+ if (type === "funnel-pre") return handleFunnelPreHooks(record, meta);
824
+ if (type === "funnel-post") return handleFunnelPostHooks(record, meta);
825
+ if (type === "everything") return handleEverythingHooks(record, meta);
780
826
  return record;
781
827
  },
782
828
  };