@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,57 +1,51 @@
1
- // ── TWEAK THESE ──
2
- const SEED = "harness-media";
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
8
  import { findFirstSequence, scaleFunnelTTC } from "../../lib/hook-helpers/timing.js";
17
-
18
- dayjs.extend(utc);
19
- const chance = u.initChance(SEED);
20
-
21
9
  /** @typedef {import("../../types").Dungeon} Config */
22
10
 
23
- /**
24
- * ═══════════════════════════════════════════════════════════════════════════════
25
- * DATASET OVERVIEW — STREAMVAULT VIDEO STREAMING PLATFORM
26
- * ═══════════════════════════════════════════════════════════════════════════════
27
- *
28
- * StreamVault is a Netflix/Hulu-style video streaming platform where users browse a rich
29
- * catalog of movies, series, documentaries, and specials. Users manage watchlists, watch
30
- * content with configurable playback options, rate and share content, and manage family
31
- * profiles under a single account.
32
- *
33
- * - 10,000 users over 120 days
34
- * - ~600,000 events across 17 event types
35
- * - 9 funnels (onboarding, content discovery, engagement loop, search, watchlist, etc.)
36
- * - Subscription tiers: Free (ad-supported), Standard ($9.99/mo), Premium ($14.99/mo)
37
- * - Device types: Smart TV, Mobile, Tablet, Laptop, Desktop
38
- * - Content catalog: 500 titles with genres, types, and a blockbuster release event
39
- *
40
- * Core loop: onboarding -> discovery -> consumption -> engagement -> monetization.
41
- * Users land on a personalized home screen, discover content via browse/search/recommendations,
42
- * watch with quality and subtitle options, rate and share, and manage subscriptions.
43
- * Profile switching (main, kids, partner, guest) reveals household dynamics.
11
+ // ── OVERVIEW ──
12
+ /*
13
+ * NAME: StreamVault
14
+ * APP: Netflix/Hulu-style video streaming platform. Users browse a
15
+ * catalog of movies, series, documentaries and specials, manage
16
+ * watchlists, watch with configurable quality/subtitle/speed,
17
+ * rate and share content, and switch household profiles (main,
18
+ * kids, partner, guest) under a single account.
19
+ * SCALE: 10,000 users, ~600K events, 121 days (2026-01-01 → 2026-05-01)
20
+ * CORE LOOP: account created → content browsed → content selected → playback started → playback completed
21
+ *
22
+ * EVENTS (17):
23
+ * content browsed (20) > playback started (18) > content selected (15)
24
+ * > playback completed (12) > playback paused (10) > recommendation clicked (9)
25
+ * > watchlist added (8) > ad impression (8) > search performed (7)
26
+ * > content rated (6) > download started (5) > profile switched (4)
27
+ * > subtitle toggled (4) > watchlist removed (3) > share content (3)
28
+ * > subscription changed (2) > account created (1)
29
+ *
30
+ * FUNNELS (9):
31
+ * - Account to Playback: account created content browsed playback started (80%)
32
+ * - Core Viewing Loop: content browsed → content selected → playback started → playback completed (55%)
33
+ * - Recommendation Driven: recommendation clicked → playback started → playback completed → content rated (35%)
34
+ * - Search Discovery: search performed → content selected → playback started (50%)
35
+ * - Watchlist Management: content browsed → watchlist added → content selected → playback started (40%)
36
+ * - Profile + Subtitle: profile switched → subtitle toggled → playback started → playback completed (45%)
37
+ * - Ad Experience: ad impression → playback started → playback paused (60%)
38
+ * - Share + Download: playback completed → share content → download started (25%)
39
+ * - Subscription Change: content browsed → subscription changed (15%)
40
+ *
41
+ * USER PROPS: preferred_genre, avg_session_duration_min, total_watch_hours, profiles_count, downloads_enabled, subscription_plan, device_type
42
+ * SUPER PROPS: subscription_plan, device_type
43
+ * SCD PROPS: subscription_plan (free/basic/standard/premium, monthly fuzzy, max 6)
44
+ * GROUPS: none
44
45
  */
45
46
 
46
- /**
47
- * ═══════════════════════════════════════════════════════════════════════════════
48
- * ANALYTICS HOOKS (10 hooks)
49
- *
50
- * Adds 10. CORE VIEWING LOOP: premium 0.67x watch_duration_min, free 1.4x
51
- * (property scaling in everything hook). Discover via Insights → playback
52
- * completed → Avg watch_duration_min → breakdown subscription_plan.
53
- * ═══════════════════════════════════════════════════════════════════════════════
54
- *
47
+ // ── HOOK STORIES ──
48
+ /*
55
49
  * NOTE: All cohort effects are HIDDEN — discoverable only via behavioral cohorts
56
50
  * (count an event per user, then measure downstream). No cohort flag is stamped
57
51
  * on events. Time-window patterns mutate config-defined props or drop events.
@@ -294,35 +288,355 @@ const chance = u.initChance(SEED);
294
288
  * Core Viewing Loop | free/premium duration | 1x | >= 2x | 2.09x
295
289
  */
