@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,64 +1,51 @@
1
- // ── TWEAK THESE ──
2
- const SEED = "dm4-travel";
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
-
17
- dayjs.extend(utc);
18
- const chance = u.initChance(SEED);
19
8
  /** @typedef {import("../../types").Dungeon} Config */
20
9
 
21
- const hotelIds = v.range(1, 200).map(() => `HTL_${v.uid(6)}`);
22
- const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona", "Dubai", "Sydney", "Rome", "Bangkok", "Cancun", "Bali", "Amsterdam", "Miami", "Singapore", "Lisbon"];
23
-
24
- /**
25
- * ═══════════════════════════════════════════════════════════════
26
- * DATASET OVERVIEW
27
- * ═══════════════════════════════════════════════════════════════
28
- *
29
- * StayQuest a hotel booking platform for business and leisure travelers.
30
- * Users search destinations, compare hotels, book rooms, and leave reviews.
31
- *
32
- * - 5,000 users over 120 days, ~600K events
33
- * - Segments: business travelers (weekday), leisure families, luxury, budget
34
- * - Core loop: search view hotel compare book stay review
35
- * - Revenue: commission per booking + premium loyalty membership
36
- *
37
- * Advanced Features:
38
- * - Personas: 4 traveler archetypes with different booking behaviors
39
- * - World Events: summer sale (day 40), hurricane disruption (day 65), loyalty launch (day 50)
40
- * - Engagement Decay: exponential with 90-day half-life (travel is infrequent)
41
- * - Attribution: Google Hotels, Instagram, TripAdvisor, organic
42
- * - Geo: North America, Europe, Asia-Pacific with timezone + currency
43
- * - Anomalies: whale bookings, coordinated group booking spike
10
+ // ── OVERVIEW ──
11
+ /*
12
+ * NAME: StayQuest
13
+ * APP: Hotel booking platform for business and leisure travelers.
14
+ * Users search destinations, compare hotels, book rooms, and leave
15
+ * reviews. Revenue from commission per booking plus premium loyalty
16
+ * membership. Four traveler archetypes: business, leisure family,
17
+ * luxury, budget.
18
+ * SCALE: 10,000 users, ~600K events, 121 days (2026-01-01 2026-05-01)
19
+ * CORE LOOP: search view hotel → compare book stay review
20
+ *
21
+ * EVENTS (17):
22
+ * destination searched (8) > hotel viewed (7) > app session (7) > notification received (5)
23
+ * > price compared (4) > amenity used (4) > booking completed (3) > wishlist updated (3)
24
+ * > room upgrade selected (2) > booking cancelled (2) > check in completed (2)
25
+ * > review submitted (2) > price alert set (2) > account created (1)
26
+ * > loyalty points redeemed (1) > support contacted (1) > account deactivated (1)
27
+ *
28
+ * FUNNELS (5):
29
+ * - Onboarding to First Booking: account created destination searched hotel viewed → booking completed (35%)
30
+ * - Search to Book: destination searched hotel viewed → price compared → booking completed (25%)
31
+ * - Full Stay Journey: booking completed check in completed amenity used → review submitted (30%)
32
+ * - Loyalty Engagement: booking completed loyalty points redeemed → review submitted (15%)
33
+ * - Upsell Path: hotel viewed → booking completed → room upgrade selected (20%)
34
+ *
35
+ * USER PROPS: customer_segment, travel_frequency, company_name, preferred_destination,
36
+ * avg_budget_per_night, Platform, membership_tier
37
+ * SUPER PROPS: Platform, membership_tier
38
+ * SCD PROPS: membership_tier (member/silver/gold/platinum, monthly fuzzy, max 8)
39
+ * GROUPS: none
44
40
  */
45
41
 
