@ak--47/dungeon-master 1.5.0 → 1.5.1
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/CHANGELOG.md +43 -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,20 +1,39 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
// ── IMPORTS ──
|
|
2
|
+
import dayjs from 'dayjs';
|
|
3
|
+
import { applyFunnelFrequencyBreakdown } from '../../lib/hook-patterns/index.js';
|
|
4
|
+
|
|
5
|
+
// ── OVERVIEW ──
|
|
6
|
+
/*
|
|
7
|
+
* NAME: pattern-funnel-frequency
|
|
8
|
+
* PURPOSE: Phase 4 reference fixture — funnel frequency breakdown.
|
|
9
|
+
* Engaged-cohort users (high count of `Browse`) are 1.5× more likely
|
|
10
|
+
* to complete the activation funnel's last step than low-cohort
|
|
11
|
+
* users. Verified via the funnelFrequency emulator.
|
|
12
|
+
* SCALE: 1,000 users, ~120K events, 30 days
|
|
13
|
+
* EVENTS (4): Browse (5) > Land (1) > Sign Up (1) > Activate (1)
|
|
14
|
+
* FUNNELS (1): Activation: Land → Sign Up → Activate (70%)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// ── HOOK STORIES ──
|
|
18
|
+
/*
|
|
19
|
+
* PATTERN: Bin users by Browse count (low 0-3, high 3+); within funnel-post,
|
|
20
|
+
* drop final-step `Activate` events at rates {low:0.5, high:0.95}, lifting
|
|
21
|
+
* the high cohort's conversion-per-step.
|
|
7
22
|
*
|
|
8
|
-
* Note: this pattern is funnel-post; it edits the funnel's own events.
|
|
9
|
-
* with
|
|
23
|
+
* Note: this pattern is funnel-post; it edits the funnel's own events.
|
|
24
|
+
* Combined with per-user funnel attempts, the breakdown shows a stronger
|
|
10
25
|
* conversion-per-step lift in the high cohort.
|
|
26
|
+
*
|
|
27
|
+
* In funnel-post we don't have the user's full event stream — the pattern
|
|
28
|
+
* accepts null for `allUserEvents` and falls back to counting cohortEvent
|
|
29
|
+
* inside the funnel-only events. Real dungeons usually wire this via
|
|
30
|
+
* `everything` instead.
|
|
11
31
|
*/
|
|
12
32
|
|
|
13
|
-
|
|
14
|
-
import { applyFunnelFrequencyBreakdown } from '../../lib/hook-patterns/index.js';
|
|
15
|
-
|
|
33
|
+
// ── SCALE ──
|
|
16
34
|
const FIXED_NOW = dayjs('2024-02-02').unix();
|
|
17
35
|
|
|
36
|
+
// ── CONFIG ──
|
|
18
37
|
export default {
|
|
19
38
|
name: 'pattern-funnel-freq',
|
|
20
39
|
seed: 'phase4-funnel-freq',
|
|
@@ -23,7 +42,6 @@ export default {
|
|
|
23
42
|
numUsers: 1_000,
|
|
24
43
|
avgEventsPerUserPerDay: 4,
|
|
25
44
|
percentUsersBornInDataset: 100,
|
|
26
|
-
hasAnonIds: false,
|
|
27
45
|
format: 'json',
|
|
28
46
|
concurrency: 1,
|
|
29
47
|
writeToDisk: true,
|
|
@@ -41,9 +59,6 @@ export default {
|
|
|
41
59
|
hook: function (record, type, meta) {
|
|
42
60
|
if (type !== 'funnel-post' || !Array.isArray(record)) return;
|
|
43
61
|
if (!meta.isFirstFunnel) return;
|
|
44
|
-
// In funnel-post we don't have the user's full event stream — pattern accepts
|
|
45
|
-
// null for `allUserEvents` and falls back to counting cohortEvent inside the
|
|
46
|
-
// funnel-only events. Real dungeons usually wire this via `everything` instead.
|
|
47
62
|
applyFunnelFrequencyBreakdown(null, meta.profile || {}, record, {
|
|
48
63
|
cohortEvent: 'Browse',
|
|
49
64
|
bins: { low: [0, 3], high: [3, Infinity] },
|
|
@@ -1,15 +1,29 @@
|
|
|
1
|
-
|
|
2
|
-
* Phase 4 reference dungeon — time to convert by segment.
|
|
3
|
-
*
|
|
4
|
-
* Trial users take 4× longer to complete the activation funnel than enterprise
|
|
5
|
-
* users (0.5× faster). Verified via the timeToConvert emulator.
|
|
6
|
-
*/
|
|
7
|
-
|
|
1
|
+
// ── IMPORTS ──
|
|
8
2
|
import dayjs from 'dayjs';
|
|
9
3
|
import { applyTTCBySegment } from '../../lib/hook-patterns/index.js';
|
|
10
4
|
|
|
5
|
+
// ── OVERVIEW ──
|
|
6
|
+
/*
|
|
7
|
+
* NAME: pattern-ttc-by-segment
|
|
8
|
+
* PURPOSE: Phase 4 reference fixture — time-to-convert by segment. Trial
|
|
9
|
+
* users take 4× longer to complete the activation funnel; enterprise
|
|
10
|
+
* 0.5× faster. Verified via the timeToConvert emulator.
|
|
11
|
+
* SCALE: 1,000 users, ~120K events, 30 days
|
|
12
|
+
* EVENTS (4): Browse (5) > Land (1) > Sign Up (1) > Activate (1)
|
|
13
|
+
* FUNNELS (1): Activation: Land → Sign Up → Activate (100%)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ── HOOK STORIES ──
|
|
17
|
+
/*
|
|
18
|
+
* PATTERN: Scale the activation funnel's time-to-convert by user tier:
|
|
19
|
+
* trial users 4× slower, enterprise 0.5× faster. Verified via the
|
|
20
|
+
* timeToConvert emulator.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// ── SCALE ──
|
|
11
24
|
const FIXED_NOW = dayjs('2024-02-02').unix();
|
|
12
25
|
|
|
26
|
+
// ── CONFIG ──
|
|
13
27
|
export default {
|
|
14
28
|
name: 'pattern-ttc-by-segment',
|
|
15
29
|
seed: 'phase4-ttc-seg',
|
|
@@ -18,7 +32,6 @@ export default {
|
|
|
18
32
|
numUsers: 1_000,
|
|
19
33
|
avgEventsPerUserPerDay: 4,
|
|
20
34
|
percentUsersBornInDataset: 100,
|
|
21
|
-
hasAnonIds: false,
|
|
22
35
|
format: 'json',
|
|
23
36
|
concurrency: 1,
|
|
24
37
|
writeToDisk: true,
|
|
@@ -1,38 +1,25 @@
|
|
|
1
|
-
// ──
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const num_users = 100;
|
|
5
|
-
const avg_events_per_user_per_day = 0.14;
|
|
6
|
-
let token = "";
|
|
7
|
-
|
|
8
|
-
// ── env overrides ──
|
|
9
|
-
if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
|
|
10
|
-
|
|
11
|
-
import dayjs from 'dayjs';
|
|
12
|
-
import utc from 'dayjs/plugin/utc.js';
|
|
1
|
+
// ── IMPORTS ──
|
|
2
|
+
import dayjs from "dayjs";
|
|
3
|
+
import utc from "dayjs/plugin/utc.js";
|
|
13
4
|
dayjs.extend(utc);
|
|
14
5
|
import * as u from "../../lib/utils/utils.js";
|
|
15
|
-
import * as v from
|
|
16
|
-
const chance = u.initChance(SEED);
|
|
17
|
-
|
|
6
|
+
import * as v from "ak-tools";
|
|
18
7
|
/** @typedef {import("../../types").Dungeon} Config */
|
|
19
8
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
* ═══════════════════════════════════════════════════════════════
|
|
35
|
-
*
|
|
9
|
+
// ── OVERVIEW ──
|
|
10
|
+
/*
|
|
11
|
+
* NAME: retention-cadence
|
|
12
|
+
* PURPOSE: Exercises retention shaping — the everything hook REPLACES all
|
|
13
|
+
* generated events with precisely cadenced behavior streams
|
|
14
|
+
* (hourly/daily/weekly/monthly) with color-driven churn.
|
|
15
|
+
* SCALE: 100 users, ~5K base events (replaced by cadenced events), 360 days
|
|
16
|
+
* EVENTS (6): first behavior, hourly behavior, daily behavior, weekly behavior,
|
|
17
|
+
* monthly behavior, placeholder
|
|
18
|
+
* FUNNELS (0): none
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// ── HOOK STORIES ──
|
|
22
|
+
/*
|
|
36
23
|
* 1. COLOR-BASED CADENCED CHURN (everything hook)
|
|
37
24
|
* All raw events are replaced with 4 cadenced behavior streams
|
|
38
25
|
* (hourly/daily/weekly/monthly). Each user's "favorite color"
|
|
@@ -52,6 +39,16 @@ const chance = u.initChance(SEED);
|
|
|
52
39
|
* Expected: each color dominates its respective cadence's churn events
|
|
53
40
|
*/
|
|
54
41
|
|
|
42
|
+
// ── SCALE ──
|
|
43
|
+
const SEED = "kurby-retention";
|
|
44
|
+
const NUM_DAYS = 360;
|
|
45
|
+
const NUM_USERS = 100;
|
|
46
|
+
const EVENTS_PER_DAY = 0.14;
|
|
47
|
+
const token = process.env.MP_TOKEN || "";
|
|
48
|
+
|
|
49
|
+
const chance = u.initChance(SEED);
|
|
50
|
+
|
|
51
|
+
// ── KNOBS (tweak these to reshape stories) ──
|
|
55
52
|
// Churn rates by favorite color per behavior type (percentage likelihood)
|
|
56
53
|
const CHURN_RATES = {
|
|
57
54
|
red: { hourly: 15, daily: 40, weekly: 15, monthly: 10 },
|
|
@@ -61,7 +58,7 @@ const CHURN_RATES = {
|
|
|
61
58
|
purple: { hourly: 40, daily: 15, weekly: 15, monthly: 10 },
|
|
62
59
|
};
|
|
63
60
|
|
|
64
|
-
// Behavior cadence definitions:
|
|
61
|
+
// Behavior cadence definitions: interval + unit + jitter window
|
|
65
62
|
const BEHAVIORS = {
|
|
66
63
|
hourly: { interval: 1, unit: 'hour', jitterMax: 10, jitterUnit: 'minute' },
|
|
67
64
|
daily: { interval: 1, unit: 'day', jitterMax: 2, jitterUnit: 'hour' },
|
|
@@ -69,6 +66,7 @@ const BEHAVIORS = {
|
|
|
69
66
|
monthly: { interval: 30, unit: 'day', jitterMax: 2, jitterUnit: 'day' },
|
|
70
67
|
};
|
|
71
68
|
|
|
69
|
+
// ── HELPER FUNCTIONS ──
|
|
72
70
|
function generateCadencedEvents(behaviorName, cadence, startTime, endTime, churnTime, userId) {
|
|
73
71
|
const events = [];
|
|
74
72
|
let cursor = dayjs(startTime);
|
|
@@ -105,26 +103,94 @@ function generateCadencedEvents(behaviorName, cadence, startTime, endTime, churn
|
|
|
105
103
|
return events;
|
|
106
104
|
}
|
|
107
105
|
|
|
106
|
+
function handleEverythingHooks(record, meta) {
|
|
107
|
+
if (!record.length) return record;
|
|
108
|
+
|
|
109
|
+
const userId = record[0].user_id || record[0].device_id;
|
|
110
|
+
if (!userId) return record;
|
|
111
|
+
|
|
112
|
+
const favoriteColor = meta.profile?.["favorite color"] || "green";
|
|
113
|
+
const colorChurnRates = CHURN_RATES[favoriteColor] || CHURN_RATES.green;
|
|
114
|
+
|
|
115
|
+
// Find the time range from the raw events
|
|
116
|
+
const times = record.map(e => new Date(e.time).getTime()).filter(t => !isNaN(t));
|
|
117
|
+
if (!times.length) return record;
|
|
118
|
+
const startTime = dayjs(Math.min(...times));
|
|
119
|
+
const endTime = dayjs(Math.max(...times));
|
|
120
|
+
const totalSpanHours = endTime.diff(startTime, 'hour');
|
|
121
|
+
|
|
122
|
+
if (totalSpanHours < 1) return record;
|
|
123
|
+
|
|
124
|
+
// Keep the "first behavior" event from the original stream
|
|
125
|
+
const firstBehavior = record.find(e => e.event === "first behavior");
|
|
126
|
+
const newEvents = [];
|
|
127
|
+
|
|
128
|
+
if (firstBehavior) {
|
|
129
|
+
newEvents.push(firstBehavior);
|
|
130
|
+
} else {
|
|
131
|
+
// Create a first behavior event at the start
|
|
132
|
+
newEvents.push({
|
|
133
|
+
event: "first behavior",
|
|
134
|
+
time: startTime.toISOString(),
|
|
135
|
+
user_id: userId,
|
|
136
|
+
insert_id: v.uid(12),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// For each behavior type, determine churn and generate cadenced events
|
|
141
|
+
for (const [behaviorKey, cadence] of Object.entries(BEHAVIORS)) {
|
|
142
|
+
const churnRate = colorChurnRates[behaviorKey];
|
|
143
|
+
let churnTime = null;
|
|
144
|
+
|
|
145
|
+
if (chance.bool({ likelihood: churnRate })) {
|
|
146
|
+
// Pick churn time between 20% and 80% into the active period
|
|
147
|
+
const churnOffsetHours = chance.integer({
|
|
148
|
+
min: Math.floor(totalSpanHours * 0.2),
|
|
149
|
+
max: Math.floor(totalSpanHours * 0.8),
|
|
150
|
+
});
|
|
151
|
+
churnTime = startTime.add(churnOffsetHours, 'hour');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const behaviorEvents = generateCadencedEvents(
|
|
155
|
+
behaviorKey,
|
|
156
|
+
cadence,
|
|
157
|
+
startTime,
|
|
158
|
+
endTime,
|
|
159
|
+
churnTime,
|
|
160
|
+
userId
|
|
161
|
+
);
|
|
162
|
+
newEvents.push(...behaviorEvents);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Sort all events by time
|
|
166
|
+
newEvents.sort((a, b) => new Date(a.time) - new Date(b.time));
|
|
167
|
+
return newEvents;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── CONFIG ──
|
|
108
171
|
/** @type {Config} */
|
|
109
172
|
const config = {
|
|
110
|
-
token,
|
|
111
173
|
seed: SEED,
|
|
112
|
-
numDays:
|
|
113
|
-
avgEventsPerUserPerDay:
|
|
114
|
-
numUsers:
|
|
115
|
-
hasAnonIds: false,
|
|
116
|
-
hasSessionIds: false,
|
|
174
|
+
numDays: NUM_DAYS,
|
|
175
|
+
avgEventsPerUserPerDay: EVENTS_PER_DAY,
|
|
176
|
+
numUsers: NUM_USERS,
|
|
117
177
|
format: "json",
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
178
|
+
credentials: {
|
|
179
|
+
token,
|
|
180
|
+
},
|
|
181
|
+
switches: {
|
|
182
|
+
hasSessionIds: false,
|
|
183
|
+
alsoInferFunnels: false,
|
|
184
|
+
hasLocation: false,
|
|
185
|
+
hasAndroidDevices: false,
|
|
186
|
+
hasIOSDevices: false,
|
|
187
|
+
hasDesktopDevices: false,
|
|
188
|
+
hasBrowser: false,
|
|
189
|
+
hasCampaigns: false,
|
|
190
|
+
isAnonymous: false,
|
|
191
|
+
hasAdSpend: false,
|
|
192
|
+
hasAvatar: false,
|
|
193
|
+
},
|
|
128
194
|
concurrency: 1,
|
|
129
195
|
writeToDisk: false,
|
|
130
196
|
|
|
@@ -177,70 +243,7 @@ const config = {
|
|
|
177
243
|
lookupTables: [],
|
|
178
244
|
|
|
179
245
|
hook: function (record, type, meta) {
|
|
180
|
-
if (type === "everything")
|
|
181
|
-
if (!record.length) return record;
|
|
182
|
-
|
|
183
|
-
const userId = record[0].user_id || record[0].device_id;
|
|
184
|
-
if (!userId) return record;
|
|
185
|
-
|
|
186
|
-
const favoriteColor = meta.profile?.["favorite color"] || "green";
|
|
187
|
-
const colorChurnRates = CHURN_RATES[favoriteColor] || CHURN_RATES.green;
|
|
188
|
-
|
|
189
|
-
// Find the time range from the raw events
|
|
190
|
-
const times = record.map(e => new Date(e.time).getTime()).filter(t => !isNaN(t));
|
|
191
|
-
if (!times.length) return record;
|
|
192
|
-
const startTime = dayjs(Math.min(...times));
|
|
193
|
-
const endTime = dayjs(Math.max(...times));
|
|
194
|
-
const totalSpanHours = endTime.diff(startTime, 'hour');
|
|
195
|
-
|
|
196
|
-
if (totalSpanHours < 1) return record;
|
|
197
|
-
|
|
198
|
-
// Keep the "first behavior" event from the original stream
|
|
199
|
-
const firstBehavior = record.find(e => e.event === "first behavior");
|
|
200
|
-
const newEvents = [];
|
|
201
|
-
|
|
202
|
-
if (firstBehavior) {
|
|
203
|
-
newEvents.push(firstBehavior);
|
|
204
|
-
} else {
|
|
205
|
-
// Create a first behavior event at the start
|
|
206
|
-
newEvents.push({
|
|
207
|
-
event: "first behavior",
|
|
208
|
-
time: startTime.toISOString(),
|
|
209
|
-
user_id: userId,
|
|
210
|
-
insert_id: v.uid(12),
|
|
211
|
-
});
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// For each behavior type, determine churn and generate cadenced events
|
|
215
|
-
for (const [behaviorKey, cadence] of Object.entries(BEHAVIORS)) {
|
|
216
|
-
const churnRate = colorChurnRates[behaviorKey];
|
|
217
|
-
let churnTime = null;
|
|
218
|
-
|
|
219
|
-
if (chance.bool({ likelihood: churnRate })) {
|
|
220
|
-
// Pick churn time between 20% and 80% into the active period
|
|
221
|
-
const churnOffsetHours = chance.integer({
|
|
222
|
-
min: Math.floor(totalSpanHours * 0.2),
|
|
223
|
-
max: Math.floor(totalSpanHours * 0.8),
|
|
224
|
-
});
|
|
225
|
-
churnTime = startTime.add(churnOffsetHours, 'hour');
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
const behaviorEvents = generateCadencedEvents(
|
|
229
|
-
behaviorKey,
|
|
230
|
-
cadence,
|
|
231
|
-
startTime,
|
|
232
|
-
endTime,
|
|
233
|
-
churnTime,
|
|
234
|
-
userId
|
|
235
|
-
);
|
|
236
|
-
newEvents.push(...behaviorEvents);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Sort all events by time
|
|
240
|
-
newEvents.sort((a, b) => new Date(a.time) - new Date(b.time));
|
|
241
|
-
return newEvents;
|
|
242
|
-
}
|
|
243
|
-
|
|
246
|
+
if (type === "everything") return handleEverythingHooks(record, meta);
|
|
244
247
|
return record;
|
|
245
248
|
},
|
|
246
249
|
};
|
|
@@ -1,60 +1,100 @@
|
|
|
1
|
-
// ──
|
|
2
|
-
const SEED = "foo bar";
|
|
3
|
-
const num_days = 90;
|
|
4
|
-
const num_users = 500;
|
|
5
|
-
const avg_events_per_user_per_day = 1.11;
|
|
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
|
-
import dayjs from 'dayjs';
|
|
14
|
-
import utc from 'dayjs/plugin/utc.js';
|
|
15
|
-
dayjs.extend(utc);
|
|
16
3
|
import { weighNumRange, integer } from "../../lib/utils/utils.js";
|
|
17
|
-
|
|
18
4
|
/** @typedef {import("../../types").Dungeon} Config */
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
* ANALYTICS HOOKS (2 patterns)
|
|
32
|
-
* ═══════════════════════════════════════════════════════════════
|
|
33
|
-
*
|
|
5
|
+
|
|
6
|
+
// ── OVERVIEW ──
|
|
7
|
+
/*
|
|
8
|
+
* NAME: sanity
|
|
9
|
+
* PURPOSE: lightweight dungeon for module integration testing — abstract foo-yak events, inferred funnels
|
|
10
|
+
* SCALE: 500 users, ~50K events, 90 days
|
|
11
|
+
* EVENTS (10): foo, bar, baz, qux, garply, durtle, linny, fonk, crumn, yak
|
|
12
|
+
* FUNNELS (2): qux→garply→durtle→linny→fonk→crumn→yak; foo→bar→baz (first)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// ── HOOK STORIES ──
|
|
16
|
+
/*
|
|
34
17
|
* 1. TEMPERATURE TAGGING (event hook)
|
|
35
18
|
* foo/bar/baz = "hot", crumn/yak = "cold", everything else = "warm".
|
|
19
|
+
* (NOTE: current implementation stamps an integer engagement_score
|
|
20
|
+
* bucketed by event name — high/mid/low engagement bands.)
|
|
36
21
|
*
|
|
37
22
|
* 2. HASH-BASED POWER USERS (everything hook)
|
|
38
23
|
* ~10% of users (by distinct_id hash) get 3 extra duplicate events.
|
|
24
|
+
* (NOTE: current implementation trims the tail of users with 4-7
|
|
25
|
+
* events to simulate light-user churn.)
|
|
39
26
|
*/
|
|
40
27
|
|
|
28
|
+
// ── SCALE ──
|
|
29
|
+
const SEED = "foo bar";
|
|
30
|
+
const NUM_DAYS = 90;
|
|
31
|
+
const NUM_USERS = 500;
|
|
32
|
+
const EVENTS_PER_DAY = 1.11;
|
|
33
|
+
const token = process.env.MP_TOKEN || "";
|
|
34
|
+
|
|
35
|
+
const chance = new Chance();
|
|
36
|
+
|
|
37
|
+
// ── DATA ARRAYS ──
|
|
38
|
+
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"];
|
|
39
|
+
const colors = ["red", "orange", "yellow", "green", "blue", "indigo", "violet"];
|
|
40
|
+
|
|
41
|
+
// ── HELPER FUNCTIONS ──
|
|
42
|
+
function handleUserHook(record) {
|
|
43
|
+
// tag power users based on luckyNumber
|
|
44
|
+
record.userTier = record.luckyNumber > 300 ? "power" : "regular";
|
|
45
|
+
return record;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function handleEventHook(record) {
|
|
49
|
+
// add an engagement score based on event type
|
|
50
|
+
const highEngagement = ["fonk", "crumn", "yak"];
|
|
51
|
+
const midEngagement = ["garply", "durtle", "linny"];
|
|
52
|
+
if (highEngagement.includes(record.event)) {
|
|
53
|
+
record.engagement_score = chance.integer({ min: 70, max: 100 });
|
|
54
|
+
} else if (midEngagement.includes(record.event)) {
|
|
55
|
+
record.engagement_score = chance.integer({ min: 30, max: 69 });
|
|
56
|
+
} else {
|
|
57
|
+
record.engagement_score = chance.integer({ min: 1, max: 29 });
|
|
58
|
+
}
|
|
59
|
+
return record;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function handleEverythingHook(record, meta) {
|
|
63
|
+
// low-activity users lose their last few events (churn simulation)
|
|
64
|
+
const profile = meta.profile;
|
|
65
|
+
record.forEach(e => {
|
|
66
|
+
e.color = profile.color;
|
|
67
|
+
e.number = profile.number;
|
|
68
|
+
// engagement_score intentionally varies per event (set by event hook)
|
|
69
|
+
});
|
|
70
|
+
if (record.length > 3 && record.length < 8) {
|
|
71
|
+
// users with few events lose the tail end — simulates churn
|
|
72
|
+
return record.slice(0, Math.ceil(record.length * 0.6));
|
|
73
|
+
}
|
|
74
|
+
return record;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── CONFIG ──
|
|
41
78
|
/** @type {import('../types.js').Dungeon} */
|
|
42
79
|
const config = {
|
|
43
|
-
token,
|
|
44
80
|
seed: SEED,
|
|
45
|
-
numDays:
|
|
46
|
-
avgEventsPerUserPerDay:
|
|
47
|
-
numUsers:
|
|
81
|
+
numDays: NUM_DAYS,
|
|
82
|
+
avgEventsPerUserPerDay: EVENTS_PER_DAY,
|
|
83
|
+
numUsers: NUM_USERS,
|
|
48
84
|
format: 'json', //csv or json
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
85
|
+
credentials: {
|
|
86
|
+
token,
|
|
87
|
+
region: "US",
|
|
88
|
+
},
|
|
89
|
+
switches: {
|
|
90
|
+
hasSessionIds: false, //if true, hasSessionIds are created for each user
|
|
91
|
+
alsoInferFunnels: true, //if true, infer funnels from events
|
|
92
|
+
},
|
|
53
93
|
writeToDisk: false,
|
|
54
94
|
concurrency: 1,
|
|
55
95
|
funnels: [
|
|
56
96
|
{
|
|
57
|
-
sequence: ["qux", "garply", "durtle", "linny", "fonk", "crumn", "yak"],
|
|
97
|
+
sequence: ["qux", "garply", "durtle", "linny", "fonk", "crumn", "yak"],
|
|
58
98
|
},
|
|
59
99
|
{
|
|
60
100
|
sequence: ["foo", "bar", "baz"],
|
|
@@ -115,7 +155,7 @@ const config = {
|
|
|
115
155
|
}
|
|
116
156
|
],
|
|
117
157
|
superProps: {
|
|
118
|
-
color:
|
|
158
|
+
color: colors,
|
|
119
159
|
number: integer,
|
|
120
160
|
engagement_score: [0],
|
|
121
161
|
},
|
|
@@ -123,51 +163,17 @@ const config = {
|
|
|
123
163
|
title: chance.profession.bind(chance),
|
|
124
164
|
luckyNumber: weighNumRange(42, 420),
|
|
125
165
|
userTier: ["regular"],
|
|
126
|
-
spiritAnimal:
|
|
127
|
-
color:
|
|
166
|
+
spiritAnimal: spiritAnimals,
|
|
167
|
+
color: colors,
|
|
128
168
|
number: integer,
|
|
129
169
|
engagement_score: [0],
|
|
130
170
|
},
|
|
131
171
|
hook: function (record, type, meta) {
|
|
132
|
-
|
|
133
|
-
if (type === "
|
|
134
|
-
|
|
135
|
-
return record;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// --- event hook: add an engagement score based on event type ---
|
|
139
|
-
if (type === "event") {
|
|
140
|
-
const highEngagement = ["fonk", "crumn", "yak"];
|
|
141
|
-
const midEngagement = ["garply", "durtle", "linny"];
|
|
142
|
-
if (highEngagement.includes(record.event)) {
|
|
143
|
-
record.engagement_score = chance.integer({ min: 70, max: 100 });
|
|
144
|
-
} else if (midEngagement.includes(record.event)) {
|
|
145
|
-
record.engagement_score = chance.integer({ min: 30, max: 69 });
|
|
146
|
-
} else {
|
|
147
|
-
record.engagement_score = chance.integer({ min: 1, max: 29 });
|
|
148
|
-
}
|
|
149
|
-
return record;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// --- everything hook: low-activity users lose their last few events (churn simulation) ---
|
|
153
|
-
if (type === "everything") {
|
|
154
|
-
const profile = meta.profile;
|
|
155
|
-
record.forEach(e => {
|
|
156
|
-
e.color = profile.color;
|
|
157
|
-
e.number = profile.number;
|
|
158
|
-
// engagement_score intentionally varies per event (set by event hook)
|
|
159
|
-
});
|
|
160
|
-
if (record.length > 3 && record.length < 8) {
|
|
161
|
-
// users with few events lose the tail end — simulates churn
|
|
162
|
-
return record.slice(0, Math.ceil(record.length * 0.6));
|
|
163
|
-
}
|
|
164
|
-
return record;
|
|
165
|
-
}
|
|
166
|
-
|
|
172
|
+
if (type === "user") return handleUserHook(record);
|
|
173
|
+
if (type === "event") return handleEventHook(record);
|
|
174
|
+
if (type === "everything") return handleEverythingHook(record, meta);
|
|
167
175
|
return record;
|
|
168
176
|
}
|
|
169
177
|
};
|
|
170
178
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
export default config;
|
|
179
|
+
export default config;
|