@ak--47/dungeon-master 1.3.1 → 1.4.0

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 (51) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dungeons/technical/hook-helpers-verify.js +89 -0
  3. package/dungeons/technical/identity-model-verify.js +47 -0
  4. package/dungeons/technical/pattern-aggregate-by-bin.js +41 -0
  5. package/dungeons/technical/pattern-attributed-by-source.js +42 -0
  6. package/dungeons/technical/pattern-frequency-by-frequency.js +40 -0
  7. package/dungeons/technical/pattern-funnel-frequency.js +54 -0
  8. package/dungeons/technical/pattern-ttc-by-segment.js +45 -0
  9. package/dungeons/vertical/ai-platform.js +45 -52
  10. package/dungeons/vertical/community.js +11 -8
  11. package/dungeons/vertical/crypto.js +25 -24
  12. package/dungeons/vertical/dating.js +56 -48
  13. package/dungeons/vertical/devtools.js +25 -18
  14. package/dungeons/vertical/ecommerce.js +42 -38
  15. package/dungeons/vertical/education.js +24 -9
  16. package/dungeons/vertical/fintech.js +13 -8
  17. package/dungeons/vertical/fitness.js +73 -122
  18. package/dungeons/vertical/food-delivery.js +18 -19
  19. package/dungeons/vertical/gaming.js +19 -20
  20. package/dungeons/vertical/healthcare.js +11 -8
  21. package/dungeons/vertical/insurance-application.js +6 -3
  22. package/dungeons/vertical/logistics.js +15 -9
  23. package/dungeons/vertical/marketplace.js +36 -27
  24. package/dungeons/vertical/media.js +27 -25
  25. package/dungeons/vertical/real-estate.js +18 -7
  26. package/dungeons/vertical/sass.js +84 -68
  27. package/dungeons/vertical/social.js +46 -47
  28. package/dungeons/vertical/travel.js +8 -5
  29. package/lib/core/config-validator.js +136 -157
  30. package/lib/generators/events.js +49 -93
  31. package/lib/generators/funnels.js +202 -91
  32. package/lib/hook-helpers/_internal.js +23 -0
  33. package/lib/hook-helpers/cohort.js +124 -0
  34. package/lib/hook-helpers/identity.js +56 -0
  35. package/lib/hook-helpers/index.js +44 -0
  36. package/lib/hook-helpers/inject.js +99 -0
  37. package/lib/hook-helpers/mutate.js +151 -0
  38. package/lib/hook-helpers/timing.js +99 -0
  39. package/lib/hook-patterns/aggregate-per-user-by-bin.js +38 -0
  40. package/lib/hook-patterns/attributed-by-source.js +72 -0
  41. package/lib/hook-patterns/frequency-by-frequency.js +46 -0
  42. package/lib/hook-patterns/funnel-frequency-breakdown.js +73 -0
  43. package/lib/hook-patterns/index.js +14 -0
  44. package/lib/hook-patterns/time-to-convert-by-segment.js +41 -0
  45. package/lib/orchestrators/user-loop.js +119 -269
  46. package/lib/utils/utils.js +29 -16
  47. package/lib/verify/emulate-breakdown.js +281 -0
  48. package/lib/verify/index.js +12 -0
  49. package/lib/verify/verify-dungeon.js +61 -0
  50. package/package.json +6 -4
  51. package/types.d.ts +397 -211
@@ -1,7 +1,7 @@
1
1
  // ── TWEAK THESE ──
2
2
  const SEED = "dm4-logistics";
3
- const num_days = 100;
4
- const num_users = 5_000;
3
+ const num_days = 120;
4
+ const num_users = 10_000;
5
5
  const avg_events_per_user_per_day = 1.2;
6
6
  let token = "your-mixpanel-token";
7
7
 
@@ -31,7 +31,7 @@ const supplierIds = v.range(1, 150).map(() => `SUP_${v.uid(6)}`);
31
31
  * businesses to track stock levels, purchase orders, suppliers,
32
32
  * shipments, and quality inspections across multiple warehouses.
33
33
  *
34
- * - 5,000 users over 100 days, ~600K events
34
+ * - 10,000 users over 120 days
35
35
  * - Multi-tier system: enterprise (15%), mid-market (35%), small business (40%), trial (10%)
36
36
  * - Core loop: sign up -> check inventory -> create PO -> receive order -> track shipment
37
37
  * - Revenue: free_trial / starter ($49) / professional ($199) / enterprise ($599)