296
290
 
297
- // Generate consistent content IDs for lookup tables and events
291
+ // ── SCALE ──
292
+ const SEED = "harness-media";
293
+ const NUM_USERS = 10_000;
294
+ const DATASET_START = "2026-01-01T00:00:00Z";
295
+ const DATASET_END = "2026-05-01T23:59:59Z";
296
+ const EVENTS_PER_DAY = 1.2;
297
+ const token = process.env.MP_TOKEN || "your-mixpanel-token";
298
+
299
+ const chance = u.initChance(SEED);
300
+
301
+ // ── KNOBS (tweak these to reshape stories) ──
302
+ const KIDS_RESTRICT_LIKELIHOOD = 15;
303
+
304
+ const PLAN_TTC_PREMIUM = 0.67;
305
+ const PLAN_TTC_FREE = 1.4;
306
+
307
+ const BLOCKBUSTER_START_DAY = 50;
308
+ const BLOCKBUSTER_END_DAY = 65;
309
+ const BLOCKBUSTER_SWAP_LIKELIHOOD = 20;
310
+ const BLOCKBUSTER_RATING_LIKELIHOOD = 20;
311
+
312
+ const WEEKEND_WATCH_MULT = 1.5;
313
+
314
+ const DOC_DROP_LIKELIHOOD = 25;
315
+
316
+ const REC_IMPROVEMENT_DAY = 60;
317
+ const REC_IMPROVEMENT_DROP_LIKELIHOOD = 30;
318
+
319
+ const BINGE_STREAK_THRESHOLD = 3;
320
+ const BINGE_PAUSE_DROP_LIKELIHOOD = 60;
321
+ const BINGE_CLONE_LIKELIHOOD = 40;
322
+
323
+ const SUBTITLE_COMPLETION_MULT = 1.25;
324
+ const SUBTITLE_DURATION_MULT = 1.15;
325
+ const SUBTITLE_CLONE_FACTOR = 0.2;
326
+
327
+ const REC_SWEET_MIN = 4;
328
+ const REC_SWEET_MAX = 6;
329
+ const REC_OVER_THRESHOLD = 7;
330
+ const REC_SWEET_DURATION_MULT = 1.25;
331
+ const REC_OVER_DURATION_MULT = 0.5;
332
+ const REC_OVER_DROP_LIKELIHOOD = 55;
333
+
334
+ const AD_FATIGUE_THRESHOLD = 5;
335
+ const AD_FATIGUE_CUTOFF_DAYS = 45;
336
+ const AD_FATIGUE_KEEP_MODULO = 20;
337
+
338
+ // ── DATA ARRAYS ──
298
339
  const contentIds = v.range(1, 501).map(n => `content_${v.uid(8)}`);
299
340
  const blockbusterId = `blockbuster_${v.uid(8)}`;
300
341
 
