@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-education";
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
 
@@ -28,7 +28,7 @@ const chance = u.initChance(SEED);
28
28
  * Supports self-paced and cohort-based learning with courses, quizzes,
29
29
  * assignments, and social study features.
30
30
  *
31
- * Scale: 5,000 users / 600K events / 100 days / 17 event types
31
+ * Scale: 10,000 users / ~1.4M events / 120 days / 17 event types
32
32
  *
33
33
  * CORE LOOP:
34
34
  * Register → browse/enroll in courses → watch lectures → practice problems →
@@ -52,132 +52,256 @@ const chance = u.initChance(SEED);
52
52
 
53
53
  /**
54
54
  * ═══════════════════════════════════════════════════════════════════════════════
55
- * ANALYTICS HOOKS (8 architected patterns)
55
+ * ANALYTICS HOOKS (9 hooks)
56
56
  * ═══════════════════════════════════════════════════════════════════════════════
57
57
  *
58
- * 1. STUDENT VS INSTRUCTOR PROFILES
59
- * Instructor profiles get teaching attributes (courses_created,
60
- * teaching_experience_years, instructor_rating). Students get learning
61
- * attributes (learning_goal, study_hours_per_week).
62
- *
63
- * Mixpanel reports:
64
- * • Insights → Any event → Unique users → Breakdown: "account_type"
65
- * Expected: ~89% students, ~11% instructors
66
- * • Insights → "instructor feedback given" → Total per user → Breakdown: "account_type"
67
- * Expected: Instructors dominate feedback; students show learning_goal instead
68
- *
69
- * 2. DEADLINE CRAMMING
70
- * Assignments submitted on Sun/Mon are rushed: 60% late (vs ~20% baseline),
71
- * quiz scores drop by 25 points. Events carry is_deadline_rush: true.
72
- *
73
- * Mixpanel reports:
74
- * • Insights → "assignment submitted" → Total → Breakdown: "is_deadline_rush"
75
- * Expected: is_deadline_rush=true shows ~60% late rate vs ~20% baseline
76
- * • Insights → "quiz completed" → Avg "score_percent" → Breakdown: Day of Week
77
- * Expected: Sun/Mon scores ~25 points lower (~40 vs ~65)
78
- *
79
- * 3. NOTES-TAKERS SUCCEED
80
- * Students with 5+ notes_taken=true lectures get +20 quiz score boost
81
- * (capped at 100) and 40% chance of bonus certificate. Marked diligent_student: true.
82
- *
83
- * Mixpanel reports:
84
- * • Insights → "quiz completed" → Avg "score_percent" → Breakdown: "diligent_student"
85
- * Expected: diligent_student=true ≈ 85 avg vs ~65 baseline (+20 pts)
86
- * • Insights → "certificate earned" → Total per user → Breakdown: "diligent_student"
87
- * Expected: diligent_student=true earn ~40% more certificates
88
- *
89
- * 4. STUDY GROUP RETENTION
90
- * Early study group joiners (within 10 days) retain and get bonus discussions.
91
- * Non-joiners with low quiz scores (<60) churn hard at day 14 (all later events removed).
92
- *
93
- * Mixpanel reports:
94
- * • Retention → A: "account registered" → B: Any event → Segment by early study group join
95
- * Expected: Early joiners ~90% D14 retention; non-joiners with low scores ~30%
96
- * • Insights → "discussion posted" → Total per user → Breakdown: "study_group_member"
97
- * Expected: study_group_member=true users post more
98
- *
99
- * 5. HINT DEPENDENCY
100
- * Hint users get 60% chance of easy problems; non-hint users get 40% chance of
101
- * hard problems with independent_solver: true.
102
- *
103
- * Mixpanel reports:
104
- * • Insights → "practice problem solved" → Total → Breakdown: "difficulty" → Filter: hint_used=true
105
- * Expected: ~60% easy (vs ~33% baseline)
106
- * • Insights → "practice problem solved" → Total → Breakdown: "difficulty" → Filter: hint_used=false
107
- * Expected: ~40% hard (vs ~33% baseline)
108
- *
109
- * 6. SEMESTER-END SPIKE
110
- * Days 75-85: quiz_started, quiz_completed, assignment_submitted events duplicated
111
- * at 80% rate. Events carry semester_end_rush: true.
112
- *
113
- * Mixpanel reports:
114
- * • Insights (line) → "quiz started" + "quiz completed" + "assignment submitted" → Daily
115
- * Expected: ~2x volume spike during days 75-85
116
- * • Insights → "quiz completed" → Total → Breakdown: "semester_end_rush"
117
- * Expected: semester_end_rush=true clusters in days 75-85
118
- *
119
- * 7. FREE VS PAID COURSES
120
- * Free users get 0.5x funnel conversion rate; paid subscribers get 1.5x.
121
- * Free users also lose 55% of certificates. Creates ~2.2x completion gap.
122
- *
123
- * Mixpanel reports:
124
- * • Funnels → "course enrolled" → "lecture completed" → "quiz completed" → "certificate earned"
125
- * Breakdown: "subscription_status"
126
- * Expected: free ≈ 15% completion, paid ≈ 33% (~2.2x difference)
127
- * • Insights → "certificate earned" → Total per user → Breakdown: "subscription_status"
128
- * Expected: Paid subscribers earn significantly more certificates
129
- *
130
- * 8. PLAYBACK SPEED CORRELATION
131
- * Speed learners (>=2.0x, 3+ lectures): compressed watch_time (0.6x),
132
- * paradoxically higher quiz scores (+8 pts). Thorough learners (<=1.0x):
133
- * extended watch_time (1.4x).
134
- *
135
- * Mixpanel reports:
136
- * • Insights → "lecture completed" → Avg "watch_time_mins" → Breakdown: "speed_learner"
137
- * Expected: speed_learner=true ≈ 0.6x watch time
138
- * • Insights → "quiz completed" → Avg "score_percent" → Breakdown: "speed_learner_effect"
139
- * Expected: speed_learner_effect=true shows +8 points (faster = better)
58
+ * NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable via
59
+ * behavioral cohorts, raw-prop breakdowns, or funnel time-to-convert.
140
60
  *
141
- * ═══════════════════════════════════════════════════════════════════════════════
142
- * ADVANCED ANALYSIS IDEAS
143
- * ═══════════════════════════════════════════════════════════════════════════════
61
+ * 1. STUDENT VS INSTRUCTOR PROFILES (user)
62
+ *
63
+ * PATTERN: Instructor profiles get teaching attributes
64
+ * (courses_created, teaching_experience_years, instructor_rating).
65
+ * Students get learning attributes (learning_goal,
66
+ * study_hours_per_week). Two-sided marketplace at ~89% students,
67
+ * ~11% instructors.
68
+ *
69
+ * HOW TO FIND IT IN MIXPANEL:
70
+ *
71
+ * Report 1: Account Mix
72
+ * - Report type: Insights
73
+ * - Event: any event
74
+ * - Measure: Unique users
75
+ * - Breakdown: "account_type"
76
+ * - Expected: ~89% students, ~11% instructors
77
+ *
78
+ * Report 2: Instructor-Driven Feedback
79
+ * - Report type: Insights
80
+ * - Event: "instructor feedback given"
81
+ * - Measure: Total per user (average)
82
+ * - Breakdown: "account_type"
83
+ * - Expected: instructors dominate feedback volume; students rarely emit
84
+ *
85
+ * REAL-WORLD ANALOGUE: Two-sided learning marketplaces have
86
+ * fundamentally different role personas — teachers create supply,
87
+ * learners consume it.
88
+ *
89
+ * ---------------------------------------------------------------
90
+ * 2. DEADLINE CRAMMING (everything)
91
+ *
92
+ * PATTERN: Assignments submitted on Sun/Mon are rushed — 60% are late
93
+ * (raw is_late prop set true at 60% likelihood) vs ~20% baseline. Quiz
94
+ * scores on Sun/Mon drop by 25 points. No flag — discover via Day of Week
95
+ * breakdown.
96
+ *
97
+ * HOW TO FIND IT IN MIXPANEL:
98
+ *
99
+ * Report 1: Late Submission Rate by Day of Week
100
+ * - Report type: Insights
101
+ * - Event: "assignment submitted"
102
+ * - Measure: count where is_late=true / total
103
+ * - Breakdown: Day of Week
104
+ * - Expected: Sun/Mon ~ 60% late vs other days ~ 20%
105
+ *
106
+ * Report 2: Quiz Score by Day of Week
107
+ * - Report type: Insights
108
+ * - Event: "quiz completed"
109
+ * - Measure: Average of "score_percent"
110
+ * - Breakdown: Day of Week
111
+ * - Expected: Sun/Mon ~ 25, other days ~ 49 (-25 pts)
112
+ *
113
+ * REAL-WORLD ANALOGUE: Procrastination clusters submissions at the
114
+ * deadline weekend and hammers performance.
115
+ *
116
+ * ---------------------------------------------------------------
117
+ * 3. NOTES MAGIC NUMBER (everything, in-funnel)
118
+ *
119
+ * PATTERN: Sweet 5-8 lectures with notes_taken=true → +30% quiz
120
+ * score_percent (cap 100) and 40% chance of bonus cloned certificate.
121
+ * Over 9+ → 35% of certificate-earned events drop (over-noted but
122
+ * stuck in study mode). No flag.
123
+ *
124
+ * HOW TO FIND IT IN MIXPANEL:
125
+ *
126
+ * Report 1: Quiz Score by Notes-Taken Bucket
127
+ * - Cohort A: users with 5-8 "lecture completed" where notes_taken=true
128
+ * - Cohort B: users with 0-4
129
+ * - Event: "quiz completed"
130
+ * - Measure: Average of "score_percent"
131
+ * - Expected: A ~ 1.3x B
132
+ *
133
+ * Report 2: Certificates per User on Heavy Note-Takers
134
+ * - Cohort C: users with >= 9 notes-taken lectures
135
+ * - Cohort A: users with 5-8
136
+ * - Event: "certificate earned"
137
+ * - Measure: Total per user
138
+ * - Expected: C ~ 35% fewer certificates per user vs A
139
+ *
140
+ * REAL-WORLD ANALOGUE: Active note-taking lifts quiz performance, but
141
+ * obsessive note-taking signals "stuck in study mode" without finishing.
142
+ *
143
+ * ---------------------------------------------------------------
144
+ * 4. STUDY GROUP RETENTION (everything)
145
+ *
146
+ * PATTERN: Users who join a study group within 10 days get bonus
147
+ * discussion events. Non-joiners with low quiz scores (<60) churn
148
+ * hard at day 14 — all later events are removed.
149
+ *
150
+ * HOW TO FIND IT IN MIXPANEL:
151
+ *
152
+ * Report 1: D14 Retention by Early Group Join Cohort
153
+ * - Cohort A: users who fired "study group joined" within first 10 days
154
+ * - Cohort B: users with no early study group joined
155
+ * - Compare D14 retention (any event past d14) per cohort
156
+ * - Expected: A ~ 90%+ vs B (with low quiz scores) ~ 30%
157
+ *
158
+ * Report 2: Discussion Volume by Group Cohort
159
+ * - Cohort A vs B (as above)
160
+ * - Event: "discussion posted"
161
+ * - Measure: Total per user
162
+ * - Expected: A posts substantially more
163
+ *
164
+ * REAL-WORLD ANALOGUE: Social learning ties create accountability
165
+ * and dramatically reduce drop-off in cohort-based courses.
166
+ *
167
+ * ---------------------------------------------------------------
168
+ * 5. HINT DEPENDENCY (event)
169
+ *
170
+ * PATTERN: On "practice problem solved", hint_used=true gets difficulty
171
+ * forced to "easy" 60% of the time. hint_used=false gets difficulty forced
172
+ * to "hard" 40% of the time. No flag — discover via difficulty breakdown
173
+ * filtered by hint_used.
174
+ *
175
+ * HOW TO FIND IT IN MIXPANEL:
176
+ *
177
+ * Report 1: Easy Problem Mix for Hint Users
178
+ * - Report type: Insights
179
+ * - Event: "practice problem solved"
180
+ * - Measure: Total
181
+ * - Filter: "hint_used" = true
182
+ * - Breakdown: "difficulty"
183
+ * - Expected: ~60% easy (vs ~33% baseline)
184
+ *
185
+ * Report 2: Hard Problem Mix for Independent Solvers
186
+ * - Report type: Insights
187
+ * - Event: "practice problem solved"
188
+ * - Measure: Total
189
+ * - Filter: "hint_used" = false
190
+ * - Breakdown: "difficulty"
191
+ * - Expected: ~40% hard (vs ~33% baseline)
192
+ *
193
+ * REAL-WORLD ANALOGUE: Learners who lean on hints get nudged toward
194
+ * easier work, while those who push through unaided self-select
195
+ * into harder material.
196
+ *
197
+ * ---------------------------------------------------------------
198
+ * 6. SEMESTER-END SPIKE (everything)
199
+ *
200
+ * PATTERN: Days 75-85 simulate semester crunch. quiz_started, quiz_completed,
201
+ * and assignment_submitted events are duplicated at an 80% rate. No flag —
202
+ * discover via line chart of those event volumes by day.
203
+ *
204
+ * HOW TO FIND IT IN MIXPANEL:
205
+ *
206
+ * Report 1: Assessment Volume Over Time
207
+ * - Report type: Insights
208
+ * - Events: "quiz started" + "quiz completed" + "assignment submitted"
209
+ * - Measure: Total
210
+ * - Line chart by day
211
+ * - Expected: ~2x volume spike on days 75-85
212
+ *
213
+ * REAL-WORLD ANALOGUE: Semester-end deadlines reliably produce a
214
+ * massive last-minute surge in student activity.
215
+ *
216
+ * ---------------------------------------------------------------
217
+ * 7. FREE VS PAID COURSES (funnel-pre + everything)
218
+ *
219
+ * PATTERN: Free users get 0.5x funnel conversion rate; paid
220
+ * subscribers get 1.5x. Free users also lose 55% of their
221
+ * certificates, producing a ~2.2x completion gap.
222
+ *
223
+ * HOW TO FIND IT IN MIXPANEL:
224
+ *
225
+ * Report 1: Course Completion Funnel by Subscription
226
+ * - Report type: Funnels
227
+ * - Steps: "course enrolled" -> "lecture completed" -> "quiz completed" -> "certificate earned"
228
+ * - Breakdown: "subscription_status"
229
+ * - Expected: free ~ 15% completion, paid ~ 33% (~2.2x gap)
230
+ *
231
+ * Report 2: Certificates Earned per User
232
+ * - Report type: Insights
233
+ * - Event: "certificate earned"
234
+ * - Measure: Total per user (average)
235
+ * - Breakdown: "subscription_status"
236
+ * - Expected: paid subscribers earn substantially more certificates
237
+ *
238
+ * REAL-WORLD ANALOGUE: Paid commitment correlates strongly with
239
+ * follow-through; free learners drop off long before completion.
240
+ *
241
+ * ---------------------------------------------------------------
242
+ * 8. PLAYBACK SPEED CORRELATION (everything)
243
+ *
244
+ * PATTERN: Speed learners (>=2.0x speed on 3+ lectures) get 0.6x
245
+ * watch_time and a paradoxical +8 quiz score boost. Thorough
246
+ * learners (<=1.0x) get 1.4x watch_time.
247
+ *
248
+ * HOW TO FIND IT IN MIXPANEL:
249
+ *
250
+ * Report 1: Watch Time by Playback Speed
251
+ * - Report type: Insights
252
+ * - Event: "lecture completed"
253
+ * - Measure: Average of "watch_time_mins"
254
+ * - Breakdown: "playback_speed"
255
+ * - Expected: speed >= 2.0 ~ 0.6x baseline; speed <= 1.0 ~ 1.4x baseline
256
+ *
257
+ * Report 2: Quiz Score by Speed Learner Cohort
258
+ * - Cohort A: users with 3+ "lecture completed" events at playback_speed >= 2.0
259
+ * - Cohort B: rest
260
+ * - Event: "quiz completed"
261
+ * - Measure: Average of "score_percent"
262
+ * - Expected: A ~ +8 pts vs B
263
+ *
264
+ * REAL-WORLD ANALOGUE: Power users who watch lectures at 2x speed
265
+ * tend to be domain-confident and outperform on assessments
266
+ * despite spending less time.
267
+ *
268
+ * ---------------------------------------------------------------
269
+ * 9. COURSE COMPLETION TIME-TO-CONVERT (funnel-post)
270
+ *
271
+ * PATTERN: Annual subscribers complete the course-completion funnel
272
+ * 1.4x faster (factor 0.71); Free users 1.4x slower (factor 1.4).
144
273
  *
145
- * CROSS-HOOK PATTERNS:
146
- * - The Ideal Student: notes (H3) + study groups (H4) + no hints (H5) + paid (H7) + speed (H8)
147
- * - Cramming Cascade: deadline crammers (H2) compounded with semester-end spike (H6)?
148
- * - Social Safety Net: does early study group joining (H4) prevent churn for low scorers?
149
- * - Hint-to-Mastery: do hint-dependent (H5) students who join groups (H4) wean off hints?
150
- * - Payment + Notes: are paid subscribers (H7) more likely to take notes (H3)?
274
+ * HOW TO FIND IT IN MIXPANEL:
151
275
  *
152
- * COHORT ANALYSIS:
153
- * - By education level: PhD vs self-taught hook patterns
154
- * - By learning style: visual vs hands-on note-taking rates
155
- * - By platform: mobile vs desktop playback speed preferences
156
- * - By course category: CS vs Arts hint usage
276
+ * Report 1: Course Completion Median Time-to-Convert by Subscription
277
+ * - Funnels > "course enrolled" -> "lecture completed" -> "quiz completed" -> "certificate earned"
278
+ * - Measure: Median time to convert
279
+ * - Breakdown: subscription_status
280
+ * - Expected: annual ~ 0.71x baseline; free ~ 1.4x baseline
157
281
  *
158
- * FUNNEL ANALYSIS:
159
- * - Onboarding by account_type
160
- * - Course completion by subscription, notes, study groups
161
- * - Practice mastery by hint usage, speed, learning style
282
+ * NOTE (funnel-post measurement): visible only via Mixpanel funnel
283
+ * median TTC. Cross-event MIN→MIN SQL queries on raw events do NOT
284
+ * show this — funnel-post adjusts gaps within funnel instances, not
285
+ * across the user's full event history.
286
+ *
287
+ * REAL-WORLD ANALOGUE: Paid commitment accelerates throughput.
162
288
  *
163
289
  * ═══════════════════════════════════════════════════════════════════════════════
164
290
  * EXPECTED METRICS SUMMARY
165
291
  * ═══════════════════════════════════════════════════════════════════════════════
166
292
  *
167
293
  * Hook | Metric | Baseline | Hook Effect | Ratio
168
- * ────────────────────────|───────────────────────|──────────|──────────────|──────
169
- * Student vs Instructor | Profile attributes | generic | role-specific| N/A
170
- * Deadline Cramming | Late submission rate | ~20% | ~60% | 3x
171
- * Deadline Cramming | Quiz score (Sun/Mon) | ~65 | ~40 | -25pt
172
- * Notes-Takers Succeed | Quiz score | ~65 | ~85 | +20pt
173
- * Notes-Takers Succeed | Certificate rate | baseline | +40% | 1.4x
174
- * Study Group Retention | D14 retention | ~40% | ~90% | 2.3x
175
- * Study Group Retention | Post-D14 events | 100% | 30% (churn) | 0.3x
176
- * Hint Dependency | Easy problem rate | ~33% | ~60% | 1.8x
177
- * Hint Dependency | Hard problem rate | ~33% | ~40% (no hint)| 1.2x
178
- * Semester-End Spike | Assessment volume | baseline | ~2x | 2x
294
+ * ------------------------|-----------------------|----------|--------------|------
295
+ * Student vs Instructor | Profile attributes | generic | role-specific| n/a
296
+ * Deadline Cramming | Sun/Mon quiz score | 1x | -25 pt | -38%
297
+ * Notes Magic Number | sweet quiz score | 1x | 1.3x | 1.3x
298
+ * Notes Magic Number | over certificates/user| 1x | 0.65x | -35%
299
+ * Study Group Retention | D14 retention | ~ 40% | ~ 90% | 2.3x
300
+ * Hint Dependency | easy problem rate (hint) | 33% | ~ 60% | 1.8x
301
+ * Semester-End Spike | Assessment volume | 1x | ~ 2x | 2x
179
302
  * Free vs Paid | Course completion | 15% | 33% | 2.2x
180
- * Playback Speed | Quiz score (speed) | ~65 | ~73 | +8pt
303
+ * Playback Speed | Quiz score (speed) | ~ 65 | ~ 73 | +8 pt
304
+ * Course Completion T2C | median min by tier | 1x | 0.71x/1.4x | ~ 2x range
181
305
  */
