@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,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
 
@@ -580,278 +894,11 @@ const config = {
580
894
 
581
895
  lookupTables: [],
582
896
 
583
- hook: function (record, type, meta) {
584
- if (type === "event") {
585
- // Hook #6: KIDS PROFILE SAFETY — 15% of selections/starts get genre
586
- // restricted to animation or documentary. Mutates existing genre prop.
587
- if (chance.bool({ likelihood: 15 })) {
588
- if (record.event === "content selected" || record.event === "playback started") {
589
- record.genre = chance.pickone(["animation", "documentary"]);
590
- }
591
- }
592
-
593
- return record;
594
- }
595
-
596
- if (type === "everything") {
597
- const datasetStart = dayjs.unix(meta.datasetStart);
598
- const userEvents = record;
599
- if (!userEvents || userEvents.length === 0) return record;
600
-
601
- const profile = meta.profile;
602
-
603
- // Stamp superProps from profile (consistent per user)
604
- const stampPlan = profile && profile.subscription_plan ? profile.subscription_plan : undefined;
605
- const stampDevice = profile && profile.device_type ? profile.device_type : undefined;
606
- if (stampPlan || stampDevice) {
607
- userEvents.forEach(e => {
608
- if (stampPlan) e.subscription_plan = stampPlan;
609
- if (stampDevice) e.device_type = stampDevice;
610
- });
611
- }
612
-
613
- // HOOK 10: CORE VIEWING LOOP TTC — premium 0.67x, free 1.4x.
614
- // Timestamp shift for Mixpanel funnel TTC + property scale for
615
- // Insights. Applied first so weekend/subtitle/rec-click hooks
616
- // amplify from the plan-adjusted base.
617
- {
618
- const plan = profile ? profile.subscription_plan : "free";
619
- const ttcFactor = plan === "premium" ? 0.67 : plan === "free" ? 1.4 : 1.0;
620
- if (ttcFactor !== 1.0) {
621
- // Timestamp shift: affects Mixpanel funnel TTC
622
- const viewSeq = findFirstSequence(
623
- userEvents,
624
- ["content browsed", "content selected", "playback started", "playback completed"],
625
- 60 * 24 * 7
626
- );
627
- if (viewSeq) scaleFunnelTTC(viewSeq, ttcFactor);
628
- // Property scale: affects Insights AVG reports
629
- for (const e of userEvents) {
630
- if (e.event === "playback completed" && typeof e.watch_duration_min === "number") {
631
- e.watch_duration_min = Math.round(e.watch_duration_min * ttcFactor * 10) / 10;
632
- }
633
- }
634
- }
635
- }
636
-
637
- // Hook #5: NEW RELEASE SPIKE — days 50-65, 20% of selections/starts
638
- // switch to the blockbuster id. Mutates existing content_id/content_type props.
639
- // (Moved from event hook to everything hook per L1: temporal checks belong here.)
640
- const BLOCKBUSTER_START = datasetStart.add(50, 'days');
641
- const BLOCKBUSTER_END = datasetStart.add(65, 'days');
642
- for (const e of userEvents) {
643
- const eventTime = dayjs(e.time);
644
- if (eventTime.isAfter(BLOCKBUSTER_START) && eventTime.isBefore(BLOCKBUSTER_END)) {
645
- if ((e.event === "content selected" || e.event === "playback started") && chance.bool({ likelihood: 20 })) {
646
- e.content_type = "movie";
647
- e.content_id = blockbusterId;
648
- }
649
- if (e.event === "content rated" && chance.bool({ likelihood: 20 })) {
650
- e.rating = chance.integer({ min: 4, max: 5 });
651
- e.content_id = blockbusterId;
652
- }
653
- }
654
- }
655
-
656
- // Hook #10: CORE VIEWING LOOP — subscription_plan property scaling.
657
- // Premium users watch more efficiently (shorter durations), free users
658
- // linger (longer durations). Scales watch_duration_min on playback
659
- // completed events BEFORE H3/H8/H9 so each subsequent hook amplifies
660
- // from the plan-adjusted base.
661
- // Discover via: Insights → playback completed → Avg watch_duration_min → breakdown subscription_plan.
662
- {
663
- const plan = stampPlan;
664
- const factor = (
665
- plan === "premium" ? 0.67 :
666
- plan === "free" ? 1.4 :
667
- 1.0
668
- );
669
- if (factor !== 1.0) {
670
- for (const e of userEvents) {
671
- if (e.event === "playback completed" && typeof e.watch_duration_min === "number") {
672
- e.watch_duration_min = Math.round(e.watch_duration_min * factor);
673
- }
674
- }
675
- }
676
- }
677
-
678
- // Hook #3: WEEKEND VS WEEKDAY — 1.5x watch_duration_min on weekends.
679
- // No flag — analyst breaks down by day of week.
680
- for (const e of userEvents) {
681
- const dow = new Date(e.time).getUTCDay();
682
- if ((dow === 0 || dow === 6) && e.event === "playback completed" && typeof e.watch_duration_min === "number") {
683
- e.watch_duration_min = Math.round(e.watch_duration_min * 1.5);
684
- }
685
- }
686
-
687
- // Hook #1: GENRE FUNNEL CONVERSION — drop 25% of documentary playback
688
- // completed events to depress documentary funnel conversion. Raw genre check.
689
- for (let i = userEvents.length - 1; i >= 0; i--) {
690
- const evt = userEvents[i];
691
- if (evt.event === "playback completed" && evt.genre === "documentary" && chance.bool({ likelihood: 25 })) {
692
- userEvents.splice(i, 1);
693
- }
694
- }
695
-
696
- // Hook #7: RECOMMENDATION ENGINE IMPROVEMENT — drop 30% of pre-day-60
697
- // content rated events. Raw time check.
698
- const IMPROVEMENT_DATE = datasetStart.add(60, 'days');
699
- for (let i = userEvents.length - 1; i >= 0; i--) {
700
- const evt = userEvents[i];
701
- if (evt.event === "content rated" && dayjs(evt.time).isBefore(IMPROVEMENT_DATE) && chance.bool({ likelihood: 30 })) {
702
- userEvents.splice(i, 1);
703
- }
704
- }
705
-
706
- const firstEventTime = dayjs(userEvents[0].time);
707
-
708
- // Identify behavioral patterns (no flags written)
709
- let consecutiveCompletions = 0;
710
- let maxConsecutiveCompletions = 0;
711
- let earlyAdCount = 0;
712
- let hasSubtitlesEnabled = false;
713
- let recClickCount = 0;
714
-
715
- const adCutoff = firstEventTime.add(45, 'days');
716
- userEvents.forEach((event, idx) => {
717
- if (event.event === "playback completed") {
718
- consecutiveCompletions++;
719
- if (consecutiveCompletions > maxConsecutiveCompletions) {
720
- maxConsecutiveCompletions = consecutiveCompletions;
721
- }
722
- } else if (event.event !== "playback started") {
723
- consecutiveCompletions = 0;
724
- }
725
- if (event.event === "ad impression" && dayjs(event.time).isBefore(adCutoff)) earlyAdCount++;
726
- if (event.event === "subtitle toggled" && event.action === "enabled") hasSubtitlesEnabled = true;
727
- if (event.event === "recommendation clicked") recClickCount++;
728
- });
729
-
730
- const isBingeWatcher = maxConsecutiveCompletions >= 3;
731
-
732
- // Hook #2: BINGE-WATCHING — drop pauses, inject extra start+complete pairs.
733
- // Cloned events use unique offset timestamps. No flag.
734
- if (isBingeWatcher) {
735
- for (let i = userEvents.length - 1; i >= 0; i--) {
736
- const event = userEvents[i];
737
- const eventTime = dayjs(event.time);
738
-
739
- if (event.event === "playback paused" && chance.bool({ likelihood: 60 })) {
740
- userEvents.splice(i, 1);
741
- continue;
742
- }
743
-
744
- if (event.event === "playback completed" && chance.bool({ likelihood: 40 })) {
745
- const nextContentId = chance.pickone(contentIds);
746
- const startTemplate = userEvents.find(e => e.event === "playback started");
747
- const extraStart = {
748
- ...(startTemplate || event),
749
- event: "playback started",
750
- time: eventTime.add(chance.integer({ min: 1, max: 5 }), 'minutes').toISOString(),
751
- user_id: event.user_id,
752
- content_id: nextContentId,
753
- content_type: "series",
754
- playback_quality: event.playback_quality || "1080p",
755
- };
756
- const extraComplete = {
757
- ...event,
758
- time: eventTime.add(chance.integer({ min: 25, max: 60 }), 'minutes').toISOString(),
759
- user_id: event.user_id,
760
- content_id: nextContentId,
761
- content_type: "series",
762
- watch_duration_min: chance.integer({ min: 20, max: 55 }),
763
- completion_percent: chance.integer({ min: 90, max: 100 }),
764
- };
765
- userEvents.splice(i + 1, 0, extraStart, extraComplete);
766
- }
767
- }
768
- }
769
-
770
- // Hook #8: SUBTITLE USERS WATCH MORE — 1.25x completion_percent (cap 100),
771
- // 1.15x watch_duration_min, plus 20% extra cloned playback completions.
772
- // No flag — discover via cohort builder on subtitle-toggled-enabled.
773
- if (hasSubtitlesEnabled) {
774
- for (let i = 0; i < userEvents.length; i++) {
775
- const event = userEvents[i];
776
- if (event.event === "playback completed") {
777
- if (event.completion_percent) {
778
- event.completion_percent = Math.min(100, Math.round(event.completion_percent * 1.25));
779
- }
780
- if (event.watch_duration_min) {
781
- event.watch_duration_min = Math.round(event.watch_duration_min * 1.15);
782
- }
783
- }
784
- }
785
-
786
- const completionEvents = userEvents.filter(e => e.event === "playback completed");
787
- const extraCount = Math.floor(completionEvents.length * 0.2);
788
- for (let j = 0; j < extraCount; j++) {
789
- const templateEvent = chance.pickone(completionEvents);
790
- const templateTime = dayjs(templateEvent.time);
791
- const extraCompletion = {
792
- ...templateEvent,
793
- time: templateTime.add(chance.integer({ min: 30, max: 180 }), 'minutes').toISOString(),
794
- user_id: templateEvent.user_id,
795
- content_id: chance.pickone(contentIds),
796
- content_type: chance.pickone(["movie", "series", "documentary"]),
797
- watch_duration_min: chance.integer({ min: 25, max: 120 }),
798
- completion_percent: chance.integer({ min: 80, max: 100 }),
799
- };
800
- userEvents.push(extraCompletion);
801
- }
802
- }
803
-
804
- // Hook #9: RECOMMENDATION-CLICKED MAGIC NUMBER (no flags)
805
- // Sweet 4-6 rec clicks → +25% watch_duration_min on playback completed.
806
- // Over 7+ → halve watch_duration_min AND drop 55% of playback completed
807
- // events (rec fatigue). Aggressive suppression overcomes the inherent
808
- // engagement confound (high-rec-click users are naturally more active).
809
- if (recClickCount >= 4 && recClickCount <= 6) {
810
- userEvents.forEach(e => {
811
- if (e.event === 'playback completed' && typeof e.watch_duration_min === 'number') {
812
- e.watch_duration_min = Math.round(e.watch_duration_min * 1.25);
813
- }
814
- });
815
- } else if (recClickCount >= 7) {
816
- for (let i = userEvents.length - 1; i >= 0; i--) {
817
- const evt = userEvents[i];
818
- if (evt.event === 'playback completed') {
819
- // Halve watch duration for surviving events
820
- if (typeof evt.watch_duration_min === 'number') {
821
- evt.watch_duration_min = Math.round(evt.watch_duration_min * 0.5);
822
- }
823
- // Drop 55% of completions
824
- if (chance.bool({ likelihood: 55 })) {
825
- userEvents.splice(i, 1);
826
- }
827
- }
828
- }
829
- }
830
-
831
- // Hook #4: AD FATIGUE CHURN — users w/ 5+ early ad impressions lose
832
- // nearly all events after day 45. Runs LAST so event-adding hooks
833
- // (binge-watching, subtitle) can't re-inflate the post-d45 count.
834
- // Applies to ALL tiers (ad fatigue affects anyone exposed to heavy ads,
835
- // regardless of plan). 95% drop overcomes the ~3x activity confound.
836
- if (earlyAdCount >= 5) {
837
- const churnCutoff = firstEventTime.add(45, 'days');
838
- for (let i = userEvents.length - 1; i >= 0; i--) {
839
- const evt = userEvents[i];
840
- if (dayjs(evt.time).isAfter(churnCutoff)) {
841
- // Keep only ~5% of post-d45 events (drop 95%)
842
- const keep = (i % 20) === 0;
843
- if (!keep) {
844
- userEvents.splice(i, 1);
845
- }
846
- }
847
- }
848
- }
849
-
850
- return record;
851
- }
852
-
897
+ hook(record, type, meta) {
898
+ if (type === "event") return handleEventHooks(record);
899
+ if (type === "everything") return handleEverythingHooks(record, meta);
853
900
  return record;
854
- }
901
+ },
855
902
  };
856
903
 
857
904
  export default config;