@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,55 +1,45 @@
1
- // ── TWEAK THESE ──
2
- const SEED = "dm4-fitness";
3
- const num_days = 120;
4
- const num_users = 10_000;
5
- const avg_events_per_user_per_day = 1.2;
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
-
16
- dayjs.extend(utc);
17
- const chance = u.initChance(SEED);
18
7
  /** @typedef {import("../../types").Dungeon} Config */
19
8
 
20
- /**
21
- * ═══════════════════════════════════════════════════════════════
22
- * DATASET OVERVIEW
23
- * ═══════════════════════════════════════════════════════════════
24
- *
25
- * FitQuest a fitness & wellness app for workout tracking, meal
26
- * planning, social fitness challenges, and AI coaching.
27
- *
28
- * - 5,000 users over 120 days, ~600K events
29
- * - 5 personas: athlete (10%), casual_exerciser (40%),
30
- * new_year_resolver (25%), social_motivator (15%), coach (10%)
31
- * - Core loop: sign up → plan workout → complete workout → track progress
32
- * - Revenue: free, monthly ($12.99, 7-day trial), annual ($99.99), family ($149.99)
33
- *
34
- * Advanced Features:
35
- * - Personas: 5 archetypes with distinct engagement and churn profiles
36
- * - Subscription: 4-tier revenue lifecycle with trial conversion
37
- * - Attribution: 5 campaign sources with persona biases
38
- * - Engagement Decay: step model with 30-day half-life
39
- * - Features: ai_coach (day 35) and group_challenges (day 55)
40
- *
41
- * Key entities:
42
- * - workout_type: strength/cardio/yoga/hiit/running/cycling
43
- * - fitness_level: beginner/intermediate/advanced/expert
44
- * - coaching_mode: self_guided vs ai_assisted (driven by Feature rollout)
45
- * - challenge_mode: solo vs group (driven by Feature rollout)
9
+ // ── OVERVIEW ──
10
+ /*
11
+ * NAME: FitQuest
12
+ * APP: Fitness & wellness app for workout tracking, meal planning,
13
+ * social fitness challenges, and AI coaching. Core loop: sign
14
+ * up plan workout complete workout track progress.
15
+ * Revenue: free / monthly ($12.99, 7-day trial) / annual
16
+ * ($99.99) / family ($149.99).
17
+ * SCALE: 10,000 users, ~1.4M events, 121 days (2026-01-01 → 2026-05-01)
18
+ * CORE LOOP: account created workout planned → workout completed → progress checked
19
+ *
20
+ * EVENTS (18):
21
+ * workout completed (8) > app session (8) > meal logged (7) > workout planned (6)
22
+ * > progress checked (5) > notification received (5) > leaderboard viewed (4)
23
+ * > nutrition plan viewed (4) > challenge joined (3) > coach session (3)
24
+ * > heart rate recorded (3) > achievement unlocked (2) > friend added (2)
25
+ * > challenge completed (2) > subscription managed (2) > profile updated (2)
26
+ * > account created (1) > account deactivated (1)
27
+ *
28
+ * FUNNELS (5):
29
+ * - Onboarding: account created → profile updated → workout planned → workout completed (45%)
30
+ * - Workout Loop: workout planned → workout completed → progress checked (45%, reentry)
31
+ * - Social Engagement: friend added → leaderboard viewed → challenge joined (35%)
32
+ * - Challenge Completion: challenge joined → workout completed → challenge completed → achievement unlocked (30%)
33
+ * - Coaching Path: coach session workout planned workout completed → progress checked (50%)
34
+ *
35
+ * USER PROPS: fitness_level, segment, streak_days, total_workouts, preferred_workout, goal, Platform, workout_type, subscription_tier
36
+ * SUPER PROPS: Platform, workout_type, subscription_tier
37
+ * SCD PROPS: fitness_level (beginner/intermediate/advanced/elite, monthly fuzzy, max 8)
38
+ * GROUPS: none
46
39
  */
47
40
 