46
- /**
47
- * ═══════════════════════════════════════════════════════════════
48
- * ANALYTICS HOOKS (10 hooks)
49
- *
42
+ // ── HOOK STORIES ──
43
+ /*
50
44
  * NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable via
51
45
  * raw-prop breakdowns (booking_window, day, segment) or behavioral cohorts.
52
46
  *
53
- * Adds 9. BOOKING TIME-TO-CONVERT (Business 1.35x faster, Budget 1.25x slower)
54
- * [funnel-post: visible only in Mixpanel funnel median TTC; cross-event
55
- * MIN→MIN SQL queries do NOT show this]
56
- * and 10. HOTEL-VIEWED MAGIC NUMBER (sweet 5-10 → +30% nightly_rate;
57
- * over 11+ → drop 35% of bookings).
58
- * ═══════════════════════════════════════════════════════════════
59
- *
60
47
  * ───────────────────────────────────────────────────────────────
61
- * 1. WEEKEND LEISURE SURGE (event hook)
48
+ * 1. WEEKEND LEISURE SURGE (everything)
62
49
  * ───────────────────────────────────────────────────────────────
63
50
  * PATTERN: Weekend bookings (Fri-Sun) get 1.3x higher nightly_rate
64
51
  * due to leisure demand pricing.
@@ -75,7 +62,7 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
75
62
  * weekend rates driven by leisure traveler demand.
76
63
  *
77
64
  * ───────────────────────────────────────────────────────────────
78
- * 2. ADVANCE BOOKING DISCOUNT (event hook)
65
+ * 2. ADVANCE BOOKING DISCOUNT (everything)
79
66
  * ───────────────────────────────────────────────────────────────
80
67
  * PATTERN: Bookings made > 21 days before the dataset end get 0.8x
81
68
  * nightly_rate (advance purchase discount). Last-minute bookings
@@ -93,7 +80,7 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
93
80
  * premiums for last-minute availability.
94
81
  *
95
82
  * ───────────────────────────────────────────────────────────────
96
- * 3. LOYALTY TIER UPGRADE PATH (everything hook)
83
+ * 3. LOYALTY TIER UPGRADE PATH (everything)
97
84
  * ───────────────────────────────────────────────────────────────
98
85
  * PATTERN: Users with 5+ bookings get boosted loyalty_points on all
99
86
  * booking events (3x the baseline).
@@ -110,7 +97,7 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
110
97
  * for frequent guests, creating a flywheel.
111
98
  *
112
99
  * ───────────────────────────────────────────────────────────────
113
- * 4. CANCELLATION BY BOOKING WINDOW (everything hook)
100
+ * 4. CANCELLATION BY BOOKING WINDOW (everything)
114
101
  * ───────────────────────────────────────────────────────────────
115
102
  * PATTERN: Users who book last-minute (< 7 days out) have 60% of
116
103
  * their "booking cancelled" events dropped — they rarely cancel.
@@ -127,7 +114,7 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
127
114
  * advance bookers have more flexible cancellation policies.
128
115
  *
129
116
  * ───────────────────────────────────────────────────────────────
130
- * 5. UPSELL SUCCESS BY SEGMENT (everything hook)
117
+ * 5. UPSELL SUCCESS BY SEGMENT (everything)
131
118
  * ───────────────────────────────────────────────────────────────
132
119
  * PATTERN: After a booking, luxury_seeker users get cloned "room
133
120
  * upgrade selected" events injected. Budget users rarely see upsells.
@@ -144,7 +131,7 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
144
131
  * upsells (suite upgrades, spa packages).
145
132
  *
146
133
  * ───────────────────────────────────────────────────────────────
147
- * 6. REVIEW QUALITY BY STAY RATING (everything hook)
134
+ * 6. REVIEW QUALITY BY STAY RATING (everything)
148
135
  * ───────────────────────────────────────────────────────────────
149
136
  * PATTERN: Users whose avg stay_rating is >= 4 have longer review_length
150
137
  * (1.5x words). Low-rating users write shorter, negative reviews.
@@ -161,7 +148,7 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
161
148
  * reviews; dissatisfied guests write brief complaints.
162
149
  *
163
150
  * ───────────────────────────────────────────────────────────────
164
- * 7. BUSINESS TRAVELER PROFILE (user hook)
151
+ * 7. BUSINESS TRAVELER PROFILE (user)
165
152
  * ───────────────────────────────────────────────────────────────
166
153
  * PATTERN: Users in business_traveler segment get company_name set
167
154
  * to a realistic company and travel_frequency to "weekly".
@@ -177,7 +164,7 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
177
164
  * with consistent, high-frequency booking patterns.
178
165
  *
179
166
  * ───────────────────────────────────────────────────────────────
180
- * 8. REPEAT DESTINATION CLUSTERING (everything hook — event filtering)
167
+ * 8. REPEAT DESTINATION CLUSTERING (everything — event filtering)
181
168
  * ───────────────────────────────────────────────────────────────
182
169
  * PATTERN: Non-business/luxury users have ~25% of "booking completed"
183
170
  * events dropped, simulating lower funnel conversion for casual segments.
@@ -193,7 +180,7 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
193
180
  * repeatedly, leading to faster, more confident conversions.
194
181
  *
195
182
  * ───────────────────────────────────────────────────────────────
196
- * 9. BOOKING TIME-TO-CONVERT (funnel-post hook)
183
+ * 9. BOOKING TIME-TO-CONVERT (funnel-post)
197
184
  * ───────────────────────────────────────────────────────────────
198
185
  *
199
186
  * PATTERN: Business travelers complete the Search-to-Book funnel
@@ -224,7 +211,7 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
224
211
  * comparing options and waiting for deals.
225
212
  *
226
213
  * ───────────────────────────────────────────────────────────────
227
- * 10. HOTEL-VIEWED MAGIC NUMBER (everything hook)
214
+ * 10. HOTEL-VIEWED MAGIC NUMBER (everything)
228
215
  * ───────────────────────────────────────────────────────────────
229
216
  *
230
217
  * PATTERN: Users who viewed 5-10 hotels sit in a "decisive comparison
@@ -279,31 +266,261 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
279
266
  * Hotel-Viewed Magic Num | over bookings/user | 1x | 0.65x | -35%
280
267
  */
