@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 = "harness-media";
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
 
@@ -29,7 +29,7 @@ const chance = u.initChance(SEED);
29
29
  * content with configurable playback options, rate and share content, and manage family
30
30
  * profiles under a single account.
31
31
  *
32
- * - 5,000 users over 100 days
32
+ * - 10,000 users over 120 days
33
33
  * - ~600,000 events across 17 event types
34
34
  * - 9 funnels (onboarding, content discovery, engagement loop, search, watchlist, etc.)
35
35
  * - Subscription tiers: Free (ad-supported), Standard ($9.99/mo), Premium ($14.99/mo)
@@ -44,179 +44,225 @@ const chance = u.initChance(SEED);
44
44
 
45
45
  /**
46
46
  * ═══════════════════════════════════════════════════════════════════════════════
47
- * ANALYTICS HOOKS (8 architected patterns)
47
+ * ANALYTICS HOOKS (10 hooks)
48
+ *
49
+ * Adds 10. CORE VIEWING LOOP TIME-TO-CONVERT: premium 0.71x faster, free 1.25x
50
+ * slower (funnel-post). Discover via funnel median TTC by subscription_plan.
51
+ * NOTE (funnel-post measurement): visible only via Mixpanel funnel median TTC.
52
+ * Cross-event MIN→MIN SQL queries on raw events do NOT show this.
48
53
  * ═══════════════════════════════════════════════════════════════════════════════
49
54
  *
55
+ * NOTE: All cohort effects are HIDDEN — discoverable only via behavioral cohorts
56
+ * (count an event per user, then measure downstream). No cohort flag is stamped
57
+ * on events. Time-window patterns mutate config-defined props or drop events.
58
+ *
50
59
  * ───────────────────────────────────────────────────────────────────────────────
51
- * 1. GENRE FUNNEL CONVERSION (funnel-pre)
60
+ * 1. GENRE FUNNEL CONVERSION (everything)
52
61
  * ───────────────────────────────────────────────────────────────────────────────
53
62
  *
54
- * PATTERN: Comedy and Animation content has 1.3x higher funnel conversion rates,
55
- * while Documentary content has 0.7x conversion (users browse but abandon more).
63
+ * PATTERN: 25% of "playback completed" events on documentary genre are dropped,
64
+ * depressing the documentary funnel completion rate. No flag discover via
65
+ * funnel breakdown by genre.
66
+ *
67
+ * HOW TO FIND IT IN MIXPANEL:
56
68
  *
57
- * HOW TO FIND IT:
58
- * - Funnels: "content browsed" -> "content selected" -> "playback started" -> "playback completed"
69
+ * Report 1: Discovery Funnel by Genre
70
+ * - Report type: Funnels
71
+ * - Steps: "content browsed" -> "content selected" -> "playback started" -> "playback completed"
59
72
  * - Breakdown: "genre"
60
- * - Expected: Comedy/Animation convert at ~65% vs Documentary at ~35%
61
- * - Also try: Insights on "content selected", breakdown by "genre_boost"
73
+ * - Expected: documentary ~ 0.7x conversion vs other genres
74
+ *
75
+ * REAL-WORLD ANALOGUE: Heavier content gets started but abandoned more often
76
+ * than light comedy or animation.
62
77
  *
63
78
  * ───────────────────────────────────────────────────────────────────────────────
64
79
  * 2. BINGE-WATCHING PATTERN (everything)
65
80
  * ───────────────────────────────────────────────────────────────────────────────
66
81
  *
67
- * PATTERN: Users who complete 3+ episodes consecutively become "binge-watchers":
68
- * - Extra playback started + playback completed events are spliced in
69
- * - Completion percentages are 90-100% (they finish every episode)
70
- * - Pause events are reduced by 60% (they don't stop watching)
71
- * - Events tagged with binge_session = true
82
+ * PATTERN: Users with 3+ consecutive playback-completed events get extra
83
+ * cloned playback-started + playback-completed pairs (with unique offset
84
+ * timestamps). 60% of their pause events are dropped. No flag discover by
85
+ * binning users on completion-streak length and comparing per-user completions.
86
+ *
87
+ * HOW TO FIND IT IN MIXPANEL:
72
88
  *
73
- * HOW TO FIND IT:
74
- * - Insights: "playback completed", total events per user, breakdown by "binge_session"
75
- * - Expected: binge_session=true users have 40-60% more completions per user
76
- * - Also try: Average of "completion_percent" by "binge_session" (90-100% vs ~70%)
89
+ * Report 1: Completions per User by Streak Cohort
90
+ * - Report type: Insights (with cohort)
91
+ * - Cohort A: users with >= 3 consecutive "playback completed"
92
+ * - Cohort B: rest
93
+ * - Event: "playback completed"
94
+ * - Measure: Total per user
95
+ * - Expected: A ~ 1.5x more completions per user
96
+ *
97
+ * REAL-WORLD ANALOGUE: Autoplay and cliffhangers push hooked viewers through
98
+ * entire seasons in a sitting.
77
99
  *
78
100
  * ───────────────────────────────────────────────────────────────────────────────
79
- * 3. WEEKEND vs WEEKDAY PATTERNS (event)
101
+ * 3. WEEKEND VS WEEKDAY PATTERNS (everything)
80
102
  * ───────────────────────────────────────────────────────────────────────────────
81
103
  *
82
- * PATTERN: Weekend viewing sessions are 1.5x longer than weekday sessions.
83
- * Weekday viewing concentrates in evening prime-time (6PM-11PM).
104
+ * PATTERN: Saturday/Sunday "playback completed" events get watch_duration_min
105
+ * boosted 1.5x. Mutates the existing watch_duration_min prop. No flag —
106
+ * discover via day-of-week breakdown.
107
+ *
108
+ * HOW TO FIND IT IN MIXPANEL:
109
+ *
110
+ * Report 1: Avg Watch Duration by Day of Week
111
+ * - Report type: Insights
112
+ * - Event: "playback completed"
113
+ * - Measure: Average of "watch_duration_min"
114
+ * - Breakdown: Day of week
115
+ * - Expected: Sat/Sun ~ 1.5x weekday avg
84
116
  *
85
- * HOW TO FIND IT:
86
- * - Insights: "playback completed", average of "watch_duration_min", breakdown by "weekend_viewing"
87
- * - Expected: weekend_viewing=true ~67 min avg vs false ~45 min avg (1.5x)
88
- * - Also try: Total "playback completed" events by "prime_time" (60-70% of weekday views)
117
+ * REAL-WORLD ANALOGUE: Weekend viewing stretches into multi-hour marathons.
89
118
  *
90
119
  * ───────────────────────────────────────────────────────────────────────────────
91
120
  * 4. AD FATIGUE CHURN (everything)
92
121
  * ───────────────────────────────────────────────────────────────────────────────
93
122
  *
94
- * PATTERN: Free-tier users who see 10+ ad impressions experience 50% churn
95
- * after day 45 of their lifecycle.
123
+ * PATTERN: Free-tier users with 10+ ad impressions lose 50% of events after
124
+ * day 45 of their lifecycle. No flag — discover via cohort retention.
125
+ *
126
+ * HOW TO FIND IT IN MIXPANEL:
127
+ *
128
+ * Report 1: Retention by Ad Exposure Cohort
129
+ * - Report type: Retention
130
+ * - Cohort A: free-tier users with >= 10 "ad impression"
131
+ * - Cohort B: free-tier users with < 10 ads
132
+ * - Expected: A ~ 50% retention drop after day 45
96
133
  *
97
- * HOW TO FIND IT:
98
- * - Retention: any event (filter subscription_plan=free), segment by ad impression count >= 10 vs < 10
99
- * - Expected: 10+ ad users show ~50% retention drop after day 45
100
- * - Also try: Insights line chart, any event filtered by "ad_fatigue" = true, weekly trend
134
+ * REAL-WORLD ANALOGUE: Ad-supported tiers carry a tolerance ceiling.
101
135
  *
102
136
  * ───────────────────────────────────────────────────────────────────────────────
103
137
  * 5. NEW RELEASE SPIKE (event)
104
138
  * ───────────────────────────────────────────────────────────────────────────────
105
139
  *
106
- * PATTERN: On day 50, a blockbuster movie releases, creating a content spike:
107
- * - 20% of content selected and playback started events redirect to the blockbuster
108
- * - Content rated events for the blockbuster skew to 4-5 star ratings
109
- * - All affected events tagged with blockbuster_release = true
140
+ * PATTERN: Days 50-65, 20% of "content selected" / "playback started" events
141
+ * have content_id swapped to the blockbuster id and content_type to "movie".
142
+ * 20% of "content rated" in the window get a 4-5 star rating on the blockbuster.
143
+ * Mutates existing props no flag.
110
144
  *
111
- * HOW TO FIND IT:
112
- * - Insights line chart: "content selected", filter "blockbuster_release" = true, daily trend
113
- * - Expected: Zero before day 50, then ~20% of content selections
114
- * - Also try: "content rated" average of "rating" by "blockbuster_release" (4-5 vs ~3.5)
145
+ * HOW TO FIND IT IN MIXPANEL:
146
+ *
147
+ * Report 1: Selections by content_id Over Time
148
+ * - Report type: Insights
149
+ * - Event: "content selected"
150
+ * - Measure: Total
151
+ * - Filter: content_id = "<blockbuster id>"
152
+ * - Line chart by day
153
+ * - Expected: zero before day 50, ~ 20% of selections days 50-65
154
+ *
155
+ * REAL-WORLD ANALOGUE: Tentpole releases dominate traffic.
115
156
  *
116
157
  * ───────────────────────────────────────────────────────────────────────────────
117
158
  * 6. KIDS PROFILE SAFETY (event)
118
159
  * ───────────────────────────────────────────────────────────────────────────────
119
160
  *
120
- * PATTERN: 15% of the time, events are tagged as kids profile activity:
121
- * - Content selection restricted to animation and documentary genres
122
- * - Ad impressions are blocked (ad_blocked = true)
123
- * - Events tagged with kids_profile = true
124
- *
125
- * HOW TO FIND IT:
126
- * - Insights: "content selected", breakdown by "genre", filter "kids_profile" = true
127
- * - Expected: 100% animation and documentary genres only
128
- * - Also try: "ad impression" breakdown by "kids_profile" — kids should have ad_blocked=true
161
+ * PATTERN: 15% of "content selected" / "playback started" events get genre
162
+ * restricted to "animation" or "documentary". Mutates the existing genre prop.
129
163
  *
130
- * ───────────────────────────────────────────────────────────────────────────────
131
- * 7. RECOMMENDATION ENGINE IMPROVEMENT (funnel-pre)
132
- * ───────────────────────────────────────────────────────────────────────────────
164
+ * HOW TO FIND IT IN MIXPANEL:
133
165
  *
134
- * PATTERN: After day 60, the engagement loop funnel (recommendation clicked ->
135
- * playback started -> content rated) gets a 1.5x conversion rate boost,
136
- * simulating a recommendation engine improvement.
166
+ * Report 1: Genre Distribution Over Time
167
+ * - Report type: Insights
168
+ * - Event: "content selected"
169
+ * - Measure: Total
170
+ * - Breakdown: "genre"
171
+ * - Expected: animation + documentary share is elevated above pure random
137
172
  *
138
- * HOW TO FIND IT:
139
- * - Funnels: "recommendation clicked" -> "playback started" -> "playback completed" -> "content rated"
140
- * - Breakdown: "improved_recs"
141
- * - Expected: improved_recs=true shows ~1.5x higher conversion rate
142
- * - Also try: Insights line chart of "recommendation clicked" filtered by "improved_recs" = true
173
+ * REAL-WORLD ANALOGUE: Kids profiles enforce age-appropriate content.
143
174
  *
144
175
  * ───────────────────────────────────────────────────────────────────────────────
145
- * 8. SUBTITLE USERS WATCH MORE (everything)
176
+ * 7. RECOMMENDATION ENGINE IMPROVEMENT (everything)
146
177
  * ───────────────────────────────────────────────────────────────────────────────
147
178
  *
148
- * PATTERN: Users who enable subtitles have measurably higher engagement:
149
- * - 25% higher completion_percent on playback completed events (capped at 100)
150
- * - 15% longer watch_duration_min
151
- * - 20% more playback completed events (extra content consumption)
152
- * - Events tagged with subtitle_user = true
179
+ * PATTERN: Pre-day-60 "content rated" events get 30% dropped, depressing the
180
+ * recommendation funnel conversion in the first 60 days. No flag — discover
181
+ * via funnel/insights line chart by day.
153
182
  *
154
- * HOW TO FIND IT:
155
- * - Insights: "playback completed", average of "completion_percent", breakdown by "subtitle_user"
156
- * - Expected: subtitle_user=true ~85% completion vs false ~68% (1.25x)
157
- * - Also try: Average of "watch_duration_min" by "subtitle_user" (~15% longer)
158
- * - Also try: Total events per user by "subtitle_user" (~20% more completions)
183
+ * HOW TO FIND IT IN MIXPANEL:
159
184
  *
160
- * ═══════════════════════════════════════════════════════════════════════════════
161
- * EXPECTED METRICS SUMMARY
162
- * ═══════════════════════════════════════════════════════════════════════════════
185
+ * Report 1: Content Rated Volume Over Time
186
+ * - Report type: Insights
187
+ * - Event: "content rated"
188
+ * - Measure: Total
189
+ * - Line chart by day
190
+ * - Expected: visible step-up at day 60
163
191
  *
164
- * Hook | Metric | Baseline | Hook Effect | Ratio
165
- * ─────────────────────────|─────────────────────────|───────────|─────────────|──────
166
- * Genre Funnel Conversion | Funnel conversion rate | 50% | 65% / 35% | 1.3x / 0.7x
167
- * Binge-Watching | Content consumed/user | 12 | 18-20 | ~1.5x
168
- * Weekend vs Weekday | Watch duration (min) | 45 | 67 (weekend)| 1.5x
169
- * Ad Fatigue Churn | Post-day-45 activity | 100% | 50% | 0.5x
170
- * New Release Spike | Content selections/day | baseline | +20% spike | 1.2x
171
- * Kids Profile Safety | Ad impressions | normal | 0 (dropped) | 0x
172
- * Rec Engine Improvement | Engagement funnel conv | 30% | 45% | 1.5x
173
- * Subtitle Users | Completion percent | 68% | 85% | 1.25x
192
+ * Report 2: Recommendation Funnel Conversion Over Time
193
+ * - Report type: Funnels
194
+ * - Steps: "recommendation clicked" -> "playback started" -> "content rated"
195
+ * - Compare date ranges (days 0-59 vs 60-100)
196
+ * - Expected: post-improvement window ~ 1.5x conversion
174
197
  *
175
- * ═══════════════════════════════════════════════════════════════════════════════
176
- * ADVANCED ANALYSIS IDEAS
177
- * ═══════════════════════════════════════════════════════════════════════════════
198
+ * REAL-WORLD ANALOGUE: Rec model upgrades produce a step-change in CTR.
178
199
  *
179
- * CROSS-HOOK PATTERNS:
200
+ * ───────────────────────────────────────────────────────────────────────────────
201
+ * 8. SUBTITLE USERS WATCH MORE (everything)
202
+ * ───────────────────────────────────────────────────────────────────────────────
180
203
  *
181
- * 1. Binge + Subtitle: Do subtitle-enabled binge-watchers have the highest
182
- * total watch hours? (Hooks #2 + #8 combined)
204
+ * PATTERN: Users who toggle subtitles enabled get 1.25x completion_percent
205
+ * (cap 100), 1.15x watch_duration_min, plus 20% extra cloned playback-completed
206
+ * events. No flag — discover via cohort builder on subtitle-toggled-enabled.
183
207
  *
184
- * 2. Ad Fatigue + Blockbuster: Does the blockbuster release (Hook #5) rescue
185
- * free-tier users from ad fatigue churn (Hook #4)?
208
+ * HOW TO FIND IT IN MIXPANEL:
186
209
  *
187
- * 3. Kids + Weekend: Is kids profile viewing concentrated on weekends (Hook #6
188
- * + #3)? Does weekend kids viewing show different genre preferences?
210
+ * Report 1: Completion by Subtitle Cohort
211
+ * - Report type: Insights (with cohort)
212
+ * - Cohort A: users with >= 1 "subtitle toggled" with action="enabled"
213
+ * - Cohort B: rest
214
+ * - Event: "playback completed"
215
+ * - Measure: Average of "completion_percent"
216
+ * - Expected: A ~ 85% vs B ~ 68%
189
217
  *
190
- * 4. Rec Engine + Genre: Does the recommendation engine improvement (Hook #7)
191
- * disproportionately help certain genres (Hook #1)?
218
+ * REAL-WORLD ANALOGUE: Subtitle adoption correlates with attentive viewers.
192
219
  *
193
- * 5. Subtitle + Binge + Weekend: The "super viewer" - subtitle-enabled,
194
- * binge-watching on weekends. What is their lifetime watch hours?
220
+ * ───────────────────────────────────────────────────────────────────────────────
221
+ * 9. RECOMMENDATION-CLICKED MAGIC NUMBER (everything)
222
+ * ───────────────────────────────────────────────────────────────────────────────
195
223
  *
196
- * COHORT ANALYSIS:
224
+ * PATTERN: Users in the 4-6 recommendation-clicked sweet spot get 1.25x
225
+ * watch_duration_min on playback-completed events. Users with 7+ rec clicks
226
+ * are over-engaged (decision fatigue); 30% of their playback-completed events
227
+ * drop. No flag — discover by binning users on rec-click count.
197
228
  *
198
- * - Cohort by signup_source: Do referral users binge more than organic?
199
- * - Cohort by device_type: Do smart TV users watch longer than mobile?
200
- * - Cohort by subscription_plan: Do premium users binge more, or does
201
- * ad-free viewing change consumption patterns?
202
- * - Cohort by preferred_genre: Does genre preference predict churn?
229
+ * HOW TO FIND IT IN MIXPANEL:
203
230
  *
204
- * FUNNEL ANALYSIS:
231
+ * Report 1: Avg Watch Duration by Rec-Click Bucket
232
+ * - Report type: Insights (with cohort)
233
+ * - Cohort A: users with 4-6 "recommendation clicked"
234
+ * - Cohort B: users with 0-3 "recommendation clicked"
235
+ * - Event: "playback completed"
236
+ * - Measure: Average of "watch_duration_min"
237
+ * - Expected: A ~ 1.25x B
205
238
  *
206
- * - Onboarding Funnel: account created -> content browsed -> playback started.
207
- * How does signup_source affect first-session conversion?
208
- * - Content Discovery Funnel: Does the browse_section (home vs trending vs
209
- * genre) affect downstream completion rates?
210
- * - Engagement Loop: How does recommendation algorithm type (collaborative
211
- * filtering vs editorial) affect the full loop conversion?
239
+ * Report 2: Completions per User on Heavy Rec Clickers
240
+ * - Report type: Insights (with cohort)
241
+ * - Cohort C: users with >= 7 "recommendation clicked"
242
+ * - Cohort A: users with 4-6
243
+ * - Event: "playback completed"
244
+ * - Measure: Total per user
245
+ * - Expected: C ~ 30% fewer completions per user vs A
212
246
  *
213
- * MONETIZATION ANALYSIS:
247
+ * REAL-WORLD ANALOGUE: A few good recs surface a watchworthy title; too many
248
+ * clicks signals indecision and drives abandonment.
214
249
  *
215
- * - Free-to-Standard conversion: Which events predict upgrade?
216
- * - Ad tolerance threshold: At what ad count do free users start churning?
217
- * - Premium value: Do premium users actually consume more content, or just
218
- * consume at higher quality (4K)?
219
- * - Download behavior: Does offline download usage correlate with retention?
250
+ * ═══════════════════════════════════════════════════════════════════════════════
251
+ * EXPECTED METRICS SUMMARY
252
+ * ═══════════════════════════════════════════════════════════════════════════════
253
+ *
254
+ * Hook | Metric | Baseline | Hook Effect | Ratio
255
+ * ─────────────────────────|─────────────────────────|----------|-------------|------
256
+ * Genre Funnel Conversion | documentary funnel conv | 1x | 0.7x | -30%
257
+ * Binge-Watching | completions per streak | 1x | ~ 1.5x | 1.5x
258
+ * Weekend vs Weekday | weekend watch_duration | 1x | ~ 1.5x | 1.5x
259
+ * Ad Fatigue Churn | retention free+10+ads | 1x | ~ 0.5x | -50%
260
+ * New Release Spike | blockbuster id share | 0% | ~ 20% | n/a
261
+ * Kids Profile Safety | animation/doc share | baseline | + 15% | n/a
262
+ * Rec Engine Improvement | content-rated post day60| 1x | ~ 1.5x | 1.5x
263
+ * Subtitle Users | completion % | 68% | 85% | 1.25x
264
+ * Rec-Click Magic Number | sweet watch duration | 1x | 1.25x | 1.25x
265
+ * Rec-Click Magic Number | over completions/user | 1x | 0.7x | -30%
220
266
  */