@@ -65,7 +65,7 @@ const supplierIds = v.range(1, 150).map(() => `SUP_${v.uid(6)}`);
65
65
  * 1. MONTH-END REPORTING SURGE (event hook)
66
66
  * -------------------------------------------------------------------
67
67
  *
68
- * PATTERN: Reports generated on calendar days 28-31 have 2x the
68
+ * PATTERN: Reports generated on calendar days 28-31 have 2.5x the
69
69
  * report_pages. Simulates end-of-month compliance and audit reporting.
70
70
  *
71
71
  * HOW TO FIND IT IN MIXPANEL:
@@ -309,14 +309,16 @@ const supplierIds = v.range(1, 150).map(() => `SUP_${v.uid(6)}`);
309
309
 
310
310
  /** @type {Config} */
311
311
  const config = {
312
+ version: 2,
312
313
  token,
313
314
  seed: SEED,
314
315
  datasetStart: "2026-01-01T00:00:00Z",
315
- datasetEnd: "2026-04-28T23:59:59Z",
316
+ datasetEnd: "2026-05-01T23:59:59Z",
316
317
  // numDays: num_days,
317
318
  avgEventsPerUserPerDay: avg_events_per_user_per_day,
318
319
  numUsers: num_users,
319
- hasAnonIds: false,
320
+ hasAnonIds: true,
321
+ avgDevicePerUser: 2,
320
322
  hasSessionIds: true,
321
323
  format: "json",
322
324
  gzip: true,
@@ -349,6 +351,7 @@ const config = {
349
351
  event: "account created",
350
352
  weight: 1,
351
353
  isFirstEvent: true,
354
+ isAuthEvent: true,
352
355
  properties: {
353
356
  referral_source: ["organic", "partner_referral", "google_search", "trade_show", "linkedin"],
354
357
  },
@@ -771,6 +774,9 @@ const config = {
771
774
  } else if (record.company_tier === "mid_market") {
772
775
  record.warehouse_count = chance.integer({ min: 2, max: 6 });
773
776
  record.employee_count = chance.integer({ min: 20, max: 200 });
777
+ } else if (record.company_tier === "small_business") {
778
+ record.warehouse_count = chance.integer({ min: 1, max: 3 });
779
+ record.employee_count = chance.integer({ min: 5, max: 80 });
774
780
  } else if (record.company_tier === "trial") {
775
781
  record.warehouse_count = 1;
776
782
  record.employee_count = chance.integer({ min: 1, max: 10 });
@@ -818,7 +824,7 @@ const config = {
818
824
  if (e.event === 'report generated') {
819
825
  const dayOfMonth = new Date(e.time).getUTCDate();
820
826
  if (dayOfMonth >= 28) {
821
- e.report_pages = Math.floor((e.report_pages || 20) * 2);
827
+ e.report_pages = Math.floor((e.report_pages || 20) * 2.5);
822
828
  }
823
829
  }
824
830
  }
@@ -896,7 +902,7 @@ const config = {
896
902
 
897
903
  // -- HOOK 9: INVENTORY-CHECK MAGIC NUMBER (no flags) ------
898
904
  // Sweet 5-15 inventory checks → +25% PO quantity.
899
- // Over 16+ → drop 30% of PO created events.
905
+ // Over 16+ → drop 45% of PO created events.
900
906
  const invCheckCount = record.filter(e => e.event === 'inventory checked').length;
901
907
  if (invCheckCount >= 5 && invCheckCount <= 15) {
902
908
  record.forEach(e => {
@@ -906,7 +912,7 @@ const config = {
906
912
  });
907
913
  } else if (invCheckCount >= 16) {
908
914
  for (let i = record.length - 1; i >= 0; i--) {
909
- if (record[i].event === 'purchase order created' && chance.bool({ likelihood: 30 })) {
915
+ if (record[i].event === 'purchase order created' && chance.bool({ likelihood: 45 })) {
910
916
  record.splice(i, 1);
911
917
  }
912
918
  }
@@ -1,7 +1,7 @@
1
1
  // ── TWEAK THESE ──
2
2
  const SEED = "dm4-marketplace";
3
- const num_days = 100;
4
- const num_users = 5_000;
3
+ const num_days = 120;
4
+ const num_users = 10_000;
5
5
  const avg_events_per_user_per_day = 1.2;
6
6
  let token = "your-mixpanel-token";
7
7
 
@@ -303,14 +303,16 @@ const listingIds = v.range(1, 500).map(() => `LST_${v.uid(8)}`);
303
303
 
304
304
  /** @type {Config} */
305
305
  const config = {
306
+ version: 2,
306
307
  token,
307
308
  seed: SEED,
308
309
  datasetStart: "2026-01-01T00:00:00Z",
309
- datasetEnd: "2026-04-28T23:59:59Z",
310
+ datasetEnd: "2026-05-01T23:59:59Z",
310
311
  // numDays: num_days,
311
312
  avgEventsPerUserPerDay: avg_events_per_user_per_day,
312
313
  numUsers: num_users,
313
- hasAnonIds: false,
314
+ hasAnonIds: true,
315
+ avgDevicePerUser: 2,
314
316
  hasSessionIds: true,
315
317
  format: "json",
316
318
  gzip: true,
@@ -343,6 +345,7 @@ const config = {
343
345
  event: "account created",
344
346
  weight: 1,
345
347
  isFirstEvent: true,
348
+ isAuthEvent: true,
346
349
  properties: {
347
350
  signup_source: ["organic", "google_shopping", "tiktok", "seller_referral", "email_campaign", "word_of_mouth"],
348
351
  },
@@ -882,38 +885,44 @@ const config = {
882
885
  }
883
886
 
884
887
  // ── HOOK 5: RESPONSE TIME → CONVERSION ───────────
885
- // Sellers with avg response_time < 12 hours get more offer_accepted.
886
- // (response_time_hours centers around ~6h via weighNumRange, so a
887
- // threshold of 2h was too tight — almost no users qualified.)
888
+ // Users with low avg response_time on messages get extra
889
+ // offer_accepted clones; slow responders lose half their accepts.
888
890
  const messages = events.filter(e => e.event === "message sent" && e.response_time_hours);
889
891
  if (messages.length > 0) {
890
892
  const avgResponseTime = messages.reduce((sum, m) => sum + m.response_time_hours, 0) / messages.length;
891
- if (avgResponseTime < 12) {
892
- const templateOffer = events.find(e => e.event === "offer accepted");
893
- if (templateOffer) {
894
- const offers = events.filter(e => e.event === "offer received");
895
- offers.forEach(offer => {
896
- if (chance.bool({ likelihood: 50 })) {
897
- events.push({
898
- ...templateOffer,
899
- time: dayjs(offer.time).add(chance.integer({ min: 1, max: 4 }), "hours").toISOString(),
900
- user_id: offer.user_id,
901
- response_time_hours: chance.floating({ min: 0.1, max: 2, fixed: 1 }),
902
- });
903
- }
893
+ const templateOffer = events.find(e => e.event === "offer accepted");
894
+ if (avgResponseTime <= 11 && templateOffer) {
895
+ // Fast responders: clone every offer_received into an offer_accepted
896
+ const offers = events.filter(e => e.event === "offer received");
897
+ offers.forEach(offer => {
898
+ events.push({
899
+ ...templateOffer,
900
+ time: dayjs(offer.time).add(chance.integer({ min: 1, max: 4 }), "hours").toISOString(),
901
+ user_id: offer.user_id,
902
+ response_time_hours: chance.floating({ min: 0.1, max: 2, fixed: 1 }),
904
903
  });
904
+ });
905
+ } else if (avgResponseTime >= 25 && templateOffer) {
906
+ // Slow responders: drop half of offer_accepted events
907
+ for (let i = events.length - 1; i >= 0; i--) {
908
+ if (events[i].event === "offer accepted" && chance.bool({ likelihood: 50 })) {
909
+ events.splice(i, 1);
910
+ }
905
911
  }
906
912
  }
907
913
  }
908
914
 
909
915
  // ── HOOK 6: NEW SELLER CHURN ─────────────────────
910
- // New sellers with <10 events lose 40% of events after day 14.
911
- if (profile && profile.segment === "new_seller" && events.length < 10) {
912
- const DAY_14 = datasetStart.add(14, "days");
913
- for (let i = events.length - 1; i >= 0; i--) {
914
- const eventTime = dayjs(events[i].time);
915
- if (eventTime.isAfter(DAY_14) && chance.bool({ likelihood: 40 })) {
916
- events.splice(i, 1);
916
+ // New sellers lose 50% of events after their first 14 days.
917
+ // Uses user's first event as anchor (not dataset start).
918
+ if (profile && profile.segment === "new_seller") {
919
+ const firstEventTime = events.length > 0 ? dayjs(events[0].time) : null;
920
+ if (firstEventTime) {
921
+ const userDay14 = firstEventTime.add(14, "days");
922
+ for (let i = events.length - 1; i >= 0; i--) {
923
+ if (dayjs(events[i].time).isAfter(userDay14) && chance.bool({ likelihood: 50 })) {
924
+ events.splice(i, 1);
925
+ }
917
926
  }
918
927
  }
919
928
  }
@@ -1,7 +1,7 @@
1
1
  // ── TWEAK THESE ──
2
2
  const SEED = "harness-media";
3
- const num_days = 100;
4
- const num_users = 5_000;
3
+ const num_days = 120;
4
+ const num_users = 10_000;
5
5
  const avg_events_per_user_per_day = 1.2;
6
6
  let token = "your-mixpanel-token";
7
7
 
@@ -29,7 +29,7 @@ const chance = u.initChance(SEED);
29
29
  * content with configurable playback options, rate and share content, and manage family
30
30
  * profiles under a single account.
31
31
  *
32
- * - 5,000 users over 100 days
32
+ * - 10,000 users over 120 days
33
33
  * - ~600,000 events across 17 event types
34
34
  * - 9 funnels (onboarding, content discovery, engagement loop, search, watchlist, etc.)
35
35
  * - Subscription tiers: Free (ad-supported), Standard ($9.99/mo), Premium ($14.99/mo)
@@ -271,14 +271,16 @@ const blockbusterId = `blockbuster_${v.uid(8)}`;
271
271
 
272
272
  /** @type {Config} */
273
273
  const config = {
274
+ version: 2,
274
275
  token,
275
276
  seed: SEED,
276
277
  datasetStart: "2026-01-01T00:00:00Z",
277
- datasetEnd: "2026-04-28T23:59:59Z",
278
+ datasetEnd: "2026-05-01T23:59:59Z",
278
279
  // numDays: num_days,
279
280
  avgEventsPerUserPerDay: avg_events_per_user_per_day,
280
281
  numUsers: num_users,
281
- hasAnonIds: false,
282
+ hasAnonIds: true,
283
+ avgDevicePerUser: 2,
282
284
  hasSessionIds: true,
283
285
  format: "json",
284
286
  gzip: true,
@@ -368,6 +370,7 @@ const config = {
368
370
  event: "account created",
369
371
  weight: 1,
370
372
  isFirstEvent: true,
373
+ isAuthEvent: true,
371
374
  properties: {
372
375
  "signup_source": ["organic", "referral", "trial_offer", "ad"],
373
376
  "plan_selected": ["free", "standard", "premium"],
@@ -563,26 +566,6 @@ const config = {
563
566
  }
564
567
 
565
568
  if (type === "event") {
566
- const datasetStart = dayjs.unix(meta.datasetStart);
567
- const BLOCKBUSTER_START = datasetStart.add(50, 'days');
568
- const BLOCKBUSTER_END = datasetStart.add(65, 'days');
569
- const EVENT_TIME = dayjs(record.time);
570
-
571
- // Hook #5: NEW RELEASE SPIKE — days 50-65, 20% of selections/starts
572
- // switch to the blockbuster id. Mutates existing content_id/content_type props.
573
- if (record.event === "content selected" || record.event === "playback started") {
574
- if (EVENT_TIME.isAfter(BLOCKBUSTER_START) && EVENT_TIME.isBefore(BLOCKBUSTER_END) && chance.bool({ likelihood: 20 })) {
575
- record.content_type = "movie";
576
- record.content_id = blockbusterId;
577
- }
578
- }
579
- if (record.event === "content rated") {
580
- if (EVENT_TIME.isAfter(BLOCKBUSTER_START) && EVENT_TIME.isBefore(BLOCKBUSTER_END) && chance.bool({ likelihood: 20 })) {
581
- record.rating = chance.integer({ min: 4, max: 5 });
582
- record.content_id = blockbusterId;
583
- }
584
- }
585
-
586
569
  // Hook #6: KIDS PROFILE SAFETY — 15% of selections/starts get genre
587
570
  // restricted to animation or documentary. Mutates existing genre prop.
588
571
  if (chance.bool({ likelihood: 15 })) {
@@ -611,6 +594,25 @@ const config = {
611
594
  });
612
595
  }
613
596
 
597
+ // Hook #5: NEW RELEASE SPIKE — days 50-65, 20% of selections/starts
598
+ // switch to the blockbuster id. Mutates existing content_id/content_type props.
599
+ // (Moved from event hook to everything hook per L1: temporal checks belong here.)
600
+ const BLOCKBUSTER_START = datasetStart.add(50, 'days');
601
+ const BLOCKBUSTER_END = datasetStart.add(65, 'days');
602
+ for (const e of userEvents) {
603
+ const eventTime = dayjs(e.time);
604
+ if (eventTime.isAfter(BLOCKBUSTER_START) && eventTime.isBefore(BLOCKBUSTER_END)) {
605
+ if ((e.event === "content selected" || e.event === "playback started") && chance.bool({ likelihood: 20 })) {
606
+ e.content_type = "movie";
607
+ e.content_id = blockbusterId;
608
+ }
609
+ if (e.event === "content rated" && chance.bool({ likelihood: 20 })) {
610
+ e.rating = chance.integer({ min: 4, max: 5 });
611
+ e.content_id = blockbusterId;
612
+ }
613
+ }
614
+ }
615
+
614
616
  // Hook #3: WEEKEND VS WEEKDAY — 1.5x watch_duration_min on weekends.
615
617
  // No flag — analyst breaks down by day of week.
616
618
  for (const e of userEvents) {
@@ -1,7 +1,7 @@
1
1
  // ── TWEAK THESE ──
2
2
  const SEED = "homenest";
3
- const num_days = 150;
4
- const num_users = 6_000;
3
+ const num_days = 120;
4
+ const num_users = 10_000;
5
5
  const avg_events_per_user_per_day = 0.53;
6
6
  let token = "your-mixpanel-token";
7
7
 
@@ -267,14 +267,16 @@ const chance = u.initChance(SEED);
267
267
 
268
268
  /** @type {Config} */
269
269
  const config = {
270
+ version: 2,
270
271
  token,
271
272
  seed: SEED,
272
273
  datasetStart: "2026-01-01T00:00:00Z",
273
- datasetEnd: "2026-04-28T23:59:59Z",
274
+ datasetEnd: "2026-05-01T23:59:59Z",
274
275
  // numDays: num_days,
275
276
  avgEventsPerUserPerDay: avg_events_per_user_per_day,
276
277
  numUsers: num_users,
277
- hasAnonIds: false,
278
+ hasAnonIds: true,
279
+ avgDevicePerUser: 2,
278
280
  hasSessionIds: true,
279
281
  format: "json",
280
282
  gzip: true,
@@ -333,6 +335,7 @@ const config = {
333
335
  event: "account created",
334
336
  weight: 1,
335
337
  isFirstEvent: true,
338
+ isAuthEvent: true,
336
339
  properties: {
337
340
  signup_method: ["email", "google", "apple", "facebook"],
338
341
  }
@@ -560,9 +563,7 @@ const config = {
560
563
  if (inSpring && e.event === "tour scheduled" && typeof e.duration_mins === "number") {
561
564
  e.duration_mins = Math.round(e.duration_mins * 3);
562
565
  }
563
- if (inSpring && e.event === "offer submitted") {
564
- e.offer_price = Math.floor((e.offer_price || 400000) * 2.5);
565
- }
566
+ // Spring offer_price boost moved to end (after all cloning)
566
567
  if (e.event === "mortgage pre-approval") {
567
568
  if ((t.isAfter(shockStart) || t.isSame(shockStart)) && t.isBefore(shockEnd)) {
568
569
  e.mortgage_rate = 7.5;
@@ -772,6 +773,16 @@ const config = {
772
773
  }
773
774
  }
774
775
  }
776
+
777
+ // HOOK 1 (cont): Spring offer_price boost — runs AFTER all cloning
778
+ // so cloned offers in the spring window also get the 2.5x boost
779
+ userEvents.forEach(e => {
780
+ if (e.event !== "offer submitted") return;
781
+ const t = dayjs.utc(e.time);
782
+ if ((t.isAfter(springStart) || t.isSame(springStart)) && t.isBefore(springEnd)) {
783
+ e.offer_price = Math.floor((e.offer_price || 400000) * 2.5);
784
+ }
785
+ });
775
786
  }
776
787
 
777
788
  return record;
@@ -1,7 +1,7 @@
1
1
  // ── TWEAK THESE ──
2
2
  const SEED = "harness-sass";
3
- const num_days = 100;
4
- const num_users = 5_000;
3
+ const num_days = 120;
4
+ const num_users = 10_000;
5
5
  const avg_events_per_user_per_day = 1.2;
6
6
  let token = "your-mixpanel-token";
7
7
 
@@ -67,7 +67,7 @@ const chance = u.initChance(SEED);
67
67
  * 1. END-OF-QUARTER SPIKE (event)
68
68
  * ─────────────────────────────────────────────────────────────────────────────
69
69
  *
70
- * PATTERN: Days 80-90 billing events shift event_type toward "plan_upgraded"
70
+ * PATTERN: Days 100-110 billing events shift event_type toward "plan_upgraded"
71
71
  * 40% of the time and team member invitations are duplicated 50% of the time.
72
72
  * No flag — discover via line chart by day.
73
73
  *
@@ -79,7 +79,7 @@ const chance = u.initChance(SEED);
79
79
  * - Measure: Total
80
80
  * - Filter: event_type = "plan_upgraded"
81
81
  * - Line chart by day
82
- * - Expected: ~4x normal upgrade volume during days 80-90
82
+ * - Expected: ~4x normal upgrade volume during days 100-110
83
83
  *
84
84
  * Report 2: Team Expansion Surge
85
85
  * - Report type: Insights
@@ -322,11 +322,15 @@ const config = {
322
322
  token,
323
323
  seed: SEED,
324
324
  datasetStart: "2026-01-01T00:00:00Z",
325
- datasetEnd: "2026-04-28T23:59:59Z",
325
+ datasetEnd: "2026-05-01T23:59:59Z",
326
326
  // numDays: num_days,
327
327
  avgEventsPerUserPerDay: avg_events_per_user_per_day,
328
328
  numUsers: num_users,
329
- hasAnonIds: false,
329
+ // Phase 2 identity model — B2B SaaS reference. Engineers commonly use 1-2
330
+ // devices (desktop + work laptop). avgDevicePerUser:2 puts a meaningful
331
+ // per-session sticky-device pattern in Mixpanel device dashboards.
332
+ hasAnonIds: true,
333
+ avgDevicePerUser: 2,
330
334
  hasSessionIds: true,
331
335
  format: "json",
332
336
  gzip: true,
@@ -360,10 +364,13 @@ const config = {
360
364
 
361
365
  funnels: [
362
366
  {
367
+ // First funnel — `workspace created` is the auth event for B2B users.
368
+ // Models real B2B onboarding: most teams take ≤1 retry before sticking.
363
369
  sequence: ["workspace created", "service deployed", "dashboard viewed"],
364
370
  isFirstFunnel: true,
365
371
  conversionRate: 70,
366
372
  timeToConvert: 2,
373
+ attempts: { min: 0, max: 1 },
367
374
  },
368
375
  {
369
376
  // Daily monitoring: dashboards, queries, API calls (most common)
@@ -421,6 +428,10 @@ const config = {
421
428
  event: "workspace created",
422
429
  weight: 1,
423
430
  isFirstEvent: true,
431
+ // Phase 2 identity: workspace creation is the B2B equivalent of Sign Up
432
+ // — engine stamps user_id+device_id on this event when it fires inside
433
+ // the user's first funnel.
434
+ isAuthEvent: true,
424
435
  properties: {
425
436
  company_size: ["startup", "smb", "mid_market", "enterprise"],
426
437
  industry: ["tech", "finance", "healthcare", "retail", "media"],
@@ -500,7 +511,7 @@ const config = {
500
511
  weight: 5,
501
512
  properties: {
502
513
  service_id: serviceIds,
503
- scale_direction: ["down", "down", "down", "down", "down", "down", "up"],
514
+ scale_direction: ["up", "up", "up", "down"],
504
515
  previous_capacity: u.weighNumRange(1, 100),
505
516
  new_capacity: u.weighNumRange(1, 100),
506
517
  auto_scaled: [false, false, false, false, false, false, true],
@@ -553,7 +564,7 @@ const config = {
553
564
  event: "billing event",
554
565
  weight: 3,
555
566
  properties: {
556
- event_type: ["invoice_generated", "payment_received", "payment_failed", "plan_upgraded", "plan_downgraded"],
567
+ event_type: ["invoice_generated", "invoice_generated", "payment_received", "payment_received", "payment_received", "payment_failed", "plan_upgraded", "plan_downgraded"],
557
568
  amount: u.weighNumRange(99, 25000),
558
569
  }
559
570
  },
@@ -632,7 +643,7 @@ const config = {
632
643
  *
633
644
  * This hook function creates 8 deliberate patterns in the data:
634
645
  *
635
- * 1. END-OF-QUARTER SPIKE: Days 80-90 drive plan upgrades and team expansion
646
+ * 1. END-OF-QUARTER SPIKE: Days 100-110 drive plan upgrades and team expansion
636
647
  * 2. CHURNED ACCOUNT SILENCING: ~10% of users go completely silent after month 1
637
648
  * 3. ALERT ESCALATION REPLACEMENT: Critical alerts become "incident created" events
638
649
  * 4. INTEGRATION USERS SUCCEED: Slack+PagerDuty users resolve incidents 50-60% faster
@@ -642,29 +653,7 @@ const config = {
642
653
  * 8. ENTERPRISE VS STARTUP: Company size determines seat count, ACV, and health score
643
654
  */
644
655
  hook: function (record, type, meta) {
645
- // HOOK 1: END-OF-QUARTER SPIKE days 80-90, 40% of billing events
646
- // flip event_type to plan_upgraded; team-member-invited events get
647
- // 50% chance of duplicate clone with unique time. No flag.
648
- if (type === "event") {
649
- const datasetStart = dayjs.unix(meta.datasetStart);
650
- const EVENT_TIME = dayjs(record.time);
651
- const dayInDataset = EVENT_TIME.diff(datasetStart, "days", true);
652
-
653
- if (record.event === "billing event") {
654
- if (dayInDataset >= 80 && dayInDataset <= 90 && chance.bool({ likelihood: 40 })) {
655
- record.event_type = "plan_upgraded";
656
- }
657
- }
658
-
659
- if (record.event === "team member invited" && dayInDataset >= 80 && dayInDataset <= 90 && chance.bool({ likelihood: 50 })) {
660
- return {
661
- ...record,
662
- time: EVENT_TIME.add(chance.integer({ min: 1, max: 60 }), "minutes").toISOString(),
663
- role: chance.pickone(["editor", "viewer"]),
664
- invitation_method: chance.pickone(["email", "sso", "slack"]),
665
- };
666
- }
667
- }
656
+ // (Hook 1a moved to everything hook for reliable datasetStart access)
668
657
 
669
658
  // HOOK 3: ALERT ESCALATION REPLACEMENT (event) — critical/emergency
670
659
  // alerts sometimes become incident-created events. Real product flow.
@@ -699,40 +688,6 @@ const config = {
699
688
  }
700
689
  }
701
690
 
702
- // HOOK 7: FAILED DEPLOYMENT RECOVERY (event) — failed deploy records
703
- // user, then next successful deploy gets duration_sec * 1.5. No flag.
704
- if (type === "event") {
705
- if (record.event === "deployment pipeline run") {
706
- if (record.status === "failed") {
707
- failedDeployUsers.set(record.user_id, true);
708
- } else if (record.status === "success" && failedDeployUsers.has(record.user_id)) {
709
- record.duration_sec = Math.floor((record.duration_sec || 300) * 1.5);
710
- failedDeployUsers.delete(record.user_id);
711
- }
712
- }
713
- }
714
-
715
- // HOOK 9 (T2C): INCIDENT RESPONSE TIME-TO-CONVERT (funnel-post)
716
- // Enterprise tier completes Incident Response funnel 1.4x faster
717
- // (factor 0.71); Startup 1.25x slower (factor 1.25).
718
- if (type === "funnel-post") {
719
- const segment = meta?.profile?.company_size;
720
- if (Array.isArray(record) && record.length > 1) {
721
- const factor = (
722
- segment === "enterprise" ? 0.71 :
723
- segment === "startup" ? 1.25 :
724
- 1.0
725
- );
726
- if (factor !== 1.0) {
727
- for (let i = 1; i < record.length; i++) {
728
- const prev = dayjs(record[i - 1].time);
729
- const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
730
- record[i].time = prev.add(newGap, "milliseconds").toISOString();
731
- }
732
- }
733
- }
734
- }
735
-
736
691
  if (type === "everything") {
737
692
  const datasetStart = dayjs.unix(meta.datasetStart);
738
693
  const userEvents = record;
@@ -743,6 +698,33 @@ const config = {
743
698
  e.cloud_provider = profile.cloud_provider;
744
699
  });
745
700
 
701
+ // HOOK 1a: END-OF-QUARTER SPIKE — days 100-110, billing events flip
702
+ // event_type to plan_upgraded 40% of the time. No flag.
703
+ userEvents.forEach(e => {
704
+ if (e.event !== "billing event") return;
705
+ const dayInDataset = dayjs(e.time).diff(datasetStart, "days", true);
706
+ if (dayInDataset >= 100 && dayInDataset <= 110 && chance.bool({ likelihood: 40 })) {
707
+ e.event_type = "plan_upgraded";
708
+ }
709
+ });
710
+
711
+ // HOOK 1b: END-OF-QUARTER TEAM INVITE SPIKE — days 100-110, clone
712
+ // 50% of team-member-invited events (push, not return). No flag.
713
+ for (let i = userEvents.length - 1; i >= 0; i--) {
714
+ const e = userEvents[i];
715
+ if (e.event !== "team member invited") continue;
716
+ const dayInDataset = dayjs(e.time).diff(datasetStart, "days", true);
717
+ if (dayInDataset >= 100 && dayInDataset <= 110 && chance.bool({ likelihood: 50 })) {
718
+ userEvents.push({
719
+ ...e,
720
+ time: dayjs(e.time).add(chance.integer({ min: 1, max: 60 }), "minutes").toISOString(),
721
+ user_id: e.user_id,
722
+ role: chance.pickone(["editor", "viewer"]),
723
+ invitation_method: chance.pickone(["email", "sso", "slack"]),
724
+ });
725
+ }
726
+ }
727
+
746
728
  // HOOK 2: CHURNED ACCOUNT SILENCING — ~20% of users (hash %5)
747
729
  // have post-day-30 events removed. No flag.
748
730
  if (userEvents && userEvents.length > 0) {
@@ -780,10 +762,10 @@ const config = {
780
762
  });
781
763
  }
782
764
 
783
- // HOOK 5 + HOOK 10: DOCS MAGIC NUMBER (in-funnel, no flags)
765
+ // HOOK 5 + HOOK 10: DOCS MAGIC NUMBER (no flags)
784
766
  // Sweet 4-7 documentation-viewed events → +40% extra cloned
785
767
  // service-deployed events. Over 8+ → drop 25% of service-deployed
786
- // events (over-reading; no shipping). No flag.
768
+ // events. No flag.
787
769
  const docsCount = userEvents.filter(e => e.event === "documentation viewed").length;
788
770
  const deployTemplate = userEvents.find(e => e.event === "service deployed");
789
771
  if (docsCount >= 4 && docsCount <= 7 && deployTemplate) {
@@ -807,6 +789,40 @@ const config = {
807
789
  }
808
790
  }
809
791
  }
792
+
793
+ // HOOK 7: FAILED DEPLOYMENT RECOVERY — find failed→success pairs
794
+ // in this user's pipeline events, multiply duration_sec by 1.5 on
795
+ // the recovery deploy. Full control via everything hook.
796
+ const pipelineEvents = userEvents
797
+ .filter(e => e.event === "deployment pipeline run")
798
+ .sort((a, b) => a.time.localeCompare(b.time));
799
+ for (let i = 1; i < pipelineEvents.length; i++) {
800
+ if (pipelineEvents[i - 1].status === "failed" && pipelineEvents[i].status === "success") {
801
+ pipelineEvents[i].duration_sec = Math.floor((pipelineEvents[i].duration_sec || 300) * 1.5);
802
+ }
803
+ }
804
+
805
+ // HOOK 9: INCIDENT RESPONSE TTC — enterprise resolves faster,
806
+ // startup resolves slower. Scale the time gap between alert
807
+ // triggered → acknowledged → resolved for this user.
808
+ const segment = profile?.company_size;
809
+ const ttcFactor = (
810
+ segment === "enterprise" ? 0.5 :
811
+ segment === "startup" ? 1.8 :
812
+ 1.0
813
+ );
814
+ if (ttcFactor !== 1.0) {
815
+ const alertEvents = userEvents
816
+ .filter(e => e.event === "alert triggered" || e.event === "alert acknowledged" || e.event === "alert resolved")
817
+ .sort((a, b) => a.time.localeCompare(b.time));
818
+ for (let i = 1; i < alertEvents.length; i++) {
819
+ const prev = dayjs(alertEvents[i - 1].time);
820
+ const curr = dayjs(alertEvents[i].time);
821
+ const gap = curr.diff(prev);
822
+ const newGap = Math.round(gap * ttcFactor);
823
+ alertEvents[i].time = prev.add(newGap, "milliseconds").toISOString();
824
+ }
825
+ }
810
826
  }
811
827
 
812
828
  // HOOK 8: ENTERPRISE VS STARTUP (user) — company size determines