@ak--47/dungeon-master 1.5.0 → 1.5.2

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 (66) hide show
  1. package/.claude/skills/create-dungeon/SKILL.md +139 -46
  2. package/.claude/skills/verify-dungeon/references/counting-semantics.md +31 -6
  3. package/.claude/skills/verify-dungeon/references/sql-recipes.md +44 -25
  4. package/.claude/skills/write-hooks/SKILL.md +31 -3
  5. package/CHANGELOG.md +85 -0
  6. package/HOOKS.md +13 -0
  7. package/dungeons/technical/ad-spend.js +41 -49
  8. package/dungeons/technical/anonymous-users.js +38 -36
  9. package/dungeons/technical/array-of-object-lookup.js +136 -153
  10. package/dungeons/technical/datagen-v15-verify.js +24 -11
  11. package/dungeons/technical/experiments.js +42 -40
  12. package/dungeons/technical/foobar.js +114 -118
  13. package/dungeons/technical/group-analytics.js +42 -40
  14. package/dungeons/technical/hook-helpers-verify.js +69 -50
  15. package/dungeons/technical/identity-model-verify.js +22 -12
  16. package/dungeons/technical/mirror-strategies.js +37 -39
  17. package/dungeons/technical/nested-objects.js +119 -118
  18. package/dungeons/technical/pattern-aggregate-by-bin.js +21 -8
  19. package/dungeons/technical/pattern-attributed-by-source.js +23 -9
  20. package/dungeons/technical/pattern-frequency-by-frequency.js +21 -8
  21. package/dungeons/technical/pattern-funnel-frequency.js +30 -15
  22. package/dungeons/technical/pattern-ttc-by-segment.js +21 -8
  23. package/dungeons/technical/retention-cadence.js +115 -112
  24. package/dungeons/technical/sanity.js +86 -80
  25. package/dungeons/technical/scale-test.js +34 -38
  26. package/dungeons/technical/scd.js +111 -128
  27. package/dungeons/technical/simple.js +134 -141
  28. package/dungeons/technical/simplest.js +54 -62
  29. package/dungeons/technical/text-generation.js +110 -146
  30. package/dungeons/vertical/ai-platform.js +296 -333
  31. package/dungeons/vertical/community.js +284 -255
  32. package/dungeons/vertical/crypto.js +395 -391
  33. package/dungeons/vertical/dating.js +411 -378
  34. package/dungeons/vertical/devtools.js +336 -298
  35. package/dungeons/vertical/ecommerce.js +316 -394
  36. package/dungeons/vertical/education.js +369 -325
  37. package/dungeons/vertical/fintech.js +358 -325
  38. package/dungeons/vertical/fitness.js +335 -291
  39. package/dungeons/vertical/food-delivery.js +343 -307
  40. package/dungeons/vertical/gaming.js +480 -444
  41. package/dungeons/vertical/healthcare.js +306 -262
  42. package/dungeons/vertical/insurance-application.js +427 -409
  43. package/dungeons/vertical/logistics.js +271 -252
  44. package/dungeons/vertical/marketplace.js +333 -323
  45. package/dungeons/vertical/media.js +382 -335
  46. package/dungeons/vertical/real-estate.js +395 -346
  47. package/dungeons/vertical/sass.js +319 -333
  48. package/dungeons/vertical/social.js +368 -316
  49. package/dungeons/vertical/travel.js +297 -295
  50. package/index.js +46 -4
  51. package/lib/core/config-validator.js +126 -28
  52. package/lib/generators/funnels.js +4 -1
  53. package/lib/orchestrators/mixpanel-sender.js +7 -0
  54. package/lib/orchestrators/user-loop.js +132 -31
  55. package/lib/templates/defaults.js +59 -59
  56. package/lib/templates/macro-presets.js +14 -2
  57. package/lib/utils/dataset-context.js +103 -0
  58. package/lib/utils/retention-curve.js +140 -0
  59. package/lib/utils/utils.js +149 -38
  60. package/lib/verify/counting.js +40 -0
  61. package/lib/verify/emulate-breakdown.js +20 -1
  62. package/lib/verify/index.js +1 -0
  63. package/lib/verify/schema-validator.js +3 -1
  64. package/package.json +11 -2
  65. package/scripts/run-dungeon.mjs +12 -1
  66. package/types.d.ts +117 -1
@@ -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
 
