@ak--47/dungeon-master 1.5.4 → 1.6.0

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 (146) 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/verify-dungeon/SKILL.md +56 -25
  6. package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
  7. package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
  8. package/.claude/skills/write-hooks/SKILL.md +156 -18
  9. package/CHANGELOG.md +183 -0
  10. package/HOOKS.md +566 -112
  11. package/README.md +28 -9
  12. package/docs/guides/1.3.0-upgrade-guide.md +262 -0
  13. package/docs/guides/1.3.1-upgrade-guide.md +328 -0
  14. package/docs/guides/1.4.0-upgrade-guide.md +130 -0
  15. package/docs/guides/1.4.1-upgrade-guide.md +125 -0
  16. package/docs/guides/1.4.3-upgrade-guide.md +200 -0
  17. package/docs/guides/1.4.4-upgrade-guide.md +95 -0
  18. package/docs/guides/1.5.0-upgrade-guide.md +426 -0
  19. package/docs/guides/1.5.1-upgrade-guide.md +178 -0
  20. package/docs/guides/1.5.3-upgrade-guide.md +117 -0
  21. package/docs/guides/1.6.0-upgrade-guide.md +204 -0
  22. package/dungeons/technical/stories-verify.js +152 -0
  23. package/dungeons/vertical/README.md +154 -0
  24. package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
  25. package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
  26. package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
  27. package/dungeons/vertical/community/community.js +1589 -0
  28. package/dungeons/vertical/community/community.sql +216 -0
  29. package/dungeons/vertical/community/community.verify.mjs +72 -0
  30. package/dungeons/vertical/crypto/crypto.js +1483 -0
  31. package/dungeons/vertical/crypto/crypto.sql +194 -0
  32. package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
  33. package/dungeons/vertical/dating/dating.js +1540 -0
  34. package/dungeons/vertical/dating/dating.sql +288 -0
  35. package/dungeons/vertical/dating/dating.verify.mjs +72 -0
  36. package/dungeons/vertical/devtools/devtools.js +1524 -0
  37. package/dungeons/vertical/devtools/devtools.sql +161 -0
  38. package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
  39. package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
  40. package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
  41. package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
  42. package/dungeons/vertical/education/education.js +1763 -0
  43. package/dungeons/vertical/education/education.sql +243 -0
  44. package/dungeons/vertical/education/education.verify.mjs +75 -0
  45. package/dungeons/vertical/fintech/fintech.js +1410 -0
  46. package/dungeons/vertical/fintech/fintech.sql +175 -0
  47. package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
  48. package/dungeons/vertical/fitness/fitness.js +1495 -0
  49. package/dungeons/vertical/fitness/fitness.sql +206 -0
  50. package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
  51. package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
  52. package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
  53. package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
  54. package/dungeons/vertical/gaming/gaming.js +1741 -0
  55. package/dungeons/vertical/gaming/gaming.sql +249 -0
  56. package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
  57. package/dungeons/vertical/healthcare/healthcare.js +1488 -0
  58. package/dungeons/vertical/healthcare/healthcare.sql +193 -0
  59. package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
  60. package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
  61. package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
  62. package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
  63. package/dungeons/vertical/logistics/logistics.js +1541 -0
  64. package/dungeons/vertical/logistics/logistics.sql +205 -0
  65. package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
  66. package/dungeons/vertical/marketplace/marketplace.js +1438 -0
  67. package/dungeons/vertical/marketplace/marketplace.sql +148 -0
  68. package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
  69. package/dungeons/vertical/{media.js → media/media.js} +557 -58
  70. package/dungeons/vertical/media/media.sql +173 -0
  71. package/dungeons/vertical/media/media.verify.mjs +74 -0
  72. package/dungeons/vertical/real-estate/real-estate.js +1629 -0
  73. package/dungeons/vertical/real-estate/real-estate.sql +167 -0
  74. package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
  75. package/dungeons/vertical/sass/sass.js +1733 -0
  76. package/dungeons/vertical/sass/sass.sql +173 -0
  77. package/dungeons/vertical/sass/sass.verify.mjs +74 -0
  78. package/dungeons/vertical/social/social.js +1456 -0
  79. package/dungeons/vertical/social/social.sql +160 -0
  80. package/dungeons/vertical/social/social.verify.mjs +76 -0
  81. package/dungeons/vertical/streaming/streaming.js +677 -0
  82. package/dungeons/vertical/streaming/streaming.sql +101 -0
  83. package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
  84. package/dungeons/vertical/support-desk/support-desk.js +741 -0
  85. package/dungeons/vertical/support-desk/support-desk.sql +137 -0
  86. package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
  87. package/dungeons/vertical/travel/travel.js +1603 -0
  88. package/dungeons/vertical/travel/travel.sql +236 -0
  89. package/dungeons/vertical/travel/travel.verify.mjs +72 -0
  90. package/index.js +2 -2
  91. package/lib/core/config-validator.js +37 -2
  92. package/lib/core/context.js +4 -2
  93. package/lib/core/dungeon-loader.js +20 -15
  94. package/lib/core/dungeon-to-json.js +22 -4
  95. package/lib/core/storage.js +3 -3
  96. package/lib/generators/funnels.js +36 -9
  97. package/lib/hook-helpers/cohort.js +53 -0
  98. package/lib/hook-helpers/index.js +8 -0
  99. package/lib/hook-helpers/mutate.js +1 -1
  100. package/lib/hook-helpers/shape.js +319 -0
  101. package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
  102. package/lib/hook-patterns/attributed-by-source.js +60 -58
  103. package/lib/hook-patterns/frequency-by-frequency.js +14 -4
  104. package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
  105. package/lib/hook-patterns/index.js +1 -1
  106. package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
  107. package/lib/orchestrators/user-loop.js +41 -0
  108. package/lib/templates/story-spec.schema.json +175 -0
  109. package/lib/utils/json-evaluator.js +1 -1
  110. package/lib/utils/utils.js +5 -4
  111. package/lib/verify/coerce.js +186 -0
  112. package/lib/verify/counting.js +270 -32
  113. package/lib/verify/emulate-breakdown.js +1285 -203
  114. package/lib/verify/first-time.js +89 -0
  115. package/lib/verify/flows.js +679 -0
  116. package/lib/verify/formula.js +259 -0
  117. package/lib/verify/funnel-engine.js +697 -113
  118. package/lib/verify/index.js +32 -4
  119. package/lib/verify/sessionize.js +275 -0
  120. package/lib/verify/story-runner.js +483 -0
  121. package/lib/verify/verify-dungeon.js +59 -37
  122. package/package.json +2 -1
  123. package/scripts/extract-dungeon-schema.mjs +2 -2
  124. package/scripts/run-many.mjs +2 -2
  125. package/scripts/verify-runner.mjs +2 -2
  126. package/scripts/verify-stories.mjs +244 -0
  127. package/types.d.ts +296 -30
  128. package/dungeons/vertical/ai-platform.js +0 -818
  129. package/dungeons/vertical/community.js +0 -966
  130. package/dungeons/vertical/crypto.js +0 -895
  131. package/dungeons/vertical/dating.js +0 -830
  132. package/dungeons/vertical/devtools.js +0 -1050
  133. package/dungeons/vertical/ecommerce.js +0 -811
  134. package/dungeons/vertical/education.js +0 -964
  135. package/dungeons/vertical/fintech.js +0 -946
  136. package/dungeons/vertical/fitness.js +0 -935
  137. package/dungeons/vertical/food-delivery.js +0 -934
  138. package/dungeons/vertical/gaming.js +0 -1120
  139. package/dungeons/vertical/healthcare.js +0 -945
  140. package/dungeons/vertical/insurance-application.js +0 -1039
  141. package/dungeons/vertical/logistics.js +0 -986
  142. package/dungeons/vertical/marketplace.js +0 -1019
  143. package/dungeons/vertical/real-estate.js +0 -872
  144. package/dungeons/vertical/sass.js +0 -995
  145. package/dungeons/vertical/social.js +0 -904
  146. package/dungeons/vertical/travel.js +0 -930
