@ak--47/dungeon-master 1.4.5 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +158 -0
  2. package/.claude/skills/create-dungeon/SKILL.md +464 -0
  3. package/.claude/skills/verify-dungeon/SKILL.md +157 -0
  4. package/.claude/skills/verify-dungeon/references/counting-semantics.md +161 -0
  5. package/.claude/skills/verify-dungeon/references/report-format.md +216 -0
  6. package/.claude/skills/verify-dungeon/references/sql-recipes.md +857 -0
  7. package/.claude/skills/write-hooks/SKILL.md +468 -0
  8. package/CHANGELOG.md +182 -0
  9. package/HOOKS.md +1256 -597
  10. package/README.md +140 -5
  11. package/dungeons/technical/ad-spend.js +41 -49
  12. package/dungeons/technical/anonymous-users.js +38 -36
  13. package/dungeons/technical/array-of-object-lookup.js +136 -153
  14. package/dungeons/technical/datagen-v15-verify.js +87 -0
  15. package/dungeons/technical/experiments.js +42 -40
  16. package/dungeons/technical/foobar.js +114 -118
  17. package/dungeons/technical/group-analytics.js +42 -40
  18. package/dungeons/technical/hook-helpers-verify.js +69 -50
  19. package/dungeons/technical/identity-model-verify.js +22 -12
  20. package/dungeons/technical/mirror-strategies.js +37 -39
  21. package/dungeons/technical/nested-objects.js +119 -118
  22. package/dungeons/technical/pattern-aggregate-by-bin.js +21 -8
  23. package/dungeons/technical/pattern-attributed-by-source.js +23 -9
  24. package/dungeons/technical/pattern-frequency-by-frequency.js +21 -8
  25. package/dungeons/technical/pattern-funnel-frequency.js +30 -15
  26. package/dungeons/technical/pattern-ttc-by-segment.js +21 -8
  27. package/dungeons/technical/retention-cadence.js +115 -112
  28. package/dungeons/technical/sanity.js +86 -80
  29. package/dungeons/technical/scale-test.js +34 -38
  30. package/dungeons/technical/scd.js +111 -128
  31. package/dungeons/technical/simple.js +134 -141
  32. package/dungeons/technical/simplest.js +111 -65
  33. package/dungeons/technical/text-generation.js +110 -146
  34. package/dungeons/vertical/ai-platform.js +300 -333
  35. package/dungeons/vertical/community.js +290 -255
  36. package/dungeons/vertical/crypto.js +400 -391
  37. package/dungeons/vertical/dating.js +421 -375
  38. package/dungeons/vertical/devtools.js +346 -298
  39. package/dungeons/vertical/ecommerce.js +322 -394
  40. package/dungeons/vertical/education.js +380 -325
  41. package/dungeons/vertical/fintech.js +371 -325
  42. package/dungeons/vertical/fitness.js +345 -291
  43. package/dungeons/vertical/food-delivery.js +352 -307
  44. package/dungeons/vertical/gaming.js +490 -444
  45. package/dungeons/vertical/healthcare.js +311 -262
  46. package/dungeons/vertical/insurance-application.js +437 -409
  47. package/dungeons/vertical/logistics.js +278 -252
  48. package/dungeons/vertical/marketplace.js +340 -323
  49. package/dungeons/vertical/media.js +390 -335
  50. package/dungeons/vertical/real-estate.js +402 -347
  51. package/dungeons/vertical/sass.js +331 -333
  52. package/dungeons/vertical/social.js +377 -316
  53. package/dungeons/vertical/travel.js +302 -295
  54. package/index.js +64 -7
  55. package/lib/core/config-validator.js +378 -17
  56. package/lib/core/dungeon-loader.js +2 -5
  57. package/lib/generators/events.js +12 -13
  58. package/lib/generators/funnels.js +76 -2
  59. package/lib/hook-helpers/index.js +1 -0
  60. package/lib/hook-helpers/inject.js +95 -0
  61. package/lib/orchestrators/mixpanel-sender.js +7 -0
  62. package/lib/orchestrators/user-loop.js +598 -48
  63. package/lib/templates/defaults.js +59 -59
  64. package/lib/templates/macro-presets.js +53 -11
  65. package/lib/utils/dataset-context.js +103 -0
  66. package/lib/utils/retention-curve.js +140 -0
  67. package/lib/utils/utils.js +157 -109
  68. package/lib/verify/counting.js +360 -0
  69. package/lib/verify/emulate-breakdown.js +531 -108
  70. package/lib/verify/funnel-engine.js +539 -0
  71. package/lib/verify/identity.js +78 -0
  72. package/lib/verify/index.js +20 -0
  73. package/lib/verify/schema-validator.js +3 -1
  74. package/lib/verify/verify-dungeon.js +58 -0
  75. package/package.json +14 -3
  76. package/scripts/run-dungeon.mjs +12 -1
  77. package/types.d.ts +353 -4
  78. package/scripts/smoke-test-all.mjs +0 -162