342
+ // ── HELPER FUNCTIONS ──
343
+ function handleEventHooks(record) {
344
+ // H6: KIDS PROFILE SAFETY — 15% of selections/starts get genre restricted
345
+ // to animation or documentary. Mutates existing genre prop.
346
+ if (chance.bool({ likelihood: KIDS_RESTRICT_LIKELIHOOD })) {
347
+ if (record.event === "content selected" || record.event === "playback started") {
348
+ record.genre = chance.pickone(["animation", "documentary"]);
349
+ }
350
+ }
351
+ return record;
352
+ }
353
+
354
+ function handleEverythingHooks(record, meta) {
355
+ const datasetStart = dayjs.unix(meta.datasetStart);
356
+ const userEvents = record;
357
+ if (!userEvents || userEvents.length === 0) return record;
358
+
359
+ const profile = meta.profile;
360
+
361
+ // Stamp superProps from profile (consistent per user)
362
+ const stampPlan = profile && profile.subscription_plan ? profile.subscription_plan : undefined;
363
+ const stampDevice = profile && profile.device_type ? profile.device_type : undefined;
364
+ if (stampPlan || stampDevice) {
365
+ userEvents.forEach(e => {
366
+ if (stampPlan) e.subscription_plan = stampPlan;
367
+ if (stampDevice) e.device_type = stampDevice;
368
+ });
369
+ }
370
+
371
+ // HOOK 10: CORE VIEWING LOOP TTC — premium 0.67x, free 1.4x.
372
+ // Timestamp shift for Mixpanel funnel TTC + property scale for
373
+ // Insights. Applied first so weekend/subtitle/rec-click hooks
374
+ // amplify from the plan-adjusted base.
375
+ {
376
+ const plan = profile ? profile.subscription_plan : "free";
377
+ const ttcFactor = plan === "premium" ? PLAN_TTC_PREMIUM : plan === "free" ? PLAN_TTC_FREE : 1.0;
378
+ if (ttcFactor !== 1.0) {
379
+ // Timestamp shift: affects Mixpanel funnel TTC
380
+ const viewSeq = findFirstSequence(
381
+ userEvents,
382
+ ["content browsed", "content selected", "playback started", "playback completed"],
383
+ 60 * 24 * 7
384
+ );
385
+ if (viewSeq) scaleFunnelTTC(viewSeq, ttcFactor);
386
+ // Property scale: affects Insights AVG reports
387
+ for (const e of userEvents) {
388
+ if (e.event === "playback completed" && typeof e.watch_duration_min === "number") {
389
+ e.watch_duration_min = Math.round(e.watch_duration_min * ttcFactor * 10) / 10;
390
+ }
391
+ }
392
+ }
393
+ }
394
+
395
+ // Hook #5: NEW RELEASE SPIKE — days 50-65, 20% of selections/starts
396
+ // switch to the blockbuster id. Mutates existing content_id/content_type props.
397
+ const BLOCKBUSTER_START = datasetStart.add(BLOCKBUSTER_START_DAY, "days");
398
+ const BLOCKBUSTER_END = datasetStart.add(BLOCKBUSTER_END_DAY, "days");
399
+ for (const e of userEvents) {
400
+ const eventTime = dayjs(e.time);
401
+ if (eventTime.isAfter(BLOCKBUSTER_START) && eventTime.isBefore(BLOCKBUSTER_END)) {
402
+ if ((e.event === "content selected" || e.event === "playback started") && chance.bool({ likelihood: BLOCKBUSTER_SWAP_LIKELIHOOD })) {
403
+ e.content_type = "movie";
404
+ e.content_id = blockbusterId;
405
+ }
406
+ if (e.event === "content rated" && chance.bool({ likelihood: BLOCKBUSTER_RATING_LIKELIHOOD })) {
407
+ e.rating = chance.integer({ min: 4, max: 5 });
408
+ e.content_id = blockbusterId;
409
+ }
410
+ }
411
+ }
412
+
413
+ // Hook #10: CORE VIEWING LOOP — subscription_plan property scaling.
414
+ // Premium users watch more efficiently (shorter durations), free users
415
+ // linger (longer durations). Scales watch_duration_min on playback
416
+ // completed events BEFORE H3/H8/H9 so each subsequent hook amplifies
417
+ // from the plan-adjusted base.
418
+ // Discover via: Insights → playback completed → Avg watch_duration_min → breakdown subscription_plan.
419
+ {
420
+ const plan = stampPlan;
421
+ const factor = (
422
+ plan === "premium" ? PLAN_TTC_PREMIUM :
423
+ plan === "free" ? PLAN_TTC_FREE :
424
+ 1.0
425
+ );
426
+ if (factor !== 1.0) {
427
+ for (const e of userEvents) {
428
+ if (e.event === "playback completed" && typeof e.watch_duration_min === "number") {
429
+ e.watch_duration_min = Math.round(e.watch_duration_min * factor);
430
+ }
431
+ }
432
+ }
433
+ }
434
+
435
+ // Hook #3: WEEKEND VS WEEKDAY — 1.5x watch_duration_min on weekends.
436
+ // No flag — analyst breaks down by day of week.
437
+ for (const e of userEvents) {
438
+ const dow = new Date(e.time).getUTCDay();
439
+ if ((dow === 0 || dow === 6) && e.event === "playback completed" && typeof e.watch_duration_min === "number") {
440
+ e.watch_duration_min = Math.round(e.watch_duration_min * WEEKEND_WATCH_MULT);
441
+ }
442
+ }
443
+
444
+ // Hook #1: GENRE FUNNEL CONVERSION — drop 25% of documentary playback
445
+ // completed events to depress documentary funnel conversion. Raw genre check.
446
+ for (let i = userEvents.length - 1; i >= 0; i--) {
447
+ const evt = userEvents[i];
448
+ if (evt.event === "playback completed" && evt.genre === "documentary" && chance.bool({ likelihood: DOC_DROP_LIKELIHOOD })) {
449
+ userEvents.splice(i, 1);
450
+ }
451
+ }
452
+
453
+ // Hook #7: RECOMMENDATION ENGINE IMPROVEMENT — drop 30% of pre-day-60
454
+ // content rated events. Raw time check.
455
+ const IMPROVEMENT_DATE = datasetStart.add(REC_IMPROVEMENT_DAY, "days");
456
+ for (let i = userEvents.length - 1; i >= 0; i--) {
457
+ const evt = userEvents[i];
458
+ if (evt.event === "content rated" && dayjs(evt.time).isBefore(IMPROVEMENT_DATE) && chance.bool({ likelihood: REC_IMPROVEMENT_DROP_LIKELIHOOD })) {
459
+ userEvents.splice(i, 1);
460
+ }
461
+ }
462
+
463
+ const firstEventTime = dayjs(userEvents[0].time);
464
+
465
+ // Identify behavioral patterns (no flags written)
466
+ let consecutiveCompletions = 0;
467
+ let maxConsecutiveCompletions = 0;
468
+ let earlyAdCount = 0;
469
+ let hasSubtitlesEnabled = false;
470
+ let recClickCount = 0;
471
+
472
+ const adCutoff = firstEventTime.add(AD_FATIGUE_CUTOFF_DAYS, "days");
473
+ userEvents.forEach((event, idx) => {
474
+ if (event.event === "playback completed") {
475
+ consecutiveCompletions++;
476
+ if (consecutiveCompletions > maxConsecutiveCompletions) {
477
+ maxConsecutiveCompletions = consecutiveCompletions;
478
+ }
479
+ } else if (event.event !== "playback started") {
480
+ consecutiveCompletions = 0;
481
+ }
482
+ if (event.event === "ad impression" && dayjs(event.time).isBefore(adCutoff)) earlyAdCount++;
483
+ if (event.event === "subtitle toggled" && event.action === "enabled") hasSubtitlesEnabled = true;
484
+ if (event.event === "recommendation clicked") recClickCount++;
485
+ });
486
+
487
+ const isBingeWatcher = maxConsecutiveCompletions >= BINGE_STREAK_THRESHOLD;
488
+
489
+ // Hook #2: BINGE-WATCHING — drop pauses, inject extra start+complete pairs.
490
+ // Cloned events use unique offset timestamps. No flag.
491
+ if (isBingeWatcher) {
492
+ for (let i = userEvents.length - 1; i >= 0; i--) {
493
+ const event = userEvents[i];
494
+ const eventTime = dayjs(event.time);
495
+
496
+ if (event.event === "playback paused" && chance.bool({ likelihood: BINGE_PAUSE_DROP_LIKELIHOOD })) {
497
+ userEvents.splice(i, 1);
498
+ continue;
499
+ }
500
+
501
+ if (event.event === "playback completed" && chance.bool({ likelihood: BINGE_CLONE_LIKELIHOOD })) {
502
+ const nextContentId = chance.pickone(contentIds);
503
+ const startTemplate = userEvents.find(e => e.event === "playback started");
504
+ const extraStart = {
505
+ ...(startTemplate || event),
506
+ event: "playback started",
507
+ time: eventTime.add(chance.integer({ min: 1, max: 5 }), "minutes").toISOString(),
508
+ user_id: event.user_id,
509
+ content_id: nextContentId,
510
+ content_type: "series",
511
+ playback_quality: event.playback_quality || "1080p",
512
+ };
513
+ const extraComplete = {
514
+ ...event,
515
+ time: eventTime.add(chance.integer({ min: 25, max: 60 }), "minutes").toISOString(),
516
+ user_id: event.user_id,
517
+ content_id: nextContentId,
518
+ content_type: "series",
519
+ watch_duration_min: chance.integer({ min: 20, max: 55 }),
520
+ completion_percent: chance.integer({ min: 90, max: 100 }),
521
+ };
522
+ userEvents.splice(i + 1, 0, extraStart, extraComplete);
523
+ }
524
+ }
525
+ }
526
+
527
+ // Hook #8: SUBTITLE USERS WATCH MORE — 1.25x completion_percent (cap 100),
528
+ // 1.15x watch_duration_min, plus 20% extra cloned playback completions.
529
+ // No flag — discover via cohort builder on subtitle-toggled-enabled.
530
+ if (hasSubtitlesEnabled) {
531
+ for (let i = 0; i < userEvents.length; i++) {
532
+ const event = userEvents[i];
533
+ if (event.event === "playback completed") {
534
+ if (event.completion_percent) {
535
+ event.completion_percent = Math.min(100, Math.round(event.completion_percent * SUBTITLE_COMPLETION_MULT));
536
+ }
537
+ if (event.watch_duration_min) {
538
+ event.watch_duration_min = Math.round(event.watch_duration_min * SUBTITLE_DURATION_MULT);
539
+ }
540
+ }
541
+ }
542
+
543
+ const completionEvents = userEvents.filter(e => e.event === "playback completed");
544
+ const extraCount = Math.floor(completionEvents.length * SUBTITLE_CLONE_FACTOR);
545
+ for (let j = 0; j < extraCount; j++) {
546
+ const templateEvent = chance.pickone(completionEvents);
547
+ const templateTime = dayjs(templateEvent.time);
548
+ const extraCompletion = {
549
+ ...templateEvent,
550
+ time: templateTime.add(chance.integer({ min: 30, max: 180 }), "minutes").toISOString(),
551
+ user_id: templateEvent.user_id,
552
+ content_id: chance.pickone(contentIds),
553
+ content_type: chance.pickone(["movie", "series", "documentary"]),
554
+ watch_duration_min: chance.integer({ min: 25, max: 120 }),
555
+ completion_percent: chance.integer({ min: 80, max: 100 }),
556
+ };
557
+ userEvents.push(extraCompletion);
558
+ }
559
+ }
560
+
561
+ // Hook #9: RECOMMENDATION-CLICKED MAGIC NUMBER (no flags)
562
+ // Sweet 4-6 rec clicks → +25% watch_duration_min on playback completed.
563
+ // Over 7+ → halve watch_duration_min AND drop 55% of playback completed
564
+ // events (rec fatigue). Aggressive suppression overcomes the inherent
565
+ // engagement confound (high-rec-click users are naturally more active).
566
+ if (recClickCount >= REC_SWEET_MIN && recClickCount <= REC_SWEET_MAX) {
567
+ userEvents.forEach(e => {
568
+ if (e.event === "playback completed" && typeof e.watch_duration_min === "number") {
569
+ e.watch_duration_min = Math.round(e.watch_duration_min * REC_SWEET_DURATION_MULT);
570
+ }
571
+ });
572
+ } else if (recClickCount >= REC_OVER_THRESHOLD) {
573
+ for (let i = userEvents.length - 1; i >= 0; i--) {
574
+ const evt = userEvents[i];
575
+ if (evt.event === "playback completed") {
576
+ // Halve watch duration for surviving events
577
+ if (typeof evt.watch_duration_min === "number") {
578
+ evt.watch_duration_min = Math.round(evt.watch_duration_min * REC_OVER_DURATION_MULT);
579
+ }
580
+ // Drop 55% of completions
581
+ if (chance.bool({ likelihood: REC_OVER_DROP_LIKELIHOOD })) {
582
+ userEvents.splice(i, 1);
583
+ }
584
+ }
585
+ }
586
+ }
587
+
588
+ // Hook #4: AD FATIGUE CHURN — users w/ 5+ early ad impressions lose
589
+ // nearly all events after day 45. Runs LAST so event-adding hooks
590
+ // (binge-watching, subtitle) can't re-inflate the post-d45 count.
591
+ // Applies to ALL tiers (ad fatigue affects anyone exposed to heavy ads,
592
+ // regardless of plan). 95% drop overcomes the ~3x activity confound.
593
+ if (earlyAdCount >= AD_FATIGUE_THRESHOLD) {
594
+ const churnCutoff = firstEventTime.add(AD_FATIGUE_CUTOFF_DAYS, "days");
595
+ for (let i = userEvents.length - 1; i >= 0; i--) {
596
+ const evt = userEvents[i];
597
+ if (dayjs(evt.time).isAfter(churnCutoff)) {
598
+ // Keep only ~5% of post-d45 events (drop 95%)
599
+ const keep = (i % AD_FATIGUE_KEEP_MODULO) === 0;
600
+ if (!keep) {
601
+ userEvents.splice(i, 1);
602
+ }
603
+ }
604
+ }
605
+ }
606
+
607
+ return record;
608
+ }
609
+
610
+ // ── CONFIG ──
301
611
  /** @type {Config} */
