@ak--47/dungeon-master 1.4.5 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +158 -0
  2. package/.claude/skills/create-dungeon/SKILL.md +464 -0
  3. package/.claude/skills/verify-dungeon/SKILL.md +157 -0
  4. package/.claude/skills/verify-dungeon/references/counting-semantics.md +161 -0
  5. package/.claude/skills/verify-dungeon/references/report-format.md +216 -0
  6. package/.claude/skills/verify-dungeon/references/sql-recipes.md +857 -0
  7. package/.claude/skills/write-hooks/SKILL.md +468 -0
  8. package/CHANGELOG.md +182 -0
  9. package/HOOKS.md +1256 -597
  10. package/README.md +140 -5
  11. package/dungeons/technical/ad-spend.js +41 -49
  12. package/dungeons/technical/anonymous-users.js +38 -36
  13. package/dungeons/technical/array-of-object-lookup.js +136 -153
  14. package/dungeons/technical/datagen-v15-verify.js +87 -0
  15. package/dungeons/technical/experiments.js +42 -40
  16. package/dungeons/technical/foobar.js +114 -118
  17. package/dungeons/technical/group-analytics.js +42 -40
  18. package/dungeons/technical/hook-helpers-verify.js +69 -50
  19. package/dungeons/technical/identity-model-verify.js +22 -12
  20. package/dungeons/technical/mirror-strategies.js +37 -39
  21. package/dungeons/technical/nested-objects.js +119 -118
  22. package/dungeons/technical/pattern-aggregate-by-bin.js +21 -8
  23. package/dungeons/technical/pattern-attributed-by-source.js +23 -9
  24. package/dungeons/technical/pattern-frequency-by-frequency.js +21 -8
  25. package/dungeons/technical/pattern-funnel-frequency.js +30 -15
  26. package/dungeons/technical/pattern-ttc-by-segment.js +21 -8
  27. package/dungeons/technical/retention-cadence.js +115 -112
  28. package/dungeons/technical/sanity.js +86 -80
  29. package/dungeons/technical/scale-test.js +34 -38
  30. package/dungeons/technical/scd.js +111 -128
  31. package/dungeons/technical/simple.js +134 -141
  32. package/dungeons/technical/simplest.js +111 -65
  33. package/dungeons/technical/text-generation.js +110 -146
  34. package/dungeons/vertical/ai-platform.js +300 -333
  35. package/dungeons/vertical/community.js +290 -255
  36. package/dungeons/vertical/crypto.js +400 -391
  37. package/dungeons/vertical/dating.js +421 -375
  38. package/dungeons/vertical/devtools.js +346 -298
  39. package/dungeons/vertical/ecommerce.js +322 -394
  40. package/dungeons/vertical/education.js +380 -325
  41. package/dungeons/vertical/fintech.js +371 -325
  42. package/dungeons/vertical/fitness.js +345 -291
  43. package/dungeons/vertical/food-delivery.js +352 -307
  44. package/dungeons/vertical/gaming.js +490 -444
  45. package/dungeons/vertical/healthcare.js +311 -262
  46. package/dungeons/vertical/insurance-application.js +437 -409
  47. package/dungeons/vertical/logistics.js +278 -252
  48. package/dungeons/vertical/marketplace.js +340 -323
  49. package/dungeons/vertical/media.js +390 -335
  50. package/dungeons/vertical/real-estate.js +402 -347
  51. package/dungeons/vertical/sass.js +331 -333
  52. package/dungeons/vertical/social.js +377 -316
  53. package/dungeons/vertical/travel.js +302 -295
  54. package/index.js +64 -7
  55. package/lib/core/config-validator.js +378 -17
  56. package/lib/core/dungeon-loader.js +2 -5
  57. package/lib/generators/events.js +12 -13
  58. package/lib/generators/funnels.js +76 -2
  59. package/lib/hook-helpers/index.js +1 -0
  60. package/lib/hook-helpers/inject.js +95 -0
  61. package/lib/orchestrators/mixpanel-sender.js +7 -0
  62. package/lib/orchestrators/user-loop.js +598 -48
  63. package/lib/templates/defaults.js +59 -59
  64. package/lib/templates/macro-presets.js +53 -11
  65. package/lib/utils/dataset-context.js +103 -0
  66. package/lib/utils/retention-curve.js +140 -0
  67. package/lib/utils/utils.js +157 -109
  68. package/lib/verify/counting.js +360 -0
  69. package/lib/verify/emulate-breakdown.js +531 -108
  70. package/lib/verify/funnel-engine.js +539 -0
  71. package/lib/verify/identity.js +78 -0
  72. package/lib/verify/index.js +20 -0
  73. package/lib/verify/schema-validator.js +3 -1
  74. package/lib/verify/verify-dungeon.js +58 -0
  75. package/package.json +14 -3
  76. package/scripts/run-dungeon.mjs +12 -1
  77. package/types.d.ts +353 -4
  78. package/scripts/smoke-test-all.mjs +0 -162
@@ -1,64 +1,50 @@
1
- // ── TWEAK THESE ──
2
- const SEED = "harness-education";
3
- const num_days = 120;
4
- const num_users = 10_000;
5
- const avg_events_per_user_per_day = 1.2;
6
- let token = "your-mixpanel-token";
7
-
8
- // ── env overrides ──
9
- if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
10
-
1
+ // ── IMPORTS ──
11
2
  import dayjs from "dayjs";
12
3
  import utc from "dayjs/plugin/utc.js";
4
+ dayjs.extend(utc);
13
5
  import "dotenv/config";
14
6
  import * as u from "../../lib/utils/utils.js";
15
7
  import * as v from "ak-tools";
16
-
17
- dayjs.extend(utc);
18
- const chance = u.initChance(SEED);
19
-
20
8
  /** @typedef {import("../../types").Dungeon} Config */
21
9
 
