@ak--47/dungeon-master 1.5.4 → 1.6.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 (149) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +21 -12
  2. package/.claude/skills/create-dungeon/SKILL.md +65 -5
  3. package/.claude/skills/create-project/SKILL.md +1 -1
  4. package/.claude/skills/create-project/provision.mjs +57 -10
  5. package/.claude/skills/powertools/SKILL.md +75 -0
  6. package/.claude/skills/powertools/pt.mjs +91 -0
  7. package/.claude/skills/powertools/snapshot-project.mjs +124 -0
  8. package/.claude/skills/verify-dungeon/SKILL.md +56 -25
  9. package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
  10. package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
  11. package/.claude/skills/write-hooks/SKILL.md +156 -18
  12. package/CHANGELOG.md +224 -0
  13. package/HOOKS.md +566 -112
  14. package/README.md +30 -9
  15. package/docs/guides/1.3.0-upgrade-guide.md +262 -0
  16. package/docs/guides/1.3.1-upgrade-guide.md +328 -0
  17. package/docs/guides/1.4.0-upgrade-guide.md +130 -0
  18. package/docs/guides/1.4.1-upgrade-guide.md +125 -0
  19. package/docs/guides/1.4.3-upgrade-guide.md +200 -0
  20. package/docs/guides/1.4.4-upgrade-guide.md +95 -0
  21. package/docs/guides/1.5.0-upgrade-guide.md +426 -0
  22. package/docs/guides/1.5.1-upgrade-guide.md +178 -0
  23. package/docs/guides/1.5.3-upgrade-guide.md +117 -0
  24. package/docs/guides/1.6.0-upgrade-guide.md +204 -0
  25. package/dungeons/technical/stories-verify.js +152 -0
  26. package/dungeons/vertical/README.md +154 -0
  27. package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
  28. package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
  29. package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
  30. package/dungeons/vertical/community/community.js +1589 -0
  31. package/dungeons/vertical/community/community.sql +216 -0
  32. package/dungeons/vertical/community/community.verify.mjs +72 -0
  33. package/dungeons/vertical/crypto/crypto.js +1483 -0
  34. package/dungeons/vertical/crypto/crypto.sql +194 -0
  35. package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
  36. package/dungeons/vertical/dating/dating.js +1540 -0
  37. package/dungeons/vertical/dating/dating.sql +288 -0
  38. package/dungeons/vertical/dating/dating.verify.mjs +72 -0
  39. package/dungeons/vertical/devtools/devtools.js +1524 -0
  40. package/dungeons/vertical/devtools/devtools.sql +161 -0
  41. package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
  42. package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
  43. package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
  44. package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
  45. package/dungeons/vertical/education/education.js +1763 -0
  46. package/dungeons/vertical/education/education.sql +243 -0
  47. package/dungeons/vertical/education/education.verify.mjs +75 -0
  48. package/dungeons/vertical/fintech/fintech.js +1410 -0
  49. package/dungeons/vertical/fintech/fintech.sql +175 -0
  50. package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
  51. package/dungeons/vertical/fitness/fitness.js +1495 -0
  52. package/dungeons/vertical/fitness/fitness.sql +206 -0
  53. package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
  54. package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
  55. package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
  56. package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
  57. package/dungeons/vertical/gaming/gaming.js +1741 -0
  58. package/dungeons/vertical/gaming/gaming.sql +249 -0
  59. package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
  60. package/dungeons/vertical/healthcare/healthcare.js +1488 -0
  61. package/dungeons/vertical/healthcare/healthcare.sql +193 -0
  62. package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
  63. package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
  64. package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
  65. package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
  66. package/dungeons/vertical/logistics/logistics.js +1541 -0
  67. package/dungeons/vertical/logistics/logistics.sql +205 -0
  68. package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
  69. package/dungeons/vertical/marketplace/marketplace.js +1438 -0
  70. package/dungeons/vertical/marketplace/marketplace.sql +148 -0
  71. package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
  72. package/dungeons/vertical/{media.js → media/media.js} +557 -58
  73. package/dungeons/vertical/media/media.sql +173 -0
  74. package/dungeons/vertical/media/media.verify.mjs +74 -0
  75. package/dungeons/vertical/real-estate/real-estate.js +1629 -0
  76. package/dungeons/vertical/real-estate/real-estate.sql +167 -0
  77. package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
  78. package/dungeons/vertical/sass/sass.js +1733 -0
  79. package/dungeons/vertical/sass/sass.sql +173 -0
  80. package/dungeons/vertical/sass/sass.verify.mjs +74 -0
  81. package/dungeons/vertical/social/social.js +1456 -0
  82. package/dungeons/vertical/social/social.sql +160 -0
  83. package/dungeons/vertical/social/social.verify.mjs +76 -0
  84. package/dungeons/vertical/streaming/streaming.js +677 -0
  85. package/dungeons/vertical/streaming/streaming.sql +101 -0
  86. package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
  87. package/dungeons/vertical/support-desk/support-desk.js +741 -0
  88. package/dungeons/vertical/support-desk/support-desk.sql +137 -0
  89. package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
  90. package/dungeons/vertical/travel/travel.js +1603 -0
  91. package/dungeons/vertical/travel/travel.sql +236 -0
  92. package/dungeons/vertical/travel/travel.verify.mjs +72 -0
  93. package/index.js +7 -3
  94. package/lib/core/config-validator.js +37 -2
  95. package/lib/core/context.js +4 -2
  96. package/lib/core/dungeon-loader.js +20 -15
  97. package/lib/core/dungeon-to-json.js +22 -4
  98. package/lib/core/storage.js +3 -3
  99. package/lib/generators/funnels.js +36 -9
  100. package/lib/hook-helpers/cohort.js +53 -0
  101. package/lib/hook-helpers/index.js +8 -0
  102. package/lib/hook-helpers/mutate.js +1 -1
  103. package/lib/hook-helpers/shape.js +319 -0
  104. package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
  105. package/lib/hook-patterns/attributed-by-source.js +60 -58
  106. package/lib/hook-patterns/frequency-by-frequency.js +14 -4
  107. package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
  108. package/lib/hook-patterns/index.js +1 -1
  109. package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
  110. package/lib/orchestrators/user-loop.js +41 -0
  111. package/lib/templates/story-spec.schema.json +175 -0
  112. package/lib/utils/json-evaluator.js +1 -1
  113. package/lib/utils/utils.js +151 -59
  114. package/lib/verify/coerce.js +186 -0
  115. package/lib/verify/counting.js +270 -32
  116. package/lib/verify/emulate-breakdown.js +1285 -203
  117. package/lib/verify/first-time.js +89 -0
  118. package/lib/verify/flows.js +679 -0
  119. package/lib/verify/formula.js +259 -0
  120. package/lib/verify/funnel-engine.js +697 -113
  121. package/lib/verify/index.js +32 -4
  122. package/lib/verify/sessionize.js +275 -0
  123. package/lib/verify/story-runner.js +483 -0
  124. package/lib/verify/verify-dungeon.js +59 -37
  125. package/package.json +2 -1
  126. package/scripts/extract-dungeon-schema.mjs +2 -2
  127. package/scripts/run-many.mjs +2 -2
  128. package/scripts/verify-runner.mjs +2 -2
  129. package/scripts/verify-stories.mjs +244 -0
  130. package/types.d.ts +298 -30
  131. package/dungeons/vertical/ai-platform.js +0 -818
  132. package/dungeons/vertical/community.js +0 -966
  133. package/dungeons/vertical/crypto.js +0 -895
  134. package/dungeons/vertical/dating.js +0 -830
  135. package/dungeons/vertical/devtools.js +0 -1050
  136. package/dungeons/vertical/ecommerce.js +0 -811
  137. package/dungeons/vertical/education.js +0 -964
  138. package/dungeons/vertical/fintech.js +0 -946
  139. package/dungeons/vertical/fitness.js +0 -935
  140. package/dungeons/vertical/food-delivery.js +0 -934
  141. package/dungeons/vertical/gaming.js +0 -1120
  142. package/dungeons/vertical/healthcare.js +0 -945
  143. package/dungeons/vertical/insurance-application.js +0 -1039
  144. package/dungeons/vertical/logistics.js +0 -986
  145. package/dungeons/vertical/marketplace.js +0 -1019
  146. package/dungeons/vertical/real-estate.js +0 -872
  147. package/dungeons/vertical/sass.js +0 -995
  148. package/dungeons/vertical/social.js +0 -904
  149. package/dungeons/vertical/travel.js +0 -930
