@ak--47/dungeon-master 1.5.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/.claude/skills/create-dungeon/SKILL.md +139 -46
  2. package/.claude/skills/verify-dungeon/references/counting-semantics.md +31 -6
  3. package/.claude/skills/verify-dungeon/references/sql-recipes.md +44 -25
  4. package/.claude/skills/write-hooks/SKILL.md +31 -3
  5. package/CHANGELOG.md +85 -0
  6. package/HOOKS.md +13 -0
  7. package/dungeons/technical/ad-spend.js +41 -49
  8. package/dungeons/technical/anonymous-users.js +38 -36
  9. package/dungeons/technical/array-of-object-lookup.js +136 -153
  10. package/dungeons/technical/datagen-v15-verify.js +24 -11
  11. package/dungeons/technical/experiments.js +42 -40
  12. package/dungeons/technical/foobar.js +114 -118
  13. package/dungeons/technical/group-analytics.js +42 -40
  14. package/dungeons/technical/hook-helpers-verify.js +69 -50
  15. package/dungeons/technical/identity-model-verify.js +22 -12
  16. package/dungeons/technical/mirror-strategies.js +37 -39
  17. package/dungeons/technical/nested-objects.js +119 -118
  18. package/dungeons/technical/pattern-aggregate-by-bin.js +21 -8
  19. package/dungeons/technical/pattern-attributed-by-source.js +23 -9
  20. package/dungeons/technical/pattern-frequency-by-frequency.js +21 -8
  21. package/dungeons/technical/pattern-funnel-frequency.js +30 -15
  22. package/dungeons/technical/pattern-ttc-by-segment.js +21 -8
  23. package/dungeons/technical/retention-cadence.js +115 -112
  24. package/dungeons/technical/sanity.js +86 -80
  25. package/dungeons/technical/scale-test.js +34 -38
  26. package/dungeons/technical/scd.js +111 -128
  27. package/dungeons/technical/simple.js +134 -141
  28. package/dungeons/technical/simplest.js +54 -62
  29. package/dungeons/technical/text-generation.js +110 -146
  30. package/dungeons/vertical/ai-platform.js +296 -333
  31. package/dungeons/vertical/community.js +284 -255
  32. package/dungeons/vertical/crypto.js +395 -391
  33. package/dungeons/vertical/dating.js +411 -378
  34. package/dungeons/vertical/devtools.js +336 -298
  35. package/dungeons/vertical/ecommerce.js +316 -394
  36. package/dungeons/vertical/education.js +369 -325
  37. package/dungeons/vertical/fintech.js +358 -325
  38. package/dungeons/vertical/fitness.js +335 -291
  39. package/dungeons/vertical/food-delivery.js +343 -307
  40. package/dungeons/vertical/gaming.js +480 -444
  41. package/dungeons/vertical/healthcare.js +306 -262
  42. package/dungeons/vertical/insurance-application.js +427 -409
  43. package/dungeons/vertical/logistics.js +271 -252
  44. package/dungeons/vertical/marketplace.js +333 -323
  45. package/dungeons/vertical/media.js +382 -335
  46. package/dungeons/vertical/real-estate.js +395 -346
  47. package/dungeons/vertical/sass.js +319 -333
  48. package/dungeons/vertical/social.js +368 -316
  49. package/dungeons/vertical/travel.js +297 -295
  50. package/index.js +46 -4
  51. package/lib/core/config-validator.js +126 -28
  52. package/lib/generators/funnels.js +4 -1
  53. package/lib/orchestrators/mixpanel-sender.js +7 -0
  54. package/lib/orchestrators/user-loop.js +132 -31
  55. package/lib/templates/defaults.js +59 -59
  56. package/lib/templates/macro-presets.js +14 -2
  57. package/lib/utils/dataset-context.js +103 -0
  58. package/lib/utils/retention-curve.js +140 -0
  59. package/lib/utils/utils.js +149 -38
  60. package/lib/verify/counting.js +40 -0
  61. package/lib/verify/emulate-breakdown.js +20 -1
  62. package/lib/verify/index.js +1 -0
  63. package/lib/verify/schema-validator.js +3 -1
  64. package/package.json +11 -2
  65. package/scripts/run-dungeon.mjs +12 -1
  66. package/types.d.ts +117 -1
@@ -1,62 +1,50 @@
1
- // ── TWEAK THESE ──
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
- * DATASET OVERVIEW
25
- * ═══════════════════════════════════════════════════════════════════════════════
26
- *
27
- * QuickBite a food delivery platform (DoorDash/Uber Eats style).
28
- * Users browse restaurants, build carts, place orders, track deliveries,
29
- * and rate their experiences.
30
- *
31
- * Scale: 5,000 users · 600K events · 100 days · 17 event types
32
- *
33
- * Core loop:
34
- * sign up browse/search restaurants add items to cart
35
- * checkout order placed track delivery rate reorder
36
- *
37
- * Restaurant ecosystem: 200 restaurants across 8 cuisine types,
38
- * four price tiers ($–$$$$), modeled as group profiles.
39
- *
40
- * Monetization: delivery fees, QuickBite+ subscription ($9.99/mo or
41
- * $79.99/yr for free delivery), and promotional coupons.
42
- *
43
- * Support & retention: support tickets (missing items, wrong orders,
44
- * late delivery, quality, refunds) and reorder events model service
45
- * quality and repeat behavior.
46
- *
47
- * Subscription tiers: Free vs QuickBite+ create a natural A/B
48
- * comparison for monetization and retention analysis.
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 (event + everything)
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 | 1x | 0.65x | -35%
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
- // Generate consistent IDs for lookup tables and event properties
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: "2026-01-01T00:00:00Z",
320
- datasetEnd: "2026-05-01T23:59:59Z",
321
- // numDays: num_days,
322
- avgEventsPerUserPerDay: avg_events_per_user_per_day,
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
- alsoInferFunnels: false,
330
- hasLocation: true,
331
- hasAndroidDevices: true,
332
- hasIOSDevices: true,
333
- hasDesktopDevices: true,
334
- hasBrowser: false,
335
- hasCampaigns: false,
336
- isAnonymous: false,
337
- hasAdSpend: false,
338
- hasAvatar: true,
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: function (record, type, meta) {
657
- // HOOK 10: CITY DENSITY REORDER BOOST (funnel-pre)
658
- // Dense cities (SF, NYC) convert 1.4x on the reorder funnel;
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
  };