@ak--47/dungeon-master 1.5.0 → 1.5.2

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 (66) hide show
  1. package/.claude/skills/create-dungeon/SKILL.md +139 -46
  2. package/.claude/skills/verify-dungeon/references/counting-semantics.md +31 -6
  3. package/.claude/skills/verify-dungeon/references/sql-recipes.md +44 -25
  4. package/.claude/skills/write-hooks/SKILL.md +31 -3
  5. package/CHANGELOG.md +85 -0
  6. package/HOOKS.md +13 -0
  7. package/dungeons/technical/ad-spend.js +41 -49
  8. package/dungeons/technical/anonymous-users.js +38 -36
  9. package/dungeons/technical/array-of-object-lookup.js +136 -153
  10. package/dungeons/technical/datagen-v15-verify.js +24 -11
  11. package/dungeons/technical/experiments.js +42 -40
  12. package/dungeons/technical/foobar.js +114 -118
  13. package/dungeons/technical/group-analytics.js +42 -40
  14. package/dungeons/technical/hook-helpers-verify.js +69 -50
  15. package/dungeons/technical/identity-model-verify.js +22 -12
  16. package/dungeons/technical/mirror-strategies.js +37 -39
  17. package/dungeons/technical/nested-objects.js +119 -118
  18. package/dungeons/technical/pattern-aggregate-by-bin.js +21 -8
  19. package/dungeons/technical/pattern-attributed-by-source.js +23 -9
  20. package/dungeons/technical/pattern-frequency-by-frequency.js +21 -8
  21. package/dungeons/technical/pattern-funnel-frequency.js +30 -15
  22. package/dungeons/technical/pattern-ttc-by-segment.js +21 -8
  23. package/dungeons/technical/retention-cadence.js +115 -112
  24. package/dungeons/technical/sanity.js +86 -80
  25. package/dungeons/technical/scale-test.js +34 -38
  26. package/dungeons/technical/scd.js +111 -128
  27. package/dungeons/technical/simple.js +134 -141
  28. package/dungeons/technical/simplest.js +54 -62
  29. package/dungeons/technical/text-generation.js +110 -146
  30. package/dungeons/vertical/ai-platform.js +296 -333
  31. package/dungeons/vertical/community.js +284 -255
  32. package/dungeons/vertical/crypto.js +395 -391
  33. package/dungeons/vertical/dating.js +411 -378
  34. package/dungeons/vertical/devtools.js +336 -298
  35. package/dungeons/vertical/ecommerce.js +316 -394
  36. package/dungeons/vertical/education.js +369 -325
  37. package/dungeons/vertical/fintech.js +358 -325
  38. package/dungeons/vertical/fitness.js +335 -291
  39. package/dungeons/vertical/food-delivery.js +343 -307
  40. package/dungeons/vertical/gaming.js +480 -444
  41. package/dungeons/vertical/healthcare.js +306 -262
  42. package/dungeons/vertical/insurance-application.js +427 -409
  43. package/dungeons/vertical/logistics.js +271 -252
  44. package/dungeons/vertical/marketplace.js +333 -323
  45. package/dungeons/vertical/media.js +382 -335
  46. package/dungeons/vertical/real-estate.js +395 -346
  47. package/dungeons/vertical/sass.js +319 -333
  48. package/dungeons/vertical/social.js +368 -316
  49. package/dungeons/vertical/travel.js +297 -295
  50. package/index.js +46 -4
  51. package/lib/core/config-validator.js +126 -28
  52. package/lib/generators/funnels.js +4 -1
  53. package/lib/orchestrators/mixpanel-sender.js +7 -0
  54. package/lib/orchestrators/user-loop.js +132 -31
  55. package/lib/templates/defaults.js +59 -59
  56. package/lib/templates/macro-presets.js +14 -2
  57. package/lib/utils/dataset-context.js +103 -0
  58. package/lib/utils/retention-curve.js +140 -0
  59. package/lib/utils/utils.js +149 -38
  60. package/lib/verify/counting.js +40 -0
  61. package/lib/verify/emulate-breakdown.js +20 -1
  62. package/lib/verify/index.js +1 -0
  63. package/lib/verify/schema-validator.js +3 -1
  64. package/package.json +11 -2
  65. package/scripts/run-dungeon.mjs +12 -1
  66. package/types.d.ts +117 -1