@@ -663,256 +952,11 @@ const config = {
663
952
 
664
953
  lookupTables: [],
665
954
 
666
- /**
667
- * ARCHITECTED ANALYTICS HOOKS
668
- *
669
- * This hook function creates 10 deliberate patterns in the data:
670
- *
671
- * 1. STUDENT VS INSTRUCTOR PROFILES: Instructor profiles get teaching attributes; students get learning attributes
672
- * 2. DEADLINE CRAMMING: Assignments submitted on Sun/Mon are rushed and lower quality
673
- * 3. NOTES-TAKERS SUCCEED: Students who take notes during lectures score higher on quizzes
674
- * 4. STUDY GROUP RETENTION: Early study group joiners retain; non-joiners with low scores churn
675
- * 5. HINT DEPENDENCY: Hint users get locked into easy problems; non-hint users tackle harder ones
676
- * 6. SEMESTER-END SPIKE: Days 75-85 see doubled assessment activity (cramming period)
677
- * 7. FREE VS PAID COURSES: Paid subscribers convert through Course Completion funnel at ~2.2x rate (funnel-pre scoped to cert funnel only)
678
- * 8. PLAYBACK SPEED CORRELATION: Speed learners paradoxically score higher; thorough learners get extended time
679
- * 9. COURSE COMPLETION TTC: Annual subscribers complete cert funnel faster; free users slower (everything hook)
680
- * 10. SOCIAL LEARNING EXPERIMENT: A/B test on Social Learning funnel with AI Study Buddy variant (funnel experiment config)
681
- */
682
- hook: function (record, type, meta) {
683
- // HOOK 1: STUDENT VS INSTRUCTOR PROFILES (user) — role-based attributes.
684
- if (type === "user") {
685
- if (record.account_type === "instructor") {
686
- record.courses_created = chance.integer({ min: 1, max: 15 });
687
- record.teaching_experience_years = chance.integer({ min: 1, max: 20 });
688
- record.instructor_rating = Math.round((chance.floating({ min: 3.0, max: 5.0 }) + Number.EPSILON) * 100) / 100;
689
- } else {
690
- record.learning_goal = chance.pickone(["career_change", "skill_upgrade", "hobby", "degree_requirement"]);
691
- record.study_hours_per_week = chance.integer({ min: 2, max: 30 });
692
- }
693
- }
694
-
695
- // HOOK 5 (event): HINT DEPENDENCY — hint users get 60% easy problems;
696
- // non-hint users get 40% hard problems. Mutates difficulty (raw).
697
- // HOOK 8 (event): PLAYBACK SPEED — speed learners (>= 2.0x) get
698
- // watch_time_mins compressed 0.6x; thorough learners (<= 1.0x) get 1.4x.
699
- if (type === "event") {
700
- if (record.event === "practice problem solved") {
701
- if (record.hint_used === true && chance.bool({ likelihood: 60 })) {
702
- record.difficulty = "easy";
703
- } else if (record.hint_used === false && chance.bool({ likelihood: 40 })) {
704
- record.difficulty = "hard";
705
- }
706
- }
707
- if (record.event === "lecture completed") {
708
- const speed = record.playback_speed;
709
- if (speed >= 2.0 && record.watch_time_mins !== undefined) {
710
- record.watch_time_mins = Math.max(3, Math.floor(record.watch_time_mins * 0.6));
711
- } else if (speed !== undefined && speed <= 1.0 && record.watch_time_mins !== undefined) {
712
- record.watch_time_mins = Math.min(90, Math.floor(record.watch_time_mins * 1.4));
713
- }
714
- }
715
- }
716
-
717
- // HOOK 7 (funnel-pre): FREE VS PAID — free users get 0.5x conversion rate;
718
- // paid subscribers get 1.5x. Scoped to the course-completion funnel ONLY
719
- // (sequence ending in "certificate earned") to avoid displacing standalone
720
- // events for paid users and triggering unintended churn in H4.
721
- if (type === "funnel-pre") {
722
- const isCertFunnel = Array.isArray(meta?.funnel?.sequence) &&
723
- meta.funnel.sequence.includes("certificate earned");
724
- if (isCertFunnel) {
725
- const subStatus = meta?.profile?.subscription_status;
726
- if (subStatus === "free") {
727
- record.conversionRate = Math.round(record.conversionRate * 0.5);
728
- } else if (subStatus === "monthly" || subStatus === "annual") {
729
- record.conversionRate = Math.min(100, Math.round(record.conversionRate * 1.5));
730
- }
731
- }
732
- }
733
-
734
- if (type === "everything") {
735
- const datasetStart = dayjs.unix(meta.datasetStart);
736
- const userEvents = record;
737
- const profile = meta.profile;
738
- const firstEventTime = userEvents.length > 0 ? dayjs(userEvents[0].time) : null;
739
-
740
- if (profile) {
741
- userEvents.forEach((event) => {
742
- if (profile.Platform !== undefined) event.Platform = profile.Platform;
743
- });
744
- }
745
-
746
- let notesTakenCount = 0;
747
- let joinedStudyGroupEarly = false;
748
- let hasLowQuizScore = false;
749
- let speedLectureCount = 0;
750
-
751
- userEvents.forEach((event) => {
752
- const eventTime = dayjs(event.time);
753
- const daysSinceStart = firstEventTime ? eventTime.diff(firstEventTime, 'days', true) : 0;
754
- if (event.event === "lecture completed" && event.notes_taken === true) notesTakenCount++;
755
- if (event.event === "study group joined" && daysSinceStart <= 10) joinedStudyGroupEarly = true;
756
- if (event.event === "quiz completed" && event.score_percent < 60) hasLowQuizScore = true;
757
- if (event.event === "lecture completed" && event.playback_speed >= 2.0) speedLectureCount++;
758
- });
759
-
760
- // HOOK 3 + HOOK 10: NOTES MAGIC NUMBER (in-funnel, no flags)
761
- // Sweet 5-8 notes-taken lectures → +30% quiz score_percent (cap 100).
762
- // Over 9+ → drop 35% of certificate-earned events (over-noted but
763
- // can't synthesize; gets stuck in "study mode").
764
- if (notesTakenCount >= 5 && notesTakenCount <= 8) {
765
- userEvents.forEach((event) => {
766
- if (event.event === "quiz completed" && event.score_percent !== undefined) {
767
- event.score_percent = Math.min(100, Math.round(event.score_percent * 1.3));
768
- }
769
- });
770
- if (chance.bool({ likelihood: 40 })) {
771
- const lastEvent = userEvents[userEvents.length - 1];
772
- const certTemplate = userEvents.find(e => e.event === "certificate earned");
773
- if (lastEvent && certTemplate) {
774
- userEvents.push({
775
- ...certTemplate,
776
- time: dayjs(lastEvent.time).add(chance.integer({ min: 1, max: 5 }), 'days').toISOString(),
777
- user_id: lastEvent.user_id,
778
- course_id: chance.pickone(courseIds),
779
- completion_time_days: chance.integer({ min: 14, max: 90 }),
780
- final_grade: chance.integer({ min: 80, max: 100 }),
781
- });
782
- }
783
- }
784
- } else if (notesTakenCount >= 9) {
785
- // Over-noters: drop 35% of certificates (stuck in study mode)
786
- for (let i = userEvents.length - 1; i >= 0; i--) {
787
- if (userEvents[i].event === "certificate earned" && chance.bool({ likelihood: 35 })) {
788
- userEvents.splice(i, 1);
789
- }
790
- }
791
- }
792
-
793
- // HOOK 8 (cont): Speed learners (3+ lectures at 2.0x) score +8 on quizzes.
794
- if (speedLectureCount >= 3) {
795
- userEvents.forEach((event) => {
796
- if (event.event === "quiz completed" && event.score_percent !== undefined) {
797
- event.score_percent = Math.min(100, event.score_percent + 8);
798
- }
799
- });
800
- }
801
-
802
- // HOOK 6: SEMESTER-END SPIKE — duplicate quiz/assignment events
803
- // in days 75-85 window. No flag — discover via line chart.
804
- const duplicates = [];
805
- const spikableEvents = ["quiz started", "quiz completed", "assignment submitted"];
806
- userEvents.forEach((event) => {
807
- if (spikableEvents.includes(event.event) && event.time) {
808
- const dayInDataset = dayjs.utc(event.time).diff(datasetStart, 'days', true);
809
- if (dayInDataset >= 75 && dayInDataset <= 85 && chance.bool({ likelihood: 80 })) {
810
- const dup = JSON.parse(JSON.stringify(event));
811
- dup.time = dayjs(event.time).add(chance.integer({ min: 5, max: 120 }), 'minutes').toISOString();
812
- duplicates.push(dup);
813
- }
814
- }
815
- });
816
- if (duplicates.length > 0) userEvents.push(...duplicates);
817
-
818
- const subStatus = profile ? profile.subscription_status : "free";
819
-
820
- // HOOK 9 (T2C): COURSE COMPLETION TIME-TO-CONVERT (everything)
821
- // Annual subscribers complete the cert funnel 2x faster (factor 0.5);
822
- // Free users 1.8x slower (factor 1.8). For each "certificate earned"
823
- // event, find the nearest preceding "course enrolled" and scale the gap.
824
- // Runs BEFORE cert-dropping (H7) so TTC adjustments aren't masked by
825
- // survivorship bias from the 55% free cert removal.
826
- {
827
- const ttcFactor = (
828
- subStatus === "annual" ? 0.5 :
829
- subStatus === "free" ? 1.8 :
830
- 1.0
831
- );
832
- if (ttcFactor !== 1.0) {
833
- // Collect all "course enrolled" times (sorted) for binary lookup
834
- const enrolledTimes = userEvents
835
- .filter(e => e.event === "course enrolled")
836
- .map(e => dayjs(e.time))
837
- .sort((a, b) => a.valueOf() - b.valueOf());
838
-
839
- if (enrolledTimes.length > 0) {
840
- for (const event of userEvents) {
841
- if (event.event === "certificate earned") {
842
- const certTime = dayjs(event.time);
843
- // Find the latest enrolled time before this cert
844
- let anchor = null;
845
- for (let k = enrolledTimes.length - 1; k >= 0; k--) {
846
- if (enrolledTimes[k].isBefore(certTime)) {
847
- anchor = enrolledTimes[k];
848
- break;
849
- }
850
- }
851
- if (anchor) {
852
- const gap = certTime.diff(anchor);
853
- const newGap = Math.round(gap * ttcFactor);
854
- event.time = anchor.add(newGap, "milliseconds").toISOString();
855
- }
856
- }
857
- }
858
- }
859
- }
860
- }
861
-
862
- // HOOK 7: FREE VS PAID — free users lose 55% of certificates.
863
- if (subStatus === "free") {
864
- for (let i = userEvents.length - 1; i >= 0; i--) {
865
- if (userEvents[i].event === "certificate earned" && chance.bool({ likelihood: 55 })) {
866
- userEvents.splice(i, 1);
867
- }
868
- }
869
- }
870
-
871
- // HOOK 4: STUDY GROUP RETENTION — non-joiners with low scores lose
872
- // all post-day-14 events. Joiners get extra cloned discussion events.
873
- if (!joinedStudyGroupEarly && hasLowQuizScore) {
874
- const churnCutoff = firstEventTime ? firstEventTime.add(14, 'days') : null;
875
- for (let i = userEvents.length - 1; i >= 0; i--) {
876
- if (churnCutoff && dayjs(userEvents[i].time).isAfter(churnCutoff)) {
877
- userEvents.splice(i, 1);
878
- }
879
- }
880
- } else if (joinedStudyGroupEarly) {
881
- const lastEvent = userEvents[userEvents.length - 1];
882
- const discussionTemplate = userEvents.find(e => e.event === "discussion posted");
883
- if (lastEvent && discussionTemplate && chance.bool({ likelihood: 60 })) {
884
- userEvents.push({
885
- ...discussionTemplate,
886
- time: dayjs(lastEvent.time).add(chance.integer({ min: 1, max: 3 }), 'days').toISOString(),
887
- user_id: lastEvent.user_id,
888
- course_id: chance.pickone(courseIds),
889
- post_type: chance.pickone(["question", "answer", "comment"]),
890
- word_count: chance.integer({ min: 20, max: 400 }),
891
- });
892
- }
893
- }
894
-
895
- // HOOK 2: DEADLINE CRAMMING — Sun/Mon assignment_submitted events
896
- // flip is_late to true 60% of the time and quiz_completed score_percent
897
- // drops 25 points. Mutates raw is_late + score_percent.
898
- for (const event of userEvents) {
899
- if (event.event === "assignment submitted" && event.time) {
900
- const dow = new Date(event.time).getUTCDay();
901
- if (dow === 0 || dow === 1) {
902
- event.is_late = chance.bool({ likelihood: 60 });
903
- }
904
- }
905
- }
906
- userEvents.forEach((event) => {
907
- if (event.event === "quiz completed" && event.time) {
908
- const dow = new Date(event.time).getUTCDay();
909
- if ((dow === 0 || dow === 1) && event.score_percent !== undefined) {
910
- event.score_percent = Math.max(0, event.score_percent - 25);
911
- }
912
- }
913
- });
914
- }
915
-
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);
916
960
  return record;
917
961
  }
918
962
  };