@ak--47/dungeon-master 1.3.0 → 1.4.0

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 (98) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dungeons/technical/array-of-object-lookup.js +0 -2
  3. package/dungeons/technical/hook-helpers-verify.js +89 -0
  4. package/dungeons/technical/identity-model-verify.js +47 -0
  5. package/dungeons/technical/pattern-aggregate-by-bin.js +41 -0
  6. package/dungeons/technical/pattern-attributed-by-source.js +42 -0
  7. package/dungeons/technical/pattern-frequency-by-frequency.js +40 -0
  8. package/dungeons/technical/pattern-funnel-frequency.js +54 -0
  9. package/dungeons/technical/pattern-ttc-by-segment.js +45 -0
  10. package/dungeons/technical/simple.js +3 -4
  11. package/dungeons/technical/simplest-schema.json +5 -0
  12. package/dungeons/technical/text-generation.js +1 -1
  13. package/dungeons/vertical/ai-platform.js +216 -164
  14. package/dungeons/vertical/community.js +74 -41
  15. package/dungeons/vertical/crypto.js +343 -233
  16. package/dungeons/vertical/dating.js +246 -292
  17. package/dungeons/vertical/devtools.js +179 -70
  18. package/dungeons/vertical/ecommerce.js +275 -122
  19. package/dungeons/vertical/education.js +345 -312
  20. package/dungeons/vertical/fintech.js +445 -314
  21. package/dungeons/vertical/fitness.js +199 -165
  22. package/dungeons/vertical/food-delivery.js +309 -330
  23. package/dungeons/vertical/gaming.js +387 -241
  24. package/dungeons/vertical/healthcare.js +149 -66
  25. package/dungeons/vertical/insurance-application.js +173 -75
  26. package/dungeons/vertical/logistics.js +107 -12
  27. package/dungeons/vertical/marketplace.js +171 -76
  28. package/dungeons/vertical/media.js +259 -402
  29. package/dungeons/vertical/real-estate.js +336 -318
  30. package/dungeons/vertical/sass.js +294 -288
  31. package/dungeons/vertical/social.js +289 -234
  32. package/dungeons/vertical/travel.js +66 -20
  33. package/index.js +17 -17
  34. package/lib/core/config-validator.js +226 -175
  35. package/lib/core/context.js +10 -24
  36. package/lib/core/storage.js +6 -1
  37. package/lib/generators/events.js +57 -104
  38. package/lib/generators/funnels.js +212 -93
  39. package/lib/generators/mirror.js +3 -2
  40. package/lib/generators/product-names.js +1 -1
  41. package/lib/generators/scd.js +2 -1
  42. package/lib/generators/text.js +1 -1
  43. package/lib/hook-helpers/_internal.js +23 -0
  44. package/lib/hook-helpers/cohort.js +124 -0
  45. package/lib/hook-helpers/identity.js +56 -0
  46. package/lib/hook-helpers/index.js +44 -0
  47. package/lib/hook-helpers/inject.js +99 -0
  48. package/lib/hook-helpers/mutate.js +151 -0
  49. package/lib/hook-helpers/timing.js +99 -0
  50. package/lib/hook-patterns/aggregate-per-user-by-bin.js +38 -0
  51. package/lib/hook-patterns/attributed-by-source.js +72 -0
  52. package/lib/hook-patterns/frequency-by-frequency.js +46 -0
  53. package/lib/hook-patterns/funnel-frequency-breakdown.js +73 -0
  54. package/lib/hook-patterns/index.js +14 -0
  55. package/lib/hook-patterns/time-to-convert-by-segment.js +41 -0
  56. package/lib/orchestrators/user-loop.js +143 -293
  57. package/lib/utils/utils.js +97 -52
  58. package/lib/verify/emulate-breakdown.js +281 -0
  59. package/lib/verify/index.js +12 -0
  60. package/lib/verify/verify-dungeon.js +61 -0
  61. package/package.json +6 -4
  62. package/scripts/smoke-test-all.mjs +162 -0
  63. package/scripts/verify-runner.mjs +72 -24
  64. package/types.d.ts +434 -225
  65. package/dungeons/technical/ad-spend-schema.json +0 -128
  66. package/dungeons/technical/anonymous-users-schema.json +0 -92
  67. package/dungeons/technical/array-of-object-lookup-schema.json +0 -191
  68. package/dungeons/technical/experiments-schema.json +0 -203
  69. package/dungeons/technical/foobar-schema.json +0 -362
  70. package/dungeons/technical/group-analytics-schema.json +0 -241
  71. package/dungeons/technical/mirror-strategies-schema.json +0 -84
  72. package/dungeons/technical/nested-objects-schema.json +0 -145
  73. package/dungeons/technical/retention-cadence-schema.json +0 -37
  74. package/dungeons/technical/sanity-schema.json +0 -185
  75. package/dungeons/technical/scale-test-schema.json +0 -70
  76. package/dungeons/technical/scd-schema.json +0 -467
  77. package/dungeons/technical/simple-schema.json +0 -362
  78. package/dungeons/technical/text-generation-schema.json +0 -1062
  79. package/dungeons/vertical/ai-platform-schema.json +0 -617
  80. package/dungeons/vertical/community-schema.json +0 -579
  81. package/dungeons/vertical/crypto-schema.json +0 -546
  82. package/dungeons/vertical/dating-schema.json +0 -401
  83. package/dungeons/vertical/devtools-schema.json +0 -601
  84. package/dungeons/vertical/ecommerce-schema.json +0 -604
  85. package/dungeons/vertical/education-schema.json +0 -5686
  86. package/dungeons/vertical/fintech-schema.json +0 -630
  87. package/dungeons/vertical/fitness-schema.json +0 -530
  88. package/dungeons/vertical/food-delivery-schema.json +0 -36728
  89. package/dungeons/vertical/gaming-schema.json +0 -2703
  90. package/dungeons/vertical/healthcare-schema.json +0 -549
  91. package/dungeons/vertical/insurance-application-schema.json +0 -485
  92. package/dungeons/vertical/logistics-schema.json +0 -574
  93. package/dungeons/vertical/marketplace-schema.json +0 -533
  94. package/dungeons/vertical/media-schema.json +0 -4749
  95. package/dungeons/vertical/real-estate-schema.json +0 -527
  96. package/dungeons/vertical/sass-schema.json +0 -3128
  97. package/dungeons/vertical/social-schema.json +0 -620
  98. package/dungeons/vertical/travel-schema.json +0 -580
