@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.
- package/.claude/skills/analyze-soup/SKILL.md +21 -12
- package/.claude/skills/create-dungeon/SKILL.md +65 -5
- package/.claude/skills/create-project/SKILL.md +1 -1
- package/.claude/skills/create-project/provision.mjs +57 -10
- package/.claude/skills/verify-dungeon/SKILL.md +56 -25
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
- package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
- package/.claude/skills/write-hooks/SKILL.md +156 -18
- package/CHANGELOG.md +183 -0
- package/HOOKS.md +566 -112
- package/README.md +28 -9
- package/docs/guides/1.3.0-upgrade-guide.md +262 -0
- package/docs/guides/1.3.1-upgrade-guide.md +328 -0
- package/docs/guides/1.4.0-upgrade-guide.md +130 -0
- package/docs/guides/1.4.1-upgrade-guide.md +125 -0
- package/docs/guides/1.4.3-upgrade-guide.md +200 -0
- package/docs/guides/1.4.4-upgrade-guide.md +95 -0
- package/docs/guides/1.5.0-upgrade-guide.md +426 -0
- package/docs/guides/1.5.1-upgrade-guide.md +178 -0
- package/docs/guides/1.5.3-upgrade-guide.md +117 -0
- package/docs/guides/1.6.0-upgrade-guide.md +204 -0
- package/dungeons/technical/stories-verify.js +152 -0
- package/dungeons/vertical/README.md +154 -0
- package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
- package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
- package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
- package/dungeons/vertical/community/community.js +1589 -0
- package/dungeons/vertical/community/community.sql +216 -0
- package/dungeons/vertical/community/community.verify.mjs +72 -0
- package/dungeons/vertical/crypto/crypto.js +1483 -0
- package/dungeons/vertical/crypto/crypto.sql +194 -0
- package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
- package/dungeons/vertical/dating/dating.js +1540 -0
- package/dungeons/vertical/dating/dating.sql +288 -0
- package/dungeons/vertical/dating/dating.verify.mjs +72 -0
- package/dungeons/vertical/devtools/devtools.js +1524 -0
- package/dungeons/vertical/devtools/devtools.sql +161 -0
- package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
- package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
- package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
- package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
- package/dungeons/vertical/education/education.js +1763 -0
- package/dungeons/vertical/education/education.sql +243 -0
- package/dungeons/vertical/education/education.verify.mjs +75 -0
- package/dungeons/vertical/fintech/fintech.js +1410 -0
- package/dungeons/vertical/fintech/fintech.sql +175 -0
- package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
- package/dungeons/vertical/fitness/fitness.js +1495 -0
- package/dungeons/vertical/fitness/fitness.sql +206 -0
- package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
- package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
- package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
- package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
- package/dungeons/vertical/gaming/gaming.js +1741 -0
- package/dungeons/vertical/gaming/gaming.sql +249 -0
- package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
- package/dungeons/vertical/healthcare/healthcare.js +1488 -0
- package/dungeons/vertical/healthcare/healthcare.sql +193 -0
- package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
- package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
- package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
- package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
- package/dungeons/vertical/logistics/logistics.js +1541 -0
- package/dungeons/vertical/logistics/logistics.sql +205 -0
- package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
- package/dungeons/vertical/marketplace/marketplace.js +1438 -0
- package/dungeons/vertical/marketplace/marketplace.sql +148 -0
- package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
- package/dungeons/vertical/{media.js → media/media.js} +557 -58
- package/dungeons/vertical/media/media.sql +173 -0
- package/dungeons/vertical/media/media.verify.mjs +74 -0
- package/dungeons/vertical/real-estate/real-estate.js +1629 -0
- package/dungeons/vertical/real-estate/real-estate.sql +167 -0
- package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
- package/dungeons/vertical/sass/sass.js +1733 -0
- package/dungeons/vertical/sass/sass.sql +173 -0
- package/dungeons/vertical/sass/sass.verify.mjs +74 -0
- package/dungeons/vertical/social/social.js +1456 -0
- package/dungeons/vertical/social/social.sql +160 -0
- package/dungeons/vertical/social/social.verify.mjs +76 -0
- package/dungeons/vertical/streaming/streaming.js +677 -0
- package/dungeons/vertical/streaming/streaming.sql +101 -0
- package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
- package/dungeons/vertical/support-desk/support-desk.js +741 -0
- package/dungeons/vertical/support-desk/support-desk.sql +137 -0
- package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
- package/dungeons/vertical/travel/travel.js +1603 -0
- package/dungeons/vertical/travel/travel.sql +236 -0
- package/dungeons/vertical/travel/travel.verify.mjs +72 -0
- package/index.js +2 -2
- package/lib/core/config-validator.js +37 -2
- package/lib/core/context.js +4 -2
- package/lib/core/dungeon-loader.js +20 -15
- package/lib/core/dungeon-to-json.js +22 -4
- package/lib/core/storage.js +3 -3
- package/lib/generators/funnels.js +36 -9
- package/lib/hook-helpers/cohort.js +53 -0
- package/lib/hook-helpers/index.js +8 -0
- package/lib/hook-helpers/mutate.js +1 -1
- package/lib/hook-helpers/shape.js +319 -0
- package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
- package/lib/hook-patterns/attributed-by-source.js +60 -58
- package/lib/hook-patterns/frequency-by-frequency.js +14 -4
- package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
- package/lib/hook-patterns/index.js +1 -1
- package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
- package/lib/orchestrators/user-loop.js +41 -0
- package/lib/templates/story-spec.schema.json +175 -0
- package/lib/utils/json-evaluator.js +1 -1
- package/lib/utils/utils.js +5 -4
- package/lib/verify/coerce.js +186 -0
- package/lib/verify/counting.js +270 -32
- package/lib/verify/emulate-breakdown.js +1285 -203
- package/lib/verify/first-time.js +89 -0
- package/lib/verify/flows.js +679 -0
- package/lib/verify/formula.js +259 -0
- package/lib/verify/funnel-engine.js +697 -113
- package/lib/verify/index.js +32 -4
- package/lib/verify/sessionize.js +275 -0
- package/lib/verify/story-runner.js +483 -0
- package/lib/verify/verify-dungeon.js +59 -37
- package/package.json +2 -1
- package/scripts/extract-dungeon-schema.mjs +2 -2
- package/scripts/run-many.mjs +2 -2
- package/scripts/verify-runner.mjs +2 -2
- package/scripts/verify-stories.mjs +244 -0
- package/types.d.ts +296 -30
- package/dungeons/vertical/ai-platform.js +0 -818
- package/dungeons/vertical/community.js +0 -966
- package/dungeons/vertical/crypto.js +0 -895
- package/dungeons/vertical/dating.js +0 -830
- package/dungeons/vertical/devtools.js +0 -1050
- package/dungeons/vertical/ecommerce.js +0 -811
- package/dungeons/vertical/education.js +0 -964
- package/dungeons/vertical/fintech.js +0 -946
- package/dungeons/vertical/fitness.js +0 -935
- package/dungeons/vertical/food-delivery.js +0 -934
- package/dungeons/vertical/gaming.js +0 -1120
- package/dungeons/vertical/healthcare.js +0 -945
- package/dungeons/vertical/insurance-application.js +0 -1039
- package/dungeons/vertical/logistics.js +0 -986
- package/dungeons/vertical/marketplace.js +0 -1019
- package/dungeons/vertical/real-estate.js +0 -872
- package/dungeons/vertical/sass.js +0 -995
- package/dungeons/vertical/social.js +0 -904
- 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
|
+
];
|