281
268
 
269
+ // ── SCALE ──
270
+ const SEED = "dm4-travel";
271
+ const NUM_USERS = 10_000;
272
+ const DATASET_START = "2026-01-01T00:00:00Z";
273
+ const DATASET_END = "2026-05-01T23:59:59Z";
274
+ const EVENTS_PER_DAY = 1.2;
275
+ const token = process.env.MP_TOKEN || "your-mixpanel-token";
276
+
277
+ const chance = u.initChance(SEED);
278
+
279
+ // ── KNOBS (tweak these to reshape stories) ──
280
+ const WEEKEND_RATE_BOOST = 1.3;
281
+ const ADVANCE_DAYS_THRESHOLD = 21;
282
+ const ADVANCE_RATE_FACTOR = 0.8;
283
+ const LAST_MINUTE_DAYS_THRESHOLD = 3;
284
+ const LAST_MINUTE_RATE_FACTOR = 1.4;
285
+ const LOYALTY_BOOKING_THRESHOLD = 5;
286
+ const LOYALTY_POINT_BASE_MULT = 2.5;
287
+ const LOYALTY_POINT_VARIANCE = 1.0;
288
+ const REPEAT_DEST_DROP_LIKELIHOOD = 25;
289
+ const CANCEL_LAST_MINUTE_DROP_LIKELIHOOD = 60;
290
+ const UPSELL_LUXURY_LIKELIHOOD = 50;
291
+ const REVIEW_HIGH_RATING_THRESHOLD = 4;
292
+ const REVIEW_LOW_RATING_THRESHOLD = 2;
293
+ const REVIEW_HIGH_LENGTH_MULT = 1.5;
294
+ const REVIEW_LOW_LENGTH_MULT = 0.5;
295
+ const HOTEL_SWEET_MIN = 5;
296
+ const HOTEL_SWEET_MAX = 10;
297
+ const HOTEL_OVER_THRESHOLD = 11;
298
+ const HOTEL_SWEET_RATE_BOOST = 1.3;
299
+ const HOTEL_OVER_BOOKING_DROP_LIKELIHOOD = 35;
300
+ const TTC_BUSINESS_FACTOR = 0.74;
301
+ const TTC_LEISURE_BUDGET_FACTOR = 1.25;
302
+
303
+ // ── DATA ARRAYS ──
304
+ const hotelIds = v.range(1, 200).map(() => `HTL_${v.uid(6)}`);
305
+ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona", "Dubai", "Sydney", "Rome", "Bangkok", "Cancun", "Bali", "Amsterdam", "Miami", "Singapore", "Lisbon"];
306
+
307
+ // ── HELPER FUNCTIONS ──
308
+ function handleUserHooks(record) {
309
+ // H7: BUSINESS TRAVELER PROFILE
310
+ if (record.customer_segment === "business_traveler") {
311
+ record.company_name = chance.pickone(["Acme Corp", "GlobalTech", "Initech", "Prestige Consulting", "Summit Partners", "Atlas Industries"]);
312
+ record.travel_frequency = "weekly";
313
+ } else if (record.customer_segment === "luxury_seeker") {
314
+ record.avg_budget_per_night = chance.integer({ min: 250, max: 500 });
315
+ } else if (record.customer_segment === "budget_hunter") {
316
+ record.avg_budget_per_night = chance.integer({ min: 50, max: 120 });
317
+ }
318
+ return record;
319
+ }
320
+
321
+ function handleFunnelPostHooks(record, meta) {
322
+ // H9: BOOKING TIME-TO-CONVERT — business 1.35x faster (0.74),
323
+ // leisure_family/budget 1.25x slower.
324
+ const segment = meta?.profile?.customer_segment;
325
+ if (Array.isArray(record) && record.length > 1) {
326
+ const factor = (
327
+ segment === "business_traveler" ? TTC_BUSINESS_FACTOR :
328
+ segment === "budget_hunter" || segment === "leisure_family" ? TTC_LEISURE_BUDGET_FACTOR :
329
+ 1.0
330
+ );
331
+ if (factor !== 1.0) {
332
+ for (let i = 1; i < record.length; i++) {
333
+ const prev = dayjs(record[i - 1].time);
334
+ const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
335
+ record[i].time = prev.add(newGap, "milliseconds").toISOString();
336
+ }
337
+ }
338
+ }
339
+ return record;
340
+ }
341
+
342
+ function handleEverythingHooks(record, meta) {
343
+ const events = record;
344
+ if (!events.length) return record;
345
+
346
+ const profile = meta.profile;
347
+
348
+ // Stamp superProps from profile (consistent per user)
349
+ const stampPlatform = profile && profile.Platform ? profile.Platform : undefined;
350
+ const stampTier = profile && profile.membership_tier ? profile.membership_tier : undefined;
351
+ if (stampPlatform || stampTier) {
352
+ events.forEach(e => {
353
+ if (stampPlatform) e.Platform = stampPlatform;
354
+ if (stampTier) e.membership_tier = stampTier;
355
+ });
356
+ }
357
+
358
+ // H1: WEEKEND LEISURE SURGE — Fri/Sat/Sun bookings get +30% rate
359
+ events.forEach(e => {
360
+ if (e.event === "booking completed") {
361
+ const dayOfWeek = new Date(e.time).getUTCDay();
362
+ // Friday=5, Saturday=6, Sunday=0
363
+ if (dayOfWeek === 0 || dayOfWeek === 5 || dayOfWeek === 6) {
364
+ e.nightly_rate = Math.floor((e.nightly_rate || 150) * WEEKEND_RATE_BOOST);
365
+ e.total_cost = Math.floor((e.total_cost || 450) * WEEKEND_RATE_BOOST);
366
+ }
367
+ }
368
+ });
369
+
370
+ // H2: ADVANCE BOOKING DISCOUNT
371
+ const datasetEndForBooking = dayjs.unix(meta.datasetEnd);
372
+ events.forEach(e => {
373
+ if (e.event === "booking completed") {
374
+ const eventTime = dayjs(e.time);
375
+ const daysUntilEnd = datasetEndForBooking.diff(eventTime, "days");
376
+ if (daysUntilEnd > ADVANCE_DAYS_THRESHOLD) {
377
+ e.booking_window = "advance";
378
+ e.nightly_rate = Math.floor((e.nightly_rate || 150) * ADVANCE_RATE_FACTOR);
379
+ } else if (daysUntilEnd < LAST_MINUTE_DAYS_THRESHOLD) {
380
+ e.booking_window = "last_minute";
381
+ e.nightly_rate = Math.floor((e.nightly_rate || 150) * LAST_MINUTE_RATE_FACTOR);
382
+ }
383
+ }
384
+ });
385
+
386
+ // H8: REPEAT DESTINATION CLUSTERING — drop ~25% of "booking completed"
387
+ // for non-business/luxury users.
388
+ const segment = profile && profile.customer_segment;
389
+ if (segment !== "business_traveler" && segment !== "luxury_seeker"
390
+ && chance.bool({ likelihood: REPEAT_DEST_DROP_LIKELIHOOD })) {
391
+ for (let i = events.length - 1; i >= 0; i--) {
392
+ if (events[i].event === "booking completed") {
393
+ events.splice(i, 1);
394
+ }
395
+ }
396
+ }
397
+
398
+ // H3: LOYALTY TIER UPGRADE PATH — 5+ bookings → ~3x loyalty_points
399
+ let bookingCount = 0;
400
+ events.forEach(e => { if (e.event === "booking completed") bookingCount++; });
401
+ if (bookingCount >= LOYALTY_BOOKING_THRESHOLD) {
402
+ events.forEach(e => {
403
+ if (e.event === "booking completed" && e.loyalty_points) {
404
+ e.loyalty_points = Math.floor(e.loyalty_points * (LOYALTY_POINT_BASE_MULT + chance.floating({ min: 0, max: LOYALTY_POINT_VARIANCE })));
405
+ }
406
+ });
407
+ }
408
+
409
+ // H4: CANCELLATION BY BOOKING WINDOW — copy nearest preceding booking's
410
+ // booking_window onto each cancellation, then drop 60% of last-minute cancels.
411
+ const bookingsByTime = events
412
+ .filter(e => e.event === "booking completed")
413
+ .sort((a, b) => new Date(a.time) - new Date(b.time));
414
+ events.forEach(e => {
415
+ if (e.event === "booking cancelled" && bookingsByTime.length > 0) {
416
+ const cancelTime = new Date(e.time).getTime();
417
+ let matched = bookingsByTime[0];
418
+ for (let b = bookingsByTime.length - 1; b >= 0; b--) {
419
+ if (new Date(bookingsByTime[b].time).getTime() <= cancelTime) {
420
+ matched = bookingsByTime[b];
421
+ break;
422
+ }
423
+ }
424
+ e.booking_window = matched.booking_window;
425
+ }
426
+ });
427
+ for (let i = events.length - 1; i >= 0; i--) {
428
+ if (events[i].event === "booking cancelled" && events[i].booking_window === "last_minute") {
429
+ if (chance.bool({ likelihood: CANCEL_LAST_MINUTE_DROP_LIKELIHOOD })) {
430
+ events.splice(i, 1);
431
+ }
432
+ }
433
+ }
434
+
435
+ // H5: UPSELL SUCCESS BY SEGMENT — luxury_seeker users get cloned upgrades
436
+ if (profile && profile.customer_segment === "luxury_seeker") {
437
+ const templateUpgrade = events.find(e => e.event === "room upgrade selected");
438
+ if (templateUpgrade) {
439
+ const bookings = events.filter(e => e.event === "booking completed");
440
+ bookings.forEach(booking => {
441
+ if (chance.bool({ likelihood: UPSELL_LUXURY_LIKELIHOOD })) {
442
+ events.push({
443
+ ...templateUpgrade,
444
+ time: dayjs(booking.time).add(chance.integer({ min: 1, max: 30 }), "minutes").toISOString(),
445
+ user_id: booking.user_id,
446
+ upgrade_cost: chance.integer({ min: 75, max: 200 }),
447
+ });
448
+ }
449
+ });
450
+ }
451
+ }
452
+
453
+ // H6: REVIEW QUALITY BY STAY RATING — high avg → 1.5x review_length;
454
+ // low avg → 0.5x.
455
+ let totalRating = 0;
456
+ let ratingCount = 0;
457
+ events.forEach(e => {
458
+ if (e.event === "review submitted" && e.stay_rating) {
459
+ totalRating += e.stay_rating;
460
+ ratingCount++;
461
+ }
462
+ });
463
+ const avgRating = ratingCount > 0 ? totalRating / ratingCount : 3;
464
+ events.forEach(e => {
465
+ if (e.event === "review submitted") {
466
+ if (avgRating >= REVIEW_HIGH_RATING_THRESHOLD) {
467
+ e.review_length = Math.floor((e.review_length || 120) * REVIEW_HIGH_LENGTH_MULT);
468
+ } else if (avgRating <= REVIEW_LOW_RATING_THRESHOLD) {
469
+ e.review_length = Math.floor((e.review_length || 120) * REVIEW_LOW_LENGTH_MULT);
470
+ }
471
+ }
472
+ });
473
+
474
+ // H10: HOTEL-VIEWED MAGIC NUMBER — sweet 5-10 → +30% nightly_rate;
475
+ // over 11+ → drop 35% of bookings.
476
+ const hotelViews = events.filter(e => e.event === "hotel viewed").length;
477
+ if (hotelViews >= HOTEL_SWEET_MIN && hotelViews <= HOTEL_SWEET_MAX) {
478
+ events.forEach(e => {
479
+ if (e.event === "booking completed" && typeof e.nightly_rate === "number") {
480
+ e.nightly_rate = Math.round(e.nightly_rate * HOTEL_SWEET_RATE_BOOST);
481
+ }
482
+ });
483
+ } else if (hotelViews >= HOTEL_OVER_THRESHOLD) {
484
+ for (let i = events.length - 1; i >= 0; i--) {
485
+ if (events[i].event === "booking completed" && chance.bool({ likelihood: HOTEL_OVER_BOOKING_DROP_LIKELIHOOD })) {
486
+ events.splice(i, 1);
487
+ }
488
+ }
489
+ }
490
+
491
+ return record;
492
+ }
493
+
494
+ // ── CONFIG ──
282
495
  /** @type {Config} */