@@ -1,7 +1,7 @@
1
1
  // ── TWEAK THESE ──
2
2
  const SEED = "dm4-fitness";
3
- const num_days = 100;
4
- const num_users = 5_000;
3
+ const num_days = 120;
4
+ const num_users = 10_000;
5
5
  const avg_events_per_user_per_day = 1.2;
6
6
  let token = "your-mixpanel-token";
7
7
 
@@ -15,9 +15,6 @@ import * as u from "../../lib/utils/utils.js";
15
15
 
16
16
  dayjs.extend(utc);
17
17
  const chance = u.initChance(SEED);
18
- const NOW = dayjs();
19
- const DATASET_START = NOW.subtract(num_days, "days");
20
-
21
18
  /** @typedef {import("../../types").Dungeon} Config */
22
19
 
23
20
  /**
@@ -28,7 +25,7 @@ const DATASET_START = NOW.subtract(num_days, "days");
28
25
  * FitQuest — a fitness & wellness app for workout tracking, meal
29
26
  * planning, social fitness challenges, and AI coaching.
30
27
  *
31
- * - 5,000 users over 100 days, ~600K events
28
+ * - 5,000 users over 120 days, ~600K events
32
29
  * - 5 personas: athlete (10%), casual_exerciser (40%),
33
30
  * new_year_resolver (25%), social_motivator (15%), coach (10%)
34
31
  * - Core loop: sign up → plan workout → complete workout → track progress
@@ -50,32 +47,31 @@ const DATASET_START = NOW.subtract(num_days, "days");
50
47
 
51
48
  /**
52
49
  * ═══════════════════════════════════════════════════════════════
53
- * ANALYTICS HOOKS (8 hooks)
50
+ * ANALYTICS HOOKS (10 hooks)
54
51
  * ═══════════════════════════════════════════════════════════════
55
52
  *
53
+ * NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable
54
+ * via raw-prop breakdowns (HOD, day, segment) or behavioral cohorts.
55
+ *
56
56
  * ───────────────────────────────────────────────────────────────
57
- * 1. MORNING WORKOUT BOOST (event hook)
57
+ * 1. MORNING WORKOUT BOOST (everything)
58
58
  * ───────────────────────────────────────────────────────────────
59
59
  *
60
- * PATTERN: Workouts completed between 5AM-9AM get 1.3x
61
- * calories_burned. Simulates the metabolic boost from fasted
62
- * morning exercise.
60
+ * PATTERN: Workouts in 5-9 UTC get calories_burned 1.3x. Mutates
61
+ * raw prop. No flag.
63
62
  *
64
63
  * HOW TO FIND IT IN MIXPANEL:
65
64
  *
66
- * Report 1: Morning vs Non-Morning Calorie Burn
67
- * Report type: Insights
68
- * Event: "workout completed"
69
- * Measure: Average of "calories_burned"
70
- * Breakdown: "is_morning_workout"
71
- * • Expected: is_morning_workout=true should show ~1.3x avg
72
- * calories vs false (true ≈ 390, false ≈ 300)
65
+ * Report 1: Avg Calories Burned by Hour of Day
66
+ * - Event: "workout completed"
67
+ * - Measure: Average of "calories_burned"
68
+ * - Breakdown: hour of day
69
+ * - Expected: 5-9 hours show ~ 1.3x baseline
73
70
  *
74
- * REAL-WORLD ANALOGUE: Fitness apps promote morning workouts
75
- * as more effective for fat loss, driving engagement.
71
+ * REAL-WORLD ANALOGUE: Morning workouts get a metabolic boost.
76
72
  *
77
73
  * ───────────────────────────────────────────────────────────────
78
- * 2. POST-LAUNCH AI COACHING LIFT (event hook)
74
+ * 2. POST-LAUNCH AI COACHING LIFT (everything hook)
79
75
  * ───────────────────────────────────────────────────────────────
80
76
  *
81
77
  * PATTERN: After day 35 (ai_coach feature launch), workouts
@@ -244,30 +240,84 @@ const DATASET_START = NOW.subtract(num_days, "days");
244
240
  * REAL-WORLD ANALOGUE: Annual gym memberships have higher
245
241
  * utilization — sunk cost + commitment drives consistency.
246
242
  *
243
+ * ───────────────────────────────────────────────────────────────
244
+ * 9. WORKOUT LOOP TIME-TO-CONVERT (funnel-post)
245
+ * ───────────────────────────────────────────────────────────────
246
+ *
247
+ * PATTERN: Annual + family subscribers complete the Workout Loop
248
+ * funnel 1.3x faster (factor 0.77); Free users 1.25x slower (factor 1.25).
249
+ *
250
+ * HOW TO FIND IT IN MIXPANEL:
251
+ *
252
+ * Report 1: Workout Loop Median Time-to-Convert by Subscription
253
+ * - Funnels > "workout planned" -> "workout completed" -> "progress checked"
254
+ * - Measure: Median time to convert
255
+ * - Breakdown: subscription_plan
256
+ * - Expected: annual ~ 0.77x; free ~ 1.25x
257
+ *
258
+ * NOTE (funnel-post measurement): visible only via Mixpanel funnel
259
+ * median TTC. Cross-event MIN→MIN SQL queries on raw events do NOT
260
+ * show this — funnel-post adjusts gaps within funnel instances, not
261
+ * across the user's full event history.
262
+ *
263
+ * ───────────────────────────────────────────────────────────────
264
+ * 10. WORKOUT-COUNT MAGIC NUMBER (everything)
265
+ * ───────────────────────────────────────────────────────────────
266
+ *
267
+ * PATTERN: Sweet 12-20 workouts/user → +35% on workout
268
+ * duration_minutes (peak progression). Over 21+ → drop 30% of
269
+ * post-day-30 events (overtraining churn). No flag.
270
+ *
271
+ * HOW TO FIND IT IN MIXPANEL:
272
+ *
273
+ * Report 1: Avg Workout Duration by Workout-Count Bucket
274
+ * - Cohort A: users with 12-20 "workout completed"
275
+ * - Cohort B: users with 0-11
276
+ * - Event: "workout completed"
277
+ * - Measure: Average of "duration_minutes"
278
+ * - Expected: A ~ 1.35x B
279
+ *
280
+ * Report 2: D30+ Activity on Heavy Workout Cohort
281
+ * - Cohort C: users with >= 21 "workout completed"
282
+ * - Cohort A: users with 12-20
283
+ * - Event: any event
284
+ * - Measure: Total per user, post-day-30
285
+ * - Expected: C ~ 30% fewer post-day-30 events per user
286
+ *
287
+ * REAL-WORLD ANALOGUE: Sweet-spot training drives progression;
288
+ * over-training causes injury and burnout.
289
+ *
247
290
  * ═══════════════════════════════════════════════════════════════
248
291
  * EXPECTED METRICS SUMMARY
249
292
  * ═══════════════════════════════════════════════════════════════
250
293
  *
251
- * Hook | Metric | Baseline | Effect | Ratio
252
- * ────────────────────────────|─────────────────────|──────────|─────────|──────
253
- * Morning Workout Boost | calories_burned | 300 | 390 | 1.3x
254
- * AI Coaching Lift | duration_minutes | 40 | 48 | 1.2x
255
- * Streak Retention | achievements/user | 1 | 2-3 | 2-3x
256
- * Social Challenge Completion | challenges/user | 1 | 1.5 | 1.5x
257
- * Resolver Churn Cliff | events after day 14 | 100% | 30% | 0.3x
258
- * Coach Session Quality | satisfaction_score | 3.0 | 4.3 | 1.4x
259
- * Coach Profile Enrichment | total_workouts | 0 | 350 | N/A
260
- * Annual Funnel Lift | funnel conversion | 45% | 63% | 1.4x
294
+ * Hook | Metric | Baseline | Effect | Ratio
295
+ * ----------------------------|---------------------|----------|-----------|------
296
+ * Morning Workout Boost | calories_burned 5-9 | 1x | 1.3x | 1.3x
297
+ * AI Coaching Lift | duration_minutes | 1x | 1.2x | 1.2x
298
+ * Streak Retention | achievements/user | 1x | 2-3x | 2-3x
299
+ * Social Challenge Completion | challenges/user | 1x | 1.5x | 1.5x
300
+ * Resolver Churn Cliff | events after day 14 | 1x | 0.3x | -70%
301
+ * Coach Session Quality | satisfaction_score | 3.0 | 4.3 | 1.4x
302
+ * Coach Profile Enrichment | total_workouts | 0 | 350 | n/a
303
+ * Annual Funnel Lift | funnel conversion | 45% | 63% | 1.4x
304
+ * Workout Loop T2C | median min by tier | 1x | 0.77/1.25x| ~ 1.6x range
305
+ * Workout Magic Number | sweet duration_min | 1x | 1.35x | 1.35x
306
+ * Workout Magic Number | over D30+ activity | 1x | 0.7x | -30%
261
307
  */