@@ -0,0 +1,1603 @@
1
+ // ── IMPORTS ──
2
+ import dayjs from "dayjs";
3
+ import utc from "dayjs/plugin/utc.js";
4
+ dayjs.extend(utc);
5
+ import "dotenv/config";
6
+ import * as u from "@ak--47/dungeon-master/utils";
7
+ import * as v from "ak-tools";
8
+ /** @typedef {import("../../../types").Dungeon} Config */
9
+
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, ~990K 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
40
+ */
41
+
42
+ // ── HOOK STORIES ──
43
+ /*
44
+ * NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable via
45
+ * raw-prop breakdowns (booking_window, day, segment) or behavioral cohorts.
46
+ *
47
+ * ───────────────────────────────────────────────────────────────
48
+ * 1. WEEKEND LEISURE SURGE (everything)
49
+ * ───────────────────────────────────────────────────────────────
50
+ * PATTERN: Weekend bookings (Fri-Sun) get 1.3x higher nightly_rate
51
+ * due to leisure demand pricing.
52
+ *
53
+ * HOW TO FIND IT IN MIXPANEL:
54
+ * Report 1: Weekend vs Weekday Rates
55
+ * • Report type: Insights
56
+ * • Event: "booking completed"
57
+ * • Measure: Average of "nightly_rate"
58
+ * • Breakdown: Day of week
59
+ * • Expected: Fri-Sun avg ≈ 1.3x Mon-Thu avg (both arms mix H2's
60
+ * window factors near-identically, so the day-of-week ratio reads
61
+ * the knob clean; "hotel viewed" carries the same nightly_rate
62
+ * prop untouched — the placebo arm)
63
+ *
64
+ * REAL-WORLD ANALOGUE: Hotels use dynamic pricing with higher
65
+ * weekend rates driven by leisure traveler demand.
66
+ *
67
+ * ───────────────────────────────────────────────────────────────
68
+ * 2. ADVANCE BOOKING DISCOUNT (everything)
69
+ * ───────────────────────────────────────────────────────────────
70
+ * PATTERN: Bookings made > 21 days before the dataset end get 0.8x
71
+ * nightly_rate and booking_window overwritten to "advance". Bookings
72
+ * < 3 days before the end get 1.4x and "last_minute". Bookings in the
73
+ * middle band (3-21 days out) KEEP their organic booking_window label
74
+ * (2/5 advance, 2/5 standard, 1/5 last_minute) with untouched rates —
75
+ * so a naive breakdown by booking_window dilutes both treated labels
76
+ * (roughly half of last_minute-LABELED bookings are organic
77
+ * middle-band ones). The clean read is by calendar region.
78
+ *
79
+ * HOW TO FIND IT IN MIXPANEL:
80
+ * Report 1: Rate by Booking Window (label view — diluted)
81
+ * • Report type: Insights
82
+ * • Event: "booking completed"
83
+ * • Measure: Average of "nightly_rate"
84
+ * • Breakdown: "booking_window"
85
+ * • Expected: last_minute vs advance ≈ 1.45x (label mix dilutes
86
+ * the raw 1.4/0.8 = 1.75x factor ratio)
87
+ * Report 2: Rate by calendar date (region view — clean)
88
+ * • Same measure, X-axis: day
89
+ * • Expected: advance region ≈ 0.79x the middle band; last-minute
90
+ * region (final 3 days) ≈ 1.35x the middle band (weekend-mix
91
+ * corrected — the last 3 days are Wed/Thu/Fri)
92
+ *
93
+ * REAL-WORLD ANALOGUE: Hotels offer early-bird discounts and charge
94
+ * premiums for last-minute availability.
95
+ *
96
+ * ───────────────────────────────────────────────────────────────
97
+ * 3. LOYALTY TIER UPGRADE PATH (everything)
98
+ * ───────────────────────────────────────────────────────────────
99
+ * PATTERN: Users with 5+ bookings get boosted loyalty_points on all
100
+ * booking events (3x the baseline).
101
+ *
102
+ * HOW TO FIND IT IN MIXPANEL:
103
+ * Report 1: Loyalty Points by Booking Frequency
104
+ * • Report type: Insights
105
+ * • Event: "booking completed"
106
+ * • Measure: Average of "loyalty_points"
107
+ * • Breakdown: user property "customer_segment"
108
+ * • Expected: power users ≈ 3x points vs casuals
109
+ *
110
+ * REAL-WORLD ANALOGUE: Hotel loyalty programs accelerate rewards
111
+ * for frequent guests, creating a flywheel.
112
+ *
113
+ * ───────────────────────────────────────────────────────────────
114
+ * 4. CANCELLATION BY BOOKING WINDOW (everything)
115
+ * ───────────────────────────────────────────────────────────────
116
+ * PATTERN: Each "booking cancelled" is stamped with the booking_window
117
+ * of the user's nearest PRECEDING "booking completed"; cancellations
118
+ * whose label lands on "last_minute" then have 60% dropped — committed
119
+ * last-minute bookers rarely cancel. Advance/standard-labeled
120
+ * cancellations are all kept.
121
+ *
122
+ * HOW TO FIND IT IN MIXPANEL:
123
+ * Report 1: Cancellation Rate by Window
124
+ * • Report type: Funnels
125
+ * • Steps: "booking completed" → "booking cancelled"
126
+ * • Breakdown: "booking_window"
127
+ * • Expected: last_minute ~8% cancel vs advance ~20% cancel
128
+ *
129
+ * REAL-WORLD ANALOGUE: Last-minute bookers are committed;
130
+ * advance bookers have more flexible cancellation policies.
131
+ *
132
+ * ───────────────────────────────────────────────────────────────
133
+ * 5. UPSELL SUCCESS BY SEGMENT (everything)
134
+ * ───────────────────────────────────────────────────────────────
135
+ * PATTERN: After a booking, luxury_seeker users get cloned "room
136
+ * upgrade selected" events injected. Budget users rarely see upsells.
137
+ *
138
+ * HOW TO FIND IT IN MIXPANEL:
139
+ * Report 1: Upgrade Rate by Segment
140
+ * • Report type: Insights
141
+ * • Event: "room upgrade selected"
142
+ * • Measure: Total per user
143
+ * • Breakdown: user property "customer_segment"
144
+ * • Expected: luxury_seeker ≈ 12x upgrades vs budget_hunter — the
145
+ * organic upsell base is thin (~0.08 upgrades per booking), so the
146
+ * 50%-per-booking clone injection dominates; the clean knob read is
147
+ * the upgrades-per-booking DIFFERENCE (≈ +0.5), not the ratio
148
+ *
149
+ * REAL-WORLD ANALOGUE: Luxury travelers are receptive to premium
150
+ * upsells (suite upgrades, spa packages).
151
+ *
152
+ * ───────────────────────────────────────────────────────────────
153
+ * 6. REVIEW QUALITY BY STAY RATING (everything)
154
+ * ───────────────────────────────────────────────────────────────
155
+ * PATTERN: Users whose avg stay_rating is >= 4 have longer review_length
156
+ * (1.5x words). Low-rating users write shorter, negative reviews.
157
+ *
158
+ * HOW TO FIND IT IN MIXPANEL:
159
+ * Report 1: Review Length vs Stay Rating
160
+ * • Report type: Insights
161
+ * • Event: "review submitted"
162
+ * • Measure: Average of "review_length"
163
+ * • Breakdown: "stay_rating"
164
+ * • Expected: users with avg rating >= 4 write ≈ 1.5x the baseline
165
+ * length, avg rating <= 2 ≈ 0.5x (review_length pool mean ≈ 160
166
+ * words → high arm ≈ 240, low arm ≈ 80; mid arm untouched)
167
+ *
168
+ * REAL-WORLD ANALOGUE: Satisfied guests write detailed positive
169
+ * reviews; dissatisfied guests write brief complaints.
170
+ *
171
+ * ───────────────────────────────────────────────────────────────
172
+ * 7. BUSINESS TRAVELER PROFILE (user)
173
+ * ───────────────────────────────────────────────────────────────
174
+ * PATTERN: Users in business_traveler segment get company_name set
175
+ * to a realistic company and travel_frequency to "weekly".
176
+ *
177
+ * HOW TO FIND IT IN MIXPANEL:
178
+ * Report 1: Travel Frequency by Segment
179
+ * • Report type: Insights
180
+ * • Measure: Profiles → Breakdown by "travel_frequency"
181
+ * • Filter: customer_segment = "business_traveler"
182
+ * • Expected: 100% weekly for business, mixed for others
183
+ *
184
+ * REAL-WORLD ANALOGUE: Business travelers have corporate accounts
185
+ * with consistent, high-frequency booking patterns.
186
+ *
187
+ * ───────────────────────────────────────────────────────────────
188
+ * 8. REPEAT DESTINATION CLUSTERING (everything — event filtering)
189
+ * ───────────────────────────────────────────────────────────────
190
+ * PATTERN: Non-business/luxury users have ~25% of "booking completed"
191
+ * events dropped, simulating lower funnel conversion for casual segments.
192
+ *
193
+ * HOW TO FIND IT IN MIXPANEL:
194
+ * Report 1: Conversion by Segment
195
+ * • Report type: Funnels
196
+ * • Steps: "destination searched" → "hotel viewed" → "booking completed"
197
+ * • Breakdown: user property "customer_segment"
198
+ * • Expected: business_traveler ≈ 52% vs budget_hunter ≈ 30%
199
+ *
200
+ * REAL-WORLD ANALOGUE: Business travelers book the same hotels
201
+ * repeatedly, leading to faster, more confident conversions.
202
+ *
203
+ * ───────────────────────────────────────────────────────────────
204
+ * 9. BOOKING TIME-TO-CONVERT (funnel-post)
205
+ * ───────────────────────────────────────────────────────────────
206
+ *
207
+ * PATTERN: Business travelers complete the Search-to-Book funnel
208
+ * 1.35x faster (time gaps scaled by 0.74). Budget and leisure-family
209
+ * users complete it 1.25x slower (gaps scaled by 1.25). The hook
210
+ * iterates over the funnel-post event array, compresses or stretches
211
+ * the inter-step time gaps based on the user's customer_segment from
212
+ * meta.profile, then rewrites each event's timestamp. Scoped to the
213
+ * "Search to Book" funnel only (v1.6) — v1.5 stretched every funnel,
214
+ * leaking an undocumented segment-speed pattern into the other four.
215
+ *
216
+ * HOW TO FIND IT IN MIXPANEL:
217
+ *
218
+ * Report 1: Search-to-Book TTC by Segment
219
+ * - Report type: Funnels
220
+ * - Steps: "destination searched" -> "hotel viewed" -> "price compared" -> "booking completed"
221
+ * - Breakdown: user property "customer_segment"
222
+ * - Metric: Median time to convert
223
+ * - Expected: business_traveler median TTC ~ 0.74x of budget/leisure TTC
224
+ * (e.g., business ~ 24h vs budget ~ 43h)
225
+ *
226
+ * NOTE: Cross-event MIN->MIN SQL on raw events does NOT show this —
227
+ * greedy pairing crosses funnel instances and buries the signal. The
228
+ * story asserts the delta through the Mixpanel-aligned emulator
229
+ * (timeToConvert) at a 60h conversion window: 48h generative window
230
+ * x the 1.25 max stretch, covering the stretched support so the
231
+ * slow arm is not censored into a fake speedup.
232
+ *
233
+ * REAL-WORLD ANALOGUE: Business travelers know their preferred hotel
234
+ * chains and corporate rates, moving from search to booking with
235
+ * minimal comparison. Leisure and budget travelers deliberate longer,
236
+ * comparing options and waiting for deals.
237
+ *
238
+ * ───────────────────────────────────────────────────────────────
239
+ * 10. HOTEL-VIEWED MAGIC NUMBER (everything)
240
+ * ───────────────────────────────────────────────────────────────
241
+ *
242
+ * PATTERN: Users who viewed 5-10 hotels sit in a "decisive comparison
243
+ * shopper" sweet spot -- all their nightly_rate values on "booking
244
+ * completed" events are boosted by +30% (factor 1.3), indicating they
245
+ * chose higher-tier rooms after deliberate comparison. Users who
246
+ * viewed 11+ hotels hit analysis-paralysis burnout from day 60
247
+ * (HOTEL_FATIGUE_START_DAY, 2026-03-02): 35% of their "booking
248
+ * completed" events on/after that day are dropped. No flag is
249
+ * stamped -- discoverable only by binning users on hotel-viewed
250
+ * COUNT and comparing booking rates or before/after booking volume.
251
+ *
252
+ * HOW TO FIND IT IN MIXPANEL:
253
+ *
254
+ * Report 1: Nightly Rate by Hotel-View Cohort
255
+ * - Report type: Insights (with cohorts)
256
+ * - Cohort A: users who did "hotel viewed" 5-10 times
257
+ * - Cohort B: users who did "hotel viewed" 0-4 times
258
+ * - Event: "booking completed"
259
+ * - Measure: Average of "nightly_rate"
260
+ * - Compare cohort A vs cohort B
261
+ * - Expected: cohort A ~ 1.3x higher avg nightly_rate
262
+ *
263
+ * Report 2: Booking Volume Collapse After Day 60
264
+ * - Report type: Insights (with cohorts)
265
+ * - Cohort C: users who did "hotel viewed" 11+ times
266
+ * - Cohort A: users who did "hotel viewed" 5-10 times
267
+ * - Event: "booking completed", normalized by "app session"
268
+ * - X-axis: month; compare each cohort's own after-Mar-2 /
269
+ * before-Mar-2 bookings-per-session ratio
270
+ * - Expected: cohort C's ratio ≈ 0.65x cohort A's ratio (the keep
271
+ * rate). A straight cohort-C-vs-A level comparison does NOT work:
272
+ * view count is activity-coupled, so heavy viewers book more per
273
+ * capita organically whatever the denominator — only the
274
+ * difference-in-differences isolates the drop.
275
+ *
276
+ * REAL-WORLD ANALOGUE: Travelers who compare a handful of hotels
277
+ * make confident, higher-value bookings; those who endlessly browse
278
+ * suffer decision fatigue and often abandon the search entirely.
279
+ *
280
+ * ═══════════════════════════════════════════════════════════════
281
+ * EXPECTED METRICS SUMMARY (Measured = full fidelity, 10K users / 991,659 events)
282
+ * ═══════════════════════════════════════════════════════════════
283
+ *
284
+ * Story id | Metric | Expected | Measured
285
+ * ────────────────────────|───────────────────────────────────────────|───────────────|─────────
286
+ * H1-weekend-rate-surge | wkn/wkd booking nightly_rate ratio | ≈1.3 | 1.293
287
+ * | placebo: hotel viewed wkn/wkd ratio | ≈1.0 | 1.001
288
+ * H2-booking-window | advance-region / middle-band rate | ≈0.79 | 0.784
289
+ * | last-minute-region / middle-band rate | ≈1.35 | 1.408
290
+ * H3-loyalty-boost | 5+-booking / 1-4-booking loyalty_points | ≈3.0 | 2.942
291
+ * H4-cancel-by-window | last_minute cancel keep vs organic mix | ≈0.4 | 0.484
292
+ * | placebo: advance/standard cancel ratio | ≈1.0 | 0.892
293
+ * H5-luxury-upsell | lux − budget upgrades-per-booking | ≈+0.5 | +0.496
294
+ * | lux / budget upgrades-per-user | ≈12x | 12.68x
295
+ * H6-review-quality | high-avg / mid review_length | ≈1.5 | 1.497
296
+ * | low-avg / mid review_length | ≈0.5 | 0.510
297
+ * H7-business-profile | biz weekly + company share | 100% | 100%
298
+ * H8-casual-booking-drop | zero-booking inflation (treated−control) | ≈+0.25 | +0.241
299
+ * | funnel conversion biz/budget | ≈1.9x | 2.46x
300
+ * H9-booking-ttc | Search-to-Book median TTC biz/lux | ≈0.74 | 0.900
301
+ * | Search-to-Book median TTC budget/lux | ≈1.25 | 1.061
302
+ * H10-hotel-view-magic | sweet/low nightly_rate (advance region) | ≈1.3 | 1.311
303
+ * | DiD bookings-per-session over vs sweet | ≈0.65 | 0.613
304
+ */
305
+
306
+ // ── SCALE ──
307
+ const SEED = "dm4-travel";
308
+ const NUM_USERS = 10_000;
309
+ const DATASET_START = "2026-01-01T00:00:00Z";
310
+ const DATASET_END = "2026-05-01T23:59:59Z";
311
+ const EVENTS_PER_DAY = 1.2;
312
+ const token = process.env.MP_TOKEN || "your-mixpanel-token";
313
+
314
+ const chance = u.initChance(SEED);
315
+
316
+ // ── KNOBS (tweak these to reshape stories) ──
317
+ const WEEKEND_RATE_BOOST = 1.3;
318
+ const ADVANCE_DAYS_THRESHOLD = 21;
319
+ const ADVANCE_RATE_FACTOR = 0.8;
320
+ const LAST_MINUTE_DAYS_THRESHOLD = 3;
321
+ const LAST_MINUTE_RATE_FACTOR = 1.4;
322
+ const LOYALTY_BOOKING_THRESHOLD = 5;
323
+ const LOYALTY_POINT_BASE_MULT = 2.5;
324
+ const LOYALTY_POINT_VARIANCE = 1.0;
325
+ const REPEAT_DEST_DROP_LIKELIHOOD = 25;
326
+ const CANCEL_LAST_MINUTE_DROP_LIKELIHOOD = 60;
327
+ const UPSELL_LUXURY_LIKELIHOOD = 50;
328
+ const REVIEW_HIGH_RATING_THRESHOLD = 4;
329
+ const REVIEW_LOW_RATING_THRESHOLD = 2;
330
+ const REVIEW_HIGH_LENGTH_MULT = 1.5;
331
+ const REVIEW_LOW_LENGTH_MULT = 0.5;
332
+ const HOTEL_SWEET_MIN = 5;
333
+ const HOTEL_SWEET_MAX = 10;
334
+ const HOTEL_OVER_THRESHOLD = 11;
335
+ const HOTEL_SWEET_RATE_BOOST = 1.3;
336
+ const HOTEL_OVER_BOOKING_DROP_LIKELIHOOD = 35;
337
+ // H10's booking drop is CALENDAR-SCOPED (bookings on/after this dataset day).
338
+ // Hotel-view count is intrinsically activity-coupled — more views means more
339
+ // of everything — so no cross-sectional denominator can separate a 35%
340
+ // booking drop from organic activity composition (community.js proved this
341
+ // confound class). An in-window calendar edge turns the read into a
342
+ // difference-in-differences: each arm's own after/before bookings-per-session
343
+ // ratio cancels its activity composition.
344
+ const HOTEL_FATIGUE_START_DAY = 60;
345
+ const TTC_BUSINESS_FACTOR = 0.74;
346
+ const TTC_LEISURE_BUDGET_FACTOR = 1.25;
347
+
348
+ // ── DATA ARRAYS ──
349
+ const hotelIds = v.range(1, 200).map(() => `HTL_${v.uid(6)}`);
350
+ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona", "Dubai", "Sydney", "Rome", "Bangkok", "Cancun", "Bali", "Amsterdam", "Miami", "Singapore", "Lisbon"];
351
+
352
+ // ── HELPER FUNCTIONS ──
353
+ function handleUserHooks(record) {
354
+ // H7: BUSINESS TRAVELER PROFILE
355
+ if (record.customer_segment === "business_traveler") {
356
+ record.company_name = chance.pickone(["Acme Corp", "GlobalTech", "Initech", "Prestige Consulting", "Summit Partners", "Atlas Industries"]);
357
+ record.travel_frequency = "weekly";
358
+ } else if (record.customer_segment === "luxury_seeker") {
359
+ record.avg_budget_per_night = chance.integer({ min: 250, max: 500 });
360
+ } else if (record.customer_segment === "budget_hunter") {
361
+ record.avg_budget_per_night = chance.integer({ min: 50, max: 120 });
362
+ }
363
+ return record;
364
+ }
365
+
366
+ function handleFunnelPostHooks(record, meta) {
367
+ // H9: BOOKING TIME-TO-CONVERT — business 1.35x faster (0.74),
368
+ // leisure_family/budget 1.25x slower. Scoped to the Search to Book funnel
369
+ // only: the documented story (and the Mixpanel report it teaches) is
370
+ // Search-to-Book median TTC — stretching every funnel leaked an
371
+ // undocumented segment-speed pattern into Onboarding/Full Stay/Loyalty/
372
+ // Upsell and polluted their TTC distributions.
373
+ if (meta?.funnel?.name !== "Search to Book") return record;
374
+ const segment = meta?.profile?.customer_segment;
375
+ if (Array.isArray(record) && record.length > 1) {
376
+ const factor = (
377
+ segment === "business_traveler" ? TTC_BUSINESS_FACTOR :
378
+ segment === "budget_hunter" || segment === "leisure_family" ? TTC_LEISURE_BUDGET_FACTOR :
379
+ 1.0
380
+ );
381
+ if (factor !== 1.0) {
382
+ for (let i = 1; i < record.length; i++) {
383
+ const prev = dayjs(record[i - 1].time);
384
+ const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
385
+ record[i].time = prev.add(newGap, "milliseconds").toISOString();
386
+ }
387
+ }
388
+ }
389
+ return record;
390
+ }
391
+
392
+ function handleEverythingHooks(record, meta) {
393
+ const events = record;
394
+ if (!events.length) return record;
395
+
396
+ const profile = meta.profile;
397
+
398
+ // Stamp superProps from profile (consistent per user)
399
+ const stampPlatform = profile && profile.Platform ? profile.Platform : undefined;
400
+ const stampTier = profile && profile.membership_tier ? profile.membership_tier : undefined;
401
+ if (stampPlatform || stampTier) {
402
+ events.forEach(e => {
403
+ if (stampPlatform) e.Platform = stampPlatform;
404
+ if (stampTier) e.membership_tier = stampTier;
405
+ });
406
+ }
407
+
408
+ // H1: WEEKEND LEISURE SURGE — Fri/Sat/Sun bookings get +30% rate
409
+ events.forEach(e => {
410
+ if (e.event === "booking completed") {
411
+ const dayOfWeek = new Date(e.time).getUTCDay();
412
+ // Friday=5, Saturday=6, Sunday=0
413
+ if (dayOfWeek === 0 || dayOfWeek === 5 || dayOfWeek === 6) {
414
+ e.nightly_rate = Math.floor((e.nightly_rate || 150) * WEEKEND_RATE_BOOST);
415
+ e.total_cost = Math.floor((e.total_cost || 450) * WEEKEND_RATE_BOOST);
416
+ }
417
+ }
418
+ });
419
+
420
+ // H2: ADVANCE BOOKING DISCOUNT
421
+ const datasetEndForBooking = dayjs.unix(meta.datasetEnd);
422
+ events.forEach(e => {
423
+ if (e.event === "booking completed") {
424
+ const eventTime = dayjs(e.time);
425
+ const daysUntilEnd = datasetEndForBooking.diff(eventTime, "days");
426
+ if (daysUntilEnd > ADVANCE_DAYS_THRESHOLD) {
427
+ e.booking_window = "advance";
428
+ e.nightly_rate = Math.floor((e.nightly_rate || 150) * ADVANCE_RATE_FACTOR);
429
+ } else if (daysUntilEnd < LAST_MINUTE_DAYS_THRESHOLD) {
430
+ e.booking_window = "last_minute";
431
+ e.nightly_rate = Math.floor((e.nightly_rate || 150) * LAST_MINUTE_RATE_FACTOR);
432
+ }
433
+ }
434
+ });
435
+
436
+ // H8: REPEAT DESTINATION CLUSTERING — drop ~25% of "booking completed"
437
+ // for non-business/luxury users.
438
+ const segment = profile && profile.customer_segment;
439
+ if (segment !== "business_traveler" && segment !== "luxury_seeker"
440
+ && chance.bool({ likelihood: REPEAT_DEST_DROP_LIKELIHOOD })) {
441
+ for (let i = events.length - 1; i >= 0; i--) {
442
+ if (events[i].event === "booking completed") {
443
+ events.splice(i, 1);
444
+ }
445
+ }
446
+ }
447
+
448
+ // H3: LOYALTY TIER UPGRADE PATH — 5+ bookings → ~3x loyalty_points
449
+ let bookingCount = 0;
450
+ events.forEach(e => { if (e.event === "booking completed") bookingCount++; });
451
+ if (bookingCount >= LOYALTY_BOOKING_THRESHOLD) {
452
+ events.forEach(e => {
453
+ if (e.event === "booking completed" && e.loyalty_points) {
454
+ e.loyalty_points = Math.floor(e.loyalty_points * (LOYALTY_POINT_BASE_MULT + chance.floating({ min: 0, max: LOYALTY_POINT_VARIANCE })));
455
+ }
456
+ });
457
+ }
458
+
459
+ // H4: CANCELLATION BY BOOKING WINDOW — copy nearest preceding booking's
460
+ // booking_window onto each cancellation, then drop 60% of last-minute cancels.
461
+ const bookingsByTime = events
462
+ .filter(e => e.event === "booking completed")
463
+ .sort((a, b) => new Date(a.time) - new Date(b.time));
464
+ events.forEach(e => {
465
+ if (e.event === "booking cancelled" && bookingsByTime.length > 0) {
466
+ const cancelTime = new Date(e.time).getTime();
467
+ let matched = bookingsByTime[0];
468
+ for (let b = bookingsByTime.length - 1; b >= 0; b--) {
469
+ if (new Date(bookingsByTime[b].time).getTime() <= cancelTime) {
470
+ matched = bookingsByTime[b];
471
+ break;
472
+ }
473
+ }
474
+ e.booking_window = matched.booking_window;
475
+ }
476
+ });
477
+ for (let i = events.length - 1; i >= 0; i--) {
478
+ if (events[i].event === "booking cancelled" && events[i].booking_window === "last_minute") {
479
+ if (chance.bool({ likelihood: CANCEL_LAST_MINUTE_DROP_LIKELIHOOD })) {
480
+ events.splice(i, 1);
481
+ }
482
+ }
483
+ }
484
+
485
+ // H5: UPSELL SUCCESS BY SEGMENT — luxury_seeker users get cloned upgrades
486
+ if (profile && profile.customer_segment === "luxury_seeker") {
487
+ const templateUpgrade = events.find(e => e.event === "room upgrade selected");
488
+ if (templateUpgrade) {
489
+ const bookings = events.filter(e => e.event === "booking completed");
490
+ bookings.forEach(booking => {
491
+ if (chance.bool({ likelihood: UPSELL_LUXURY_LIKELIHOOD })) {
492
+ events.push({
493
+ ...templateUpgrade,
494
+ time: dayjs(booking.time).add(chance.integer({ min: 1, max: 30 }), "minutes").toISOString(),
495
+ user_id: booking.user_id,
496
+ upgrade_cost: chance.integer({ min: 75, max: 200 }),
497
+ insert_id: chance.guid(),
498
+ });
499
+ }
500
+ });
501
+ }
502
+ }
503
+
504
+ // H6: REVIEW QUALITY BY STAY RATING — high avg → 1.5x review_length;
505
+ // low avg → 0.5x.
506
+ let totalRating = 0;
507
+ let ratingCount = 0;
508
+ events.forEach(e => {
509
+ if (e.event === "review submitted" && e.stay_rating) {
510
+ totalRating += e.stay_rating;
511
+ ratingCount++;
512
+ }
513
+ });
514
+ const avgRating = ratingCount > 0 ? totalRating / ratingCount : 3;
515
+ events.forEach(e => {
516
+ if (e.event === "review submitted") {
517
+ if (avgRating >= REVIEW_HIGH_RATING_THRESHOLD) {
518
+ e.review_length = Math.floor((e.review_length || 120) * REVIEW_HIGH_LENGTH_MULT);
519
+ } else if (avgRating <= REVIEW_LOW_RATING_THRESHOLD) {
520
+ e.review_length = Math.floor((e.review_length || 120) * REVIEW_LOW_LENGTH_MULT);
521
+ }
522
+ }
523
+ });
524
+
525
+ // H10: HOTEL-VIEWED MAGIC NUMBER — sweet 5-10 → +30% nightly_rate;
526
+ // over 11+ → analysis-paralysis burnout: 35% of bookings on/after
527
+ // day HOTEL_FATIGUE_START_DAY dropped. The calendar scope (vs the v1.5
528
+ // uniform drop) is what makes the volume story measurable — see the
529
+ // knob comment on HOTEL_FATIGUE_START_DAY.
530
+ const hotelViews = events.filter(e => e.event === "hotel viewed").length;
531
+ if (hotelViews >= HOTEL_SWEET_MIN && hotelViews <= HOTEL_SWEET_MAX) {
532
+ events.forEach(e => {
533
+ if (e.event === "booking completed" && typeof e.nightly_rate === "number") {
534
+ e.nightly_rate = Math.round(e.nightly_rate * HOTEL_SWEET_RATE_BOOST);
535
+ }
536
+ });
537
+ } else if (hotelViews >= HOTEL_OVER_THRESHOLD) {
538
+ const fatigueCutoff = dayjs.unix(meta.datasetStart).add(HOTEL_FATIGUE_START_DAY, "days");
539
+ for (let i = events.length - 1; i >= 0; i--) {
540
+ if (events[i].event === "booking completed"
541
+ && !dayjs(events[i].time).isBefore(fatigueCutoff)
542
+ && chance.bool({ likelihood: HOTEL_OVER_BOOKING_DROP_LIKELIHOOD })) {
543
+ events.splice(i, 1);
544
+ }
545
+ }
546
+ }
547
+
548
+ return record;
549
+ }
550
+
551
+ // ── CONFIG ──
552
+ /** @type {Config} */
553
+ const config = {
554
+ version: 2,
555
+ seed: SEED,
556
+ datasetStart: DATASET_START,
557
+ datasetEnd: DATASET_END,
558
+ avgEventsPerUserPerDay: EVENTS_PER_DAY,
559
+ numUsers: NUM_USERS,
560
+ format: "json",
561
+ gzip: true,
562
+ credentials: {
563
+ token,
564
+ },
565
+ switches: {
566
+ hasSessionIds: true,
567
+ alsoInferFunnels: false,
568
+ hasLocation: true,
569
+ hasAndroidDevices: true,
570
+ hasIOSDevices: true,
571
+ hasDesktopDevices: true,
572
+ hasBrowser: false,
573
+ hasCampaigns: false,
574
+ isAnonymous: false,
575
+ hasAdSpend: false,
576
+ hasAvatar: true,
577
+ },
578
+ identity: {
579
+ avgDevicePerUser: 2,
580
+ },
581
+ concurrency: 1,
582
+ writeToDisk: false,
583
+ scdProps: {
584
+ membership_tier: {
585
+ values: ["member", "silver", "gold", "platinum"],
586
+ frequency: "month",
587
+ timing: "fuzzy",
588
+ max: 8
589
+ }
590
+ },
591
+ mirrorProps: {},
592
+ lookupTables: [],
593
+
594
+ events: [
595
+ {
596
+ event: "account created",
597
+ weight: 1,
598
+ isFirstEvent: true,
599
+ isAuthEvent: true,
600
+ properties: {
601
+ signup_source: ["organic", "google", "instagram", "tripadvisor", "referral", "email_campaign"],
602
+ },
603
+ },
604
+ {
605
+ event: "destination searched",
606
+ weight: 8,
607
+ properties: {
608
+ destination: chance.pickone.bind(chance, destinationCities),
609
+ check_in_days_out: u.weighNumRange(1, 90, 0.4, 14),
610
+ nights: u.weighNumRange(1, 14, 0.4, 3),
611
+ guests: u.weighNumRange(1, 6, 0.3, 2),
612
+ search_filters: ["price", "price", "rating", "location", "amenities", "pool", "breakfast"],
613
+ },
614
+ },
615
+ {
616
+ event: "hotel viewed",
617
+ weight: 7,
618
+ isStrictEvent: false,
619
+ properties: {
620
+ hotel_id: chance.pickone.bind(chance, hotelIds),
621
+ destination: chance.pickone.bind(chance, destinationCities),
622
+ star_rating: [3, 3, 4, 4, 4, 5],
623
+ nightly_rate: u.weighNumRange(50, 500, 0.3, 150),
624
+ has_pool: [true, true, false],
625
+ has_breakfast: [true, false],
626
+ photos_viewed: u.weighNumRange(0, 20, 0.5, 5),
627
+ },
628
+ },
629
+ {
630
+ event: "price compared",
631
+ weight: 4,
632
+ properties: {
633
+ hotels_compared: u.weighNumRange(2, 5),
634
+ price_difference_pct: u.weighNumRange(0, 40, 0.5, 10),
635
+ chose_cheapest: [true, true, true, false, false],
636
+ },
637
+ },
638
+ {
639
+ event: "booking completed",
640
+ weight: 3,
641
+ isStrictEvent: false,
642
+ properties: {
643
+ hotel_id: chance.pickone.bind(chance, hotelIds),
644
+ destination: chance.pickone.bind(chance, destinationCities),
645
+ nightly_rate: u.weighNumRange(50, 500, 0.3, 150),
646
+ total_cost: u.weighNumRange(100, 5000, 0.3, 450),
647
+ nights: u.weighNumRange(1, 14, 0.4, 3),
648
+ guests: u.weighNumRange(1, 6, 0.3, 2),
649
+ booking_window: ["advance", "advance", "standard", "standard", "last_minute"],
650
+ payment_method: ["credit_card", "credit_card", "debit", "paypal", "apple_pay"],
651
+ loyalty_points: u.weighNumRange(0, 500, 0.5, 100),
652
+ },
653
+ },
654
+ {
655
+ event: "room upgrade selected",
656
+ weight: 2,
657
+ isStrictEvent: false,
658
+ properties: {
659
+ upgrade_type: ["suite", "ocean_view", "executive_floor", "club_lounge", "premium_king"],
660
+ upgrade_cost: u.weighNumRange(25, 200, 0.4, 75),
661
+ hotel_id: chance.pickone.bind(chance, hotelIds),
662
+ },
663
+ },
664
+ {
665
+ event: "booking cancelled",
666
+ weight: 2,
667
+ properties: {
668
+ cancellation_reason: ["change_of_plans", "found_cheaper", "travel_restriction", "schedule_conflict", "weather"],
669
+ refund_amount: u.weighNumRange(0, 2000, 0.4, 200),
670
+ days_before_checkin: u.weighNumRange(0, 60, 0.5, 14),
671
+ booking_window: ["advance", "advance", "standard", "standard", "last_minute"],
672
+ },
673
+ },
674
+ {
675
+ event: "check in completed",
676
+ weight: 2,
677
+ properties: {
678
+ hotel_id: chance.pickone.bind(chance, hotelIds),
679
+ check_in_method: ["app", "app", "front_desk", "kiosk"],
680
+ early_checkin: [false, false, false, true],
681
+ },
682
+ },
683
+ {
684
+ event: "amenity used",
685
+ weight: 4,
686
+ properties: {
687
+ amenity_type: ["pool", "gym", "spa", "restaurant", "bar", "room_service", "business_center", "concierge"],
688
+ spend_amount: u.weighNumRange(0, 200, 0.4, 30),
689
+ },
690
+ },
691
+ {
692
+ event: "review submitted",
693
+ weight: 2,
694
+ isStrictEvent: false,
695
+ properties: {
696
+ hotel_id: chance.pickone.bind(chance, hotelIds),
697
+ // literal weighted integers, NOT weighNumRange: the v1.5 call
698
+ // weighNumRange(1, 5, 0.7, 4) built a 4-VALUE float pool whose
699
+ // contents decide whether the H6 high/low arms even exist (one
700
+ // observed pool was [2,2,3,5] — no avg>=4 user without an all-5
701
+ // draw). Explicit weights pin P(5)=.25 P(4)=.333 P(3)=.167
702
+ // P(2)=.167 P(1)=.083 → mean 3.5, both H6 arms populated.
703
+ stay_rating: [5, 5, 5, 4, 4, 4, 4, 3, 3, 2, 2, 1],
704
+ review_length: u.weighNumRange(20, 300, 0.5, 120),
705
+ would_return: [true, true, true, false],
706
+ },
707
+ },
708
+ {
709
+ event: "loyalty points redeemed",
710
+ weight: 1,
711
+ properties: {
712
+ points_used: u.weighNumRange(100, 5000, 0.4, 500),
713
+ redemption_type: ["room_discount", "free_night", "upgrade", "amenity_credit"],
714
+ },
715
+ },
716
+ {
717
+ event: "notification received",
718
+ weight: 5,
719
+ properties: {
720
+ notification_type: ["deal_alert", "deal_alert", "booking_confirmation", "checkin_reminder", "review_prompt", "loyalty_update"],
721
+ channel: ["push", "push", "email", "sms"],
722
+ opened: [true, true, true, false],
723
+ },
724
+ },
725
+ {
726
+ event: "wishlist updated",
727
+ weight: 3,
728
+ properties: {
729
+ action: ["added", "added", "added", "removed"],
730
+ destination: chance.pickone.bind(chance, destinationCities),
731
+ hotel_id: chance.pickone.bind(chance, hotelIds),
732
+ },
733
+ },
734
+ {
735
+ event: "support contacted",
736
+ weight: 1,
737
+ properties: {
738
+ issue_type: ["booking_change", "refund", "complaint", "question", "loyalty_inquiry"],
739
+ channel: ["chat", "chat", "phone", "email"],
740
+ resolution_time_hours: u.weighNumRange(0.5, 48, 0.3, 4),
741
+ },
742
+ },
743
+ {
744
+ event: "app session",
745
+ weight: 7,
746
+ properties: {
747
+ session_duration_sec: u.weighNumRange(15, 1200, 0.4, 180),
748
+ pages_viewed: u.weighNumRange(1, 20, 0.5, 4),
749
+ },
750
+ },
751
+ {
752
+ event: "price alert set",
753
+ weight: 2,
754
+ properties: {
755
+ destination: chance.pickone.bind(chance, destinationCities),
756
+ target_price: u.weighNumRange(50, 300, 0.5, 120),
757
+ alert_status: ["active", "active", "triggered", "expired"],
758
+ },
759
+ },
760
+ {
761
+ event: "account deactivated",
762
+ weight: 1,
763
+ isChurnEvent: true,
764
+ returnLikelihood: 0.2,
765
+ isStrictEvent: true,
766
+ properties: {
767
+ reason: ["found_alternative", "too_expensive", "poor_experience", "infrequent_travel", "privacy"],
768
+ },
769
+ },
770
+ ],
771
+
772
+ funnels: [
773
+ {
774
+ name: "Onboarding to First Booking",
775
+ sequence: ["account created", "destination searched", "hotel viewed", "booking completed"],
776
+ conversionRate: 35,
777
+ order: "sequential",
778
+ isFirstFunnel: true,
779
+ timeToConvert: 72,
780
+ weight: 3,
781
+ },
782
+ {
783
+ name: "Search to Book",
784
+ sequence: ["destination searched", "hotel viewed", "price compared", "booking completed"],
785
+ conversionRate: 25,
786
+ order: "sequential",
787
+ timeToConvert: 48,
788
+ weight: 5,
789
+ reentry: true,
790
+ },
791
+ {
792
+ name: "Full Stay Journey",
793
+ sequence: ["booking completed", "check in completed", "amenity used", "review submitted"],
794
+ conversionRate: 30,
795
+ order: "sequential",
796
+ timeToConvert: 336,
797
+ weight: 3,
798
+ },
799
+ {
800
+ name: "Loyalty Engagement",
801
+ sequence: ["booking completed", "loyalty points redeemed", "review submitted"],
802
+ conversionRate: 15,
803
+ order: "sequential",
804
+ timeToConvert: 720,
805
+ weight: 2,
806
+ },
807
+ {
808
+ name: "Upsell Path",
809
+ sequence: ["hotel viewed", "booking completed", "room upgrade selected"],
810
+ conversionRate: 20,
811
+ order: "sequential",
812
+ timeToConvert: 24,
813
+ weight: 2,
814
+ },
815
+ ],
816
+
817
+ superProps: {
818
+ Platform: ["ios", "android", "web", "web"],
819
+ membership_tier: ["standard", "standard", "standard", "gold", "platinum"],
820
+ },
821
+
822
+ userProps: {
823
+ customer_segment: ["leisure_family"],
824
+ travel_frequency: ["occasional", "occasional", "monthly", "weekly"],
825
+ company_name: ["none"],
826
+ preferred_destination: chance.pickone.bind(chance, destinationCities),
827
+ avg_budget_per_night: u.weighNumRange(50, 400, 0.4, 150),
828
+ Platform: ["ios", "android", "web", "web"],
829
+ membership_tier: ["standard", "standard", "standard", "gold", "platinum"],
830
+ },
831
+
832
+ personas: [
833
+ {
834
+ name: "business_traveler",
835
+ weight: 20,
836
+ eventMultiplier: 2.5,
837
+ conversionModifier: 1.3,
838
+ churnRate: 0.02,
839
+ properties: { customer_segment: "business_traveler", travel_frequency: "weekly" },
840
+ },
841
+ {
842
+ name: "leisure_family",
843
+ weight: 35,
844
+ eventMultiplier: 0.8,
845
+ conversionModifier: 0.9,
846
+ churnRate: 0.08,
847
+ properties: { customer_segment: "leisure_family", travel_frequency: "occasional" },
848
+ },
849
+ {
850
+ name: "luxury_seeker",
851
+ weight: 15,
852
+ eventMultiplier: 1.5,
853
+ conversionModifier: 1.2,
854
+ churnRate: 0.04,
855
+ properties: { customer_segment: "luxury_seeker", travel_frequency: "monthly" },
856
+ },
857
+ {
858
+ name: "budget_hunter",
859
+ weight: 30,
860
+ eventMultiplier: 1.0,
861
+ conversionModifier: 0.7,
862
+ churnRate: 0.10,
863
+ properties: { customer_segment: "budget_hunter", travel_frequency: "occasional" },
864
+ },
865
+ ],
866
+
867
+ worldEvents: [
868
+ {
869
+ name: "summer_sale",
870
+ type: "campaign",
871
+ startDay: 40,
872
+ duration: 5,
873
+ volumeMultiplier: 2.0,
874
+ conversionModifier: 1.5,
875
+ injectProps: { promo: "summer_sale" },
876
+ affectsEvents: ["booking completed", "hotel viewed", "destination searched"],
877
+ },
878
+ {
879
+ name: "hurricane_disruption",
880
+ type: "outage",
881
+ startDay: 65,
882
+ duration: 0.5,
883
+ volumeMultiplier: 0.2,
884
+ affectsEvents: ["booking completed", "check in completed"],
885
+ aftermath: { duration: 3, volumeMultiplier: 1.4 },
886
+ },
887
+ {
888
+ name: "loyalty_program_launch",
889
+ type: "product_launch",
890
+ startDay: 50,
891
+ duration: null,
892
+ injectProps: { loyalty_program: "active" },
893
+ affectsEvents: ["booking completed", "loyalty points redeemed"],
894
+ },
895
+ ],
896
+
897
+ engagementDecay: {
898
+ model: "exponential",
899
+ halfLife: 90,
900
+ floor: 0.2,
901
+ reactivationChance: 0.02,
902
+ },
903
+
904
+ hook(record, type, meta) {
905
+ if (type === "user") return handleUserHooks(record);
906
+ if (type === "funnel-post") return handleFunnelPostHooks(record, meta);
907
+ if (type === "everything") return handleEverythingHooks(record, meta);
908
+ return record;
909
+ },
910
+ };
911
+
912
+ export default config;
913
+
914
+ // ── STORIES (v1.6 machine-checkable contract — one story per numbered hook) ──
915
+ // Generate: node scripts/verify-runner.mjs dungeons/vertical/travel/travel.js verify-travel
916
+ // Evaluate: node scripts/verify-stories.mjs dungeons/vertical/travel/travel.js --data-prefix verify-travel
917
+ //
918
+ // Measurement doctrine for this dungeon:
919
+ // - Value hooks (H1/H2/H3/H6/H10-sweet) multiply iid per-event pool draws
920
+ // (nightly_rate, loyalty_points, review_length are drawn independent of
921
+ // user activity), so cross-arm MEAN ratios read the knobs directly. The
922
+ // only systematic confound is calendar composition — H2 scales
923
+ // nightly_rate by booking date — handled by scoping every rate read to a
924
+ // single calendar region where H2's factor is constant.
925
+ // - Bookings are only ever DELETED post-generation (H8 all-or-nothing per
926
+ // user, H10 per-event after day 60, the silent future-time guard on
927
+ // H9-stretched instances); hotel-viewed / searches / sessions / reviews /
928
+ // upgrades are never deleted. So view/search/session/review counts are
929
+ // EXACT hook-time recoveries, and booking-count cohorts are one-sided:
930
+ // output >= threshold implies hook-time >= threshold. Controls pin the
931
+ // other side with hv <= HOTEL_SWEET_MAX (H10 never drops for them) or
932
+ // pre-day-60 restrictions.
933
+ // - H10's volume read is a difference-in-differences: hotel-view count is
934
+ // intrinsically activity-coupled, so no cross-sectional denominator can
935
+ // recover a 35% drop (community.js proved this confound class). The
936
+ // calendar scope (day >= HOTEL_FATIGUE_START_DAY) lets each arm's own
937
+ // after/before bookings-per-session ratio cancel its activity
938
+ // composition; worldEvents (summer sale ×2, hurricane ×0.2) are
939
+ // arm-invariant volume scalers and cancel in the cross-arm DiD.
940
+
941
+ const EV = `read_json_auto('{{PREFIX}}-EVENTS*.json', sample_size=-1, union_by_name=true)`;
942
+ const US = `read_json_auto('{{PREFIX}}-USERS*.json', sample_size=-1, union_by_name=true)`;
943
+ // identity prelude: avgDevicePerUser 2 + account created is isAuthEvent+
944
+ // isFirstEvent, so born users auth on their first event; the device-pool
945
+ // resolve is belt-and-braces for any device-only edge. ::VARCHAR casts —
946
+ // user_id sniffs as UUID, device_id as VARCHAR; DuckDB refuses to coalesce
947
+ // mixed types.
948
+ const ID_CTE = `
949
+ us AS (SELECT * FROM ${US}),
950
+ dm AS (SELECT unnest("anonymousIds") AS device_id, distinct_id FROM us),
951
+ ev AS (
952
+ SELECT coalesce(m.distinct_id::VARCHAR, e.user_id::VARCHAR, e.device_id::VARCHAR) AS uid,
953
+ e.time::TIMESTAMP AS t, e.*
954
+ FROM ${EV} e
955
+ LEFT JOIN dm m ON e.device_id = m.device_id
956
+ )`;
957
+
958
+ // per-user counts used across stories (views/bookings/searches/sessions/upgrades)
959
+ const PU_CTE = `
960
+ pu AS (
961
+ SELECT e.uid,
962
+ count(*) FILTER (WHERE e.event = 'hotel viewed') AS hv,
963
+ count(*) FILTER (WHERE e.event = 'booking completed') AS bookings,
964
+ count(*) FILTER (WHERE e.event = 'destination searched') AS searches,
965
+ count(*) FILTER (WHERE e.event = 'app session') AS sessions,
966
+ count(*) FILTER (WHERE e.event = 'room upgrade selected') AS upgrades
967
+ FROM ev e GROUP BY 1
968
+ )`;
969
+
970
+ // knob-derived timestamps. H2 stamps by daysUntilEnd = datasetEnd.diff(t,
971
+ // "days"), which TRUNCATES: advance <=> diff > 21 <=> t <= end-22d;
972
+ // last_minute <=> diff < 3 <=> t > end-3d. Region boundaries below carry a
973
+ // >= 1-day / 1-hour interior margin so no boundary-truncation ambiguity
974
+ // can leak a mis-stamped booking into a region read.
975
+ const DS = dayjs.utc(DATASET_START);
976
+ const DE = dayjs.utc(DATASET_END);
977
+ const TS = (d) => d.format("YYYY-MM-DD HH:mm:ss");
978
+ const ADV_END_TS = TS(DE.subtract(ADVANCE_DAYS_THRESHOLD + 2, "day")); // t < this => stamped advance
979
+ const MID_START_TS = TS(DE.subtract(ADVANCE_DAYS_THRESHOLD - 1, "day")); // t >= this => not advance-stamped
980
+ const MID_END_TS = TS(DE.subtract(LAST_MINUTE_DAYS_THRESHOLD + 1, "day")); // t <= this => not last_minute-stamped
981
+ const LM_START_TS = TS(DE.subtract(LAST_MINUTE_DAYS_THRESHOLD, "day").add(1, "hour")); // t >= this => stamped last_minute
982
+ const FATIGUE_TS = TS(DS.add(HOTEL_FATIGUE_START_DAY, "day"));
983
+ // H10 DiD arms need genuine exposure on both sides of the day-60 edge:
984
+ // users born after day 45 have no meaningful before-period
985
+ const H10_EARLYBORN_TS = TS(DS.add(45, "day"));
986
+
987
+ const cellsOf = (rows, key) => Object.fromEntries((rows || []).map((r) => [r[key], r]));
988
+
989
+ export const stories = [
990
+ {
991
+ id: "H1-weekend-rate-surge",
992
+ hook: "H1",
993
+ archetype: "temporal-inflection",
994
+ narrative:
995
+ `Fri/Sat/Sun bookings get nightly_rate (and total_cost) x${WEEKEND_RATE_BOOST}, floored. ` +
996
+ "nightly_rate is an iid per-event pool draw, and every other rate hook (H2 window factors, " +
997
+ "H10 sweet boost) is either calendar-mixed near-identically across the two DOW arms or " +
998
+ "user-level constant, so the weekend/weekday mean ratio reads the 1.3 knob within ~±2% " +
999
+ "composition drift (the last-minute region's 1.4x factor covers Wed/Thu/Fri only — a " +
1000
+ "sub-1% asymmetry at region volume share). Band [1.22, 1.38]. 'hotel viewed' carries the " +
1001
+ "same nightly_rate pool untouched by every hook — the placebo arm must sit in [0.96, 1.04].",
1002
+ assertions: [
1003
+ {
1004
+ breakdown: {
1005
+ type: "duckdb",
1006
+ sql: `WITH ${ID_CTE}
1007
+ SELECT CASE WHEN dayofweek(t) IN (0, 5, 6) THEN 'wkn' ELSE 'wkd' END AS bucket,
1008
+ count(*)::BIGINT AS user_count, avg(nightly_rate) AS avg_rate
1009
+ FROM ev WHERE event = 'booking completed' AND nightly_rate IS NOT NULL
1010
+ GROUP BY 1`,
1011
+ },
1012
+ select: {
1013
+ wkn: { where: { bucket: "wkn" } },
1014
+ wkd: { where: { bucket: "wkd" } },
1015
+ },
1016
+ expect: { metric: "wkn.avg_rate / wkd.avg_rate", op: "between", target: [1.22, 1.38] },
1017
+ minCohort: 800,
1018
+ },
1019
+ {
1020
+ breakdown: {
1021
+ type: "duckdb",
1022
+ sql: `WITH ${ID_CTE}
1023
+ SELECT CASE WHEN dayofweek(t) IN (0, 5, 6) THEN 'wkn' ELSE 'wkd' END AS bucket,
1024
+ count(*)::BIGINT AS user_count, avg(nightly_rate) AS avg_rate
1025
+ FROM ev WHERE event = 'hotel viewed' AND nightly_rate IS NOT NULL
1026
+ GROUP BY 1`,
1027
+ },
1028
+ select: {
1029
+ wkn: { where: { bucket: "wkn" } },
1030
+ wkd: { where: { bucket: "wkd" } },
1031
+ },
1032
+ expect: { metric: "wkn.avg_rate / wkd.avg_rate", op: "between", target: [0.96, 1.04] },
1033
+ minCohort: 3000,
1034
+ },
1035
+ ],
1036
+ },
1037
+ {
1038
+ id: "H2-booking-window",
1039
+ hook: "H2",
1040
+ archetype: "temporal-inflection",
1041
+ narrative:
1042
+ `Bookings > ${ADVANCE_DAYS_THRESHOLD} days before dataset end are stamped 'advance' and get ` +
1043
+ `x${ADVANCE_RATE_FACTOR}; bookings < ${LAST_MINUTE_DAYS_THRESHOLD} days before end are stamped ` +
1044
+ `'last_minute' and get x${LAST_MINUTE_RATE_FACTOR}. The 3-21-day middle band keeps its ORGANIC ` +
1045
+ "booking_window label (2/5 advance, 2/5 standard, 1/5 last_minute) with untouched rates, so a " +
1046
+ "label breakdown dilutes both treated labels — the clean read is by calendar REGION with the " +
1047
+ "untreated middle band as baseline. Weekend-mix correction: advance region carries ~3/7 weekend " +
1048
+ "days (uniform-mix factor 1.129), middle band Apr 12-27 carries 7/16 (1.131), last-minute region " +
1049
+ "Wed/Thu/Fri carries 1/3 (1.100); expected adv/mid = 0.8 x 1.129/1.131 = 0.80 with soup-DOW " +
1050
+ "volume weighting drifting the mix a few percent either way — band [0.72, 0.86]. Expected " +
1051
+ "lm/mid = 1.4 x 1.100/1.131 = 1.36, wider band [1.20, 1.52] for the ~3-day region's n and " +
1052
+ "end-of-window decay composition. Third assertion pins the stamping mechanics: stamped regions " +
1053
+ "must be label-pure and the middle band must keep the organic 0.4 advance share.",
1054
+ assertions: [
1055
+ {
1056
+ breakdown: {
1057
+ type: "duckdb",
1058
+ sql: `WITH ${ID_CTE}
1059
+ SELECT CASE WHEN t < TIMESTAMP '${ADV_END_TS}' THEN 'adv'
1060
+ WHEN t >= TIMESTAMP '${MID_START_TS}' AND t <= TIMESTAMP '${MID_END_TS}' THEN 'mid' END AS region,
1061
+ count(*)::BIGINT AS user_count, avg(nightly_rate) AS avg_rate
1062
+ FROM ev WHERE event = 'booking completed' AND nightly_rate IS NOT NULL
1063
+ GROUP BY 1`,
1064
+ },
1065
+ select: {
1066
+ adv: { where: { region: "adv" } },
1067
+ mid: { where: { region: "mid" } },
1068
+ },
1069
+ expect: { metric: "adv.avg_rate / mid.avg_rate", op: "between", target: [0.72, 0.86] },
1070
+ minCohort: 500,
1071
+ },
1072
+ {
1073
+ breakdown: {
1074
+ type: "duckdb",
1075
+ sql: `WITH ${ID_CTE}
1076
+ SELECT CASE WHEN t >= TIMESTAMP '${LM_START_TS}' THEN 'lm'
1077
+ WHEN t >= TIMESTAMP '${MID_START_TS}' AND t <= TIMESTAMP '${MID_END_TS}' THEN 'mid' END AS region,
1078
+ count(*)::BIGINT AS user_count, avg(nightly_rate) AS avg_rate
1079
+ FROM ev WHERE event = 'booking completed' AND nightly_rate IS NOT NULL
1080
+ GROUP BY 1`,
1081
+ },
1082
+ select: {
1083
+ lm: { where: { region: "lm" } },
1084
+ mid: { where: { region: "mid" } },
1085
+ },
1086
+ expect: { metric: "lm.avg_rate / mid.avg_rate", op: "between", target: [1.20, 1.52] },
1087
+ minCohort: 150,
1088
+ },
1089
+ {
1090
+ breakdown: {
1091
+ type: "duckdb",
1092
+ sql: `WITH ${ID_CTE}
1093
+ SELECT CASE WHEN t < TIMESTAMP '${ADV_END_TS}' THEN 'adv'
1094
+ WHEN t >= TIMESTAMP '${LM_START_TS}' THEN 'lm'
1095
+ WHEN t >= TIMESTAMP '${MID_START_TS}' AND t <= TIMESTAMP '${MID_END_TS}' THEN 'mid' END AS region,
1096
+ count(*)::BIGINT AS user_count,
1097
+ count(*) FILTER (WHERE booking_window = 'advance')::DOUBLE / count(*) AS adv_share,
1098
+ count(*) FILTER (WHERE booking_window = 'last_minute')::DOUBLE / count(*) AS lm_share
1099
+ FROM ev WHERE event = 'booking completed'
1100
+ GROUP BY 1`,
1101
+ },
1102
+ assert: (rows) => {
1103
+ const by = cellsOf(rows, "region");
1104
+ const a = by.adv, l = by.lm, m = by.mid;
1105
+ if (!a || !l || !m || Number(a.user_count) < 500 || Number(l.user_count) < 100 || Number(m.user_count) < 300) {
1106
+ return { verdict: "WEAK", detail: `cohort too small: adv=${a?.user_count ?? 0} lm=${l?.user_count ?? 0} mid=${m?.user_count ?? 0}` };
1107
+ }
1108
+ const detail = `adv region advance-share=${Number(a.adv_share).toFixed(4)}, lm region last_minute-share=${Number(l.lm_share).toFixed(4)}, mid organic advance-share=${Number(m.adv_share).toFixed(4)}`;
1109
+ if (a.adv_share >= 0.999 && l.lm_share >= 0.999 && m.adv_share >= 0.30 && m.adv_share <= 0.50) {
1110
+ return { verdict: "NAILED", detail };
1111
+ }
1112
+ if (a.adv_share >= 0.995 && l.lm_share >= 0.995 && m.adv_share >= 0.25 && m.adv_share <= 0.55) {
1113
+ return { verdict: "STRONG", detail };
1114
+ }
1115
+ return { verdict: a.adv_share > 0.9 && l.lm_share > 0.9 ? "WEAK" : "NONE", detail };
1116
+ },
1117
+ },
1118
+ ],
1119
+ },
1120
+ {
1121
+ id: "H3-loyalty-boost",
1122
+ hook: "H3",
1123
+ archetype: "cohort-prop-scale",
1124
+ narrative:
1125
+ `Users with >= ${LOYALTY_BOOKING_THRESHOLD} bookings when H3 runs get loyalty_points ` +
1126
+ `x(${LOYALTY_POINT_BASE_MULT} + U[0, ${LOYALTY_POINT_VARIANCE}]) floored — E[mult] = 3.0. ` +
1127
+ "loyalty_points is an iid pool draw, so the cross-arm mean ratio is composition-clean. " +
1128
+ "Cohorts are one-sided-safe: H3 runs after H8's all-or-nothing booking drop and before H10's " +
1129
+ "post-day-60 drop, and bookings are only ever deleted afterward — so output >= 5 bookings " +
1130
+ "implies hook-time >= 5 (treated). Control = output 1-4 bookings AND hv <= " +
1131
+ `${HOTEL_SWEET_MAX}: H10 never drops bookings for hv <= 10 users, so their output count is ` +
1132
+ "their H3-time count (the only post-H3 deletion left is the future-time guard on " +
1133
+ "H9-stretched instances, sub-1%). The zero-guard in the hook skips loyalty_points = 0 draws, " +
1134
+ "which contribute 0 to both arm means and cancel. Band [2.6, 3.4] around the exact 3.0.",
1135
+ assertions: [
1136
+ {
1137
+ breakdown: {
1138
+ type: "duckdb",
1139
+ sql: `WITH ${ID_CTE},
1140
+ ${PU_CTE},
1141
+ j AS (
1142
+ SELECT p.uid, CASE WHEN p.bookings >= ${LOYALTY_BOOKING_THRESHOLD} THEN 'big'
1143
+ WHEN p.bookings BETWEEN 1 AND ${LOYALTY_BOOKING_THRESHOLD - 1} AND p.hv <= ${HOTEL_SWEET_MAX} THEN 'small' END AS arm
1144
+ FROM pu p
1145
+ )
1146
+ SELECT j.arm, count(DISTINCT j.uid)::BIGINT AS user_count, avg(e.loyalty_points) AS avg_lp
1147
+ FROM j JOIN ev e ON e.uid = j.uid AND e.event = 'booking completed'
1148
+ WHERE j.arm IS NOT NULL GROUP BY 1`,
1149
+ },
1150
+ select: {
1151
+ big: { where: { arm: "big" } },
1152
+ small: { where: { arm: "small" } },
1153
+ },
1154
+ expect: { metric: "big.avg_lp / small.avg_lp", op: "between", target: [2.6, 3.4] },
1155
+ minCohort: 200,
1156
+ },
1157
+ ],
1158
+ },
1159
+ {
1160
+ id: "H4-cancel-by-window",
1161
+ hook: "H4",
1162
+ archetype: "cohort-count-scale",
1163
+ narrative:
1164
+ "H4 stamps each 'booking cancelled' with the booking_window of the user's nearest PRECEDING " +
1165
+ `'booking completed', then drops ${CANCEL_LAST_MINUTE_DROP_LIKELIHOOD}% of last_minute-stamped ` +
1166
+ "cancels. The assertion REPLICATES the matching with an ASOF JOIN and restricts to cancels " +
1167
+ "whose matched booking sits in the organic middle band (labels iid 0.4/0.4/0.2 there) for " +
1168
+ `hv <= ${HOTEL_SWEET_MAX} users (their output booking set is their hook-time set — H10 never ` +
1169
+ "drops for them, so the ASOF match reproduces the hook's match). Post-drop the lm:std count " +
1170
+ "ratio should be 0.2 x 0.4 : 0.4 = organic 0.5 x keep 0.4 => keep = (lm/std)/0.5 in " +
1171
+ "[0.30, 0.52] (NAILED, binomial noise at region n) / [0.22, 0.62] (STRONG). adv:std is " +
1172
+ "untouched — placebo 1.0 in [0.85, 1.18]. Stamped-vs-ASOF label agreement >= 0.98 pins the " +
1173
+ "replication (sub-2% slack for future-guard rematches and same-timestamp ties).",
1174
+ assertions: [
1175
+ {
1176
+ breakdown: {
1177
+ type: "duckdb",
1178
+ sql: `WITH ${ID_CTE},
1179
+ ${PU_CTE},
1180
+ bk AS (SELECT uid, t, booking_window FROM ev WHERE event = 'booking completed'),
1181
+ cn AS (
1182
+ SELECT e.uid, e.t, e.booking_window AS stamped
1183
+ FROM ev e JOIN pu p ON p.uid = e.uid AND p.hv <= ${HOTEL_SWEET_MAX}
1184
+ WHERE e.event = 'booking cancelled'
1185
+ ),
1186
+ m AS (
1187
+ SELECT cn.uid, cn.stamped, bk.booking_window AS matched, bk.t AS bt
1188
+ FROM cn ASOF JOIN bk ON cn.uid = bk.uid AND cn.t >= bk.t
1189
+ )
1190
+ SELECT matched AS label, count(*)::BIGINT AS user_count,
1191
+ count(*) FILTER (WHERE stamped = matched)::DOUBLE / count(*) AS agree
1192
+ FROM m
1193
+ WHERE bt >= TIMESTAMP '${MID_START_TS}' AND bt <= TIMESTAMP '${MID_END_TS}'
1194
+ GROUP BY 1`,
1195
+ },
1196
+ assert: (rows) => {
1197
+ const by = cellsOf(rows, "label");
1198
+ const lm = by.last_minute, std = by.standard, adv = by.advance;
1199
+ const n = (c) => Number(c?.user_count ?? 0);
1200
+ if (n(std) < 80 || n(adv) < 80 || n(lm) < 20) {
1201
+ return { verdict: "WEAK", detail: `cells too small: std=${n(std)} adv=${n(adv)} lm=${n(lm)}` };
1202
+ }
1203
+ const keep = (n(lm) / n(std)) / 0.5;
1204
+ const placebo = n(adv) / n(std);
1205
+ const total = n(lm) + n(std) + n(adv);
1206
+ const agree = (n(lm) * Number(lm.agree) + n(std) * Number(std.agree) + n(adv) * Number(adv.agree)) / total;
1207
+ const detail = `keep=${keep.toFixed(3)} (knob ${1 - CANCEL_LAST_MINUTE_DROP_LIKELIHOOD / 100}), adv/std placebo=${placebo.toFixed(3)}, stamped-vs-ASOF agreement=${agree.toFixed(4)} (lm=${n(lm)} std=${n(std)} adv=${n(adv)})`;
1208
+ if (agree < 0.98) return { verdict: "NONE", detail: `ASOF replication broke: ${detail}` };
1209
+ if (keep >= 0.30 && keep <= 0.52 && placebo >= 0.85 && placebo <= 1.18) return { verdict: "NAILED", detail };
1210
+ if (keep >= 0.22 && keep <= 0.62 && placebo >= 0.75 && placebo <= 1.30) return { verdict: "STRONG", detail };
1211
+ if (keep < 0.8) return { verdict: "WEAK", detail };
1212
+ return { verdict: "INVERSE", detail };
1213
+ },
1214
+ },
1215
+ ],
1216
+ },
1217
+ {
1218
+ id: "H5-luxury-upsell",
1219
+ hook: "H5",
1220
+ archetype: "cohort-count-scale",
1221
+ narrative:
1222
+ `luxury_seeker users with an existing 'room upgrade selected' template get a ${UPSELL_LUXURY_LIKELIHOOD}% ` +
1223
+ "cloned upgrade per booking — an ADDITIVE +0.5 upgrades-per-booking on top of the organic rate. " +
1224
+ "Read 1 restricts both arms (lux vs budget_hunter) to template owners (output upgrade >= 1 " +
1225
+ "implies hook-time template — upgrades are never deleted) and to PRE-day-60 bookings/upgrades " +
1226
+ "(H10's post-day-60 booking drop would shrink over-viewers' denominators and lux skews " +
1227
+ "over-viewer); pooled sum(upgrades)/sum(bookings) then measures organic_diff + 0.5 exactly, " +
1228
+ "with the organic lux-vs-budget upsell-funnel gap (conversionModifier 1.2 vs 0.7) adding a " +
1229
+ "positive confound bounded by the Upsell Path's 20% base — band [0.40, 0.78] (NAILED) / " +
1230
+ "[0.30, 0.92] (STRONG). Read 2 is the doc-level Insights report: upgrades per user, lux vs " +
1231
+ "budget across ALL users. The organic upsell base is THIN (budget upgrades-per-booking " +
1232
+ "measured ~0.08 at reduced scale), so the +0.5 additive clone term dominates the ratio: " +
1233
+ "ratio = [(1.2p + 0.5) / 0.7p] x (B_lux/B_bud) with organic propensity p in [0.08, 0.16] " +
1234
+ "and booking-ratio in [1.3, 2.0] gives [7, 22] — wide but knob-derived; the v1.5 doc's " +
1235
+ "'~3x' figure assumed a thick organic base that the funnel math never supported.",
1236
+ assertions: [
1237
+ {
1238
+ breakdown: {
1239
+ type: "duckdb",
1240
+ sql: `WITH ${ID_CTE},
1241
+ ${PU_CTE},
1242
+ pre AS (
1243
+ SELECT e.uid,
1244
+ count(*) FILTER (WHERE e.event = 'booking completed' AND e.t < TIMESTAMP '${FATIGUE_TS}') AS bookings_pre,
1245
+ count(*) FILTER (WHERE e.event = 'room upgrade selected' AND e.t < TIMESTAMP '${FATIGUE_TS}') AS upgrades_pre
1246
+ FROM ev e GROUP BY 1
1247
+ ),
1248
+ j AS (
1249
+ SELECT u.customer_segment AS seg, pre.bookings_pre, pre.upgrades_pre
1250
+ FROM pre
1251
+ JOIN pu p ON p.uid = pre.uid AND p.upgrades >= 1
1252
+ JOIN us u ON u.distinct_id::VARCHAR = pre.uid
1253
+ WHERE u.customer_segment IN ('luxury_seeker', 'budget_hunter') AND pre.bookings_pre >= 1
1254
+ )
1255
+ SELECT seg, count(*)::BIGINT AS user_count,
1256
+ sum(upgrades_pre)::DOUBLE / sum(bookings_pre) AS upb
1257
+ FROM j GROUP BY 1`,
1258
+ },
1259
+ assert: (rows) => {
1260
+ const by = cellsOf(rows, "seg");
1261
+ const lux = by.luxury_seeker, bud = by.budget_hunter;
1262
+ if (!lux || !bud || Number(lux.user_count) < 120 || Number(bud.user_count) < 120) {
1263
+ return { verdict: "WEAK", detail: `cohort too small: lux=${lux?.user_count ?? 0} budget=${bud?.user_count ?? 0}` };
1264
+ }
1265
+ const diff = Number(lux.upb) - Number(bud.upb);
1266
+ const detail = `upgrades-per-booking diff=${diff.toFixed(3)} (lux ${Number(lux.upb).toFixed(3)} - budget ${Number(bud.upb).toFixed(3)}; knob +0.5; lux n=${lux.user_count}, budget n=${bud.user_count})`;
1267
+ if (diff >= 0.40 && diff <= 0.78) return { verdict: "NAILED", detail };
1268
+ if (diff >= 0.30 && diff <= 0.92) return { verdict: "STRONG", detail };
1269
+ return { verdict: diff > 0.1 ? "WEAK" : "INVERSE", detail };
1270
+ },
1271
+ },
1272
+ {
1273
+ breakdown: {
1274
+ type: "duckdb",
1275
+ sql: `WITH ${ID_CTE},
1276
+ ${PU_CTE}
1277
+ SELECT u.customer_segment AS seg, count(*)::BIGINT AS user_count,
1278
+ avg(coalesce(p.upgrades, 0)) AS avg_upg
1279
+ FROM us u LEFT JOIN pu p ON p.uid = u.distinct_id::VARCHAR
1280
+ WHERE u.customer_segment IN ('luxury_seeker', 'budget_hunter')
1281
+ GROUP BY 1`,
1282
+ },
1283
+ select: {
1284
+ lux: { where: { seg: "luxury_seeker" } },
1285
+ bud: { where: { seg: "budget_hunter" } },
1286
+ },
1287
+ expect: { metric: "lux.avg_upg / bud.avg_upg", op: "between", target: [7, 22] },
1288
+ minCohort: 800,
1289
+ },
1290
+ ],
1291
+ },
1292
+ {
1293
+ id: "H6-review-quality",
1294
+ hook: "H6",
1295
+ archetype: "cohort-prop-scale",
1296
+ narrative:
1297
+ `Users whose avg stay_rating >= ${REVIEW_HIGH_RATING_THRESHOLD} get review_length x${REVIEW_HIGH_LENGTH_MULT} ` +
1298
+ `on ALL reviews; avg <= ${REVIEW_LOW_RATING_THRESHOLD} gets x${REVIEW_LOW_LENGTH_MULT}. Reviews are never ` +
1299
+ "deleted or cloned and no other hook touches stay_rating or review_length, so the output per-user " +
1300
+ "rating average EXACTLY reproduces the hook-time classification — arm assignment is not a proxy, " +
1301
+ "it is the treatment variable itself. stay_rating is a literal weighted integer draw (mean 3.5, " +
1302
+ "P(5)=.25) so both arms are structurally populated. review_length is an iid pool draw: arm/mid " +
1303
+ "mean ratios read the knobs within floor()-rounding, bands [1.40, 1.60] and [0.42, 0.58].",
1304
+ assertions: [
1305
+ {
1306
+ breakdown: {
1307
+ type: "duckdb",
1308
+ sql: `WITH ${ID_CTE},
1309
+ rv AS (SELECT uid, avg(stay_rating) AS avg_r FROM ev WHERE event = 'review submitted' GROUP BY 1),
1310
+ j AS (
1311
+ SELECT uid, CASE WHEN avg_r >= ${REVIEW_HIGH_RATING_THRESHOLD} THEN 'high'
1312
+ WHEN avg_r <= ${REVIEW_LOW_RATING_THRESHOLD} THEN 'low' ELSE 'mid' END AS arm
1313
+ FROM rv
1314
+ )
1315
+ SELECT j.arm, count(*)::BIGINT AS user_count, avg(e.review_length) AS avg_len
1316
+ FROM j JOIN ev e ON e.uid = j.uid AND e.event = 'review submitted'
1317
+ GROUP BY 1`,
1318
+ },
1319
+ select: {
1320
+ high: { where: { arm: "high" } },
1321
+ mid: { where: { arm: "mid" } },
1322
+ },
1323
+ expect: { metric: "high.avg_len / mid.avg_len", op: "between", target: [1.40, 1.60] },
1324
+ minCohort: 300,
1325
+ },
1326
+ {
1327
+ breakdown: {
1328
+ type: "duckdb",
1329
+ sql: `WITH ${ID_CTE},
1330
+ rv AS (SELECT uid, avg(stay_rating) AS avg_r FROM ev WHERE event = 'review submitted' GROUP BY 1),
1331
+ j AS (
1332
+ SELECT uid, CASE WHEN avg_r >= ${REVIEW_HIGH_RATING_THRESHOLD} THEN 'high'
1333
+ WHEN avg_r <= ${REVIEW_LOW_RATING_THRESHOLD} THEN 'low' ELSE 'mid' END AS arm
1334
+ FROM rv
1335
+ )
1336
+ SELECT j.arm, count(*)::BIGINT AS user_count, avg(e.review_length) AS avg_len
1337
+ FROM j JOIN ev e ON e.uid = j.uid AND e.event = 'review submitted'
1338
+ GROUP BY 1`,
1339
+ },
1340
+ select: {
1341
+ low: { where: { arm: "low" } },
1342
+ mid: { where: { arm: "mid" } },
1343
+ },
1344
+ expect: { metric: "low.avg_len / mid.avg_len", op: "between", target: [0.42, 0.58] },
1345
+ minCohort: 120,
1346
+ },
1347
+ ],
1348
+ },
1349
+ {
1350
+ id: "H7-business-profile",
1351
+ hook: "H7",
1352
+ archetype: "cohort-prop-scale",
1353
+ narrative:
1354
+ "The user hook overwrites deterministically by segment: business_traveler gets a real " +
1355
+ "company_name (never 'none') and travel_frequency 'weekly'; luxury_seeker gets " +
1356
+ "avg_budget_per_night U-int[250, 500]; budget_hunter U-int[50, 120]; everyone else keeps " +
1357
+ "company_name 'none' (the schema default). Profile props are written once and never mutated " +
1358
+ "again, so every check is exact: shares 1.0/0.0 and hard min/max range bounds.",
1359
+ assertions: [
1360
+ {
1361
+ breakdown: {
1362
+ type: "duckdb",
1363
+ sql: `SELECT customer_segment AS seg, count(*)::BIGINT AS user_count,
1364
+ avg((travel_frequency = 'weekly')::INT) AS weekly_share,
1365
+ avg((company_name IS NOT NULL AND company_name <> 'none')::INT) AS company_share,
1366
+ min(avg_budget_per_night) AS min_budget, max(avg_budget_per_night) AS max_budget
1367
+ FROM ${US} GROUP BY 1`,
1368
+ },
1369
+ assert: (rows) => {
1370
+ const by = cellsOf(rows, "seg");
1371
+ const biz = by.business_traveler, lux = by.luxury_seeker, bud = by.budget_hunter, fam = by.leisure_family;
1372
+ if (!biz || !lux || !bud || !fam) return { verdict: "NONE", detail: "missing segment rows" };
1373
+ if (Number(biz.user_count) < 500 || Number(lux.user_count) < 300 || Number(bud.user_count) < 500) {
1374
+ return { verdict: "WEAK", detail: `cohorts too small: biz=${biz.user_count} lux=${lux.user_count} budget=${bud.user_count}` };
1375
+ }
1376
+ const detail =
1377
+ `biz weekly=${Number(biz.weekly_share).toFixed(4)} company=${Number(biz.company_share).toFixed(4)}; ` +
1378
+ `leisure company=${Number(fam.company_share).toFixed(4)}; ` +
1379
+ `lux budget [${lux.min_budget}, ${lux.max_budget}]; budget [${bud.min_budget}, ${bud.max_budget}]`;
1380
+ const exact =
1381
+ Number(biz.weekly_share) === 1 && Number(biz.company_share) === 1 &&
1382
+ Number(fam.company_share) === 0 &&
1383
+ Number(lux.min_budget) >= 250 && Number(lux.max_budget) <= 500 &&
1384
+ Number(bud.min_budget) >= 50 && Number(bud.max_budget) <= 120;
1385
+ if (exact) return { verdict: "NAILED", detail };
1386
+ const close =
1387
+ Number(biz.weekly_share) >= 0.99 && Number(biz.company_share) >= 0.99 &&
1388
+ Number(fam.company_share) <= 0.01 &&
1389
+ Number(lux.min_budget) >= 245 && Number(lux.max_budget) <= 505 &&
1390
+ Number(bud.min_budget) >= 45 && Number(bud.max_budget) <= 125;
1391
+ if (close) return { verdict: "STRONG", detail };
1392
+ return { verdict: Number(biz.weekly_share) > 0.9 ? "WEAK" : "NONE", detail };
1393
+ },
1394
+ },
1395
+ ],
1396
+ },
1397
+ {
1398
+ id: "H8-casual-booking-drop",
1399
+ hook: "H8",
1400
+ archetype: "funnel-conversion-by-segment",
1401
+ narrative:
1402
+ `Non-business/luxury users have a ${REPEAT_DEST_DROP_LIKELIHOOD}% per-user chance that ALL their ` +
1403
+ "'booking completed' events are spliced out. Read 1: among users with >= 10 output searches " +
1404
+ "(searches are never deleted — exact hook-time activity floor), the zero-booking share should " +
1405
+ "be inflated in the treated segments (leisure_family + budget_hunter) by ~0.25 x (1 - organic " +
1406
+ "P0) plus an organic conversion gap (convMod 0.9/0.7 vs 1.3/1.2, churn 8-10% vs 2-4%) — " +
1407
+ "expected diff ~0.22-0.30, band [0.18, 0.33] (NAILED) / [0.13, 0.40] (STRONG), with the " +
1408
+ "untreated arm's own P0 <= 0.12 as the derivation guard. Read 2 is the doc-level funnel: " +
1409
+ "emulator conversion searched -> viewed -> booked per segment at the 72h Onboarding window; " +
1410
+ "business/budget lift = (convMod 1.3/0.7 compressed by organic weight-drawn completions) x " +
1411
+ "1/0.75 H8 keep => band [1.35, 2.6] (NAILED) / [1.2, 3.2] (STRONG).",
1412
+ assertions: [
1413
+ {
1414
+ breakdown: {
1415
+ type: "duckdb",
1416
+ sql: `WITH ${ID_CTE},
1417
+ ${PU_CTE},
1418
+ j AS (
1419
+ SELECT p.uid, p.bookings,
1420
+ CASE WHEN u.customer_segment IN ('leisure_family', 'budget_hunter') THEN 'treated' ELSE 'control' END AS arm
1421
+ FROM pu p JOIN us u ON u.distinct_id::VARCHAR = p.uid
1422
+ WHERE p.searches >= 10
1423
+ )
1424
+ SELECT arm, count(*)::BIGINT AS user_count, avg((bookings = 0)::INT) AS zero_share
1425
+ FROM j GROUP BY 1`,
1426
+ },
1427
+ assert: (rows) => {
1428
+ const by = cellsOf(rows, "arm");
1429
+ const t = by.treated, c = by.control;
1430
+ if (!t || !c || Number(t.user_count) < 500 || Number(c.user_count) < 300) {
1431
+ return { verdict: "WEAK", detail: `cohort too small: treated=${t?.user_count ?? 0} control=${c?.user_count ?? 0}` };
1432
+ }
1433
+ const diff = Number(t.zero_share) - Number(c.zero_share);
1434
+ const detail = `zero-booking share treated=${Number(t.zero_share).toFixed(4)} control=${Number(c.zero_share).toFixed(4)} diff=${diff.toFixed(4)} (n=${t.user_count}/${c.user_count})`;
1435
+ if (Number(c.zero_share) > 0.12) {
1436
+ return { verdict: diff > 0.13 ? "WEAK" : "NONE", detail: `control P0 exceeds derivation guard 0.12: ${detail}` };
1437
+ }
1438
+ if (diff >= 0.18 && diff <= 0.33) return { verdict: "NAILED", detail };
1439
+ if (diff >= 0.13 && diff <= 0.40) return { verdict: "STRONG", detail };
1440
+ return { verdict: diff > 0.05 ? "WEAK" : "INVERSE", detail };
1441
+ },
1442
+ },
1443
+ {
1444
+ breakdown: {
1445
+ type: "timeToConvert",
1446
+ steps: ["destination searched", "hotel viewed", "booking completed"],
1447
+ breakdownByUserProperty: "customer_segment",
1448
+ conversionWindowMs: 72 * 3600 * 1000,
1449
+ },
1450
+ assert: (rows) => {
1451
+ const by = cellsOf(rows, "segment_value");
1452
+ const conv = (cell) => {
1453
+ const entered = cell?.step_counts?.[0] ?? 0;
1454
+ const done = cell?.step_counts?.[2] ?? 0;
1455
+ return entered > 0 ? { rate: done / entered, entered } : null;
1456
+ };
1457
+ const biz = conv(by.business_traveler);
1458
+ const bud = conv(by.budget_hunter);
1459
+ if (!biz || !bud) return { verdict: "NONE", detail: "missing segment cells in emulator rows" };
1460
+ if (biz.entered < 500 || bud.entered < 500) {
1461
+ return { verdict: "WEAK", detail: `attempts too few: biz=${biz.entered} budget=${bud.entered}` };
1462
+ }
1463
+ const lift = biz.rate / bud.rate;
1464
+ const detail = `searched->viewed->booked conv biz=${biz.rate.toFixed(4)} budget=${bud.rate.toFixed(4)} lift=${lift.toFixed(3)} (attempts ${biz.entered}/${bud.entered})`;
1465
+ if (lift >= 1.35 && lift <= 2.6) return { verdict: "NAILED", detail };
1466
+ if (lift >= 1.2 && lift <= 3.2) return { verdict: "STRONG", detail };
1467
+ return { verdict: lift > 1 ? "WEAK" : "INVERSE", detail };
1468
+ },
1469
+ },
1470
+ ],
1471
+ },
1472
+ {
1473
+ id: "H9-booking-ttc",
1474
+ hook: "H9",
1475
+ archetype: "funnel-ttc-by-segment",
1476
+ narrative:
1477
+ `funnel-post scales Search to Book inter-step gaps: business x${TTC_BUSINESS_FACTOR}, ` +
1478
+ `budget/leisure x${TTC_LEISURE_BUDGET_FACTOR}, luxury untouched (the v1.6 hook is scoped to ` +
1479
+ "Search to Book only). Cross-event SQL cannot see this (greedy single-pass pairing across " +
1480
+ "funnel instances — the documented v1.5 limitation), so both reads go through the " +
1481
+ "Mixpanel-aligned emulator's timeToConvert at a conversion window of 48h x " +
1482
+ `${TTC_LEISURE_BUDGET_FACTOR} = 60h — the generative window times the max stretch, covering ` +
1483
+ "the stretched support so slow-arm conversions are not right-censored into a fake speedup " +
1484
+ "(the ai-platform lesson). The 4-step sequence includes 'price compared', which filters most " +
1485
+ "Onboarding-funnel cross-instances (that funnel has no compare step). Organic cross-instance " +
1486
+ "pairings compress ratios toward 1 asymmetrically (stretches compress harder than " +
1487
+ "compressions — the dating measurement); bands: business/luxury [0.60, 0.95], budget/luxury " +
1488
+ "[1.04, 1.42].",
1489
+ assertions: [
1490
+ {
1491
+ breakdown: {
1492
+ type: "timeToConvert",
1493
+ steps: ["destination searched", "hotel viewed", "price compared", "booking completed"],
1494
+ breakdownByUserProperty: "customer_segment",
1495
+ conversionWindowMs: Math.round(48 * TTC_LEISURE_BUDGET_FACTOR * 3600 * 1000),
1496
+ },
1497
+ select: {
1498
+ biz: { where: { segment_value: "business_traveler" } },
1499
+ lux: { where: { segment_value: "luxury_seeker" } },
1500
+ },
1501
+ expect: { metric: "biz.median_ttc_ms / lux.median_ttc_ms", op: "between", target: [0.60, 0.95] },
1502
+ minCohort: 200,
1503
+ },
1504
+ {
1505
+ breakdown: {
1506
+ type: "timeToConvert",
1507
+ steps: ["destination searched", "hotel viewed", "price compared", "booking completed"],
1508
+ breakdownByUserProperty: "customer_segment",
1509
+ conversionWindowMs: Math.round(48 * TTC_LEISURE_BUDGET_FACTOR * 3600 * 1000),
1510
+ },
1511
+ select: {
1512
+ bud: { where: { segment_value: "budget_hunter" } },
1513
+ lux: { where: { segment_value: "luxury_seeker" } },
1514
+ },
1515
+ expect: { metric: "bud.median_ttc_ms / lux.median_ttc_ms", op: "between", target: [1.04, 1.42] },
1516
+ minCohort: 200,
1517
+ },
1518
+ ],
1519
+ },
1520
+ {
1521
+ id: "H10-hotel-view-magic",
1522
+ hook: "H10",
1523
+ archetype: "frequency-sweet-spot",
1524
+ narrative:
1525
+ `Sweet-spot viewers (${HOTEL_SWEET_MIN}-${HOTEL_SWEET_MAX} hotel views — view counts are never ` +
1526
+ `deleted, so the output bin is exact) get nightly_rate x${HOTEL_SWEET_RATE_BOOST} on all ` +
1527
+ `bookings; over-viewers (${HOTEL_OVER_THRESHOLD}+) lose ${HOTEL_OVER_BOOKING_DROP_LIKELIHOOD}% ` +
1528
+ `of bookings on/after day ${HOTEL_FATIGUE_START_DAY} (${FATIGUE_TS}). Read 1: sweet vs low-view ` +
1529
+ "(0-4) nightly_rate restricted to the ADVANCE region, where H2's factor is a constant 0.8 on " +
1530
+ "both arms and cancels — the iid pool draw makes the mean ratio read 1.3 directly, band " +
1531
+ "[1.18, 1.42]. Read 2 is the volume DiD: view count is activity-coupled, so no cross-arm " +
1532
+ "level comparison works; instead each arm's own after/before bookings-per-session ratio " +
1533
+ "cancels its activity composition ('app session' is untouched by every hook AND by the " +
1534
+ "hurricane/summer-sale worldEvents' booking-side scaling — those are arm-invariant and cancel " +
1535
+ "in the cross-arm ratio anyway). Restricted to users born before day 45 so both arms have " +
1536
+ "real before-period exposure. DiD = (over after/before) / (sweet after/before) reads the 0.65 " +
1537
+ "keep rate: [0.55, 0.78] (NAILED) / [0.45, 0.88] (STRONG).",
1538
+ assertions: [
1539
+ {
1540
+ breakdown: {
1541
+ type: "duckdb",
1542
+ sql: `WITH ${ID_CTE},
1543
+ ${PU_CTE},
1544
+ j AS (
1545
+ SELECT uid, CASE WHEN hv BETWEEN ${HOTEL_SWEET_MIN} AND ${HOTEL_SWEET_MAX} THEN 'sweet'
1546
+ WHEN hv < ${HOTEL_SWEET_MIN} THEN 'low' END AS arm
1547
+ FROM pu
1548
+ )
1549
+ SELECT j.arm, count(DISTINCT j.uid)::BIGINT AS user_count, avg(e.nightly_rate) AS avg_rate
1550
+ FROM j JOIN ev e ON e.uid = j.uid AND e.event = 'booking completed'
1551
+ WHERE j.arm IS NOT NULL AND e.t < TIMESTAMP '${ADV_END_TS}'
1552
+ GROUP BY 1`,
1553
+ },
1554
+ select: {
1555
+ sweet: { where: { arm: "sweet" } },
1556
+ low: { where: { arm: "low" } },
1557
+ },
1558
+ expect: { metric: "sweet.avg_rate / low.avg_rate", op: "between", target: [1.18, 1.42] },
1559
+ minCohort: 250,
1560
+ },
1561
+ {
1562
+ breakdown: {
1563
+ type: "duckdb",
1564
+ sql: `WITH ${ID_CTE},
1565
+ ${PU_CTE},
1566
+ fe AS (SELECT uid, min(t) AS f FROM ev GROUP BY 1),
1567
+ j AS (
1568
+ SELECT p.uid, CASE WHEN p.hv BETWEEN ${HOTEL_SWEET_MIN} AND ${HOTEL_SWEET_MAX} THEN 'sweet'
1569
+ WHEN p.hv >= ${HOTEL_OVER_THRESHOLD} THEN 'over' END AS arm
1570
+ FROM pu p JOIN fe ON fe.uid = p.uid
1571
+ WHERE fe.f < TIMESTAMP '${H10_EARLYBORN_TS}'
1572
+ )
1573
+ SELECT j.arm || '_' || CASE WHEN e.t >= TIMESTAMP '${FATIGUE_TS}' THEN 'after' ELSE 'before' END AS cell,
1574
+ count(DISTINCT j.uid)::BIGINT AS user_count,
1575
+ count(*) FILTER (WHERE e.event = 'booking completed')::BIGINT AS bookings,
1576
+ count(*) FILTER (WHERE e.event = 'app session')::BIGINT AS sessions
1577
+ FROM j JOIN ev e ON e.uid = j.uid
1578
+ WHERE j.arm IS NOT NULL
1579
+ GROUP BY 1`,
1580
+ },
1581
+ assert: (rows) => {
1582
+ const by = cellsOf(rows, "cell");
1583
+ const need = ["sweet_before", "sweet_after", "over_before", "over_after"];
1584
+ for (const k of need) {
1585
+ const c = by[k];
1586
+ if (!c) return { verdict: "NONE", detail: `missing DiD cell ${k}` };
1587
+ if (Number(c.bookings) < 150 || Number(c.sessions) < 500) {
1588
+ return { verdict: "WEAK", detail: `cell ${k} too small: bookings=${c.bookings} sessions=${c.sessions}` };
1589
+ }
1590
+ }
1591
+ const bps = (k) => Number(by[k].bookings) / Number(by[k].sessions);
1592
+ const rSweet = bps("sweet_after") / bps("sweet_before");
1593
+ const rOver = bps("over_after") / bps("over_before");
1594
+ const did = rOver / rSweet;
1595
+ const detail = `DiD=${did.toFixed(3)} (over after/before ${rOver.toFixed(3)} ÷ sweet ${rSweet.toFixed(3)}; keep knob ${1 - HOTEL_OVER_BOOKING_DROP_LIKELIHOOD / 100}; bookings s=${by.sweet_before.bookings}/${by.sweet_after.bookings} o=${by.over_before.bookings}/${by.over_after.bookings})`;
1596
+ if (did >= 0.55 && did <= 0.78) return { verdict: "NAILED", detail };
1597
+ if (did >= 0.45 && did <= 0.88) return { verdict: "STRONG", detail };
1598
+ return { verdict: did < 1 ? "WEAK" : "INVERSE", detail };
1599
+ },
1600
+ },
1601
+ ],
1602
+ },
1603
+ ];