@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.
- package/.claude/skills/create-dungeon/SKILL.md +139 -46
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +31 -6
- package/.claude/skills/verify-dungeon/references/sql-recipes.md +44 -25
- package/.claude/skills/write-hooks/SKILL.md +31 -3
- package/CHANGELOG.md +85 -0
- package/HOOKS.md +13 -0
- package/dungeons/technical/ad-spend.js +41 -49
- package/dungeons/technical/anonymous-users.js +38 -36
- package/dungeons/technical/array-of-object-lookup.js +136 -153
- package/dungeons/technical/datagen-v15-verify.js +24 -11
- package/dungeons/technical/experiments.js +42 -40
- package/dungeons/technical/foobar.js +114 -118
- package/dungeons/technical/group-analytics.js +42 -40
- package/dungeons/technical/hook-helpers-verify.js +69 -50
- package/dungeons/technical/identity-model-verify.js +22 -12
- package/dungeons/technical/mirror-strategies.js +37 -39
- package/dungeons/technical/nested-objects.js +119 -118
- package/dungeons/technical/pattern-aggregate-by-bin.js +21 -8
- package/dungeons/technical/pattern-attributed-by-source.js +23 -9
- package/dungeons/technical/pattern-frequency-by-frequency.js +21 -8
- package/dungeons/technical/pattern-funnel-frequency.js +30 -15
- package/dungeons/technical/pattern-ttc-by-segment.js +21 -8
- package/dungeons/technical/retention-cadence.js +115 -112
- package/dungeons/technical/sanity.js +86 -80
- package/dungeons/technical/scale-test.js +34 -38
- package/dungeons/technical/scd.js +111 -128
- package/dungeons/technical/simple.js +134 -141
- package/dungeons/technical/simplest.js +54 -62
- package/dungeons/technical/text-generation.js +110 -146
- package/dungeons/vertical/ai-platform.js +296 -333
- package/dungeons/vertical/community.js +284 -255
- package/dungeons/vertical/crypto.js +395 -391
- package/dungeons/vertical/dating.js +411 -378
- package/dungeons/vertical/devtools.js +336 -298
- package/dungeons/vertical/ecommerce.js +316 -394
- package/dungeons/vertical/education.js +369 -325
- package/dungeons/vertical/fintech.js +358 -325
- package/dungeons/vertical/fitness.js +335 -291
- package/dungeons/vertical/food-delivery.js +343 -307
- package/dungeons/vertical/gaming.js +480 -444
- package/dungeons/vertical/healthcare.js +306 -262
- package/dungeons/vertical/insurance-application.js +427 -409
- package/dungeons/vertical/logistics.js +271 -252
- package/dungeons/vertical/marketplace.js +333 -323
- package/dungeons/vertical/media.js +382 -335
- package/dungeons/vertical/real-estate.js +395 -346
- package/dungeons/vertical/sass.js +319 -333
- package/dungeons/vertical/social.js +368 -316
- package/dungeons/vertical/travel.js +297 -295
- package/index.js +46 -4
- package/lib/core/config-validator.js +126 -28
- package/lib/generators/funnels.js +4 -1
- package/lib/orchestrators/mixpanel-sender.js +7 -0
- package/lib/orchestrators/user-loop.js +132 -31
- package/lib/templates/defaults.js +59 -59
- package/lib/templates/macro-presets.js +14 -2
- package/lib/utils/dataset-context.js +103 -0
- package/lib/utils/retention-curve.js +140 -0
- package/lib/utils/utils.js +149 -38
- package/lib/verify/counting.js +40 -0
- package/lib/verify/emulate-breakdown.js +20 -1
- package/lib/verify/index.js +1 -0
- package/lib/verify/schema-validator.js +3 -1
- package/package.json +11 -2
- package/scripts/run-dungeon.mjs +12 -1
- package/types.d.ts +117 -1
|
@@ -1,64 +1,50 @@
|
|
|
1
|
-
// ──
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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 registered → course enrolled → lecture started (75%)
|
|
30
|
+
* - Learning Loop: lecture started → lecture completed → practice problem solved (70%, reentry)
|
|
31
|
+
* - Assessment: quiz started → quiz completed → assignment submitted (55%, reentry)
|
|
32
|
+
* - Course Completion: course enrolled → lecture completed → quiz completed → certificate earned (30%)
|
|
33
|
+
* - Social Learning: discussion posted → study group joined → resource downloaded (50%, AI Study Buddy A/B)
|
|
34
|
+
* - Instructor Interaction: assignment submitted → assignment graded → instructor 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
|
-
*
|
|
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
|
|
334
|
-
* Social Learning Exp
|
|
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:
|
|
351
|
-
datasetEnd:
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
};
|