22
- /**
23
- * ═══════════════════════════════════════════════════════════════════════════════
24
- * DATASET OVERVIEW — LearnPath eLearning Platform
25
- * ═══════════════════════════════════════════════════════════════════════════════
26
- *
27
- * An online learning platform modeled after Coursera, Khan Academy, and Udemy.
28
- * Supports self-paced and cohort-based learning with courses, quizzes,
29
- * assignments, and social study features.
30
- *
31
- * Scale: 10,000 users / ~1.4M events / 120 days / 17 event types
32
- *
33
- * CORE LOOP:
34
- * Register browse/enroll in courses watch lectures practice problems →
35
- * quizzes/assignments certificate earned. Social layer (study groups,
36
- * discussions) drives retention. Subscription tiers (free/monthly/annual)
37
- * gate completion rates.
38
- *
39
- * FUNNELS:
40
- * - Onboarding: account registered → course enrolled → lecture started
41
- * - Learning loop: lecture started lecture completedpractice problem solved
42
- * - Assessment: quiz started → quiz completed → assignment submitted
43
- * - Course completion: course enrolled lecture completed → quiz completed certificate earned
44
- * - Social learning: discussion postedstudy group joined resource downloaded
45
- * - Instructor interaction: assignment submitted assignment gradedinstructor feedback given
46
- * - Support/monetization: help requested subscription purchasedcourse reviewed
47
- *
48
- * GROUPS: course_id (150 courses), group_id (300 study groups)
49
- * SUBSCRIPTIONS: free (~60%), monthly, annual
50
- * ACCOUNT TYPES: ~89% students, ~11% instructors (two-sided marketplace)
10
+ // ── OVERVIEW ──
11
+ /*
12
+ * NAME: LearnPath
13
+ * APP: Online learning platform modeled after Coursera, Khan Academy,
14
+ * and Udemy. Self-paced and cohort-based courses with quizzes,
15
+ * assignments, certificates, and a social study layer. Two-sided
16
+ * marketplace: ~89% students, ~11% instructors.
17
+ * SCALE: 10,000 users, ~1.4M events, 121 days (2026-01-01 → 2026-05-01)
18
+ * CORE LOOP: account registered → course enrolled → lecture started/completed → quiz → certificate
19
+ *
20
+ * EVENTS (17):
21
+ * lecture started (18) > lecture completed (14) > practice problem solved (12)
22
+ * > quiz started (10) > resource downloaded (9) > course enrolled (8)
23
+ * > quiz completed (8) > discussion posted (7) > assignment submitted (6)
24
+ * > assignment graded (5) > help requested (4) > study group joined (4)
25
+ * > instructor feedback given (3) > course reviewed (3) > certificate earned (2)
26
+ * > subscription purchased (2) > account registered (1)
27
+ *
28
+ * FUNNELS (7):
29
+ * - Onboarding: account registeredcourse enrolledlecture started (75%)
30
+ * - Learning Loop: lecture started → lecture completed → practice problem solved (70%, reentry)
31
+ * - Assessment: quiz startedquiz completed → assignment submitted (55%, reentry)
32
+ * - Course Completion: course enrolled lecture completed quiz completedcertificate earned (30%)
33
+ * - Social Learning: discussion postedstudy group joined resource downloaded (50%, AI Study Buddy A/B)
34
+ * - Instructor Interaction: assignment submittedassignment gradedinstructor feedback given (45%)
35
+ * - Support/Monetization: help requested → subscription purchased → course reviewed (35%)
36
+ *
37
+ * USER PROPS: account_type, subscription_status, learning_style, education_level, timezone, courses_created, teaching_experience_years, instructor_rating, learning_goal, study_hours_per_week, Platform
38
+ * SUPER PROPS: Platform
39
+ * SCD PROPS: enrollment_status (enrolled/active/completed/dropped, monthly fuzzy, max 6), course_status (draft/published/archived/deprecated, monthly fixed, max 6, type: course_id)
40
+ * GROUPS: course_id (150 courses), group_id (300 study groups)
51
41
  */
52
42
 
53
- /**
54
- * ═══════════════════════════════════════════════════════════════════════════════
55
- * ANALYTICS HOOKS (10 hooks)
56
- * ═══════════════════════════════════════════════════════════════════════════════
57
- *
58
- * NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable via
59
- * behavioral cohorts, raw-prop breakdowns, or funnel time-to-convert.
60
- *
43
+ // ── HOOK STORIES ──
44
+ /*
45
+ * ---------------------------------------------------------------
61
46
  * 1. STUDENT VS INSTRUCTOR PROFILES (user)
47
+ * ---------------------------------------------------------------
62
48
  *
63
49
  * PATTERN: Instructor profiles get teaching attributes
64
50
  * (courses_created, teaching_experience_years, instructor_rating).
@@ -88,6 +74,7 @@ const chance = u.initChance(SEED);
88
74
  *
89
75
  * ---------------------------------------------------------------
90
76
  * 2. DEADLINE CRAMMING (everything)
77
+ * ---------------------------------------------------------------
91
78
  *
92
79
  * PATTERN: Assignments submitted on Sun/Mon are rushed — 60% are late
93
80
  * (raw is_late prop set true at 60% likelihood) vs ~20% baseline. Quiz
@@ -115,6 +102,7 @@ const chance = u.initChance(SEED);
115
102
  *
116
103
  * ---------------------------------------------------------------
117
104
  * 3. NOTES MAGIC NUMBER (everything, in-funnel)
105
+ * ---------------------------------------------------------------
118
106
  *
119
107
  * PATTERN: Sweet 5-8 lectures with notes_taken=true → +30% quiz
120
108
  * score_percent (cap 100) and 40% chance of bonus cloned certificate.
@@ -142,6 +130,7 @@ const chance = u.initChance(SEED);
142
130
  *
143
131
  * ---------------------------------------------------------------
144
132
  * 4. STUDY GROUP RETENTION (everything)
133
+ * ---------------------------------------------------------------
145
134
  *
146
135
  * PATTERN: Users who join a study group within 10 days get bonus
147
136
  * discussion events. Non-joiners with low quiz scores (<60) churn
@@ -166,6 +155,7 @@ const chance = u.initChance(SEED);
166
155
  *
167
156
  * ---------------------------------------------------------------
168
157
  * 5. HINT DEPENDENCY (event)
158
+ * ---------------------------------------------------------------
169
159
  *
170
160
  * PATTERN: On "practice problem solved", hint_used=true gets difficulty
171
161
  * forced to "easy" 60% of the time. hint_used=false gets difficulty forced
@@ -196,6 +186,7 @@ const chance = u.initChance(SEED);
196
186
  *
197
187
  * ---------------------------------------------------------------
198
188
  * 6. SEMESTER-END SPIKE (everything)
189
+ * ---------------------------------------------------------------
199
190
  *
200
191
  * PATTERN: Days 75-85 simulate semester crunch. quiz_started, quiz_completed,
201
192
  * and assignment_submitted events are duplicated at an 80% rate. No flag —
@@ -215,6 +206,7 @@ const chance = u.initChance(SEED);
215
206
  *
216
207
  * ---------------------------------------------------------------
217
208
  * 7. FREE VS PAID COURSES (funnel-pre + everything)
209
+ * ---------------------------------------------------------------
218
210
  *
219
211
  * PATTERN: Free users get 0.5x funnel conversion rate on the cert funnel only; paid
220
212
  * subscribers get 1.5x. Free users also lose 55% of their
@@ -239,7 +231,8 @@ const chance = u.initChance(SEED);
239
231
  * follow-through; free learners drop off long before completion.
240
232
  *
241
233
  * ---------------------------------------------------------------
242
- * 8. PLAYBACK SPEED CORRELATION (everything)
234
+ * 8. PLAYBACK SPEED CORRELATION (event + everything)
235
+ * ---------------------------------------------------------------
243
236
  *
244
237
  * PATTERN: Speed learners (>=2.0x speed on 3+ lectures) get 0.6x
245
238
  * watch_time and a paradoxical +8 quiz score boost. Thorough
@@ -267,6 +260,7 @@ const chance = u.initChance(SEED);
267
260
  *
268
261
  * ---------------------------------------------------------------
269
262
  * 9. COURSE COMPLETION TIME-TO-CONVERT (everything)
263
+ * ---------------------------------------------------------------
270
264
  *
271
265
  * PATTERN: Annual subscribers complete the course-completion funnel
272
266
  * 2x faster (factor 0.5); Free users 1.8x slower (factor 1.8).
@@ -290,6 +284,7 @@ const chance = u.initChance(SEED);
290
284
  *
291
285
  * ---------------------------------------------------------------
292
286
  * 10. SOCIAL LEARNING EXPERIMENT (funnel experiment)
287
+ * ---------------------------------------------------------------
293
288
  *
294
289
  * PATTERN: A/B experiment on the Social Learning funnel (discussion
295
290
  * posted → study group joined → resource downloaded). "AI Study
@@ -314,9 +309,9 @@ const chance = u.initChance(SEED);
314
309
  * REAL-WORLD ANALOGUE: AI-powered study companions boost social
315
310
  * engagement and resource discovery in cohort-based courses.
316
311
  *
317
- * ═══════════════════════════════════════════════════════════════════════════════
312
+ * ===============================================================
318
313
  * EXPECTED METRICS SUMMARY
319
- * ═══════════════════════════════════════════════════════════════════════════════
314
+ * ===============================================================
320
315
  *
321
316
  * Hook | Metric | Baseline | Hook Effect | Ratio
322
317
  * ------------------------|-----------------------|----------|--------------|------
@@ -330,10 +325,60 @@ const chance = u.initChance(SEED);
330
325
  * Free vs Paid | Course completion | 15% | 33% | 2.2x
331
326
  * Playback Speed | Quiz score (speed) | ~ 65 | ~ 73 | +8 pt
332
327
  * Course Completion T2C | median min by tier | 1x | 0.5x/1.8x | ~ 3.6x range
333
- * Social Learning Exp | AI variant conversion | 1x | 1.4x | 1.4x
334
- * Social Learning Exp | AI variant TTC | 1x | 0.85x | -15%
328
+ * Social Learning Exp | AI variant conversion | 1x | 1.4x | 1.4x
329
+ * Social Learning Exp | AI variant TTC | 1x | 0.85x | -15%
335
330
  */
