@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.
- package/.claude/skills/create-dungeon/SKILL.md +139 -46
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +31 -6
- package/.claude/skills/verify-dungeon/references/sql-recipes.md +44 -25
- package/.claude/skills/write-hooks/SKILL.md +31 -3
- package/CHANGELOG.md +85 -0
- package/HOOKS.md +13 -0
- package/dungeons/technical/ad-spend.js +41 -49
- package/dungeons/technical/anonymous-users.js +38 -36
- package/dungeons/technical/array-of-object-lookup.js +136 -153
- package/dungeons/technical/datagen-v15-verify.js +24 -11
- package/dungeons/technical/experiments.js +42 -40
- package/dungeons/technical/foobar.js +114 -118
- package/dungeons/technical/group-analytics.js +42 -40
- package/dungeons/technical/hook-helpers-verify.js +69 -50
- package/dungeons/technical/identity-model-verify.js +22 -12
- package/dungeons/technical/mirror-strategies.js +37 -39
- package/dungeons/technical/nested-objects.js +119 -118
- package/dungeons/technical/pattern-aggregate-by-bin.js +21 -8
- package/dungeons/technical/pattern-attributed-by-source.js +23 -9
- package/dungeons/technical/pattern-frequency-by-frequency.js +21 -8
- package/dungeons/technical/pattern-funnel-frequency.js +30 -15
- package/dungeons/technical/pattern-ttc-by-segment.js +21 -8
- package/dungeons/technical/retention-cadence.js +115 -112
- package/dungeons/technical/sanity.js +86 -80
- package/dungeons/technical/scale-test.js +34 -38
- package/dungeons/technical/scd.js +111 -128
- package/dungeons/technical/simple.js +134 -141
- package/dungeons/technical/simplest.js +54 -62
- package/dungeons/technical/text-generation.js +110 -146
- package/dungeons/vertical/ai-platform.js +296 -333
- package/dungeons/vertical/community.js +284 -255
- package/dungeons/vertical/crypto.js +395 -391
- package/dungeons/vertical/dating.js +411 -378
- package/dungeons/vertical/devtools.js +336 -298
- package/dungeons/vertical/ecommerce.js +316 -394
- package/dungeons/vertical/education.js +369 -325
- package/dungeons/vertical/fintech.js +358 -325
- package/dungeons/vertical/fitness.js +335 -291
- package/dungeons/vertical/food-delivery.js +343 -307
- package/dungeons/vertical/gaming.js +480 -444
- package/dungeons/vertical/healthcare.js +306 -262
- package/dungeons/vertical/insurance-application.js +427 -409
- package/dungeons/vertical/logistics.js +271 -252
- package/dungeons/vertical/marketplace.js +333 -323
- package/dungeons/vertical/media.js +382 -335
- package/dungeons/vertical/real-estate.js +395 -346
- package/dungeons/vertical/sass.js +319 -333
- package/dungeons/vertical/social.js +368 -316
- package/dungeons/vertical/travel.js +297 -295
- package/index.js +46 -4
- package/lib/core/config-validator.js +126 -28
- package/lib/generators/funnels.js +4 -1
- package/lib/orchestrators/mixpanel-sender.js +7 -0
- package/lib/orchestrators/user-loop.js +132 -31
- package/lib/templates/defaults.js +59 -59
- package/lib/templates/macro-presets.js +14 -2
- package/lib/utils/dataset-context.js +103 -0
- package/lib/utils/retention-curve.js +140 -0
- package/lib/utils/utils.js +149 -38
- package/lib/verify/counting.js +40 -0
- package/lib/verify/emulate-breakdown.js +20 -1
- package/lib/verify/index.js +1 -0
- package/lib/verify/schema-validator.js +3 -1
- package/package.json +11 -2
- package/scripts/run-dungeon.mjs +12 -1
- package/types.d.ts +117 -1
|
@@ -1,42 +1,23 @@
|
|
|
1
|
-
// ──
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
67
|
-
avgEventsPerUserPerDay:
|
|
68
|
-
numUsers:
|
|
119
|
+
numDays: NUM_DAYS,
|
|
120
|
+
avgEventsPerUserPerDay: EVENTS_PER_DAY,
|
|
121
|
+
numUsers: NUM_USERS,
|
|
69
122
|
format: 'csv', //csv or json
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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:
|
|
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(
|
|
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
|
-
|
|
249
|
-
if (type === "
|
|
250
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
76
|
-
avgEventsPerUserPerDay:
|
|
77
|
-
numUsers:
|
|
150
|
+
numDays: NUM_DAYS,
|
|
151
|
+
avgEventsPerUserPerDay: EVENTS_PER_DAY,
|
|
152
|
+
numUsers: NUM_USERS,
|
|
78
153
|
format: 'json', //csv or json
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
295
|
-
return chance.bool({ likelihood });
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
export default config;
|
|
292
|
+
export default config;
|