@ak--47/dungeon-master 1.2.0 → 1.2.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 (54) hide show
  1. package/dungeons/technical/ad-spend.js +15 -4
  2. package/dungeons/technical/anonymous-users.js +15 -5
  3. package/dungeons/technical/array-of-object-lookup.js +21 -5
  4. package/dungeons/technical/experiments.js +23 -5
  5. package/dungeons/technical/foobar.js +28 -12
  6. package/dungeons/technical/group-analytics.js +15 -5
  7. package/dungeons/technical/mirror-strategies.js +15 -5
  8. package/dungeons/technical/nested-objects.js +15 -4
  9. package/dungeons/technical/retention-cadence.js +13 -7
  10. package/dungeons/technical/sanity.js +25 -6
  11. package/dungeons/technical/scale-test.js +15 -4
  12. package/dungeons/technical/scd.js +24 -5
  13. package/dungeons/technical/simple.js +20 -5
  14. package/dungeons/technical/simplest.js +23 -6
  15. package/dungeons/technical/text-generation.js +22 -6
  16. package/dungeons/user/.gitkeep +0 -0
  17. package/dungeons/vertical/community-schema.json +1 -1
  18. package/dungeons/vertical/community.js +49 -28
  19. package/dungeons/vertical/devtools-schema.json +1 -1
  20. package/dungeons/vertical/devtools.js +50 -29
  21. package/dungeons/vertical/ecommerce.js +23 -6
  22. package/dungeons/vertical/education-schema.json +1 -1
  23. package/dungeons/vertical/education.js +29 -19
  24. package/dungeons/vertical/fintech-schema.json +1 -1
  25. package/dungeons/vertical/fintech.js +27 -8
  26. package/dungeons/vertical/fitness-schema.json +1 -1
  27. package/dungeons/vertical/fitness.js +48 -22
  28. package/dungeons/vertical/food-delivery-schema.json +1 -1
  29. package/dungeons/vertical/food-delivery.js +51 -12
  30. package/dungeons/vertical/gaming-schema.json +1 -1
  31. package/dungeons/vertical/gaming.js +41 -7
  32. package/dungeons/vertical/healthcare-schema.json +1 -1
  33. package/dungeons/vertical/healthcare.js +69 -40
  34. package/dungeons/vertical/insurance-application-schema.json +1 -1
  35. package/dungeons/vertical/insurance-application.js +26 -8
  36. package/dungeons/vertical/logistics-schema.json +1 -1
  37. package/dungeons/vertical/logistics.js +69 -41
  38. package/dungeons/vertical/marketplace-schema.json +1 -1
  39. package/dungeons/vertical/marketplace.js +44 -19
  40. package/dungeons/vertical/media.js +56 -15
  41. package/dungeons/vertical/rpg-schema.json +1 -1
  42. package/dungeons/vertical/rpg.js +38 -8
  43. package/dungeons/vertical/sass.js +24 -7
  44. package/dungeons/vertical/social.js +23 -7
  45. package/dungeons/vertical/travel-schema.json +1 -1
  46. package/dungeons/vertical/travel.js +50 -19
  47. package/index.js +3 -0
  48. package/lib/core/config-validator.js +13 -2
  49. package/lib/generators/events.js +3 -5
  50. package/lib/orchestrators/mixpanel-sender.js +64 -3
  51. package/lib/orchestrators/user-loop.js +20 -0
  52. package/lib/utils/utils.js +129 -9
  53. package/package.json +2 -2
  54. package/types.d.ts +5 -3
@@ -1,13 +1,20 @@
1
+ // ── TWEAK THESE ──
2
+ const SEED = "dm4-insurance";
3
+ const num_days = 100;
4
+ const num_users = 5_000;
5
+ const avg_events_per_user = 120;
6
+ let token = "your-mixpanel-token";
7
+
8
+ // ── env overrides ──
9
+ if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
10
+
1
11
  import dayjs from "dayjs";
2
12
  import utc from "dayjs/plugin/utc.js";
3
13
  import "dotenv/config";
4
14
  import * as u from "../../lib/utils/utils.js";