336
331
 
332
+ // ── SCALE ──
333
+ const SEED = "harness-education";
334
+ const NUM_USERS = 10_000;
335
+ const DATASET_START = "2026-01-01T00:00:00Z";
336
+ const DATASET_END = "2026-05-01T23:59:59Z";
337
+ const EVENTS_PER_DAY = 1.2;
338
+ const token = process.env.MP_TOKEN || "your-mixpanel-token";
339
+
340
+ const chance = u.initChance(SEED);
341
+
342
+ // ── KNOBS (tweak these to reshape stories) ──
343
+ const HINT_EASY_LIKELIHOOD = 60;
344
+ const HINT_HARD_LIKELIHOOD = 40;
345
+
346
+ const SPEED_FAST_THRESHOLD = 2.0;
347
+ const SPEED_FAST_WATCH_FACTOR = 0.6;
348
+ const SPEED_FAST_WATCH_MIN = 3;
349
+ const SPEED_SLOW_THRESHOLD = 1.0;
350
+ const SPEED_SLOW_WATCH_FACTOR = 1.4;
351
+ const SPEED_SLOW_WATCH_MAX = 90;
352
+ const SPEED_LECTURE_COUNT_THRESHOLD = 3;
353
+ const SPEED_QUIZ_BOOST_POINTS = 8;
354
+
355
+ const FREE_FUNNEL_CONV_FACTOR = 0.5;
356
+ const PAID_FUNNEL_CONV_FACTOR = 1.5;
357
+ const FREE_CERT_DROP_LIKELIHOOD = 55;
358
+
359
+ const NOTES_SWEET_MIN = 5;
360
+ const NOTES_SWEET_MAX = 8;
361
+ const NOTES_OVER_THRESHOLD = 9;
362
+ const NOTES_QUIZ_BOOST = 1.3;
363
+ const NOTES_BONUS_CERT_LIKELIHOOD = 40;
364
+ const NOTES_OVER_CERT_DROP_LIKELIHOOD = 35;
365
+
366
+ const SEMESTER_SPIKE_START_DAY = 75;
367
+ const SEMESTER_SPIKE_END_DAY = 85;
368
+ const SEMESTER_SPIKE_LIKELIHOOD = 80;
369
+
370
+ const TTC_ANNUAL_FACTOR = 0.5;
371
+ const TTC_FREE_FACTOR = 1.8;
372
+
373
+ const STUDY_GROUP_EARLY_DAYS = 10;
374
+ const STUDY_GROUP_LOW_QUIZ_THRESHOLD = 60;
375
+ const STUDY_GROUP_CHURN_CUTOFF_DAYS = 14;
376
+ const STUDY_GROUP_DISCUSSION_CLONE_LIKELIHOOD = 60;
377
+
378
+ const DEADLINE_LATE_LIKELIHOOD = 60;
379
+ const DEADLINE_QUIZ_PENALTY = 25;
380
+
381
+ // ── DATA ARRAYS ──
337
382
  // Generate consistent IDs for lookup tables and event properties
338
383
  const courseIds = v.range(1, 151).map(n => `course_${v.uid(6)}`);
339
384
  const quizIds = v.range(1, 401).map(n => `quiz_${v.uid(6)}`);
@@ -342,31 +387,275 @@ const lectureIds = v.range(1, 501).map(n => `lecture_${v.uid(6)}`);
342
387
  const assignmentIds = v.range(1, 201).map(n => `assignment_${v.uid(6)}`);
