make-mp-data 3.0.4 → 3.0.5

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 (70) hide show
  1. package/README.md +46 -0
  2. package/dungeons/array-of-object-lookup-schema.json +327 -0
  3. package/dungeons/array-of-object-lookup.js +28 -8
  4. package/dungeons/capstone/capstone-ic3.js +291 -0
  5. package/dungeons/capstone/capstone-ic4.js +598 -0
  6. package/dungeons/capstone/capstone-ic5.js +668 -0
  7. package/dungeons/capstone/generate-product-lookup.js +309 -0
  8. package/dungeons/ecommerce-schema.json +462 -0
  9. package/dungeons/{copilot.js → ecommerce.js} +77 -15
  10. package/dungeons/education-schema.json +2409 -0
  11. package/dungeons/education.js +206 -442
  12. package/dungeons/fintech-schema.json +14034 -0
  13. package/dungeons/fintech.js +110 -389
  14. package/dungeons/foobar-schema.json +403 -0
  15. package/dungeons/foobar.js +27 -4
  16. package/dungeons/food-delivery-schema.json +192 -0
  17. package/dungeons/food-delivery.js +602 -0
  18. package/dungeons/food-schema.json +1152 -0
  19. package/dungeons/food.js +150 -383
  20. package/dungeons/gaming-schema.json +1270 -0
  21. package/dungeons/gaming.js +143 -3
  22. package/dungeons/insurance-application-schema.json +204 -0
  23. package/dungeons/insurance-application.js +605 -0
  24. package/dungeons/media-schema.json +906 -0
  25. package/dungeons/media.js +221 -391
  26. package/dungeons/retention-cadence-schema.json +78 -0
  27. package/dungeons/retention-cadence.js +35 -1
  28. package/dungeons/rpg-schema.json +4526 -0
  29. package/dungeons/rpg.js +130 -388
  30. package/dungeons/sanity-schema.json +255 -0
  31. package/dungeons/sanity.js +21 -10
  32. package/dungeons/sass-schema.json +1291 -0
  33. package/dungeons/sass.js +210 -337
  34. package/dungeons/scd-schema.json +919 -0
  35. package/dungeons/scd.js +38 -10
  36. package/dungeons/simple-schema.json +608 -0
  37. package/dungeons/simple.js +48 -11
  38. package/dungeons/simplest-schema.json +1418 -0
  39. package/dungeons/simplest.js +392 -0
  40. package/dungeons/social-schema.json +1118 -0
  41. package/dungeons/social.js +124 -365
  42. package/dungeons/text-generation-schema.json +3096 -0
  43. package/dungeons/text-generation.js +71 -0
  44. package/index.js +6 -3
  45. package/lib/core/config-validator.js +18 -0
  46. package/lib/core/storage.js +5 -5
  47. package/lib/generators/events.js +4 -4
  48. package/lib/orchestrators/mixpanel-sender.js +12 -7
  49. package/lib/orchestrators/user-loop.js +14 -6
  50. package/lib/templates/soup-presets.js +188 -0
  51. package/lib/utils/utils.js +52 -6
  52. package/package.json +1 -1
  53. package/types.d.ts +20 -3
  54. package/dungeons/adspend.js +0 -117
  55. package/dungeons/anon.js +0 -128
  56. package/dungeons/benchmark-heavy.js +0 -240
  57. package/dungeons/benchmark-light.js +0 -126
  58. package/dungeons/big.js +0 -226
  59. package/dungeons/business.js +0 -391
  60. package/dungeons/complex.js +0 -428
  61. package/dungeons/experiments.js +0 -137
  62. package/dungeons/funnels.js +0 -309
  63. package/dungeons/mil.js +0 -323
  64. package/dungeons/mirror.js +0 -160
  65. package/dungeons/soup-test.js +0 -52
  66. package/dungeons/streaming.js +0 -372
  67. package/dungeons/strict-event-test.js +0 -30
  68. package/dungeons/student-teacher.js +0 -438
  69. package/dungeons/too-big-events.js +0 -203
  70. package/dungeons/user-agent.js +0 -209