@@ -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",
@@ -342,6 +558,7 @@ const config = {
342
558
  {
343
559
  event: "hotel viewed",
344
560
  weight: 7,
561
+ isStrictEvent: false,
345
562
  properties: {
346
563
  hotel_id: chance.pickone.bind(chance, hotelIds),
347
564
  destination: chance.pickone.bind(chance, destinationCities),
@@ -364,6 +581,7 @@ const config = {
364
581
  {
365
582
  event: "booking completed",
366
583
  weight: 3,
584
+ isStrictEvent: false,
367
585
  properties: {
368
586
  hotel_id: chance.pickone.bind(chance, hotelIds),
369
587
  destination: chance.pickone.bind(chance, destinationCities),
@@ -379,6 +597,7 @@ const config = {
379
597
  {
380
598
  event: "room upgrade selected",
381
599
  weight: 2,
600
+ isStrictEvent: false,
382
601
  properties: {
383
602
  upgrade_type: ["suite", "ocean_view", "executive_floor", "club_lounge", "premium_king"],
384
603
  upgrade_cost: u.weighNumRange(25, 200, 0.4, 75),
@@ -415,6 +634,7 @@ const config = {
415
634
  {
416
635
  event: "review submitted",
417
636
  weight: 2,
637
+ isStrictEvent: false,
418
638
  properties: {
419
639
  hotel_id: chance.pickone.bind(chance, hotelIds),
420
640
  stay_rating: u.weighNumRange(1, 5, 0.7, 4),
@@ -486,7 +706,6 @@ const config = {
486
706
  },
487
707
  ],
488
708
 
489
- // ── Funnels (5) ──────────────────────────────────────────
490
709
  funnels: [
491
710
  {
492
711
  name: "Onboarding to First Booking",
@@ -504,6 +723,7 @@ const config = {
504
723
  order: "sequential",
505
724
  timeToConvert: 48,
506
725
  weight: 5,
726
+ reentry: true,
507
727
  },
508
728
  {
509
729
  name: "Full Stay Journey",
@@ -531,13 +751,11 @@ const config = {
531
751
  },
532
752
  ],
533
753
 
534
- // ── SuperProps ──────────────────────────────────────────
535
754
  superProps: {
536
755
  Platform: ["ios", "android", "web", "web"],
537
756
  membership_tier: ["standard", "standard", "standard", "gold", "platinum"],
538
757
  },
539
758
 
540
- // ── UserProps ──────────────────────────────────────────
541
759
  userProps: {
542
760
  customer_segment: ["leisure_family"],
543
761
  travel_frequency: ["occasional", "occasional", "monthly", "weekly"],
@@ -548,7 +766,6 @@ const config = {
548
766
  membership_tier: ["standard", "standard", "standard", "gold", "platinum"],
549
767
  },
550
768
 
551
- // ── Personas ──────────────────────────────────
552
769
  personas: [
553
770
  {
554
771
  name: "business_traveler",
@@ -584,7 +801,6 @@ const config = {
584
801
  },
585
802
  ],
586
803
 
587
- // ── World Events ──────────────────────────────
588
804
  worldEvents: [
589
805
  {
590
806
  name: "summer_sale",
@@ -615,7 +831,6 @@ const config = {
615
831
  },
616
832
  ],
617
833
 
618
- // ── Engagement Decay ──────────────────────────
619
834
  engagementDecay: {
620
835
  model: "exponential",
621
836
  halfLife: 90,
@@ -623,7 +838,6 @@ const config = {
623
838
  reactivationChance: 0.02,
624
839
  },
625
840
 
626
- // ── Attribution ──────────────────────────────
627
841
  attribution: {
628
842
  model: "last_touch",
629
843
  window: 7,
@@ -659,7 +873,6 @@ const config = {
659
873
  organicRate: 0.35,
660
874
  },
661
875
 
662
- // ── Geo ──────────────────────────────────────
663
876
  geo: {
664
877
  sticky: true,
665
878
  regions: [
@@ -687,7 +900,6 @@ const config = {
687
900
  ],
688
901
  },
689
902
 
690
- // ── Anomalies ──────────────────────────────────
691
903
  anomalies: [
692
904
  {
693
905
  type: "extreme_value",
@@ -707,215 +919,10 @@ const config = {
707
919
  },
708
920
  ],
709
921
 
710
- // ── Hook Function ──────────────────────────────────────
711
- hook: function (record, type, meta) {
712
- // ── HOOK 7: BUSINESS TRAVELER PROFILE (user) ─────────
713
- if (type === "user") {
714
- if (record.customer_segment === "business_traveler") {
715
- record.company_name = chance.pickone(["Acme Corp", "GlobalTech", "Initech", "Prestige Consulting", "Summit Partners", "Atlas Industries"]);
716
- record.travel_frequency = "weekly";
717
- } else if (record.customer_segment === "luxury_seeker") {
718
- record.avg_budget_per_night = chance.integer({ min: 250, max: 500 });
719
- } else if (record.customer_segment === "budget_hunter") {
720
- record.avg_budget_per_night = chance.integer({ min: 50, max: 120 });
721
- }
722
- }
723
-
724
- // HOOK 9 (T2C): BOOKING TIME-TO-CONVERT (funnel-post)
725
- // Business travelers complete the Search-to-Book funnel 1.35x faster
726
- // (factor 0.74); leisure_family/budget customers 1.25x slower (1.25).
727
- if (type === "funnel-post") {
728
- const segment = meta?.profile?.customer_segment;
729
- if (Array.isArray(record) && record.length > 1) {
730
- const factor = (
731
- segment === "business_traveler" ? 0.74 :
732
- segment === "budget_hunter" || segment === "leisure_family" ? 1.25 :
733
- 1.0
734
- );
735
- if (factor !== 1.0) {
736
- for (let i = 1; i < record.length; i++) {
737
- const prev = dayjs(record[i - 1].time);
738
- const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
739
- record[i].time = prev.add(newGap, "milliseconds").toISOString();
740
- }
741
- }
742
- }
743
- }
744
-
745
- // ── HOOK 1: WEEKEND LEISURE SURGE ────────────
746
- // Moved to everything hook (sessionization reassigns times after event hook)
747
-
748
- // ── HOOK 2: ADVANCE BOOKING DISCOUNT ─────
749
- // Moved to everything hook (sessionization reassigns times after event hook)
750
- if (type === "event") {
751
- // no-op: DOW-dependent hooks moved to everything hook
752
- }
753
-
754
- // ── EVERYTHING HOOKS ─────────────────────────────────
755
- if (type === "everything") {
756
- const events = record;
757
- if (!events.length) return record;
758
-
759
- const profile = meta.profile;
760
-
761
- // ─── Stamp superProps from profile (consistent per user) ───
762
- const stampPlatform = profile && profile.Platform ? profile.Platform : undefined;
763
- const stampTier = profile && profile.membership_tier ? profile.membership_tier : undefined;
764
- if (stampPlatform || stampTier) {
765
- events.forEach(e => {
766
- if (stampPlatform) e.Platform = stampPlatform;
767
- if (stampTier) e.membership_tier = stampTier;
768
- });
769
- }
770
-
771
- // ── HOOK 1: WEEKEND LEISURE SURGE ────────────
772
- // Apply AFTER sessionization has finalized times
773
- events.forEach(e => {
774
- if (e.event === "booking completed") {
775
- const dayOfWeek = new Date(e.time).getUTCDay();
776
- // Friday=5, Saturday=6, Sunday=0
777
- if (dayOfWeek === 0 || dayOfWeek === 5 || dayOfWeek === 6) {
778
- e.nightly_rate = Math.floor((e.nightly_rate || 150) * 1.3);
779
- e.total_cost = Math.floor((e.total_cost || 450) * 1.3);
780
- }
781
- }
782
- });
783
-
784
- // ── HOOK 2: ADVANCE BOOKING DISCOUNT ─────
785
- const datasetEndForBooking = dayjs.unix(meta.datasetEnd);
786
- events.forEach(e => {
787
- if (e.event === "booking completed") {
788
- const eventTime = dayjs(e.time);
789
- const daysUntilEnd = datasetEndForBooking.diff(eventTime, "days");
790
- if (daysUntilEnd > 21) {
791
- e.booking_window = "advance";
792
- e.nightly_rate = Math.floor((e.nightly_rate || 150) * 0.8);
793
- } else if (daysUntilEnd < 3) {
794
- e.booking_window = "last_minute";
795
- e.nightly_rate = Math.floor((e.nightly_rate || 150) * 1.4);
796
- }
797
- }
798
- });
799
-
800
- // ─── Bug 2 fix: Repeat destination clustering conversion filtering ───
801
- // Drop ~25% of "booking completed" events for users who are NOT
802
- // business_traveler or luxury_seeker to simulate their lower funnel
803
- // conversion (was conversionRate * 1.3 / 1.15 in funnel-pre)
804
- const segment = profile && profile.customer_segment;
805
- if (segment !== "business_traveler" && segment !== "luxury_seeker"
806
- && chance.bool({ likelihood: 25 })) {
807
- for (let i = events.length - 1; i >= 0; i--) {
808
- if (events[i].event === "booking completed") {
809
- events.splice(i, 1);
810
- }
811
- }
812
- }
813
-
814
- // ── HOOK 3: LOYALTY TIER UPGRADE PATH ────────────
815
- let bookingCount = 0;
816
- events.forEach(e => { if (e.event === "booking completed") bookingCount++; });
817
- if (bookingCount >= 5) {
818
- events.forEach(e => {
819
- if (e.event === "booking completed" && e.loyalty_points) {
820
- e.loyalty_points = Math.floor(e.loyalty_points * (2.5 + chance.floating({ min: 0, max: 1.0 })));
821
- }
822
- });
823
- }
824
-
825
- // ── HOOK 4: CANCELLATION BY BOOKING WINDOW ───────
826
- // Match each cancellation to its nearest preceding booking
827
- // and copy the booking's booking_window. This replaces the
828
- // random booking_window that the event config assigns to
829
- // cancellation events independently.
830
- const bookingsByTime = events
831
- .filter(e => e.event === "booking completed")
832
- .sort((a, b) => new Date(a.time) - new Date(b.time));
833
- events.forEach(e => {
834
- if (e.event === "booking cancelled" && bookingsByTime.length > 0) {
835
- const cancelTime = new Date(e.time).getTime();
836
- // Find the nearest preceding booking (or the first one)
837
- let matched = bookingsByTime[0];
838
- for (let b = bookingsByTime.length - 1; b >= 0; b--) {
839
- if (new Date(bookingsByTime[b].time).getTime() <= cancelTime) {
840
- matched = bookingsByTime[b];
841
- break;
842
- }
843
- }
844
- e.booking_window = matched.booking_window;
845
- }
846
- });
847
-
848
- // Last-minute bookers rarely cancel — drop 60%.
849
- for (let i = events.length - 1; i >= 0; i--) {
850
- if (events[i].event === "booking cancelled" && events[i].booking_window === "last_minute") {
851
- if (chance.bool({ likelihood: 60 })) {
852
- events.splice(i, 1);
853
- }
854
- }
855
- }
856
-
857
- // ── HOOK 5: UPSELL SUCCESS BY SEGMENT ────────────
858
- if (profile && profile.customer_segment === "luxury_seeker") {
859
- const templateUpgrade = events.find(e => e.event === "room upgrade selected");
860
- if (templateUpgrade) {
861
- const bookings = events.filter(e => e.event === "booking completed");
862
- bookings.forEach(booking => {
863
- if (chance.bool({ likelihood: 50 })) {
864
- events.push({
865
- ...templateUpgrade,
866
- time: dayjs(booking.time).add(chance.integer({ min: 1, max: 30 }), "minutes").toISOString(),
867
- user_id: booking.user_id,
868
- upgrade_cost: chance.integer({ min: 75, max: 200 }),
869
- });
870
- }
871
- });
872
- }
873
- }
874
-
875
- // HOOK 6: REVIEW QUALITY BY STAY RATING — high avg ratings get
876
- // review_length 1.5x; low avg ratings get 0.5x. Mutates raw prop.
877
- let totalRating = 0;
878
- let ratingCount = 0;
879
- events.forEach(e => {
880
- if (e.event === "review submitted" && e.stay_rating) {
881
- totalRating += e.stay_rating;
882
- ratingCount++;
883
- }
884
- });
885
- const avgRating = ratingCount > 0 ? totalRating / ratingCount : 3;
886
- events.forEach(e => {
887
- if (e.event === "review submitted") {
888
- if (avgRating >= 4) {
889
- e.review_length = Math.floor((e.review_length || 120) * 1.5);
890
- } else if (avgRating <= 2) {
891
- e.review_length = Math.floor((e.review_length || 120) * 0.5);
892
- }
893
- }
894
- });
895
-
896
- // HOOK 10: HOTEL-VIEWED MAGIC NUMBER (in-funnel, no flags)
897
- // Sweet 5-10 hotel-viewed events → +30% on booking nightly_rate
898
- // (decisive comparison shoppers book higher-tier rooms).
899
- // Over 11+ → drop 35% of booking-completed events (analysis
900
- // paralysis blocks conversion).
901
- const hotelViews = events.filter(e => e.event === "hotel viewed").length;
902
- if (hotelViews >= 5 && hotelViews <= 10) {
903
- events.forEach(e => {
904
- if (e.event === "booking completed" && typeof e.nightly_rate === "number") {
905
- e.nightly_rate = Math.round(e.nightly_rate * 1.3);
906
- }
907
- });
908
- } else if (hotelViews >= 11) {
909
- for (let i = events.length - 1; i >= 0; i--) {
910
- if (events[i].event === "booking completed" && chance.bool({ likelihood: 35 })) {
911
- events.splice(i, 1);
912
- }
913
- }
914
- }
915
-
916
- return record;
917
- }
918
-
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);
919
926
  return record;
920
927
  },
921
928
  };