283
496
  const config = {
284
497
  version: 2,
285
- token,
286
498
  seed: SEED,
287
- datasetStart: "2026-01-01T00:00:00Z",
288
- datasetEnd: "2026-05-01T23:59:59Z",
289
- // numDays: num_days,
290
- avgEventsPerUserPerDay: avg_events_per_user_per_day,
291
- numUsers: num_users,
292
- hasAnonIds: true,
293
- avgDevicePerUser: 2,
294
- hasSessionIds: true,
499
+ datasetStart: DATASET_START,
500
+ datasetEnd: DATASET_END,
501
+ avgEventsPerUserPerDay: EVENTS_PER_DAY,
502
+ numUsers: NUM_USERS,
295
503
  format: "json",
296
504
  gzip: true,
297
- alsoInferFunnels: false,
298
- hasLocation: true,
299
- hasAndroidDevices: true,
300
- hasIOSDevices: true,
301
- hasDesktopDevices: true,
302
- hasBrowser: false,
303
- hasCampaigns: false,
304
- isAnonymous: false,
305
- hasAdSpend: false,
306
- hasAvatar: true,
505
+ credentials: {
506
+ token,
507
+ },
508
+ switches: {
509
+ hasSessionIds: true,
510
+ alsoInferFunnels: false,
511
+ hasLocation: true,
512
+ hasAndroidDevices: true,
513
+ hasIOSDevices: true,
514
+ hasDesktopDevices: true,
515
+ hasBrowser: false,
516
+ hasCampaigns: false,
517
+ isAnonymous: false,
518
+ hasAdSpend: false,
519
+ hasAvatar: true,
520
+ },
521
+ identity: {
522
+ avgDevicePerUser: 2,
523
+ },
307
524
  concurrency: 1,
308
525
  writeToDisk: false,
309
526
  scdProps: {
@@ -317,7 +534,6 @@ const config = {
317
534
  mirrorProps: {},
318
535
  lookupTables: [],
319
536
 
320
- // ── Events (17) ──────────────────────────────────────────
321
537
  events: [
322
538
  {
323
539
  event: "account created",
@@ -490,7 +706,6 @@ const config = {
490
706
  },
491
707
  ],
492
708
 
493
- // ── Funnels (5) ──────────────────────────────────────────
494
709
  funnels: [
495
710
  {
496
711
  name: "Onboarding to First Booking",
@@ -536,13 +751,11 @@ const config = {
536
751
  },
537
752
  ],
538
753
 
539
- // ── SuperProps ──────────────────────────────────────────
540
754
  superProps: {
541
755
  Platform: ["ios", "android", "web", "web"],
542
756
  membership_tier: ["standard", "standard", "standard", "gold", "platinum"],
543
757
  },
544
758
 
545
- // ── UserProps ──────────────────────────────────────────
546
759
  userProps: {
547
760
  customer_segment: ["leisure_family"],
548
761
  travel_frequency: ["occasional", "occasional", "monthly", "weekly"],
@@ -553,7 +766,6 @@ const config = {
553
766
  membership_tier: ["standard", "standard", "standard", "gold", "platinum"],
554
767
  },
555
768
 
556
- // ── Personas ──────────────────────────────────
557
769
  personas: [
558
770
  {
559
771
  name: "business_traveler",
@@ -589,7 +801,6 @@ const config = {
589
801
  },
590
802
  ],
591
803
 
592
- // ── World Events ──────────────────────────────
593
804
  worldEvents: [
594
805
  {
595
806
  name: "summer_sale",
@@ -620,7 +831,6 @@ const config = {
620
831
  },
621
832
  ],
622
833
 
623
- // ── Engagement Decay ──────────────────────────
624
834
  engagementDecay: {
625
835
  model: "exponential",
626
836
  halfLife: 90,
@@ -628,7 +838,6 @@ const config = {
628
838
  reactivationChance: 0.02,
629
839
  },
630
840
 
631
- // ── Attribution ──────────────────────────────
632
841
  attribution: {
633
842
  model: "last_touch",
634
843
  window: 7,
@@ -664,7 +873,6 @@ const config = {
664
873
  organicRate: 0.35,
665
874
  },
666
875
 
667
- // ── Geo ──────────────────────────────────────
668
876
  geo: {
669
877
  sticky: true,
670
878
  regions: [
@@ -692,7 +900,6 @@ const config = {
692
900
  ],
693
901
  },
694
902
 
695
- // ── Anomalies ──────────────────────────────────
696
903
  anomalies: [
697
904
  {
698
905
  type: "extreme_value",
@@ -712,215 +919,10 @@ const config = {
712
919
  },
713
920
  ],
714
921
 
715
- // ── Hook Function ──────────────────────────────────────
716
- hook: function (record, type, meta) {
717
- // ── HOOK 7: BUSINESS TRAVELER PROFILE (user) ─────────
718
- if (type === "user") {
719
- if (record.customer_segment === "business_traveler") {
720
- record.company_name = chance.pickone(["Acme Corp", "GlobalTech", "Initech", "Prestige Consulting", "Summit Partners", "Atlas Industries"]);
721
- record.travel_frequency = "weekly";
722
- } else if (record.customer_segment === "luxury_seeker") {
723
- record.avg_budget_per_night = chance.integer({ min: 250, max: 500 });
724
- } else if (record.customer_segment === "budget_hunter") {
725
- record.avg_budget_per_night = chance.integer({ min: 50, max: 120 });
726
- }
727
- }
728
-
729
- // HOOK 9 (T2C): BOOKING TIME-TO-CONVERT (funnel-post)
730
- // Business travelers complete the Search-to-Book funnel 1.35x faster
731
- // (factor 0.74); leisure_family/budget customers 1.25x slower (1.25).
732
- if (type === "funnel-post") {
733
- const segment = meta?.profile?.customer_segment;
734
- if (Array.isArray(record) && record.length > 1) {
735
- const factor = (
736
- segment === "business_traveler" ? 0.74 :
737
- segment === "budget_hunter" || segment === "leisure_family" ? 1.25 :
738
- 1.0
739
- );
740
- if (factor !== 1.0) {
741
- for (let i = 1; i < record.length; i++) {
742
- const prev = dayjs(record[i - 1].time);
743
- const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
744
- record[i].time = prev.add(newGap, "milliseconds").toISOString();
745
- }
746
- }
747
- }
748
- }
749
-
750
- // ── HOOK 1: WEEKEND LEISURE SURGE ────────────
751
- // Moved to everything hook (sessionization reassigns times after event hook)
752
-
753
- // ── HOOK 2: ADVANCE BOOKING DISCOUNT ─────
754
- // Moved to everything hook (sessionization reassigns times after event hook)
755
- if (type === "event") {
756
- // no-op: DOW-dependent hooks moved to everything hook
757
- }
758
-
759
- // ── EVERYTHING HOOKS ─────────────────────────────────
760
- if (type === "everything") {
761
- const events = record;
762
- if (!events.length) return record;
763
-
764
- const profile = meta.profile;
765
-
766
- // ─── Stamp superProps from profile (consistent per user) ───
767
- const stampPlatform = profile && profile.Platform ? profile.Platform : undefined;
768
- const stampTier = profile && profile.membership_tier ? profile.membership_tier : undefined;
769
- if (stampPlatform || stampTier) {
770
- events.forEach(e => {
771
- if (stampPlatform) e.Platform = stampPlatform;
772
- if (stampTier) e.membership_tier = stampTier;
773
- });
774
- }
775
-
776
- // ── HOOK 1: WEEKEND LEISURE SURGE ────────────
777
- // Apply AFTER sessionization has finalized times
778
- events.forEach(e => {
779
- if (e.event === "booking completed") {
780
- const dayOfWeek = new Date(e.time).getUTCDay();
781
- // Friday=5, Saturday=6, Sunday=0
782
- if (dayOfWeek === 0 || dayOfWeek === 5 || dayOfWeek === 6) {
783
- e.nightly_rate = Math.floor((e.nightly_rate || 150) * 1.3);
784
- e.total_cost = Math.floor((e.total_cost || 450) * 1.3);
785
- }
786
- }
787
- });
788
-
789
- // ── HOOK 2: ADVANCE BOOKING DISCOUNT ─────
790
- const datasetEndForBooking = dayjs.unix(meta.datasetEnd);
791
- events.forEach(e => {
792
- if (e.event === "booking completed") {
793
- const eventTime = dayjs(e.time);
794
- const daysUntilEnd = datasetEndForBooking.diff(eventTime, "days");
795
- if (daysUntilEnd > 21) {
796
- e.booking_window = "advance";
797
- e.nightly_rate = Math.floor((e.nightly_rate || 150) * 0.8);
798
- } else if (daysUntilEnd < 3) {
799
- e.booking_window = "last_minute";
800
- e.nightly_rate = Math.floor((e.nightly_rate || 150) * 1.4);
801
- }
802
- }
803
- });
804
-
805
- // ─── Bug 2 fix: Repeat destination clustering conversion filtering ───
806
- // Drop ~25% of "booking completed" events for users who are NOT
807
- // business_traveler or luxury_seeker to simulate their lower funnel
808
- // conversion (was conversionRate * 1.3 / 1.15 in funnel-pre)
809
- const segment = profile && profile.customer_segment;
810
- if (segment !== "business_traveler" && segment !== "luxury_seeker"
811
- && chance.bool({ likelihood: 25 })) {
812
- for (let i = events.length - 1; i >= 0; i--) {
813
- if (events[i].event === "booking completed") {
814
- events.splice(i, 1);
815
- }
816
- }
817
- }
818
-
819
- // ── HOOK 3: LOYALTY TIER UPGRADE PATH ────────────
820
- let bookingCount = 0;
821
- events.forEach(e => { if (e.event === "booking completed") bookingCount++; });
822
- if (bookingCount >= 5) {
823
- events.forEach(e => {
824
- if (e.event === "booking completed" && e.loyalty_points) {
825
- e.loyalty_points = Math.floor(e.loyalty_points * (2.5 + chance.floating({ min: 0, max: 1.0 })));
826
- }
827
- });
828
- }
829
-
830
- // ── HOOK 4: CANCELLATION BY BOOKING WINDOW ───────
831
- // Match each cancellation to its nearest preceding booking
832
- // and copy the booking's booking_window. This replaces the
833
- // random booking_window that the event config assigns to
834
- // cancellation events independently.
835
- const bookingsByTime = events
836
- .filter(e => e.event === "booking completed")
837
- .sort((a, b) => new Date(a.time) - new Date(b.time));
838
- events.forEach(e => {
839
- if (e.event === "booking cancelled" && bookingsByTime.length > 0) {
840
- const cancelTime = new Date(e.time).getTime();
841
- // Find the nearest preceding booking (or the first one)
842
- let matched = bookingsByTime[0];
843
- for (let b = bookingsByTime.length - 1; b >= 0; b--) {
844
- if (new Date(bookingsByTime[b].time).getTime() <= cancelTime) {
845
- matched = bookingsByTime[b];
846
- break;
847
- }
848
- }
849
- e.booking_window = matched.booking_window;
850
- }
851
- });
852
-
853
- // Last-minute bookers rarely cancel — drop 60%.
854
- for (let i = events.length - 1; i >= 0; i--) {
855
- if (events[i].event === "booking cancelled" && events[i].booking_window === "last_minute") {
856
- if (chance.bool({ likelihood: 60 })) {
857
- events.splice(i, 1);
858
- }
859
- }
860
- }
861
-
862
- // ── HOOK 5: UPSELL SUCCESS BY SEGMENT ────────────
863
- if (profile && profile.customer_segment === "luxury_seeker") {
864
- const templateUpgrade = events.find(e => e.event === "room upgrade selected");
865
- if (templateUpgrade) {
866
- const bookings = events.filter(e => e.event === "booking completed");
867
- bookings.forEach(booking => {
868
- if (chance.bool({ likelihood: 50 })) {
869
- events.push({
870
- ...templateUpgrade,
871
- time: dayjs(booking.time).add(chance.integer({ min: 1, max: 30 }), "minutes").toISOString(),
872
- user_id: booking.user_id,
873
- upgrade_cost: chance.integer({ min: 75, max: 200 }),
874
- });
875
- }
876
- });
877
- }
878
- }
879
-
880
- // HOOK 6: REVIEW QUALITY BY STAY RATING — high avg ratings get
881
- // review_length 1.5x; low avg ratings get 0.5x. Mutates raw prop.
882
- let totalRating = 0;
883
- let ratingCount = 0;
884
- events.forEach(e => {
885
- if (e.event === "review submitted" && e.stay_rating) {
886
- totalRating += e.stay_rating;
887
- ratingCount++;
888
- }
889
- });
890
- const avgRating = ratingCount > 0 ? totalRating / ratingCount : 3;
891
- events.forEach(e => {
892
- if (e.event === "review submitted") {
893
- if (avgRating >= 4) {
894
- e.review_length = Math.floor((e.review_length || 120) * 1.5);
895
- } else if (avgRating <= 2) {
896
- e.review_length = Math.floor((e.review_length || 120) * 0.5);
897
- }
898
- }
899
- });
900
-
901
- // HOOK 10: HOTEL-VIEWED MAGIC NUMBER (in-funnel, no flags)
902
- // Sweet 5-10 hotel-viewed events → +30% on booking nightly_rate
903
- // (decisive comparison shoppers book higher-tier rooms).
904
- // Over 11+ → drop 35% of booking-completed events (analysis
905
- // paralysis blocks conversion).
906
- const hotelViews = events.filter(e => e.event === "hotel viewed").length;
907
- if (hotelViews >= 5 && hotelViews <= 10) {
908
- events.forEach(e => {
909
- if (e.event === "booking completed" && typeof e.nightly_rate === "number") {
910
- e.nightly_rate = Math.round(e.nightly_rate * 1.3);
911
- }
912
- });
913
- } else if (hotelViews >= 11) {
914
- for (let i = events.length - 1; i >= 0; i--) {
915
- if (events[i].event === "booking completed" && chance.bool({ likelihood: 35 })) {
916
- events.splice(i, 1);
917
- }
918
- }
919
- }
920
-
921
- return record;
922
- }
923
-
922
+ hook(record, type, meta) {
923
+ if (type === "user") return handleUserHooks(record);
924
+ if (type === "funnel-post") return handleFunnelPostHooks(record, meta);
925
+ if (type === "everything") return handleEverythingHooks(record, meta);
924
926
  return record;
925
927
  },
926
928
  };