@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,42 +1,23 @@
1
- // ── TWEAK THESE ──
2
- const SEED = "simple is best";
3
- const num_days = 30;
4
- const num_users = 500;
5
- const avg_events_per_user_per_day = 3.33;
6
- let token = "";
7
-
8
- // ── env overrides ──
9
- if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
10
-
1
+ // ── IMPORTS ──
11
2
  import Chance from 'chance';
12
- const chance = new Chance();
13
3
  import dayjs from "dayjs";
14
4
  import utc from "dayjs/plugin/utc.js";
15
5
  dayjs.extend(utc);
16
- import { uid, comma } from 'ak-tools';
17
6
  import { pickAWinner, weighNumRange, date, integer, weighChoices } from "../../lib/utils/utils.js";
18
-
19
7
  /** @typedef {import("../../types").Dungeon} Config */
20
- const itemCategories = ["Books", "Movies", "Music", "Games", "Electronics", "Computers", "Smart Home", "Home", "Garden", "Pet", "Beauty", "Health", "Toys", "Kids", "Baby", "Handmade", "Sports", "Outdoors", "Automotive", "Industrial", "Entertainment", "Art", "Food", "Appliances", "Office", "Wedding", "Software"];
21
8
 
22
- const videoCategories = ["funny", "educational", "inspirational", "music", "news", "sports", "cooking", "DIY", "travel", "gaming"];
9
+ // ── OVERVIEW ──
10
+ /*
11
+ * NAME: scd
12
+ * PURPOSE: SCD-focused dungeon — user + group slowly-changing dimensions across e-commerce events
13
+ * SCALE: 500 users, ~50K events, 30 days (CSV format, 1,000 company groups)
14
+ * EVENTS (7): checkout, add to cart, page view, watch video, view item, save item, sign up, cart_abandoned
15
+ * FUNNELS (0): none
16
+ * SCDs: user — role (weekly), NPS (daily); company_id — MRR (monthly), AccountHealthScore (weekly), plan (monthly)
17
+ */
23
18
 
24
- /**
25
- * ═══════════════════════════════════════════════════════════════
26
- * DATASET OVERVIEW
27
- * ═══════════════════════════════════════════════════════════════
28
- *
29
- * SCD Test — dungeon focused on Slowly Changing Dimensions.
30
- * - 500 users over 30 days, ~50K events (CSV format)
31
- * - E-commerce events: checkout, add to cart, page view, watch video, view/save item
32
- * - User SCDs: role (weekly), NPS (daily)
33
- * - Group SCDs: MRR (monthly), AccountHealthScore (weekly), plan (monthly)
34
- * - 1,000 company groups with industry, segment, CSM assignments
35
- *
36
- * ═══════════════════════════════════════════════════════════════
37
- * ANALYTICS HOOKS (3 patterns)
38
- * ═══════════════════════════════════════════════════════════════
39
- *
19
+ // ── HOOK STORIES ──
20
+ /*
40
21
  * 1. SPEND TIERS (user hook)
41
22
  * Users with luckyNumber > 250 = "high_spender", else "budget".
42
23
  *
@@ -59,27 +40,101 @@ const videoCategories = ["funny", "educational", "inspirational", "music", "news
59
40
  * Expected: visible volume of abandoned cart events
60
41
  */
61
42
 