221
267
 
222
268
  // Generate consistent content IDs for lookup tables and events
@@ -225,12 +271,16 @@ const blockbusterId = `blockbuster_${v.uid(8)}`;
225
271
 
226
272
  /** @type {Config} */
227
273
  const config = {
274
+ version: 2,
228
275
  token,
229
276
  seed: SEED,
230
- numDays: num_days,
277
+ datasetStart: "2026-01-01T00:00:00Z",
278
+ datasetEnd: "2026-05-01T23:59:59Z",
279
+ // numDays: num_days,
231
280
  avgEventsPerUserPerDay: avg_events_per_user_per_day,
232
281
  numUsers: num_users,
233
- hasAnonIds: false,
282
+ hasAnonIds: true,
283
+ avgDevicePerUser: 2,
234
284
  hasSessionIds: true,
235
285
  format: "json",
236
286
  gzip: true,
@@ -320,13 +370,10 @@ const config = {
320
370
  event: "account created",
321
371
  weight: 1,
322
372
  isFirstEvent: true,
373
+ isAuthEvent: true,
323
374
  properties: {
324
375
  "signup_source": ["organic", "referral", "trial_offer", "ad"],
325
376
  "plan_selected": ["free", "standard", "premium"],
326
- "weekend_viewing": [false],
327
- "prime_time": [false],
328
- "kids_profile": [false],
329
- "ad_fatigue": [false],
330
377
  }
331
378
  },
332
379
  {
@@ -335,10 +382,6 @@ const config = {
335
382
  properties: {
336
383
  "browse_section": ["home", "trending", "new_releases", "genre", "continue_watching"],
337
384
  "genre": ["action", "comedy", "drama", "documentary", "horror", "sci_fi", "animation", "thriller", "romance"],
338
- "weekend_viewing": [false],
339
- "prime_time": [false],
340
- "kids_profile": [false],
341
- "ad_fatigue": [false],
342
385
  }
343
386
  },
344
387
  {
@@ -348,11 +391,6 @@ const config = {
348
391
  "content_type": ["movie", "series", "documentary", "special"],
349
392
  "genre": ["action", "comedy", "drama", "documentary", "horror", "sci_fi", "animation", "thriller", "romance"],
350
393
  "content_id": contentIds,
351
- "blockbuster_release": [false],
352
- "kids_profile": [false],
353
- "weekend_viewing": [false],
354
- "prime_time": [false],
355
- "ad_fatigue": [false],
356
394
  }
357
395
  },
358
396
  {
@@ -364,12 +402,6 @@ const config = {
364
402
  "playback_quality": ["480p", "720p", "1080p", "4k"],
365
403
  "subtitle_language": ["none", "english", "spanish", "french", "japanese", "korean"],
366
404
  "playback_speed": ["0.5x", "1x", "1x", "1x", "1.25x", "1.5x", "2x"],
367
- "blockbuster_release": [false],
368
- "kids_profile": [false],
369
- "binge_session": [false],
370
- "weekend_viewing": [false],
371
- "prime_time": [false],
372
- "ad_fatigue": [false],
373
405
  }
374
406
  },
375
407
  {
@@ -380,12 +412,6 @@ const config = {
380
412
  "content_type": ["movie", "series", "documentary", "special"],
381
413
  "watch_duration_min": u.weighNumRange(5, 180, 0.5, 45),
382
414
  "completion_percent": u.weighNumRange(10, 100, 1.5, 85),
383
- "binge_session": [false],
384
- "subtitle_user": [false],
385
- "weekend_viewing": [false],
386
- "prime_time": [false],
387
- "kids_profile": [false],
388
- "ad_fatigue": [false],
389
415
  }
390
416
  },
391
417
  {
@@ -394,10 +420,6 @@ const config = {
394
420
  properties: {
395
421
  "content_id": contentIds,
396
422
  "pause_reason": ["manual", "ad_break", "buffering", "notification"],
397
- "weekend_viewing": [false],
398
- "prime_time": [false],
399
- "kids_profile": [false],
400
- "ad_fatigue": [false],
401
423
  }
402
424
  },
403
425
  {
@@ -407,11 +429,6 @@ const config = {
407
429
  "content_id": contentIds,
408
430
  "rating": u.weighNumRange(1, 5, 2, 4),
409
431
  "review_text_length": u.weighNumRange(0, 500, 0.2, 0),
410
- "blockbuster_release": [false],
411
- "weekend_viewing": [false],
412
- "prime_time": [false],
413
- "kids_profile": [false],
414
- "ad_fatigue": [false],
415
432
  }
416
433
  },
417
434
  {
@@ -421,10 +438,6 @@ const config = {
421
438
  "content_id": contentIds,
422
439
  "content_type": ["movie", "series", "documentary", "special"],
423
440
  "genre": ["action", "comedy", "drama", "documentary", "horror", "sci_fi", "animation", "thriller", "romance"],
424
- "weekend_viewing": [false],
425
- "prime_time": [false],
426
- "kids_profile": [false],
427
- "ad_fatigue": [false],
428
441
  }
429
442
  },
430
443
  {
@@ -433,10 +446,6 @@ const config = {
433
446
  properties: {
434
447
  "content_id": contentIds,
435
448
  "reason": ["watched", "not_interested", "expired"],
436
- "weekend_viewing": [false],
437
- "prime_time": [false],
438
- "kids_profile": [false],
439
- "ad_fatigue": [false],
440
449
  }
441
450
  },
442
451
  {
@@ -446,10 +455,6 @@ const config = {
446
455
  "search_term": () => chance.word(),
447
456
  "results_count": u.weighNumRange(0, 50, 0.5, 15),
448
457
  "search_type": ["title", "actor", "director", "genre"],
449
- "weekend_viewing": [false],
450
- "prime_time": [false],
451
- "kids_profile": [false],
452
- "ad_fatigue": [false],
453
458
  }
454
459
  },
455
460
  {
@@ -458,10 +463,6 @@ const config = {
458
463
  properties: {
459
464
  "algorithm": ["collaborative_filtering", "content_based", "trending", "editorial"],
460
465
  "position": u.weighNumRange(1, 20),
461
- "weekend_viewing": [false],
462
- "prime_time": [false],
463
- "kids_profile": [false],
464
- "ad_fatigue": [false],
465
466
  }
466
467
  },
467
468
  {
@@ -469,10 +470,6 @@ const config = {
469
470
  weight: 4,
470
471
  properties: {
471
472
  "profile_type": ["main", "kids", "partner", "guest"],
472
- "weekend_viewing": [false],
473
- "prime_time": [false],
474
- "kids_profile": [false],
475
- "ad_fatigue": [false],
476
473
  }
477
474
  },
478
475
  {
@@ -482,11 +479,6 @@ const config = {
482
479
  "ad_type": ["pre_roll", "mid_roll", "banner", "interstitial"],
483
480
  "ad_duration_sec": u.weighNumRange(5, 30),
484
481
  "skipped": [false, false, false, true, true],
485
- "ad_blocked": [false],
486
- "kids_profile": [false],
487
- "weekend_viewing": [false],
488
- "prime_time": [false],
489
- "ad_fatigue": [false],
490
482
  }
491
483
  },
492
484
  {
@@ -496,10 +488,6 @@ const config = {
496
488
  "old_plan": ["free", "standard", "premium"],
497
489
  "new_plan": ["free", "standard", "premium"],
498
490
  "change_reason": ["upgrade", "downgrade", "cancel", "resubscribe"],
499
- "weekend_viewing": [false],
500
- "prime_time": [false],
501
- "kids_profile": [false],
502
- "ad_fatigue": [false],
503
491
  }
504
492
  },
505
493
  {
@@ -509,10 +497,6 @@ const config = {
509
497
  "content_id": contentIds,
510
498
  "content_type": ["movie", "series", "documentary", "special"],
511
499
  "download_quality": ["720p", "1080p", "4k"],
512
- "weekend_viewing": [false],
513
- "prime_time": [false],
514
- "kids_profile": [false],
515
- "ad_fatigue": [false],
516
500
  }
517
501
  },
518
502
  {
@@ -521,10 +505,6 @@ const config = {
521
505
  properties: {
522
506
  "share_method": ["link", "social", "dm", "email"],
523
507
  "content_type": ["movie", "series", "documentary", "special"],
524
- "weekend_viewing": [false],
525
- "prime_time": [false],
526
- "kids_profile": [false],
527
- "ad_fatigue": [false],
528
508
  }
529
509
  },
530
510
  {
@@ -533,10 +513,6 @@ const config = {
533
513
  properties: {
534
514
  "subtitle_language": ["none", "english", "spanish", "french", "japanese", "korean"],
535
515
  "action": ["enabled", "disabled", "changed"],
536
- "weekend_viewing": [false],
537
- "prime_time": [false],
538
- "kids_profile": [false],
539
- "ad_fatigue": [false],
540
516
  }
541
517
  },
542
518
  ],
@@ -567,141 +543,48 @@ const config = {
567
543
 
568
544
  lookupTables: [],
569
545
 
570
- /**
571
- * ARCHITECTED ANALYTICS HOOKS
572
- *
573
- * This hook function creates 8 deliberate patterns in the data:
574
- *
575
- * 1. GENRE FUNNEL CONVERSION: Comedy/Animation props in funnel-pre; documentary completion drop in everything
576
- * 2. BINGE-WATCHING: Users with 3+ consecutive completions get extra episodes (everything)
577
- * 3. WEEKEND vs WEEKDAY: Weekend sessions 1.5x longer; weekday prime-time tagging (event)
578
- * 4. AD FATIGUE CHURN: Free-tier users with 10+ ads churn after day 45 (everything)
579
- * 5. NEW RELEASE SPIKE: Blockbuster release on day 50 drives content selection (event)
580
- * 6. KIDS PROFILE SAFETY: Kids mode restricts genres and drops ads (event)
581
- * 7. RECOMMENDATION ENGINE IMPROVEMENT: Props in funnel-pre; pre-day-60 content rated drop in everything
582
- * 8. SUBTITLE USERS WATCH MORE: Subtitle-enabled users have higher completion rates (everything)
583
- */
584
546
  hook: function (record, type, meta) {
585
- const NOW = dayjs();
586
- const DATASET_START = NOW.subtract(num_days, 'days');
587
- // FIXED_START is the pre-shift time range start; needed because
588
- // meta.firstEventTime in funnel-pre is in the FIXED (pre-shift) timeframe
589
- const FIXED_NOW_UNIX = dayjs('2024-02-02').unix();
590
- const FIXED_START = dayjs.unix(FIXED_NOW_UNIX).subtract(num_days, 'days');
591
-
592
- // ─────────────────────────────────────────────────────────────
593
- // Hook #1: GENRE FUNNEL CONVERSION (funnel-pre)
594
- // Comedy/Animation funnels convert 1.3x better; Documentary 0.7x
595
- // ─────────────────────────────────────────────────────────────
596
- if (type === "funnel-pre") {
597
- record.props = record.props || {};
598
- const genre = record.props.genre;
599
-
600
- if (genre === "comedy" || genre === "animation") {
601
- record.props.genre_boost = true;
602
- record.props.genre_penalty = false;
603
- } else if (genre === "documentary") {
604
- record.props.genre_boost = false;
605
- record.props.genre_penalty = true;
606
- } else if (!genre && chance.bool({ likelihood: 25 })) {
607
- // Randomly apply genre effects when genre isn't in funnel props
608
- if (chance.bool({ likelihood: 60 })) {
609
- record.props.genre_boost = true;
610
- record.props.genre_penalty = false;
611
- } else {
612
- record.props.genre_boost = false;
613
- record.props.genre_penalty = true;
547
+ // Hook #10 (T2C): CORE VIEWING LOOP TIME-TO-CONVERT (funnel-post)
548
+ // Premium subscribers complete browse→play funnel 1.4x faster
549
+ // (factor 0.71); free users 1.25x slower (factor 1.25).
550
+ if (type === "funnel-post") {
551
+ const segment = meta?.profile?.subscription_plan;
552
+ if (Array.isArray(record) && record.length > 1) {
553
+ const factor = (
554
+ segment === "premium" ? 0.71 :
555
+ segment === "free" ? 1.25 :
556
+ 1.0
557
+ );
558
+ if (factor !== 1.0) {
559
+ for (let i = 1; i < record.length; i++) {
560
+ const prev = dayjs(record[i - 1].time);
561
+ const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
562
+ record[i].time = prev.add(newGap, "milliseconds").toISOString();
563
+ }
614
564
  }
615
- } else {
616
- record.props.genre_boost = false;
617
- record.props.genre_penalty = false;
618
565
  }
619
-
620
- // ─────────────────────────────────────────────────────────────
621
- // Hook #7: RECOMMENDATION ENGINE IMPROVEMENT (funnel-pre)
622
- // After day 60, engagement funnel gets 1.5x boost
623
- // ─────────────────────────────────────────────────────────────
624
- const seq = record.sequence || [];
625
- const isEngagementFunnel = seq.includes("recommendation clicked");
626
- const funnelTime = meta && meta.firstEventTime ? dayjs.unix(meta.firstEventTime) : null;
627
- const isAfterImprovement = funnelTime && funnelTime.isAfter(FIXED_START.add(60, 'days'));
628
-
629
- if (isEngagementFunnel && isAfterImprovement) {
630
- record.props.improved_recs = true;
631
- } else {
632
- record.props.improved_recs = false;
633
- }
634
-
635
- return record;
636
566
  }
637
567
 
638
- // ─────────────────────────────────────────────────────────────
639
- // Hook #3: WEEKEND vs WEEKDAY PATTERNS
640
- // Moved to everything hook (after sessionization) so DOW/HOD
641
- // tags match final timestamps. See everything hook below.
642
- // ─────────────────────────────────────────────────────────────
643
-
644
568
  if (type === "event") {
645
- // ─────────────────────────────────────────────────────────────
646
- // Hook #5: NEW RELEASE SPIKE (event)
647
- // Days 50-65: blockbuster release drives content selection.
648
- // Bounded window concentrates the spike instead of spreading
649
- // it across the entire second half of the dataset.
650
- // ─────────────────────────────────────────────────────────────
651
- const BLOCKBUSTER_START = DATASET_START.add(50, 'days');
652
- const BLOCKBUSTER_END = DATASET_START.add(65, 'days');
653
- const EVENT_TIME = dayjs(record.time);
654
- if (record.event === "content selected" || record.event === "playback started") {
655
- if (EVENT_TIME.isAfter(BLOCKBUSTER_START) && EVENT_TIME.isBefore(BLOCKBUSTER_END) && chance.bool({ likelihood: 20 })) {
656
- record.content_type = "movie";
657
- record.content_id = blockbusterId;
658
- record.blockbuster_release = true;
659
- } else {
660
- record.blockbuster_release = false;
661
- }
662
- }
663
-
664
- if (record.event === "content rated") {
665
- if (EVENT_TIME.isAfter(BLOCKBUSTER_START) && EVENT_TIME.isBefore(BLOCKBUSTER_END) && chance.bool({ likelihood: 20 })) {
666
- record.rating = chance.integer({ min: 4, max: 5 });
667
- record.content_id = blockbusterId;
668
- record.blockbuster_release = true;
669
- } else {
670
- record.blockbuster_release = false;
671
- }
672
- }
673
-
674
- // ─────────────────────────────────────────────────────────────
675
- // Hook #6: KIDS PROFILE SAFETY (event)
676
- // 15% of the time, apply kids mode: restrict genres, drop ads
677
- // ─────────────────────────────────────────────────────────────
569
+ // Hook #6: KIDS PROFILE SAFETY — 15% of selections/starts get genre
570
+ // restricted to animation or documentary. Mutates existing genre prop.
678
571
  if (chance.bool({ likelihood: 15 })) {
679
- record.kids_profile = true;
680
572
  if (record.event === "content selected" || record.event === "playback started") {
681
573
  record.genre = chance.pickone(["animation", "documentary"]);
682
- } else if (record.event === "ad impression") {
683
- record.ad_blocked = true;
684
- }
685
- } else {
686
- record.kids_profile = false;
687
- if (record.event === "ad impression") {
688
- record.ad_blocked = false;
689
574
  }
690
575
  }
691
576
 
692
577
  return record;
693
578
  }
694
579
 
695
- // ─────────────────────────────────────────────────────────────
696
- // Hook #2, #4, #8: EVERYTHING PASS - Complex behavioral patterns
697
- // ─────────────────────────────────────────────────────────────
698
580
  if (type === "everything") {
581
+ const datasetStart = dayjs.unix(meta.datasetStart);
699
582
  const userEvents = record;
700
583
  if (!userEvents || userEvents.length === 0) return record;
701
584
 
702
585
  const profile = meta.profile;
703
586
 
704
- // ─── Stamp superProps from profile (consistent per user) ───
587
+ // Stamp superProps from profile (consistent per user)
705
588
  const stampPlan = profile && profile.subscription_plan ? profile.subscription_plan : undefined;
706
589
  const stampDevice = profile && profile.device_type ? profile.device_type : undefined;
707
590
  if (stampPlan || stampDevice) {
@@ -711,126 +594,94 @@ const config = {
711
594
  });
712
595
  }
713
596
 
714
- // ─── Hook #3: WEEKEND vs WEEKDAY PATTERNS ───
715
- // Weekend: 1.5x watch duration. Weekday 6PM-11PM: prime_time tag.
716
- // Runs after sessionization so DOW/HOD tags match final timestamps.
597
+ // Hook #5: NEW RELEASE SPIKE days 50-65, 20% of selections/starts
598
+ // switch to the blockbuster id. Mutates existing content_id/content_type props.
599
+ // (Moved from event hook to everything hook per L1: temporal checks belong here.)
600
+ const BLOCKBUSTER_START = datasetStart.add(50, 'days');
601
+ const BLOCKBUSTER_END = datasetStart.add(65, 'days');
717
602
  for (const e of userEvents) {
718
- const eventDate = new Date(e.time);
719
- const dayOfWeek = eventDate.getUTCDay(); // 0 = Sunday, 6 = Saturday
720
- const hour = eventDate.getUTCHours();
721
- const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
722
-
723
- if (isWeekend) {
724
- e.weekend_viewing = true;
725
- e.prime_time = false;
726
- if (e.event === "playback completed" && e.watch_duration_min) {
727
- e.watch_duration_min = Math.round(e.watch_duration_min * 1.5);
603
+ const eventTime = dayjs(e.time);
604
+ if (eventTime.isAfter(BLOCKBUSTER_START) && eventTime.isBefore(BLOCKBUSTER_END)) {
605
+ if ((e.event === "content selected" || e.event === "playback started") && chance.bool({ likelihood: 20 })) {
606
+ e.content_type = "movie";
607
+ e.content_id = blockbusterId;
728
608
  }
729
- } else {
730
- e.weekend_viewing = false;
731
- if (hour >= 18 && hour <= 23) {
732
- e.prime_time = true;
733
- } else {
734
- e.prime_time = false;
609
+ if (e.event === "content rated" && chance.bool({ likelihood: 20 })) {
610
+ e.rating = chance.integer({ min: 4, max: 5 });
611
+ e.content_id = blockbusterId;
735
612
  }
736
613
  }
737
614
  }
738
615
 
739
- // ─── Bug 2 fix: Genre funnel conversion filtering ───
740
- // Drop ~25% of "playback completed" events for documentary genre
741
- // to simulate lower documentary funnel completion (was conversionRate * 0.7)
616
+ // Hook #3: WEEKEND VS WEEKDAY 1.5x watch_duration_min on weekends.
617
+ // No flag analyst breaks down by day of week.
618
+ for (const e of userEvents) {
619
+ const dow = new Date(e.time).getUTCDay();
620
+ if ((dow === 0 || dow === 6) && e.event === "playback completed" && typeof e.watch_duration_min === "number") {
621
+ e.watch_duration_min = Math.round(e.watch_duration_min * 1.5);
622
+ }
623
+ }
624
+
625
+ // Hook #1: GENRE FUNNEL CONVERSION — drop 25% of documentary playback
626
+ // completed events to depress documentary funnel conversion. Raw genre check.
742
627
  for (let i = userEvents.length - 1; i >= 0; i--) {
743
628
  const evt = userEvents[i];
744
- if (evt.event === "playback completed" && evt.genre_penalty === true) {
745
- if (chance.bool({ likelihood: 25 })) {
746
- userEvents.splice(i, 1);
747
- }
629
+ if (evt.event === "playback completed" && evt.genre === "documentary" && chance.bool({ likelihood: 25 })) {
630
+ userEvents.splice(i, 1);
748
631
  }
749
632
  }
750
633
 
751
- // ─── Bug 2 fix: Recommendation engine improvement filtering ───
752
- // Drop ~30% of "content rated" events that occur BEFORE day 60
753
- // to simulate lower pre-improvement rec engine conversion (was conversionRate * 1.5)
754
- const IMPROVEMENT_DATE = DATASET_START.add(60, 'days');
634
+ // Hook #7: RECOMMENDATION ENGINE IMPROVEMENT drop 30% of pre-day-60
635
+ // content rated events. Raw time check.
636
+ const IMPROVEMENT_DATE = datasetStart.add(60, 'days');
755
637
  for (let i = userEvents.length - 1; i >= 0; i--) {
756
638
  const evt = userEvents[i];
757
- if (evt.event === "content rated" && dayjs(evt.time).isBefore(IMPROVEMENT_DATE)) {
758
- if (chance.bool({ likelihood: 30 })) {
759
- userEvents.splice(i, 1);
760
- }
639
+ if (evt.event === "content rated" && dayjs(evt.time).isBefore(IMPROVEMENT_DATE) && chance.bool({ likelihood: 30 })) {
640
+ userEvents.splice(i, 1);
761
641
  }
762
642
  }
763
643
 
764
644
  const firstEventTime = dayjs(userEvents[0].time);
765
645
 
766
- // ─── First pass: identify user patterns ───
646
+ // Identify behavioral patterns (no flags written)
767
647
  let consecutiveCompletions = 0;
768
648
  let maxConsecutiveCompletions = 0;
769
- let isBingeWatcher = false;
770
649
  let adImpressionCount = 0;
771
650
  let isFreeTier = false;
772
651
  let hasSubtitlesEnabled = false;
652
+ let recClickCount = 0;
773
653
 
774
654
  userEvents.forEach((event, idx) => {
775
- // Track subscription tier from superProps
776
655
  if (idx === 0 && event.subscription_plan) {
777
656
  isFreeTier = event.subscription_plan === "free";
778
657
  }
779
-
780
- // Hook #2: Track consecutive playback completions
781
658
  if (event.event === "playback completed") {
782
659
  consecutiveCompletions++;
783
660
  if (consecutiveCompletions > maxConsecutiveCompletions) {
784
661
  maxConsecutiveCompletions = consecutiveCompletions;
785
662
  }
786
663
  } else if (event.event !== "playback started") {
787
- // Reset streak on non-playback events (started doesn't break streak)
788
664
  consecutiveCompletions = 0;
789
665
  }
790
-
791
- // Hook #4: Count ad impressions for free-tier users
792
- if (event.event === "ad impression") {
793
- adImpressionCount++;
794
- }
795
-
796
- // Hook #8: Check for subtitle enabled
797
- if (event.event === "subtitle toggled" && event.action === "enabled") {
798
- hasSubtitlesEnabled = true;
799
- }
666
+ if (event.event === "ad impression") adImpressionCount++;
667
+ if (event.event === "subtitle toggled" && event.action === "enabled") hasSubtitlesEnabled = true;
668
+ if (event.event === "recommendation clicked") recClickCount++;
800
669
  });
801
670
 
802
- isBingeWatcher = maxConsecutiveCompletions >= 3;
803
-
804
- // ─── Second pass: set schema defaults then modify ───
671
+ const isBingeWatcher = maxConsecutiveCompletions >= 3;
805
672
 
806
- // Set defaults for conditional properties on all events
807
- userEvents.forEach((event) => {
808
- if (event.event === "playback completed") {
809
- if (event.binge_session === undefined) event.binge_session = false;
810
- if (event.subtitle_user === undefined) event.subtitle_user = false;
811
- }
812
- if (event.event === "playback started") {
813
- if (event.binge_session === undefined) event.binge_session = false;
814
- }
815
- if (event.ad_fatigue === undefined) event.ad_fatigue = false;
816
- });
817
-
818
- // Hook #2: BINGE-WATCHING PATTERN
819
- // Binge-watchers get extra episodes, high completion, fewer pauses
673
+ // Hook #2: BINGE-WATCHING drop pauses, inject extra start+complete pairs.
674
+ // Cloned events use unique offset timestamps. No flag.
820
675
  if (isBingeWatcher) {
821
676
  for (let i = userEvents.length - 1; i >= 0; i--) {
822
677
  const event = userEvents[i];
823
678
  const eventTime = dayjs(event.time);
824
679
 
825
- // Remove some pause events (binge-watchers don't stop)
826
- if (event.event === "playback paused") {
827
- if (chance.bool({ likelihood: 60 })) {
828
- userEvents.splice(i, 1);
829
- continue;
830
- }
680
+ if (event.event === "playback paused" && chance.bool({ likelihood: 60 })) {
681
+ userEvents.splice(i, 1);
682
+ continue;
831
683
  }
832
684
 
833
- // Add extra viewing events after completions
834
685
  if (event.event === "playback completed" && chance.bool({ likelihood: 40 })) {
835
686
  const nextContentId = chance.pickone(contentIds);
836
687
  const startTemplate = userEvents.find(e => e.event === "playback started");
@@ -842,7 +693,6 @@ const config = {
842
693
  content_id: nextContentId,
843
694
  content_type: "series",
844
695
  playback_quality: event.playback_quality || "1080p",
845
- binge_session: true,
846
696
  };
847
697
  const extraComplete = {
848
698
  ...event,
@@ -852,51 +702,42 @@ const config = {
852
702
  content_type: "series",
853
703
  watch_duration_min: chance.integer({ min: 20, max: 55 }),
854
704
  completion_percent: chance.integer({ min: 90, max: 100 }),
855
- binge_session: true,
856
705
  };
857
706
  userEvents.splice(i + 1, 0, extraStart, extraComplete);
858
707
  }
859
708
  }
860
709
  }
861
710
 
862
- // Hook #4: AD FATIGUE CHURN
863
- // Free-tier users with 10+ ads lose 50% of events after day 45
711
+ // Hook #4: AD FATIGUE CHURN — free-tier users w/ 10+ ads lose 50% of
712
+ // events after day 45. No flag discover via cohort retention.
864
713
  if (isFreeTier && adImpressionCount >= 10) {
865
714
  const churnCutoff = firstEventTime.add(45, 'days');
866
715
  for (let i = userEvents.length - 1; i >= 0; i--) {
867
716
  const evt = userEvents[i];
868
- if (dayjs(evt.time).isAfter(churnCutoff)) {
869
- if (chance.bool({ likelihood: 50 })) {
870
- userEvents.splice(i, 1);
871
- } else {
872
- evt.ad_fatigue = true;
873
- }
717
+ if (dayjs(evt.time).isAfter(churnCutoff) && chance.bool({ likelihood: 50 })) {
718
+ userEvents.splice(i, 1);
874
719
  }
875
720
  }
876
721
  }
877
722
 
878
- // Hook #8: SUBTITLE USERS WATCH MORE
879
- // Users who enabled subtitles have 25% higher completion and 15% longer watch time
723
+ // Hook #8: SUBTITLE USERS WATCH MORE — 1.25x completion_percent (cap 100),
724
+ // 1.15x watch_duration_min, plus 20% extra cloned playback completions.
725
+ // No flag — discover via cohort builder on subtitle-toggled-enabled.
880
726
  if (hasSubtitlesEnabled) {
881
727
  for (let i = 0; i < userEvents.length; i++) {
882
728
  const event = userEvents[i];
883
-
884
729
  if (event.event === "playback completed") {
885
- // Boost completion percent (cap at 100)
886
730
  if (event.completion_percent) {
887
731
  event.completion_percent = Math.min(100, Math.round(event.completion_percent * 1.25));
888
732
  }
889
- // Boost watch duration
890
733
  if (event.watch_duration_min) {
891
734
  event.watch_duration_min = Math.round(event.watch_duration_min * 1.15);
892
735
  }
893
- event.subtitle_user = true;
894
736
  }
895
737
  }
896
738
 
897
- // Splice extra playback completed events (subtitle users watch more overall)
898
739
  const completionEvents = userEvents.filter(e => e.event === "playback completed");
899
- const extraCount = Math.floor(completionEvents.length * 0.2); // 20% more completions
740
+ const extraCount = Math.floor(completionEvents.length * 0.2);
900
741
  for (let j = 0; j < extraCount; j++) {
901
742
  const templateEvent = chance.pickone(completionEvents);
902
743
  const templateTime = dayjs(templateEvent.time);
@@ -908,12 +749,28 @@ const config = {
908
749
  content_type: chance.pickone(["movie", "series", "documentary"]),
909
750
  watch_duration_min: chance.integer({ min: 25, max: 120 }),
910
751
  completion_percent: chance.integer({ min: 80, max: 100 }),
911
- subtitle_user: true,
912
752
  };
913
753
  userEvents.push(extraCompletion);
914
754
  }
915
755
  }
916
756
 
757
+ // Hook #9: RECOMMENDATION-CLICKED MAGIC NUMBER (no flags)
758
+ // Sweet 4-6 rec clicks → +25% watch_duration_min on playback completed.
759
+ // Over 7+ → drop 30% of playback completed events (rec fatigue).
760
+ if (recClickCount >= 4 && recClickCount <= 6) {
761
+ userEvents.forEach(e => {
762
+ if (e.event === 'playback completed' && typeof e.watch_duration_min === 'number') {
763
+ e.watch_duration_min = Math.round(e.watch_duration_min * 1.25);
764
+ }
765
+ });
766
+ } else if (recClickCount >= 7) {
767
+ for (let i = userEvents.length - 1; i >= 0; i--) {
768
+ if (userEvents[i].event === 'playback completed' && chance.bool({ likelihood: 30 })) {
769
+ userEvents.splice(i, 1);
770
+ }
771
+ }
772
+ }
773
+
917
774
  return record;
918
775
  }
919
776