48
- /**
49
- * ═══════════════════════════════════════════════════════════════
50
- * ANALYTICS HOOKS (10 hooks)
51
- * ═══════════════════════════════════════════════════════════════
52
- *
41
+ // ── HOOK STORIES ──
42
+ /*
53
43
  * NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable
54
44
  * via raw-prop breakdowns (HOD, day, segment) or behavioral cohorts.
55
45
  *
@@ -310,31 +300,309 @@ const chance = u.initChance(SEED);
310
300
  * Workout Magic Number | over D30+ post/pre | 1x | 0.29x | -71%
311
301
  */
312
302
 
303
+ // ── SCALE ──
304
+ const SEED = "dm4-fitness";
305
+ const NUM_USERS = 10_000;
306
+ const DATASET_START = "2026-01-01T00:00:00Z";
307
+ const DATASET_END = "2026-05-01T23:59:59Z";
308
+ const EVENTS_PER_DAY = 1.2;
309
+ const token = process.env.MP_TOKEN || "your-mixpanel-token";
310
+
311
+ const chance = u.initChance(SEED);
312
+
313
+ // ── KNOBS (tweak these to reshape stories) ──
314
+ const MORNING_HOUR_START = 5;
315
+ const MORNING_HOUR_END = 9;
316
+ const MORNING_CALORIE_MULT = 1.3;
317
+
318
+ const AI_LAUNCH_DAY = 35;
319
+ const AI_ADOPTION_LIKELIHOOD = 40;
320
+ const AI_DURATION_MULT = 1.2;
321
+
322
+ const GROUP_LAUNCH_DAY = 55;
323
+ const GROUP_ADOPTION_LIKELIHOOD = 30;
324
+
325
+ const STREAK_MIN_WORKOUTS = 2;
326
+ const STREAK_LINEAR_CAP = 3; // workouts 2-4 (count - 1 capped to 3) → 1 achievement each
327
+ const STREAK_SUPER_LINEAR_MULT = 4; // workouts 5+ → 4 achievements each
328
+
329
+ const SOCIAL_FRIEND_THRESHOLD = 3;
330
+ const SOCIAL_CHALLENGE_CLONE_FACTOR = 0.5;
331
+
332
+ const RESOLVER_EVENT_THRESHOLD = 30;
333
+ const RESOLVER_CLIFF_DAYS = 14;
334
+ const RESOLVER_DROP_LIKELIHOOD = 70;
335
+
336
+ const COACH_SESSION_SATISFACTION_MIN = 4.0;
337
+ const COACH_SESSION_SATISFACTION_MAX = 5.0;
338
+
339
+ const COACH_TOTAL_WORKOUTS_MIN = 200;
340
+ const COACH_TOTAL_WORKOUTS_MAX = 500;
341
+ const COACH_STREAK_DAYS_MIN = 60;
342
+ const COACH_STREAK_DAYS_MAX = 365;
343
+
344
+ const ANNUAL_FUNNEL_FREE_DROP_LIKELIHOOD = 30;
345
+
346
+ const TTC_ANNUAL_FACTOR = 0.77;
347
+ const TTC_FREE_FACTOR = 1.25;
348
+
349
+ const WORKOUT_SWEET_MIN = 12;
350
+ const WORKOUT_SWEET_MAX = 14;
351
+ const WORKOUT_OVER_THRESHOLD = 15;
352
+ const WORKOUT_DURATION_BOOST = 1.35;
353
+ const WORKOUT_OVER_CUTOFF_DAYS = 30;
354
+ const WORKOUT_OVER_DROP_LIKELIHOOD = 65;
355
+
356
+ // ── HELPER FUNCTIONS ──
357
+ function handleUserHooks(record) {
358
+ // H7: COACH PROFILE ENRICHMENT — coach segment users get high
359
+ // total_workouts + streak_days. Also assign subscription_tier by segment.
360
+ if (record.segment === "coach") {
361
+ record.total_workouts = chance.integer({ min: COACH_TOTAL_WORKOUTS_MIN, max: COACH_TOTAL_WORKOUTS_MAX });
362
+ record.streak_days = chance.integer({ min: COACH_STREAK_DAYS_MIN, max: COACH_STREAK_DAYS_MAX });
363
+ }
364
+ // Subscription tier: athletes/coaches → annual/family; social → monthly; casual/resolver → mostly free
365
+ if (record.segment === "athlete") {
366
+ record.subscription_tier = chance.pickone(["annual", "annual", "family", "monthly"]);
367
+ } else if (record.segment === "coach") {
368
+ record.subscription_tier = chance.pickone(["annual", "family", "family"]);
369
+ } else if (record.segment === "social") {
370
+ record.subscription_tier = chance.pickone(["monthly", "monthly", "annual", "free"]);
371
+ } else if (record.segment === "resolver") {
372
+ record.subscription_tier = chance.pickone(["free", "free", "free", "monthly"]);
373
+ } else {
374
+ record.subscription_tier = chance.pickone(["free", "free", "monthly"]);
375
+ }
376
+ return record;
377
+ }
378
+
379
+ function handleFunnelPostHooks(record, meta) {
380
+ // H9: WORKOUT LOOP TIME-TO-CONVERT — Annual/family complete 1.3x faster
381
+ // (factor 0.77); Free 1.25x slower (factor 1.25).
382
+ const tier = meta?.profile?.subscription_tier;
383
+ if (Array.isArray(record) && record.length > 1) {
384
+ const factor = (
385
+ tier === "annual" || tier === "family" ? TTC_ANNUAL_FACTOR :
386
+ tier === "free" ? TTC_FREE_FACTOR :
387
+ 1.0
388
+ );
389
+ if (factor !== 1.0) {
390
+ for (let i = 1; i < record.length; i++) {
391
+ const prev = dayjs(record[i - 1].time);
392
+ const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
393
+ record[i].time = prev.add(newGap, "milliseconds").toISOString();
394
+ }
395
+ }
396
+ }
397
+ return record;
398
+ }
399
+
400
+ function handleEverythingHooks(record, meta) {
401
+ const datasetStart = dayjs.unix(meta.datasetStart);
402
+ let events = record;
403
+ if (!events.length) return record;
404
+
405
+ // ── SUPERPROP STAMPING ──────────────────────────
406
+ // Stamp superProps from profile so they are consistent per user.
407
+ if (meta && meta.profile) {
408
+ const p = meta.profile;
409
+ events.forEach(e => {
410
+ if (p.Platform) e.Platform = p.Platform;
411
+ if (p.workout_type) e.workout_type = p.workout_type;
412
+ if (p.subscription_tier) e.subscription_tier = p.subscription_tier;
413
+ });
414
+ }
415
+
416
+ // HOOK 1: MORNING WORKOUT BOOST — 5AM-9AM UTC workouts get
417
+ // calories_burned 1.3x. No flag — analyst breaks down by HOD.
418
+ events.forEach(e => {
419
+ if (e.event === "workout completed") {
420
+ const hour = new Date(e.time).getUTCHours();
421
+ if (hour >= MORNING_HOUR_START && hour < MORNING_HOUR_END && e.calories_burned) {
422
+ e.calories_burned = Math.floor(e.calories_burned * MORNING_CALORIE_MULT);
423
+ }
424
+ }
425
+ });
426
+
427
+ // ── HOOK 2: POST-LAUNCH AI COACHING LIFT ────────────
428
+ // After day 35, ~40% of workouts switch to ai_assisted coaching,
429
+ // then ai_assisted workouts get 1.2x duration.
430
+ const AI_LAUNCH = datasetStart.add(AI_LAUNCH_DAY, "days");
431
+ events.forEach(e => {
432
+ if ((e.event === "workout completed" || e.event === "workout planned") &&
433
+ dayjs(e.time).isAfter(AI_LAUNCH)) {
434
+ // Adopt ai_assisted for ~40% of post-launch workouts
435
+ if (chance.bool({ likelihood: AI_ADOPTION_LIKELIHOOD })) {
436
+ e.coaching_mode = "ai_assisted";
437
+ }
438
+ // AI-assisted workouts get 1.2x duration
439
+ if (e.coaching_mode === "ai_assisted") {
440
+ if (e.duration_minutes) {
441
+ e.duration_minutes = Math.floor(e.duration_minutes * AI_DURATION_MULT);
442
+ }
443
+ if (e.planned_duration_minutes) {
444
+ e.planned_duration_minutes = Math.floor(e.planned_duration_minutes * AI_DURATION_MULT);
445
+ }
446
+ }
447
+ }
448
+ });
449
+
450
+ // ── GROUP CHALLENGES ADOPTION ────────────────────
451
+ // After day 55, ~30% of challenge events switch to group mode.
452
+ const GROUP_LAUNCH = datasetStart.add(GROUP_LAUNCH_DAY, "days");
453
+ events.forEach(e => {
454
+ if ((e.event === "challenge joined" || e.event === "workout completed") &&
455
+ dayjs(e.time).isAfter(GROUP_LAUNCH) &&
456
+ chance.bool({ likelihood: GROUP_ADOPTION_LIKELIHOOD })) {
457
+ e.challenge_mode = "group";
458
+ }
459
+ });
460
+
461
+ // ── HOOK 8: ANNUAL SUBSCRIBER CONVERSION FILTER ─
462
+ // Free/monthly-tier users drop ~30% of "progress checked"
463
+ // (last step of Workout Loop funnel) to simulate lower conversion.
464
+ if (meta && meta.profile) {
465
+ const tier = meta.profile.subscription_tier;
466
+ if (tier !== "annual" && tier !== "family" && chance.bool({ likelihood: ANNUAL_FUNNEL_FREE_DROP_LIKELIHOOD })) {
467
+ record = record.filter(e => e.event !== "progress checked");
468
+ events = record;
469
+ }
470
+ }
471
+
472
+ // ── HOOK 3: STREAK RETENTION ─────────────────────
473
+ // Users with >=2 workouts get streak_days updated and
474
+ // cloned achievement events. Achievements scale super-
475
+ // linearly: 1 per workout for workouts 2-4, then 4 per
476
+ // workout beyond that.
477
+ const workoutEvents = events.filter(e => e.event === "workout completed");
478
+ if (workoutEvents.length >= STREAK_MIN_WORKOUTS) {
479
+ // Update profile streak_days via a profile update event
480
+ if (meta && meta.profile) {
481
+ meta.profile.streak_days = workoutEvents.length;
482
+ }
483
+
484
+ // Super-linear achievement scaling:
485
+ // workouts 2-4: 1 achievement each
486
+ // workouts 5+: 4 achievements each
487
+ const templateAchievement = events.find(e => e.event === "achievement unlocked");
488
+ if (templateAchievement) {
489
+ let achievementCount = Math.min(workoutEvents.length - 1, STREAK_LINEAR_CAP); // 1 each for workouts 2-4
490
+ if (workoutEvents.length > 4) {
491
+ achievementCount += (workoutEvents.length - 4) * STREAK_SUPER_LINEAR_MULT; // 4 each for workouts 5+
492
+ }
493
+ for (let a = 0; a < achievementCount; a++) {
494
+ const srcIdx = Math.min(a, workoutEvents.length - 1);
495
+ const sourceEvent = workoutEvents[srcIdx];
496
+ events.push({
497
+ ...templateAchievement,
498
+ time: dayjs(sourceEvent.time).add(chance.integer({ min: 1, max: 60 }), "minutes").toISOString(),
499
+ user_id: sourceEvent.user_id,
500
+ achievement_type: "streak_milestone",
501
+ streak_days_at_unlock: a + 2,
502
+ });
503
+ }
504
+ }
505
+ }
506
+
507
+ // ── HOOK 4: SOCIAL CHALLENGE COMPLETION ──────────
508
+ // Users with >=3 friend_added events get 1.5x challenge completions.
509
+ const friendCount = events.filter(e => e.event === "friend added").length;
510
+ if (friendCount >= SOCIAL_FRIEND_THRESHOLD) {
511
+ const templateChallenge = events.find(e => e.event === "challenge completed");
512
+ if (templateChallenge) {
513
+ const challengeCompletions = events.filter(e => e.event === "challenge completed");
514
+ const extraCount = Math.max(1, Math.floor(challengeCompletions.length * SOCIAL_CHALLENGE_CLONE_FACTOR));
515
+ for (let i = 0; i < extraCount; i++) {
516
+ const source = challengeCompletions[i % challengeCompletions.length];
517
+ events.push({
518
+ ...templateChallenge,
519
+ time: dayjs(source.time).add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
520
+ user_id: source.user_id,
521
+ challenge_type: source.challenge_type,
522
+ completion_pct: chance.integer({ min: 80, max: 100 }),
523
+ });
524
+ }
525
+ }
526
+ }
527
+
528
+ // ── HOOK 5: RESOLVER CHURN CLIFF ─────────────────
529
+ // Resolver segment users with <30 events lose 70% after day 14.
530
+ if (meta && meta.profile && meta.profile.segment === "resolver" && events.length < RESOLVER_EVENT_THRESHOLD) {
531
+ const CHURN_CLIFF = datasetStart.add(RESOLVER_CLIFF_DAYS, "days");
532
+ for (let i = events.length - 1; i >= 0; i--) {
533
+ const eventTime = dayjs(events[i].time);
534
+ if (eventTime.isAfter(CHURN_CLIFF) && chance.bool({ likelihood: RESOLVER_DROP_LIKELIHOOD })) {
535
+ events.splice(i, 1);
536
+ }
537
+ }
538
+ }
539
+
540
+ // HOOK 6: COACH SESSION QUALITY — coach-session satisfaction 4-5.
541
+ const hasCoachSessions = events.some(e => e.event === "coach session");
542
+ if (hasCoachSessions) {
543
+ events.forEach(e => {
544
+ if (e.event === "coach session") {
545
+ e.satisfaction_score = chance.floating({ min: COACH_SESSION_SATISFACTION_MIN, max: COACH_SESSION_SATISFACTION_MAX, fixed: 1 });
546
+ }
547
+ });
548
+ }
549
+
550
+ // HOOK 10: WORKOUT-COUNT MAGIC NUMBER (no flags)
551
+ // Sweet 12-14 workouts → +35% on workout duration_minutes (peak
552
+ // progression). Over 15+ → drop 65% of post-day-30 non-workout
553
+ // events (overtraining → churn). Workout events are preserved
554
+ // so the bucket categorization stays consistent.
555
+ const workoutCount = events.filter(e => e.event === "workout completed").length;
556
+ if (workoutCount >= WORKOUT_SWEET_MIN && workoutCount <= WORKOUT_SWEET_MAX) {
557
+ events.forEach(e => {
558
+ if (e.event === "workout completed" && typeof e.duration_minutes === "number") {
559
+ e.duration_minutes = Math.round(e.duration_minutes * WORKOUT_DURATION_BOOST);
560
+ }
561
+ });
562
+ } else if (workoutCount >= WORKOUT_OVER_THRESHOLD) {
563
+ const day30 = datasetStart.add(WORKOUT_OVER_CUTOFF_DAYS, "days");
564
+ const preserveEvents = new Set(["workout completed", "progress checked"]);
565
+ for (let i = events.length - 1; i >= 0; i--) {
566
+ if (!preserveEvents.has(events[i].event) &&
567
+ dayjs(events[i].time).isAfter(day30) && chance.bool({ likelihood: WORKOUT_OVER_DROP_LIKELIHOOD })) {
568
+ events.splice(i, 1);
569
+ }
570
+ }
571
+ }
572
+
573
+ return record;
574
+ }
575
+
576
+ // ── CONFIG ──
313
577
  /** @type {Config} */