62
- /** @type {import('../../types').Dungeon} */
43
+ // ── SCALE ──
44
+ const SEED = "simple is best";
45
+ const NUM_DAYS = 30;
46
+ const NUM_USERS = 500;
47
+ const EVENTS_PER_DAY = 3.33;
48
+ const token = process.env.MP_TOKEN || "";
49
+
50
+ const chance = new Chance();
51
+
52
+ // ── KNOBS (tweak these to reshape stories) ──
53
+ const HIGH_SPENDER_THRESHOLD = 250;
54
+ const WEEKEND_WATCH_MULTIPLIER = 1.5;
55
+ const ABANDONMENT_DELAY_MINUTES = 30;
56
+
57
+ // ── DATA ARRAYS ──
58
+ const itemCategories = ["Books", "Movies", "Music", "Games", "Electronics", "Computers", "Smart Home", "Home", "Garden", "Pet", "Beauty", "Health", "Toys", "Kids", "Baby", "Handmade", "Sports", "Outdoors", "Automotive", "Industrial", "Entertainment", "Art", "Food", "Appliances", "Office", "Wedding", "Software"];
59
+ const videoCategories = ["funny", "educational", "inspirational", "music", "news", "sports", "cooking", "DIY", "travel", "gaming"];
60
+ const spiritAnimals = ["duck", "dog", "otter", "penguin", "cat", "elephant", "lion", "cheetah", "giraffe", "zebra", "rhino", "hippo", "whale", "dolphin", "shark", "octopus", "squid", "jellyfish", "starfish", "seahorse", "crab", "lobster", "shrimp", "clam", "snail", "slug", "butterfly", "moth", "bee", "wasp", "ant", "beetle", "ladybug", "caterpillar", "centipede", "millipede", "scorpion", "spider", "tarantula", "tick", "mite", "mosquito", "fly", "dragonfly", "damselfly", "grasshopper", "cricket", "locust", "mantis", "cockroach", "termite", "praying mantis", "walking stick", "stick bug", "leaf insect", "lacewing", "aphid", "cicada", "thrips", "psyllid", "scale insect", "whitefly", "mealybug", "planthopper", "leafhopper", "treehopper", "flea", "louse", "bedbug", "flea beetle", "weevil", "longhorn beetle", "leaf beetle", "tiger beetle", "ground beetle", "lady beetle", "firefly", "click beetle", "rove beetle", "scarab beetle", "dung beetle", "stag beetle", "rhinoceros beetle", "hercules beetle", "goliath beetle", "jewel beetle", "tortoise beetle"];
61
+ const industries = ["technology", "education", "finance", "healthcare", "retail", "manufacturing", "transportation", "entertainment", "media", "real estate", "construction", "hospitality", "energy", "utilities", "agriculture", "other"];
62
+ const csmNames = ["AK", "Neha", "Rajiv", "Deepak", "Justin", "Hans", "Katie", "Somya", "Tony", "Kaan"];
63
+
64
+ // ── HELPER FUNCTIONS ──
65
+ function handleUserHook(record) {
66
+ // classify users into spending tiers
67
+ record.spendTier = record.luckyNumber > HIGH_SPENDER_THRESHOLD ? "high_spender" : "budget";
68
+ return record;
69
+ }
70
+
71
+ function handleEventHook(record) {
72
+ // coupon users get discounted checkout amounts
73
+ if (record.event === "checkout" && record.coupon && record.coupon !== "none") {
74
+ const discountPct = parseInt(record.coupon) || 10;
75
+ record.amount = Math.round(record.amount * (1 - discountPct / 100));
76
+ record.discount_applied = true;
77
+ }
78
+ // weekend watchers get longer watch times
79
+ if (record.event === "watch video" && record.time) {
80
+ const day = dayjs(record.time).day();
81
+ if (day === 0 || day === 6) {
82
+ record.watchTimeSec = Math.round((record.watchTimeSec || 60) * WEEKEND_WATCH_MULTIPLIER);
83
+ record.is_weekend = true;
84
+ }
85
+ }
86
+ return record;
87
+ }
88
+
89
+ function handleEverythingHook(record, meta) {
90
+ // stamp superProps from profile for consistency
91
+ const profile = meta.profile;
92
+ record.forEach(e => {
93
+ e.platform = profile.platform;
94
+ e.currentTheme = profile.currentTheme;
95
+ });
96
+
97
+ const hasAddToCart = record.some(e => e.event === "add to cart");
98
+ const hasCheckout = record.some(e => e.event === "checkout");
99
+ // users who added to cart but never checked out: synthesize a cart_abandoned event
100
+ if (hasAddToCart && !hasCheckout && record.length > 2) {
101
+ const lastAdd = record.filter(e => e.event === "add to cart").pop();
102
+ if (lastAdd) {
103
+ record.push({
104
+ ...lastAdd,
105
+ event: "cart_abandoned",
106
+ time: dayjs(lastAdd.time).add(ABANDONMENT_DELAY_MINUTES, "minute").toISOString(),
107
+ user_id: lastAdd.user_id,
108
+ amount: lastAdd.amount,
109
+ });
110
+ }
111
+ }
112
+ return record;
113
+ }
114
+
115
+ // ── CONFIG ──
116
+ /** @type {Config} */
63
117
  const config = {
64
- token,
65
118
  seed: SEED,
66
- numDays: num_days,
67
- avgEventsPerUserPerDay: avg_events_per_user_per_day,
68
- numUsers: num_users,
119
+ numDays: NUM_DAYS,
120
+ avgEventsPerUserPerDay: EVENTS_PER_DAY,
121
+ numUsers: NUM_USERS,
69
122
  format: 'csv', //csv or json
70
- region: "US",
71
- hasAnonIds: false, //if true, anonymousIds are created for each user
72
- hasSessionIds: false, //if true, hasSessionIds are created for each user
73
- hasAdSpend: false,
74
- hasLocation: true,
75
- hasAndroidDevices: true,
76
- hasIOSDevices: true,
77
- hasDesktopDevices: true,
78
- hasBrowser: true,
79
- hasCampaigns: true,
80
- isAnonymous: false,
81
-
82
-
123
+ credentials: {
124
+ token,
125
+ region: "US",
126
+ },
127
+ switches: {
128
+ hasSessionIds: false, //if true, hasSessionIds are created for each user
129
+ hasAdSpend: false,
130
+ hasLocation: true,
131
+ hasAndroidDevices: true,
132
+ hasIOSDevices: true,
133
+ hasDesktopDevices: true,
134
+ hasBrowser: true,
135
+ hasCampaigns: true,
136
+ isAnonymous: false,
137
+ },
83
138
  events: [
84
139
  {
85
140
  event: "checkout",
@@ -168,14 +223,10 @@ const config = {
168
223
  platform: ["web", "mobile", "web", "mobile", "web", "web", "kiosk", "smartTV"],
169
224
  currentTheme: weighChoices(["light", "dark", "custom", "light", "dark"]),
170
225
  },
171
- /*
172
- user properties work the same as event properties
173
- each key should be an array or function reference
174
- */
175
226
  userProps: {
176
227
  title: chance.profession.bind(chance),
177
228
  luckyNumber: weighNumRange(42, 420, .3),
178
- spiritAnimal: ["duck", "dog", "otter", "penguin", "cat", "elephant", "lion", "cheetah", "giraffe", "zebra", "rhino", "hippo", "whale", "dolphin", "shark", "octopus", "squid", "jellyfish", "starfish", "seahorse", "crab", "lobster", "shrimp", "clam", "snail", "slug", "butterfly", "moth", "bee", "wasp", "ant", "beetle", "ladybug", "caterpillar", "centipede", "millipede", "scorpion", "spider", "tarantula", "tick", "mite", "mosquito", "fly", "dragonfly", "damselfly", "grasshopper", "cricket", "locust", "mantis", "cockroach", "termite", "praying mantis", "walking stick", "stick bug", "leaf insect", "lacewing", "aphid", "cicada", "thrips", "psyllid", "scale insect", "whitefly", "mealybug", "planthopper", "leafhopper", "treehopper", "flea", "louse", "bedbug", "flea beetle", "weevil", "longhorn beetle", "leaf beetle", "tiger beetle", "ground beetle", "lady beetle", "firefly", "click beetle", "rove beetle", "scarab beetle", "dung beetle", "stag beetle", "rhinoceros beetle", "hercules beetle", "goliath beetle", "jewel beetle", "tortoise beetle"],
229
+ spiritAnimal: spiritAnimals,
179
230
  spendTier: ["budget"],
180
231
  platform: ["web", "mobile", "web", "mobile", "web", "web", "kiosk", "smartTV"],
181
232
  currentTheme: weighChoices(["light", "dark", "custom", "light", "dark"]),
@@ -221,86 +272,18 @@ const config = {
221
272
  groupProps: {
222
273
  company_id: {
223
274
  name: () => { return chance.name(); },
224
- email: () => { return `CSM: ${chance.pickone(["AK", "Neha", "Rajiv", "Deepak", "Justin", "Hans", "Katie", "Somya", "Tony", "Kaan"])}`; },
225
- industry: [
226
- "technology",
227
- "education",
228
- "finance",
229
- "healthcare",
230
- "retail",
231
- "manufacturing",
232
- "transportation",
233
- "entertainment",
234
- "media",
235
- "real estate",
236
- "construction",
237
- "hospitality",
238
- "energy",
239
- "utilities",
240
- "agriculture",
241
- "other",
242
- ],
275
+ email: () => { return `CSM: ${chance.pickone(csmNames)}`; },
276
+ industry: industries,
243
277
  segment: ["SMB", "SMB", "SMB", "Mid Market", "Mid Market", "Enterprise"],
244
278
  "# active users": chance.integer({ min: 2, max: 20 })
245
279
  }
246
280
  },
247
281
  hook: function (record, type, meta) {
248
- // --- user hook: classify users into spending tiers ---
249
- if (type === "user") {
250
- record.spendTier = record.luckyNumber > 250 ? "high_spender" : "budget";
251
- return record;
252
- }
253
-
254
- // --- event hook: coupon users get discounted checkout amounts ---
255
- if (type === "event") {
256
- if (record.event === "checkout" && record.coupon && record.coupon !== "none") {
257
- const discountPct = parseInt(record.coupon) || 10;
258
- record.amount = Math.round(record.amount * (1 - discountPct / 100));
259
- record.discount_applied = true;
260
- }
261
- // weekend watchers get longer watch times
262
- if (record.event === "watch video" && record.time) {
263
- const day = dayjs(record.time).day();
264
- if (day === 0 || day === 6) {
265
- record.watchTimeSec = Math.round((record.watchTimeSec || 60) * 1.5);
266
- record.is_weekend = true;
267
- }
268
- }
269
- return record;
270
- }
271
-
272
- // --- everything hook: simulate cart abandonment ---
273
- if (type === "everything") {
274
- // stamp superProps from profile for consistency
275
- const profile = meta.profile;
276
- record.forEach(e => {
277
- e.platform = profile.platform;
278
- e.currentTheme = profile.currentTheme;
279
- });
280
-
281
- const hasAddToCart = record.some(e => e.event === "add to cart");
282
- const hasCheckout = record.some(e => e.event === "checkout");
283
- // users who added to cart but never checked out: remove checkout events (if any slipped through)
284
- // and mark them as abandoned
285
- if (hasAddToCart && !hasCheckout && record.length > 2) {
286
- const lastAdd = record.filter(e => e.event === "add to cart").pop();
287
- if (lastAdd) {
288
- record.push({
289
- ...lastAdd,
290
- event: "cart_abandoned",
291
- time: dayjs(lastAdd.time).add(30, "minute").toISOString(),
292
- user_id: lastAdd.user_id,
293
- amount: lastAdd.amount,
294
- });
295
- }
296
- }
297
- return record;
298
- }
299
-
282
+ if (type === "user") return handleUserHook(record);
283
+ if (type === "event") return handleEventHook(record);
284
+ if (type === "everything") return handleEverythingHook(record, meta);
300
285
  return record;
301
286
  }
302
287
  };
303
288
 
304
-
305
-
306
- export default config;
289
+ export default config;
@@ -1,42 +1,22 @@
1
- // ── TWEAK THESE ──
2
- const SEED = "simple is best";
3
- const num_days = 100;
4
- const num_users = 5_000;
5
- const avg_events_per_user_per_day = 1;
6
- let token = "";
7
-
8
- // ── env overrides ──
9
- if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
10
-
1
+ // ── IMPORTS ──
11
2
  import Chance from 'chance';
12
- let chance = new Chance();
13
3
  import dayjs from "dayjs";
14
4
  import utc from "dayjs/plugin/utc.js";
15
5
  dayjs.extend(utc);
16
- import { uid, comma } from 'ak-tools';
17
6
  import { pickAWinner, weighNumRange, date, integer, weighChoices } from "../../lib/utils/utils.js";
18
-
19
7
  /** @typedef {import("../../types").Dungeon} Config */
20
- const itemCategories = ["Books", "Movies", "Music", "Games", "Electronics", "Computers", "Smart Home", "Home", "Garden", "Pet", "Beauty", "Health", "Toys", "Kids", "Baby", "Handmade", "Sports", "Outdoors", "Automotive", "Industrial", "Entertainment", "Art", "Food", "Appliances", "Office", "Wedding", "Software"];
21
8
 
22
- const videoCategories = ["funny", "educational", "inspirational", "music", "news", "sports", "cooking", "DIY", "travel", "gaming"];
9
+ // ── OVERVIEW ──
10
+ /*
11
+ * NAME: simple
12
+ * PURPOSE: simple e-commerce + media dungeon — exercises event/everything hooks, campaigns, signup funnel A/B
13
+ * SCALE: 5,000 users, ~500K events, 100 days
14
+ * EVENTS (9): ad impression, ad click, checkout, add to cart, page view, watch video, view item, save item, sign up
15
+ * FUNNELS (1): page view → view item → save item → page view → sign up (Signup Flow, A/B experiment)
16
+ */
23
17
 
24
- /**
25
- * ═══════════════════════════════════════════════════════════════
26
- * DATASET OVERVIEW
27
- * ═══════════════════════════════════════════════════════════════
28
- *
29
- * Simple E-Commerce + Media App
30
- * - 5,000 users over 100 days, ~500K events
31
- * - Events: ad impressions/clicks, page views, video watching,
32
- * item browsing/saving, cart, checkout, signup
33
- * - Signup funnel with A/B experiment
34
- * - Campaigns, location, browser, and device data enabled
35
- *
36
- * ═══════════════════════════════════════════════════════════════
37
- * ANALYTICS HOOKS (3 patterns)
38
- * ═══════════════════════════════════════════════════════════════
39
- *
18
+ // ── HOOK STORIES ──
19
+ /*
40
20
  * 1. TEMPORAL IMPROVEMENT (event hook)
41
21
  * After 15 days ago: checkout amounts 1.5x, watch times 1.5x.
42
22
  * Before that: 33% of events are dropped (tagged _drop, filtered in everything).
@@ -68,29 +48,129 @@ const videoCategories = ["funny", "educational", "inspirational", "music", "news
68
48
  * Expected: low-quality cohort volume thins out after midpoint
69
49
  */
70
50
 
71
- /** @type {import('../../types').Dungeon} */
51
+ // ── SCALE ──
52
+ const SEED = "simple is best";
53
+ const NUM_DAYS = 100;
54
+ const NUM_USERS = 5_000;
55
+ const EVENTS_PER_DAY = 1;
56
+ const token = process.env.MP_TOKEN || "";
57
+
58
+ const chance = new Chance();
59
+
60
+ // ── KNOBS (tweak these to reshape stories) ──
61
+ const IMPROVEMENT_DAYS_AGO = 15;
62
+ const IMPROVEMENT_MULTIPLIER = 1.5;
63
+ const PRE_IMPROVEMENT_DROP_LIKELIHOOD = 33;
64
+ const CUSTOM_THEME_CHECKOUT_AMOUNT_MULT = 2;
65
+ const LOW_QUALITY_CHURN_LIKELIHOOD = 50;
66
+ const LOW_QUALITY_TIERS = ["480p", "360p", "240p"];
67
+
68
+ // ── DATA ARRAYS ──
69
+ const itemCategories = ["Books", "Movies", "Music", "Games", "Electronics", "Computers", "Smart Home", "Home", "Garden", "Pet", "Beauty", "Health", "Toys", "Kids", "Baby", "Handmade", "Sports", "Outdoors", "Automotive", "Industrial", "Entertainment", "Art", "Food", "Appliances", "Office", "Wedding", "Software"];
70
+ const videoCategories = ["funny", "educational", "inspirational", "music", "news", "sports", "cooking", "DIY", "travel", "gaming"];
71
+ const spiritAnimals = ["duck", "dog", "otter", "penguin", "cat", "elephant", "lion", "cheetah", "giraffe", "zebra", "rhino", "hippo", "whale", "dolphin", "shark", "octopus", "squid", "jellyfish", "starfish", "seahorse", "crab", "lobster", "shrimp", "clam", "snail", "slug", "butterfly", "moth", "bee", "wasp", "ant", "beetle", "ladybug", "caterpillar", "centipede", "millipede", "scorpion", "spider", "tarantula", "tick", "mite", "mosquito", "fly", "dragonfly", "damselfly", "grasshopper", "cricket", "locust", "mantis", "cockroach", "termite", "praying mantis", "walking stick", "stick bug", "leaf insect", "lacewing", "aphid", "cicada", "thrips", "psyllid", "scale insect", "whitefly", "mealybug", "planthopper", "leafhopper", "treehopper", "flea", "louse", "bedbug", "flea beetle", "weevil", "longhorn beetle", "leaf beetle", "tiger beetle", "ground beetle", "lady beetle", "firefly", "click beetle", "rove beetle", "scarab beetle", "dung beetle", "stag beetle", "rhinoceros beetle", "hercules beetle", "goliath beetle", "jewel beetle", "tortoise beetle"];
72
+
73
+ // ── HELPER FUNCTIONS ──
74
+ function flip(likelihood = 50) {
75
+ return chance.bool({ likelihood });
76
+ }
77
+
78
+ function handleEventHook(record, meta) {
79
+ // Anchor relative dates to the resolved dataset window — never wall-clock.
80
+ const datasetEnd = dayjs.unix(meta.datasetEnd);
81
+ const OVER_THINGS_GET_BETTER = datasetEnd.subtract(IMPROVEMENT_DAYS_AGO, 'day');
82
+ const EVENT_TIME = dayjs(record.time);
83
+
84
+ if (EVENT_TIME.isAfter(OVER_THINGS_GET_BETTER)) {
85
+ // checkouts are bigger
86
+ if (record.event === "checkout") {
87
+ record.amount = Math.round(record.amount * IMPROVEMENT_MULTIPLIER);
88
+ }
89
+
90
+ // videos are longer
91
+ if (record.event === "watch video") {
92
+ record.watchTimeSec = Math.round(record.watchTimeSec * IMPROVEMENT_MULTIPLIER);
93
+ }
94
+ }
95
+
96
+ if (EVENT_TIME.isBefore(OVER_THINGS_GET_BETTER)) {
97
+ // tag 33% for removal (filtered in "everything" hook)
98
+ if (chance.bool({ likelihood: PRE_IMPROVEMENT_DROP_LIKELIHOOD })) record._drop = true;
99
+ }
100
+
101
+ return record;
102
+ }
103
+
104
+ function handleEverythingHook(record, meta) {
105
+ const profile = meta.profile;
106
+ record.forEach(e => {
107
+ e.theme = profile.theme;
108
+ });
109
+
110
+ // custom themes purchase more:
111
+ const numCustomMode = record.filter(a => a.theme === 'custom').length;
112
+ const numLightMode = record.filter(a => a.theme === 'light').length;
113
+ const numDarkMode = record.filter(a => a.theme === 'dark').length;
114
+ if (numCustomMode > numLightMode || numCustomMode > numDarkMode) {
115
+ // triple their checkout events
116
+ const checkoutEvents = record.filter(a => a.event === 'checkout');
117
+ const newCheckouts = checkoutEvents.map(a => {
118
+ const randomInt = integer(-48, 48);
119
+ const newCheckout = {
120
+ ...a,
121
+ time: dayjs(a.time).add(randomInt, 'hour').toISOString(),
122
+ event: "checkout",
123
+ amount: a.amount * CUSTOM_THEME_CHECKOUT_AMOUNT_MULT,
124
+ coupon: "50%OFF"
125
+ };
126
+ return newCheckout;
127
+ });
128
+ record.push(...newCheckouts);
129
+ }
130
+
131
+ // users who watch low quality videos churn more:
132
+ const lowQualityWatches = record.filter(a => a.event === 'watch video' && LOW_QUALITY_TIERS.includes(a.quality));
133
+ const highQualityWatches = record.filter(a => a.event === 'watch video' && !LOW_QUALITY_TIERS.includes(a.quality));
134
+ if (lowQualityWatches.length > highQualityWatches.length) {
135
+ if (flip(LOW_QUALITY_CHURN_LIKELIHOOD)) {
136
+ // find midpoint of records
137
+ const midpoint = Math.floor(record.length / 2);
138
+ record = record.slice(0, midpoint);
139
+ }
140
+ }
141
+
142
+ // Filter out events tagged for removal in the event hook
143
+ return record.filter(e => !e._drop);
144
+ }
145
+
146
+ // ── CONFIG ──
147
+ /** @type {Config} */
72
148
  const config = {
73
- token,
74
149
  seed: SEED,
75
- numDays: num_days,
76
- avgEventsPerUserPerDay: avg_events_per_user_per_day,
77
- numUsers: num_users,
150
+ numDays: NUM_DAYS,
151
+ avgEventsPerUserPerDay: EVENTS_PER_DAY,
152
+ numUsers: NUM_USERS,
78
153
  format: 'json', //csv or json
79
- region: "US",
80
- hasAnonIds: true, //if true, anonymousIds are created for each user
81
- hasSessionIds: true, //if true, hasSessionIds are created for each user
82
- hasAdSpend: false,
83
- hasLocation: true,
84
- hasAndroidDevices: true,
85
- hasIOSDevices: true,
86
- hasDesktopDevices: true,
87
- hasBrowser: true,
88
- hasCampaigns: true,
89
- isAnonymous: false,
90
- alsoInferFunnels: true,
154
+ credentials: {
155
+ token,
156
+ region: "US",
157
+ },
158
+ switches: {
159
+ hasSessionIds: true, //if true, hasSessionIds are created for each user
160
+ hasAdSpend: false,
161
+ hasLocation: true,
162
+ hasAndroidDevices: true,
163
+ hasIOSDevices: true,
164
+ hasDesktopDevices: true,
165
+ hasBrowser: true,
166
+ hasCampaigns: true,
167
+ isAnonymous: false,
168
+ alsoInferFunnels: true,
169
+ },
170
+ identity: {
171
+ avgDevicePerUser: 1,
172
+ },
91
173
  concurrency: 1,
92
-
93
-
94
174
  events: [
95
175
  {
96
176
  event: "ad impression",
@@ -114,7 +194,6 @@ const config = {
114
194
  currency: ["USD", "CAD", "EUR", "BTC", "ETH", "JPY"],
115
195
  coupon: weighChoices(["none", "none", "none", "none", "10%OFF", "20%OFF", "10%OFF", "20%OFF", "30%OFF", "40%OFF", "50%OFF"]),
116
196
  numItems: weighNumRange(1, 10),
117
-
118
197
  }
119
198
  },
120
199
  {
@@ -147,7 +226,6 @@ const config = {
147
226
  quality: ["2160p", "1440p", "1080p", "720p", "480p", "360p", "240p"],
148
227
  format: ["mp4", "avi", "mov", "mpg"],
149
228
  uploader_id: chance.guid.bind(chance)
150
-
151
229
  }
152
230
  },
153
231
  {
@@ -177,7 +255,6 @@ const config = {
177
255
  referral: weighChoices(["none", "none", "none", "friend", "ad", "ad", "ad", "friend", "friend", "friend", "friend"]),
178
256
  }
179
257
  },
180
-
181
258
  ],
182
259
  funnels: [
183
260
  {
@@ -189,111 +266,27 @@ const config = {
189
266
  timeToConvert: 2,
190
267
  experiment: true,
191
268
  name: "Signup Flow"
192
-
193
269
  }
194
-
195
270
  ],
196
271
  superProps: {
197
272
  theme: ["light", "dark", "custom", "light", "dark"],
198
273
  },
199
- /*
200
- user properties work the same as event properties
201
- each key should be an array or function reference
202
- */
203
274
  userProps: {
204
275
  theme: ["light", "dark", "custom", "light", "dark"],
205
276
  title: chance.profession.bind(chance),
206
277
  luckyNumber: weighNumRange(42, 420, .3),
207
- spiritAnimal: ["duck", "dog", "otter", "penguin", "cat", "elephant", "lion", "cheetah", "giraffe", "zebra", "rhino", "hippo", "whale", "dolphin", "shark", "octopus", "squid", "jellyfish", "starfish", "seahorse", "crab", "lobster", "shrimp", "clam", "snail", "slug", "butterfly", "moth", "bee", "wasp", "ant", "beetle", "ladybug", "caterpillar", "centipede", "millipede", "scorpion", "spider", "tarantula", "tick", "mite", "mosquito", "fly", "dragonfly", "damselfly", "grasshopper", "cricket", "locust", "mantis", "cockroach", "termite", "praying mantis", "walking stick", "stick bug", "leaf insect", "lacewing", "aphid", "cicada", "thrips", "psyllid", "scale insect", "whitefly", "mealybug", "planthopper", "leafhopper", "treehopper", "flea", "louse", "bedbug", "flea beetle", "weevil", "longhorn beetle", "leaf beetle", "tiger beetle", "ground beetle", "lady beetle", "firefly", "click beetle", "rove beetle", "scarab beetle", "dung beetle", "stag beetle", "rhinoceros beetle", "hercules beetle", "goliath beetle", "jewel beetle", "tortoise beetle"]
278
+ spiritAnimal: spiritAnimals,
208
279
  },
209
280
  scdProps: {},
210
281
  mirrorProps: {},
211
-
212
- /*
213
- for group analytics keys, we need an array of arrays [[],[],[]]
214
- each pair represents a group_key and the number of profiles for that key
215
- */
216
282
  groupKeys: [],
217
283
  groupProps: {},
218
284
  lookupTables: [],
219
285
  hook: function (record, type, meta) {
220
-
221
- // Anchor relative dates to the resolved dataset window — never wall-clock.
222
- const datasetEnd = dayjs.unix(meta.datasetEnd);
223
- const OVER_THINGS_GET_BETTER = datasetEnd.subtract(15, 'day');
224
-
225
- if (type === "event") {
226
- const EVENT_TIME = dayjs(record.time);
227
-
228
- if (EVENT_TIME.isAfter(OVER_THINGS_GET_BETTER)) {
229
- // checkouts are bigger
230
- if (record.event === "checkout") {
231
- record.amount = Math.round(record.amount * 1.5);
232
- }
233
-
234
- // videos are longer
235
- if (record.event === "watch video") {
236
- record.watchTimeSec = Math.round(record.watchTimeSec * 1.5);
237
- }
238
- }
239
-
240
- if (EVENT_TIME.isBefore(OVER_THINGS_GET_BETTER)) {
241
- // tag 33% for removal (filtered in "everything" hook)
242
- if (chance.bool({ likelihood: 33 })) record._drop = true;
243
- }
244
- }
245
-
246
- if (type === "everything") {
247
- const profile = meta.profile;
248
- record.forEach(e => {
249
- e.theme = profile.theme;
250
- });
251
-
252
- //custom themes purchase more:
253
- const numCustomMode = record.filter(a => a.theme === 'custom').length;
254
- const numLightMode = record.filter(a => a.theme === 'light').length;
255
- const numDarkMode = record.filter(a => a.theme === 'dark').length;
256
- if (numCustomMode > numLightMode || numCustomMode > numDarkMode) {
257
- //triple their checkout events
258
- const checkoutEvents = record.filter(a => a.event === 'checkout');
259
- const newCheckouts = checkoutEvents.map(a => {
260
- const randomInt = integer(-48, 48);
261
- const newCheckout = {
262
- ...a,
263
- time: dayjs(a.time).add(randomInt, 'hour').toISOString(),
264
- event: "checkout",
265
- amount: a.amount * 2,
266
- coupon: "50%OFF"
267
- };
268
- return newCheckout;
269
- });
270
- record.push(...newCheckouts);
271
- }
272
-
273
- //users who watch low quality videos churn more:
274
- const loQuality = ["480p", "360p", "240p"];
275
- const lowQualityWatches = record.filter(a => a.event === 'watch video' && loQuality.includes(a.quality));
276
- const highQualityWatches = record.filter(a => a.event === 'watch video' && !loQuality.includes(a.quality));
277
- if (lowQualityWatches.length > highQualityWatches.length) {
278
- if (flip()) {
279
- // find midpoint of records
280
- const midpoint = Math.floor(record.length / 2);
281
- record = record.slice(0, midpoint);
282
-
283
- }
284
- }
285
-
286
- // Filter out events tagged for removal in the event hook
287
- record = record.filter(e => !e._drop);
288
- }
289
-
286
+ if (type === "event") return handleEventHook(record, meta);
287
+ if (type === "everything") return handleEverythingHook(record, meta);
290
288
  return record;
291
289
  }
292
290
  };
293
291
 
294
- function flip(likelihood = 50) {
295
- return chance.bool({ likelihood });
296
- }
297
-
298
-
299
- export default config;
292
+ export default config;