@@ -0,0 +1,602 @@
1
+ import dayjs from "dayjs";
2
+ import utc from "dayjs/plugin/utc.js";
3
+ import "dotenv/config";
4
+ import * as u from "../lib/utils/utils.js";
5
+ import * as v from "ak-tools";
6
+
7
+ const SEED = "dm4-food-delivery";
8
+ dayjs.extend(utc);
9
+ const chance = u.initChance(SEED);
10
+ const num_users = 5_000;
11
+ const days = 100;
12
+
13
+ /** @typedef {import("../types.d.ts").Dungeon} Config */
14
+
15
+ /**
16
+ * ===============================================================================
17
+ * DATASET OVERVIEW
18
+ * ===============================================================================
19
+ *
20
+ * QuickBite Delivery - A food delivery platform modeled after DoorDash/UberEats,
21
+ * focused on the discovery-to-loyalty pipeline: users browse restaurants, discover
22
+ * menu items, build favorites lists, and place orders.
23
+ *
24
+ * CORE USER LOOP:
25
+ * Sign up -> browse restaurants -> view menu items -> favorite items -> build cart
26
+ * -> checkout -> order -> delivery -> rate -> reorder
27
+ *
28
+ * The central mechanic is item discovery and favoriting. Users who curate a focused
29
+ * favorites list (exactly 3 items) have the highest order values and most orders.
30
+ * Over-favoriters (4+) are impulsive and actually perform worse. Users who never
31
+ * favorite anything churn at high rates.
32
+ *
33
+ * SCALE:
34
+ * - 5,000 users over 100 days (~600K events)
35
+ * - 18 event types, 4 funnels (onboarding, order, discovery, reorder)
36
+ * - 200 restaurants (group analytics)
37
+ * - Subscription tiers: Free, QuickBite+
38
+ * - Session tracking enabled
39
+ */
40
+
41
+ /**
42
+ * ===============================================================================
43
+ * ANALYTICS HOOKS
44
+ * ===============================================================================
45
+ *
46
+ * 4 deliberately architected hooks centered on the discovery-to-loyalty pipeline.
47
+ * The hooks create a cascading behavioral pattern: views -> favorites -> orders -> retention.
48
+ *
49
+ * ---------------------------------------------------------------------------
50
+ * 1. BELL CURVE: VIEW -> FAVORITE RELATIONSHIP (everything hook)
51
+ * ---------------------------------------------------------------------------
52
+ *
53
+ * PATTERN: Bell-shaped relationship between menu item views and favorites.
54
+ * Gaussian peaks at ~25 views -> 5 favorites. Users with <=3 views get 0 favorites.
55
+ * Formula: targetFav = round(5 * exp(-((views-25)^2) / (2*12^2)))
56
+ *
57
+ * MIXPANEL REPORT:
58
+ * 1. Insights > Segmentation
59
+ * 2. Count "view menu item" per user -> bucket into ranges
60
+ * 3. Count "favorite item" per user
61
+ * 4. Plot favorites vs. views -- clear bell curve peaking at ~25 views
62
+ * 5. Bucket users by view count, compute avg favorites per bucket
63
+ *
64
+ * EXPECTED: Inverted-U peaking at ~5 favorites for 25 views, 0 at both extremes.
65
+ *
66
+ * ---------------------------------------------------------------------------
67
+ * 2. MAGIC NUMBER: 3 FAVORITES = PEAK ORDER PERFORMANCE (everything hook)
68
+ * ---------------------------------------------------------------------------
69
+ *
70
+ * PATTERN: Exactly 3 favorites is the sweet spot.
71
+ * - 3 favorites: 1.6x order values, ~35% extra order duplication
72
+ * - 1-2 favorites: 1.15x small boost
73
+ * - 4+ favorites: PENALIZED -- 0.7x order values, ~30% orders removed, views removed
74
+ *
75
+ * MIXPANEL REPORT:
76
+ * 1. Insights > Segmentation
77
+ * 2. Segment users by favorite count: 0, 1-2, 3, 4+
78
+ * 3. Compare avg order_total and order count per segment
79
+ * 4. Filter: magic_number_customer = true to isolate the effect
80
+ * 5. Verify 4+ favorites has LOWEST order values (even below baseline)
81
+ *
82
+ * EXPECTED: 3 favorites dominates on order value and count. Over-favoriters
83
+ * are impulsive and indecisive -- they perform worse than focused curators.
84
+ * Real-world analogue: Facebook's "7 friends in 10 days" activation threshold.
85
+ *
86
+ * ---------------------------------------------------------------------------
87
+ * 3. SESSION AMPLIFICATION (everything hook)
88
+ * ---------------------------------------------------------------------------
89
+ *
90
+ * PATTERN: Magic-number boost is amplified for users with 10+ distinct sessions.
91
+ * These users get 2.2x order value (vs 1.6x) and 60% order duplication (vs 35%).
92
+ * Only applies to exactly-3-favorites users.
93
+ *
94
+ * MIXPANEL REPORT:
95
+ * 1. Insights > Segmentation
96
+ * 2. Count distinct session_id per user
97
+ * 3. Segment: 3 favorites AND 10+ sessions vs 3 favorites AND <10 sessions
98
+ * 4. Compare order_total and order count between segments
99
+ * 5. Filter: session_amplified = true
100
+ *
101
+ * EXPECTED: Compound effect of magic number + session depth creates the
102
+ * highest-value "power user" segment.
103
+ *
104
+ * ---------------------------------------------------------------------------
105
+ * 4. NO FAVORITES -> CHURN (everything hook)
106
+ * ---------------------------------------------------------------------------
107
+ *
108
+ * PATTERN: Users who never favorite any item lose ~60% of their events from
109
+ * the second half of their timeline, simulating disengagement and abandonment.
110
+ *
111
+ * MIXPANEL REPORT:
112
+ * 1. Insights > Segmentation
113
+ * 2. Segment users: any "favorite item" event vs none
114
+ * 3. Compare median total event count per user (use median, not mean)
115
+ * 4. Look for ~60% fewer events in the 0-favorites group
116
+ *
117
+ * EXPECTED: 0-favorites users have ~60% fewer events. They fade out.
118
+ *
119
+ * ---------------------------------------------------------------------------
120
+ * ADVANCED ANALYSIS IDEAS
121
+ * ---------------------------------------------------------------------------
122
+ *
123
+ * CROSS-HOOK PATTERNS:
124
+ * - Full Cascade: views -> favorites -> orders -> retention (Hooks 1-4 chain)
125
+ * - Over-Favoriting Paradox: more favorites = WORSE performance past 3
126
+ * - Magic Number Discovery: plot order value by favorite count, peak at 3
127
+ * - Session-to-Activation: how many sessions before hitting magic number?
128
+ *
129
+ * COHORT ANALYSIS:
130
+ * - By favorites count: 0, 1-2, 3 (magic), 4+
131
+ * - By session depth: <10, 10+
132
+ * - By view count bucket: 0-5, 6-10, 11-15, 16-25, 25+
133
+ * - Cross-cohort: favorites x sessions matrix
134
+ *
135
+ * ---------------------------------------------------------------------------
136
+ * EXPECTED METRICS SUMMARY
137
+ * ---------------------------------------------------------------------------
138
+ *
139
+ * Hook | Metric | Baseline | Hook Effect | Ratio
140
+ * ------------------------|-------------------------|----------|--------------|------
141
+ * Bell Curve | Favorites at 25 views | random | ~5 | peak
142
+ * Bell Curve | Favorites at 50+ views | random | ~0-1 | zero
143
+ * Magic Number (3 fav) | Order total | ~$70 | ~$143 | 2.0x
144
+ * Magic Number (3 fav) | Orders per user | ~4 | ~9 | 2.3x
145
+ * Over-Favoriter (4+ fav) | Order total | ~$70 | ~$48 | 0.7x
146
+ * Over-Favoriter (4+ fav) | Avg views | ~16 | ~13 | lower
147
+ * Session Amplification | Order total (10+ sess) | ~$143 | ~$153 | 2.2x
148
+ * No Favorites -> Churn | Median events (0 fav) | ~78 | ~27 | 0.35x
149
+ */
150
+
151
+ // Generate consistent IDs for entity references
152
+ const restaurantIds = v.range(1, 201).map(() => `rest_${v.uid(6)}`);
153
+ const itemIds = v.range(1, 401).map(() => `item_${v.uid(7)}`);
154
+ const orderIds = v.range(1, 5001).map(() => `order_${v.uid(8)}`);
155
+ const couponCodes = v.range(1, 51).map(() => `QUICK${v.uid(5).toUpperCase()}`);
156
+
157
+ const cuisineTypes = ["American", "Italian", "Chinese", "Japanese", "Mexican", "Indian", "Thai", "Mediterranean"];
158
+ const itemCategories = ["entree", "appetizer", "drink", "dessert", "side"];
159
+
160
+ /** @type {Config} */
161
+ const config = {
162
+ token: "dbdf44f9b8f6527c71262030119de387",
163
+ seed: SEED,
164
+ numDays: days,
165
+ numEvents: num_users * 120,
166
+ numUsers: num_users,
167
+ hasAnonIds: false,
168
+ hasSessionIds: true,
169
+ format: "json",
170
+ gzip: true,
171
+ alsoInferFunnels: false,
172
+ hasLocation: true,
173
+ hasAndroidDevices: true,
174
+ hasIOSDevices: true,
175
+ hasDesktopDevices: true,
176
+ hasBrowser: false,
177
+ hasCampaigns: false,
178
+ isAnonymous: false,
179
+ hasAdSpend: false,
180
+ percentUsersBornInDataset: 35,
181
+ hasAvatar: true,
182
+ batchSize: 2_500_000,
183
+ concurrency: 1,
184
+ writeToDisk: false,
185
+ scdProps: {},
186
+ mirrorProps: {},
187
+ lookupTables: [],
188
+
189
+ events: [
190
+ {
191
+ event: "account created",
192
+ weight: 1,
193
+ isFirstEvent: true,
194
+ properties: {
195
+ signup_method: ["email", "google", "apple", "facebook"],
196
+ referral_source: ["organic", "referral", "paid_ad", "social_media"],
197
+ }
198
+ },
199
+ {
200
+ event: "app opened",
201
+ weight: 2,
202
+ isSessionStartEvent: true,
203
+ properties: {
204
+ open_source: ["direct", "push_notification", "deeplink", "widget"],
205
+ }
206
+ },
207
+ {
208
+ event: "browse restaurants",
209
+ weight: 15,
210
+ properties: {
211
+ cuisine_filter: cuisineTypes,
212
+ sort_by: ["recommended", "distance", "rating", "price", "delivery_time"],
213
+ price_filter: ["any", "$", "$$", "$$$", "$$$$"],
214
+ }
215
+ },
216
+ {
217
+ event: "search",
218
+ weight: 10,
219
+ properties: {
220
+ search_query: () => chance.pickone([
221
+ "pizza", "sushi", "burgers", "tacos", "pad thai",
222
+ "chicken wings", "salad", "ramen", "pasta", "sandwiches",
223
+ "burritos", "curry", "pho", "steak", "dumplings",
224
+ "acai bowl", "falafel", "poke", "fried rice", "soup"
225
+ ]),
226
+ results_count: u.weighNumRange(0, 50, 0.8, 30),
227
+ search_type: ["dish", "restaurant", "cuisine"],
228
+ }
229
+ },
230
+ {
231
+ event: "restaurant viewed",
232
+ weight: 14,
233
+ properties: {
234
+ restaurant_id: u.pickAWinner(restaurantIds),
235
+ cuisine_type: cuisineTypes,
236
+ avg_rating: u.weighNumRange(1, 5, 0.8, 30),
237
+ delivery_time_est: u.weighNumRange(15, 75, 1.0, 35),
238
+ price_tier: ["$", "$$", "$$$", "$$$$"],
239
+ }
240
+ },
241
+ {
242
+ event: "view menu item",
243
+ weight: 16,
244
+ properties: {
245
+ item_id: u.pickAWinner(itemIds),
246
+ item_category: itemCategories,
247
+ item_price: u.weighNumRange(3, 55, 1.0, 25),
248
+ restaurant_id: u.pickAWinner(restaurantIds),
249
+ has_photo: u.pickAWinner([true, true, true, false]),
250
+ }
251
+ },
252
+ {
253
+ event: "favorite item",
254
+ weight: 5,
255
+ properties: {
256
+ item_id: u.pickAWinner(itemIds),
257
+ item_category: itemCategories,
258
+ item_price: u.weighNumRange(3, 55, 1.0, 25),
259
+ restaurant_id: u.pickAWinner(restaurantIds),
260
+ }
261
+ },
262
+ {
263
+ event: "add to cart",
264
+ weight: 12,
265
+ properties: {
266
+ item_id: u.pickAWinner(itemIds),
267
+ item_price: u.weighNumRange(3, 55, 1.0, 25),
268
+ quantity: u.weighNumRange(1, 5, 2.0, 10),
269
+ customization_count: u.weighNumRange(0, 4, 1.5, 15),
270
+ }
271
+ },
272
+ {
273
+ event: "remove from cart",
274
+ weight: 3,
275
+ properties: {
276
+ item_id: u.pickAWinner(itemIds),
277
+ removal_reason: ["changed_mind", "too_expensive", "substitution"],
278
+ }
279
+ },
280
+ {
281
+ event: "checkout started",
282
+ weight: 8,
283
+ properties: {
284
+ cart_total: u.weighNumRange(10, 120, 0.8, 35),
285
+ items_count: u.weighNumRange(1, 8, 1.2, 15),
286
+ delivery_address_saved: u.pickAWinner([true, true, true, false]),
287
+ }
288
+ },
289
+ {
290
+ event: "order placed",
291
+ weight: 7,
292
+ properties: {
293
+ order_id: u.pickAWinner(orderIds),
294
+ payment_method: ["credit_card", "apple_pay", "google_pay", "debit_card", "paypal"],
295
+ order_total: u.weighNumRange(12, 150, 0.8, 35),
296
+ tip_amount: u.weighNumRange(0, 25, 1.5, 15),
297
+ delivery_fee: u.weighNumRange(0, 10, 1.0, 15),
298
+ }
299
+ },
300
+ {
301
+ event: "order tracked",
302
+ weight: 9,
303
+ properties: {
304
+ order_id: u.pickAWinner(orderIds),
305
+ tracking_status: ["confirmed", "preparing", "picked_up", "en_route", "delivered"],
306
+ eta_mins: u.weighNumRange(5, 60, 1.0, 25),
307
+ }
308
+ },
309
+ {
310
+ event: "order delivered",
311
+ weight: 6,
312
+ properties: {
313
+ order_id: u.pickAWinner(orderIds),
314
+ delivery_time_mins: u.weighNumRange(15, 80, 1.0, 35),
315
+ on_time: u.pickAWinner([true, true, true, false]),
316
+ }
317
+ },
318
+ {
319
+ event: "order rated",
320
+ weight: 5,
321
+ properties: {
322
+ order_id: u.pickAWinner(orderIds),
323
+ food_rating: u.weighNumRange(1, 5, 0.8, 30),
324
+ delivery_rating: u.weighNumRange(1, 5, 0.8, 30),
325
+ would_reorder: u.pickAWinner([true, true, false]),
326
+ }
327
+ },
328
+ {
329
+ event: "promotion viewed",
330
+ weight: 6,
331
+ properties: {
332
+ promo_type: ["banner", "push_notification", "in_feed", "email"],
333
+ promo_value: ["10%", "15%", "20%", "25%", "$5 off", "$10 off", "free delivery"],
334
+ }
335
+ },
336
+ {
337
+ event: "coupon applied",
338
+ weight: 3,
339
+ properties: {
340
+ coupon_code: u.pickAWinner(couponCodes),
341
+ discount_type: ["percent", "flat", "free_delivery"],
342
+ discount_value: u.weighNumRange(5, 40, 1.2, 15),
343
+ }
344
+ },
345
+ {
346
+ event: "reorder initiated",
347
+ weight: 4,
348
+ properties: {
349
+ original_order_id: u.pickAWinner(orderIds),
350
+ days_since_original: u.weighNumRange(1, 45, 1.5, 20),
351
+ }
352
+ },
353
+ {
354
+ event: "support contacted",
355
+ weight: 2,
356
+ properties: {
357
+ issue_type: ["missing_item", "wrong_order", "late_delivery", "quality_issue", "refund_request", "app_bug"],
358
+ order_id: u.pickAWinner(orderIds),
359
+ }
360
+ },
361
+ ],
362
+
363
+ funnels: [
364
+ {
365
+ sequence: ["account created", "browse restaurants", "restaurant viewed"],
366
+ isFirstFunnel: true,
367
+ conversionRate: 75,
368
+ timeToConvert: 0.5,
369
+ },
370
+ {
371
+ sequence: ["view menu item", "add to cart", "checkout started", "order placed"],
372
+ conversionRate: 50,
373
+ timeToConvert: 1,
374
+ weight: 5,
375
+ },
376
+ {
377
+ sequence: ["browse restaurants", "restaurant viewed", "view menu item", "favorite item"],
378
+ conversionRate: 35,
379
+ timeToConvert: 2,
380
+ weight: 3,
381
+ },
382
+ {
383
+ sequence: ["order delivered", "order rated", "reorder initiated"],
384
+ conversionRate: 40,
385
+ timeToConvert: 24,
386
+ weight: 2,
387
+ },
388
+ ],
389
+
390
+ superProps: {
391
+ platform: ["iOS", "Android", "Web"],
392
+ subscription_tier: u.pickAWinner(["Free", "Free", "Free", "Free", "QuickBite+"]),
393
+ city: ["New York", "Los Angeles", "Chicago", "Houston", "Phoenix", "San Francisco", "Seattle", "Miami"],
394
+ },
395
+
396
+ userProps: {
397
+ preferred_cuisine: cuisineTypes,
398
+ avg_order_value: u.weighNumRange(15, 80, 0.8, 35),
399
+ orders_per_month: u.weighNumRange(1, 15, 1.5, 8),
400
+ account_age_days: u.weighNumRange(1, 365, 0.5, 60),
401
+ },
402
+
403
+ groupKeys: [
404
+ ["restaurant_id", 200, ["restaurant viewed", "order placed", "order rated"]],
405
+ ],
406
+
407
+ groupProps: {
408
+ restaurant_id: {
409
+ name: () => `${chance.pickone(["The", "Big", "Lucky", "Golden", "Fresh", "Urban", "Tasty", "Royal"])} ${chance.pickone(["Kitchen", "Grill", "Bowl", "Wok", "Bistro", "Plate", "Table", "Fork"])}`,
410
+ cuisine: cuisineTypes,
411
+ avg_rating: u.weighNumRange(1, 5, 0.8, 30),
412
+ delivery_radius_mi: u.weighNumRange(1, 12, 1.0, 8),
413
+ }
414
+ },
415
+
416
+ /**
417
+ * ARCHITECTED ANALYTICS HOOKS
418
+ *
419
+ * This hook function creates 4 deliberate patterns in the data:
420
+ *
421
+ * 1. BELL CURVE: VIEW → FAVORITE RELATIONSHIP
422
+ * Users who view ~25 menu items favorite ~5 (peak). Few views or 55+ views → 0.
423
+ * The curve produces a range of 0-5 favorites across the user population.
424
+ *
425
+ * 2. MAGIC NUMBER: 3 FAVORITES = PEAK ORDER PERFORMANCE
426
+ * Exactly 3 favorites is the sweet spot. These users get the highest order
427
+ * values (1.6x) and extra orders. Users with 1-2 favorites get a small boost (1.15x).
428
+ * Users with 4+ favorites (over-favoriters) are PENALIZED: lower order values (0.7x),
429
+ * fewer orders, and fewer view events — they favorite impulsively without engaging deeply.
430
+ *
431
+ * 3. SESSION AMPLIFICATION
432
+ * The magic-number boost is amplified for users with 10+ sessions (2.2x instead of 1.6x).
433
+ *
434
+ * 4. NO FAVORITES → CHURN
435
+ * Users who never favorite an item churn significantly — 60% of their late events are removed.
436
+ */
437
+ hook: function (record, type, meta) {
438
+
439
+ if (type === "everything") {
440
+ const userEvents = record;
441
+ if (!userEvents || userEvents.length === 0) return record;
442
+
443
+ // ═══════════════════════════════════════════════════════════════
444
+ // HOOK 1: BELL CURVE — VIEWS → FAVORITES
445
+ // Gaussian: peak at 25 views → 5 favorites. Sigma=12.
446
+ // Users with ≤3 views are forced to 0 favorites.
447
+ // Range: 0-5 favorites across the population.
448
+ // ═══════════════════════════════════════════════════════════════
449
+
450
+ // Count view menu item events
451
+ let viewCount = 0;
452
+ userEvents.forEach(e => {
453
+ if (e.event === "view menu item") viewCount++;
454
+ });
455
+
456
+ // Gaussian: peak at 25 views → 5 favorites, sigma=12
457
+ // Users with very few views (≤3) get forced to 0
458
+ const rawTarget = Math.round(5 * Math.exp(-((viewCount - 25) ** 2) / (2 * 12 ** 2)));
459
+ const targetFav = viewCount <= 3 ? 0 : rawTarget;
460
+
461
+ // Count current favorites
462
+ let currentFav = 0;
463
+ userEvents.forEach(e => {
464
+ if (e.event === "favorite item") currentFav++;
465
+ });
466
+
467
+ // Adjust favorites to match bell curve target
468
+ if (currentFav > targetFav) {
469
+ // Remove excess favorites (backwards to avoid index issues)
470
+ let toRemove = currentFav - targetFav;
471
+ for (let i = userEvents.length - 1; i >= 0 && toRemove > 0; i--) {
472
+ if (userEvents[i].event === "favorite item") {
473
+ userEvents.splice(i, 1);
474
+ toRemove--;
475
+ }
476
+ }
477
+ } else if (currentFav < targetFav) {
478
+ // Add favorites near existing view events
479
+ const viewEvents = userEvents.filter(e => e.event === "view menu item");
480
+ const toAdd = targetFav - currentFav;
481
+ for (let j = 0; j < toAdd && j < viewEvents.length; j++) {
482
+ const sourceView = viewEvents[j];
483
+ userEvents.push({
484
+ event: "favorite item",
485
+ time: dayjs(sourceView.time).add(chance.integer({ min: 10, max: 120 }), 'seconds').toISOString(),
486
+ user_id: sourceView.user_id,
487
+ item_id: sourceView.item_id,
488
+ item_category: sourceView.item_category,
489
+ item_price: sourceView.item_price,
490
+ restaurant_id: sourceView.restaurant_id,
491
+ });
492
+ }
493
+ }
494
+
495
+ // ═══════════════════════════════════════════════════════════════
496
+ // Recount favorites after adjustment
497
+ // ═══════════════════════════════════════════════════════════════
498
+ let favCount = 0;
499
+ userEvents.forEach(e => {
500
+ if (e.event === "favorite item") favCount++;
501
+ });
502
+
503
+ // ═══════════════════════════════════════════════════════════════
504
+ // Count distinct sessions (for Hook 3)
505
+ // ═══════════════════════════════════════════════════════════════
506
+ const sessionSet = new Set();
507
+ userEvents.forEach(e => {
508
+ if (e.session_id) sessionSet.add(e.session_id);
509
+ });
510
+ const sessionCount = sessionSet.size;
511
+
512
+ // ═══════════════════════════════════════════════════════════════
513
+ // HOOK 2 + 3: MAGIC NUMBER (3 FAVORITES) + SESSION AMPLIFICATION
514
+ //
515
+ // Exactly 3 favorites = peak order performance (the sweet spot).
516
+ // 1-2 favorites = small boost (engaged but haven't found the groove).
517
+ // 4+ favorites = over-favoriters: penalized with lower order values,
518
+ // fewer orders, and fewer view events.
519
+ // Session amplification only applies at the magic number.
520
+ // ═══════════════════════════════════════════════════════════════
521
+ if (favCount === 3) {
522
+ // MAGIC NUMBER: maximum order boost
523
+ const amplified = sessionCount > 10;
524
+ const valueMultiplier = amplified ? 2.2 : 1.6;
525
+ const dupeChance = amplified ? 60 : 35;
526
+
527
+ for (let i = userEvents.length - 1; i >= 0; i--) {
528
+ const event = userEvents[i];
529
+ if (event.event === "order placed") {
530
+ event.order_total = Math.round((event.order_total || 30) * valueMultiplier * 100) / 100;
531
+ event.magic_number_customer = true;
532
+ if (amplified) event.session_amplified = true;
533
+
534
+ if (chance.bool({ likelihood: dupeChance })) {
535
+ const extraOrder = {
536
+ event: "order placed",
537
+ time: dayjs(event.time).add(chance.integer({ min: 1, max: 5 }), 'days').toISOString(),
538
+ user_id: event.user_id,
539
+ order_id: chance.pickone(orderIds),
540
+ payment_method: chance.pickone(["credit_card", "apple_pay", "google_pay", "debit_card"]),
541
+ order_total: Math.round(chance.integer({ min: 25, max: 120 }) * valueMultiplier * 100) / 100,
542
+ tip_amount: chance.integer({ min: 3, max: 20 }),
543
+ delivery_fee: chance.integer({ min: 0, max: 8 }),
544
+ magic_number_customer: true,
545
+ bonus_order: true,
546
+ };
547
+ if (amplified) extraOrder.session_amplified = true;
548
+ userEvents.splice(i + 1, 0, extraOrder);
549
+ }
550
+ }
551
+ }
552
+ } else if (favCount > 0 && favCount < 3) {
553
+ // Below magic number: small boost (engaged but not at sweet spot)
554
+ for (let i = 0; i < userEvents.length; i++) {
555
+ if (userEvents[i].event === "order placed") {
556
+ userEvents[i].order_total = Math.round((userEvents[i].order_total || 30) * 1.15 * 100) / 100;
557
+ }
558
+ }
559
+ } else if (favCount > 3) {
560
+ // Over-favoriters: PENALTY
561
+ // Lower order values (0.7x) and remove ~30% of orders
562
+ for (let i = userEvents.length - 1; i >= 0; i--) {
563
+ if (userEvents[i].event === "order placed") {
564
+ userEvents[i].order_total = Math.round((userEvents[i].order_total || 30) * 0.7 * 100) / 100;
565
+ if (chance.bool({ likelihood: 30 })) {
566
+ userEvents.splice(i, 1);
567
+ }
568
+ }
569
+ }
570
+ // Remove view events — scale with distance from magic number
571
+ const excessFavs = favCount - 3;
572
+ let viewsToRemove = Math.ceil(viewCount * Math.min(0.6, excessFavs * 0.25));
573
+ for (let i = userEvents.length - 1; i >= 0 && viewsToRemove > 0; i--) {
574
+ if (userEvents[i].event === "view menu item") {
575
+ userEvents.splice(i, 1);
576
+ viewsToRemove--;
577
+ }
578
+ }
579
+ }
580
+
581
+ // ═══════════════════════════════════════════════════════════════
582
+ // HOOK 4: NO FAVORITES → CHURN
583
+ // Users who never favorite any item lose ~60% of their events
584
+ // from the second half of their timeline.
585
+ // ═══════════════════════════════════════════════════════════════
586
+ if (favCount === 0) {
587
+ const midIdx = Math.floor(userEvents.length / 2);
588
+ for (let i = userEvents.length - 1; i >= midIdx; i--) {
589
+ if (chance.bool({ likelihood: 60 })) {
590
+ userEvents.splice(i, 1);
591
+ }
592
+ }
593
+ }
594
+
595
+ return record;
596
+ }
597
+
598
+ return record;
599
+ }
600
+ };
601
+
602
+ export default config;