343
388
  const problemIds = v.range(1, 601).map(n => `problem_${v.uid(6)}`);
344
389
 
390
+ // ── HELPER FUNCTIONS ──
391
+ function handleUserHooks(record) {
392
+ // H1: STUDENT VS INSTRUCTOR PROFILES — role-based attributes.
393
+ if (record.account_type === "instructor") {
394
+ record.courses_created = chance.integer({ min: 1, max: 15 });
395
+ record.teaching_experience_years = chance.integer({ min: 1, max: 20 });
396
+ record.instructor_rating = Math.round((chance.floating({ min: 3.0, max: 5.0 }) + Number.EPSILON) * 100) / 100;
397
+ } else {
398
+ record.learning_goal = chance.pickone(["career_change", "skill_upgrade", "hobby", "degree_requirement"]);
399
+ record.study_hours_per_week = chance.integer({ min: 2, max: 30 });
400
+ }
401
+ return record;
402
+ }
403
+
404
+ function handleEventHooks(record) {
405
+ // H5: HINT DEPENDENCY — hint users get 60% easy problems; non-hint
406
+ // users get 40% hard problems. Mutates difficulty (raw).
407
+ if (record.event === "practice problem solved") {
408
+ if (record.hint_used === true && chance.bool({ likelihood: HINT_EASY_LIKELIHOOD })) {
409
+ record.difficulty = "easy";
410
+ } else if (record.hint_used === false && chance.bool({ likelihood: HINT_HARD_LIKELIHOOD })) {
411
+ record.difficulty = "hard";
412
+ }
413
+ }
414
+ // H8 (event): PLAYBACK SPEED — speed learners (>= 2.0x) get
415
+ // watch_time_mins compressed 0.6x; thorough learners (<= 1.0x) get 1.4x.
416
+ if (record.event === "lecture completed") {
417
+ const speed = record.playback_speed;
418
+ if (speed >= SPEED_FAST_THRESHOLD && record.watch_time_mins !== undefined) {
419
+ record.watch_time_mins = Math.max(SPEED_FAST_WATCH_MIN, Math.floor(record.watch_time_mins * SPEED_FAST_WATCH_FACTOR));
420
+ } else if (speed !== undefined && speed <= SPEED_SLOW_THRESHOLD && record.watch_time_mins !== undefined) {
421
+ record.watch_time_mins = Math.min(SPEED_SLOW_WATCH_MAX, Math.floor(record.watch_time_mins * SPEED_SLOW_WATCH_FACTOR));
422
+ }
423
+ }
424
+ return record;
425
+ }
426
+
427
+ function handleFunnelPreHooks(record, meta) {
428
+ // H7: FREE VS PAID — free users get 0.5x conversion rate; paid
429
+ // subscribers get 1.5x. Scoped to the course-completion funnel ONLY
430
+ // (sequence ending in "certificate earned") to avoid displacing standalone
431
+ // events for paid users and triggering unintended churn in H4.
432
+ const isCertFunnel = Array.isArray(meta?.funnel?.sequence) &&
433
+ meta.funnel.sequence.includes("certificate earned");
434
+ if (isCertFunnel) {
435
+ const subStatus = meta?.profile?.subscription_status;
436
+ if (subStatus === "free") {
437
+ record.conversionRate = Math.round(record.conversionRate * FREE_FUNNEL_CONV_FACTOR);
438
+ } else if (subStatus === "monthly" || subStatus === "annual") {
439
+ record.conversionRate = Math.min(100, Math.round(record.conversionRate * PAID_FUNNEL_CONV_FACTOR));
440
+ }
441
+ }
442
+ return record;
443
+ }
444
+
445
+ function handleEverythingHooks(record, meta) {
446
+ const datasetStart = dayjs.unix(meta.datasetStart);
447
+ const userEvents = record;
448
+ const profile = meta.profile;
449
+ const firstEventTime = userEvents.length > 0 ? dayjs(userEvents[0].time) : null;
450
+
451
+ if (profile) {
452
+ userEvents.forEach((event) => {
453
+ if (profile.Platform !== undefined) event.Platform = profile.Platform;
454
+ });
455
+ }
456
+
457
+ let notesTakenCount = 0;
458
+ let joinedStudyGroupEarly = false;
459
+ let hasLowQuizScore = false;
460
+ let speedLectureCount = 0;
461
+
462
+ userEvents.forEach((event) => {
463
+ const eventTime = dayjs(event.time);
464
+ const daysSinceStart = firstEventTime ? eventTime.diff(firstEventTime, 'days', true) : 0;
465
+ if (event.event === "lecture completed" && event.notes_taken === true) notesTakenCount++;
466
+ if (event.event === "study group joined" && daysSinceStart <= STUDY_GROUP_EARLY_DAYS) joinedStudyGroupEarly = true;
467
+ if (event.event === "quiz completed" && event.score_percent < STUDY_GROUP_LOW_QUIZ_THRESHOLD) hasLowQuizScore = true;
468
+ if (event.event === "lecture completed" && event.playback_speed >= SPEED_FAST_THRESHOLD) speedLectureCount++;
469
+ });
470
+
471
+ // H3 + H10: NOTES MAGIC NUMBER (in-funnel, no flags)
472
+ // Sweet 5-8 notes-taken lectures → +30% quiz score_percent (cap 100).
473
+ // Over 9+ → drop 35% of certificate-earned events (over-noted but
474
+ // can't synthesize; gets stuck in "study mode").
475
+ if (notesTakenCount >= NOTES_SWEET_MIN && notesTakenCount <= NOTES_SWEET_MAX) {
476
+ userEvents.forEach((event) => {
477
+ if (event.event === "quiz completed" && event.score_percent !== undefined) {
478
+ event.score_percent = Math.min(100, Math.round(event.score_percent * NOTES_QUIZ_BOOST));
479
+ }
480
+ });
481
+ if (chance.bool({ likelihood: NOTES_BONUS_CERT_LIKELIHOOD })) {
482
+ const lastEvent = userEvents[userEvents.length - 1];
483
+ const certTemplate = userEvents.find(e => e.event === "certificate earned");
484
+ if (lastEvent && certTemplate) {
485
+ userEvents.push({
486
+ ...certTemplate,
487
+ time: dayjs(lastEvent.time).add(chance.integer({ min: 1, max: 5 }), 'days').toISOString(),
488
+ user_id: lastEvent.user_id,
489
+ course_id: chance.pickone(courseIds),
490
+ completion_time_days: chance.integer({ min: 14, max: 90 }),
491
+ final_grade: chance.integer({ min: 80, max: 100 }),
492
+ });
493
+ }
494
+ }
495
+ } else if (notesTakenCount >= NOTES_OVER_THRESHOLD) {
496
+ // Over-noters: drop 35% of certificates (stuck in study mode)
497
+ for (let i = userEvents.length - 1; i >= 0; i--) {
498
+ if (userEvents[i].event === "certificate earned" && chance.bool({ likelihood: NOTES_OVER_CERT_DROP_LIKELIHOOD })) {
499
+ userEvents.splice(i, 1);
500
+ }
501
+ }
502
+ }
503
+
504
+ // H8 (cont): Speed learners (3+ lectures at 2.0x) score +8 on quizzes.
505
+ if (speedLectureCount >= SPEED_LECTURE_COUNT_THRESHOLD) {
506
+ userEvents.forEach((event) => {
507
+ if (event.event === "quiz completed" && event.score_percent !== undefined) {
508
+ event.score_percent = Math.min(100, event.score_percent + SPEED_QUIZ_BOOST_POINTS);
509
+ }
510
+ });
511
+ }
512
+
513
+ // H6: SEMESTER-END SPIKE — duplicate quiz/assignment events
514
+ // in days 75-85 window. No flag — discover via line chart.
515
+ const duplicates = [];
516
+ const spikableEvents = ["quiz started", "quiz completed", "assignment submitted"];
517
+ userEvents.forEach((event) => {
518
+ if (spikableEvents.includes(event.event) && event.time) {
519
+ const dayInDataset = dayjs.utc(event.time).diff(datasetStart, 'days', true);
520
+ if (dayInDataset >= SEMESTER_SPIKE_START_DAY && dayInDataset <= SEMESTER_SPIKE_END_DAY && chance.bool({ likelihood: SEMESTER_SPIKE_LIKELIHOOD })) {
521
+ const dup = JSON.parse(JSON.stringify(event));
522
+ dup.time = dayjs(event.time).add(chance.integer({ min: 5, max: 120 }), 'minutes').toISOString();
523
+ duplicates.push(dup);
524
+ }
525
+ }
526
+ });
527
+ if (duplicates.length > 0) userEvents.push(...duplicates);
528
+
529
+ const subStatus = profile ? profile.subscription_status : "free";
530
+
531
+ // H9 (T2C): COURSE COMPLETION TIME-TO-CONVERT (everything)
532
+ // Annual subscribers complete the cert funnel 2x faster (factor 0.5);
533
+ // Free users 1.8x slower (factor 1.8). For each "certificate earned"
534
+ // event, find the nearest preceding "course enrolled" and scale the gap.
535
+ // Runs BEFORE cert-dropping (H7) so TTC adjustments aren't masked by
536
+ // survivorship bias from the 55% free cert removal.
537
+ {
538
+ const ttcFactor = (
539
+ subStatus === "annual" ? TTC_ANNUAL_FACTOR :
540
+ subStatus === "free" ? TTC_FREE_FACTOR :
541
+ 1.0
542
+ );
543
+ if (ttcFactor !== 1.0) {
544
+ // Collect all "course enrolled" times (sorted) for binary lookup
545
+ const enrolledTimes = userEvents
546
+ .filter(e => e.event === "course enrolled")
547
+ .map(e => dayjs(e.time))
548
+ .sort((a, b) => a.valueOf() - b.valueOf());
549
+
550
+ if (enrolledTimes.length > 0) {
551
+ for (const event of userEvents) {
552
+ if (event.event === "certificate earned") {
553
+ const certTime = dayjs(event.time);
554
+ // Find the latest enrolled time before this cert
555
+ let anchor = null;
556
+ for (let k = enrolledTimes.length - 1; k >= 0; k--) {
557
+ if (enrolledTimes[k].isBefore(certTime)) {
558
+ anchor = enrolledTimes[k];
559
+ break;
560
+ }
561
+ }
562
+ if (anchor) {
563
+ const gap = certTime.diff(anchor);
564
+ const newGap = Math.round(gap * ttcFactor);
565
+ event.time = anchor.add(newGap, "milliseconds").toISOString();
566
+ }
567
+ }
568
+ }
569
+ }
570
+ }
571
+ }
572
+
573
+ // H7: FREE VS PAID — free users lose 55% of certificates.
574
+ if (subStatus === "free") {
575
+ for (let i = userEvents.length - 1; i >= 0; i--) {
576
+ if (userEvents[i].event === "certificate earned" && chance.bool({ likelihood: FREE_CERT_DROP_LIKELIHOOD })) {
577
+ userEvents.splice(i, 1);
578
+ }
579
+ }
580
+ }
581
+
582
+ // H4: STUDY GROUP RETENTION — non-joiners with low scores lose
583
+ // all post-day-14 events. Joiners get extra cloned discussion events.
584
+ if (!joinedStudyGroupEarly && hasLowQuizScore) {
585
+ const churnCutoff = firstEventTime ? firstEventTime.add(STUDY_GROUP_CHURN_CUTOFF_DAYS, 'days') : null;
586
+ for (let i = userEvents.length - 1; i >= 0; i--) {
587
+ if (churnCutoff && dayjs(userEvents[i].time).isAfter(churnCutoff)) {
588
+ userEvents.splice(i, 1);
589
+ }
590
+ }
591
+ } else if (joinedStudyGroupEarly) {
592
+ const lastEvent = userEvents[userEvents.length - 1];
593
+ const discussionTemplate = userEvents.find(e => e.event === "discussion posted");
594
+ if (lastEvent && discussionTemplate && chance.bool({ likelihood: STUDY_GROUP_DISCUSSION_CLONE_LIKELIHOOD })) {
595
+ userEvents.push({
596
+ ...discussionTemplate,
597
+ time: dayjs(lastEvent.time).add(chance.integer({ min: 1, max: 3 }), 'days').toISOString(),
598
+ user_id: lastEvent.user_id,
599
+ course_id: chance.pickone(courseIds),
600
+ post_type: chance.pickone(["question", "answer", "comment"]),
601
+ word_count: chance.integer({ min: 20, max: 400 }),
602
+ });
603
+ }
604
+ }
605
+
606
+ // H2: DEADLINE CRAMMING — Sun/Mon assignment_submitted events
607
+ // flip is_late to true 60% of the time and quiz_completed score_percent
608
+ // drops 25 points. Mutates raw is_late + score_percent.
609
+ for (const event of userEvents) {
610
+ if (event.event === "assignment submitted" && event.time) {
611
+ const dow = new Date(event.time).getUTCDay();
612
+ if (dow === 0 || dow === 1) {
613
+ event.is_late = chance.bool({ likelihood: DEADLINE_LATE_LIKELIHOOD });
614
+ }
615
+ }
616
+ }
617
+ userEvents.forEach((event) => {
618
+ if (event.event === "quiz completed" && event.time) {
619
+ const dow = new Date(event.time).getUTCDay();
620
+ if ((dow === 0 || dow === 1) && event.score_percent !== undefined) {
621
+ event.score_percent = Math.max(0, event.score_percent - DEADLINE_QUIZ_PENALTY);
622
+ }
623
+ }
624
+ });
625
+
626
+ return record;
627
+ }
628
+
629
+ // ── CONFIG ──
345
630
  /** @type {Config} */