5
15
 
6
- const SEED = "dm4-insurance";
7
16
  dayjs.extend(utc);
8
17
  const chance = u.initChance(SEED);
9
- const num_users = 5_000;
10
- const days = 100;
11
18
 
12
19
  /** @typedef {import("../../types").Dungeon} Config */
13
20
 
@@ -126,17 +133,17 @@ const days = 100;
126
133
 
127
134
  // ── Time constants for hook calculations ──
128
135
  const NOW = dayjs();
129
- const DATASET_START = NOW.subtract(days, "days");
136
+ const DATASET_START = NOW.subtract(num_days, "days");
130
137
  const V211_DATE = DATASET_START.add(30, "days");
131
138
  const V212_DATE = DATASET_START.add(60, "days");
132
139
  const V213_DATE = NOW.subtract(10, "days");
133
140
 
134
141
  /** @type {Config} */
135
142
  const config = {
136
- token: "",
143
+ token,
137
144
  seed: SEED,
138
- numDays: days,
139
- numEvents: num_users * 120,
145
+ numDays: num_days,
146
+ numEvents: num_users * avg_events_per_user,
140
147
  numUsers: num_users,
141
148
  hasAnonIds: false,
142
149
  hasSessionIds: true,
@@ -431,13 +438,16 @@ const config = {
431
438
 
432
439
  // ── Super Props (on every event) ──
433
440
  superProps: {
434
- platform: ["web", "ios", "android"],
441
+ Platform: ["web", "ios", "android"],
435
442
  insurance_type: ["auto", "home", "life", "health", "renters"],
436
443
  app_version: ["2.10"],
437
444
  },
438
445
 
439
446
  // ── User Props (set once per user) ──
440
447
  userProps: {
448
+ Platform: ["web", "ios", "android"],
449
+ insurance_type: ["auto", "home", "life", "health", "renters"],
450
+ app_version: ["2.10"],
441
451
  age_range: ["18-25", "26-35", "36-45", "46-55", "56-65", "65+"],
442
452
  risk_profile: ["low", "medium", "high"],
443
453
  policy_count: u.weighNumRange(0, 5, 0.5, 1),
@@ -477,6 +487,14 @@ const config = {
477
487
  const userEvents = record;
478
488
  if (userEvents.length === 0) return record;
479
489
 
490
+ // Stamp superProps from profile for consistency
491
+ const profile = meta.profile;
492
+ userEvents.forEach(e => {
493
+ e.Platform = profile.Platform;
494
+ e.insurance_type = profile.insurance_type;
495
+ e.app_version = profile.app_version;
496
+ });
497
+
480
498
  // ─── Hook #1: VERSION STAMPING ───
481
499
  // Deterministic app_version on every event based on its final timestamp.
482
500
  // v2.10 (days 0-30) → v2.11 (30-60) → v2.12 (60-90) → v2.13 (last 10 days)
@@ -521,7 +521,7 @@
521
521
  }
522
522
  ],
523
523
  "superProps": {
524
- "platform": [
524
+ "Platform": [
525
525
  "web",
526
526
  "desktop_app"
527
527
  ],
@@ -1,16 +1,23 @@
1
+ // ── TWEAK THESE ──
2
+ const SEED = "dm4-logistics";
3
+ const num_days = 100;
4
+ const num_users = 5_000;
5
+ const avg_events_per_user = 120;
6
+ let token = "your-mixpanel-token";
7
+
8
+ // ── env overrides ──
9
+ if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
10
+
1
11
  import dayjs from "dayjs";
2
12
  import utc from "dayjs/plugin/utc.js";
3
13
  import "dotenv/config";
4
14
  import * as u from "../../lib/utils/utils.js";
5
15
  import * as v from "ak-tools";
6
16
 
7
- const SEED = "dm4-logistics";
8
17
  dayjs.extend(utc);
9
18
  const chance = u.initChance(SEED);
10
- const num_users = 5_000;
11
- const days = 100;
12
19
  const NOW = dayjs();
13
- const DATASET_START = NOW.subtract(days, "days");
20
+ const DATASET_START = NOW.subtract(num_days, "days");
14
21
 
15
22
  /** @typedef {import("../../types").Dungeon} Config */
16
23
 
@@ -216,12 +223,14 @@ const supplierIds = v.range(1, 150).map(() => `SUP_${v.uid(6)}`);
216
223
  * networks with large logistics teams, driving higher ACV.
217
224
  *
218
225
  * -------------------------------------------------------------------
219
- * 8. ENTERPRISE INTEGRATION FUNNEL LIFT (funnel-pre hook)
226
+ * 8. SMALL-BUSINESS CONVERSION DROP (everything hook)
220
227
  * -------------------------------------------------------------------
221
228
  *
222
- * PATTERN: Users with company_tier "enterprise" convert 1.5x better
223
- * through the Integration Setup funnel. Enterprises have dedicated
224
- * IT teams and structured onboarding processes.
229
+ * PATTERN: Small-business users lose ~35% of "alert configured"
230
+ * events (last step of the Integration Setup funnel). This is
231
+ * implemented via event filtering in the everything hook rather
232
+ * than conversionRate modification in funnel-pre, so the effect
233
+ * is not diluted by organic (non-funnel) events.
225
234
  *
226
235
  * HOW TO FIND IT IN MIXPANEL:
227
236
  *
@@ -229,10 +238,10 @@ const supplierIds = v.range(1, 150).map(() => `SUP_${v.uid(6)}`);
229
238
  * - Report type: Funnels
230
239
  * - Steps: "integration connected" -> "report generated" -> "alert configured"
231
240
  * - Breakdown: user property "company_tier"
232
- * - Expected: enterprise ~60% vs small_business ~30% conversion
241
+ * - Expected: small_business ~20% vs enterprise/mid_market ~30% conversion
233
242
  *
234
- * REAL-WORLD ANALOGUE: Enterprise IT teams have dedicated resources
235
- * for integrations, leading to faster and more complete setup.
243
+ * REAL-WORLD ANALOGUE: Small businesses lack dedicated IT teams,
244
+ * leading to incomplete integration setup and lower alert adoption.
236
245
  *
237
246
  * ===================================================================
238
247
  * EXPECTED METRICS SUMMARY
@@ -247,15 +256,15 @@ const supplierIds = v.range(1, 150).map(() => `SUP_${v.uid(6)}`);
247
256
  * Alert Fatigue | response_time_hours | 4h | 8-12h | 2-3x
248
257
  * Trial Churn | events after wk 1 | 5 | 2.5 | 0.5x
249
258
  * Enterprise Profiles | warehouse_count | 3 | 10 | 3.3x
250
- * Enterprise Integration Lift | funnel conversion | 30% | 45% | 1.5x
259
+ * Small-Biz Conversion Drop | funnel conversion | 30% | 20% | 0.65x
251
260
  */
252
261
 
253
262
  /** @type {Config} */
254
263
  const config = {
255
- token: "",
264
+ token,
256
265
  seed: SEED,
257
- numDays: days,
258
- numEvents: num_users * 120,
266
+ numDays: num_days,
267
+ numEvents: num_users * avg_events_per_user,
259
268
  numUsers: num_users,
260
269
  hasAnonIds: false,
261
270
  hasSessionIds: true,
@@ -505,7 +514,7 @@ const config = {
505
514
 
506
515
  // -- SuperProps ----------------------------------------------------
507
516
  superProps: {
508
- platform: ["web", "web", "desktop_app"],
517
+ Platform: ["web", "web", "desktop_app"],
509
518
  subscription_plan: ["free_trial", "free_trial", "starter", "starter", "professional", "enterprise"],
510
519
  },
511
520
 
@@ -516,6 +525,8 @@ const config = {
516
525
  employee_count: u.weighNumRange(1, 500, 0.3, 25),
517
526
  industry: ["retail", "manufacturing", "food_beverage", "pharma", "electronics"],
518
527
  integration_count: [0],
528
+ Platform: ["web", "web", "desktop_app"],
529
+ subscription_plan: ["free_trial", "free_trial", "starter", "starter", "professional", "enterprise"],
519
530
  },
520
531
 
521
532
  // -- Phase 2: Personas --------------------------------------------
@@ -697,16 +708,9 @@ const config = {
697
708
  }
698
709
 
699
710
  // -- HOOK 8: ENTERPRISE INTEGRATION FUNNEL LIFT (funnel-pre) --
700
- // Enterprise subscribers convert 1.5x through integration funnel.
711
+ // (conversionRate boost removed filtering applied in everything hook instead)
701
712
  if (type === "funnel-pre") {
702
- if (meta && meta.profile) {
703
- const tier = meta.profile.company_tier;
704
- if (tier === "enterprise") {
705
- record.conversionRate = Math.min(record.conversionRate * 1.5, 95);
706
- } else if (tier === "mid_market") {
707
- record.conversionRate = Math.min(record.conversionRate * 1.2, 85);
708
- }
709
- }
713
+ // no-op: conversion differentiation handled via event filtering below
710
714
  }
711
715
 
712
716
  // -- HOOK 1: MONTH-END REPORTING SURGE (event) ----------------
@@ -728,29 +732,53 @@ const config = {
728
732
 
729
733
  // -- EVERYTHING HOOKS -----------------------------------------
730
734
  if (type === "everything") {
731
- const events = record;
732
- if (!events.length) return record;
735
+ if (!record.length) return record;
736
+ const profile = meta.profile;
737
+
738
+ // -- SUPER-PROP STAMPING ----------------------------------
739
+ // Stamp superProps from profile so they are consistent per-user.
740
+ if (profile) {
741
+ const plat = profile.Platform;
742
+ const plan = profile.subscription_plan;
743
+ record.forEach(e => {
744
+ if (plat) e.Platform = plat;
745
+ if (plan) e.subscription_plan = plan;
746
+ });
747
+ }
748
+
749
+ // -- HOOK 8: SMALL-BUSINESS CONVERSION DROP ---------------
750
+ // Small-business users lose ~35% of "alert configured" events
751
+ // (last step of Integration Setup funnel), simulating lower
752
+ // conversion for smaller teams without dedicated IT resources.
753
+ if (profile && profile.company_tier === "small_business") {
754
+ record = record.filter(e => {
755
+ if (e.event === "alert configured" && chance.bool({ likelihood: 35 })) {
756
+ return false;
757
+ }
758
+ return true;
759
+ });
760
+ }
733
761
 
734
762
  // -- HOOK 3: REORDER ACCURACY BY TIER ---------------------
735
763
  // Enterprise users get 10% of stockout alerts removed.
736
- if (meta.profile && meta.profile.company_tier === "enterprise") {
737
- for (let i = events.length - 1; i >= 0; i--) {
738
- if (events[i].event === "stockout alert" && chance.bool({ likelihood: 10 })) {
739
- events.splice(i, 1);
764
+ if (profile && profile.company_tier === "enterprise") {
765
+ for (let i = record.length - 1; i >= 0; i--) {
766
+ if (record[i].event === "stockout alert" && chance.bool({ likelihood: 10 })) {
767
+ record.splice(i, 1);
740
768
  }
741
769
  }
742
770
  }
743
771
 
744
772
  // -- HOOK 4: INTEGRATION COMPLETION DRIVES RETENTION ------
745
773
  // Users with 3+ integration events get cloned report events.
746
- const integrationCount = events.filter(e => e.event === "integration connected").length;
774
+ const integrationCount = record.filter(e => e.event === "integration connected").length;
747
775
  if (integrationCount >= 3) {
748
- const templateReport = events.find(e => e.event === "report generated");
776
+ const templateReport = record.find(e => e.event === "report generated");
749
777
  if (templateReport) {
750
- const integrationEvents = events.filter(e => e.event === "integration connected");
778
+ const integrationEvents = record.filter(e => e.event === "integration connected");
751
779
  integrationEvents.forEach(ie => {
752
780
  if (chance.bool({ likelihood: 65 })) {
753
- events.push({
781
+ record.push({
754
782
  ...templateReport,
755
783
  time: dayjs(ie.time).add(chance.integer({ min: 1, max: 5 }), "days").toISOString(),
756
784
  user_id: ie.user_id,
@@ -764,7 +792,7 @@ const config = {
764
792
 
765
793
  // -- HOOK 5: ALERT FATIGUE --------------------------------
766
794
  // Users with >30 stockout alerts get increasing response times.
767
- const alertEvents = events.filter(e => e.event === "stockout alert");
795
+ const alertEvents = record.filter(e => e.event === "stockout alert");
768
796
  if (alertEvents.length > 30) {
769
797
  alertEvents.forEach((alert, idx) => {
770
798
  if (idx >= 20) {
@@ -776,14 +804,14 @@ const config = {
776
804
 
777
805
  // -- HOOK 6: TRIAL CHURN ----------------------------------
778
806
  // Users with <10 events lose 50% after day 7.
779
- if (events.length < 10) {
780
- const userFirstEvent = events[0];
807
+ if (record.length < 10) {
808
+ const userFirstEvent = record[0];
781
809
  if (userFirstEvent) {
782
810
  const firstTime = dayjs(userFirstEvent.time);
783
811
  const cutoff = firstTime.add(7, "days");
784
- for (let i = events.length - 1; i >= 0; i--) {
785
- if (dayjs(events[i].time).isAfter(cutoff) && chance.bool({ likelihood: 50 })) {
786
- events.splice(i, 1);
812
+ for (let i = record.length - 1; i >= 0; i--) {
813
+ if (dayjs(record[i].time).isAfter(cutoff) && chance.bool({ likelihood: 50 })) {
814
+ record.splice(i, 1);
787
815
  }
788
816
  }
789
817
  }
@@ -475,7 +475,7 @@
475
475
  }
476
476
  ],
477
477
  "superProps": {
478
- "platform": [
478
+ "Platform": [
479
479
  "ios",
480
480
  "android",
481
481
  "web"
@@ -1,16 +1,23 @@
1
+ // ── TWEAK THESE ──
2
+ const SEED = "dm4-marketplace";
3
+ const num_days = 100;
4
+ const num_users = 5_000;
5
+ const avg_events_per_user = 120;
6
+ let token = "your-mixpanel-token";
7
+
8
+ // ── env overrides ──
9
+ if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
10
+
1
11
  import dayjs from "dayjs";
2
12
  import utc from "dayjs/plugin/utc.js";
3
13
  import "dotenv/config";
4
14
  import * as u from "../../lib/utils/utils.js";
5
15
  import * as v from "ak-tools";
6
16
 
7
- const SEED = "dm4-marketplace";
8
17
  dayjs.extend(utc);
9
18
  const chance = u.initChance(SEED);
10
- const num_users = 5_000;
11
- const days = 100;
12
19
  const NOW = dayjs();
13
- const DATASET_START = NOW.subtract(days, "days");
20
+ const DATASET_START = NOW.subtract(num_days, "days");
14
21
 
15
22
  /** @typedef {import("../../types").Dungeon} Config */
16
23
 
@@ -213,12 +220,12 @@ const listingIds = v.range(1, 500).map(() => `LST_${v.uid(8)}`);
213
220
  * high transaction volumes, and near-perfect ratings.
214
221
  *
215
222
  * ───────────────────────────────────────────────────────────────
216
- * 8. FREQUENT BUYER FUNNEL LIFT (funnel-pre hook)
223
+ * 8. FREQUENT BUYER FUNNEL LIFT (everything hook)
217
224
  * ───────────────────────────────────────────────────────────────
218
225
  *
219
- * PATTERN: Users with segment "frequent_buyer" get 1.3x conversion
220
- * rate on the browse-to-purchase funnel. Repeat buyers know what
221
- * they want and convert faster.
226
+ * PATTERN: Non-frequent-buyer users lose ~25% of "purchase completed"
227
+ * events (last step of Browse to Purchase funnel), simulating lower
228
+ * conversion. Frequent buyers retain all purchase events.
222
229
  *
223
230
  * HOW TO FIND IT IN MIXPANEL:
224
231
  *
@@ -249,10 +256,10 @@ const listingIds = v.range(1, 500).map(() => `LST_${v.uid(8)}`);
249
256
 
250
257
  /** @type {Config} */
251
258
  const config = {
252
- token: "",
259
+ token,
253
260
  seed: SEED,
254
- numDays: days,
255
- numEvents: num_users * 120,
261
+ numDays: num_days,
262
+ numEvents: num_users * avg_events_per_user,
256
263
  numUsers: num_users,
257
264
  hasAnonIds: false,
258
265
  hasSessionIds: true,
@@ -502,7 +509,7 @@ const config = {
502
509
 
503
510
  // ── SuperProps ──────────────────────────────────────────
504
511
  superProps: {
505
- platform: ["ios", "android", "web"],
512
+ Platform: ["ios", "android", "web"],
506
513
  category: ["electronics", "clothing", "home_garden", "collectibles", "sports", "toys", "automotive"],
507
514
  },
508
515
 
@@ -514,6 +521,8 @@ const config = {
514
521
  total_transactions: [0],
515
522
  response_time_hours: u.weighNumRange(0, 48, 0.3),
516
523
  store_name: ["none"],
524
+ Platform: ["ios", "android", "web"],
525
+ category: ["electronics", "clothing", "home_garden", "collectibles", "sports", "toys", "automotive"],
517
526
  },
518
527
 
519
528
  // ── Personas ──────────────────────────────────
@@ -721,13 +730,9 @@ const config = {
721
730
  }
722
731
 
723
732
  // ── HOOK 8: FREQUENT BUYER FUNNEL LIFT (funnel-pre) ──
724
- // Frequent buyers convert 1.3x better through browse-to-purchase.
733
+ // (conversionRate filtering moved to everything hook)
725
734
  if (type === "funnel-pre") {
726
- if (meta && meta.profile) {
727
- if (meta.profile.segment === "frequent_buyer") {
728
- record.conversionRate = Math.min(record.conversionRate * 1.3, 90);
729
- }
730
- }
735
+ // no-op: conversion filtering handled via event dropping in everything hook
731
736
  }
732
737
 
733
738
  // ── HOOK 1: FEE CHANGE IMPACT (event) ────────────────
@@ -755,11 +760,31 @@ const config = {
755
760
 
756
761
  // ── EVERYTHING HOOKS ─────────────────────────────────
757
762
  if (type === "everything") {
758
- const events = record;
763
+ let events = record;
759
764
  if (!events.length) return record;
760
765
 
761
766
  const profile = meta.profile;
762
767
 
768
+ // ── SUPERPROP STAMPING ──────────────────────────
769
+ // Stamp superProps from profile so they are consistent per user.
770
+ if (profile) {
771
+ events.forEach(e => {
772
+ if (profile.Platform) e.Platform = profile.Platform;
773
+ if (profile.category) e.category = profile.category;
774
+ });
775
+ }
776
+
777
+ // ── HOOK 8: FREQUENT BUYER CONVERSION FILTER ────
778
+ // Non-frequent-buyer users drop ~25% of "purchase completed"
779
+ // (last step of Browse to Purchase funnel) to simulate lower conversion.
780
+ if (profile && profile.segment !== "frequent_buyer") {
781
+ record = record.filter(e => {
782
+ if (e.event === "purchase completed" && chance.bool({ likelihood: 25 })) return false;
783
+ return true;
784
+ });
785
+ events = record;
786
+ }
787
+
763
788
  // ── HOOK 3: SELLER SUCCESS → BUYER TRUST ─────────
764
789
  // Power sellers get 2x purchase events (cloned from existing).
765
790
  if (profile && profile.segment === "power_seller") {
@@ -1,14 +1,21 @@
1
+ // ── TWEAK THESE ──
2
+ const SEED = "harness-media";
3
+ const num_days = 100;
4
+ const num_users = 5_000;
5
+ const avg_events_per_user = 120;
6
+ let token = "your-mixpanel-token";
7
+
8
+ // ── env overrides ──
9
+ if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
10
+
1
11
  import dayjs from "dayjs";
2
12
  import utc from "dayjs/plugin/utc.js";
3
13
  import "dotenv/config";
4
14
  import * as u from "../../lib/utils/utils.js";
5
15
  import * as v from "ak-tools";
6
16
 
7
- const SEED = "harness-media";
8
17
  dayjs.extend(utc);
9
18
  const chance = u.initChance(SEED);
10
- const num_users = 5_000;
11
- const days = 100;
12
19
 
13
20
  /** @typedef {import("../../types").Dungeon} Config */
14
21
 
@@ -218,10 +225,10 @@ const blockbusterId = `blockbuster_${v.uid(8)}`;
218
225
 
219
226
  /** @type {Config} */
220
227
  const config = {
221
- token: "",
228
+ token,
222
229
  seed: SEED,
223
- numDays: days,
224
- numEvents: num_users * 120,
230
+ numDays: num_days,
231
+ numEvents: num_users * avg_events_per_user,
225
232
  numUsers: num_users,
226
233
  hasAnonIds: false,
227
234
  hasSessionIds: true,
@@ -555,6 +562,8 @@ const config = {
555
562
  "total_watch_hours": u.weighNumRange(0, 500, 0.8, 50),
556
563
  "profiles_count": u.weighNumRange(1, 5),
557
564
  "downloads_enabled": [false, false, false, true, true],
565
+ "subscription_plan": ["free", "free", "standard", "standard", "standard", "premium"],
566
+ "device_type": ["smart_tv", "mobile", "tablet", "laptop", "desktop"],
558
567
  },
559
568
 
560
569
  lookupTables: [],
@@ -564,22 +573,22 @@ const config = {
564
573
  *
565
574
  * This hook function creates 8 deliberate patterns in the data:
566
575
  *
567
- * 1. GENRE FUNNEL CONVERSION: Comedy/Animation complete more; Documentary abandons more (funnel-pre)
576
+ * 1. GENRE FUNNEL CONVERSION: Comedy/Animation props in funnel-pre; documentary completion drop in everything
568
577
  * 2. BINGE-WATCHING: Users with 3+ consecutive completions get extra episodes (everything)
569
578
  * 3. WEEKEND vs WEEKDAY: Weekend sessions 1.5x longer; weekday prime-time tagging (event)
570
579
  * 4. AD FATIGUE CHURN: Free-tier users with 10+ ads churn after day 45 (everything)
571
580
  * 5. NEW RELEASE SPIKE: Blockbuster release on day 50 drives content selection (event)
572
581
  * 6. KIDS PROFILE SAFETY: Kids mode restricts genres and drops ads (event)
573
- * 7. RECOMMENDATION ENGINE IMPROVEMENT: Post-day-60 boost to engagement funnel (funnel-pre)
582
+ * 7. RECOMMENDATION ENGINE IMPROVEMENT: Props in funnel-pre; pre-day-60 content rated drop in everything
574
583
  * 8. SUBTITLE USERS WATCH MORE: Subtitle-enabled users have higher completion rates (everything)
575
584
  */
576
585
  hook: function (record, type, meta) {
577
586
  const NOW = dayjs();
578
- const DATASET_START = NOW.subtract(days, 'days');
587
+ const DATASET_START = NOW.subtract(num_days, 'days');
579
588
  // FIXED_START is the pre-shift time range start; needed because
580
589
  // meta.firstEventTime in funnel-pre is in the FIXED (pre-shift) timeframe
581
590
  const FIXED_NOW_UNIX = dayjs('2024-02-02').unix();
582
- const FIXED_START = dayjs.unix(FIXED_NOW_UNIX).subtract(days, 'days');
591
+ const FIXED_START = dayjs.unix(FIXED_NOW_UNIX).subtract(num_days, 'days');
583
592
 
584
593
  // ─────────────────────────────────────────────────────────────
585
594
  // Hook #1: GENRE FUNNEL CONVERSION (funnel-pre)
@@ -590,21 +599,17 @@ const config = {
590
599
  const genre = record.props.genre;
591
600
 
592
601
  if (genre === "comedy" || genre === "animation") {
593
- record.conversionRate = record.conversionRate * 1.3;
594
602
  record.props.genre_boost = true;
595
603
  record.props.genre_penalty = false;
596
604
  } else if (genre === "documentary") {
597
- record.conversionRate = record.conversionRate * 0.7;
598
605
  record.props.genre_boost = false;
599
606
  record.props.genre_penalty = true;
600
607
  } else if (!genre && chance.bool({ likelihood: 25 })) {
601
608
  // Randomly apply genre effects when genre isn't in funnel props
602
609
  if (chance.bool({ likelihood: 60 })) {
603
- record.conversionRate = record.conversionRate * 1.3;
604
610
  record.props.genre_boost = true;
605
611
  record.props.genre_penalty = false;
606
612
  } else {
607
- record.conversionRate = record.conversionRate * 0.7;
608
613
  record.props.genre_boost = false;
609
614
  record.props.genre_penalty = true;
610
615
  }
@@ -623,7 +628,6 @@ const config = {
623
628
  const isAfterImprovement = funnelTime && funnelTime.isAfter(FIXED_START.add(60, 'days'));
624
629
 
625
630
  if (isEngagementFunnel && isAfterImprovement) {
626
- record.conversionRate = record.conversionRate * 1.5;
627
631
  record.props.improved_recs = true;
628
632
  } else {
629
633
  record.props.improved_recs = false;
@@ -711,6 +715,43 @@ const config = {
711
715
  const userEvents = record;
712
716
  if (!userEvents || userEvents.length === 0) return record;
713
717
 
718
+ const profile = meta.profile;
719
+
720
+ // ─── Stamp superProps from profile (consistent per user) ───
721
+ const stampPlan = profile && profile.subscription_plan ? profile.subscription_plan : undefined;
722
+ const stampDevice = profile && profile.device_type ? profile.device_type : undefined;
723
+ if (stampPlan || stampDevice) {
724
+ userEvents.forEach(e => {
725
+ if (stampPlan) e.subscription_plan = stampPlan;
726
+ if (stampDevice) e.device_type = stampDevice;
727
+ });
728
+ }
729
+
730
+ // ─── Bug 2 fix: Genre funnel conversion filtering ───
731
+ // Drop ~25% of "playback completed" events for documentary genre
732
+ // to simulate lower documentary funnel completion (was conversionRate * 0.7)
733
+ for (let i = userEvents.length - 1; i >= 0; i--) {
734
+ const evt = userEvents[i];
735
+ if (evt.event === "playback completed" && evt.genre_penalty === true) {
736
+ if (chance.bool({ likelihood: 25 })) {
737
+ userEvents.splice(i, 1);
738
+ }
739
+ }
740
+ }
741
+
742
+ // ─── Bug 2 fix: Recommendation engine improvement filtering ───
743
+ // Drop ~30% of "content rated" events that occur BEFORE day 60
744
+ // to simulate lower pre-improvement rec engine conversion (was conversionRate * 1.5)
745
+ const IMPROVEMENT_DATE = DATASET_START.add(60, 'days');
746
+ for (let i = userEvents.length - 1; i >= 0; i--) {
747
+ const evt = userEvents[i];
748
+ if (evt.event === "content rated" && dayjs(evt.time).isBefore(IMPROVEMENT_DATE)) {
749
+ if (chance.bool({ likelihood: 30 })) {
750
+ userEvents.splice(i, 1);
751
+ }
752
+ }
753
+ }
754
+
714
755
  const firstEventTime = dayjs(userEvents[0].time);
715
756
 
716
757
  // ─── First pass: identify user patterns ───
@@ -2389,7 +2389,7 @@
2389
2389
  }
2390
2390
  ],
2391
2391
  "superProps": {
2392
- "platform": [
2392
+ "Platform": [
2393
2393
  "PC",
2394
2394
  "Mac",
2395
2395
  "PlayStation",