314
578
  const config = {
315
579
  version: 2,
316
- token,
317
580
  seed: SEED,
318
- datasetStart: "2026-01-01T00:00:00Z",
319
- datasetEnd: "2026-05-01T23:59:59Z",
320
- // numDays: num_days,
321
- avgEventsPerUserPerDay: avg_events_per_user_per_day,
322
- numUsers: num_users,
323
- hasAnonIds: true,
324
- avgDevicePerUser: 3,
325
- hasSessionIds: true,
581
+ datasetStart: DATASET_START,
582
+ datasetEnd: DATASET_END,
583
+ avgEventsPerUserPerDay: EVENTS_PER_DAY,
584
+ numUsers: NUM_USERS,
326
585
  format: "json",
327
586
  gzip: true,
328
- alsoInferFunnels: false,
329
- hasLocation: true,
330
- hasAndroidDevices: true,
331
- hasIOSDevices: true,
332
- hasDesktopDevices: false,
333
- hasBrowser: false,
334
- hasCampaigns: false,
335
- isAnonymous: false,
336
- hasAdSpend: false,
337
- hasAvatar: true,
587
+ credentials: {
588
+ token,
589
+ },
590
+ switches: {
591
+ hasSessionIds: true,
592
+ alsoInferFunnels: false,
593
+ hasLocation: true,
594
+ hasAndroidDevices: true,
595
+ hasIOSDevices: true,
596
+ hasDesktopDevices: false,
597
+ hasBrowser: false,
598
+ hasCampaigns: false,
599
+ isAnonymous: false,
600
+ hasAdSpend: false,
601
+ hasAvatar: true,
602
+ },
603
+ identity: {
604
+ avgDevicePerUser: 3,
605
+ },
338
606
  concurrency: 1,
339
607
  writeToDisk: false,
340
608
  scdProps: {
@@ -656,234 +924,10 @@ const config = {
656
924
  reactivationChance: 0.02,
657
925
  },
658
926
 
659
- // ── Hook Function ──────────────────────────────────────
660
- hook: function (record, type, meta) {
661
- // HOOK 7: COACH PROFILE ENRICHMENT (user) — coach segment users
662
- // get high total_workouts + streak_days.
663
- // Also assign subscription_tier by segment (replacing deprecated subscription feature).
664
- if (type === "user") {
665
- if (record.segment === "coach") {
666
- record.total_workouts = chance.integer({ min: 200, max: 500 });
667
- record.streak_days = chance.integer({ min: 60, max: 365 });
668
- }
669
- // Subscription tier: athletes/coaches → annual/family; social → monthly; casual/resolver → mostly free
670
- if (record.segment === "athlete") {
671
- record.subscription_tier = chance.pickone(["annual", "annual", "family", "monthly"]);
672
- } else if (record.segment === "coach") {
673
- record.subscription_tier = chance.pickone(["annual", "family", "family"]);
674
- } else if (record.segment === "social") {
675
- record.subscription_tier = chance.pickone(["monthly", "monthly", "annual", "free"]);
676
- } else if (record.segment === "resolver") {
677
- record.subscription_tier = chance.pickone(["free", "free", "free", "monthly"]);
678
- } else {
679
- record.subscription_tier = chance.pickone(["free", "free", "monthly"]);
680
- }
681
- }
682
-
683
- // HOOK 9 (T2C): WORKOUT LOOP TIME-TO-CONVERT (funnel-post)
684
- // Annual subscribers complete the Workout Loop funnel 1.3x faster
685
- // (factor 0.77); Free users 1.25x slower (factor 1.25).
686
- if (type === "funnel-post") {
687
- const tier = meta?.profile?.subscription_tier;
688
- if (Array.isArray(record) && record.length > 1) {
689
- const factor = (
690
- tier === "annual" || tier === "family" ? 0.77 :
691
- tier === "free" ? 1.25 :
692
- 1.0
693
- );
694
- if (factor !== 1.0) {
695
- for (let i = 1; i < record.length; i++) {
696
- const prev = dayjs(record[i - 1].time);
697
- const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
698
- record[i].time = prev.add(newGap, "milliseconds").toISOString();
699
- }
700
- }
701
- }
702
- }
703
-
704
- // (HOOK 1 & 2 moved to everything hook — temporal and hour checks
705
- // must run after bunchIntoSessions redistributes timestamps)
706
-
707
- // ── EVERYTHING HOOKS ─────────────────────────────────
708
- if (type === "everything") {
709
- const datasetStart = dayjs.unix(meta.datasetStart);
710
- let events = record;
711
- if (!events.length) return record;
712
-
713
- // ── SUPERPROP STAMPING ──────────────────────────
714
- // Stamp superProps from profile so they are consistent per user.
715
- if (meta && meta.profile) {
716
- const p = meta.profile;
717
- events.forEach(e => {
718
- if (p.Platform) e.Platform = p.Platform;
719
- if (p.workout_type) e.workout_type = p.workout_type;
720
- if (p.subscription_tier) e.subscription_tier = p.subscription_tier;
721
- });
722
- }
723
-
724
- // HOOK 1: MORNING WORKOUT BOOST — 5AM-9AM UTC workouts get
725
- // calories_burned 1.3x. No flag — analyst breaks down by HOD.
726
- events.forEach(e => {
727
- if (e.event === "workout completed") {
728
- const hour = new Date(e.time).getUTCHours();
729
- if (hour >= 5 && hour < 9 && e.calories_burned) {
730
- e.calories_burned = Math.floor(e.calories_burned * 1.3);
731
- }
732
- }
733
- });
734
-
735
- // ── HOOK 2: POST-LAUNCH AI COACHING LIFT ────────────
736
- // After day 35, ~40% of workouts switch to ai_assisted coaching,
737
- // then ai_assisted workouts get 1.2x duration.
738
- // (Replaces deprecated 'features' config; temporal check in everything per L1)
739
- const AI_LAUNCH = datasetStart.add(35, "days");
740
- events.forEach(e => {
741
- if ((e.event === "workout completed" || e.event === "workout planned") &&
742
- dayjs(e.time).isAfter(AI_LAUNCH)) {
743
- // Adopt ai_assisted for ~40% of post-launch workouts
744
- if (chance.bool({ likelihood: 40 })) {
745
- e.coaching_mode = "ai_assisted";
746
- }
747
- // AI-assisted workouts get 1.2x duration
748
- if (e.coaching_mode === "ai_assisted") {
749
- if (e.duration_minutes) {
750
- e.duration_minutes = Math.floor(e.duration_minutes * 1.2);
751
- }
752
- if (e.planned_duration_minutes) {
753
- e.planned_duration_minutes = Math.floor(e.planned_duration_minutes * 1.2);
754
- }
755
- }
756
- }
757
- });
758
-
759
- // ── GROUP CHALLENGES ADOPTION ────────────────────
760
- // After day 55, ~30% of challenge events switch to group mode.
761
- // (Replaces deprecated 'features' config)
762
- const GROUP_LAUNCH = datasetStart.add(55, "days");
763
- events.forEach(e => {
764
- if ((e.event === "challenge joined" || e.event === "workout completed") &&
765
- dayjs(e.time).isAfter(GROUP_LAUNCH) &&
766
- chance.bool({ likelihood: 30 })) {
767
- e.challenge_mode = "group";
768
- }
769
- });
770
-
771
- // ── HOOK 8: ANNUAL SUBSCRIBER CONVERSION FILTER ─
772
- // Free/monthly-tier users drop ~30% of "progress checked"
773
- // (last step of Workout Loop funnel) to simulate lower conversion.
774
- if (meta && meta.profile) {
775
- const tier = meta.profile.subscription_tier;
776
- if (tier !== "annual" && tier !== "family" && chance.bool({ likelihood: 30 })) {
777
- record = record.filter(e => e.event !== "progress checked");
778
- events = record;
779
- }
780
- }
781
-
782
- // ── HOOK 3: STREAK RETENTION ─────────────────────
783
- // Users with >=2 workouts get streak_days updated and
784
- // cloned achievement events. Achievements scale super-
785
- // linearly: 1 per workout for workouts 2-4, then 4 per
786
- // workout beyond that. This amplifies the gap between
787
- // high-workout segments (athlete/coach) and casual users.
788
- const workoutEvents = events.filter(e => e.event === "workout completed");
789
- if (workoutEvents.length >= 2) {
790
- // Update profile streak_days via a profile update event
791
- if (meta && meta.profile) {
792
- meta.profile.streak_days = workoutEvents.length;
793
- }
794
-
795
- // Super-linear achievement scaling:
796
- // workouts 2-4: 1 achievement each
797
- // workouts 5+: 4 achievements each
798
- const templateAchievement = events.find(e => e.event === "achievement unlocked");
799
- if (templateAchievement) {
800
- let achievementCount = Math.min(workoutEvents.length - 1, 3); // 1 each for workouts 2-4
801
- if (workoutEvents.length > 4) {
802
- achievementCount += (workoutEvents.length - 4) * 4; // 4 each for workouts 5+
803
- }
804
- for (let a = 0; a < achievementCount; a++) {
805
- const srcIdx = Math.min(a, workoutEvents.length - 1);
806
- const sourceEvent = workoutEvents[srcIdx];
807
- events.push({
808
- ...templateAchievement,
809
- time: dayjs(sourceEvent.time).add(chance.integer({ min: 1, max: 60 }), "minutes").toISOString(),
810
- user_id: sourceEvent.user_id,
811
- achievement_type: "streak_milestone",
812
- streak_days_at_unlock: a + 2,
813
- });
814
- }
815
- }
816
- }
817
-
818
- // ── HOOK 4: SOCIAL CHALLENGE COMPLETION ──────────
819
- // Users with >=3 friend_added events get 1.5x challenge completions.
820
- const friendCount = events.filter(e => e.event === "friend added").length;
821
- if (friendCount >= 3) {
822
- const templateChallenge = events.find(e => e.event === "challenge completed");
823
- if (templateChallenge) {
824
- const challengeCompletions = events.filter(e => e.event === "challenge completed");
825
- const extraCount = Math.max(1, Math.floor(challengeCompletions.length * 0.5));
826
- for (let i = 0; i < extraCount; i++) {
827
- const source = challengeCompletions[i % challengeCompletions.length];
828
- events.push({
829
- ...templateChallenge,
830
- time: dayjs(source.time).add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
831
- user_id: source.user_id,
832
- challenge_type: source.challenge_type,
833
- completion_pct: chance.integer({ min: 80, max: 100 }),
834
- });
835
- }
836
- }
837
- }
838
-
839
- // ── HOOK 5: RESOLVER CHURN CLIFF ─────────────────
840
- // Resolver segment users with <30 events lose 70% after day 14.
841
- if (meta && meta.profile && meta.profile.segment === "resolver" && events.length < 30) {
842
- const CHURN_CLIFF = datasetStart.add(14, "days");
843
- for (let i = events.length - 1; i >= 0; i--) {
844
- const eventTime = dayjs(events[i].time);
845
- if (eventTime.isAfter(CHURN_CLIFF) && chance.bool({ likelihood: 70 })) {
846
- events.splice(i, 1);
847
- }
848
- }
849
- }
850
-
851
- // HOOK 6: COACH SESSION QUALITY — coach-session satisfaction 4-5.
852
- const hasCoachSessions = events.some(e => e.event === "coach session");
853
- if (hasCoachSessions) {
854
- events.forEach(e => {
855
- if (e.event === "coach session") {
856
- e.satisfaction_score = chance.floating({ min: 4.0, max: 5.0, fixed: 1 });
857
- }
858
- });
859
- }
860
-
861
- // HOOK 10: WORKOUT-COUNT MAGIC NUMBER (no flags)
862
- // Sweet 12-14 workouts → +35% on workout duration_minutes (peak
863
- // progression). Over 15+ → drop 65% of post-day-30 non-workout
864
- // events (overtraining → churn). Workout events are preserved
865
- // so the bucket categorization stays consistent.
866
- const workoutCount = events.filter(e => e.event === "workout completed").length;
867
- if (workoutCount >= 12 && workoutCount <= 14) {
868
- events.forEach(e => {
869
- if (e.event === "workout completed" && typeof e.duration_minutes === "number") {
870
- e.duration_minutes = Math.round(e.duration_minutes * 1.35);
871
- }
872
- });
873
- } else if (workoutCount >= 15) {
874
- const day30 = datasetStart.add(30, "days");
875
- const preserveEvents = new Set(["workout completed", "progress checked"]);
876
- for (let i = events.length - 1; i >= 0; i--) {
877
- if (!preserveEvents.has(events[i].event) &&
878
- dayjs(events[i].time).isAfter(day30) && chance.bool({ likelihood: 65 })) {
879
- events.splice(i, 1);
880
- }
881
- }
882
- }
883
-
884
- return record;
885
- }
886
-
927
+ hook(record, type, meta) {
928
+ if (type === "user") return handleUserHooks(record);
929
+ if (type === "funnel-post") return handleFunnelPostHooks(record, meta);
930
+ if (type === "everything") return handleEverythingHooks(record, meta);
887
931
  return record;
888
932
  },
889
933
  };