@@ -0,0 +1,1641 @@
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
+ import { findFirstSequence, scaleFunnelTTC } from "@ak--47/dungeon-master/hook-helpers";
9
+ /** @typedef {import("../../../types").Dungeon} Config */
10
+
11
+ // ── OVERVIEW ──
12
+ /*
13
+ * NAME: QuickBite
14
+ * APP: Food delivery platform (DoorDash/Uber Eats style). Users browse
15
+ * restaurants, build carts, place orders, track deliveries, and
16
+ * rate their experiences. Monetization via delivery fees,
17
+ * QuickBite+ subscription, and promotional coupons.
18
+ * SCALE: 10,000 users, ~1.0M events, 121 days (2026-01-01 → 2026-05-01)
19
+ * CORE LOOP: sign up → browse/search → add to cart → checkout → order placed → track → rate → reorder
20
+ *
21
+ * EVENTS (17):
22
+ * restaurant browsed (18) > restaurant viewed (15) > item added to cart (14)
23
+ * > order tracked (13) > checkout started (12) > search performed (11)
24
+ * > order placed (10) > order delivered (9) > promotion viewed (8)
25
+ * > order rated (7) > reorder initiated (6) > item removed from cart (5)
26
+ * > coupon applied (4) > support ticket (3) > subscription started (2)
27
+ * > subscription cancelled (1) > account created (1)
28
+ *
29
+ * FUNNELS (8):
30
+ * - Onboarding: account created → restaurant browsed → restaurant viewed (80%)
31
+ * - Browse Discovery: restaurant browsed → restaurant viewed → item added to cart (55%)
32
+ * - Search Ordering: search performed → restaurant viewed → item added to cart → checkout started (45%)
33
+ * - Order Lifecycle: checkout started → order placed → order tracked → order delivered (65%)
34
+ * - Reorder Loop: order delivered → order rated → reorder initiated (40%)
35
+ * - Promo Flow: promotion viewed → coupon applied → checkout started (50%)
36
+ * - Support Flow: support ticket → order rated (45%)
37
+ * - Subscription Mgmt: subscription started → order placed → subscription cancelled (20%)
38
+ *
39
+ * USER PROPS: preferred_cuisine, avg_order_value, orders_per_month, favorite_restaurant_count, Platform, subscription_tier, city
40
+ * SUPER PROPS: Platform, subscription_tier, city
41
+ * SCD PROPS: subscription_tier (free/trial/monthly/annual, monthly fuzzy, max 6),
42
+ * restaurant_tier (new/verified/featured/premium, monthly fixed, max 6, type=restaurant_id)
43
+ * GROUPS: restaurant_id (200 restaurants; restaurant viewed / order placed / order rated)
44
+ */
45
+
46
+ // ── HOOK STORIES ──
47
+ /*
48
+ * NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable only via
49
+ * behavioral cohorts or raw-prop breakdowns (HOD, day, segment).
50
+ *
51
+ * ───────────────────────────────────────────────────────────────────────────────
52
+ * H1. LUNCH/DINNER RUSH (everything)
53
+ * ───────────────────────────────────────────────────────────────────────────────
54
+ *
55
+ * PATTERN: 30% of "order delivered" events that fall outside meal-hour
56
+ * windows (11-13 UTC and 17-20 UTC) are dropped, depressing non-meal-time
57
+ * completion. Mutation: event drop. Discover via order delivered HOD chart.
58
+ *
59
+ * MEASURABLE SIGNATURE: raw HOD volume is soup-confounded (the engine's hour
60
+ * distribution is not flat), so the clean read is a ratio-of-ratios:
61
+ * delivered-per-placed off-hours ÷ delivered-per-placed rush-hours ≈ 0.70
62
+ * (the keep rate). "order placed" shares the soup HOD and is untouched by
63
+ * H1; H7's drop is hour-independent so it cancels too.
64
+ *
65
+ * HOW TO FIND IT IN MIXPANEL:
66
+ *
67
+ * Report 1: Order Delivered Volume by Hour of Day
68
+ * - Report type: Insights
69
+ * - Event: "order delivered"
70
+ * - Measure: Total
71
+ * - Breakdown: Hour of day
72
+ * - Expected: 11-13 and 17-20 stand ~1.4x above neighboring hours
73
+ *
74
+ * REAL-WORLD ANALOGUE: Meal-hour orders convert at higher rates.
75
+ *
76
+ * ───────────────────────────────────────────────────────────────────────────────
77
+ * H2. COUPON INJECTION (everything)
78
+ * ───────────────────────────────────────────────────────────────────────────────
79
+ *
80
+ * PATTERN: Free-tier users get extra "coupon applied" events cloned into the
81
+ * stream near checkout (30% chance per checkout). Cloned with unique offset
82
+ * timestamps. No flag.
83
+ *
84
+ * MEASURABLE SIGNATURE: coupons-per-checkout(Free) − coupons-per-checkout(QB+)
85
+ * ≈ +0.30 — the injection likelihood recovered directly. Organic coupons/user
86
+ * is tier-independent (~3.1); Free users average ~13 checkouts, so total
87
+ * coupons/user lands near 7.0 for Free vs 3.1 for QB+ — a ~2.0-2.5x ratio
88
+ * (NOT the ~1.3x an additive-percentage intuition suggests: 30% of 13
89
+ * checkouts more than doubles the organic coupon count).
90
+ *
91
+ * HOW TO FIND IT IN MIXPANEL:
92
+ *
93
+ * Report 1: Coupons per User by Tier
94
+ * - Report type: Insights
95
+ * - Event: "coupon applied"
96
+ * - Measure: Total per user
97
+ * - Breakdown: "subscription_tier"
98
+ * - Expected: Free ~ 2.0-2.5x QuickBite+
99
+ *
100
+ * REAL-WORLD ANALOGUE: Free-tier users are the target of coupon promos.
101
+ *
102
+ * ───────────────────────────────────────────────────────────────────────────────
103
+ * H3. LATE NIGHT MUNCHIES (everything)
104
+ * ───────────────────────────────────────────────────────────────────────────────
105
+ *
106
+ * PATTERN: 10PM-2AM UTC: 70% of "restaurant viewed" / "item added to cart"
107
+ * events get cuisine_type flipped to American, item_price bumped 1.3x
108
+ * (price bump is unconditional in the window; the flip is the 70% coin).
109
+ * Mutates existing props. No flag — discover via HOD breakdown.
110
+ *
111
+ * MEASURABLE SIGNATURE: organic American share is ~0.16 (engine's pick is
112
+ * not uniform over the 8 cuisines), hour-independent — "restaurant browsed"
113
+ * (never flipped) confirms it at any hour. Late-night viewed share
114
+ * = 0.70 + 0.30 × organic ≈ 0.75. Inverting recovers the knob exactly:
115
+ * (late_share − off_share) / (1 − off_share) ≈ 0.70. Late-night
116
+ * item_price ≈ 1.3x off-hours item_price.
117
+ *
118
+ * HOW TO FIND IT IN MIXPANEL:
119
+ *
120
+ * Report 1: Cuisine Distribution by Hour of Day
121
+ * - Report type: Insights
122
+ * - Event: "restaurant viewed"
123
+ * - Measure: Total
124
+ * - Breakdown: "cuisine_type"
125
+ * - Filter: hour 22-02
126
+ * - Expected: American share ~75% vs ~16% off-hours
127
+ *
128
+ * Report 2: Avg item_price by Hour of Day
129
+ * - Report type: Insights
130
+ * - Event: "item added to cart"
131
+ * - Measure: Average of "item_price"
132
+ * - Breakdown: Hour of day
133
+ * - Expected: 22-02 hours show ~ 1.3x baseline price
134
+ *
135
+ * REAL-WORLD ANALOGUE: Late-night ordering skews to fast food and impulse buys.
136
+ *
137
+ * ───────────────────────────────────────────────────────────────────────────────
138
+ * H4. RAINY WEEK SURGE (everything)
139
+ * ───────────────────────────────────────────────────────────────────────────────
140
+ *
141
+ * PATTERN: Days 20-27 (Jan 21-28, inclusive), "order placed" delivery_fee
142
+ * doubled and 40% of in-window order-placed events get a duplicate cloned
143
+ * event with a 5-60 min offset. No flag — discover via line chart by day on
144
+ * order placed volume + delivery_fee average.
145
+ *
146
+ * MEASURABLE SIGNATURE: the cleanest read is the duplicate share itself —
147
+ * in-window (uid, order_id) pairs appearing twice ÷ distinct orders ≈ 0.40
148
+ * (duplicates are byte-identical clones except a 5-60 min time offset).
149
+ * Volume: raw daily counts drift with the soup, so use a ratio-of-ratios
150
+ * against "checkout started" (same soup, untouched by H4):
151
+ * (placed_win/placed_base) ÷ (checkout_win/checkout_base) ≈ 1.40, diluted a
152
+ * few percent by H6-churned users whose window orders were deleted. Fee:
153
+ * window avg ≈ 2.0x baseline avg (±5% wobble from the window's organic fee
154
+ * draw; the `(fee || 5)` fallback is inert in practice — the fee pool
155
+ * bottoms out at 1, never 0). Duplicates share the doubled fee (cloned
156
+ * after the fee pass).
157
+ *
158
+ * HOW TO FIND IT IN MIXPANEL:
159
+ *
160
+ * Report 1: Order Volume Over Time
161
+ * - Report type: Insights
162
+ * - Event: "order placed"
163
+ * - Measure: Total
164
+ * - Line chart by day
165
+ * - Expected: visible ~1.4x spike days 20-27
166
+ *
167
+ * Report 2: Avg delivery_fee Over Time
168
+ * - Report type: Insights
169
+ * - Event: "order placed"
170
+ * - Measure: Average of "delivery_fee"
171
+ * - Line chart by day
172
+ * - Expected: ~ 2x days 20-27
173
+ *
174
+ * REAL-WORLD ANALOGUE: Weather-driven demand surge with surge pricing.
175
+ *
176
+ * ───────────────────────────────────────────────────────────────────────────────
177
+ * H5. REFERRAL POWER USERS (everything)
178
+ * ───────────────────────────────────────────────────────────────────────────────
179
+ *
180
+ * PATTERN: Users with referral_code=true on account-created event (~1/3 of
181
+ * born-in users) get food_rating boosted to 4-5 and ~50% of their reorder
182
+ * events cloned with a 1-7 day offset. Mutates existing prop, no flag.
183
+ *
184
+ * MEASURABLE SIGNATURE: cohort is only identifiable among born-in-dataset
185
+ * users (pre-existing users have no account-created event) — compare within
186
+ * born-ins. Reorders/user referred ÷ non-referred ≈ 1.4 (50% clone chance
187
+ * per visited reorder; clones spliced at idx+1 are re-visited and can
188
+ * re-clone, but tail events shifted past the original loop range are
189
+ * skipped, so the net multiplier sits below a naive 1.5; the referred
190
+ * cohort is ~1/3 of born-ins, so expect ±0.1 sampling wobble). Same range
191
+ * truncation leaves some "order rated" events unboosted: referred avg
192
+ * food_rating ≈ 4.4 (not a full 4.5) vs organic ≈ 2.8.
193
+ *
194
+ * HOW TO FIND IT IN MIXPANEL:
195
+ *
196
+ * Report 1: Reorders per User by Referral Cohort
197
+ * - Report type: Insights (with cohort)
198
+ * - Cohort A: users with account-created.referral_code=true
199
+ * - Cohort B: users with account-created.referral_code=false
200
+ * - Event: "reorder initiated"
201
+ * - Measure: Total per user
202
+ * - Expected: A ~ 1.4-1.5x B
203
+ *
204
+ * REAL-WORLD ANALOGUE: Referred users tend to be more loyal.
205
+ *
206
+ * ───────────────────────────────────────────────────────────────────────────────
207
+ * H6. TRIAL CONVERSION (everything)
208
+ * ───────────────────────────────────────────────────────────────────────────────
209
+ *
210
+ * PATTERN: Users with subscription-started.trial=true who place <3 orders in
211
+ * their first 14 days: 60% of those users (per-user coin, all-or-nothing)
212
+ * have ALL events after day 14 deleted. No flag.
213
+ *
214
+ * MEASURABLE SIGNATURE: the deletion removes the subscription-started event
215
+ * itself whenever it fired after day 14, so churned users with a late trial
216
+ * start VANISH from the visible trial cohort (survivor bias). The honest
217
+ * observable: among visible trial users, share with zero post-day-14
218
+ * activity ≈ 0.25 for non-activated (<3 early orders) vs ≈ 0.04 for
219
+ * activated — a ~5-7x retention divergence. Do NOT read per-user post/pre
220
+ * event ratios: activation (≥3 early orders) selects for front-loaded
221
+ * activity and confounds the comparison.
222
+ *
223
+ * HOW TO FIND IT IN MIXPANEL:
224
+ *
225
+ * Report 1: Retention by Trial Order Count
226
+ * - Report type: Retention
227
+ * - Cohort A: trial users with >= 3 orders in first 14 days
228
+ * - Cohort B: trial users with < 3
229
+ * - Expected: B shows a sharp cliff after day 14; A retains normally
230
+ *
231
+ * REAL-WORLD ANALOGUE: Trial users who fail to activate churn fast.
232
+ *
233
+ * ───────────────────────────────────────────────────────────────────────────────
234
+ * H7. FIRST ORDER BONUS (everything)
235
+ * ───────────────────────────────────────────────────────────────────────────────
236
+ *
237
+ * PATTERN: ~50% of users (deterministic: first char of user_id has odd
238
+ * char code → "returning") have 30% of their "order delivered" events
239
+ * dropped. No flag — analyst sees segment-level completion gap via cohort
240
+ * builder by hash bucket.
241
+ *
242
+ * MEASURABLE SIGNATURE: delivered-per-placed (odd bucket) ÷
243
+ * delivered-per-placed (even bucket) ≈ 0.70 — the keep rate recovered.
244
+ * "order placed" is untouched by H7 and normalizes engagement; H1's drop is
245
+ * hash-independent and cancels in the ratio.
246
+ *
247
+ * HOW TO FIND IT IN MIXPANEL:
248
+ *
249
+ * Report 1: Order Delivered Conversion by First-Letter-Hash
250
+ * - Report type: Funnels
251
+ * - Steps: "checkout started" -> "order placed" -> "order delivered"
252
+ * - Breakdown: derived hash bucket on distinct_id
253
+ * - Expected: odd bucket ~ 30% lower conversion on final step
254
+ *
255
+ * REAL-WORLD ANALOGUE: First-order promos lift new-user conversion.
256
+ *
257
+ * ───────────────────────────────────────────────────────────────────────────────
258
+ * H8. ORDER-COUNT MAGIC NUMBER (everything)
259
+ * ───────────────────────────────────────────────────────────────────────────────
260
+ *
261
+ * PATTERN: Users in the 4-8 order-placed sweet spot get +40% on order_total.
262
+ * Users with 9+ orders are over-engaged; their order_total is reduced to
263
+ * 0.65x (basket fatigue). No flag — discover by binning users on order count.
264
+ *
265
+ * MEASURABLE SIGNATURE: the hook buckets on the order count at hook time,
266
+ * BEFORE H4 duplication and H6 deletion — output-count bucketing is
267
+ * contaminated at the edges (an H6-churned 9+ user lands in the 0-3 output
268
+ * bucket carrying 0.65x totals; an H4-duplicated 8-order user lands in 9+
269
+ * carrying 1.4x). Clean read restricts to users unaffected by both: alive
270
+ * past day 14 AND zero rainy-window orders. On that population:
271
+ * sweet/base avg order_total ≈ 1.4, over/sweet ≈ 0.46 (= 0.65/1.4).
272
+ *
273
+ * HOW TO FIND IT IN MIXPANEL:
274
+ *
275
+ * Report 1: Avg Order Total by Order-Count Bucket
276
+ * - Report type: Insights (with cohort)
277
+ * - Cohort A: users with 4-8 "order placed"
278
+ * - Cohort B: users with 0-3
279
+ * - Event: "order placed"
280
+ * - Measure: Average of "order_total"
281
+ * - Expected: A ~ 1.4x B
282
+ *
283
+ * Report 2: Avg Order Total on Heavy Orderers
284
+ * - Report type: Insights (with cohort)
285
+ * - Cohort C: users with >= 9 "order placed"
286
+ * - Cohort A: users with 4-8
287
+ * - Event: "order placed"
288
+ * - Measure: Average of "order_total"
289
+ * - Expected: C ~ 0.46x order_total vs A (0.65x cut on a 1.4x-boosted
290
+ * comparison group)
291
+ *
292
+ * REAL-WORLD ANALOGUE: Engaged orderers lift basket size; over-orderers
293
+ * hit fatigue and slow down.
294
+ *
295
+ * ───────────────────────────────────────────────────────────────────────────────
296
+ * H9. ORDER LIFECYCLE TTC (everything)
297
+ * ───────────────────────────────────────────────────────────────────────────────
298
+ *
299
+ * PATTERN: QuickBite+ users get delivery timing properties scaled 0.67x
300
+ * (faster), Free users get 1.4x (slower). Affects actual_delivery_mins,
301
+ * eta_mins, delivery_time_est_mins on every event carrying them, plus a
302
+ * timestamp compression on the user's FIRST checkout→placed→tracked→delivered
303
+ * sequence (visible in Mixpanel's per-instance funnel TTC).
304
+ *
305
+ * MEASURABLE SIGNATURE: property ratio QB+/Free ≈ 0.48 (= 0.67/1.4) on
306
+ * avg actual_delivery_mins and avg eta_mins. The timestamp shift touches
307
+ * only one funnel instance among a user's ~13 checkouts, so cross-event
308
+ * SQL/JS TTC aggregations CANNOT see it — do not assert wall-clock TTC
309
+ * outside Mixpanel's per-instance funnel report.
310
+ *
311
+ * HOW TO FIND IT IN MIXPANEL:
312
+ *
313
+ * Report 1: Avg Delivery Time by Subscription Tier
314
+ * - Report type: Insights
315
+ * - Event: "order delivered"
316
+ * - Measure: Average of "actual_delivery_mins"
317
+ * - Breakdown: "subscription_tier"
318
+ * - Expected: QuickBite+ ~ 0.48x Free
319
+ *
320
+ * Report 2: Avg ETA by Subscription Tier
321
+ * - Report type: Insights
322
+ * - Event: "order tracked"
323
+ * - Measure: Average of "eta_mins"
324
+ * - Breakdown: "subscription_tier"
325
+ * - Expected: QuickBite+ ~ 0.48x Free
326
+ *
327
+ * REAL-WORLD ANALOGUE: Premium subscribers get priority dispatch and faster
328
+ * delivery routing.
329
+ *
330
+ * ───────────────────────────────────────────────────────────────────────────────
331
+ * H10. CITY DENSITY REORDER BOOST (funnel-pre)
332
+ * ───────────────────────────────────────────────────────────────────────────────
333
+ *
334
+ * PATTERN: On the reorder funnel (order delivered → order rated → reorder
335
+ * initiated), dense cities (SF, NYC) get conversionRate 40 → 56; sprawl
336
+ * cities (Houston, Phoenix) 40 → 28. Scoped to the funnel containing
337
+ * "reorder initiated".
338
+ *
339
+ * MEASURABLE SIGNATURE: non-converted instances take u.integer(1, steps−1)
340
+ * steps (determineConversion, lib/generators/funnels.js) — the LAST step
341
+ * fires only on conversion, so P(reorder per instance) = conversionRate
342
+ * exactly. Delivered-per-user is city-flat (H1/H7 drops are
343
+ * city-independent), so reorders-per-DELIVERED recovers the knobs exactly:
344
+ * dense/base = 1.40, sprawl/base = 0.70. Plain reorders-per-user shows the
345
+ * same direction but carries per-city engagement noise. H5's referral
346
+ * cloning is city-independent and scales all cities equally.
347
+ *
348
+ * HOW TO FIND IT IN MIXPANEL:
349
+ *
350
+ * Report 1: Reorder Funnel Conversion by City
351
+ * - Report type: Funnels
352
+ * - Steps: "order delivered" → "order rated" → "reorder initiated"
353
+ * - Breakdown: "city"
354
+ * - Expected: SF / NYC above baseline; Houston / Phoenix below
355
+ *
356
+ * REAL-WORLD ANALOGUE: Dense cities have more restaurant choice and
357
+ * faster delivery, driving higher repeat ordering behavior.
358
+ *
359
+ * ───────────────────────────────────────────────────────────────────────────────
360
+ * EXPECTED METRICS SUMMARY
361
+ * ───────────────────────────────────────────────────────────────────────────────
362
+ *
363
+ * Hook | Metric | Mechanism | Measured (10K)
364
+ * ─────|-------------------------------------------|-----------|---------------
365
+ * H1 | delivered-per-placed off/rush RoR | 0.70 | 0.701
366
+ * H2 | coupons-per-checkout diff (Free − QB+) | +0.30 | +0.293
367
+ * H2 | coupons/user Free ÷ QB+ | ~2.0-2.5 | 2.155
368
+ * H3 | flip-rate inversion (late−off)/(1−off) | 0.70 | 0.704
369
+ * H3 | late/off item_price ratio | 1.30 | 1.290
370
+ * H4 | in-window duplicate share | 0.40 | 0.397
371
+ * H4 | placed vol RoR vs checkout (win/base) | ~1.40 | 1.391
372
+ * H4 | delivery_fee window/baseline avg | ~2.0 | 1.976
373
+ * H5 | born-in reorders/user ref ÷ non-ref | ~1.4 | 1.647 (STRONG; 2K iters read 1.34/1.45 — clone-cascade truncation is user-mix-sensitive, cohort n=379)
374
+ * H5 | referred avg food_rating (vs organic ~2.8)| ~4.4 | 4.47 / 2.72
375
+ * H6 | zero-post-day-14 share nonact vs act | ~0.25/0.04| 0.238 / 0.050 (STRONG; divergence 4.7x — act share is the organic quiet rate, not a knob)
376
+ * H7 | delivered-per-placed odd ÷ even | 0.70 | 0.703
377
+ * H8 | clean-pop sweet/base order_total | 1.40 | 1.402
378
+ * H8 | clean-pop over/sweet order_total | 0.46 | 0.465
379
+ * H9 | actual_delivery_mins QB+ ÷ Free | 0.48 | 0.479
380
+ * H9 | eta_mins QB+ ÷ Free | 0.48 | 0.476
381
+ * H10 | reorders-per-delivered dense ÷ base | 1.40 | 1.369
382
+ * H10 | reorders-per-delivered sprawl ÷ base | 0.70 | 0.716
383
+ */
384
+
385
+ // ── SCALE ──
386
+ const SEED = "harness-food";
387
+ const NUM_USERS = 10_000;
388
+ const DATASET_START = "2026-01-01T00:00:00Z";
389
+ const DATASET_END = "2026-05-01T23:59:59Z";
390
+ const EVENTS_PER_DAY = 1.2;
391
+ const token = process.env.MP_TOKEN || "your-mixpanel-token";
392
+
393
+ const chance = u.initChance(SEED);
394
+
395
+ // ── KNOBS (tweak these to reshape stories) ──
396
+ const RUSH_DROP_LIKELIHOOD = 30;
397
+ const RUSH_LUNCH_START = 11;
398
+ const RUSH_LUNCH_END = 13;
399
+ const RUSH_DINNER_START = 17;
400
+ const RUSH_DINNER_END = 20;
401
+
402
+ const COUPON_INJECT_LIKELIHOOD = 30;
403
+
404
+ const LATE_NIGHT_START = 22;
405
+ const LATE_NIGHT_END = 2;
406
+ const LATE_NIGHT_FLIP_LIKELIHOOD = 70;
407
+ const LATE_NIGHT_PRICE_MULT = 1.3;
408
+
409
+ const RAINY_START_DAY = 20;
410
+ const RAINY_END_DAY = 27;
411
+ const RAINY_FEE_MULT = 2;
412
+ const RAINY_DUP_LIKELIHOOD = 40;
413
+
414
+ const REFERRAL_CLONE_LIKELIHOOD = 50;
415
+ const REFERRAL_RATING_MIN = 4;
416
+ const REFERRAL_RATING_MAX = 5;
417
+
418
+ const TRIAL_EARLY_DAYS = 14;
419
+ const TRIAL_MIN_ORDERS = 3;
420
+ const TRIAL_DROP_LIKELIHOOD = 60;
421
+
422
+ const FIRST_ORDER_DROP_LIKELIHOOD = 30;
423
+
424
+ const ORDER_SWEET_MIN = 4;
425
+ const ORDER_SWEET_MAX = 8;
426
+ const ORDER_OVER_THRESHOLD = 9;
427
+ const ORDER_SWEET_BOOST = 1.4;
428
+ const ORDER_OVER_FACTOR = 0.65;
429
+
430
+ const TTC_QB_PLUS_FACTOR = 0.67;
431
+ const TTC_FREE_FACTOR = 1.4;
432
+
433
+ const CITY_DENSE_MULT = 1.4;
434
+ const CITY_SPRAWL_MULT = 0.7;
435
+
436
+ // ── DATA ARRAYS ──
437
+ const restaurantIds = v.range(1, 201).map(n => `rest_${v.uid(6)}`);
438
+ const itemIds = v.range(1, 301).map(n => `item_${v.uid(7)}`);
439
+ const orderIds = v.range(1, 5001).map(n => `order_${v.uid(8)}`);
440
+ const couponCodes = v.range(1, 51).map(n => `QUICK${v.uid(5).toUpperCase()}`);
441
+
442
+ // ── HELPER FUNCTIONS ──
443
+ function handleFunnelPreHooks(record, meta) {
444
+ // H10: CITY DENSITY REORDER BOOST — dense cities 1.4x; sprawl 0.7x
445
+ // on the reorder funnel.
446
+ const isReorderFunnel = meta.funnel?.sequence?.includes("reorder initiated");
447
+ if (isReorderFunnel) {
448
+ const city = meta.profile?.city;
449
+ if (city === "San Francisco" || city === "New York") {
450
+ record.conversionRate = Math.min(95, Math.round(record.conversionRate * CITY_DENSE_MULT));
451
+ } else if (city === "Houston" || city === "Phoenix") {
452
+ record.conversionRate = Math.round(record.conversionRate * CITY_SPRAWL_MULT);
453
+ }
454
+ }
455
+ return record;
456
+ }
457
+
458
+ function handleEverythingHooks(record, meta) {
459
+ // UTC mode is load-bearing: dayjs.unix() returns a LOCAL-mode instance, and
460
+ // local .add(N, "days") does calendar-day arithmetic — it slips 1h across the
461
+ // March-8-2026 DST spring-forward and makes every derived boundary (rainy
462
+ // window, trial day-14 cutoff) depend on the host timezone, breaking the
463
+ // seeded-determinism contract. Same fix as real-estate / insurance.
464
+ const datasetStart = dayjs.unix(meta.datasetStart).utc();
465
+ const RAINY_WEEK_START = datasetStart.add(RAINY_START_DAY, 'days');
466
+ // END_DAY + 1 because the gate below is `isBefore(end)`: with end at the
467
+ // START of day 27, day 27's events fell outside the window and the doc's
468
+ // "days 20-27" only covered 20-26. End at start-of-day-28 makes day 27
469
+ // (the last rainy day) inclusive.
470
+ const RAINY_WEEK_END = datasetStart.add(RAINY_END_DAY + 1, 'days');
471
+ const userEvents = record;
472
+ const profile = meta.profile;
473
+
474
+ // Stamp superProps from profile so they stay consistent per user
475
+ if (profile) {
476
+ userEvents.forEach((event) => {
477
+ if (profile.Platform !== undefined) event.Platform = profile.Platform;
478
+ if (profile.subscription_tier !== undefined) event.subscription_tier = profile.subscription_tier;
479
+ if (profile.city !== undefined) event.city = profile.city;
480
+ });
481
+ }
482
+
483
+ // H9: ORDER LIFECYCLE TTC — QuickBite+ 0.67x, Free 1.4x on delivery
484
+ // timing properties + funnel timestamp shift.
485
+ if (profile) {
486
+ const tier = profile.subscription_tier;
487
+ const ttcFactor = (
488
+ tier === "QuickBite+" ? TTC_QB_PLUS_FACTOR :
489
+ tier === "Free" ? TTC_FREE_FACTOR :
490
+ 1.0
491
+ );
492
+ if (ttcFactor !== 1.0) {
493
+ // Timestamp shift: affects Mixpanel funnel TTC
494
+ const orderSeq = findFirstSequence(
495
+ userEvents,
496
+ ["checkout started", "order placed", "order tracked", "order delivered"],
497
+ 60 * 24 * 7
498
+ );
499
+ if (orderSeq) scaleFunnelTTC(orderSeq, ttcFactor);
500
+ // Property scale: affects Insights AVG reports
501
+ userEvents.forEach(e => {
502
+ if (typeof e.actual_delivery_mins === "number") {
503
+ e.actual_delivery_mins = Math.round(e.actual_delivery_mins * ttcFactor);
504
+ }
505
+ if (typeof e.eta_mins === "number") {
506
+ e.eta_mins = Math.round(e.eta_mins * ttcFactor);
507
+ }
508
+ if (typeof e.delivery_time_est_mins === "number") {
509
+ e.delivery_time_est_mins = Math.round(e.delivery_time_est_mins * ttcFactor);
510
+ }
511
+ });
512
+ }
513
+ }
514
+
515
+ // H3: LATE NIGHT MUNCHIES — 22-02 UTC: 70% flip to American, 1.3x price
516
+ userEvents.forEach(e => {
517
+ if (e.event === "restaurant viewed" || e.event === "item added to cart") {
518
+ const hour = new Date(e.time).getUTCHours();
519
+ const isLateNight = hour >= LATE_NIGHT_START || hour <= LATE_NIGHT_END;
520
+ if (isLateNight) {
521
+ if (e.cuisine_type !== undefined && chance.bool({ likelihood: LATE_NIGHT_FLIP_LIKELIHOOD })) {
522
+ e.cuisine_type = "American";
523
+ }
524
+ if (e.item_price !== undefined) {
525
+ e.item_price = Math.round(e.item_price * LATE_NIGHT_PRICE_MULT * 100) / 100;
526
+ }
527
+ }
528
+ }
529
+ });
530
+
531
+ // H2: COUPON INJECTION — Free-tier users get coupon-applied events
532
+ // spliced near checkout. Cloned from existing template with unique offset.
533
+ if (profile && profile.subscription_tier === "Free") {
534
+ for (let i = userEvents.length - 1; i >= 1; i--) {
535
+ const evt = userEvents[i];
536
+ if (evt.event === "checkout started" && chance.bool({ likelihood: COUPON_INJECT_LIKELIHOOD })) {
537
+ const prevEvent = userEvents[i - 1];
538
+ const midTime = dayjs(prevEvent.time).add(
539
+ dayjs(evt.time).diff(dayjs(prevEvent.time)) / 2,
540
+ 'milliseconds'
541
+ ).toISOString();
542
+
543
+ const couponTemplate = userEvents.find(e => e.event === "coupon applied");
544
+ const couponEvent = {
545
+ ...(couponTemplate || evt),
546
+ event: "coupon applied",
547
+ time: midTime,
548
+ user_id: evt.user_id,
549
+ subscription_tier: profile.subscription_tier,
550
+ Platform: profile.Platform,
551
+ city: profile.city,
552
+ coupon_code: chance.pickone(couponCodes),
553
+ discount_type: chance.pickone(["percent", "flat", "free_delivery"]),
554
+ discount_value: chance.integer({ min: 10, max: 30 }),
555
+ };
556
+ // The checkout-event fallback template carries checkout-only props;
557
+ // strip them so injected coupons match the declared coupon schema.
558
+ delete couponEvent.cart_total;
559
+ delete couponEvent.items_count;
560
+ delete couponEvent.delivery_address_saved;
561
+ userEvents.splice(i, 0, couponEvent);
562
+ }
563
+ }
564
+ }
565
+
566
+ // H1: LUNCH/DINNER RUSH — drop 30% of order-delivered events outside
567
+ // meal windows (11-13 UTC and 17-20 UTC).
568
+ for (let i = userEvents.length - 1; i >= 0; i--) {
569
+ const event = userEvents[i];
570
+ if (event.event === "order delivered") {
571
+ const hour = new Date(event.time).getUTCHours();
572
+ const inRush = (hour >= RUSH_LUNCH_START && hour <= RUSH_LUNCH_END) || (hour >= RUSH_DINNER_START && hour <= RUSH_DINNER_END);
573
+ if (!inRush && chance.bool({ likelihood: RUSH_DROP_LIKELIHOOD })) {
574
+ userEvents.splice(i, 1);
575
+ }
576
+ }
577
+ }
578
+
579
+ // H7: FIRST ORDER BONUS — hash-based ~50% of users (returning) drop
580
+ // 30% of order delivered events. New users keep all.
581
+ const hashUser = userEvents[0] && userEvents[0].user_id;
582
+ const isNewUser = typeof hashUser === "string" && hashUser.charCodeAt(0) % 2 === 0;
583
+ if (!isNewUser) {
584
+ for (let i = userEvents.length - 1; i >= 0; i--) {
585
+ if (userEvents[i].event === "order delivered" && chance.bool({ likelihood: FIRST_ORDER_DROP_LIKELIHOOD })) {
586
+ userEvents.splice(i, 1);
587
+ }
588
+ }
589
+ }
590
+
591
+ // First pass: identify behavioral patterns (no flags written)
592
+ let isReferralUser = false;
593
+ let hasTrialSubscription = false;
594
+ let earlyOrderCount = 0;
595
+ let orderPlacedCount = 0;
596
+ const firstEventTime = userEvents.length > 0 ? dayjs.utc(userEvents[0].time) : null;
597
+
598
+ userEvents.forEach((event) => {
599
+ const eventTime = dayjs.utc(event.time);
600
+ const daysSinceStart = firstEventTime ? eventTime.diff(firstEventTime, 'days', true) : 0;
601
+ if (event.event === "account created" && event.referral_code === true) isReferralUser = true;
602
+ if (event.event === "subscription started" && event.trial === true) hasTrialSubscription = true;
603
+ if (event.event === "order placed") {
604
+ orderPlacedCount++;
605
+ if (daysSinceStart <= TRIAL_EARLY_DAYS) earlyOrderCount++;
606
+ }
607
+ });
608
+
609
+ // H5: REFERRAL POWER USERS — boost food rating to 4-5, clone reorders.
610
+ userEvents.forEach((event, idx) => {
611
+ if (isReferralUser && event.event === "order rated") {
612
+ event.food_rating = chance.integer({ min: REFERRAL_RATING_MIN, max: REFERRAL_RATING_MAX });
613
+ }
614
+ if (isReferralUser && event.event === "reorder initiated" && chance.bool({ likelihood: REFERRAL_CLONE_LIKELIHOOD })) {
615
+ const eventTime = dayjs.utc(event.time);
616
+ userEvents.splice(idx + 1, 0, {
617
+ ...event,
618
+ time: eventTime.add(chance.integer({ min: 1, max: 7 }), 'days').toISOString(),
619
+ user_id: event.user_id,
620
+ order_id: chance.pickone(orderIds),
621
+ original_order_age_days: chance.integer({ min: 3, max: 30 }),
622
+ });
623
+ }
624
+ });
625
+
626
+ // H6: TRIAL CONVERSION — trial subs with <3 early orders drop 60% of
627
+ // post-day-14 events.
628
+ if (hasTrialSubscription && earlyOrderCount < TRIAL_MIN_ORDERS && chance.bool({ likelihood: TRIAL_DROP_LIKELIHOOD })) {
629
+ const trialCutoff = firstEventTime ? firstEventTime.add(TRIAL_EARLY_DAYS, 'days') : null;
630
+ if (trialCutoff) {
631
+ for (let i = userEvents.length - 1; i >= 0; i--) {
632
+ if (dayjs.utc(userEvents[i].time).isAfter(trialCutoff)) {
633
+ userEvents.splice(i, 1);
634
+ }
635
+ }
636
+ }
637
+ }
638
+
639
+ // H4: RAINY WEEK SURGE — days 20-27, double delivery_fee on order-placed.
640
+ userEvents.forEach(e => {
641
+ if (e.event === "order placed") {
642
+ const t = dayjs.utc(e.time);
643
+ if (t.isAfter(RAINY_WEEK_START) && t.isBefore(RAINY_WEEK_END)) {
644
+ e.delivery_fee = (e.delivery_fee || 5) * RAINY_FEE_MULT;
645
+ }
646
+ }
647
+ });
648
+
649
+ // H4 (cont): RAINY WEEK SURGE — duplicate 40% of order-placed events
650
+ // in the rainy window. Cloned with unique offset.
651
+ const rainyDuplicates = [];
652
+ userEvents.forEach((event) => {
653
+ if (event.event === "order placed") {
654
+ const t = dayjs.utc(event.time);
655
+ if (t.isAfter(RAINY_WEEK_START) && t.isBefore(RAINY_WEEK_END) && chance.bool({ likelihood: RAINY_DUP_LIKELIHOOD })) {
656
+ const dup = JSON.parse(JSON.stringify(event));
657
+ dup.time = t.add(chance.integer({ min: 5, max: 60 }), 'minutes').toISOString();
658
+ rainyDuplicates.push(dup);
659
+ }
660
+ }
661
+ });
662
+ if (rainyDuplicates.length > 0) userEvents.push(...rainyDuplicates);
663
+
664
+ // H8: ORDER-COUNT MAGIC NUMBER — sweet 4-8 → +40% on order_total;
665
+ // over 9+ → 0.65x order_total (basket fatigue). No flag.
666
+ if (orderPlacedCount >= ORDER_SWEET_MIN && orderPlacedCount <= ORDER_SWEET_MAX) {
667
+ userEvents.forEach(e => {
668
+ if (e.event === "order placed" && typeof e.order_total === "number") {
669
+ e.order_total = Math.round(e.order_total * ORDER_SWEET_BOOST);
670
+ }
671
+ });
672
+ } else if (orderPlacedCount >= ORDER_OVER_THRESHOLD) {
673
+ userEvents.forEach(e => {
674
+ if (e.event === "order placed" && typeof e.order_total === "number") {
675
+ e.order_total = Math.round(e.order_total * ORDER_OVER_FACTOR);
676
+ }
677
+ });
678
+ }
679
+
680
+ return userEvents;
681
+ }
682
+
683
+ // ── CONFIG ──
684
+ /** @type {Config} */
685
+ const config = {
686
+ version: 2,
687
+ seed: SEED,
688
+ datasetStart: DATASET_START,
689
+ datasetEnd: DATASET_END,
690
+ avgEventsPerUserPerDay: EVENTS_PER_DAY,
691
+ numUsers: NUM_USERS,
692
+ format: "json",
693
+ gzip: true,
694
+ credentials: {
695
+ token,
696
+ },
697
+ switches: {
698
+ hasSessionIds: true,
699
+ alsoInferFunnels: false,
700
+ hasLocation: true,
701
+ hasAndroidDevices: true,
702
+ hasIOSDevices: true,
703
+ hasDesktopDevices: true,
704
+ hasBrowser: false,
705
+ hasCampaigns: false,
706
+ isAnonymous: false,
707
+ hasAdSpend: false,
708
+ hasAvatar: true,
709
+ },
710
+ identity: {
711
+ avgDevicePerUser: 2,
712
+ },
713
+ concurrency: 1,
714
+ writeToDisk: false,
715
+ scdProps: {
716
+ subscription_tier: {
717
+ values: ["free", "trial", "monthly", "annual"],
718
+ frequency: "month",
719
+ timing: "fuzzy",
720
+ max: 6
721
+ },
722
+ restaurant_tier: {
723
+ values: ["new", "verified", "featured", "premium"],
724
+ frequency: "month",
725
+ timing: "fixed",
726
+ max: 6,
727
+ type: "restaurant_id"
728
+ }
729
+ },
730
+
731
+ funnels: [
732
+ {
733
+ sequence: ["account created", "restaurant browsed", "restaurant viewed"],
734
+ isFirstFunnel: true,
735
+ conversionRate: 80,
736
+ timeToConvert: 0.5,
737
+ },
738
+ {
739
+ // Browse and discover: most common action on food delivery apps
740
+ sequence: ["restaurant browsed", "restaurant viewed", "item added to cart"],
741
+ conversionRate: 55,
742
+ timeToConvert: 1,
743
+ weight: 5,
744
+ props: { "restaurant_id": restaurantIds },
745
+ },
746
+ {
747
+ // Search-driven ordering
748
+ sequence: ["search performed", "restaurant viewed", "item added to cart", "checkout started"],
749
+ conversionRate: 45,
750
+ timeToConvert: 2,
751
+ weight: 3,
752
+ },
753
+ {
754
+ // Full order lifecycle: checkout to delivery
755
+ sequence: ["checkout started", "order placed", "order tracked", "order delivered"],
756
+ conversionRate: 65,
757
+ timeToConvert: 2,
758
+ weight: 4,
759
+ props: { "order_id": orderIds },
760
+ },
761
+ {
762
+ // Post-order: rate and reorder
763
+ sequence: ["order delivered", "order rated", "reorder initiated"],
764
+ conversionRate: 40,
765
+ timeToConvert: 24,
766
+ weight: 2,
767
+ },
768
+ {
769
+ // Browsing promos and coupons
770
+ sequence: ["promotion viewed", "coupon applied", "checkout started"],
771
+ conversionRate: 50,
772
+ timeToConvert: 1,
773
+ weight: 2,
774
+ },
775
+ {
776
+ // Support flow
777
+ sequence: ["support ticket", "order rated"],
778
+ conversionRate: 45,
779
+ timeToConvert: 6,
780
+ weight: 1,
781
+ },
782
+ {
783
+ // Subscription management
784
+ sequence: ["subscription started", "order placed", "subscription cancelled"],
785
+ conversionRate: 20,
786
+ timeToConvert: 48,
787
+ weight: 1,
788
+ },
789
+ ],
790
+
791
+ events: [
792
+ {
793
+ event: "account created",
794
+ weight: 1,
795
+ isFirstEvent: true,
796
+ isAuthEvent: true,
797
+ properties: {
798
+ "signup_method": ["email", "google", "apple", "facebook"],
799
+ "referral_code": [false, false, true],
800
+ }
801
+ },
802
+ {
803
+ event: "restaurant browsed",
804
+ weight: 18,
805
+ properties: {
806
+ "cuisine_type": [
807
+ "American",
808
+ "Italian",
809
+ "Chinese",
810
+ "Japanese",
811
+ "Mexican",
812
+ "Indian",
813
+ "Thai",
814
+ "Mediterranean"
815
+ ],
816
+ "sort_by": ["recommended", "distance", "rating", "price"],
817
+ "filter_applied": [false, false, false, true, true],
818
+ }
819
+ },
820
+ {
821
+ event: "restaurant viewed",
822
+ weight: 15,
823
+ isStrictEvent: false,
824
+ properties: {
825
+ "restaurant_id": restaurantIds,
826
+ "cuisine_type": [
827
+ "American",
828
+ "Italian",
829
+ "Chinese",
830
+ "Japanese",
831
+ "Mexican",
832
+ "Indian",
833
+ "Thai",
834
+ "Mediterranean"
835
+ ],
836
+ "avg_rating": u.weighNumRange(1, 5, 0.8, 30),
837
+ "delivery_time_est_mins": u.weighNumRange(15, 90, 1.2, 40),
838
+ "price_tier": ["$", "$$", "$$$", "$$$$"],
839
+ }
840
+ },
841
+ {
842
+ event: "item added to cart",
843
+ weight: 14,
844
+ isStrictEvent: false,
845
+ properties: {
846
+ "item_id": itemIds,
847
+ "item_category": ["entree", "appetizer", "drink", "dessert", "side"],
848
+ "item_price": u.weighNumRange(3, 65, 1.0, 40),
849
+ "customization_count": u.weighNumRange(0, 5, 1.5, 20),
850
+ }
851
+ },
852
+ {
853
+ event: "item removed from cart",
854
+ weight: 5,
855
+ properties: {
856
+ "item_id": itemIds,
857
+ "removal_reason": ["changed_mind", "too_expensive", "substitution"],
858
+ }
859
+ },
860
+ {
861
+ event: "coupon applied",
862
+ weight: 4,
863
+ isStrictEvent: false,
864
+ properties: {
865
+ "coupon_code": couponCodes,
866
+ "discount_type": ["percent", "flat", "free_delivery"],
867
+ "discount_value": u.weighNumRange(5, 50, 1.2, 20),
868
+ }
869
+ },
870
+ {
871
+ event: "checkout started",
872
+ weight: 12,
873
+ isStrictEvent: false,
874
+ properties: {
875
+ "cart_total": u.weighNumRange(8, 150, 0.8, 40),
876
+ "items_count": u.weighNumRange(1, 8, 1.2, 20),
877
+ "delivery_address_saved": [false, false, false, true, true, true, true, true, true, true],
878
+ }
879
+ },
880
+ {
881
+ event: "order placed",
882
+ weight: 10,
883
+ isStrictEvent: false,
884
+ properties: {
885
+ "order_id": orderIds,
886
+ "payment_method": ["credit_card", "apple_pay", "google_pay", "paypal", "cash"],
887
+ "order_total": u.weighNumRange(10, 200, 0.8, 40),
888
+ "tip_amount": u.weighNumRange(0, 30, 1.5, 20),
889
+ "delivery_fee": u.weighNumRange(0, 12, 1.0, 20),
890
+ }
891
+ },
892
+ {
893
+ event: "order tracked",
894
+ weight: 13,
895
+ properties: {
896
+ "order_id": orderIds,
897
+ "order_status": ["confirmed", "preparing", "picked_up", "en_route", "delivered"],
898
+ "eta_mins": u.weighNumRange(5, 60, 1.0, 30),
899
+ }
900
+ },
901
+ {
902
+ event: "order delivered",
903
+ weight: 9,
904
+ isStrictEvent: false,
905
+ properties: {
906
+ "order_id": orderIds,
907
+ "actual_delivery_mins": u.weighNumRange(12, 90, 1.0, 40),
908
+ "on_time": [false, false, false, true, true, true, true, true, true, true],
909
+ }
910
+ },
911
+ {
912
+ event: "order rated",
913
+ weight: 7,
914
+ isStrictEvent: false,
915
+ properties: {
916
+ "order_id": orderIds,
917
+ "food_rating": u.weighNumRange(1, 5, 0.8, 30),
918
+ "delivery_rating": u.weighNumRange(1, 5, 0.8, 30),
919
+ "would_reorder": [false, true, true],
920
+ }
921
+ },
922
+ {
923
+ event: "search performed",
924
+ weight: 11,
925
+ properties: {
926
+ "search_query": () => chance.pickone([
927
+ "pizza", "sushi", "burger", "tacos", "pad thai",
928
+ "chicken", "salad", "ramen", "pasta", "sandwich",
929
+ "wings", "curry", "pho", "burritos", "steak"
930
+ ]),
931
+ "results_count": u.weighNumRange(0, 50, 0.8, 30),
932
+ "search_type": ["restaurant", "cuisine", "dish"],
933
+ }
934
+ },
935
+ {
936
+ event: "promotion viewed",
937
+ weight: 8,
938
+ properties: {
939
+ "promo_id": () => `promo_${v.uid(5)}`,
940
+ "promo_type": ["banner", "push", "in_feed"],
941
+ "promo_value": ["10%", "15%", "20%", "25%", "30%", "40%", "50%"],
942
+ }
943
+ },
944
+ {
945
+ event: "subscription started",
946
+ weight: 2,
947
+ isStrictEvent: false,
948
+ properties: {
949
+ "plan": ["quickbite_plus_monthly", "quickbite_plus_monthly", "quickbite_plus_annual"],
950
+ "price": [9.99, 9.99, 79.99],
951
+ "trial": [true, false],
952
+ }
953
+ },
954
+ {
955
+ event: "subscription cancelled",
956
+ weight: 1,
957
+ properties: {
958
+ "reason": ["too_expensive", "not_ordering_enough", "found_alternative", "bad_experience"],
959
+ "months_subscribed": u.weighNumRange(1, 24, 1.5, 15),
960
+ }
961
+ },
962
+ {
963
+ event: "support ticket",
964
+ weight: 3,
965
+ properties: {
966
+ "issue_type": ["missing_item", "wrong_order", "late_delivery", "quality_issue", "refund_request"],
967
+ "order_id": orderIds,
968
+ }
969
+ },
970
+ {
971
+ event: "reorder initiated",
972
+ weight: 6,
973
+ isStrictEvent: false,
974
+ properties: {
975
+ "order_id": orderIds,
976
+ "original_order_age_days": u.weighNumRange(1, 60, 1.5, 30),
977
+ }
978
+ },
979
+ ],
980
+
981
+ superProps: {
982
+ Platform: ["iOS", "Android", "Web"],
983
+ subscription_tier: ["Free", "Free", "Free", "Free", "QuickBite+"],
984
+ city: ["New York", "Los Angeles", "Chicago", "Houston", "Phoenix", "San Francisco"],
985
+ },
986
+
987
+ userProps: {
988
+ "preferred_cuisine": [
989
+ "American",
990
+ "Italian",
991
+ "Chinese",
992
+ "Japanese",
993
+ "Mexican",
994
+ "Indian",
995
+ "Thai",
996
+ "Mediterranean"
997
+ ],
998
+ "avg_order_value": u.weighNumRange(15, 80, 0.8, 40),
999
+ "orders_per_month": u.weighNumRange(1, 20, 1.5, 10),
1000
+ "favorite_restaurant_count": u.weighNumRange(1, 10),
1001
+ "Platform": ["iOS", "Android", "Web"],
1002
+ "subscription_tier": ["Free", "Free", "Free", "Free", "QuickBite+"],
1003
+ "city": ["New York", "Los Angeles", "Chicago", "Houston", "Phoenix", "San Francisco"],
1004
+ },
1005
+
1006
+ groupKeys: [
1007
+ ["restaurant_id", 200, ["restaurant viewed", "order placed", "order rated"]],
1008
+ ],
1009
+
1010
+ groupProps: {
1011
+ restaurant_id: {
1012
+ "name": () => `${chance.pickone(["The", "Big", "Lucky", "Golden", "Fresh", "Urban"])} ${chance.pickone(["Kitchen", "Grill", "Bowl", "Wok", "Bistro", "Plate", "Table", "Fork"])}`,
1013
+ "cuisine": [
1014
+ "American",
1015
+ "Italian",
1016
+ "Chinese",
1017
+ "Japanese",
1018
+ "Mexican",
1019
+ "Indian",
1020
+ "Thai",
1021
+ "Mediterranean"
1022
+ ],
1023
+ "avg_rating": u.weighNumRange(1, 5, 0.8, 30),
1024
+ "delivery_radius_mi": u.weighNumRange(1, 15, 1.0, 10),
1025
+ }
1026
+ },
1027
+
1028
+ lookupTables: [],
1029
+
1030
+ hook(record, type, meta) {
1031
+ if (type === "funnel-pre") return handleFunnelPreHooks(record, meta);
1032
+ if (type === "everything") return handleEverythingHooks(record, meta);
1033
+ return record;
1034
+ }
1035
+ };
1036
+
1037
+ export default config;
1038
+
1039
+ // ── STORIES ──
1040
+ // Machine-checkable contract for the 10 numbered hooks above. Evaluated by
1041
+ // ./food-delivery.verify.mjs (thin wrapper) or scripts/verify-stories.mjs.
1042
+ // All breakdowns are DuckDB: every read here is a ratio-of-ratios or a
1043
+ // behavioral-cohort aggregation that the emulator's breakdown types don't
1044
+ // model. Bands center on MECHANISM numbers derived in each hook's
1045
+ // MEASURABLE SIGNATURE block; scale guards return WEAK below full fidelity.
1046
+
1047
+ const EV_CTE = `
1048
+ ev AS (SELECT e.user_id::VARCHAR AS uid, e.time::TIMESTAMP AS t,
1049
+ hour(e.time::TIMESTAMP) AS hr,
1050
+ date_diff('day', TIMESTAMP '2026-01-01 00:00:00', e.time::TIMESTAMP) AS day_idx, e.*
1051
+ FROM read_json_auto('{{PREFIX}}-EVENTS*.json', sample_size=-1, union_by_name=true) e)`;
1052
+
1053
+ /**
1054
+ * Band verdict helper: NAILED inside the tight band, STRONG inside the wide
1055
+ * band, INVERSE when the caller's inversion predicate fires, else WEAK.
1056
+ * @param {number} x
1057
+ * @param {[number, number]} nailed
1058
+ * @param {[number, number]} strong
1059
+ * @param {string} detail
1060
+ * @param {boolean} [inverse]
1061
+ */
1062
+ function bandVerdict(x, nailed, strong, detail, inverse = false) {
1063
+ if (Number.isFinite(x) && x >= nailed[0] && x <= nailed[1]) return { verdict: "NAILED", detail };
1064
+ if (Number.isFinite(x) && x >= strong[0] && x <= strong[1]) return { verdict: "STRONG", detail };
1065
+ if (inverse) return { verdict: "INVERSE", detail };
1066
+ return { verdict: "WEAK", detail };
1067
+ }
1068
+
1069
+ export const stories = [
1070
+ {
1071
+ id: "H1-rush-keep-rate",
1072
+ hook: "H1",
1073
+ archetype: "bespoke",
1074
+ narrative:
1075
+ "30% of order-delivered events outside 11-13/17-20 UTC are dropped. Raw HOD volume is " +
1076
+ "soup-confounded, so the read is delivered-per-placed off-hours ÷ delivered-per-placed " +
1077
+ "rush-hours ≈ 0.70 — 'order placed' shares the soup HOD and is untouched by H1, and " +
1078
+ "H7's drop is hour-independent, so both cancel. Measured 0.699 at 2K.",
1079
+ assertions: [
1080
+ {
1081
+ breakdown: {
1082
+ type: "duckdb",
1083
+ sql: `WITH ${EV_CTE},
1084
+ agg AS (SELECT (hr BETWEEN 11 AND 13) OR (hr BETWEEN 17 AND 20) AS rush,
1085
+ count(*) FILTER (WHERE event='order delivered')::BIGINT AS del,
1086
+ count(*) FILTER (WHERE event='order placed')::BIGINT AS placed
1087
+ FROM ev WHERE event IN ('order delivered','order placed') GROUP BY 1)
1088
+ SELECT max(CASE WHEN NOT rush THEN del::DOUBLE/placed END) /
1089
+ max(CASE WHEN rush THEN del::DOUBLE/placed END) AS ror,
1090
+ max(CASE WHEN rush THEN del END)::BIGINT AS del_rush,
1091
+ max(CASE WHEN NOT rush THEN del END)::BIGINT AS del_off
1092
+ FROM agg`,
1093
+ },
1094
+ assert: (rows) => {
1095
+ const r = rows?.[0];
1096
+ if (!r || Number(r.del_rush) < 10000 || Number(r.del_off) < 20000) {
1097
+ return { verdict: "WEAK", detail: `volume too small: del_rush=${r?.del_rush ?? 0} del_off=${r?.del_off ?? 0}` };
1098
+ }
1099
+ const x = Number(r.ror);
1100
+ return bandVerdict(x, [0.65, 0.75], [0.60, 0.80],
1101
+ `delivered-per-placed off/rush RoR=${x.toFixed(3)} (mech 0.70; del_rush=${r.del_rush} del_off=${r.del_off})`,
1102
+ x > 1.0);
1103
+ },
1104
+ },
1105
+ ],
1106
+ },
1107
+ {
1108
+ id: "H2-coupon-injection",
1109
+ hook: "H2",
1110
+ archetype: "cohort-count-scale",
1111
+ narrative:
1112
+ "Free-tier users get a coupon-applied clone spliced before 30% of checkouts. Exact " +
1113
+ "knob recovery: coupons-per-checkout(Free) − coupons-per-checkout(QB+) ≈ +0.30. " +
1114
+ "Organic coupons/user is tier-independent (~3.1) and Free users average ~13 checkouts, " +
1115
+ "so total coupons/user ratio lands at ~2.2 (NOT 1.3x — 30% of 13 checkouts more than " +
1116
+ "doubles the organic count). Measured diff 0.296, ratio 2.19 at 2K.",
1117
+ assertions: [
1118
+ {
1119
+ breakdown: {
1120
+ type: "duckdb",
1121
+ sql: `WITH ${EV_CTE},
1122
+ pu AS (SELECT uid, any_value(subscription_tier) AS tier,
1123
+ count(*) FILTER (WHERE event='coupon applied') AS coupons,
1124
+ count(*) FILTER (WHERE event='checkout started') AS checkouts
1125
+ FROM ev WHERE event IN ('coupon applied','checkout started') GROUP BY 1)
1126
+ SELECT tier, count(*)::BIGINT AS users,
1127
+ sum(coupons)::DOUBLE/count(*) AS cpu,
1128
+ sum(coupons)::DOUBLE/nullif(sum(checkouts),0) AS cpc
1129
+ FROM pu GROUP BY tier ORDER BY tier`,
1130
+ },
1131
+ assert: (rows) => {
1132
+ const free = rows?.find(r => r.tier === "Free");
1133
+ const plus = rows?.find(r => r.tier === "QuickBite+");
1134
+ if (!free || !plus || Number(free.users) < 4000 || Number(plus.users) < 900) {
1135
+ return { verdict: "WEAK", detail: `cohorts too small: free=${free?.users ?? 0} plus=${plus?.users ?? 0}` };
1136
+ }
1137
+ const diff = Number(free.cpc) - Number(plus.cpc);
1138
+ return bandVerdict(diff, [0.26, 0.34], [0.22, 0.38],
1139
+ `coupons-per-checkout diff=${diff.toFixed(3)} (mech +0.30; Free=${Number(free.cpc).toFixed(3)} QB+=${Number(plus.cpc).toFixed(3)})`,
1140
+ diff < 0);
1141
+ },
1142
+ },
1143
+ {
1144
+ breakdown: {
1145
+ type: "duckdb",
1146
+ sql: `WITH ${EV_CTE},
1147
+ pu AS (SELECT uid, any_value(subscription_tier) AS tier,
1148
+ count(*) FILTER (WHERE event='coupon applied') AS coupons
1149
+ FROM ev WHERE event IN ('coupon applied','checkout started') GROUP BY 1)
1150
+ SELECT tier, count(*)::BIGINT AS users, sum(coupons)::DOUBLE/count(*) AS cpu
1151
+ FROM pu GROUP BY tier ORDER BY tier`,
1152
+ },
1153
+ assert: (rows) => {
1154
+ const free = rows?.find(r => r.tier === "Free");
1155
+ const plus = rows?.find(r => r.tier === "QuickBite+");
1156
+ if (!free || !plus || Number(free.users) < 4000 || Number(plus.users) < 900) {
1157
+ return { verdict: "WEAK", detail: `cohorts too small: free=${free?.users ?? 0} plus=${plus?.users ?? 0}` };
1158
+ }
1159
+ const ratio = Number(free.cpu) / Number(plus.cpu);
1160
+ return bandVerdict(ratio, [1.9, 2.6], [1.7, 2.9],
1161
+ `coupons/user Free÷QB+=${ratio.toFixed(3)} (mech ~2.2; Free=${Number(free.cpu).toFixed(2)} QB+=${Number(plus.cpu).toFixed(2)})`,
1162
+ ratio < 1.0);
1163
+ },
1164
+ },
1165
+ ],
1166
+ },
1167
+ {
1168
+ id: "H3-late-night-flip",
1169
+ hook: "H3",
1170
+ archetype: "composition-drift",
1171
+ narrative:
1172
+ "22:00-02:59 UTC: 70% of restaurant-viewed/cart events flip cuisine_type to American; " +
1173
+ "item_price bumped 1.3x unconditionally in the window. Organic American share is ~0.16 " +
1174
+ "(engine's pick is non-uniform), hour-independent — 'restaurant browsed' (never " +
1175
+ "flipped) is the control. Inverting the mix equation recovers the knob: " +
1176
+ "(late_share − off_share)/(1 − off_share) ≈ 0.70. Measured 0.705 and price 1.281 at 2K.",
1177
+ assertions: [
1178
+ {
1179
+ breakdown: {
1180
+ type: "duckdb",
1181
+ sql: `WITH ${EV_CTE}
1182
+ SELECT avg((cuisine_type='American')::INT) FILTER (WHERE event='restaurant viewed' AND (hr>=22 OR hr<=2)) AS late_share,
1183
+ avg((cuisine_type='American')::INT) FILTER (WHERE event='restaurant viewed' AND hr BETWEEN 6 AND 18) AS off_share,
1184
+ avg((cuisine_type='American')::INT) FILTER (WHERE event='restaurant browsed') AS organic_share,
1185
+ count(*) FILTER (WHERE event='restaurant viewed' AND (hr>=22 OR hr<=2))::BIGINT AS n_late
1186
+ FROM ev WHERE event IN ('restaurant viewed','restaurant browsed')`,
1187
+ },
1188
+ assert: (rows) => {
1189
+ const r = rows?.[0];
1190
+ if (!r || Number(r.n_late) < 10000) {
1191
+ return { verdict: "WEAK", detail: `late-night views too few: n_late=${r?.n_late ?? 0}` };
1192
+ }
1193
+ const off = Number(r.off_share);
1194
+ const organic = Number(r.organic_share);
1195
+ if (organic < 0.10 || organic > 0.22) {
1196
+ return { verdict: "WEAK", detail: `organic American share ${organic.toFixed(3)} outside expected [0.10,0.22] — derivation baseline broken` };
1197
+ }
1198
+ const flip = (Number(r.late_share) - off) / (1 - off);
1199
+ return bandVerdict(flip, [0.65, 0.75], [0.60, 0.80],
1200
+ `flip-rate inversion=${flip.toFixed(3)} (mech 0.70; late=${Number(r.late_share).toFixed(3)} off=${off.toFixed(3)} organic=${organic.toFixed(3)})`,
1201
+ flip < 0);
1202
+ },
1203
+ },
1204
+ {
1205
+ breakdown: {
1206
+ type: "duckdb",
1207
+ sql: `WITH ${EV_CTE}
1208
+ SELECT avg(TRY_CAST(item_price AS DOUBLE)) FILTER (WHERE hr>=22 OR hr<=2) /
1209
+ avg(TRY_CAST(item_price AS DOUBLE)) FILTER (WHERE hr BETWEEN 6 AND 18) AS price_ratio,
1210
+ count(*) FILTER (WHERE hr>=22 OR hr<=2)::BIGINT AS n_late
1211
+ FROM ev WHERE event='item added to cart'`,
1212
+ },
1213
+ assert: (rows) => {
1214
+ const r = rows?.[0];
1215
+ if (!r || Number(r.n_late) < 10000) {
1216
+ return { verdict: "WEAK", detail: `late-night carts too few: n_late=${r?.n_late ?? 0}` };
1217
+ }
1218
+ const x = Number(r.price_ratio);
1219
+ return bandVerdict(x, [1.24, 1.36], [1.18, 1.42],
1220
+ `late/off item_price ratio=${x.toFixed(3)} (mech 1.30; n_late=${r.n_late})`,
1221
+ x < 1.0);
1222
+ },
1223
+ },
1224
+ ],
1225
+ },
1226
+ {
1227
+ id: "H4-rainy-week",
1228
+ hook: "H4",
1229
+ archetype: "temporal-inflection",
1230
+ narrative:
1231
+ "Days 20-27: delivery_fee doubled on order-placed, 40% duplicated with 5-60 min offset. " +
1232
+ "Duplicate share (in-window (uid, order_id) twins ÷ distinct orders) recovers the 0.40 " +
1233
+ "knob directly. Fee window/baseline ≈ 2.0 (fee pool bottoms at 1, the (fee||5) fallback " +
1234
+ "is inert). Volume RoR vs checkout ≈ 1.40 diluted a few percent by H6-churned users. " +
1235
+ "Measured dup 0.367, fee 1.937, RoR 1.343 at 2K.",
1236
+ assertions: [
1237
+ {
1238
+ breakdown: {
1239
+ type: "duckdb",
1240
+ sql: `WITH ${EV_CTE}
1241
+ SELECT count(*)::BIGINT AS total_win,
1242
+ count(DISTINCT uid || '|' || order_id)::BIGINT AS distinct_orders,
1243
+ (count(*) - count(DISTINCT uid || '|' || order_id))::DOUBLE /
1244
+ count(DISTINCT uid || '|' || order_id) AS dup_share
1245
+ FROM ev WHERE event='order placed' AND day_idx BETWEEN 20 AND 27`,
1246
+ },
1247
+ assert: (rows) => {
1248
+ const r = rows?.[0];
1249
+ if (!r || Number(r.distinct_orders) < 2500) {
1250
+ return { verdict: "WEAK", detail: `window orders too few: distinct=${r?.distinct_orders ?? 0}` };
1251
+ }
1252
+ const x = Number(r.dup_share);
1253
+ return bandVerdict(x, [0.33, 0.47], [0.28, 0.52],
1254
+ `in-window duplicate share=${x.toFixed(3)} (mech 0.40; ${r.total_win} events over ${r.distinct_orders} orders)`,
1255
+ x < 0.02);
1256
+ },
1257
+ },
1258
+ {
1259
+ breakdown: {
1260
+ type: "duckdb",
1261
+ sql: `WITH ${EV_CTE}
1262
+ SELECT avg(TRY_CAST(delivery_fee AS DOUBLE)) FILTER (WHERE day_idx BETWEEN 20 AND 27) /
1263
+ avg(TRY_CAST(delivery_fee AS DOUBLE)) FILTER (WHERE day_idx BETWEEN 10 AND 19 OR day_idx BETWEEN 28 AND 37) AS fee_ratio,
1264
+ count(*) FILTER (WHERE day_idx BETWEEN 20 AND 27)::BIGINT AS n_win
1265
+ FROM ev WHERE event='order placed'`,
1266
+ },
1267
+ assert: (rows) => {
1268
+ const r = rows?.[0];
1269
+ if (!r || Number(r.n_win) < 3000) {
1270
+ return { verdict: "WEAK", detail: `window orders too few: n_win=${r?.n_win ?? 0}` };
1271
+ }
1272
+ const x = Number(r.fee_ratio);
1273
+ return bandVerdict(x, [1.85, 2.15], [1.70, 2.30],
1274
+ `delivery_fee window/baseline=${x.toFixed(3)} (mech 2.0; n_win=${r.n_win})`,
1275
+ x < 1.0);
1276
+ },
1277
+ },
1278
+ {
1279
+ breakdown: {
1280
+ type: "duckdb",
1281
+ sql: `WITH ${EV_CTE},
1282
+ agg AS (SELECT CASE WHEN day_idx BETWEEN 20 AND 27 THEN 'win'
1283
+ WHEN day_idx BETWEEN 10 AND 19 OR day_idx BETWEEN 28 AND 37 THEN 'base' END AS zone,
1284
+ count(*) FILTER (WHERE event='order placed')::BIGINT AS placed,
1285
+ count(*) FILTER (WHERE event='checkout started')::BIGINT AS chk
1286
+ FROM ev WHERE event IN ('order placed','checkout started') AND day_idx BETWEEN 10 AND 37 GROUP BY 1)
1287
+ SELECT (max(CASE WHEN zone='win' THEN placed END)::DOUBLE / max(CASE WHEN zone='base' THEN placed END)) /
1288
+ (max(CASE WHEN zone='win' THEN chk END)::DOUBLE / max(CASE WHEN zone='base' THEN chk END)) AS vol_ror,
1289
+ max(CASE WHEN zone='win' THEN placed END)::BIGINT AS placed_win
1290
+ FROM agg`,
1291
+ },
1292
+ assert: (rows) => {
1293
+ const r = rows?.[0];
1294
+ if (!r || Number(r.placed_win) < 3000) {
1295
+ return { verdict: "WEAK", detail: `window orders too few: placed_win=${r?.placed_win ?? 0}` };
1296
+ }
1297
+ const x = Number(r.vol_ror);
1298
+ return bandVerdict(x, [1.25, 1.55], [1.15, 1.70],
1299
+ `placed vol RoR vs checkout=${x.toFixed(3)} (mech ~1.40 minus churn dilution; placed_win=${r.placed_win})`,
1300
+ x < 1.0);
1301
+ },
1302
+ },
1303
+ ],
1304
+ },
1305
+ {
1306
+ id: "H5-referral-power",
1307
+ hook: "H5",
1308
+ archetype: "cohort-count-scale",
1309
+ narrative:
1310
+ "Born-in users with referral_code=true on account-created (~1/3 of born-ins) get " +
1311
+ "food_rating rerolled to 4-5 and ~50% of visited reorders cloned. Cohort only exists " +
1312
+ "among born-ins (pre-existing users have no account-created). Reorders/user ratio ≈ 1.4 " +
1313
+ "(clones re-visited at idx+1 can re-clone; tail events pushed past the fixed forEach " +
1314
+ "range are skipped, so net sits below naive 1.5). Ratings: referred user-mean ≈ 4.4 " +
1315
+ "(same truncation leaves some rated events unboosted) vs organic ≈ 2.8. Measured " +
1316
+ "1.45/1.34 across 2K iterations; ratings 4.45 vs 2.78.",
1317
+ assertions: [
1318
+ {
1319
+ breakdown: {
1320
+ type: "duckdb",
1321
+ sql: `WITH ${EV_CTE},
1322
+ born AS (SELECT uid, bool_or(referral_code = true) AS referred
1323
+ FROM ev WHERE event='account created' GROUP BY 1),
1324
+ pu AS (SELECT b.uid, b.referred,
1325
+ count(*) FILTER (WHERE e.event='reorder initiated') AS reorders,
1326
+ avg(TRY_CAST(e.food_rating AS DOUBLE)) FILTER (WHERE e.event='order rated') AS user_rating
1327
+ FROM born b LEFT JOIN ev e ON e.uid=b.uid GROUP BY 1,2)
1328
+ SELECT referred, count(*)::BIGINT AS users,
1329
+ sum(reorders)::DOUBLE/count(*) AS reorders_pu,
1330
+ avg(user_rating) AS mean_rating
1331
+ FROM pu GROUP BY referred ORDER BY referred`,
1332
+ },
1333
+ assert: (rows) => {
1334
+ const ref = rows?.find(r => r.referred === true);
1335
+ const non = rows?.find(r => r.referred === false);
1336
+ if (!ref || !non || Number(ref.users) < 250 || Number(non.users) < 500) {
1337
+ return { verdict: "WEAK", detail: `born-in cohorts too small: ref=${ref?.users ?? 0} nonref=${non?.users ?? 0}` };
1338
+ }
1339
+ const ratio = Number(ref.reorders_pu) / Number(non.reorders_pu);
1340
+ return bandVerdict(ratio, [1.25, 1.60], [1.12, 1.75],
1341
+ `born-in reorders/user ref÷nonref=${ratio.toFixed(3)} (mech ~1.4; ref=${Number(ref.reorders_pu).toFixed(2)} n=${ref.users}, nonref=${Number(non.reorders_pu).toFixed(2)} n=${non.users})`,
1342
+ ratio < 1.0);
1343
+ },
1344
+ },
1345
+ {
1346
+ breakdown: {
1347
+ type: "duckdb",
1348
+ sql: `WITH ${EV_CTE},
1349
+ born AS (SELECT uid, bool_or(referral_code = true) AS referred
1350
+ FROM ev WHERE event='account created' GROUP BY 1),
1351
+ pu AS (SELECT b.uid, b.referred,
1352
+ avg(TRY_CAST(e.food_rating AS DOUBLE)) FILTER (WHERE e.event='order rated') AS user_rating
1353
+ FROM born b LEFT JOIN ev e ON e.uid=b.uid GROUP BY 1,2)
1354
+ SELECT referred, count(*) FILTER (WHERE user_rating IS NOT NULL)::BIGINT AS raters,
1355
+ avg(user_rating) AS mean_rating
1356
+ FROM pu GROUP BY referred ORDER BY referred`,
1357
+ },
1358
+ assert: (rows) => {
1359
+ const ref = rows?.find(r => r.referred === true);
1360
+ const non = rows?.find(r => r.referred === false);
1361
+ if (!ref || !non || Number(ref.raters) < 200 || Number(non.raters) < 400) {
1362
+ return { verdict: "WEAK", detail: `rater cohorts too small: ref=${ref?.raters ?? 0} nonref=${non?.raters ?? 0}` };
1363
+ }
1364
+ const rr = Number(ref.mean_rating), nr = Number(non.mean_rating);
1365
+ const detail = `referred mean rating=${rr.toFixed(2)} vs organic=${nr.toFixed(2)} (mech ~4.4 vs ~2.8)`;
1366
+ if (rr >= 4.3 && rr <= 4.55 && nr >= 2.6 && nr <= 3.0) return { verdict: "NAILED", detail };
1367
+ if (rr >= 4.15 && rr <= 4.65 && nr >= 2.45 && nr <= 3.15) return { verdict: "STRONG", detail };
1368
+ if (rr < nr) return { verdict: "INVERSE", detail };
1369
+ return { verdict: "WEAK", detail };
1370
+ },
1371
+ },
1372
+ ],
1373
+ },
1374
+ {
1375
+ id: "H6-trial-churn",
1376
+ hook: "H6",
1377
+ archetype: "retention-divergence",
1378
+ narrative:
1379
+ "Trial subscribers with <3 orders in their first 14 days: 60% (per-user coin) lose ALL " +
1380
+ "post-day-14 events. The deletion removes late subscription-started events themselves, " +
1381
+ "so churned users with a late trial start vanish from the visible cohort (survivor " +
1382
+ "bias) — the visible zero-post share lands near 0.25, not 0.60. Honest observable: " +
1383
+ "zero-post-day-14 share non-activated ≈ 0.25 vs activated ≈ 0.04, a 5-7x divergence. " +
1384
+ "Per-user post/pre ratios are composition-confounded (activation selects front-loaded " +
1385
+ "activity) — deliberately not asserted. Measured 0.245/0.036 at 2K.",
1386
+ assertions: [
1387
+ {
1388
+ breakdown: {
1389
+ type: "duckdb",
1390
+ sql: `WITH ${EV_CTE},
1391
+ fu AS (SELECT uid, min(t) AS first_t FROM ev GROUP BY 1),
1392
+ trial AS (SELECT uid FROM ev WHERE event='subscription started' AND trial = true GROUP BY 1),
1393
+ pux AS (SELECT f.uid,
1394
+ count(*) FILTER (WHERE e.event='order placed' AND e.t <= f.first_t + INTERVAL '14 days') AS early_orders,
1395
+ count(*) FILTER (WHERE e.t > f.first_t + INTERVAL '14 days') AS post_n
1396
+ FROM fu f JOIN trial tr ON tr.uid=f.uid JOIN ev e ON e.uid=f.uid GROUP BY 1)
1397
+ SELECT (early_orders >= 3) AS activated, count(*)::BIGINT AS users,
1398
+ avg(CASE WHEN post_n = 0 THEN 1.0 ELSE 0 END) AS zero_post_share
1399
+ FROM pux GROUP BY (early_orders >= 3) ORDER BY 1`,
1400
+ },
1401
+ assert: (rows) => {
1402
+ const non = rows?.find(r => r.activated === false);
1403
+ const act = rows?.find(r => r.activated === true);
1404
+ if (!non || !act || Number(non.users) < 1200 || Number(act.users) < 1000) {
1405
+ return { verdict: "WEAK", detail: `trial cohorts too small: nonact=${non?.users ?? 0} act=${act?.users ?? 0}` };
1406
+ }
1407
+ const ns = Number(non.zero_post_share), as = Number(act.zero_post_share);
1408
+ const ratio = as > 0 ? ns / as : Infinity;
1409
+ const detail = `zero-post share nonact=${ns.toFixed(3)} vs act=${as.toFixed(3)} (ratio ${ratio === Infinity ? "inf" : ratio.toFixed(1)}x; survivor-biased vs 0.60 knob by construction)`;
1410
+ if (ns >= 0.15 && ns <= 0.35 && as <= 0.06 && ratio >= 5) return { verdict: "NAILED", detail };
1411
+ if (ns >= 0.12 && ns <= 0.40 && as <= 0.10 && ratio >= 3) return { verdict: "STRONG", detail };
1412
+ if (ns < as) return { verdict: "INVERSE", detail };
1413
+ return { verdict: "WEAK", detail };
1414
+ },
1415
+ },
1416
+ ],
1417
+ },
1418
+ {
1419
+ id: "H7-hash-bucket-drop",
1420
+ hook: "H7",
1421
+ archetype: "funnel-conversion-by-segment",
1422
+ narrative:
1423
+ "Users whose user_id first char has an ODD char code lose 30% of order-delivered " +
1424
+ "events. delivered-per-placed(odd) ÷ delivered-per-placed(even) ≈ 0.70 — placed " +
1425
+ "normalizes engagement and H1's drop is hash-independent, so both cancel. " +
1426
+ "Measured 0.716 at 2K.",
1427
+ assertions: [
1428
+ {
1429
+ breakdown: {
1430
+ type: "duckdb",
1431
+ sql: `WITH ${EV_CTE},
1432
+ pu AS (SELECT uid, (ascii(substr(uid,1,1)) % 2 = 0) AS even_bucket,
1433
+ count(*) FILTER (WHERE event='order placed') AS placed,
1434
+ count(*) FILTER (WHERE event='order delivered') AS delivered
1435
+ FROM ev WHERE event IN ('order placed','order delivered') GROUP BY 1,2)
1436
+ SELECT even_bucket, count(*)::BIGINT AS users,
1437
+ sum(delivered)::DOUBLE/nullif(sum(placed),0) AS dpp
1438
+ FROM pu GROUP BY even_bucket ORDER BY even_bucket`,
1439
+ },
1440
+ assert: (rows) => {
1441
+ const odd = rows?.find(r => r.even_bucket === false);
1442
+ const even = rows?.find(r => r.even_bucket === true);
1443
+ if (!odd || !even || Number(odd.users) < 2500 || Number(even.users) < 2500) {
1444
+ return { verdict: "WEAK", detail: `hash buckets too small: odd=${odd?.users ?? 0} even=${even?.users ?? 0}` };
1445
+ }
1446
+ const ratio = Number(odd.dpp) / Number(even.dpp);
1447
+ return bandVerdict(ratio, [0.65, 0.75], [0.60, 0.80],
1448
+ `delivered-per-placed odd÷even=${ratio.toFixed(3)} (mech 0.70; odd=${Number(odd.dpp).toFixed(3)} even=${Number(even.dpp).toFixed(3)})`,
1449
+ ratio > 1.0);
1450
+ },
1451
+ },
1452
+ ],
1453
+ },
1454
+ {
1455
+ id: "H8-order-count-magic",
1456
+ hook: "H8",
1457
+ archetype: "frequency-sweet-spot",
1458
+ narrative:
1459
+ "order_total scaled by hook-time order count: 4-8 orders → 1.4x, 9+ → 0.65x. The hook " +
1460
+ "buckets BEFORE H4 duplication and H6 deletion, so output-count bucketing is edge-" +
1461
+ "contaminated; the clean population excludes rainy-window orderers and H6-churn " +
1462
+ "candidates (users with no post-day-14 activity). On it: sweet/base ≈ 1.40, " +
1463
+ "over/sweet ≈ 0.46 (= 0.65/1.4). Measured 1.405 and 0.456 at 2K.",
1464
+ assertions: [
1465
+ {
1466
+ breakdown: {
1467
+ type: "duckdb",
1468
+ sql: `WITH ${EV_CTE},
1469
+ fu AS (SELECT uid, min(t) AS first_t FROM ev GROUP BY 1),
1470
+ flags AS (SELECT r.uid,
1471
+ max(CASE WHEN r.event='order placed' AND r.day_idx BETWEEN 20 AND 27 THEN 1 ELSE 0 END) AS rainy,
1472
+ max(CASE WHEN r.t > f.first_t + INTERVAL '14 days' THEN 1 ELSE 0 END) AS alive,
1473
+ count(*) FILTER (WHERE r.event='order placed') AS orders,
1474
+ sum(TRY_CAST(r.order_total AS DOUBLE)) FILTER (WHERE r.event='order placed') AS spend
1475
+ FROM ev r JOIN fu f USING(uid) GROUP BY 1)
1476
+ SELECT CASE WHEN orders BETWEEN 4 AND 8 THEN 'sweet' WHEN orders >= 9 THEN 'over' ELSE 'base' END AS bucket,
1477
+ count(*)::BIGINT AS users, sum(spend)/nullif(sum(orders),0) AS aot
1478
+ FROM flags WHERE rainy = 0 AND alive = 1 AND orders > 0 GROUP BY 1 ORDER BY 1`,
1479
+ },
1480
+ assert: (rows) => {
1481
+ const base = rows?.find(r => r.bucket === "base");
1482
+ const sweet = rows?.find(r => r.bucket === "sweet");
1483
+ if (!base || !sweet || Number(base.users) < 130 || Number(sweet.users) < 450) {
1484
+ return { verdict: "WEAK", detail: `clean-pop buckets too small: base=${base?.users ?? 0} sweet=${sweet?.users ?? 0}` };
1485
+ }
1486
+ const ratio = Number(sweet.aot) / Number(base.aot);
1487
+ return bandVerdict(ratio, [1.25, 1.55], [1.15, 1.70],
1488
+ `clean-pop sweet/base order_total=${ratio.toFixed(3)} (mech 1.40; sweet=${Number(sweet.aot).toFixed(1)} n=${sweet.users}, base=${Number(base.aot).toFixed(1)} n=${base.users})`,
1489
+ ratio < 1.0);
1490
+ },
1491
+ },
1492
+ {
1493
+ breakdown: {
1494
+ type: "duckdb",
1495
+ sql: `WITH ${EV_CTE},
1496
+ fu AS (SELECT uid, min(t) AS first_t FROM ev GROUP BY 1),
1497
+ flags AS (SELECT r.uid,
1498
+ max(CASE WHEN r.event='order placed' AND r.day_idx BETWEEN 20 AND 27 THEN 1 ELSE 0 END) AS rainy,
1499
+ max(CASE WHEN r.t > f.first_t + INTERVAL '14 days' THEN 1 ELSE 0 END) AS alive,
1500
+ count(*) FILTER (WHERE r.event='order placed') AS orders,
1501
+ sum(TRY_CAST(r.order_total AS DOUBLE)) FILTER (WHERE r.event='order placed') AS spend
1502
+ FROM ev r JOIN fu f USING(uid) GROUP BY 1)
1503
+ SELECT CASE WHEN orders BETWEEN 4 AND 8 THEN 'sweet' WHEN orders >= 9 THEN 'over' ELSE 'base' END AS bucket,
1504
+ count(*)::BIGINT AS users, sum(spend)/nullif(sum(orders),0) AS aot
1505
+ FROM flags WHERE rainy = 0 AND alive = 1 AND orders > 0 GROUP BY 1 ORDER BY 1`,
1506
+ },
1507
+ assert: (rows) => {
1508
+ const sweet = rows?.find(r => r.bucket === "sweet");
1509
+ const over = rows?.find(r => r.bucket === "over");
1510
+ if (!sweet || !over || Number(sweet.users) < 450 || Number(over.users) < 1000) {
1511
+ return { verdict: "WEAK", detail: `clean-pop buckets too small: sweet=${sweet?.users ?? 0} over=${over?.users ?? 0}` };
1512
+ }
1513
+ const ratio = Number(over.aot) / Number(sweet.aot);
1514
+ return bandVerdict(ratio, [0.40, 0.53], [0.35, 0.58],
1515
+ `clean-pop over/sweet order_total=${ratio.toFixed(3)} (mech 0.464 = 0.65/1.4; over=${Number(over.aot).toFixed(1)} n=${over.users})`,
1516
+ ratio > 1.0);
1517
+ },
1518
+ },
1519
+ ],
1520
+ },
1521
+ {
1522
+ id: "H9-tier-delivery-speed",
1523
+ hook: "H9",
1524
+ archetype: "cohort-prop-scale",
1525
+ narrative:
1526
+ "Delivery timing properties scaled by tier: QuickBite+ 0.67x, Free 1.4x → QB+/Free " +
1527
+ "property ratio 0.479 on actual_delivery_mins and eta_mins. The companion funnel-" +
1528
+ "timestamp compression touches only the FIRST checkout→delivered sequence per user " +
1529
+ "(~1 of 13 checkouts) — invisible to cross-event aggregation, so wall-clock TTC is " +
1530
+ "deliberately NOT asserted (visible only in Mixpanel's per-instance funnel report). " +
1531
+ "Measured 0.476/0.481 at 2K.",
1532
+ assertions: [
1533
+ {
1534
+ breakdown: {
1535
+ type: "duckdb",
1536
+ sql: `WITH ${EV_CTE}
1537
+ SELECT avg(TRY_CAST(actual_delivery_mins AS DOUBLE)) FILTER (WHERE subscription_tier='QuickBite+') /
1538
+ avg(TRY_CAST(actual_delivery_mins AS DOUBLE)) FILTER (WHERE subscription_tier='Free') AS adm_ratio,
1539
+ count(*) FILTER (WHERE subscription_tier='QuickBite+')::BIGINT AS n_plus
1540
+ FROM ev WHERE event='order delivered'`,
1541
+ },
1542
+ assert: (rows) => {
1543
+ const r = rows?.[0];
1544
+ if (!r || Number(r.n_plus) < 5000) {
1545
+ return { verdict: "WEAK", detail: `QB+ delivered too few: n_plus=${r?.n_plus ?? 0}` };
1546
+ }
1547
+ const x = Number(r.adm_ratio);
1548
+ return bandVerdict(x, [0.44, 0.52], [0.41, 0.56],
1549
+ `actual_delivery_mins QB+÷Free=${x.toFixed(3)} (mech 0.479 = 0.67/1.4; n_plus=${r.n_plus})`,
1550
+ x > 1.0);
1551
+ },
1552
+ },
1553
+ {
1554
+ breakdown: {
1555
+ type: "duckdb",
1556
+ sql: `WITH ${EV_CTE}
1557
+ SELECT avg(TRY_CAST(eta_mins AS DOUBLE)) FILTER (WHERE subscription_tier='QuickBite+') /
1558
+ avg(TRY_CAST(eta_mins AS DOUBLE)) FILTER (WHERE subscription_tier='Free') AS eta_ratio,
1559
+ count(*) FILTER (WHERE subscription_tier='QuickBite+')::BIGINT AS n_plus
1560
+ FROM ev WHERE event='order tracked'`,
1561
+ },
1562
+ assert: (rows) => {
1563
+ const r = rows?.[0];
1564
+ if (!r || Number(r.n_plus) < 5000) {
1565
+ return { verdict: "WEAK", detail: `QB+ tracked too few: n_plus=${r?.n_plus ?? 0}` };
1566
+ }
1567
+ const x = Number(r.eta_ratio);
1568
+ return bandVerdict(x, [0.44, 0.52], [0.41, 0.56],
1569
+ `eta_mins QB+÷Free=${x.toFixed(3)} (mech 0.479; n_plus=${r.n_plus})`,
1570
+ x > 1.0);
1571
+ },
1572
+ },
1573
+ ],
1574
+ },
1575
+ {
1576
+ id: "H10-city-density",
1577
+ hook: "H10",
1578
+ archetype: "funnel-conversion-by-segment",
1579
+ narrative:
1580
+ "Reorder-funnel conversionRate scaled per city in funnel-pre: SF/NY 40→56, HOU/PHX " +
1581
+ "40→28. Non-converted instances take u.integer(1, steps−1) steps (determineConversion, " +
1582
+ "lib/generators/funnels.js) — the LAST step fires only on conversion, so P(reorder per " +
1583
+ "instance) = conversionRate exactly. Delivered-per-user is city-flat, so reorders-per-" +
1584
+ "DELIVERED recovers the knobs: dense/base = 56/40 = 1.40, sprawl/base = 28/40 = 0.70. " +
1585
+ "Measured 1.404 and 0.696 at 2K.",
1586
+ assertions: [
1587
+ {
1588
+ breakdown: {
1589
+ type: "duckdb",
1590
+ sql: `WITH ${EV_CTE},
1591
+ pu AS (SELECT uid, any_value(city) AS city,
1592
+ count(*) FILTER (WHERE event='order delivered') AS delivered,
1593
+ count(*) FILTER (WHERE event='reorder initiated') AS reorders
1594
+ FROM ev WHERE event IN ('order delivered','reorder initiated') GROUP BY 1),
1595
+ grp AS (SELECT CASE WHEN city IN ('San Francisco','New York') THEN 'dense'
1596
+ WHEN city IN ('Houston','Phoenix') THEN 'sprawl' ELSE 'base' END AS g,
1597
+ count(*)::BIGINT AS users, sum(reorders)::DOUBLE/nullif(sum(delivered),0) AS rpd
1598
+ FROM pu GROUP BY 1)
1599
+ SELECT g, users, rpd FROM grp ORDER BY g`,
1600
+ },
1601
+ assert: (rows) => {
1602
+ const dense = rows?.find(r => r.g === "dense");
1603
+ const base = rows?.find(r => r.g === "base");
1604
+ if (!dense || !base || Number(dense.users) < 1800 || Number(base.users) < 1800) {
1605
+ return { verdict: "WEAK", detail: `city groups too small: dense=${dense?.users ?? 0} base=${base?.users ?? 0}` };
1606
+ }
1607
+ const ratio = Number(dense.rpd) / Number(base.rpd);
1608
+ return bandVerdict(ratio, [1.30, 1.50], [1.20, 1.60],
1609
+ `reorders-per-delivered dense÷base=${ratio.toFixed(3)} (mech 1.40; dense=${Number(dense.rpd).toFixed(3)} base=${Number(base.rpd).toFixed(3)})`,
1610
+ ratio < 1.0);
1611
+ },
1612
+ },
1613
+ {
1614
+ breakdown: {
1615
+ type: "duckdb",
1616
+ sql: `WITH ${EV_CTE},
1617
+ pu AS (SELECT uid, any_value(city) AS city,
1618
+ count(*) FILTER (WHERE event='order delivered') AS delivered,
1619
+ count(*) FILTER (WHERE event='reorder initiated') AS reorders
1620
+ FROM ev WHERE event IN ('order delivered','reorder initiated') GROUP BY 1),
1621
+ grp AS (SELECT CASE WHEN city IN ('San Francisco','New York') THEN 'dense'
1622
+ WHEN city IN ('Houston','Phoenix') THEN 'sprawl' ELSE 'base' END AS g,
1623
+ count(*)::BIGINT AS users, sum(reorders)::DOUBLE/nullif(sum(delivered),0) AS rpd
1624
+ FROM pu GROUP BY 1)
1625
+ SELECT g, users, rpd FROM grp ORDER BY g`,
1626
+ },
1627
+ assert: (rows) => {
1628
+ const sprawl = rows?.find(r => r.g === "sprawl");
1629
+ const base = rows?.find(r => r.g === "base");
1630
+ if (!sprawl || !base || Number(sprawl.users) < 1800 || Number(base.users) < 1800) {
1631
+ return { verdict: "WEAK", detail: `city groups too small: sprawl=${sprawl?.users ?? 0} base=${base?.users ?? 0}` };
1632
+ }
1633
+ const ratio = Number(sprawl.rpd) / Number(base.rpd);
1634
+ return bandVerdict(ratio, [0.63, 0.77], [0.56, 0.84],
1635
+ `reorders-per-delivered sprawl÷base=${ratio.toFixed(3)} (mech 0.70; sprawl=${Number(sprawl.rpd).toFixed(3)} base=${Number(base.rpd).toFixed(3)})`,
1636
+ ratio > 1.0);
1637
+ },
1638
+ },
1639
+ ],
1640
+ },
1641
+ ];