346
631
  const config = {
347
632
  version: 2,
348
- token,
349
633
  seed: SEED,
350
- datasetStart: "2026-01-01T00:00:00Z",
351
- datasetEnd: "2026-05-01T23:59:59Z",
352
- // numDays: num_days,
353
- avgEventsPerUserPerDay: avg_events_per_user_per_day,
354
- numUsers: num_users,
355
- hasAnonIds: true,
356
- avgDevicePerUser: 2,
357
- hasSessionIds: true,
634
+ datasetStart: DATASET_START,
635
+ datasetEnd: DATASET_END,
636
+ avgEventsPerUserPerDay: EVENTS_PER_DAY,
637
+ numUsers: NUM_USERS,
358
638
  format: "json",
359
639
  gzip: true,
360
- alsoInferFunnels: false,
361
- hasLocation: true,
362
- hasAndroidDevices: true,
363
- hasIOSDevices: true,
364
- hasDesktopDevices: true,
365
- hasBrowser: false,
366
- hasCampaigns: false,
367
- isAnonymous: false,
368
- hasAdSpend: false,
369
- hasAvatar: true,
640
+ credentials: {
641
+ token,
642
+ },
643
+ switches: {
644
+ hasSessionIds: true,
645
+ alsoInferFunnels: false,
646
+ hasLocation: true,
647
+ hasAndroidDevices: true,
648
+ hasIOSDevices: true,
649
+ hasDesktopDevices: true,
650
+ hasBrowser: false,
651
+ hasCampaigns: false,
652
+ isAnonymous: false,
653
+ hasAdSpend: false,
654
+ hasAvatar: true,
655
+ },
656
+ identity: {
657
+ avgDevicePerUser: 2,
658
+ },
370
659
  concurrency: 1,
371
660
  writeToDisk: false,
372
661
 
@@ -383,6 +672,7 @@ const config = {
383
672
  conversionRate: 70,
384
673
  timeToConvert: 4,
385
674
  weight: 5,
675
+ reentry: true,
386
676
  },
387
677
  {
388
678
  // Assessment flow: quizzes and assignments after studying
@@ -390,6 +680,7 @@ const config = {
390
680
  conversionRate: 55,
391
681
  timeToConvert: 8,
392
682
  weight: 3,
683
+ reentry: true,
393
684
  },
394
685
  {
395
686
  // Course completion journey: enroll → complete → earn certificate
@@ -443,6 +734,7 @@ const config = {
443
734
  {
444
735
  event: "course enrolled",
445
736
  weight: 8,
737
+ isStrictEvent: false,
446
738
  properties: {
447
739
  "course_id": courseIds,
448
740
  "course_category": ["CS", "Math", "Science", "Business", "Arts", "Languages"],
@@ -463,6 +755,7 @@ const config = {
463
755
  {
464
756
  event: "lecture completed",
465
757
  weight: 14,
758
+ isStrictEvent: false,
466
759
  properties: {
467
760
  "course_id": courseIds,
468
761
  "lecture_id": lectureIds,
@@ -474,6 +767,7 @@ const config = {
474
767
  {
475
768
  event: "quiz started",
476
769
  weight: 10,
770
+ isStrictEvent: false,
477
771
  properties: {
478
772
  "course_id": courseIds,
479
773
  "quiz_id": quizIds,
@@ -484,6 +778,7 @@ const config = {
484
778
  {
485
779
  event: "quiz completed",
486
780
  weight: 8,
781
+ isStrictEvent: false,
487
782
  properties: {
488
783
  "course_id": courseIds,
489
784
  "quiz_id": quizIds,
@@ -495,6 +790,7 @@ const config = {
495
790
  {
496
791
  event: "assignment submitted",
497
792
  weight: 6,
793
+ isStrictEvent: false,
498
794
  properties: {
499
795
  "course_id": courseIds,
500
796
  "assignment_id": assignmentIds,
@@ -517,6 +813,7 @@ const config = {
517
813
  {
518
814
  event: "discussion posted",
519
815
  weight: 7,
816
+ isStrictEvent: false,
520
817
  properties: {
521
818
  "course_id": courseIds,
522
819
  "post_type": ["question", "answer", "comment"],
@@ -526,6 +823,7 @@ const config = {
526
823
  {
527
824
  event: "certificate earned",
528
825
  weight: 2,
826
+ isStrictEvent: false,
529
827
  properties: {
530
828
  "course_id": courseIds,
531
829
  "completion_time_days": u.weighNumRange(7, 180, 0.5, 45),
@@ -535,6 +833,7 @@ const config = {
535
833
  {
536
834
  event: "study group joined",
537
835
  weight: 4,
836
+ isStrictEvent: false,
538
837
  properties: {
539
838
  "group_id": groupIds,
540
839
  "group_size": u.weighNumRange(3, 20, 0.7, 8),
@@ -587,6 +886,7 @@ const config = {
587
886
  {
588
887
  event: "practice problem solved",
589
888
  weight: 12,
889
+ isStrictEvent: false,
590
890
  properties: {
591
891
  "course_id": courseIds,
592
892
  "problem_id": problemIds,
@@ -652,256 +952,11 @@ const config = {
652
952
 
653
953
  lookupTables: [],
654
954
 
655
- /**
656
- * ARCHITECTED ANALYTICS HOOKS
657
- *
658
- * This hook function creates 10 deliberate patterns in the data:
659
- *
660
- * 1. STUDENT VS INSTRUCTOR PROFILES: Instructor profiles get teaching attributes; students get learning attributes
661
- * 2. DEADLINE CRAMMING: Assignments submitted on Sun/Mon are rushed and lower quality
662
- * 3. NOTES-TAKERS SUCCEED: Students who take notes during lectures score higher on quizzes
663
- * 4. STUDY GROUP RETENTION: Early study group joiners retain; non-joiners with low scores churn
664
- * 5. HINT DEPENDENCY: Hint users get locked into easy problems; non-hint users tackle harder ones
665
- * 6. SEMESTER-END SPIKE: Days 75-85 see doubled assessment activity (cramming period)
666
- * 7. FREE VS PAID COURSES: Paid subscribers convert through Course Completion funnel at ~2.2x rate (funnel-pre scoped to cert funnel only)
667
- * 8. PLAYBACK SPEED CORRELATION: Speed learners paradoxically score higher; thorough learners get extended time
668
- * 9. COURSE COMPLETION TTC: Annual subscribers complete cert funnel faster; free users slower (everything hook)
669
- * 10. SOCIAL LEARNING EXPERIMENT: A/B test on Social Learning funnel with AI Study Buddy variant (funnel experiment config)
670
- */
671
- hook: function (record, type, meta) {
672
- // HOOK 1: STUDENT VS INSTRUCTOR PROFILES (user) — role-based attributes.
673
- if (type === "user") {
674
- if (record.account_type === "instructor") {
675
- record.courses_created = chance.integer({ min: 1, max: 15 });
676
- record.teaching_experience_years = chance.integer({ min: 1, max: 20 });
677
- record.instructor_rating = Math.round((chance.floating({ min: 3.0, max: 5.0 }) + Number.EPSILON) * 100) / 100;
678
- } else {
679
- record.learning_goal = chance.pickone(["career_change", "skill_upgrade", "hobby", "degree_requirement"]);
680
- record.study_hours_per_week = chance.integer({ min: 2, max: 30 });
681
- }
682
- }
683
-
684
- // HOOK 5 (event): HINT DEPENDENCY — hint users get 60% easy problems;
685
- // non-hint users get 40% hard problems. Mutates difficulty (raw).
686
- // HOOK 8 (event): PLAYBACK SPEED — speed learners (>= 2.0x) get
687
- // watch_time_mins compressed 0.6x; thorough learners (<= 1.0x) get 1.4x.
688
- if (type === "event") {
689
- if (record.event === "practice problem solved") {
690
- if (record.hint_used === true && chance.bool({ likelihood: 60 })) {
691
- record.difficulty = "easy";
692
- } else if (record.hint_used === false && chance.bool({ likelihood: 40 })) {
693
- record.difficulty = "hard";
694
- }
695
- }
696
- if (record.event === "lecture completed") {
697
- const speed = record.playback_speed;
698
- if (speed >= 2.0 && record.watch_time_mins !== undefined) {
699
- record.watch_time_mins = Math.max(3, Math.floor(record.watch_time_mins * 0.6));
700
- } else if (speed !== undefined && speed <= 1.0 && record.watch_time_mins !== undefined) {
701
- record.watch_time_mins = Math.min(90, Math.floor(record.watch_time_mins * 1.4));
702
- }
703
- }
704
- }
705
-
706
- // HOOK 7 (funnel-pre): FREE VS PAID — free users get 0.5x conversion rate;
707
- // paid subscribers get 1.5x. Scoped to the course-completion funnel ONLY
708
- // (sequence ending in "certificate earned") to avoid displacing standalone
709
- // events for paid users and triggering unintended churn in H4.
710
- if (type === "funnel-pre") {
711
- const isCertFunnel = Array.isArray(meta?.funnel?.sequence) &&
712
- meta.funnel.sequence.includes("certificate earned");
713
- if (isCertFunnel) {
714
- const subStatus = meta?.profile?.subscription_status;
715
- if (subStatus === "free") {
716
- record.conversionRate = Math.round(record.conversionRate * 0.5);
717
- } else if (subStatus === "monthly" || subStatus === "annual") {
718
- record.conversionRate = Math.min(100, Math.round(record.conversionRate * 1.5));
719
- }
720
- }
721
- }
722
-
723
- if (type === "everything") {
724
- const datasetStart = dayjs.unix(meta.datasetStart);
725
- const userEvents = record;
726
- const profile = meta.profile;
727
- const firstEventTime = userEvents.length > 0 ? dayjs(userEvents[0].time) : null;
728
-
729
- if (profile) {
730
- userEvents.forEach((event) => {
731
- if (profile.Platform !== undefined) event.Platform = profile.Platform;
732
- });
733
- }
734
-
735
- let notesTakenCount = 0;
736
- let joinedStudyGroupEarly = false;
737
- let hasLowQuizScore = false;
738
- let speedLectureCount = 0;
739
-
740
- userEvents.forEach((event) => {
741
- const eventTime = dayjs(event.time);
742
- const daysSinceStart = firstEventTime ? eventTime.diff(firstEventTime, 'days', true) : 0;
743
- if (event.event === "lecture completed" && event.notes_taken === true) notesTakenCount++;
744
- if (event.event === "study group joined" && daysSinceStart <= 10) joinedStudyGroupEarly = true;
745
- if (event.event === "quiz completed" && event.score_percent < 60) hasLowQuizScore = true;
746
- if (event.event === "lecture completed" && event.playback_speed >= 2.0) speedLectureCount++;
747
- });
748
-
749
- // HOOK 3 + HOOK 10: NOTES MAGIC NUMBER (in-funnel, no flags)
750
- // Sweet 5-8 notes-taken lectures → +30% quiz score_percent (cap 100).
751
- // Over 9+ → drop 35% of certificate-earned events (over-noted but
752
- // can't synthesize; gets stuck in "study mode").
753
- if (notesTakenCount >= 5 && notesTakenCount <= 8) {
754
- userEvents.forEach((event) => {
755
- if (event.event === "quiz completed" && event.score_percent !== undefined) {
756
- event.score_percent = Math.min(100, Math.round(event.score_percent * 1.3));
757
- }
758
- });
759
- if (chance.bool({ likelihood: 40 })) {
760
- const lastEvent = userEvents[userEvents.length - 1];
761
- const certTemplate = userEvents.find(e => e.event === "certificate earned");
762
- if (lastEvent && certTemplate) {
763
- userEvents.push({
764
- ...certTemplate,
765
- time: dayjs(lastEvent.time).add(chance.integer({ min: 1, max: 5 }), 'days').toISOString(),
766
- user_id: lastEvent.user_id,
767
- course_id: chance.pickone(courseIds),
768
- completion_time_days: chance.integer({ min: 14, max: 90 }),
769
- final_grade: chance.integer({ min: 80, max: 100 }),
770
- });
771
- }
772
- }
773
- } else if (notesTakenCount >= 9) {
774
- // Over-noters: drop 35% of certificates (stuck in study mode)
775
- for (let i = userEvents.length - 1; i >= 0; i--) {
776
- if (userEvents[i].event === "certificate earned" && chance.bool({ likelihood: 35 })) {
777
- userEvents.splice(i, 1);
778
- }
779
- }
780
- }
781
-
782
- // HOOK 8 (cont): Speed learners (3+ lectures at 2.0x) score +8 on quizzes.
783
- if (speedLectureCount >= 3) {
784
- userEvents.forEach((event) => {
785
- if (event.event === "quiz completed" && event.score_percent !== undefined) {
786
- event.score_percent = Math.min(100, event.score_percent + 8);
787
- }
788
- });
789
- }
790
-
791
- // HOOK 6: SEMESTER-END SPIKE — duplicate quiz/assignment events
792
- // in days 75-85 window. No flag — discover via line chart.
793
- const duplicates = [];
794
- const spikableEvents = ["quiz started", "quiz completed", "assignment submitted"];
795
- userEvents.forEach((event) => {
796
- if (spikableEvents.includes(event.event) && event.time) {
797
- const dayInDataset = dayjs.utc(event.time).diff(datasetStart, 'days', true);
798
- if (dayInDataset >= 75 && dayInDataset <= 85 && chance.bool({ likelihood: 80 })) {
799
- const dup = JSON.parse(JSON.stringify(event));
800
- dup.time = dayjs(event.time).add(chance.integer({ min: 5, max: 120 }), 'minutes').toISOString();
801
- duplicates.push(dup);
802
- }
803
- }
804
- });
805
- if (duplicates.length > 0) userEvents.push(...duplicates);
806
-
807
- const subStatus = profile ? profile.subscription_status : "free";
808
-
809
- // HOOK 9 (T2C): COURSE COMPLETION TIME-TO-CONVERT (everything)
810
- // Annual subscribers complete the cert funnel 2x faster (factor 0.5);
811
- // Free users 1.8x slower (factor 1.8). For each "certificate earned"
812
- // event, find the nearest preceding "course enrolled" and scale the gap.
813
- // Runs BEFORE cert-dropping (H7) so TTC adjustments aren't masked by
814
- // survivorship bias from the 55% free cert removal.
815
- {
816
- const ttcFactor = (
817
- subStatus === "annual" ? 0.5 :
818
- subStatus === "free" ? 1.8 :
819
- 1.0
820
- );
821
- if (ttcFactor !== 1.0) {
822
- // Collect all "course enrolled" times (sorted) for binary lookup
823
- const enrolledTimes = userEvents
824
- .filter(e => e.event === "course enrolled")
825
- .map(e => dayjs(e.time))
826
- .sort((a, b) => a.valueOf() - b.valueOf());
827
-
828
- if (enrolledTimes.length > 0) {
829
- for (const event of userEvents) {
830
- if (event.event === "certificate earned") {
831
- const certTime = dayjs(event.time);
832
- // Find the latest enrolled time before this cert
833
- let anchor = null;
834
- for (let k = enrolledTimes.length - 1; k >= 0; k--) {
835
- if (enrolledTimes[k].isBefore(certTime)) {
836
- anchor = enrolledTimes[k];
837
- break;
838
- }
839
- }
840
- if (anchor) {
841
- const gap = certTime.diff(anchor);
842
- const newGap = Math.round(gap * ttcFactor);
843
- event.time = anchor.add(newGap, "milliseconds").toISOString();
844
- }
845
- }
846
- }
847
- }
848
- }
849
- }
850
-
851
- // HOOK 7: FREE VS PAID — free users lose 55% of certificates.
852
- if (subStatus === "free") {
853
- for (let i = userEvents.length - 1; i >= 0; i--) {
854
- if (userEvents[i].event === "certificate earned" && chance.bool({ likelihood: 55 })) {
855
- userEvents.splice(i, 1);
856
- }
857
- }
858
- }
859
-
860
- // HOOK 4: STUDY GROUP RETENTION — non-joiners with low scores lose
861
- // all post-day-14 events. Joiners get extra cloned discussion events.
862
- if (!joinedStudyGroupEarly && hasLowQuizScore) {
863
- const churnCutoff = firstEventTime ? firstEventTime.add(14, 'days') : null;
864
- for (let i = userEvents.length - 1; i >= 0; i--) {
865
- if (churnCutoff && dayjs(userEvents[i].time).isAfter(churnCutoff)) {
866
- userEvents.splice(i, 1);
867
- }
868
- }
869
- } else if (joinedStudyGroupEarly) {
870
- const lastEvent = userEvents[userEvents.length - 1];
871
- const discussionTemplate = userEvents.find(e => e.event === "discussion posted");
872
- if (lastEvent && discussionTemplate && chance.bool({ likelihood: 60 })) {
873
- userEvents.push({
874
- ...discussionTemplate,
875
- time: dayjs(lastEvent.time).add(chance.integer({ min: 1, max: 3 }), 'days').toISOString(),
876
- user_id: lastEvent.user_id,
877
- course_id: chance.pickone(courseIds),
878
- post_type: chance.pickone(["question", "answer", "comment"]),
879
- word_count: chance.integer({ min: 20, max: 400 }),
880
- });
881
- }
882
- }
883
-
884
- // HOOK 2: DEADLINE CRAMMING — Sun/Mon assignment_submitted events
885
- // flip is_late to true 60% of the time and quiz_completed score_percent
886
- // drops 25 points. Mutates raw is_late + score_percent.
887
- for (const event of userEvents) {
888
- if (event.event === "assignment submitted" && event.time) {
889
- const dow = new Date(event.time).getUTCDay();
890
- if (dow === 0 || dow === 1) {
891
- event.is_late = chance.bool({ likelihood: 60 });
892
- }
893
- }
894
- }
895
- userEvents.forEach((event) => {
896
- if (event.event === "quiz completed" && event.time) {
897
- const dow = new Date(event.time).getUTCDay();
898
- if ((dow === 0 || dow === 1) && event.score_percent !== undefined) {
899
- event.score_percent = Math.max(0, event.score_percent - 25);
900
- }
901
- }
902
- });
903
- }
904
-
955
+ hook(record, type, meta) {
956
+ if (type === "user") return handleUserHooks(record);
957
+ if (type === "event") return handleEventHooks(record);
958
+ if (type === "funnel-pre") return handleFunnelPreHooks(record, meta);
959
+ if (type === "everything") return handleEverythingHooks(record, meta);
905
960
  return record;
906
961
  }
907
962
  };