@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,50 +1,52 @@
|
|
|
1
|
-
// ──
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const avg_events_per_user_per_day = 0.83;
|
|
6
|
-
let token = "";
|
|
7
|
-
|
|
8
|
-
// ── env overrides ──
|
|
9
|
-
if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
|
|
1
|
+
// ── IMPORTS ──
|
|
2
|
+
import Chance from "chance";
|
|
3
|
+
import { weighNumRange, weighChoices } from "../../lib/utils/utils.js";
|
|
4
|
+
/** @typedef {import("../../types").Dungeon} Config */
|
|
10
5
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
*
|
|
14
|
-
* Exercises
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* - No hooks
|
|
6
|
+
// ── OVERVIEW ──
|
|
7
|
+
/*
|
|
8
|
+
* NAME: experiments
|
|
9
|
+
* PURPOSE: Exercises funnel experiments (A/B/C variants), bindPropsIndex,
|
|
10
|
+
* and multiple funnel ordering modes (sequential, first-and-last-fixed, random).
|
|
11
|
+
* SCALE: 2,000 users, ~100K events, 60 days
|
|
12
|
+
* EVENTS (7): page view, signup, feature viewed, action taken, checkout, button click, help viewed
|
|
13
|
+
* FUNNELS (4): Onboarding Flow (sequential, experiment), Purchase Funnel (first-and-last-fixed, experiment),
|
|
14
|
+
* Feature Adoption (random, experiment, bindPropsIndex), Quick Signup (sequential, control)
|
|
21
15
|
*/
|
|
22
16
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
17
|
+
// ── SCALE ──
|
|
18
|
+
const SEED = "experiments";
|
|
19
|
+
const NUM_DAYS = 60;
|
|
20
|
+
const NUM_USERS = 2_000;
|
|
21
|
+
const EVENTS_PER_DAY = 0.83;
|
|
22
|
+
const token = process.env.MP_TOKEN || "";
|
|
26
23
|
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
const chance = new Chance();
|
|
25
|
+
|
|
26
|
+
// ── CONFIG ──
|
|
27
|
+
/** @type {Config} */
|
|
29
28
|
const config = {
|
|
30
|
-
token,
|
|
31
29
|
seed: SEED,
|
|
32
|
-
numDays:
|
|
33
|
-
avgEventsPerUserPerDay:
|
|
34
|
-
numUsers:
|
|
30
|
+
numDays: NUM_DAYS,
|
|
31
|
+
avgEventsPerUserPerDay: EVENTS_PER_DAY,
|
|
32
|
+
numUsers: NUM_USERS,
|
|
35
33
|
format: "json",
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
34
|
+
credentials: {
|
|
35
|
+
token,
|
|
36
|
+
region: "US",
|
|
37
|
+
},
|
|
38
|
+
switches: {
|
|
39
|
+
hasSessionIds: true,
|
|
40
|
+
hasAdSpend: false,
|
|
41
|
+
hasLocation: false,
|
|
42
|
+
hasAndroidDevices: true,
|
|
43
|
+
hasIOSDevices: true,
|
|
44
|
+
hasDesktopDevices: true,
|
|
45
|
+
hasBrowser: true,
|
|
46
|
+
hasCampaigns: false,
|
|
47
|
+
isAnonymous: false,
|
|
48
|
+
alsoInferFunnels: false,
|
|
49
|
+
},
|
|
48
50
|
concurrency: 1,
|
|
49
51
|
writeToDisk: false,
|
|
50
52
|
|
|
@@ -1,63 +1,39 @@
|
|
|
1
|
-
// ──
|
|
2
|
-
|
|
3
|
-
const num_days = 30;
|
|
4
|
-
const num_users = 4_000_000;
|
|
5
|
-
const avg_events_per_user_per_day = 16.67;
|
|
6
|
-
let token = "";
|
|
1
|
+
// ── IMPORTS ──
|
|
2
|
+
/** @typedef {import("../../types").Dungeon} Config */
|
|
7
3
|
|
|
8
|
-
// ──
|
|
9
|
-
|
|
4
|
+
// ── OVERVIEW ──
|
|
5
|
+
/*
|
|
6
|
+
* NAME: foobar
|
|
7
|
+
* PURPOSE: minimal stress/sanity dungeon — abstract event names, weight-driven distribution, no funnels, no event properties
|
|
8
|
+
* SCALE: 4,000,000 users, ~2B events, 30 days (configurable for stress testing)
|
|
9
|
+
* EVENTS (10): foo, bar, baz, qux, garply, durtle, linny, fonk, crumn, yak
|
|
10
|
+
* FUNNELS (0): none
|
|
11
|
+
*/
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
* DATASET OVERVIEW
|
|
14
|
-
* ═══════════════════════════════════════════════════════════════
|
|
15
|
-
*
|
|
16
|
-
* Foobar — minimal test/sanity dungeon with abstract event names.
|
|
17
|
-
* - Configurable scale (default 2 billion events for stress testing)
|
|
18
|
-
* - 10 events: foo, bar, baz, qux, garply, durtle, linny, fonk, crumn, yak
|
|
19
|
-
* - Super props: string, number, boolean, date
|
|
20
|
-
* - No funnels, no properties on events — pure weight-driven distribution
|
|
21
|
-
*
|
|
22
|
-
* ═══════════════════════════════════════════════════════════════
|
|
23
|
-
* ANALYTICS HOOKS (2 patterns)
|
|
24
|
-
* ═══════════════════════════════════════════════════════════════
|
|
25
|
-
*
|
|
13
|
+
// ── HOOK STORIES ──
|
|
14
|
+
/*
|
|
26
15
|
* 1. TEMPERATURE TAGGING (event hook)
|
|
27
16
|
* foo/bar/baz = "hot", crumn/yak = "cold", everything else = "warm".
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* - Insights: any event, total events, breakdown by temperature
|
|
31
|
-
* Expected: "hot" dominates (highest-weight events)
|
|
17
|
+
* Mixpanel report: Insights, any event, total events, breakdown by temperature.
|
|
18
|
+
* Expected: "hot" dominates (highest-weight events).
|
|
32
19
|
*
|
|
33
20
|
* 2. HASH-BASED POWER USERS (everything hook)
|
|
34
|
-
* ~10% of users (by distinct_id
|
|
35
|
-
*
|
|
36
|
-
* Mixpanel
|
|
37
|
-
*
|
|
38
|
-
* Expected: ~10% of users have extra events tagged is_duplicate=true
|
|
21
|
+
* ~10% of users (by distinct_id charCode mod 10) get 3 extra duplicate events
|
|
22
|
+
* tagged is_duplicate=true.
|
|
23
|
+
* Mixpanel report: Insights, any event, total per user, breakdown by is_duplicate.
|
|
24
|
+
* Expected: ~10% of users have extra events tagged is_duplicate=true.
|
|
39
25
|
*/
|
|
40
26
|
|
|
27
|
+
// ── SCALE ──
|
|
28
|
+
const SEED = "dm4-foobar";
|
|
29
|
+
const NUM_DAYS = 30;
|
|
30
|
+
const NUM_USERS = 4_000_000;
|
|
31
|
+
const EVENTS_PER_DAY = 16.67;
|
|
32
|
+
const token = process.env.MP_TOKEN || "";
|
|
41
33
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
// If min equals max, just return the value
|
|
46
|
-
if (min === max) {
|
|
47
|
-
return min;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// Swap min and max if min is greater than max
|
|
51
|
-
if (min > max) {
|
|
52
|
-
[min, max] = [max, min];
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Generate a random integer between min and max (inclusive)
|
|
56
|
-
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** @typedef {import("../../types").Dungeon} Config */
|
|
60
|
-
|
|
34
|
+
// ── DATA ARRAYS ──
|
|
35
|
+
const colors = ["red", "orange", "yellow", "green", "blue", "indigo", "violet"];
|
|
36
|
+
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
37
|
const dates = [
|
|
62
38
|
"2024-09-18T21:48:28.173Z",
|
|
63
39
|
"2024-12-09T03:17:46.661Z",
|
|
@@ -161,26 +137,88 @@ const dates = [
|
|
|
161
137
|
"2024-06-03T06:52:21.652Z"
|
|
162
138
|
];
|
|
163
139
|
|
|
164
|
-
|
|
140
|
+
// ── HELPER FUNCTIONS ──
|
|
141
|
+
function integer(min = 1, max = 100) {
|
|
142
|
+
// If min equals max, just return the value
|
|
143
|
+
if (min === max) {
|
|
144
|
+
return min;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Swap min and max if min is greater than max
|
|
148
|
+
if (min > max) {
|
|
149
|
+
[min, max] = [max, min];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Generate a random integer between min and max (inclusive)
|
|
153
|
+
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function handleEventHook(record) {
|
|
157
|
+
// H1: temperature tagging — high-weight events "hot", low-weight "cold", rest "warm"
|
|
158
|
+
const hotEvents = ["foo", "bar", "baz"];
|
|
159
|
+
const coldEvents = ["crumn", "yak"];
|
|
160
|
+
if (hotEvents.includes(record.event)) {
|
|
161
|
+
record.temperature = "hot";
|
|
162
|
+
} else if (coldEvents.includes(record.event)) {
|
|
163
|
+
record.temperature = "cold";
|
|
164
|
+
} else {
|
|
165
|
+
record.temperature = "warm";
|
|
166
|
+
}
|
|
167
|
+
return record;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function handleEverythingHook(record, meta) {
|
|
171
|
+
// stamp profile-derived superProps for consistency
|
|
172
|
+
const profile = meta.profile;
|
|
173
|
+
record.forEach(e => {
|
|
174
|
+
e.string = profile.string;
|
|
175
|
+
e.number = profile.number;
|
|
176
|
+
e.boolean = profile.boolean;
|
|
177
|
+
e.date = profile.date;
|
|
178
|
+
e.temperature = profile.temperature;
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// H2: hash-based power users — ~10% of users (distinct_id charCode mod 10) get 3 extra duplicate events
|
|
182
|
+
if (record.length > 0) {
|
|
183
|
+
const userId = record[0].user_id || record[0].distinct_id || "";
|
|
184
|
+
if (userId && userId.charCodeAt(0) % 10 === 0) {
|
|
185
|
+
const extras = record.slice(0, 3).map(e => ({
|
|
186
|
+
...e,
|
|
187
|
+
event: e.event,
|
|
188
|
+
user_id: e.user_id,
|
|
189
|
+
time: e.time,
|
|
190
|
+
is_duplicate: true,
|
|
191
|
+
}));
|
|
192
|
+
return record.concat(extras);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return record;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── CONFIG ──
|
|
199
|
+
/** @type {Config} */
|
|
165
200
|
const config = {
|
|
166
|
-
token,
|
|
167
201
|
seed: SEED,
|
|
168
|
-
numDays:
|
|
169
|
-
avgEventsPerUserPerDay:
|
|
170
|
-
numUsers:
|
|
202
|
+
numDays: NUM_DAYS,
|
|
203
|
+
avgEventsPerUserPerDay: EVENTS_PER_DAY,
|
|
204
|
+
numUsers: NUM_USERS,
|
|
171
205
|
format: 'json', //csv or json
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
206
|
+
credentials: {
|
|
207
|
+
token,
|
|
208
|
+
region: "US",
|
|
209
|
+
},
|
|
210
|
+
switches: {
|
|
211
|
+
hasSessionIds: false, //if true, hasSessionIds are created for each user
|
|
212
|
+
hasAdSpend: false,
|
|
213
|
+
hasAvatar: false,
|
|
214
|
+
hasBrowser: false,
|
|
215
|
+
hasCampaigns: false,
|
|
216
|
+
hasIOSDevices: false,
|
|
217
|
+
hasLocation: false,
|
|
218
|
+
isAnonymous: true,
|
|
219
|
+
hasAndroidDevices: false,
|
|
220
|
+
hasDesktopDevices: false,
|
|
221
|
+
},
|
|
184
222
|
writeToDisk: false,
|
|
185
223
|
concurrency: 1,
|
|
186
224
|
|
|
@@ -237,7 +275,7 @@ const config = {
|
|
|
237
275
|
}
|
|
238
276
|
],
|
|
239
277
|
superProps: {
|
|
240
|
-
string:
|
|
278
|
+
string: colors,
|
|
241
279
|
number: integer,
|
|
242
280
|
boolean: [true, false],
|
|
243
281
|
date: dates,
|
|
@@ -245,9 +283,9 @@ const config = {
|
|
|
245
283
|
},
|
|
246
284
|
userProps: {
|
|
247
285
|
luckyNumber: integer,
|
|
248
|
-
spiritAnimal:
|
|
286
|
+
spiritAnimal: spiritAnimals,
|
|
249
287
|
created: dates,
|
|
250
|
-
string:
|
|
288
|
+
string: colors,
|
|
251
289
|
number: integer,
|
|
252
290
|
boolean: [true, false],
|
|
253
291
|
date: dates,
|
|
@@ -257,56 +295,14 @@ const config = {
|
|
|
257
295
|
scdProps: {},
|
|
258
296
|
mirrorProps: {},
|
|
259
297
|
lookupTables: [],
|
|
260
|
-
groupKeys: [
|
|
261
|
-
|
|
262
|
-
groupProps: {
|
|
263
|
-
},
|
|
298
|
+
groupKeys: [],
|
|
299
|
+
groupProps: {},
|
|
264
300
|
|
|
265
301
|
hook: function (record, type, meta) {
|
|
266
|
-
|
|
267
|
-
if (type === "
|
|
268
|
-
const hotEvents = ["foo", "bar", "baz"];
|
|
269
|
-
const coldEvents = ["crumn", "yak"];
|
|
270
|
-
if (hotEvents.includes(record.event)) {
|
|
271
|
-
record.temperature = "hot";
|
|
272
|
-
} else if (coldEvents.includes(record.event)) {
|
|
273
|
-
record.temperature = "cold";
|
|
274
|
-
} else {
|
|
275
|
-
record.temperature = "warm";
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// everything hook: hash-based cohort — 10% of users (by distinct_id) get doubled events
|
|
280
|
-
if (type === "everything") {
|
|
281
|
-
const profile = meta.profile;
|
|
282
|
-
record.forEach(e => {
|
|
283
|
-
e.string = profile.string;
|
|
284
|
-
e.number = profile.number;
|
|
285
|
-
e.boolean = profile.boolean;
|
|
286
|
-
e.date = profile.date;
|
|
287
|
-
e.temperature = profile.temperature;
|
|
288
|
-
});
|
|
289
|
-
if (record.length > 0) {
|
|
290
|
-
const userId = record[0].user_id || record[0].distinct_id || "";
|
|
291
|
-
if (userId && userId.charCodeAt(0) % 10 === 0) {
|
|
292
|
-
// power user: duplicate each event with a slight time offset
|
|
293
|
-
const extras = record.slice(0, 3).map(e => ({
|
|
294
|
-
...e,
|
|
295
|
-
event: e.event,
|
|
296
|
-
user_id: e.user_id,
|
|
297
|
-
time: e.time,
|
|
298
|
-
is_duplicate: true,
|
|
299
|
-
}));
|
|
300
|
-
return record.concat(extras);
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
return record;
|
|
304
|
-
}
|
|
305
|
-
|
|
302
|
+
if (type === "event") return handleEventHook(record);
|
|
303
|
+
if (type === "everything") return handleEverythingHook(record, meta);
|
|
306
304
|
return record;
|
|
307
305
|
}
|
|
308
306
|
};
|
|
309
307
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
export default config;
|
|
308
|
+
export default config;
|
|
@@ -1,50 +1,52 @@
|
|
|
1
|
-
// ──
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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;
|
|
1
|
+
// ── IMPORTS ──
|
|
2
|
+
import Chance from 'chance';
|
|
3
|
+
import { weighNumRange, weighChoices } from "../../lib/utils/utils.js";
|
|
4
|
+
/** @typedef {import("../../types").Dungeon} Config */
|
|
10
5
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* - No hooks
|
|
6
|
+
// ── OVERVIEW ──
|
|
7
|
+
/*
|
|
8
|
+
* NAME: group-analytics
|
|
9
|
+
* PURPOSE: exercises 3 groupKeys (company/team/project) at varied scales with per-key groupProps
|
|
10
|
+
* SCALE: 500 users, ~30K events, 60 days
|
|
11
|
+
* EVENTS (8): feature used, subscription change, support ticket, task completed,
|
|
12
|
+
* meeting scheduled, code committed, deployment run, login
|
|
13
|
+
* FUNNELS (0): none
|
|
14
|
+
* GROUPS: company_id (50), team_id (200), project_id (500)
|
|
21
15
|
*/
|
|
22
16
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
17
|
+
// ── SCALE ──
|
|
18
|
+
const SEED = "group-analytics";
|
|
19
|
+
const NUM_DAYS = 60;
|
|
20
|
+
const NUM_USERS = 500;
|
|
21
|
+
const EVENTS_PER_DAY = 1;
|
|
22
|
+
const token = process.env.MP_TOKEN || "";
|
|
26
23
|
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
const chance = new Chance();
|
|
25
|
+
|
|
26
|
+
// ── CONFIG ──
|
|
27
|
+
/** @type {Config} */
|
|
29
28
|
const config = {
|
|
30
|
-
token,
|
|
31
29
|
seed: SEED,
|
|
32
|
-
numDays:
|
|
33
|
-
avgEventsPerUserPerDay:
|
|
34
|
-
numUsers:
|
|
30
|
+
numDays: NUM_DAYS,
|
|
31
|
+
avgEventsPerUserPerDay: EVENTS_PER_DAY,
|
|
32
|
+
numUsers: NUM_USERS,
|
|
35
33
|
format: "json",
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
34
|
+
credentials: {
|
|
35
|
+
token,
|
|
36
|
+
region: "US",
|
|
37
|
+
},
|
|
38
|
+
switches: {
|
|
39
|
+
hasSessionIds: false,
|
|
40
|
+
hasAdSpend: false,
|
|
41
|
+
hasLocation: false,
|
|
42
|
+
hasAndroidDevices: false,
|
|
43
|
+
hasIOSDevices: false,
|
|
44
|
+
hasDesktopDevices: true,
|
|
45
|
+
hasBrowser: true,
|
|
46
|
+
hasCampaigns: false,
|
|
47
|
+
isAnonymous: false,
|
|
48
|
+
alsoInferFunnels: false,
|
|
49
|
+
},
|
|
48
50
|
concurrency: 1,
|
|
49
51
|
writeToDisk: false,
|
|
50
52
|
|
|
@@ -1,28 +1,40 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
// ── IMPORTS ──
|
|
2
|
+
import dayjs from 'dayjs';
|
|
3
|
+
import {
|
|
4
|
+
binUsersByEventCount,
|
|
5
|
+
scalePropertyValue,
|
|
6
|
+
injectBurst,
|
|
7
|
+
cloneEvent,
|
|
8
|
+
} from '../../lib/hook-helpers/index.js';
|
|
9
|
+
/** @typedef {import("../../types").Dungeon} Config */
|
|
10
|
+
|
|
11
|
+
// ── OVERVIEW ──
|
|
12
|
+
/*
|
|
13
|
+
* NAME: hook-helpers-verify
|
|
14
|
+
* PURPOSE: Phase 3 fixture — exercises binUsersByEventCount + scalePropertyValue + injectBurst atoms
|
|
15
|
+
* SCALE: 1,000 users, ~120K events, 30 days
|
|
16
|
+
* EVENTS (2): Browse, Purchase
|
|
17
|
+
* FUNNELS (0): none
|
|
3
18
|
*
|
|
4
|
-
*
|
|
19
|
+
* Verified by DuckDB queries against the events output (see
|
|
20
|
+
* scripts/verify-runner.mjs + the Phase 3 verification gate notes in the plan).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// ── HOOK STORIES ──
|
|
24
|
+
/*
|
|
25
|
+
* Inside one `everything` hook:
|
|
5
26
|
* 1. `binUsersByEventCount` — classify users into "casual" / "engaged" / "power"
|
|
6
27
|
* based on their count of `Browse` events.
|
|
7
28
|
* 2. `scalePropertyValue` — engaged users get 2x `amount` on Purchase events;
|
|
8
29
|
* power users get 3x.
|
|
9
30
|
* 3. `injectBurst` — power users also get a burst of 5 cloned `Browse` events
|
|
10
31
|
* around the midpoint of their stream.
|
|
11
|
-
*
|
|
12
|
-
* Verified by DuckDB queries against the events output (see
|
|
13
|
-
* scripts/verify-runner.mjs + the Phase 3 verification gate notes in the plan).
|
|
14
32
|
*/
|
|
15
33
|
|
|
16
|
-
|
|
17
|
-
import {
|
|
18
|
-
binUsersByEventCount,
|
|
19
|
-
scalePropertyValue,
|
|
20
|
-
injectBurst,
|
|
21
|
-
cloneEvent,
|
|
22
|
-
} from '../../lib/hook-helpers/index.js';
|
|
23
|
-
|
|
34
|
+
// ── SCALE ──
|
|
24
35
|
const FIXED_NOW = dayjs('2024-02-02').unix();
|
|
25
36
|
|
|
37
|
+
// ── KNOBS (tweak these to reshape stories) ──
|
|
26
38
|
const BINS = {
|
|
27
39
|
casual: [0, 5],
|
|
28
40
|
engaged: [5, 15],
|
|
@@ -32,7 +44,46 @@ const BINS = {
|
|
|
32
44
|
const SCALE_BY_BIN = { casual: 1, engaged: 2, power: 3 };
|
|
33
45
|
const BURST_COUNT_BY_BIN = { casual: 0, engaged: 0, power: 5 };
|
|
34
46
|
|
|
35
|
-
|
|
47
|
+
// ── HELPER FUNCTIONS ──
|
|
48
|
+
function handleEverythingHooks(record, meta) {
|
|
49
|
+
// 1. Classify the user via cohort atom
|
|
50
|
+
const bin = binUsersByEventCount(record, 'Browse', BINS);
|
|
51
|
+
if (!bin) return record;
|
|
52
|
+
|
|
53
|
+
// Stamp the bin onto each Purchase so verification queries can group by it
|
|
54
|
+
// without re-deriving. Non-flag-stamping: this property is config-defined when
|
|
55
|
+
// dungeons add `cohort` to Purchase.properties; here we accept the small bend
|
|
56
|
+
// for the verification fixture so DuckDB queries are clean.
|
|
57
|
+
for (const ev of record) {
|
|
58
|
+
if (ev.event === 'Purchase') ev.cohort = bin;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 2. Scale Purchase amounts via mutate atom (engaged 2x, power 3x)
|
|
62
|
+
const factor = SCALE_BY_BIN[bin] || 1;
|
|
63
|
+
if (factor !== 1) {
|
|
64
|
+
scalePropertyValue(record, e => e.event === 'Purchase', 'amount', factor);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 3. Power users get a Browse burst clustered at the dataset midpoint
|
|
68
|
+
const burstCount = BURST_COUNT_BY_BIN[bin] || 0;
|
|
69
|
+
if (burstCount > 0 && record.length) {
|
|
70
|
+
const midUnixMs = (meta.datasetStart + (meta.datasetEnd - meta.datasetStart) / 2) * 1000;
|
|
71
|
+
// Use the user's first Browse as a clone template so we honor the schema.
|
|
72
|
+
const template = record.find(e => e.event === 'Browse');
|
|
73
|
+
if (template) {
|
|
74
|
+
const tpl = cloneEvent(template, { cohort: bin });
|
|
75
|
+
injectBurst(record, tpl, burstCount, midUnixMs, 60 * 60 * 1000); // ±1h spread
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Re-sort by time after burst injection so downstream pipeline sees ordered events.
|
|
80
|
+
record.sort((a, b) => (a.time < b.time ? -1 : a.time > b.time ? 1 : 0));
|
|
81
|
+
return record;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── CONFIG ──
|
|
85
|
+
/** @type {Config} */
|
|
86
|
+
const config = {
|
|
36
87
|
name: 'hook-helpers-verify',
|
|
37
88
|
seed: 'phase3-helpers-verify',
|
|
38
89
|
datasetStart: FIXED_NOW - 30 * 86400,
|
|
@@ -40,7 +91,6 @@ export default {
|
|
|
40
91
|
numUsers: 1_000,
|
|
41
92
|
avgEventsPerUserPerDay: 4,
|
|
42
93
|
percentUsersBornInDataset: 30,
|
|
43
|
-
hasAnonIds: false,
|
|
44
94
|
format: 'json',
|
|
45
95
|
concurrency: 1,
|
|
46
96
|
writeToDisk: true,
|
|
@@ -51,39 +101,8 @@ export default {
|
|
|
51
101
|
],
|
|
52
102
|
hook: function (record, type, meta) {
|
|
53
103
|
if (type !== 'everything' || !Array.isArray(record)) return record;
|
|
54
|
-
|
|
55
|
-
// 1. Classify the user via cohort atom
|
|
56
|
-
const bin = binUsersByEventCount(record, 'Browse', BINS);
|
|
57
|
-
if (!bin) return record;
|
|
58
|
-
|
|
59
|
-
// Stamp the bin onto each Purchase so verification queries can group by it
|
|
60
|
-
// without re-deriving. Non-flag-stamping: this property is config-defined when
|
|
61
|
-
// dungeons add `cohort` to Purchase.properties; here we accept the small bend
|
|
62
|
-
// for the verification fixture so DuckDB queries are clean.
|
|
63
|
-
for (const ev of record) {
|
|
64
|
-
if (ev.event === 'Purchase') ev.cohort = bin;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// 2. Scale Purchase amounts via mutate atom (engaged 2x, power 3x)
|
|
68
|
-
const factor = SCALE_BY_BIN[bin] || 1;
|
|
69
|
-
if (factor !== 1) {
|
|
70
|
-
scalePropertyValue(record, e => e.event === 'Purchase', 'amount', factor);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// 3. Power users get a Browse burst clustered at the dataset midpoint
|
|
74
|
-
const burstCount = BURST_COUNT_BY_BIN[bin] || 0;
|
|
75
|
-
if (burstCount > 0 && record.length) {
|
|
76
|
-
const midUnixMs = (meta.datasetStart + (meta.datasetEnd - meta.datasetStart) / 2) * 1000;
|
|
77
|
-
// Use the user's first Browse as a clone template so we honor the schema.
|
|
78
|
-
const template = record.find(e => e.event === 'Browse');
|
|
79
|
-
if (template) {
|
|
80
|
-
const tpl = cloneEvent(template, { cohort: bin });
|
|
81
|
-
injectBurst(record, tpl, burstCount, midUnixMs, 60 * 60 * 1000); // ±1h spread
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Re-sort by time after burst injection so downstream pipeline sees ordered events.
|
|
86
|
-
record.sort((a, b) => (a.time < b.time ? -1 : a.time > b.time ? 1 : 0));
|
|
87
|
-
return record;
|
|
104
|
+
return handleEverythingHooks(record, meta);
|
|
88
105
|
},
|
|
89
106
|
};
|
|
107
|
+
|
|
108
|
+
export default config;
|
|
@@ -1,16 +1,23 @@
|
|
|
1
|
-
|
|
2
|
-
* Phase 2 identity model — verification fixture (intentionally simple).
|
|
3
|
-
*
|
|
4
|
-
* 1K born-in-dataset users with isAuthEvent on `Sign Up` (the stitch step) and a
|
|
5
|
-
* 3-step first funnel. Dataset window pinned for determinism. avgDevicePerUser:2
|
|
6
|
-
* exercises the multi-device sticky-per-session model. Used by phase 2 verification
|
|
7
|
-
* gate (DuckDB queries against the events output).
|
|
8
|
-
*/
|
|
9
|
-
|
|
1
|
+
// ── IMPORTS ──
|
|
10
2
|
import dayjs from 'dayjs';
|
|
11
3
|
|
|
4
|
+
// ── OVERVIEW ──
|
|
5
|
+
/*
|
|
6
|
+
* NAME: identity-model-verify
|
|
7
|
+
* PURPOSE: Phase 2 identity model verification fixture (intentionally simple).
|
|
8
|
+
* 1K born-in-dataset users with isAuthEvent on `Sign Up` (the stitch
|
|
9
|
+
* step) and a 3-step first funnel. avgDevicePerUser:2 exercises the
|
|
10
|
+
* multi-device sticky-per-session model. Used by the phase 2
|
|
11
|
+
* verification gate (DuckDB queries against the events output).
|
|
12
|
+
* SCALE: 1,000 users, ~90K events, 30 days
|
|
13
|
+
* EVENTS (6): Browse (5) > Save Item (2) > Land (1) > View Pricing (1) > Sign Up (1) > Onboarding (1)
|
|
14
|
+
* FUNNELS (1): Acquisition: Land → View Pricing → Sign Up → Onboarding (80%, attempts 0-2)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// ── SCALE ──
|
|
12
18
|
const FIXED_NOW = dayjs('2024-02-02').unix();
|
|
13
19
|
|
|
20
|
+
// ── CONFIG ──
|
|
14
21
|
export default {
|
|
15
22
|
name: 'identity-model-verify',
|
|
16
23
|
seed: 'phase2-identity-verify',
|
|
@@ -19,9 +26,12 @@ export default {
|
|
|
19
26
|
numUsers: 1_000,
|
|
20
27
|
avgEventsPerUserPerDay: 3,
|
|
21
28
|
percentUsersBornInDataset: 100,
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
29
|
+
switches: {
|
|
30
|
+
hasSessionIds: true,
|
|
31
|
+
},
|
|
32
|
+
identity: {
|
|
33
|
+
avgDevicePerUser: 2,
|
|
34
|
+
},
|
|
25
35
|
format: 'json',
|
|
26
36
|
concurrency: 1,
|
|
27
37
|
writeToDisk: true,
|