@@ -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",
@@ -406,7 +750,6 @@ const config = {
406
750
  },
407
751
  ],
408
752
 
409
- // ── Funnels (4) ──────────────────────────────────────────
410
753
  funnels: [
411
754
  {
412
755
  name: "Onboarding",
@@ -445,13 +788,11 @@ const config = {
445
788
  },
446
789
  ],
447
790
 
448
- // ── SuperProps ──────────────────────────────────────────
449
791
  superProps: {
450
792
  subscription: ["Free", "Free", "Free", "Premium", "Elite"],
451
793
  Platform: ["ios", "ios", "android"],
452
794
  },
453
795
 
454
- // ── UserProps ──────────────────────────────────────────
455
796
  userProps: {
456
797
  subscription: ["Free", "Free", "Free", "Premium", "Elite"],
457
798
  age_range: ["18-24", "25-29", "30-34", "35-39", "40+"],
@@ -464,7 +805,6 @@ const config = {
464
805
  Platform: ["ios", "ios", "android"],
465
806
  },
466
807
 
467
- // ── SCD Props ──────────────────────────────────────────
468
808
  scdProps: {
469
809
  subscription_tier: {
470
810
  values: ["Free", "Premium", "Elite"],
@@ -479,317 +819,10 @@ const config = {
479
819
  mirrorProps: {},
480
820
  lookupTables: [],
481
821
 
482
- hook: function (record, type, meta) {
483
-
484
- // HOOK 10: AGE RANGE AFFECTS DATE CONVERSION (funnel-pre)
485
- // 25-29 / 30-34 convert 1.3x on the date funnel; 40+ at 0.6x.
486
- if (type === "funnel-pre") {
487
- const isDateFunnel = meta.funnel?.sequence?.includes("date scheduled");
488
- if (isDateFunnel) {
489
- const age = meta.profile?.age_range;
490
- if (age === "25-29" || age === "30-34") {
491
- record.conversionRate = Math.min(95, Math.round(record.conversionRate * 1.3));
492
- } else if (age === "40+") {
493
- record.conversionRate = Math.round(record.conversionRate * 0.6);
494
- }
495
- }
496
- }
497
-
498
- // HOOK 9 (T2C): MATCH FLOW TIME-TO-CONVERT (funnel-post)
499
- // Elite users complete swipe→match→message funnel 1.4x faster
500
- // (factor 0.71); Free users 1.4x slower (factor 1.4).
501
- if (type === "funnel-post") {
502
- const segment = meta?.profile?.subscription;
503
- if (Array.isArray(record) && record.length > 1) {
504
- const factor = (
505
- segment === "Elite" ? 0.71 :
506
- segment === "Free" ? 1.4 :
507
- 1.0
508
- );
509
- if (factor !== 1.0) {
510
- for (let i = 1; i < record.length; i++) {
511
- const prev = dayjs(record[i - 1].time);
512
- const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
513
- record[i].time = prev.add(newGap, "milliseconds").toISOString();
514
- }
515
- }
516
- }
517
- }
518
-
519
- // ─── EVERYTHING-LEVEL HOOKS ──────────────────────────────────────
520
-
521
- if (type === "everything") {
522
- const datasetStart = dayjs.unix(meta.datasetStart);
523
- const VDAY_WINDOW_START = datasetStart.add(58, "days");
524
- const VDAY_WINDOW_END = datasetStart.add(63, "days");
525
- const events = record;
526
- if (!events || events.length === 0) return record;
527
-
528
- const profile = meta.profile || {};
529
-
530
- events.forEach(e => {
531
- if (profile.subscription) e.subscription = profile.subscription;
532
- if (profile.platform) e.platform = profile.platform;
533
- });
534
-
535
- let photoUploadCount = 0;
536
- let promptAnsweredCount = 0;
537
- let hasBioUpdated = false;
538
- const matchEvents = [];
539
- const messageSentEvents = [];
540
- const superLikeEvents = [];
541
- let hasPhoneExchangedEarly = false;
542
- let hasDateScheduledEarly = false;
543
- let firstEventTime = null;
544
-
545
- events.forEach(event => {
546
- if (!firstEventTime || dayjs(event.time).isBefore(dayjs(firstEventTime))) {
547
- firstEventTime = event.time;
548
- }
549
- if (event.event === "photo uploaded") photoUploadCount++;
550
- if (event.event === "prompt answered") promptAnsweredCount++;
551
- if (event.event === "bio updated") hasBioUpdated = true;
552
- if (event.event === "match received") matchEvents.push(event);
553
- if (event.event === "message sent") messageSentEvents.push(event);
554
- if (event.event === "swipe right" && event.is_super_like === true) superLikeEvents.push(event);
555
- });
556
-
557
- if (firstEventTime) {
558
- const earlyWindow = dayjs(firstEventTime).add(14, "days");
559
- events.forEach(event => {
560
- const t = dayjs(event.time);
561
- if (t.isBefore(earlyWindow)) {
562
- if (event.event === "phone number exchanged") hasPhoneExchangedEarly = true;
563
- if (event.event === "date scheduled") hasDateScheduledEarly = true;
564
- }
565
- });
566
- }
567
-
568
- // HOOK 1: PHOTO MAGIC NUMBER (sweet 2-5 photos → clone 2-4 extra matches)
569
- // Over-6 score reduction is applied AT THE END of this hook so it also
570
- // affects matches injected by HOOK 4 (premium boost).
571
- if (photoUploadCount >= 2 && photoUploadCount <= 5 && matchEvents.length > 0) {
572
- const matchTemplate = matchEvents[0];
573
- matchEvents.forEach(m => {
574
- const extras = chance.integer({ min: 2, max: 4 });
575
- for (let i = 0; i < extras; i++) {
576
- events.push({
577
- ...matchTemplate,
578
- time: dayjs(m.time).add(chance.integer({ min: 1, max: 180 }), "minutes").toISOString(),
579
- user_id: m.user_id,
580
- match_score: chance.integer({ min: 60, max: 98 }),
581
- });
582
- }
583
- });
584
- }
585
-
586
- // HOOK 2: WEEKEND SWIPE SURGE — Sunday swipes get heavy cloning
587
- // to overcome the soup DOW weight deficit. Evening swipes (18-23)
588
- // get 5 clones; daytime Sunday swipes get 2 clones.
589
- // No flag — discover via day-of-week chart.
590
- for (let idx = events.length - 1; idx >= 0; idx--) {
591
- const event = events[idx];
592
- if (event.event === "swipe right") {
593
- const dow = new Date(event.time).getUTCDay();
594
- if (dow === 0) {
595
- const hr = new Date(event.time).getUTCHours();
596
- const clones = (hr >= 18 && hr <= 23) ? 5 : 2;
597
- const etime = dayjs(event.time);
598
- for (let c = 0; c < clones; c++) {
599
- events.push({
600
- ...event,
601
- time: etime.add(chance.integer({ min: 1, max: 60 }), "minutes").toISOString(),
602
- user_id: event.user_id,
603
- });
604
- }
605
- }
606
- }
607
- }
608
-
609
- // HOOK 3: SUPER-LIKE EFFECT — clone 3 extra match events per
610
- // super-like, near in time. No flag — discover via funnel
611
- // "swipe right where is_super_like=true" → "match received".
612
- if (superLikeEvents.length > 0) {
613
- const matchTemplate = matchEvents[0] || events[0];
614
- superLikeEvents.forEach(sle => {
615
- for (let i = 0; i < 3; i++) {
616
- events.push({
617
- ...matchTemplate,
618
- event: "match received",
619
- time: dayjs(sle.time).add(chance.integer({ min: 5, max: 120 }), "minutes").toISOString(),
620
- user_id: sle.user_id,
621
- match_score: chance.integer({ min: 70, max: 99 }),
622
- });
623
- }
624
- });
625
- }
626
-
627
- // HOOK 5: GHOSTING CHURN — users with match but no message within
628
- // 48hrs lose 80% of post-match events. No flag.
629
- // (runs BEFORE premium boost so injected premium matches survive)
630
- if (matchEvents.length > 0) {
631
- let hasTimely = false;
632
- for (const m of matchEvents) {
633
- const matchTime = dayjs(m.time);
634
- const deadline = matchTime.add(48, "hours");
635
- for (const msg of messageSentEvents) {
636
- const msgTime = dayjs(msg.time);
637
- if (msgTime.isAfter(matchTime) && msgTime.isBefore(deadline)) {
638
- hasTimely = true;
639
- break;
640
- }
641
- }
642
- if (hasTimely) break;
643
- }
644
- if (!hasTimely) {
645
- const earliestMatch = matchEvents.reduce((min, m) =>
646
- dayjs(m.time).isBefore(dayjs(min.time)) ? m : min
647
- );
648
- const churnAfter = dayjs(earliestMatch.time);
649
- for (let i = events.length - 1; i >= 0; i--) {
650
- if (dayjs(events[i].time).isAfter(churnAfter) && chance.bool({ likelihood: 80 })) {
651
- events.splice(i, 1);
652
- }
653
- }
654
- }
655
- }
656
-
657
- // HOOK 4: PREMIUM MATCH BOOST — Premium 2x, Elite 4x match events.
658
- // Elite users also get profile-viewed events injected (see-who-liked-you).
659
- // Reads subscription from profile. Runs AFTER ghosting churn so
660
- // injected matches are not culled.
661
- const sub = profile.subscription;
662
- if ((sub === "Premium" || sub === "Elite") && matchEvents.length > 0) {
663
- // Count surviving match events post-churn
664
- const survivingMatches = events.filter(e => e.event === "match received");
665
- const baseCount = survivingMatches.length || 1;
666
- const targetMultiplier = sub === "Elite" ? 4 : 2;
667
- const toAdd = Math.max(0, baseCount * targetMultiplier - baseCount);
668
- const matchTemplate = matchEvents[0];
669
- for (let i = 0; i < toAdd; i++) {
670
- const sourceMatch = survivingMatches[i % survivingMatches.length] || matchTemplate;
671
- events.push({
672
- ...matchTemplate,
673
- time: dayjs(sourceMatch.time).add(chance.integer({ min: 10, max: 240 }), "minutes").toISOString(),
674
- user_id: sourceMatch.user_id,
675
- match_score: chance.integer({ min: 65, max: 99 }),
676
- });
677
- }
678
- if (sub === "Elite") {
679
- const viewTemplate = events.find(e => e.event === "profile viewed") || matchTemplate;
680
- survivingMatches.forEach(m => {
681
- events.push({
682
- ...viewTemplate,
683
- event: "profile viewed",
684
- time: dayjs(m.time).subtract(chance.integer({ min: 10, max: 120 }), "minutes").toISOString(),
685
- user_id: m.user_id,
686
- viewer_source: "liked_you",
687
- });
688
- });
689
- }
690
- }
691
-
692
- // HOOK 6: BIO + PROMPT POWER USERS — bio + 3+ prompts → 3 extra
693
- // cloned date events per existing. No flag.
694
- if (hasBioUpdated && promptAnsweredCount >= 3) {
695
- const dateEvents = events.filter(e => e.event === "date scheduled");
696
- if (dateEvents.length > 0) {
697
- const dateTemplate = dateEvents[0];
698
- const venueTypes = ["coffee", "dinner", "drinks", "activity", "virtual"];
699
- for (let i = 0; i < dateEvents.length * 3; i++) {
700
- const sourceDate = dateEvents[i % dateEvents.length];
701
- events.push({
702
- ...dateTemplate,
703
- time: dayjs(sourceDate.time).add(chance.integer({ min: 1, max: 72 }), "hours").toISOString(),
704
- user_id: sourceDate.user_id,
705
- venue_type: chance.pickone(venueTypes),
706
- });
707
- }
708
- }
709
- }
710
-
711
- // HOOK 7: VALENTINE'S DAY SPIKE — clone profile-created events during
712
- // days 58-63 (3x volume), plus clone premium-upgrade events 5x. No flag.
713
- const vdaySignups = events.filter(e =>
714
- e.event === "profile created" &&
715
- dayjs(e.time).isAfter(VDAY_WINDOW_START) &&
716
- dayjs(e.time).isBefore(VDAY_WINDOW_END)
717
- );
718
- vdaySignups.forEach(signup => {
719
- for (let i = 0; i < 2; i++) {
720
- events.push({
721
- ...signup,
722
- time: dayjs(signup.time).add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
723
- user_id: signup.user_id,
724
- });
725
- }
726
- });
727
-
728
- const vdayUpgrades = events.filter(e =>
729
- e.event === "premium upgrade" &&
730
- dayjs(e.time).isAfter(VDAY_WINDOW_START) &&
731
- dayjs(e.time).isBefore(VDAY_WINDOW_END)
732
- );
733
- if (vdayUpgrades.length > 0) {
734
- const upgradeTemplate = vdayUpgrades[0];
735
- vdayUpgrades.forEach(upgrade => {
736
- for (let i = 0; i < 4; i++) {
737
- events.push({
738
- ...upgradeTemplate,
739
- time: dayjs(upgrade.time).add(chance.integer({ min: 1, max: 24 }), "hours").toISOString(),
740
- user_id: upgrade.user_id,
741
- plan: upgrade.plan,
742
- price_usd: upgrade.price_usd,
743
- });
744
- }
745
- });
746
- }
747
-
748
- // HOOK 1b: PHOTO MAGIC NUMBER — over-6 score reduction (applied LAST so
749
- // it also affects matches injected by HOOK 4 premium boost).
750
- if (photoUploadCount >= 6) {
751
- events.forEach(e => {
752
- if (e.event === "match received" && typeof e.match_score === "number") {
753
- e.match_score = Math.max(20, Math.round(e.match_score * 0.65));
754
- }
755
- });
756
- }
757
-
758
- // HOOK 8: OFF-APP RETENTION — users with phone-exchanged or
759
- // date-scheduled in first 14 days get extra cloned app-open + swipe
760
- // events past day 30. Non-milestone users lose 80% of post-day-30
761
- // events. No flag.
762
- if (firstEventTime) {
763
- const day30 = dayjs(firstEventTime).add(30, "days");
764
- const hasEarlyMilestone = hasPhoneExchangedEarly || hasDateScheduledEarly;
765
- if (hasEarlyMilestone) {
766
- const appOpenedTemplate = events.find(e => e.event === "app opened") || events[0];
767
- const swipeTemplate = events.find(e => e.event === "swipe right") || events[0];
768
- const postDay30Events = events.filter(e => dayjs(e.time).isAfter(day30));
769
- if (postDay30Events.length < events.length * 0.3) {
770
- const retentionCount = Math.floor(events.length * 0.3);
771
- for (let i = 0; i < retentionCount; i++) {
772
- const daysAfter = chance.integer({ min: 1, max: 60 });
773
- const template = chance.bool({ likelihood: 50 }) ? appOpenedTemplate : swipeTemplate;
774
- events.push({
775
- ...template,
776
- time: day30.add(daysAfter, "days").add(chance.integer({ min: 0, max: 23 }), "hours").toISOString(),
777
- user_id: template.user_id,
778
- });
779
- }
780
- }
781
- } else {
782
- for (let i = events.length - 1; i >= 0; i--) {
783
- if (dayjs(events[i].time).isAfter(day30) && chance.bool({ likelihood: 80 })) {
784
- events.splice(i, 1);
785
- }
786
- }
787
- }
788
- }
789
-
790
- return record;
791
- }
792
-
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);
793
826
  return record;
794
827
  },
795
828
  };