302
612
  const config = {
303
613
  version: 2,
304
- token,
305
614
  seed: SEED,
306
- datasetStart: "2026-01-01T00:00:00Z",
307
- datasetEnd: "2026-05-01T23:59:59Z",
308
- // numDays: num_days,
309
- avgEventsPerUserPerDay: avg_events_per_user_per_day,
310
- numUsers: num_users,
311
- hasAnonIds: true,
312
- avgDevicePerUser: 2,
313
- hasSessionIds: true,
615
+ datasetStart: DATASET_START,
616
+ datasetEnd: DATASET_END,
617
+ avgEventsPerUserPerDay: EVENTS_PER_DAY,
618
+ numUsers: NUM_USERS,
314
619
  format: "json",
315
620
  gzip: true,
316
- alsoInferFunnels: false,
317
- hasLocation: true,
318
- hasAndroidDevices: true,
319
- hasIOSDevices: true,
320
- hasDesktopDevices: true,
321
- hasBrowser: false,
322
- hasCampaigns: false,
323
- isAnonymous: false,
324
- hasAdSpend: false,
325
- hasAvatar: true,
621
+ credentials: {
622
+ token,
623
+ },
624
+ switches: {
625
+ hasSessionIds: true,
626
+ alsoInferFunnels: false,
627
+ hasLocation: true,
628
+ hasAndroidDevices: true,
629
+ hasIOSDevices: true,
630
+ hasDesktopDevices: true,
631
+ hasBrowser: false,
632
+ hasCampaigns: false,
633
+ isAnonymous: false,
634
+ hasAdSpend: false,
635
+ hasAvatar: true,
636
+ },
637
+ identity: {
638
+ avgDevicePerUser: 2,
639
+ },
326
640
  concurrency: 1,
327
641
  writeToDisk: false,
328
642
 
@@ -416,6 +730,7 @@ const config = {
416
730
  {
417
731
  event: "content selected",
418
732
  weight: 15,
733
+ isStrictEvent: false,
419
734
  properties: {
420
735
  "content_type": ["movie", "series", "documentary", "special"],
421
736
  "genre": ["action", "comedy", "drama", "documentary", "horror", "sci_fi", "animation", "thriller", "romance"],
@@ -425,6 +740,7 @@ const config = {
425
740
  {
426
741
  event: "playback started",
427
742
  weight: 18,
743
+ isStrictEvent: false,
428
744
  properties: {
429
745
  "content_id": contentIds,
430
746
  "content_type": ["movie", "series", "documentary", "special"],
@@ -436,6 +752,7 @@ const config = {
436
752
  {
437
753
  event: "playback completed",
438
754
  weight: 12,
755
+ isStrictEvent: false,
439
756
  properties: {
440
757
  "content_id": contentIds,
441
758
  "content_type": ["movie", "series", "documentary", "special"],
@@ -446,6 +763,7 @@ const config = {
446
763
  {
447
764
  event: "playback paused",
448
765
  weight: 10,
766
+ isStrictEvent: false,
449
767
  properties: {
450
768
  "content_id": contentIds,
451
769
  "pause_reason": ["manual", "ad_break", "buffering", "notification"],
@@ -454,6 +772,7 @@ const config = {
454
772
  {
455
773
  event: "content rated",
456
774
  weight: 6,
775
+ isStrictEvent: false,
457
776
  properties: {
458
777
  "content_id": contentIds,
459
778
  "rating": u.weighNumRange(1, 5, 2, 4),
@@ -489,6 +808,7 @@ const config = {
489
808
  {
490
809
  event: "recommendation clicked",
491
810
  weight: 9,
811
+ isStrictEvent: false,
492
812
  properties: {
493
813
  "algorithm": ["collaborative_filtering", "content_based", "trending", "editorial"],
494
814
  "position": u.weighNumRange(1, 20),
@@ -504,6 +824,7 @@ const config = {
504
824
  {
505
825
  event: "ad impression",
506
826
  weight: 8,
827
+ isStrictEvent: false,
507
828
  properties: {
508
829
  "ad_type": ["pre_roll", "mid_roll", "banner", "interstitial"],
509
830
  "ad_duration_sec": u.weighNumRange(5, 30),
@@ -539,6 +860,7 @@ const config = {
539
860
  {
540
861
  event: "subtitle toggled",
541
862
  weight: 4,
863
+ isStrictEvent: false,
542
864
  properties: {
543
865
  "subtitle_language": ["none", "english", "spanish", "french", "japanese", "korean"],
544
866
  "action": ["enabled", "disabled", "changed"],
@@ -572,278 +894,11 @@ const config = {
572
894
 
573
895
  lookupTables: [],
574
896
 
575
- hook: function (record, type, meta) {
576
- if (type === "event") {
577
- // Hook #6: KIDS PROFILE SAFETY — 15% of selections/starts get genre
578
- // restricted to animation or documentary. Mutates existing genre prop.
579
- if (chance.bool({ likelihood: 15 })) {
580
- if (record.event === "content selected" || record.event === "playback started") {
581
- record.genre = chance.pickone(["animation", "documentary"]);
582
- }
583
- }
584
-
585
- return record;
586
- }
587
-
588
- if (type === "everything") {
589
- const datasetStart = dayjs.unix(meta.datasetStart);
590
- const userEvents = record;
591
- if (!userEvents || userEvents.length === 0) return record;
592
-
593
- const profile = meta.profile;
594
-
595
- // Stamp superProps from profile (consistent per user)
596
- const stampPlan = profile && profile.subscription_plan ? profile.subscription_plan : undefined;
597
- const stampDevice = profile && profile.device_type ? profile.device_type : undefined;
598
- if (stampPlan || stampDevice) {
599
- userEvents.forEach(e => {
600
- if (stampPlan) e.subscription_plan = stampPlan;
601
- if (stampDevice) e.device_type = stampDevice;
602
- });
603
- }
604
-
605
- // HOOK 10: CORE VIEWING LOOP TTC — premium 0.67x, free 1.4x.
606
- // Timestamp shift for Mixpanel funnel TTC + property scale for
607
- // Insights. Applied first so weekend/subtitle/rec-click hooks
608
- // amplify from the plan-adjusted base.
609
- {
610
- const plan = profile ? profile.subscription_plan : "free";
611
- const ttcFactor = plan === "premium" ? 0.67 : plan === "free" ? 1.4 : 1.0;
612
- if (ttcFactor !== 1.0) {
613
- // Timestamp shift: affects Mixpanel funnel TTC
614
- const viewSeq = findFirstSequence(
615
- userEvents,
616
- ["content browsed", "content selected", "playback started", "playback completed"],
617
- 60 * 24 * 7
618
- );
619
- if (viewSeq) scaleFunnelTTC(viewSeq, ttcFactor);
620
- // Property scale: affects Insights AVG reports
621
- for (const e of userEvents) {
622
- if (e.event === "playback completed" && typeof e.watch_duration_min === "number") {
623
- e.watch_duration_min = Math.round(e.watch_duration_min * ttcFactor * 10) / 10;
624
- }
625
- }
626
- }
627
- }
628
-
629
- // Hook #5: NEW RELEASE SPIKE — days 50-65, 20% of selections/starts
630
- // switch to the blockbuster id. Mutates existing content_id/content_type props.
631
- // (Moved from event hook to everything hook per L1: temporal checks belong here.)
632
- const BLOCKBUSTER_START = datasetStart.add(50, 'days');
633
- const BLOCKBUSTER_END = datasetStart.add(65, 'days');
634
- for (const e of userEvents) {
635
- const eventTime = dayjs(e.time);
636
- if (eventTime.isAfter(BLOCKBUSTER_START) && eventTime.isBefore(BLOCKBUSTER_END)) {
637
- if ((e.event === "content selected" || e.event === "playback started") && chance.bool({ likelihood: 20 })) {
638
- e.content_type = "movie";
639
- e.content_id = blockbusterId;
640
- }
641
- if (e.event === "content rated" && chance.bool({ likelihood: 20 })) {
642
- e.rating = chance.integer({ min: 4, max: 5 });
643
- e.content_id = blockbusterId;
644
- }
645
- }
646
- }
647
-
648
- // Hook #10: CORE VIEWING LOOP — subscription_plan property scaling.
649
- // Premium users watch more efficiently (shorter durations), free users
650
- // linger (longer durations). Scales watch_duration_min on playback
651
- // completed events BEFORE H3/H8/H9 so each subsequent hook amplifies
652
- // from the plan-adjusted base.
653
- // Discover via: Insights → playback completed → Avg watch_duration_min → breakdown subscription_plan.
654
- {
655
- const plan = stampPlan;
656
- const factor = (
657
- plan === "premium" ? 0.67 :
658
- plan === "free" ? 1.4 :
659
- 1.0
660
- );
661
- if (factor !== 1.0) {
662
- for (const e of userEvents) {
663
- if (e.event === "playback completed" && typeof e.watch_duration_min === "number") {
664
- e.watch_duration_min = Math.round(e.watch_duration_min * factor);
665
- }
666
- }
667
- }
668
- }
669
-
670
- // Hook #3: WEEKEND VS WEEKDAY — 1.5x watch_duration_min on weekends.
671
- // No flag — analyst breaks down by day of week.
672
- for (const e of userEvents) {
673
- const dow = new Date(e.time).getUTCDay();
674
- if ((dow === 0 || dow === 6) && e.event === "playback completed" && typeof e.watch_duration_min === "number") {
675
- e.watch_duration_min = Math.round(e.watch_duration_min * 1.5);
676
- }
677
- }
678
-
679
- // Hook #1: GENRE FUNNEL CONVERSION — drop 25% of documentary playback
680
- // completed events to depress documentary funnel conversion. Raw genre check.
681
- for (let i = userEvents.length - 1; i >= 0; i--) {
682
- const evt = userEvents[i];
683
- if (evt.event === "playback completed" && evt.genre === "documentary" && chance.bool({ likelihood: 25 })) {
684
- userEvents.splice(i, 1);
685
- }
686
- }
687
-
688
- // Hook #7: RECOMMENDATION ENGINE IMPROVEMENT — drop 30% of pre-day-60
689
- // content rated events. Raw time check.
690
- const IMPROVEMENT_DATE = datasetStart.add(60, 'days');
691
- for (let i = userEvents.length - 1; i >= 0; i--) {
692
- const evt = userEvents[i];
693
- if (evt.event === "content rated" && dayjs(evt.time).isBefore(IMPROVEMENT_DATE) && chance.bool({ likelihood: 30 })) {
694
- userEvents.splice(i, 1);
695
- }
696
- }
697
-
698
- const firstEventTime = dayjs(userEvents[0].time);
699
-
700
- // Identify behavioral patterns (no flags written)
701
- let consecutiveCompletions = 0;
702
- let maxConsecutiveCompletions = 0;
703
- let earlyAdCount = 0;
704
- let hasSubtitlesEnabled = false;
705
- let recClickCount = 0;
706
-
707
- const adCutoff = firstEventTime.add(45, 'days');
708
- userEvents.forEach((event, idx) => {
709
- if (event.event === "playback completed") {
710
- consecutiveCompletions++;
711
- if (consecutiveCompletions > maxConsecutiveCompletions) {
712
- maxConsecutiveCompletions = consecutiveCompletions;
713
- }
714
- } else if (event.event !== "playback started") {
715
- consecutiveCompletions = 0;
716
- }
717
- if (event.event === "ad impression" && dayjs(event.time).isBefore(adCutoff)) earlyAdCount++;
718
- if (event.event === "subtitle toggled" && event.action === "enabled") hasSubtitlesEnabled = true;
719
- if (event.event === "recommendation clicked") recClickCount++;
720
- });
721
-
722
- const isBingeWatcher = maxConsecutiveCompletions >= 3;
723
-
724
- // Hook #2: BINGE-WATCHING — drop pauses, inject extra start+complete pairs.
725
- // Cloned events use unique offset timestamps. No flag.
726
- if (isBingeWatcher) {
727
- for (let i = userEvents.length - 1; i >= 0; i--) {
728
- const event = userEvents[i];
729
- const eventTime = dayjs(event.time);
730
-
731
- if (event.event === "playback paused" && chance.bool({ likelihood: 60 })) {
732
- userEvents.splice(i, 1);
733
- continue;
734
- }
735
-
736
- if (event.event === "playback completed" && chance.bool({ likelihood: 40 })) {
737
- const nextContentId = chance.pickone(contentIds);
738
- const startTemplate = userEvents.find(e => e.event === "playback started");
739
- const extraStart = {
740
- ...(startTemplate || event),
741
- event: "playback started",
742
- time: eventTime.add(chance.integer({ min: 1, max: 5 }), 'minutes').toISOString(),
743
- user_id: event.user_id,
744
- content_id: nextContentId,
745
- content_type: "series",
746
- playback_quality: event.playback_quality || "1080p",
747
- };
748
- const extraComplete = {
749
- ...event,
750
- time: eventTime.add(chance.integer({ min: 25, max: 60 }), 'minutes').toISOString(),
751
- user_id: event.user_id,
752
- content_id: nextContentId,
753
- content_type: "series",
754
- watch_duration_min: chance.integer({ min: 20, max: 55 }),
755
- completion_percent: chance.integer({ min: 90, max: 100 }),
756
- };
757
- userEvents.splice(i + 1, 0, extraStart, extraComplete);
758
- }
759
- }
760
- }
761
-
762
- // Hook #8: SUBTITLE USERS WATCH MORE — 1.25x completion_percent (cap 100),
763
- // 1.15x watch_duration_min, plus 20% extra cloned playback completions.
764
- // No flag — discover via cohort builder on subtitle-toggled-enabled.
765
- if (hasSubtitlesEnabled) {
766
- for (let i = 0; i < userEvents.length; i++) {
767
- const event = userEvents[i];
768
- if (event.event === "playback completed") {
769
- if (event.completion_percent) {
770
- event.completion_percent = Math.min(100, Math.round(event.completion_percent * 1.25));
771
- }
772
- if (event.watch_duration_min) {
773
- event.watch_duration_min = Math.round(event.watch_duration_min * 1.15);
774
- }
775
- }
776
- }
777
-
778
- const completionEvents = userEvents.filter(e => e.event === "playback completed");
779
- const extraCount = Math.floor(completionEvents.length * 0.2);
780
- for (let j = 0; j < extraCount; j++) {
781
- const templateEvent = chance.pickone(completionEvents);
782
- const templateTime = dayjs(templateEvent.time);
783
- const extraCompletion = {
784
- ...templateEvent,
785
- time: templateTime.add(chance.integer({ min: 30, max: 180 }), 'minutes').toISOString(),
786
- user_id: templateEvent.user_id,
787
- content_id: chance.pickone(contentIds),
788
- content_type: chance.pickone(["movie", "series", "documentary"]),
789
- watch_duration_min: chance.integer({ min: 25, max: 120 }),
790
- completion_percent: chance.integer({ min: 80, max: 100 }),
791
- };
792
- userEvents.push(extraCompletion);
793
- }
794
- }
795
-
796
- // Hook #9: RECOMMENDATION-CLICKED MAGIC NUMBER (no flags)
797
- // Sweet 4-6 rec clicks → +25% watch_duration_min on playback completed.
798
- // Over 7+ → halve watch_duration_min AND drop 55% of playback completed
799
- // events (rec fatigue). Aggressive suppression overcomes the inherent
800
- // engagement confound (high-rec-click users are naturally more active).
801
- if (recClickCount >= 4 && recClickCount <= 6) {
802
- userEvents.forEach(e => {
803
- if (e.event === 'playback completed' && typeof e.watch_duration_min === 'number') {
804
- e.watch_duration_min = Math.round(e.watch_duration_min * 1.25);
805
- }
806
- });
807
- } else if (recClickCount >= 7) {
808
- for (let i = userEvents.length - 1; i >= 0; i--) {
809
- const evt = userEvents[i];
810
- if (evt.event === 'playback completed') {
811
- // Halve watch duration for surviving events
812
- if (typeof evt.watch_duration_min === 'number') {
813
- evt.watch_duration_min = Math.round(evt.watch_duration_min * 0.5);
814
- }
815
- // Drop 55% of completions
816
- if (chance.bool({ likelihood: 55 })) {
817
- userEvents.splice(i, 1);
818
- }
819
- }
820
- }
821
- }
822
-
823
- // Hook #4: AD FATIGUE CHURN — users w/ 5+ early ad impressions lose
824
- // nearly all events after day 45. Runs LAST so event-adding hooks
825
- // (binge-watching, subtitle) can't re-inflate the post-d45 count.
826
- // Applies to ALL tiers (ad fatigue affects anyone exposed to heavy ads,
827
- // regardless of plan). 95% drop overcomes the ~3x activity confound.
828
- if (earlyAdCount >= 5) {
829
- const churnCutoff = firstEventTime.add(45, 'days');
830
- for (let i = userEvents.length - 1; i >= 0; i--) {
831
- const evt = userEvents[i];
832
- if (dayjs(evt.time).isAfter(churnCutoff)) {
833
- // Keep only ~5% of post-d45 events (drop 95%)
834
- const keep = (i % 20) === 0;
835
- if (!keep) {
836
- userEvents.splice(i, 1);
837
- }
838
- }
839
- }
840
- }
841
-
842
- return record;
843
- }
844
-
897
+ hook(record, type, meta) {
898
+ if (type === "event") return handleEventHooks(record);
899
+ if (type === "everything") return handleEverythingHooks(record, meta);
845
900
  return record;
846
- }
901
+ },
847
902
  };
848
903
 
849
904
  export default config;