182
306
 
183
307
  // Generate consistent IDs for lookup tables and event properties
@@ -190,12 +314,16 @@ const problemIds = v.range(1, 601).map(n => `problem_${v.uid(6)}`);
190
314
 
191
315
  /** @type {Config} */
192
316
  const config = {
317
+ version: 2,
193
318
  token,
194
319
  seed: SEED,
195
- numDays: num_days,
320
+ datasetStart: "2026-01-01T00:00:00Z",
321
+ datasetEnd: "2026-05-01T23:59:59Z",
322
+ // numDays: num_days,
196
323
  avgEventsPerUserPerDay: avg_events_per_user_per_day,
197
324
  numUsers: num_users,
198
- hasAnonIds: false,
325
+ hasAnonIds: true,
326
+ avgDevicePerUser: 2,
199
327
  hasSessionIds: true,
200
328
  format: "json",
201
329
  gzip: true,
@@ -268,6 +396,7 @@ const config = {
268
396
  event: "account registered",
269
397
  weight: 1,
270
398
  isFirstEvent: true,
399
+ isAuthEvent: true,
271
400
  properties: {
272
401
  "account_type": ["instructor", "instructor", "instructor", "instructor", "instructor", "instructor", "student"],
273
402
  "signup_source": ["organic", "referral", "school_partnership", "social_ad"],
@@ -302,8 +431,6 @@ const config = {
302
431
  "watch_time_mins": u.weighNumRange(3, 60, 0.8, 20),
303
432
  "playback_speed": [0.75, 1.0, 1.0, 1.0, 1.25, 1.5, 2.0],
304
433
  "notes_taken": [false, false, true],
305
- "speed_learner": [false],
306
- "thorough_learner": [false],
307
434
  }
308
435
  },
309
436
  {
@@ -314,7 +441,6 @@ const config = {
314
441
  "quiz_id": quizIds,
315
442
  "quiz_type": ["practice", "graded", "final_exam"],
316
443
  "question_count": u.weighNumRange(5, 50, 0.7, 15),
317
- "semester_end_rush": [false],
318
444
  }
319
445
  },
320
446
  {
@@ -326,9 +452,6 @@ const config = {
326
452
  "score_percent": u.weighNumRange(0, 100, 1.2, 50),
327
453
  "time_spent_mins": u.weighNumRange(3, 120, 0.6, 25),
328
454
  "attempts": u.weighNumRange(1, 5, 0.5, 3),
329
- "diligent_student": [false],
330
- "speed_learner_effect": [false],
331
- "semester_end_rush": [false],
332
455
  }
333
456
  },
334
457
  {
@@ -340,8 +463,6 @@ const config = {
340
463
  "submission_type": ["text", "code", "file", "project"],
341
464
  "word_count": u.weighNumRange(100, 5000, 0.6, 500),
342
465
  "is_late": [false, false, false, false, true],
343
- "is_deadline_rush": [false],
344
- "semester_end_rush": [false],
345
466
  }
346
467
  },
347
468
  {
@@ -362,7 +483,6 @@ const config = {
362
483
  "course_id": courseIds,
363
484
  "post_type": ["question", "answer", "comment"],
364
485
  "word_count": u.weighNumRange(10, 500, 0.6, 80),
365
- "study_group_member": [false],
366
486
  }
367
487
  },
368
488
  {
@@ -372,7 +492,6 @@ const config = {
372
492
  "course_id": courseIds,
373
493
  "completion_time_days": u.weighNumRange(7, 180, 0.5, 45),
374
494
  "final_grade": u.weighNumRange(60, 100, 1.2, 30),
375
- "diligent_student": [false],
376
495
  }
377
496
  },
378
497
  {
@@ -436,7 +555,6 @@ const config = {
436
555
  "difficulty": ["easy", "medium", "hard"],
437
556
  "time_to_solve_sec": u.weighNumRange(10, 3600, 0.5, 300),
438
557
  "hint_used": [false, false, true],
439
- "independent_solver": [false],
440
558
  }
441
559
  },
442
560
  ],
@@ -511,12 +629,7 @@ const config = {
511
629
  * 8. PLAYBACK SPEED CORRELATION: Speed learners paradoxically score higher; thorough learners get extended time
512
630
  */
513
631
  hook: function (record, type, meta) {
514
- const NOW = dayjs.utc();
515
- const DATASET_START = NOW.subtract(num_days, 'days');
516
-
517
- // ═══════════════════════════════════════════════════════════════════
518
- // Hook #1: STUDENT VS INSTRUCTOR PROFILES
519
- // ═══════════════════════════════════════════════════════════════════
632
+ // HOOK 1: STUDENT VS INSTRUCTOR PROFILES (user) — role-based attributes.
520
633
  if (type === "user") {
521
634
  if (record.account_type === "instructor") {
522
635
  record.courses_created = chance.integer({ min: 1, max: 15 });
@@ -528,216 +641,147 @@ const config = {
528
641
  }
529
642
  }
530
643
 
531
- // ═══════════════════════════════════════════════════════════════════
532
- // Hook #2: DEADLINE CRAMMING
533
- // ═══════════════════════════════════════════════════════════════════
644
+ // HOOK 5 (event): HINT DEPENDENCY — hint users get 60% easy problems;
645
+ // non-hint users get 40% hard problems. Mutates difficulty (raw).
646
+ // HOOK 8 (event): PLAYBACK SPEED — speed learners (>= 2.0x) get
647
+ // watch_time_mins compressed 0.6x; thorough learners (<= 1.0x) get 1.4x.
534
648
  if (type === "event") {
535
- if (record.event === "assignment submitted" && record.time) {
536
- const eventDay = dayjs(record.time).day(); // 0 = Sunday, 1 = Monday
537
- if (eventDay === 0 || eventDay === 1) {
538
- record.is_deadline_rush = true;
539
- // 60% chance of being late (only 40% on time)
540
- record.is_late = !chance.bool({ likelihood: 40 });
541
- } else {
542
- record.is_deadline_rush = false;
543
- record.is_late = !chance.bool({ likelihood: 80 });
649
+ if (record.event === "practice problem solved") {
650
+ if (record.hint_used === true && chance.bool({ likelihood: 60 })) {
651
+ record.difficulty = "easy";
652
+ } else if (record.hint_used === false && chance.bool({ likelihood: 40 })) {
653
+ record.difficulty = "hard";
544
654
  }
545
655
  }
546
-
547
- // Quiz score penalty moved to everything hook (after churn removal)
548
- // to avoid selection bias the penalty was causing more Sun/Mon
549
- // quiz-takers to trigger hasLowQuizScore churn, inflating their avg
550
- }
551
-
552
- // ═══════════════════════════════════════════════════════════════════
553
- // Hook #5: HINT DEPENDENCY
554
- // ═══════════════════════════════════════════════════════════════════
555
- if (type === "event") {
556
- if (record.event === "practice problem solved") {
557
- if (record.hint_used === true) {
558
- // Hint users gravitate toward easy problems
559
- if (chance.bool({ likelihood: 60 })) {
560
- record.difficulty = "easy";
561
- }
562
- record.independent_solver = false;
563
- } else if (record.hint_used === false) {
564
- // Independent solvers tackle harder problems
565
- if (chance.bool({ likelihood: 40 })) {
566
- record.difficulty = "hard";
567
- record.independent_solver = true;
568
- } else {
569
- record.independent_solver = false;
570
- }
571
- } else {
572
- record.independent_solver = false;
656
+ if (record.event === "lecture completed") {
657
+ const speed = record.playback_speed;
658
+ if (speed >= 2.0 && record.watch_time_mins !== undefined) {
659
+ record.watch_time_mins = Math.max(3, Math.floor(record.watch_time_mins * 0.6));
660
+ } else if (speed !== undefined && speed <= 1.0 && record.watch_time_mins !== undefined) {
661
+ record.watch_time_mins = Math.min(90, Math.floor(record.watch_time_mins * 1.4));
573
662
  }
574
663
  }
575
664
  }
576
665
 
577
- // ═══════════════════════════════════════════════════════════════════
578
- // Hook #6: SEMESTER-END SPIKE (tag in event hook, duplicate in everything hook)
579
- // ═══════════════════════════════════════════════════════════════════
580
- if (type === "event") {
581
- if (record.time) {
582
- const eventTime = dayjs.utc(record.time);
583
- const dayInDataset = eventTime.diff(DATASET_START, 'days', true);
584
-
585
- const spikableEvents = ["quiz started", "quiz completed", "assignment submitted"];
586
- if (spikableEvents.includes(record.event)) {
587
- if (dayInDataset >= 75 && dayInDataset <= 85) {
588
- record.semester_end_rush = true;
589
- } else {
590
- record.semester_end_rush = false;
591
- }
592
- }
666
+ // HOOK 7 (funnel-pre): FREE VS PAID — free users get 0.5x conversion rate;
667
+ // paid subscribers get 1.5x. Applies to course-completion funnel.
668
+ if (type === "funnel-pre") {
669
+ const subStatus = meta?.profile?.subscription_status;
670
+ if (subStatus === "free") {
671
+ record.conversionRate = Math.round(record.conversionRate * 0.5);
672
+ } else if (subStatus === "monthly" || subStatus === "annual") {
673
+ record.conversionRate = Math.min(100, Math.round(record.conversionRate * 1.5));
593
674
  }
594
675
  }
595
676
 
596
- // ═══════════════════════════════════════════════════════════════════
597
- // Hook #8: PLAYBACK SPEED CORRELATION
598
- // ═══════════════════════════════════════════════════════════════════
599
- if (type === "event") {
600
- if (record.event === "lecture completed") {
601
- const speed = record.playback_speed;
602
-
603
- if (speed >= 2.0) {
604
- record.speed_learner = true;
605
- record.thorough_learner = false;
606
- // Compress watch time for speed learners
607
- if (record.watch_time_mins !== undefined) {
608
- record.watch_time_mins = Math.max(3, Math.floor(record.watch_time_mins * 0.6));
609
- }
610
- } else if (speed !== undefined && speed <= 1.0) {
611
- record.speed_learner = false;
612
- record.thorough_learner = true;
613
- // Extend watch time for thorough learners
614
- if (record.watch_time_mins !== undefined) {
615
- record.watch_time_mins = Math.min(90, Math.floor(record.watch_time_mins * 1.4));
677
+ // HOOK 9 (T2C): COURSE COMPLETION TIME-TO-CONVERT (funnel-post)
678
+ // Annual subscribers complete the Course Completion funnel 1.4x
679
+ // faster (factor 0.71); Free users 1.4x slower (factor 1.4).
680
+ if (type === "funnel-post") {
681
+ const segment = meta?.profile?.subscription_status;
682
+ if (Array.isArray(record) && record.length > 1) {
683
+ const factor = (
684
+ segment === "annual" ? 0.71 :
685
+ segment === "free" ? 1.4 :
686
+ 1.0
687
+ );
688
+ if (factor !== 1.0) {
689
+ for (let i = 1; i < record.length; i++) {
690
+ const prev = dayjs(record[i - 1].time);
691
+ const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
692
+ record[i].time = prev.add(newGap, "milliseconds").toISOString();
616
693
  }
617
- } else {
618
- record.speed_learner = false;
619
- record.thorough_learner = false;
620
694
  }
621
695
  }
622
696
  }
623
697
 
624
- // ═══════════════════════════════════════════════════════════════════
625
- // Hook #3: NOTES-TAKERS SUCCEED
626
- // Hook #4: STUDY GROUP RETENTION
627
- // Hook #7: FREE VS PAID (funnel-pre handled below)
628
- // ═══════════════════════════════════════════════════════════════════
629
698
  if (type === "everything") {
699
+ const datasetStart = dayjs.unix(meta.datasetStart);
630
700
  const userEvents = record;
631
701
  const profile = meta.profile;
632
702
  const firstEventTime = userEvents.length > 0 ? dayjs(userEvents[0].time) : null;
633
703
 
634
- // ── Stamp superProps from profile so they stay consistent per user ──
635
704
  if (profile) {
636
705
  userEvents.forEach((event) => {
637
706
  if (profile.Platform !== undefined) event.Platform = profile.Platform;
638
707
  });
639
708
  }
640
709
 
641
- // ---------------------------------------------------------------
642
- // First pass: identify user patterns
643
- // ---------------------------------------------------------------
644
710
  let notesTakenCount = 0;
645
711
  let joinedStudyGroupEarly = false;
646
712
  let hasLowQuizScore = false;
713
+ let speedLectureCount = 0;
647
714
 
648
715
  userEvents.forEach((event) => {
649
716
  const eventTime = dayjs(event.time);
650
717
  const daysSinceStart = firstEventTime ? eventTime.diff(firstEventTime, 'days', true) : 0;
651
-
652
- // Hook #3: Count lecture_completed events where notes_taken === true
653
- if (event.event === "lecture completed" && event.notes_taken === true) {
654
- notesTakenCount++;
655
- }
656
-
657
- // Hook #4: Check if user joined a study group within the first 10 days
658
- if (event.event === "study group joined" && daysSinceStart <= 10) {
659
- joinedStudyGroupEarly = true;
660
- }
661
-
662
- // Hook #4: Check for any quiz_completed with score < 60
663
- if (event.event === "quiz completed" && event.score_percent < 60) {
664
- hasLowQuizScore = true;
665
- }
718
+ if (event.event === "lecture completed" && event.notes_taken === true) notesTakenCount++;
719
+ if (event.event === "study group joined" && daysSinceStart <= 10) joinedStudyGroupEarly = true;
720
+ if (event.event === "quiz completed" && event.score_percent < 60) hasLowQuizScore = true;
721
+ if (event.event === "lecture completed" && event.playback_speed >= 2.0) speedLectureCount++;
666
722
  });
667
723
 
668
- // ---------------------------------------------------------------
669
- // Second pass: modify events based on patterns
670
- // ---------------------------------------------------------------
671
-
672
- // Hook #3: NOTES-TAKERS SUCCEED
673
- if (notesTakenCount >= 5) {
674
- userEvents.forEach((event, idx) => {
675
- // Boost quiz scores for diligent note-takers
676
- if (event.event === "quiz completed") {
677
- if (event.score_percent !== undefined) {
678
- event.score_percent = Math.min(100, event.score_percent + 20);
679
- }
680
- event.diligent_student = true;
724
+ // HOOK 3 + HOOK 10: NOTES MAGIC NUMBER (in-funnel, no flags)
725
+ // Sweet 5-8 notes-taken lectures +30% quiz score_percent (cap 100).
726
+ // Over 9+ → drop 35% of certificate-earned events (over-noted but
727
+ // can't synthesize; gets stuck in "study mode").
728
+ if (notesTakenCount >= 5 && notesTakenCount <= 8) {
729
+ userEvents.forEach((event) => {
730
+ if (event.event === "quiz completed" && event.score_percent !== undefined) {
731
+ event.score_percent = Math.min(100, Math.round(event.score_percent * 1.3));
681
732
  }
682
733
  });
683
-
684
- // 40% chance to splice in an extra certificate_earned event
685
734
  if (chance.bool({ likelihood: 40 })) {
686
735
  const lastEvent = userEvents[userEvents.length - 1];
687
736
  const certTemplate = userEvents.find(e => e.event === "certificate earned");
688
737
  if (lastEvent && certTemplate) {
689
- const certEvent = {
738
+ userEvents.push({
690
739
  ...certTemplate,
691
740
  time: dayjs(lastEvent.time).add(chance.integer({ min: 1, max: 5 }), 'days').toISOString(),
692
741
  user_id: lastEvent.user_id,
693
742
  course_id: chance.pickone(courseIds),
694
743
  completion_time_days: chance.integer({ min: 14, max: 90 }),
695
744
  final_grade: chance.integer({ min: 80, max: 100 }),
696
- diligent_student: true,
697
- };
698
- userEvents.push(certEvent);
745
+ });
699
746
  }
700
747
  }
701
- }
702
-
703
- // Hook #8 (everything pass): Speed learners (3+ lectures at 2.0x) get higher quiz scores
704
- let speedLectureCount = 0;
705
- userEvents.forEach((event) => {
706
- if (event.event === "lecture completed" && event.speed_learner === true) {
707
- speedLectureCount++;
748
+ } else if (notesTakenCount >= 9) {
749
+ // Over-noters: drop 35% of certificates (stuck in study mode)
750
+ for (let i = userEvents.length - 1; i >= 0; i--) {
751
+ if (userEvents[i].event === "certificate earned" && chance.bool({ likelihood: 35 })) {
752
+ userEvents.splice(i, 1);
753
+ }
708
754
  }
709
- });
710
- const isSpeedLearner = speedLectureCount >= 3;
755
+ }
711
756
 
712
- if (isSpeedLearner) {
757
+ // HOOK 8 (cont): Speed learners (3+ lectures at 2.0x) score +8 on quizzes.
758
+ if (speedLectureCount >= 3) {
713
759
  userEvents.forEach((event) => {
714
- if (event.event === "quiz completed") {
715
- if (event.score_percent !== undefined) {
716
- event.score_percent = Math.min(100, event.score_percent + 8);
717
- event.speed_learner_effect = true;
718
- }
760
+ if (event.event === "quiz completed" && event.score_percent !== undefined) {
761
+ event.score_percent = Math.min(100, event.score_percent + 8);
719
762
  }
720
763
  });
721
764
  }
722
765
 
723
- // Hook #6: SEMESTER-END SPIKE - duplicate assessment events in the spike window
766
+ // HOOK 6: SEMESTER-END SPIKE duplicate quiz/assignment events
767
+ // in days 75-85 window. No flag — discover via line chart.
724
768
  const duplicates = [];
769
+ const spikableEvents = ["quiz started", "quiz completed", "assignment submitted"];
725
770
  userEvents.forEach((event) => {
726
- if (event.semester_end_rush === true && chance.bool({ likelihood: 80 })) {
727
- const dup = JSON.parse(JSON.stringify(event));
728
- dup.time = dayjs(event.time).add(chance.integer({ min: 5, max: 120 }), 'minutes').toISOString();
729
- dup.semester_end_rush = true;
730
- duplicates.push(dup);
771
+ if (spikableEvents.includes(event.event) && event.time) {
772
+ const dayInDataset = dayjs.utc(event.time).diff(datasetStart, 'days', true);
773
+ if (dayInDataset >= 75 && dayInDataset <= 85 && chance.bool({ likelihood: 80 })) {
774
+ const dup = JSON.parse(JSON.stringify(event));
775
+ dup.time = dayjs(event.time).add(chance.integer({ min: 5, max: 120 }), 'minutes').toISOString();
776
+ duplicates.push(dup);
777
+ }
731
778
  }
732
779
  });
733
- if (duplicates.length > 0) {
734
- userEvents.push(...duplicates);
735
- }
780
+ if (duplicates.length > 0) userEvents.push(...duplicates);
736
781
 
737
- // Hook #7: FREE VS PAID - reinforce the subscription effect on certificates
782
+ // HOOK 7: FREE VS PAID free users lose 55% of certificates.
738
783
  const subStatus = profile ? profile.subscription_status : "free";
739
784
  if (subStatus === "free") {
740
- // Free users lose 55% of their certificates (simulating lower completion)
741
785
  for (let i = userEvents.length - 1; i >= 0; i--) {
742
786
  if (userEvents[i].event === "certificate earned" && chance.bool({ likelihood: 55 })) {
743
787
  userEvents.splice(i, 1);
@@ -745,62 +789,51 @@ const config = {
745
789
  }
746
790
  }
747
791
 
748
- // Hook #4: STUDY GROUP RETENTION (runs LAST to ensure churn removal isn't undone by later hooks)
792
+ // HOOK 4: STUDY GROUP RETENTION non-joiners with low scores lose
793
+ // all post-day-14 events. Joiners get extra cloned discussion events.
749
794
  if (!joinedStudyGroupEarly && hasLowQuizScore) {
750
- // Non-joiners with low scores: remove ALL events after day 14 from their first event (hard churn)
751
795
  const churnCutoff = firstEventTime ? firstEventTime.add(14, 'days') : null;
752
796
  for (let i = userEvents.length - 1; i >= 0; i--) {
753
- const evt = userEvents[i];
754
- if (churnCutoff && dayjs(evt.time).isAfter(churnCutoff)) {
797
+ if (churnCutoff && dayjs(userEvents[i].time).isAfter(churnCutoff)) {
755
798
  userEvents.splice(i, 1);
756
799
  }
757
800
  }
758
801
  } else if (joinedStudyGroupEarly) {
759
- // Study group joiners keep all events and get bonus discussion_posted events
760
802
  const lastEvent = userEvents[userEvents.length - 1];
761
803
  const discussionTemplate = userEvents.find(e => e.event === "discussion posted");
762
804
  if (lastEvent && discussionTemplate && chance.bool({ likelihood: 60 })) {
763
- const bonusDiscussion = {
805
+ userEvents.push({
764
806
  ...discussionTemplate,
765
807
  time: dayjs(lastEvent.time).add(chance.integer({ min: 1, max: 3 }), 'days').toISOString(),
766
808
  user_id: lastEvent.user_id,
767
809
  course_id: chance.pickone(courseIds),
768
810
  post_type: chance.pickone(["question", "answer", "comment"]),
769
811
  word_count: chance.integer({ min: 20, max: 400 }),
770
- study_group_member: true,
771
- };
772
- userEvents.push(bonusDiscussion);
812
+ });
773
813
  }
774
814
  }
775
815
 
776
- // Hook #2b: DEADLINE CRAMMING (quiz score penalty)
777
- // Applied LAST to avoid selection bias if applied before churn,
778
- // the penalty pushes Sun/Mon quiz-takers below the hasLowQuizScore
779
- // threshold, selectively churning them and inflating the avg.
816
+ // HOOK 2: DEADLINE CRAMMING Sun/Mon assignment_submitted events
817
+ // flip is_late to true 60% of the time and quiz_completed score_percent
818
+ // drops 25 points. Mutates raw is_late + score_percent.
819
+ for (const event of userEvents) {
820
+ if (event.event === "assignment submitted" && event.time) {
821
+ const dow = new Date(event.time).getUTCDay();
822
+ if (dow === 0 || dow === 1) {
823
+ event.is_late = chance.bool({ likelihood: 60 });
824
+ }
825
+ }
826
+ }
780
827
  userEvents.forEach((event) => {
781
828
  if (event.event === "quiz completed" && event.time) {
782
- const eventDay = dayjs(event.time).day();
783
- if (eventDay === 0 || eventDay === 1) {
784
- if (event.score_percent !== undefined) {
785
- event.score_percent = Math.max(0, event.score_percent - 25);
786
- }
829
+ const dow = new Date(event.time).getUTCDay();
830
+ if ((dow === 0 || dow === 1) && event.score_percent !== undefined) {
831
+ event.score_percent = Math.max(0, event.score_percent - 25);
787
832
  }
788
833
  }
789
834
  });
790
835
  }
791
836
 
792
- // ═══════════════════════════════════════════════════════════════════
793
- // Hook #7: FREE VS PAID COURSES (funnel-pre)
794
- // ═══════════════════════════════════════════════════════════════════
795
- if (type === "funnel-pre") {
796
- // Hook #7: FREE VS PAID — prop tagging only
797
- // conversionRate manipulation removed; the everything hook handles
798
- // the completion gap by dropping 55% of free-user certificates
799
- if (meta && meta.profile && meta.funnel) {
800
- // no-op: kept for future prop-setting if needed
801
- }
802
- }
803
-
804
837
  return record;
805
838
  }
806
839
  };