@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,62 +1,50 @@
|
|
|
1
|
-
// ──
|
|
2
|
-
const SEED = "harness-food";
|
|
3
|
-
const num_days = 120;
|
|
4
|
-
const num_users = 10_000;
|
|
5
|
-
const avg_events_per_user_per_day = 1.2;
|
|
6
|
-
let token = "your-mixpanel-token";
|
|
7
|
-
|
|
8
|
-
// ── env overrides ──
|
|
9
|
-
if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
|
|
10
|
-
|
|
1
|
+
// ── IMPORTS ──
|
|
11
2
|
import dayjs from "dayjs";
|
|
12
3
|
import utc from "dayjs/plugin/utc.js";
|
|
4
|
+
dayjs.extend(utc);
|
|
13
5
|
import "dotenv/config";
|
|
14
6
|
import * as u from "../../lib/utils/utils.js";
|
|
15
7
|
import * as v from "ak-tools";
|
|
16
8
|
import { findFirstSequence, scaleFunnelTTC } from "../../lib/hook-helpers/timing.js";
|
|
17
|
-
|
|
18
|
-
dayjs.extend(utc);
|
|
19
|
-
const chance = u.initChance(SEED);
|
|
20
9
|
/** @typedef {import("../../types").Dungeon} Config */
|
|
21
10
|
|
|
11
|
+
// ── OVERVIEW ──
|
|
22
12
|
/*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* Subscription
|
|
48
|
-
*
|
|
13
|
+
* NAME: QuickBite
|
|
14
|
+
* APP: Food delivery platform (DoorDash/Uber Eats style). Users browse
|
|
15
|
+
* restaurants, build carts, place orders, track deliveries, and
|
|
16
|
+
* rate their experiences. Monetization via delivery fees,
|
|
17
|
+
* QuickBite+ subscription, and promotional coupons.
|
|
18
|
+
* SCALE: 10,000 users, ~1.4M events, 121 days (2026-01-01 → 2026-05-01)
|
|
19
|
+
* CORE LOOP: sign up → browse/search → add to cart → checkout → order placed → track → rate → reorder
|
|
20
|
+
*
|
|
21
|
+
* EVENTS (17):
|
|
22
|
+
* restaurant browsed (18) > restaurant viewed (15) > item added to cart (14)
|
|
23
|
+
* > order tracked (13) > checkout started (12) > search performed (11)
|
|
24
|
+
* > order placed (10) > order delivered (9) > promotion viewed (8)
|
|
25
|
+
* > order rated (7) > reorder initiated (6) > item removed from cart (5)
|
|
26
|
+
* > coupon applied (4) > support ticket (3) > subscription started (2)
|
|
27
|
+
* > subscription cancelled (1) > account created (1)
|
|
28
|
+
*
|
|
29
|
+
* FUNNELS (8):
|
|
30
|
+
* - Onboarding: account created → restaurant browsed → restaurant viewed (80%)
|
|
31
|
+
* - Browse Discovery: restaurant browsed → restaurant viewed → item added to cart (55%)
|
|
32
|
+
* - Search Ordering: search performed → restaurant viewed → item added to cart → checkout started (45%)
|
|
33
|
+
* - Order Lifecycle: checkout started → order placed → order tracked → order delivered (65%)
|
|
34
|
+
* - Reorder Loop: order delivered → order rated → reorder initiated (40%)
|
|
35
|
+
* - Promo Flow: promotion viewed → coupon applied → checkout started (50%)
|
|
36
|
+
* - Support Flow: support ticket → order rated (45%)
|
|
37
|
+
* - Subscription Mgmt: subscription started → order placed → subscription cancelled (20%)
|
|
38
|
+
*
|
|
39
|
+
* USER PROPS: preferred_cuisine, avg_order_value, orders_per_month, favorite_restaurant_count, Platform, subscription_tier, city
|
|
40
|
+
* SUPER PROPS: Platform, subscription_tier, city
|
|
41
|
+
* SCD PROPS: subscription_tier (free/trial/monthly/annual, monthly fuzzy, max 6),
|
|
42
|
+
* restaurant_tier (new/verified/featured/premium, monthly fixed, max 6, type=restaurant_id)
|
|
43
|
+
* GROUPS: restaurant_id (200 restaurants; restaurant viewed / order placed / order rated)
|
|
49
44
|
*/
|
|
50
45
|
|
|
46
|
+
// ── HOOK STORIES ──
|
|
51
47
|
/*
|
|
52
|
-
* ═══════════════════════════════════════════════════════════════════════════════
|
|
53
|
-
* ANALYTICS HOOKS (10 hooks)
|
|
54
|
-
*
|
|
55
|
-
* Adds 9. ORDER LIFECYCLE TIME-TO-CONVERT: QuickBite+ 0.67x delivery times,
|
|
56
|
-
* Free 1.4x slower (everything hook, property scaling). Discover via
|
|
57
|
-
* avg(actual_delivery_mins) on "order delivered" by subscription_tier.
|
|
58
|
-
* ═══════════════════════════════════════════════════════════════════════════════
|
|
59
|
-
*
|
|
60
48
|
* NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable only via
|
|
61
49
|
* behavioral cohorts or raw-prop breakdowns (HOD, day, segment).
|
|
62
50
|
*
|
|
@@ -126,7 +114,7 @@ const chance = u.initChance(SEED);
|
|
|
126
114
|
* REAL-WORLD ANALOGUE: Late-night ordering skews to fast food and impulse buys.
|
|
127
115
|
*
|
|
128
116
|
* ───────────────────────────────────────────────────────────────────────────────
|
|
129
|
-
* 4. RAINY WEEK SURGE (
|
|
117
|
+
* 4. RAINY WEEK SURGE (everything)
|
|
130
118
|
* ───────────────────────────────────────────────────────────────────────────────
|
|
131
119
|
*
|
|
132
120
|
* PATTERN: Days 20-27, "order placed" delivery_fee doubled and 40% of those
|
|
@@ -298,44 +286,327 @@ const chance = u.initChance(SEED);
|
|
|
298
286
|
* Trial Conversion | post-day-14 activity | 1x | ~ 0.4x | -60%
|
|
299
287
|
* First Order Bonus | returning conversion | 1x | 0.7x | -30%
|
|
300
288
|
* Order-Count Magic Num | sweet order_total | 1x | 1.4x | 1.4x
|
|
301
|
-
* Order-Count Magic Num | over order_total
|
|
289
|
+
* Order-Count Magic Num | over order_total | 1x | 0.65x | -35%
|
|
302
290
|
* Order Lifecycle TTC | QB+ delivery_mins | 1x | 0.67x | -33%
|
|
303
291
|
* Order Lifecycle TTC | Free delivery_mins | 1x | 1.4x | +40%
|
|
304
292
|
* City Density Reorder | SF/NYC reorder conv | 1x | 1.4x | 1.4x
|
|
305
293
|
* City Density Reorder | HOU/PHX reorder conv | 1x | 0.7x | -30%
|
|
306
294
|
*/
|
|
307
295
|
|
|
308
|
-
//
|
|
296
|
+
// ── SCALE ──
|
|
297
|
+
const SEED = "harness-food";
|
|
298
|
+
const NUM_USERS = 10_000;
|
|
299
|
+
const DATASET_START = "2026-01-01T00:00:00Z";
|
|
300
|
+
const DATASET_END = "2026-05-01T23:59:59Z";
|
|
301
|
+
const EVENTS_PER_DAY = 1.2;
|
|
302
|
+
const token = process.env.MP_TOKEN || "your-mixpanel-token";
|
|
303
|
+
|
|
304
|
+
const chance = u.initChance(SEED);
|
|
305
|
+
|
|
306
|
+
// ── KNOBS (tweak these to reshape stories) ──
|
|
307
|
+
const RUSH_DROP_LIKELIHOOD = 30;
|
|
308
|
+
const RUSH_LUNCH_START = 11;
|
|
309
|
+
const RUSH_LUNCH_END = 13;
|
|
310
|
+
const RUSH_DINNER_START = 17;
|
|
311
|
+
const RUSH_DINNER_END = 20;
|
|
312
|
+
|
|
313
|
+
const COUPON_INJECT_LIKELIHOOD = 30;
|
|
314
|
+
|
|
315
|
+
const LATE_NIGHT_START = 22;
|
|
316
|
+
const LATE_NIGHT_END = 2;
|
|
317
|
+
const LATE_NIGHT_FLIP_LIKELIHOOD = 70;
|
|
318
|
+
const LATE_NIGHT_PRICE_MULT = 1.3;
|
|
319
|
+
|
|
320
|
+
const RAINY_START_DAY = 20;
|
|
321
|
+
const RAINY_END_DAY = 27;
|
|
322
|
+
const RAINY_FEE_MULT = 2;
|
|
323
|
+
const RAINY_DUP_LIKELIHOOD = 40;
|
|
324
|
+
|
|
325
|
+
const REFERRAL_CLONE_LIKELIHOOD = 50;
|
|
326
|
+
const REFERRAL_RATING_MIN = 4;
|
|
327
|
+
const REFERRAL_RATING_MAX = 5;
|
|
328
|
+
|
|
329
|
+
const TRIAL_EARLY_DAYS = 14;
|
|
330
|
+
const TRIAL_MIN_ORDERS = 3;
|
|
331
|
+
const TRIAL_DROP_LIKELIHOOD = 60;
|
|
332
|
+
|
|
333
|
+
const FIRST_ORDER_DROP_LIKELIHOOD = 30;
|
|
334
|
+
|
|
335
|
+
const ORDER_SWEET_MIN = 4;
|
|
336
|
+
const ORDER_SWEET_MAX = 8;
|
|
337
|
+
const ORDER_OVER_THRESHOLD = 9;
|
|
338
|
+
const ORDER_SWEET_BOOST = 1.4;
|
|
339
|
+
const ORDER_OVER_FACTOR = 0.65;
|
|
340
|
+
|
|
341
|
+
const TTC_QB_PLUS_FACTOR = 0.67;
|
|
342
|
+
const TTC_FREE_FACTOR = 1.4;
|
|
343
|
+
|
|
344
|
+
const CITY_DENSE_MULT = 1.4;
|
|
345
|
+
const CITY_SPRAWL_MULT = 0.7;
|
|
346
|
+
|
|
347
|
+
// ── DATA ARRAYS ──
|
|
309
348
|
const restaurantIds = v.range(1, 201).map(n => `rest_${v.uid(6)}`);
|
|
310
349
|
const itemIds = v.range(1, 301).map(n => `item_${v.uid(7)}`);
|
|
311
350
|
const orderIds = v.range(1, 5001).map(n => `order_${v.uid(8)}`);
|
|
312
351
|
const couponCodes = v.range(1, 51).map(n => `QUICK${v.uid(5).toUpperCase()}`);
|
|
313
352
|
|
|
353
|
+
// ── HELPER FUNCTIONS ──
|
|
354
|
+
function handleFunnelPreHooks(record, meta) {
|
|
355
|
+
// H10: CITY DENSITY REORDER BOOST — dense cities 1.4x; sprawl 0.7x
|
|
356
|
+
// on the reorder funnel.
|
|
357
|
+
const isReorderFunnel = meta.funnel?.sequence?.includes("reorder initiated");
|
|
358
|
+
if (isReorderFunnel) {
|
|
359
|
+
const city = meta.profile?.city;
|
|
360
|
+
if (city === "San Francisco" || city === "New York") {
|
|
361
|
+
record.conversionRate = Math.min(95, Math.round(record.conversionRate * CITY_DENSE_MULT));
|
|
362
|
+
} else if (city === "Houston" || city === "Phoenix") {
|
|
363
|
+
record.conversionRate = Math.round(record.conversionRate * CITY_SPRAWL_MULT);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return record;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function handleEverythingHooks(record, meta) {
|
|
370
|
+
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
371
|
+
const RAINY_WEEK_START = datasetStart.add(RAINY_START_DAY, 'days');
|
|
372
|
+
const RAINY_WEEK_END = datasetStart.add(RAINY_END_DAY, 'days');
|
|
373
|
+
const userEvents = record;
|
|
374
|
+
const profile = meta.profile;
|
|
375
|
+
|
|
376
|
+
// Stamp superProps from profile so they stay consistent per user
|
|
377
|
+
if (profile) {
|
|
378
|
+
userEvents.forEach((event) => {
|
|
379
|
+
if (profile.Platform !== undefined) event.Platform = profile.Platform;
|
|
380
|
+
if (profile.subscription_tier !== undefined) event.subscription_tier = profile.subscription_tier;
|
|
381
|
+
if (profile.city !== undefined) event.city = profile.city;
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// H9: ORDER LIFECYCLE TTC — QuickBite+ 0.67x, Free 1.4x on delivery
|
|
386
|
+
// timing properties + funnel timestamp shift.
|
|
387
|
+
if (profile) {
|
|
388
|
+
const tier = profile.subscription_tier;
|
|
389
|
+
const ttcFactor = (
|
|
390
|
+
tier === "QuickBite+" ? TTC_QB_PLUS_FACTOR :
|
|
391
|
+
tier === "Free" ? TTC_FREE_FACTOR :
|
|
392
|
+
1.0
|
|
393
|
+
);
|
|
394
|
+
if (ttcFactor !== 1.0) {
|
|
395
|
+
// Timestamp shift: affects Mixpanel funnel TTC
|
|
396
|
+
const orderSeq = findFirstSequence(
|
|
397
|
+
userEvents,
|
|
398
|
+
["checkout started", "order placed", "order tracked", "order delivered"],
|
|
399
|
+
60 * 24 * 7
|
|
400
|
+
);
|
|
401
|
+
if (orderSeq) scaleFunnelTTC(orderSeq, ttcFactor);
|
|
402
|
+
// Property scale: affects Insights AVG reports
|
|
403
|
+
userEvents.forEach(e => {
|
|
404
|
+
if (typeof e.actual_delivery_mins === "number") {
|
|
405
|
+
e.actual_delivery_mins = Math.round(e.actual_delivery_mins * ttcFactor);
|
|
406
|
+
}
|
|
407
|
+
if (typeof e.eta_mins === "number") {
|
|
408
|
+
e.eta_mins = Math.round(e.eta_mins * ttcFactor);
|
|
409
|
+
}
|
|
410
|
+
if (typeof e.delivery_time_est_mins === "number") {
|
|
411
|
+
e.delivery_time_est_mins = Math.round(e.delivery_time_est_mins * ttcFactor);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// H3: LATE NIGHT MUNCHIES — 22-02 UTC: 70% flip to American, 1.3x price
|
|
418
|
+
userEvents.forEach(e => {
|
|
419
|
+
if (e.event === "restaurant viewed" || e.event === "item added to cart") {
|
|
420
|
+
const hour = new Date(e.time).getUTCHours();
|
|
421
|
+
const isLateNight = hour >= LATE_NIGHT_START || hour <= LATE_NIGHT_END;
|
|
422
|
+
if (isLateNight) {
|
|
423
|
+
if (e.cuisine_type !== undefined && chance.bool({ likelihood: LATE_NIGHT_FLIP_LIKELIHOOD })) {
|
|
424
|
+
e.cuisine_type = "American";
|
|
425
|
+
}
|
|
426
|
+
if (e.item_price !== undefined) {
|
|
427
|
+
e.item_price = Math.round(e.item_price * LATE_NIGHT_PRICE_MULT * 100) / 100;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// H2: COUPON INJECTION — Free-tier users get coupon-applied events
|
|
434
|
+
// spliced near checkout. Cloned from existing template with unique offset.
|
|
435
|
+
if (profile && profile.subscription_tier === "Free") {
|
|
436
|
+
for (let i = userEvents.length - 1; i >= 1; i--) {
|
|
437
|
+
const evt = userEvents[i];
|
|
438
|
+
if (evt.event === "checkout started" && chance.bool({ likelihood: COUPON_INJECT_LIKELIHOOD })) {
|
|
439
|
+
const prevEvent = userEvents[i - 1];
|
|
440
|
+
const midTime = dayjs(prevEvent.time).add(
|
|
441
|
+
dayjs(evt.time).diff(dayjs(prevEvent.time)) / 2,
|
|
442
|
+
'milliseconds'
|
|
443
|
+
).toISOString();
|
|
444
|
+
|
|
445
|
+
const couponTemplate = userEvents.find(e => e.event === "coupon applied");
|
|
446
|
+
const couponEvent = {
|
|
447
|
+
...(couponTemplate || evt),
|
|
448
|
+
event: "coupon applied",
|
|
449
|
+
time: midTime,
|
|
450
|
+
user_id: evt.user_id,
|
|
451
|
+
subscription_tier: profile.subscription_tier,
|
|
452
|
+
Platform: profile.Platform,
|
|
453
|
+
city: profile.city,
|
|
454
|
+
coupon_code: chance.pickone(couponCodes),
|
|
455
|
+
discount_type: chance.pickone(["percent", "flat", "free_delivery"]),
|
|
456
|
+
discount_value: chance.integer({ min: 10, max: 30 }),
|
|
457
|
+
};
|
|
458
|
+
userEvents.splice(i, 0, couponEvent);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// H1: LUNCH/DINNER RUSH — drop 30% of order-delivered events outside
|
|
464
|
+
// meal windows (11-13 UTC and 17-20 UTC).
|
|
465
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
466
|
+
const event = userEvents[i];
|
|
467
|
+
if (event.event === "order delivered") {
|
|
468
|
+
const hour = new Date(event.time).getUTCHours();
|
|
469
|
+
const inRush = (hour >= RUSH_LUNCH_START && hour <= RUSH_LUNCH_END) || (hour >= RUSH_DINNER_START && hour <= RUSH_DINNER_END);
|
|
470
|
+
if (!inRush && chance.bool({ likelihood: RUSH_DROP_LIKELIHOOD })) {
|
|
471
|
+
userEvents.splice(i, 1);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// H7: FIRST ORDER BONUS — hash-based ~50% of users (returning) drop
|
|
477
|
+
// 30% of order delivered events. New users keep all.
|
|
478
|
+
const hashUser = userEvents[0] && userEvents[0].user_id;
|
|
479
|
+
const isNewUser = typeof hashUser === "string" && hashUser.charCodeAt(0) % 2 === 0;
|
|
480
|
+
if (!isNewUser) {
|
|
481
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
482
|
+
if (userEvents[i].event === "order delivered" && chance.bool({ likelihood: FIRST_ORDER_DROP_LIKELIHOOD })) {
|
|
483
|
+
userEvents.splice(i, 1);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// First pass: identify behavioral patterns (no flags written)
|
|
489
|
+
let isReferralUser = false;
|
|
490
|
+
let hasTrialSubscription = false;
|
|
491
|
+
let earlyOrderCount = 0;
|
|
492
|
+
let orderPlacedCount = 0;
|
|
493
|
+
const firstEventTime = userEvents.length > 0 ? dayjs(userEvents[0].time) : null;
|
|
494
|
+
|
|
495
|
+
userEvents.forEach((event) => {
|
|
496
|
+
const eventTime = dayjs(event.time);
|
|
497
|
+
const daysSinceStart = firstEventTime ? eventTime.diff(firstEventTime, 'days', true) : 0;
|
|
498
|
+
if (event.event === "account created" && event.referral_code === true) isReferralUser = true;
|
|
499
|
+
if (event.event === "subscription started" && event.trial === true) hasTrialSubscription = true;
|
|
500
|
+
if (event.event === "order placed") {
|
|
501
|
+
orderPlacedCount++;
|
|
502
|
+
if (daysSinceStart <= TRIAL_EARLY_DAYS) earlyOrderCount++;
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// H5: REFERRAL POWER USERS — boost food rating to 4-5, clone reorders.
|
|
507
|
+
userEvents.forEach((event, idx) => {
|
|
508
|
+
if (isReferralUser && event.event === "order rated") {
|
|
509
|
+
event.food_rating = chance.integer({ min: REFERRAL_RATING_MIN, max: REFERRAL_RATING_MAX });
|
|
510
|
+
}
|
|
511
|
+
if (isReferralUser && event.event === "reorder initiated" && chance.bool({ likelihood: REFERRAL_CLONE_LIKELIHOOD })) {
|
|
512
|
+
const eventTime = dayjs(event.time);
|
|
513
|
+
userEvents.splice(idx + 1, 0, {
|
|
514
|
+
...event,
|
|
515
|
+
time: eventTime.add(chance.integer({ min: 1, max: 7 }), 'days').toISOString(),
|
|
516
|
+
user_id: event.user_id,
|
|
517
|
+
order_id: chance.pickone(orderIds),
|
|
518
|
+
original_order_age_days: chance.integer({ min: 3, max: 30 }),
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// H6: TRIAL CONVERSION — trial subs with <3 early orders drop 60% of
|
|
524
|
+
// post-day-14 events.
|
|
525
|
+
if (hasTrialSubscription && earlyOrderCount < TRIAL_MIN_ORDERS && chance.bool({ likelihood: TRIAL_DROP_LIKELIHOOD })) {
|
|
526
|
+
const trialCutoff = firstEventTime ? firstEventTime.add(TRIAL_EARLY_DAYS, 'days') : null;
|
|
527
|
+
if (trialCutoff) {
|
|
528
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
529
|
+
if (dayjs(userEvents[i].time).isAfter(trialCutoff)) {
|
|
530
|
+
userEvents.splice(i, 1);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// H4: RAINY WEEK SURGE — days 20-27, double delivery_fee on order-placed.
|
|
537
|
+
userEvents.forEach(e => {
|
|
538
|
+
if (e.event === "order placed") {
|
|
539
|
+
const t = dayjs(e.time);
|
|
540
|
+
if (t.isAfter(RAINY_WEEK_START) && t.isBefore(RAINY_WEEK_END)) {
|
|
541
|
+
e.delivery_fee = (e.delivery_fee || 5) * RAINY_FEE_MULT;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
// H4 (cont): RAINY WEEK SURGE — duplicate 40% of order-placed events
|
|
547
|
+
// in the rainy window. Cloned with unique offset.
|
|
548
|
+
const rainyDuplicates = [];
|
|
549
|
+
userEvents.forEach((event) => {
|
|
550
|
+
if (event.event === "order placed") {
|
|
551
|
+
const t = dayjs(event.time);
|
|
552
|
+
if (t.isAfter(RAINY_WEEK_START) && t.isBefore(RAINY_WEEK_END) && chance.bool({ likelihood: RAINY_DUP_LIKELIHOOD })) {
|
|
553
|
+
const dup = JSON.parse(JSON.stringify(event));
|
|
554
|
+
dup.time = t.add(chance.integer({ min: 5, max: 60 }), 'minutes').toISOString();
|
|
555
|
+
rainyDuplicates.push(dup);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
if (rainyDuplicates.length > 0) userEvents.push(...rainyDuplicates);
|
|
560
|
+
|
|
561
|
+
// H8: ORDER-COUNT MAGIC NUMBER — sweet 4-8 → +40% on order_total;
|
|
562
|
+
// over 9+ → 0.65x order_total (basket fatigue). No flag.
|
|
563
|
+
if (orderPlacedCount >= ORDER_SWEET_MIN && orderPlacedCount <= ORDER_SWEET_MAX) {
|
|
564
|
+
userEvents.forEach(e => {
|
|
565
|
+
if (e.event === "order placed" && typeof e.order_total === "number") {
|
|
566
|
+
e.order_total = Math.round(e.order_total * ORDER_SWEET_BOOST);
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
} else if (orderPlacedCount >= ORDER_OVER_THRESHOLD) {
|
|
570
|
+
userEvents.forEach(e => {
|
|
571
|
+
if (e.event === "order placed" && typeof e.order_total === "number") {
|
|
572
|
+
e.order_total = Math.round(e.order_total * ORDER_OVER_FACTOR);
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return userEvents;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ── CONFIG ──
|
|
314
581
|
/** @type {Config} */
|
|
315
582
|
const config = {
|
|
316
583
|
version: 2,
|
|
317
|
-
token,
|
|
318
584
|
seed: SEED,
|
|
319
|
-
datasetStart:
|
|
320
|
-
datasetEnd:
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
numUsers: num_users,
|
|
324
|
-
hasAnonIds: true,
|
|
325
|
-
avgDevicePerUser: 2,
|
|
326
|
-
hasSessionIds: true,
|
|
585
|
+
datasetStart: DATASET_START,
|
|
586
|
+
datasetEnd: DATASET_END,
|
|
587
|
+
avgEventsPerUserPerDay: EVENTS_PER_DAY,
|
|
588
|
+
numUsers: NUM_USERS,
|
|
327
589
|
format: "json",
|
|
328
590
|
gzip: true,
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
591
|
+
credentials: {
|
|
592
|
+
token,
|
|
593
|
+
},
|
|
594
|
+
switches: {
|
|
595
|
+
hasSessionIds: true,
|
|
596
|
+
alsoInferFunnels: false,
|
|
597
|
+
hasLocation: true,
|
|
598
|
+
hasAndroidDevices: true,
|
|
599
|
+
hasIOSDevices: true,
|
|
600
|
+
hasDesktopDevices: true,
|
|
601
|
+
hasBrowser: false,
|
|
602
|
+
hasCampaigns: false,
|
|
603
|
+
isAnonymous: false,
|
|
604
|
+
hasAdSpend: false,
|
|
605
|
+
hasAvatar: true,
|
|
606
|
+
},
|
|
607
|
+
identity: {
|
|
608
|
+
avgDevicePerUser: 2,
|
|
609
|
+
},
|
|
339
610
|
concurrency: 1,
|
|
340
611
|
writeToDisk: false,
|
|
341
612
|
scdProps: {
|
|
@@ -653,244 +924,9 @@ const config = {
|
|
|
653
924
|
|
|
654
925
|
lookupTables: [],
|
|
655
926
|
|
|
656
|
-
hook
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
// sprawl cities (Houston, Phoenix) at 0.7x.
|
|
660
|
-
if (type === "funnel-pre") {
|
|
661
|
-
const isReorderFunnel = meta.funnel?.sequence?.includes("reorder initiated");
|
|
662
|
-
if (isReorderFunnel) {
|
|
663
|
-
const city = meta.profile?.city;
|
|
664
|
-
if (city === "San Francisco" || city === "New York") {
|
|
665
|
-
record.conversionRate = Math.min(95, Math.round(record.conversionRate * 1.4));
|
|
666
|
-
} else if (city === "Houston" || city === "Phoenix") {
|
|
667
|
-
record.conversionRate = Math.round(record.conversionRate * 0.7);
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
if (type === "everything") {
|
|
673
|
-
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
674
|
-
const RAINY_WEEK_START = datasetStart.add(20, 'days');
|
|
675
|
-
const RAINY_WEEK_END = datasetStart.add(27, 'days');
|
|
676
|
-
const userEvents = record;
|
|
677
|
-
const profile = meta.profile;
|
|
678
|
-
|
|
679
|
-
// Stamp superProps from profile so they stay consistent per user
|
|
680
|
-
if (profile) {
|
|
681
|
-
userEvents.forEach((event) => {
|
|
682
|
-
if (profile.Platform !== undefined) event.Platform = profile.Platform;
|
|
683
|
-
if (profile.subscription_tier !== undefined) event.subscription_tier = profile.subscription_tier;
|
|
684
|
-
if (profile.city !== undefined) event.city = profile.city;
|
|
685
|
-
});
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
// HOOK 9 (TTC): ORDER LIFECYCLE TIME-TO-CONVERT (everything)
|
|
689
|
-
// QuickBite+ users experience faster delivery times (0.67x);
|
|
690
|
-
// Free users experience slower delivery times (1.4x).
|
|
691
|
-
// Scales timing properties: actual_delivery_mins, eta_mins,
|
|
692
|
-
// delivery_time_est_mins. Discover via avg(actual_delivery_mins)
|
|
693
|
-
// on "order delivered" broken down by subscription_tier.
|
|
694
|
-
if (profile) {
|
|
695
|
-
const tier = profile.subscription_tier;
|
|
696
|
-
const ttcFactor = (
|
|
697
|
-
tier === "QuickBite+" ? 0.67 :
|
|
698
|
-
tier === "Free" ? 1.4 :
|
|
699
|
-
1.0
|
|
700
|
-
);
|
|
701
|
-
if (ttcFactor !== 1.0) {
|
|
702
|
-
// Timestamp shift: affects Mixpanel funnel TTC
|
|
703
|
-
const orderSeq = findFirstSequence(
|
|
704
|
-
userEvents,
|
|
705
|
-
["checkout started", "order placed", "order tracked", "order delivered"],
|
|
706
|
-
60 * 24 * 7
|
|
707
|
-
);
|
|
708
|
-
if (orderSeq) scaleFunnelTTC(orderSeq, ttcFactor);
|
|
709
|
-
// Property scale: affects Insights AVG reports
|
|
710
|
-
userEvents.forEach(e => {
|
|
711
|
-
if (typeof e.actual_delivery_mins === "number") {
|
|
712
|
-
e.actual_delivery_mins = Math.round(e.actual_delivery_mins * ttcFactor);
|
|
713
|
-
}
|
|
714
|
-
if (typeof e.eta_mins === "number") {
|
|
715
|
-
e.eta_mins = Math.round(e.eta_mins * ttcFactor);
|
|
716
|
-
}
|
|
717
|
-
if (typeof e.delivery_time_est_mins === "number") {
|
|
718
|
-
e.delivery_time_est_mins = Math.round(e.delivery_time_est_mins * ttcFactor);
|
|
719
|
-
}
|
|
720
|
-
});
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
// HOOK 3: LATE NIGHT MUNCHIES — 10PM-2AM UTC, 70% of restaurant
|
|
725
|
-
// views/cart additions get cuisine flipped to American, 1.3x
|
|
726
|
-
// item_price. Mutates existing props. No flag — discover via
|
|
727
|
-
// HOD breakdown on cuisine_type / item_price.
|
|
728
|
-
userEvents.forEach(e => {
|
|
729
|
-
if (e.event === "restaurant viewed" || e.event === "item added to cart") {
|
|
730
|
-
const hour = new Date(e.time).getUTCHours();
|
|
731
|
-
const isLateNight = hour >= 22 || hour <= 2;
|
|
732
|
-
if (isLateNight) {
|
|
733
|
-
if (e.cuisine_type !== undefined && chance.bool({ likelihood: 70 })) {
|
|
734
|
-
e.cuisine_type = "American";
|
|
735
|
-
}
|
|
736
|
-
if (e.item_price !== undefined) {
|
|
737
|
-
e.item_price = Math.round(e.item_price * 1.3 * 100) / 100;
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
});
|
|
742
|
-
|
|
743
|
-
// HOOK 2: COUPON INJECTION — Free-tier users get coupon applied
|
|
744
|
-
// events spliced into stream near checkout. Cloned from existing
|
|
745
|
-
// coupon-applied event with unique offset time. No flag.
|
|
746
|
-
if (profile && profile.subscription_tier === "Free") {
|
|
747
|
-
for (let i = userEvents.length - 1; i >= 1; i--) {
|
|
748
|
-
const evt = userEvents[i];
|
|
749
|
-
if (evt.event === "checkout started" && chance.bool({ likelihood: 30 })) {
|
|
750
|
-
const prevEvent = userEvents[i - 1];
|
|
751
|
-
const midTime = dayjs(prevEvent.time).add(
|
|
752
|
-
dayjs(evt.time).diff(dayjs(prevEvent.time)) / 2,
|
|
753
|
-
'milliseconds'
|
|
754
|
-
).toISOString();
|
|
755
|
-
|
|
756
|
-
const couponTemplate = userEvents.find(e => e.event === "coupon applied");
|
|
757
|
-
const couponEvent = {
|
|
758
|
-
...(couponTemplate || evt),
|
|
759
|
-
event: "coupon applied",
|
|
760
|
-
time: midTime,
|
|
761
|
-
user_id: evt.user_id,
|
|
762
|
-
subscription_tier: profile.subscription_tier,
|
|
763
|
-
Platform: profile.Platform,
|
|
764
|
-
city: profile.city,
|
|
765
|
-
coupon_code: chance.pickone(couponCodes),
|
|
766
|
-
discount_type: chance.pickone(["percent", "flat", "free_delivery"]),
|
|
767
|
-
discount_value: chance.integer({ min: 10, max: 30 }),
|
|
768
|
-
};
|
|
769
|
-
userEvents.splice(i, 0, couponEvent);
|
|
770
|
-
}
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
// HOOK 1: LUNCH/DINNER RUSH — drop 30% of order delivered events
|
|
775
|
-
// that fall outside the meal hours (11-13 UTC and 17-20 UTC).
|
|
776
|
-
// Discoverable via order delivered breakdown by HOD.
|
|
777
|
-
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
778
|
-
const event = userEvents[i];
|
|
779
|
-
if (event.event === "order delivered") {
|
|
780
|
-
const hour = new Date(event.time).getUTCHours();
|
|
781
|
-
const inRush = (hour >= 11 && hour <= 13) || (hour >= 17 && hour <= 20);
|
|
782
|
-
if (!inRush && chance.bool({ likelihood: 30 })) {
|
|
783
|
-
userEvents.splice(i, 1);
|
|
784
|
-
}
|
|
785
|
-
}
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
// HOOK 7: FIRST ORDER BONUS — hash-based ~50% of users (returning)
|
|
789
|
-
// drop 30% of order delivered events. New users keep all.
|
|
790
|
-
// Discover via cohort builder on hash bucket vs conversion.
|
|
791
|
-
const hashUser = userEvents[0] && userEvents[0].user_id;
|
|
792
|
-
const isNewUser = typeof hashUser === "string" && hashUser.charCodeAt(0) % 2 === 0;
|
|
793
|
-
if (!isNewUser) {
|
|
794
|
-
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
795
|
-
if (userEvents[i].event === "order delivered" && chance.bool({ likelihood: 30 })) {
|
|
796
|
-
userEvents.splice(i, 1);
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
// First pass: identify behavioral patterns (no flags written)
|
|
802
|
-
let isReferralUser = false;
|
|
803
|
-
let hasTrialSubscription = false;
|
|
804
|
-
let earlyOrderCount = 0;
|
|
805
|
-
let orderPlacedCount = 0;
|
|
806
|
-
const firstEventTime = userEvents.length > 0 ? dayjs(userEvents[0].time) : null;
|
|
807
|
-
|
|
808
|
-
userEvents.forEach((event) => {
|
|
809
|
-
const eventTime = dayjs(event.time);
|
|
810
|
-
const daysSinceStart = firstEventTime ? eventTime.diff(firstEventTime, 'days', true) : 0;
|
|
811
|
-
if (event.event === "account created" && event.referral_code === true) isReferralUser = true;
|
|
812
|
-
if (event.event === "subscription started" && event.trial === true) hasTrialSubscription = true;
|
|
813
|
-
if (event.event === "order placed") {
|
|
814
|
-
orderPlacedCount++;
|
|
815
|
-
if (daysSinceStart <= 14) earlyOrderCount++;
|
|
816
|
-
}
|
|
817
|
-
});
|
|
818
|
-
|
|
819
|
-
// HOOK 5: REFERRAL POWER USERS — boost food rating to 4-5,
|
|
820
|
-
// clone reorder events. No flag.
|
|
821
|
-
userEvents.forEach((event, idx) => {
|
|
822
|
-
if (isReferralUser && event.event === "order rated") {
|
|
823
|
-
event.food_rating = chance.integer({ min: 4, max: 5 });
|
|
824
|
-
}
|
|
825
|
-
if (isReferralUser && event.event === "reorder initiated" && chance.bool({ likelihood: 50 })) {
|
|
826
|
-
const eventTime = dayjs(event.time);
|
|
827
|
-
userEvents.splice(idx + 1, 0, {
|
|
828
|
-
...event,
|
|
829
|
-
time: eventTime.add(chance.integer({ min: 1, max: 7 }), 'days').toISOString(),
|
|
830
|
-
user_id: event.user_id,
|
|
831
|
-
order_id: chance.pickone(orderIds),
|
|
832
|
-
original_order_age_days: chance.integer({ min: 3, max: 30 }),
|
|
833
|
-
});
|
|
834
|
-
}
|
|
835
|
-
});
|
|
836
|
-
|
|
837
|
-
// HOOK 6: TRIAL CONVERSION — trial subs with <3 early orders
|
|
838
|
-
// drop 60% of post-day-14 events. No flag.
|
|
839
|
-
if (hasTrialSubscription && earlyOrderCount < 3 && chance.bool({ likelihood: 60 })) {
|
|
840
|
-
const trialCutoff = firstEventTime ? firstEventTime.add(14, 'days') : null;
|
|
841
|
-
if (trialCutoff) {
|
|
842
|
-
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
843
|
-
if (dayjs(userEvents[i].time).isAfter(trialCutoff)) {
|
|
844
|
-
userEvents.splice(i, 1);
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
// HOOK 4: RAINY WEEK SURGE — days 20-27, double delivery_fee on
|
|
851
|
-
// order placed events. Mutates existing prop. No flag.
|
|
852
|
-
userEvents.forEach(e => {
|
|
853
|
-
if (e.event === "order placed") {
|
|
854
|
-
const t = dayjs(e.time);
|
|
855
|
-
if (t.isAfter(RAINY_WEEK_START) && t.isBefore(RAINY_WEEK_END)) {
|
|
856
|
-
e.delivery_fee = (e.delivery_fee || 5) * 2;
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
});
|
|
860
|
-
|
|
861
|
-
// HOOK 4 (cont): RAINY WEEK SURGE — duplicate 40% of order placed
|
|
862
|
-
// events that fall in the rainy window. Cloned with unique offset.
|
|
863
|
-
const rainyDuplicates = [];
|
|
864
|
-
userEvents.forEach((event) => {
|
|
865
|
-
if (event.event === "order placed") {
|
|
866
|
-
const t = dayjs(event.time);
|
|
867
|
-
if (t.isAfter(RAINY_WEEK_START) && t.isBefore(RAINY_WEEK_END) && chance.bool({ likelihood: 40 })) {
|
|
868
|
-
const dup = JSON.parse(JSON.stringify(event));
|
|
869
|
-
dup.time = t.add(chance.integer({ min: 5, max: 60 }), 'minutes').toISOString();
|
|
870
|
-
rainyDuplicates.push(dup);
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
});
|
|
874
|
-
if (rainyDuplicates.length > 0) userEvents.push(...rainyDuplicates);
|
|
875
|
-
|
|
876
|
-
// HOOK 8: ORDER-COUNT MAGIC NUMBER (no flags)
|
|
877
|
-
// Sweet 4-8 orders → +40% on order_total. Over 9+ → drop 35% of
|
|
878
|
-
// order placed events (oversaturated; analyst sees inverted-U).
|
|
879
|
-
if (orderPlacedCount >= 4 && orderPlacedCount <= 8) {
|
|
880
|
-
userEvents.forEach(e => {
|
|
881
|
-
if (e.event === "order placed" && typeof e.order_total === "number") {
|
|
882
|
-
e.order_total = Math.round(e.order_total * 1.4);
|
|
883
|
-
}
|
|
884
|
-
});
|
|
885
|
-
} else if (orderPlacedCount >= 9) {
|
|
886
|
-
userEvents.forEach(e => {
|
|
887
|
-
if (e.event === "order placed" && typeof e.order_total === "number") {
|
|
888
|
-
e.order_total = Math.round(e.order_total * 0.65);
|
|
889
|
-
}
|
|
890
|
-
});
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
|
|
927
|
+
hook(record, type, meta) {
|
|
928
|
+
if (type === "funnel-pre") return handleFunnelPreHooks(record, meta);
|
|
929
|
+
if (type === "everything") return handleEverythingHooks(record, meta);
|
|
894
930
|
return record;
|
|
895
931
|
}
|
|
896
932
|
};
|