262
308
 
263
309
  /** @type {Config} */
264
310
  const config = {
311
+ version: 2,
265
312
  token,
266
313
  seed: SEED,
267
- numDays: num_days,
314
+ datasetStart: "2026-01-01T00:00:00Z",
315
+ datasetEnd: "2026-05-01T23:59:59Z",
316
+ // numDays: num_days,
268
317
  avgEventsPerUserPerDay: avg_events_per_user_per_day,
269
318
  numUsers: num_users,
270
- hasAnonIds: false,
319
+ hasAnonIds: true,
320
+ avgDevicePerUser: 3,
271
321
  hasSessionIds: true,
272
322
  format: "json",
273
323
  gzip: true,
@@ -300,6 +350,7 @@ const config = {
300
350
  event: "account created",
301
351
  weight: 1,
302
352
  isFirstEvent: true,
353
+ isAuthEvent: true,
303
354
  properties: {
304
355
  referral_source: ["organic", "friend_invite", "app_store", "social_media", "search"],
305
356
  },
@@ -311,7 +362,6 @@ const config = {
311
362
  duration_minutes: u.weighNumRange(10, 90, 0.5, 40),
312
363
  calories_burned: u.weighNumRange(50, 800, 0.4, 300),
313
364
  heart_rate_avg: u.weighNumRange(80, 185, 0.5, 130),
314
- is_morning_workout: [false],
315
365
  satisfaction_score: u.weighNumRange(1, 5, 0.7, 3),
316
366
  coaching_mode: ["self_guided"],
317
367
  challenge_mode: ["solo"],
@@ -508,6 +558,7 @@ const config = {
508
558
  superProps: {
509
559
  Platform: ["ios", "ios", "android"],
510
560
  workout_type: ["strength", "cardio", "yoga", "hiit", "running", "cycling"],
561
+ subscription_tier: ["free"],
511
562
  },
512
563
 
513
564
  // ── UserProps ──────────────────────────────────────────
@@ -520,6 +571,7 @@ const config = {
520
571
  goal: ["weight_loss", "muscle_gain", "endurance", "flexibility", "general_health"],
521
572
  Platform: ["ios", "ios", "android"],
522
573
  workout_type: ["strength", "cardio", "yoga", "hiit", "running", "cycling"],
574
+ subscription_tier: ["free"],
523
575
  },
524
576
 
525
577
  // ── Personas ──────────────────────────────────
@@ -582,69 +634,6 @@ const config = {
582
634
  },
583
635
  ],
584
636
 
585
- // ── Subscription ──────────────────────────────
586
- subscription: {
587
- plans: [
588
- { name: "free", price: 0, default: true },
589
- { name: "monthly", price: 12.99, trialDays: 7 },
590
- { name: "annual", price: 99.99 },
591
- { name: "family", price: 149.99 },
592
- ],
593
- lifecycle: {
594
- trialToPayRate: 0.30,
595
- upgradeRate: 0.08,
596
- downgradeRate: 0.03,
597
- churnRate: 0.05,
598
- winBackRate: 0.10,
599
- winBackDelay: 21,
600
- paymentFailureRate: 0.02,
601
- },
602
- },
603
-
604
- // ── Attribution ──────────────────────────────
605
- attribution: {
606
- model: "last_touch",
607
- window: 7,
608
- campaigns: [
609
- {
610
- name: "app_store",
611
- source: "apple",
612
- medium: "app_store",
613
- activeDays: [0, 100],
614
- dailyBudget: [200, 600],
615
- acquisitionRate: 0.03,
616
- userPersonaBias: { athlete: 0.5, coach: 0.3 },
617
- },
618
- {
619
- name: "google_play",
620
- source: "google",
621
- medium: "app_store",
622
- activeDays: [0, 100],
623
- dailyBudget: [150, 500],
624
- acquisitionRate: 0.02,
625
- },
626
- {
627
- name: "instagram_ads",
628
- source: "instagram",
629
- medium: "social",
630
- activeDays: [5, 90],
631
- dailyBudget: [200, 800],
632
- acquisitionRate: 0.03,
633
- userPersonaBias: { new_year_resolver: 0.5, social_motivator: 0.3 },
634
- },
635
- {
636
- name: "referral_program",
637
- source: "referral",
638
- medium: "referral",
639
- activeDays: [0, 100],
640
- dailyBudget: [50, 200],
641
- acquisitionRate: 0.02,
642
- userPersonaBias: { social_motivator: 0.4 },
643
- },
644
- ],
645
- organicRate: 0.30,
646
- },
647
-
648
637
  // ── Engagement Decay ──────────────────────────
649
638
  engagementDecay: {
650
639
  model: "step",
@@ -653,76 +642,57 @@ const config = {
653
642
  reactivationChance: 0.02,
654
643
  },
655
644
 
656
- // ── Features ──────────────────────────────────
657
- features: [
658
- {
659
- name: "ai_coach",
660
- launchDay: 35,
661
- adoptionCurve: "fast",
662
- property: "coaching_mode",
663
- values: ["self_guided", "ai_assisted"],
664
- defaultBefore: "self_guided",
665
- affectsEvents: ["workout completed", "workout planned"],
666
- },
667
- {
668
- name: "group_challenges",
669
- launchDay: 55,
670
- adoptionCurve: { k: 0.1, midpoint: 25 },
671
- property: "challenge_mode",
672
- values: ["solo", "group"],
673
- defaultBefore: "solo",
674
- affectsEvents: ["challenge joined", "workout completed"],
675
- },
676
- ],
677
-
678
645
  // ── Hook Function ──────────────────────────────────────
679
646
  hook: function (record, type, meta) {
680
- // ── HOOK 7: COACH PROFILE ENRICHMENT (user) ──────────
681
- // Coach segment gets boosted total_workouts and streak_days.
647
+ // HOOK 7: COACH PROFILE ENRICHMENT (user) — coach segment users
648
+ // get high total_workouts + streak_days.
649
+ // Also assign subscription_tier by segment (replacing deprecated subscription feature).
682
650
  if (type === "user") {
683
651
  if (record.segment === "coach") {
684
652
  record.total_workouts = chance.integer({ min: 200, max: 500 });
685
653
  record.streak_days = chance.integer({ min: 60, max: 365 });
686
654
  }
687
- }
688
-
689
- // ── HOOK 8: ANNUAL SUBSCRIBER WORKOUT FUNNEL LIFT (funnel-pre)
690
- // (conversionRate filtering moved to everything hook)
691
- if (type === "funnel-pre") {
692
- // no-op: conversion filtering handled via event dropping in everything hook
693
- }
694
-
695
- // ── HOOK 1: MORNING WORKOUT BOOST (event) ────────────
696
- // Workouts between 5AM-9AM get 1.3x calories_burned.
697
- if (type === "event") {
698
- if (record.event === "workout completed") {
699
- const hour = dayjs(record.time).hour();
700
- if (hour >= 5 && hour < 9) {
701
- record.is_morning_workout = true;
702
- if (record.calories_burned) {
703
- record.calories_burned = Math.floor(record.calories_burned * 1.3);
704
- }
705
- }
655
+ // Subscription tier: athletes/coaches → annual/family; social → monthly; casual/resolver → mostly free
656
+ if (record.segment === "athlete") {
657
+ record.subscription_tier = chance.pickone(["annual", "annual", "family", "monthly"]);
658
+ } else if (record.segment === "coach") {
659
+ record.subscription_tier = chance.pickone(["annual", "family", "family"]);
660
+ } else if (record.segment === "social") {
661
+ record.subscription_tier = chance.pickone(["monthly", "monthly", "annual", "free"]);
662
+ } else if (record.segment === "resolver") {
663
+ record.subscription_tier = chance.pickone(["free", "free", "free", "monthly"]);
664
+ } else {
665
+ record.subscription_tier = chance.pickone(["free", "free", "monthly"]);
706
666
  }
667
+ }
707
668
 
708
- // ── HOOK 2: POST-LAUNCH AI COACHING LIFT (event) ────
709
- // After day 35, ai_assisted workouts get 1.2x duration.
710
- if (record.event === "workout completed" || record.event === "workout planned") {
711
- const AI_LAUNCH = DATASET_START.add(35, "days");
712
- const eventTime = dayjs(record.time);
713
- if (eventTime.isAfter(AI_LAUNCH) && record.coaching_mode === "ai_assisted") {
714
- if (record.duration_minutes) {
715
- record.duration_minutes = Math.floor(record.duration_minutes * 1.2);
716
- }
717
- if (record.planned_duration_minutes) {
718
- record.planned_duration_minutes = Math.floor(record.planned_duration_minutes * 1.2);
669
+ // HOOK 9 (T2C): WORKOUT LOOP TIME-TO-CONVERT (funnel-post)
670
+ // Annual subscribers complete the Workout Loop funnel 1.3x faster
671
+ // (factor 0.77); Free users 1.25x slower (factor 1.25).
672
+ if (type === "funnel-post") {
673
+ const tier = meta?.profile?.subscription_tier;
674
+ if (Array.isArray(record) && record.length > 1) {
675
+ const factor = (
676
+ tier === "annual" || tier === "family" ? 0.77 :
677
+ tier === "free" ? 1.25 :
678
+ 1.0
679
+ );
680
+ if (factor !== 1.0) {
681
+ for (let i = 1; i < record.length; i++) {
682
+ const prev = dayjs(record[i - 1].time);
683
+ const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
684
+ record[i].time = prev.add(newGap, "milliseconds").toISOString();
719
685
  }
720
686
  }
721
687
  }
722
688
  }
723
689
 
690
+ // (HOOK 1 & 2 moved to everything hook — temporal and hour checks
691
+ // must run after bunchIntoSessions redistributes timestamps)
692
+
724
693
  // ── EVERYTHING HOOKS ─────────────────────────────────
725
694
  if (type === "everything") {
695
+ const datasetStart = dayjs.unix(meta.datasetStart);
726
696
  let events = record;
727
697
  if (!events.length) return record;
728
698
 
@@ -733,19 +703,64 @@ const config = {
733
703
  events.forEach(e => {
734
704
  if (p.Platform) e.Platform = p.Platform;
735
705
  if (p.workout_type) e.workout_type = p.workout_type;
706
+ if (p.subscription_tier) e.subscription_tier = p.subscription_tier;
736
707
  });
737
708
  }
738
709
 
710
+ // HOOK 1: MORNING WORKOUT BOOST — 5AM-9AM UTC workouts get
711
+ // calories_burned 1.3x. No flag — analyst breaks down by HOD.
712
+ events.forEach(e => {
713
+ if (e.event === "workout completed") {
714
+ const hour = new Date(e.time).getUTCHours();
715
+ if (hour >= 5 && hour < 9 && e.calories_burned) {
716
+ e.calories_burned = Math.floor(e.calories_burned * 1.3);
717
+ }
718
+ }
719
+ });
720
+
721
+ // ── HOOK 2: POST-LAUNCH AI COACHING LIFT ────────────
722
+ // After day 35, ~40% of workouts switch to ai_assisted coaching,
723
+ // then ai_assisted workouts get 1.2x duration.
724
+ // (Replaces deprecated 'features' config; temporal check in everything per L1)
725
+ const AI_LAUNCH = datasetStart.add(35, "days");
726
+ events.forEach(e => {
727
+ if ((e.event === "workout completed" || e.event === "workout planned") &&
728
+ dayjs(e.time).isAfter(AI_LAUNCH)) {
729
+ // Adopt ai_assisted for ~40% of post-launch workouts
730
+ if (chance.bool({ likelihood: 40 })) {
731
+ e.coaching_mode = "ai_assisted";
732
+ }
733
+ // AI-assisted workouts get 1.2x duration
734
+ if (e.coaching_mode === "ai_assisted") {
735
+ if (e.duration_minutes) {
736
+ e.duration_minutes = Math.floor(e.duration_minutes * 1.2);
737
+ }
738
+ if (e.planned_duration_minutes) {
739
+ e.planned_duration_minutes = Math.floor(e.planned_duration_minutes * 1.2);
740
+ }
741
+ }
742
+ }
743
+ });
744
+
745
+ // ── GROUP CHALLENGES ADOPTION ────────────────────
746
+ // After day 55, ~30% of challenge events switch to group mode.
747
+ // (Replaces deprecated 'features' config)
748
+ const GROUP_LAUNCH = datasetStart.add(55, "days");
749
+ events.forEach(e => {
750
+ if ((e.event === "challenge joined" || e.event === "workout completed") &&
751
+ dayjs(e.time).isAfter(GROUP_LAUNCH) &&
752
+ chance.bool({ likelihood: 30 })) {
753
+ e.challenge_mode = "group";
754
+ }
755
+ });
756
+
739
757
  // ── HOOK 8: ANNUAL SUBSCRIBER CONVERSION FILTER ─
740
758
  // Free/monthly-tier users drop ~30% of "progress checked"
741
759
  // (last step of Workout Loop funnel) to simulate lower conversion.
742
760
  if (meta && meta.profile) {
743
761
  const tier = meta.profile.subscription_tier;
744
- if (tier !== "annual" && tier !== "family") {
745
- record = record.filter(e => {
746
- if (e.event === "progress checked" && chance.bool({ likelihood: 30 })) return false;
747
- return true;
748
- });
762
+ if (tier !== "annual" && tier !== "family" && chance.bool({ likelihood: 30 })) {
763
+ record = record.filter(e => e.event !== "progress checked");
749
764
  events = record;
750
765
  }
751
766
  }
@@ -780,13 +795,13 @@ const config = {
780
795
  }
781
796
 
782
797
  // ── HOOK 4: SOCIAL CHALLENGE COMPLETION ──────────
783
- // Users with friend_added events get 1.5x challenge completions.
784
- const hasFriends = events.some(e => e.event === "friend added");
785
- if (hasFriends) {
798
+ // Users with >=3 friend_added events get 1.5x challenge completions.
799
+ const friendCount = events.filter(e => e.event === "friend added").length;
800
+ if (friendCount >= 3) {
786
801
  const templateChallenge = events.find(e => e.event === "challenge completed");
787
802
  if (templateChallenge) {
788
803
  const challengeCompletions = events.filter(e => e.event === "challenge completed");
789
- const extraCount = Math.floor(challengeCompletions.length * 0.5);
804
+ const extraCount = Math.max(1, Math.floor(challengeCompletions.length * 0.5));
790
805
  for (let i = 0; i < extraCount; i++) {
791
806
  const source = challengeCompletions[i % challengeCompletions.length];
792
807
  events.push({
@@ -801,9 +816,9 @@ const config = {
801
816
  }
802
817
 
803
818
  // ── HOOK 5: RESOLVER CHURN CLIFF ─────────────────
804
- // Resolver segment users with <8 events lose 70% after day 14.
805
- if (meta && meta.profile && meta.profile.segment === "resolver" && events.length < 8) {
806
- const CHURN_CLIFF = DATASET_START.add(14, "days");
819
+ // Resolver segment users with <30 events lose 70% after day 14.
820
+ if (meta && meta.profile && meta.profile.segment === "resolver" && events.length < 30) {
821
+ const CHURN_CLIFF = datasetStart.add(14, "days");
807
822
  for (let i = events.length - 1; i >= 0; i--) {
808
823
  const eventTime = dayjs(events[i].time);
809
824
  if (eventTime.isAfter(CHURN_CLIFF) && chance.bool({ likelihood: 70 })) {
@@ -812,8 +827,7 @@ const config = {
812
827
  }
813
828
  }
814
829
 
815
- // ── HOOK 6: COACH SESSION QUALITY ────────────────
816
- // Users with coach_session events get higher satisfaction.
830
+ // HOOK 6: COACH SESSION QUALITY — coach-session satisfaction 4-5.
817
831
  const hasCoachSessions = events.some(e => e.event === "coach session");
818
832
  if (hasCoachSessions) {
819
833
  events.forEach(e => {
@@ -823,6 +837,26 @@ const config = {
823
837
  });
824
838
  }
825
839
 
840
+ // HOOK 10: WORKOUT-COUNT MAGIC NUMBER (no flags)
841
+ // Sweet 12-20 workouts → +35% on workout duration_minutes (peak
842
+ // progression). Over 21+ → drop 30% of post-day-30 events
843
+ // (overtraining → churn).
844
+ const workoutCount = events.filter(e => e.event === "workout completed").length;
845
+ if (workoutCount >= 12 && workoutCount <= 20) {
846
+ events.forEach(e => {
847
+ if (e.event === "workout completed" && typeof e.duration_minutes === "number") {
848
+ e.duration_minutes = Math.round(e.duration_minutes * 1.35);
849
+ }
850
+ });
851
+ } else if (workoutCount >= 21) {
852
+ const day30 = datasetStart.add(30, "days");
853
+ for (let i = events.length - 1; i >= 0; i--) {
854
+ if (dayjs(events[i].time).isAfter(day30) && chance.bool({ likelihood: 30 })) {
855
+ events.splice(i, 1);
856
+ }
857
+ }
858
+ }
859
+
826
860
  return record;
827
861
  }
828
862