@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,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: {
@@ -362,6 +630,7 @@ const config = {
362
630
  {
363
631
  event: "workout completed",
364
632
  weight: 8,
633
+ isStrictEvent: false,
365
634
  properties: {
366
635
  duration_minutes: u.weighNumRange(10, 90, 0.5, 40),
367
636
  calories_burned: u.weighNumRange(50, 800, 0.4, 300),
@@ -374,6 +643,7 @@ const config = {
374
643
  {
375
644
  event: "workout planned",
376
645
  weight: 6,
646
+ isStrictEvent: false,
377
647
  properties: {
378
648
  planned_duration_minutes: u.weighNumRange(15, 90, 0.5, 45),
379
649
  day_of_week: ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"],
@@ -393,6 +663,7 @@ const config = {
393
663
  {
394
664
  event: "challenge joined",
395
665
  weight: 3,
666
+ isStrictEvent: false,
396
667
  properties: {
397
668
  challenge_type: ["steps", "calories", "streak", "strength", "team_relay"],
398
669
  duration_days: [7, 14, 21, 30],
@@ -403,6 +674,7 @@ const config = {
403
674
  {
404
675
  event: "challenge completed",
405
676
  weight: 2,
677
+ isStrictEvent: false,
406
678
  properties: {
407
679
  challenge_type: ["steps", "calories", "streak", "strength", "team_relay"],
408
680
  final_rank: u.weighNumRange(1, 50, 0.3, 10),
@@ -413,6 +685,7 @@ const config = {
413
685
  {
414
686
  event: "achievement unlocked",
415
687
  weight: 2,
688
+ isStrictEvent: false,
416
689
  properties: {
417
690
  achievement_type: ["streak_milestone", "weight_goal", "distance_record", "calories_target", "social_champion", "first_workout"],
418
691
  streak_days_at_unlock: u.weighNumRange(1, 100, 0.3, 10),
@@ -421,6 +694,7 @@ const config = {
421
694
  {
422
695
  event: "friend added",
423
696
  weight: 2,
697
+ isStrictEvent: false,
424
698
  properties: {
425
699
  source: ["search", "contacts", "challenge", "suggestion", "qr_code"],
426
700
  },
@@ -436,6 +710,7 @@ const config = {
436
710
  {
437
711
  event: "progress checked",
438
712
  weight: 5,
713
+ isStrictEvent: false,
439
714
  properties: {
440
715
  metric_viewed: ["weight", "body_fat", "steps", "calories_burned", "workout_count", "streaks"],
441
716
  trend_direction: ["improving", "improving", "stable", "declining"],
@@ -445,6 +720,7 @@ const config = {
445
720
  {
446
721
  event: "coach session",
447
722
  weight: 3,
723
+ isStrictEvent: false,
448
724
  properties: {
449
725
  session_type: ["live_video", "chat", "plan_review", "form_check"],
450
726
  duration_minutes: u.weighNumRange(10, 60, 0.5, 30),
@@ -497,6 +773,7 @@ const config = {
497
773
  {
498
774
  event: "profile updated",
499
775
  weight: 2,
776
+ isStrictEvent: false,
500
777
  properties: {
501
778
  field_updated: ["weight", "height", "goal", "avatar", "workout_preferences", "notification_settings"],
502
779
  },
@@ -531,6 +808,7 @@ const config = {
531
808
  order: "sequential",
532
809
  timeToConvert: 48,
533
810
  weight: 5,
811
+ reentry: true,
534
812
  },
535
813
  {
536
814
  name: "Social Engagement",
@@ -646,234 +924,10 @@ const config = {
646
924
  reactivationChance: 0.02,
647
925
  },
648
926
 
649
- // ── Hook Function ──────────────────────────────────────
650
- hook: function (record, type, meta) {
651
- // HOOK 7: COACH PROFILE ENRICHMENT (user) — coach segment users
652
- // get high total_workouts + streak_days.
653
- // Also assign subscription_tier by segment (replacing deprecated subscription feature).
654
- if (type === "user") {
655
- if (record.segment === "coach") {
656
- record.total_workouts = chance.integer({ min: 200, max: 500 });
657
- record.streak_days = chance.integer({ min: 60, max: 365 });
658
- }
659
- // Subscription tier: athletes/coaches → annual/family; social → monthly; casual/resolver → mostly free
660
- if (record.segment === "athlete") {
661
- record.subscription_tier = chance.pickone(["annual", "annual", "family", "monthly"]);
662
- } else if (record.segment === "coach") {
663
- record.subscription_tier = chance.pickone(["annual", "family", "family"]);
664
- } else if (record.segment === "social") {
665
- record.subscription_tier = chance.pickone(["monthly", "monthly", "annual", "free"]);
666
- } else if (record.segment === "resolver") {
667
- record.subscription_tier = chance.pickone(["free", "free", "free", "monthly"]);
668
- } else {
669
- record.subscription_tier = chance.pickone(["free", "free", "monthly"]);
670
- }
671
- }
672
-
673
- // HOOK 9 (T2C): WORKOUT LOOP TIME-TO-CONVERT (funnel-post)
674
- // Annual subscribers complete the Workout Loop funnel 1.3x faster
675
- // (factor 0.77); Free users 1.25x slower (factor 1.25).
676
- if (type === "funnel-post") {
677
- const tier = meta?.profile?.subscription_tier;
678
- if (Array.isArray(record) && record.length > 1) {
679
- const factor = (
680
- tier === "annual" || tier === "family" ? 0.77 :
681
- tier === "free" ? 1.25 :
682
- 1.0
683
- );
684
- if (factor !== 1.0) {
685
- for (let i = 1; i < record.length; i++) {
686
- const prev = dayjs(record[i - 1].time);
687
- const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
688
- record[i].time = prev.add(newGap, "milliseconds").toISOString();
689
- }
690
- }
691
- }
692
- }
693
-
694
- // (HOOK 1 & 2 moved to everything hook — temporal and hour checks
695
- // must run after bunchIntoSessions redistributes timestamps)
696
-
697
- // ── EVERYTHING HOOKS ─────────────────────────────────
698
- if (type === "everything") {
699
- const datasetStart = dayjs.unix(meta.datasetStart);
700
- let events = record;
701
- if (!events.length) return record;
702
-
703
- // ── SUPERPROP STAMPING ──────────────────────────
704
- // Stamp superProps from profile so they are consistent per user.
705
- if (meta && meta.profile) {
706
- const p = meta.profile;
707
- events.forEach(e => {
708
- if (p.Platform) e.Platform = p.Platform;
709
- if (p.workout_type) e.workout_type = p.workout_type;
710
- if (p.subscription_tier) e.subscription_tier = p.subscription_tier;
711
- });
712
- }
713
-
714
- // HOOK 1: MORNING WORKOUT BOOST — 5AM-9AM UTC workouts get
715
- // calories_burned 1.3x. No flag — analyst breaks down by HOD.
716
- events.forEach(e => {
717
- if (e.event === "workout completed") {
718
- const hour = new Date(e.time).getUTCHours();
719
- if (hour >= 5 && hour < 9 && e.calories_burned) {
720
- e.calories_burned = Math.floor(e.calories_burned * 1.3);
721
- }
722
- }
723
- });
724
-
725
- // ── HOOK 2: POST-LAUNCH AI COACHING LIFT ────────────
726
- // After day 35, ~40% of workouts switch to ai_assisted coaching,
727
- // then ai_assisted workouts get 1.2x duration.
728
- // (Replaces deprecated 'features' config; temporal check in everything per L1)
729
- const AI_LAUNCH = datasetStart.add(35, "days");
730
- events.forEach(e => {
731
- if ((e.event === "workout completed" || e.event === "workout planned") &&
732
- dayjs(e.time).isAfter(AI_LAUNCH)) {
733
- // Adopt ai_assisted for ~40% of post-launch workouts
734
- if (chance.bool({ likelihood: 40 })) {
735
- e.coaching_mode = "ai_assisted";
736
- }
737
- // AI-assisted workouts get 1.2x duration
738
- if (e.coaching_mode === "ai_assisted") {
739
- if (e.duration_minutes) {
740
- e.duration_minutes = Math.floor(e.duration_minutes * 1.2);
741
- }
742
- if (e.planned_duration_minutes) {
743
- e.planned_duration_minutes = Math.floor(e.planned_duration_minutes * 1.2);
744
- }
745
- }
746
- }
747
- });
748
-
749
- // ── GROUP CHALLENGES ADOPTION ────────────────────
750
- // After day 55, ~30% of challenge events switch to group mode.
751
- // (Replaces deprecated 'features' config)
752
- const GROUP_LAUNCH = datasetStart.add(55, "days");
753
- events.forEach(e => {
754
- if ((e.event === "challenge joined" || e.event === "workout completed") &&
755
- dayjs(e.time).isAfter(GROUP_LAUNCH) &&
756
- chance.bool({ likelihood: 30 })) {
757
- e.challenge_mode = "group";
758
- }
759
- });
760
-
761
- // ── HOOK 8: ANNUAL SUBSCRIBER CONVERSION FILTER ─
762
- // Free/monthly-tier users drop ~30% of "progress checked"
763
- // (last step of Workout Loop funnel) to simulate lower conversion.
764
- if (meta && meta.profile) {
765
- const tier = meta.profile.subscription_tier;
766
- if (tier !== "annual" && tier !== "family" && chance.bool({ likelihood: 30 })) {
767
- record = record.filter(e => e.event !== "progress checked");
768
- events = record;
769
- }
770
- }
771
-
772
- // ── HOOK 3: STREAK RETENTION ─────────────────────
773
- // Users with >=2 workouts get streak_days updated and
774
- // cloned achievement events. Achievements scale super-
775
- // linearly: 1 per workout for workouts 2-4, then 4 per
776
- // workout beyond that. This amplifies the gap between
777
- // high-workout segments (athlete/coach) and casual users.
778
- const workoutEvents = events.filter(e => e.event === "workout completed");
779
- if (workoutEvents.length >= 2) {
780
- // Update profile streak_days via a profile update event
781
- if (meta && meta.profile) {
782
- meta.profile.streak_days = workoutEvents.length;
783
- }
784
-
785
- // Super-linear achievement scaling:
786
- // workouts 2-4: 1 achievement each
787
- // workouts 5+: 4 achievements each
788
- const templateAchievement = events.find(e => e.event === "achievement unlocked");
789
- if (templateAchievement) {
790
- let achievementCount = Math.min(workoutEvents.length - 1, 3); // 1 each for workouts 2-4
791
- if (workoutEvents.length > 4) {
792
- achievementCount += (workoutEvents.length - 4) * 4; // 4 each for workouts 5+
793
- }
794
- for (let a = 0; a < achievementCount; a++) {
795
- const srcIdx = Math.min(a, workoutEvents.length - 1);
796
- const sourceEvent = workoutEvents[srcIdx];
797
- events.push({
798
- ...templateAchievement,
799
- time: dayjs(sourceEvent.time).add(chance.integer({ min: 1, max: 60 }), "minutes").toISOString(),
800
- user_id: sourceEvent.user_id,
801
- achievement_type: "streak_milestone",
802
- streak_days_at_unlock: a + 2,
803
- });
804
- }
805
- }
806
- }
807
-
808
- // ── HOOK 4: SOCIAL CHALLENGE COMPLETION ──────────
809
- // Users with >=3 friend_added events get 1.5x challenge completions.
810
- const friendCount = events.filter(e => e.event === "friend added").length;
811
- if (friendCount >= 3) {
812
- const templateChallenge = events.find(e => e.event === "challenge completed");
813
- if (templateChallenge) {
814
- const challengeCompletions = events.filter(e => e.event === "challenge completed");
815
- const extraCount = Math.max(1, Math.floor(challengeCompletions.length * 0.5));
816
- for (let i = 0; i < extraCount; i++) {
817
- const source = challengeCompletions[i % challengeCompletions.length];
818
- events.push({
819
- ...templateChallenge,
820
- time: dayjs(source.time).add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
821
- user_id: source.user_id,
822
- challenge_type: source.challenge_type,
823
- completion_pct: chance.integer({ min: 80, max: 100 }),
824
- });
825
- }
826
- }
827
- }
828
-
829
- // ── HOOK 5: RESOLVER CHURN CLIFF ─────────────────
830
- // Resolver segment users with <30 events lose 70% after day 14.
831
- if (meta && meta.profile && meta.profile.segment === "resolver" && events.length < 30) {
832
- const CHURN_CLIFF = datasetStart.add(14, "days");
833
- for (let i = events.length - 1; i >= 0; i--) {
834
- const eventTime = dayjs(events[i].time);
835
- if (eventTime.isAfter(CHURN_CLIFF) && chance.bool({ likelihood: 70 })) {
836
- events.splice(i, 1);
837
- }
838
- }
839
- }
840
-
841
- // HOOK 6: COACH SESSION QUALITY — coach-session satisfaction 4-5.
842
- const hasCoachSessions = events.some(e => e.event === "coach session");
843
- if (hasCoachSessions) {
844
- events.forEach(e => {
845
- if (e.event === "coach session") {
846
- e.satisfaction_score = chance.floating({ min: 4.0, max: 5.0, fixed: 1 });
847
- }
848
- });
849
- }
850
-
851
- // HOOK 10: WORKOUT-COUNT MAGIC NUMBER (no flags)
852
- // Sweet 12-14 workouts → +35% on workout duration_minutes (peak
853
- // progression). Over 15+ → drop 65% of post-day-30 non-workout
854
- // events (overtraining → churn). Workout events are preserved
855
- // so the bucket categorization stays consistent.
856
- const workoutCount = events.filter(e => e.event === "workout completed").length;
857
- if (workoutCount >= 12 && workoutCount <= 14) {
858
- events.forEach(e => {
859
- if (e.event === "workout completed" && typeof e.duration_minutes === "number") {
860
- e.duration_minutes = Math.round(e.duration_minutes * 1.35);
861
- }
862
- });
863
- } else if (workoutCount >= 15) {
864
- const day30 = datasetStart.add(30, "days");
865
- const preserveEvents = new Set(["workout completed", "progress checked"]);
866
- for (let i = events.length - 1; i >= 0; i--) {
867
- if (!preserveEvents.has(events[i].event) &&
868
- dayjs(events[i].time).isAfter(day30) && chance.bool({ likelihood: 65 })) {
869
- events.splice(i, 1);
870
- }
871
- }
872
- }
873
-
874
- return record;
875
- }
876
-
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);
877
931
  return record;
878
932
  },
879
933
  };