@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,1541 @@
1
+ // ── IMPORTS ──
2
+ import dayjs from "dayjs";
3
+ import utc from "dayjs/plugin/utc.js";
4
+ dayjs.extend(utc);
5
+ import "dotenv/config";
6
+ import * as u from "@ak--47/dungeon-master/utils";
7
+ import * as v from "ak-tools";
8
+ /** @typedef {import("../../../types").Dungeon} Config */
9
+
10
+ // ── OVERVIEW ──
11
+ /*
12
+ * NAME: SupplyStack
13
+ * APP: B2B warehouse/inventory management SaaS for businesses to track
14
+ * stock levels, purchase orders, suppliers, shipments, and quality
15
+ * inspections across multiple warehouses. Multi-tier system with
16
+ * enterprise, mid-market, small business, and trial customers.
17
+ * SCALE: 10,000 users, ~2.0M events, 121 days (2026-01-01 → 2026-05-01)
18
+ * CORE LOOP: account created → inventory checked → purchase order → order received → shipment tracked
19
+ *
20
+ * EVENTS (17):
21
+ * inventory checked (8) > app session (7) > shipment tracked (6) > notification received (6)
22
+ * > stockout alert (5) > purchase order created (5) > order received (4) > report generated (4)
23
+ * > supplier contacted (3) > warehouse transfer (3) > invoice processed (3)
24
+ * > integration connected (2) > alert configured (2) > quality inspection (2)
25
+ * > account created (1) > support ticket (1) > account deactivated (1)
26
+ *
27
+ * FUNNELS (5):
28
+ * - Onboarding: account created → inventory checked → integration connected → report generated (40%)
29
+ * - Order Fulfillment: inventory checked → purchase order created → order received → shipment tracked (35%, reentry)
30
+ * - Supplier Management: supplier contacted → purchase order created → invoice processed (45%)
31
+ * - Integration Setup: integration connected → report generated → alert configured (30%)
32
+ * - Alert Response: stockout alert → purchase order created → order received (50%)
33
+ *
34
+ * USER PROPS: company_tier, warehouse_count, employee_count, industry, integration_count, Platform, subscription_plan
35
+ * SUPER PROPS: Platform, subscription_plan
36
+ * SCD PROPS: company_tier (trial/starter/professional/enterprise, monthly fuzzy, max 6)
37
+ * GROUPS: none
38
+ */
39
+
40
+ // ── HOOK STORIES ──
41
+ /*
42
+ * -------------------------------------------------------------------
43
+ * 1. MONTH-END REPORTING SURGE (event hook)
44
+ * -------------------------------------------------------------------
45
+ *
46
+ * PATTERN: Reports generated on calendar days 28-31 have 2.5x the
47
+ * report_pages. Simulates end-of-month compliance and audit reporting.
48
+ *
49
+ * HOW TO FIND IT IN MIXPANEL:
50
+ *
51
+ * Report 1: Report Volume by Day of Month
52
+ * - Report type: Insights
53
+ * - Event: "report generated"
54
+ * - Measure: Average of "report_pages"
55
+ * - Breakdown: Day of month (custom formula or daily chart)
56
+ * - Expected: Days 28-31 should show ~2x avg report_pages vs mid-month
57
+ * (month-end ~40 pages, mid-month ~20 pages)
58
+ *
59
+ * REAL-WORLD ANALOGUE: Warehouse managers generate larger, more
60
+ * comprehensive reports at month-end for audits and compliance.
61
+ *
62
+ * -------------------------------------------------------------------
63
+ * 2. RUSH ORDER PREMIUM (event hook)
64
+ * -------------------------------------------------------------------
65
+ *
66
+ * PATTERN: Purchase orders with priority "urgent" get 1.5x unit_cost.
67
+ * Simulates expedited shipping and rush fulfillment surcharges.
68
+ *
69
+ * HOW TO FIND IT IN MIXPANEL:
70
+ *
71
+ * Report 1: Unit Cost by Priority
72
+ * - Report type: Insights
73
+ * - Event: "purchase order created"
74
+ * - Measure: Average of "unit_cost"
75
+ * - Breakdown: "priority"
76
+ * - Expected: urgent ~$75 vs standard ~$50 (1.5x ratio)
77
+ *
78
+ * REAL-WORLD ANALOGUE: Rush orders from suppliers incur premium
79
+ * pricing for expedited manufacturing and express shipping.
80
+ *
81
+ * -------------------------------------------------------------------
82
+ * 3. REORDER ACCURACY BY TIER (everything hook)
83
+ * -------------------------------------------------------------------
84
+ *
85
+ * PATTERN: Enterprise users have 0.9x stockout rate -- they get 10%
86
+ * of their "stockout alert" events removed. Better forecasting and
87
+ * larger safety stock reduces out-of-stock incidents.
88
+ *
89
+ * HOW TO FIND IT IN MIXPANEL:
90
+ *
91
+ * Report 1: Stockout Rate by Company Tier
92
+ * - Report type: Insights
93
+ * - Event: "stockout alert"
94
+ * - Measure: Total per user
95
+ * - Breakdown: user property "company_tier"
96
+ * - Expected: enterprise ~10% fewer stockout alerts per user than
97
+ * mid_market or small_business
98
+ *
99
+ * Report 2: Stockout-to-Order Ratio
100
+ * - Report type: Insights (formula)
101
+ * - A = "stockout alert" total, B = "purchase order created" total
102
+ * - Formula: A / B
103
+ * - Breakdown: user property "company_tier"
104
+ * - Expected: enterprise ratio ~0.9x vs small_business baseline
105
+ *
106
+ * Report 3: Stockout-to-Inventory-Check Ratio (NORMALIZED — recommended)
107
+ * - Report type: Insights (formula)
108
+ * - A = "stockout alert" total, B = "inventory checked" total
109
+ * - Formula: A / B
110
+ * - Breakdown: user property "company_tier"
111
+ * - Expected: enterprise ratio ~ 0.9x SMB ratio (10% reduction visible)
112
+ * - WHY THIS METRIC: per-user counts are dominated by persona event multipliers
113
+ * (enterprise has 5x baseline activity); ratio normalizes that out.
114
+ *
115
+ * REAL-WORLD ANALOGUE: Enterprise operations have dedicated
116
+ * procurement teams and predictive analytics reducing stockouts.
117
+ *
118
+ * -------------------------------------------------------------------
119
+ * 4. INTEGRATION COMPLETION DRIVES RETENTION (everything hook)
120
+ * -------------------------------------------------------------------
121
+ *
122
+ * PATTERN: Users with 3+ "integration connected" events get cloned
123
+ * "report generated" events. Connected integrations produce richer
124
+ * reporting and deeper platform engagement.
125
+ *
126
+ * HOW TO FIND IT IN MIXPANEL:
127
+ *
128
+ * Report 1: Report Volume by Integration Count
129
+ * - Report type: Insights
130
+ * - Event: "report generated"
131
+ * - Measure: Total per user
132
+ * - Breakdown: user property "integration_count" (or filter by
133
+ * users who did "integration connected" 3+ times)
134
+ * - Expected: Users with 3+ integrations ~2x report events
135
+ *
136
+ * REAL-WORLD ANALOGUE: Businesses that integrate their ERP, shipping,
137
+ * and accounting systems generate more automated reports, increasing
138
+ * stickiness and reducing churn.
139
+ *
140
+ * -------------------------------------------------------------------
141
+ * 5. ALERT FATIGUE (everything hook)
142
+ * -------------------------------------------------------------------
143
+ *
144
+ * PATTERN: Users with >30 "stockout alert" events get increasing
145
+ * response_time_hours on their later alerts. The 20th+ alert has
146
+ * response_time scaled up by 1.5-3x based on position.
147
+ *
148
+ * HOW TO FIND IT IN MIXPANEL:
149
+ *
150
+ * Report 1: Alert Response Time Over Time
151
+ * - Report type: Insights
152
+ * - Event: "stockout alert"
153
+ * - Measure: Average of "response_time_hours"
154
+ * - Line chart by week
155
+ * - Filter: users with high alert volume
156
+ * - Expected: Response time trends upward over time for heavy-alert users
157
+ * (early alerts ~4h, later alerts ~8-12h)
158
+ *
159
+ * REAL-WORLD ANALOGUE: Alert fatigue is a real operational problem --
160
+ * too many alerts desensitize warehouse managers, slowing response.
161
+ *
162
+ * -------------------------------------------------------------------
163
+ * 6. TRIAL CHURN (everything hook)
164
+ * -------------------------------------------------------------------
165
+ *
166
+ * PATTERN: Trial-tier users lose 50% of their events after day 7
167
+ * (measured from their first event). Simulates trial users who briefly
168
+ * explore then abandon the platform.
169
+ *
170
+ * v1.6 BEHAVIOR CHANGE: v1.5 keyed this hook on record.length < 10,
171
+ * which at this dungeon's event rate (~145 events/user; trial persona
172
+ * ~58) matched only ~0.9% of users and never touched actual trial
173
+ * users — the documented retention read below had no engineered signal
174
+ * behind it. Now keyed on company_tier === "trial" so the story and
175
+ * the report line up.
176
+ *
177
+ * HOW TO FIND IT IN MIXPANEL:
178
+ *
179
+ * Report 1: Retention by Company Tier
180
+ * - Report type: Retention
181
+ * - Starting event: "account created"
182
+ * - Return event: Any event
183
+ * - Breakdown: user property "company_tier"
184
+ * - Expected: trial users show a sharp extra drop after week 1
185
+ * (~50% of their post-week-1 activity is removed, on top of the
186
+ * organic 14-day trial activity window)
187
+ *
188
+ * REAL-WORLD ANALOGUE: SaaS trial users who don't activate within
189
+ * the first week rarely convert to paying customers.
190
+ *
191
+ * -------------------------------------------------------------------
192
+ * 7. ENTERPRISE PROFILES (user hook)
193
+ * -------------------------------------------------------------------
194
+ *
195
+ * PATTERN: Users with company_tier "enterprise" get warehouse_count
196
+ * boosted to 5-15 and employee_count to 200-2000. Enterprise ops
197
+ * manage significantly more infrastructure.
198
+ *
199
+ * HOW TO FIND IT IN MIXPANEL:
200
+ *
201
+ * Report 1: Warehouse Count by Tier
202
+ * - Report type: Insights
203
+ * - Measure: Profiles -> Average of "warehouse_count"
204
+ * - Breakdown: "company_tier"
205
+ * - Expected: enterprise ~10 warehouses vs small_business ~2
206
+ *
207
+ * Report 2: Employee Count Distribution
208
+ * - Report type: Insights
209
+ * - Measure: Profiles -> Average of "employee_count"
210
+ * - Breakdown: "company_tier"
211
+ * - Expected: enterprise ~1100 vs small_business ~50
212
+ *
213
+ * REAL-WORLD ANALOGUE: Enterprise customers operate multi-warehouse
214
+ * networks with large logistics teams, driving higher ACV.
215
+ *
216
+ * -------------------------------------------------------------------
217
+ * 8. SMALL-BUSINESS CONVERSION DROP (everything hook)
218
+ * -------------------------------------------------------------------
219
+ *
220
+ * PATTERN: Small-business users lose ~35% of "alert configured"
221
+ * events (last step of the Integration Setup funnel). This is
222
+ * implemented via event filtering in the everything hook rather
223
+ * than conversionRate modification in funnel-pre, so the effect
224
+ * is not diluted by organic (non-funnel) events.
225
+ *
226
+ * HOW TO FIND IT IN MIXPANEL:
227
+ *
228
+ * Report 1: Integration Funnel by Tier
229
+ * - Report type: Funnels
230
+ * - Steps: "integration connected" -> "report generated" -> "alert configured"
231
+ * - Breakdown: user property "company_tier"
232
+ * - Expected: small_business ~20% vs enterprise/mid_market ~30% conversion
233
+ *
234
+ * REAL-WORLD ANALOGUE: Small businesses lack dedicated IT teams,
235
+ * leading to incomplete integration setup and lower alert adoption.
236
+ *
237
+ * -------------------------------------------------------------------
238
+ * 9. INVENTORY-CHECK MAGIC NUMBER (everything hook)
239
+ * -------------------------------------------------------------------
240
+ *
241
+ * PATTERN: Users with 5-15 "inventory checked" events sit in the
242
+ * "engaged-but-focused" sweet spot — every "purchase order created"
243
+ * event gets quantity boosted 1.4x. Users with 16 or more inventory
244
+ * checks are over-engaged (paralysis); ~60% of their "purchase order
245
+ * created" events are dropped. No flag is stamped — discoverable only
246
+ * by binning users on inventory-check COUNT and comparing PO totals.
247
+ *
248
+ * HOW TO FIND IT IN MIXPANEL:
249
+ *
250
+ * Report 1: PO Quantity by Inventory-Check Bucket
251
+ * - Report type: Insights (with cohort)
252
+ * - Cohort A: users with 5-15 "inventory checked" events
253
+ * - Cohort B: users with 0-4 "inventory checked" events
254
+ * - Event: "purchase order created"
255
+ * - Measure: Average of "quantity"
256
+ * - Compare cohort A vs cohort B
257
+ * - Expected: cohort A ~ 1.4x higher quantity than B
258
+ *
259
+ * Report 2: POs per User by Browse Intensity
260
+ * - Report type: Insights (with cohort)
261
+ * - Cohort C: users with >= 16 "inventory checked" events
262
+ * - Cohort A: users with 5-15 "inventory checked" events
263
+ * - Event: "purchase order created"
264
+ * - Measure: Total events per user
265
+ * - Compare cohort C vs cohort A
266
+ * - Expected: cohort C has ~ 60% fewer POs per user
267
+ *
268
+ * REAL-WORLD ANALOGUE: A focused operations team that monitors
269
+ * stock just enough places larger, more confident orders; an
270
+ * obsessive checker is paralysed and orders less.
271
+ *
272
+ * -------------------------------------------------------------------
273
+ * 10. ONBOARDING TIME-TO-CONVERT (funnel-post hook)
274
+ * -------------------------------------------------------------------
275
+ *
276
+ * PATTERN: Enterprise-tier users complete the Onboarding funnel
277
+ * 1.4x faster (time gaps scaled by 0.71). Small-business and trial
278
+ * users complete it 1.3x slower (gaps scaled by 1.3). The hook
279
+ * iterates over the funnel-post event array, compresses or stretches
280
+ * the inter-step time gaps based on the user's company_tier from
281
+ * meta.profile, then rewrites each event's timestamp.
282
+ *
283
+ * v1.6: scoped to the Onboarding funnel via meta.funnel.name — v1.5
284
+ * applied the factor to every funnel's gaps, contradicting this
285
+ * documented story.
286
+ *
287
+ * HOW TO FIND IT IN MIXPANEL:
288
+ *
289
+ * Report 1: Onboarding TTC by Company Tier
290
+ * - Report type: Funnels
291
+ * - Steps: "account created" -> "inventory checked" -> "integration connected" -> "report generated"
292
+ * - Breakdown: user property "company_tier"
293
+ * - Metric: Median time to convert
294
+ * - Expected: enterprise median TTC ~ 0.71x of small_business/trial TTC
295
+ * (e.g., enterprise ~ 36h vs small_business ~ 66h)
296
+ *
297
+ * NOTE: This effect is visible in Mixpanel funnel median TTC and in
298
+ * emulateBreakdown's timeToConvert (the H10 story asserts it at a
299
+ * 93.6h window = 72h generative window x the 1.3 small-business
300
+ * stretch). Cross-event MIN->MIN SQL queries on raw events do NOT
301
+ * show it — greedy single-pass pairing across funnel instances
302
+ * buries the signal.
303
+ *
304
+ * REAL-WORLD ANALOGUE: Enterprise customers have dedicated IT teams
305
+ * and onboarding specialists who move through setup, integration,
306
+ * and first reporting much faster than small businesses configuring
307
+ * the platform themselves.
308
+ *
309
+ * ===================================================================
310
+ * EXPECTED METRICS SUMMARY (Measured = full fidelity, 10K users / 2,020,201 events)
311
+ * ===================================================================
312
+ *
313
+ * Story id | Metric | Expected | Measured
314
+ * --------------------------|--------------------------------------------|---------------|---------
315
+ * H1-month-end-pages | organic month-end / mid-month report_pages | ≈2.47 | 2.476
316
+ * | placebo: clone month-end / mid-month pages | ≈1.0 | 1.002
317
+ * H2-rush-order-premium | urgent / standard unit_cost | ≈1.5 | 1.495
318
+ * | placebo: expedited / standard unit_cost | ≈1.0 | 0.999
319
+ * H3-stockout-by-tier | ent / smb stockout-per-inventory-check | ≈0.89 | 0.905
320
+ * | placebo: mid / smb ratio | ≈1.0 | 1.010
321
+ * H4-integration-reports | clones per integration (3+ cohort) | ≈0.637 | 0.627
322
+ * | <3-integration users with any clone | 0 | 0.0000
323
+ * H5-alert-fatigue | late-treated / control response_time | ≈2.0 | 2.002
324
+ * | placebo: early-untreated / control | ≈1.0 | 1.003
325
+ * H6-trial-churn | DiD trial wk2/wk1 rate vs small_business | ≈0.5-0.55 | 0.448
326
+ * | placebo: mid_market vs small_business | ≈1.0 | 1.143
327
+ * H7-enterprise-profiles | per-tier profile ranges in-range share | 100% | 100%
328
+ * H8-smb-conversion-drop | Integration Setup step2→3 smb/mid conv | ≈0.50 | 0.472
329
+ * | placebo: Supplier Mgmt smb/mid conv | ≈0.73 | 0.711
330
+ * H9-inventory-magic-number | sweet / low PO quantity | ≈1.4 | 1.388
331
+ * | keep_hat b16-23/b12-15 PO-per-inv (smb) | ≈0.37 | 0.379
332
+ * | placebo: sweet / low unit_cost | ≈1.0 | 1.005
333
+ * H10-onboarding-ttc | Onboarding median TTC ent/mid (93.6h win) | ≈0.62-0.74 | 0.741
334
+ * | Onboarding median TTC smb/mid | ≈1.1-1.2 | 1.107
335
+ */
336
+
337
+ // ── SCALE ──
338
+ const SEED = "dm4-logistics";
339
+ const NUM_USERS = 10_000;
340
+ const DATASET_START = "2026-01-01T00:00:00Z";
341
+ const DATASET_END = "2026-05-01T23:59:59Z";
342
+ const EVENTS_PER_DAY = 1.2;
343
+ const token = process.env.MP_TOKEN || "your-mixpanel-token";
344
+
345
+ const chance = u.initChance(SEED);
346
+
347
+ // ── KNOBS (tweak these to reshape stories) ──
348
+ const MONTH_END_DAY_THRESHOLD = 28;
349
+ const MONTH_END_PAGES_MULT = 2.5;
350
+
351
+ const RUSH_ORDER_COST_MULT = 1.5;
352
+
353
+ const ENTERPRISE_STOCKOUT_DROP_LIKELIHOOD = 10;
354
+
355
+ const INTEGRATION_THRESHOLD = 3;
356
+ const INTEGRATION_REPORT_CLONE_LIKELIHOOD = 65;
357
+
358
+ const ALERT_FATIGUE_THRESHOLD = 30;
359
+ const ALERT_FATIGUE_START_IDX = 20;
360
+ const ALERT_FATIGUE_BASE_MULT = 1.5;
361
+ const ALERT_FATIGUE_RAMP_MULT = 1.5;
362
+
363
+ const TRIAL_CHURN_CUTOFF_DAYS = 7;
364
+ const TRIAL_CHURN_DROP_LIKELIHOOD = 50;
365
+
366
+ const SMB_ALERT_DROP_LIKELIHOOD = 35;
367
+
368
+ const INVENTORY_SWEET_MIN = 5;
369
+ const INVENTORY_SWEET_MAX = 15;
370
+ const INVENTORY_OVER_THRESHOLD = 16;
371
+ const INVENTORY_PO_QUANTITY_BOOST = 1.4;
372
+ const INVENTORY_OVER_PO_DROP_LIKELIHOOD = 60;
373
+
374
+ const TTC_ENTERPRISE_FACTOR = 0.71;
375
+ const TTC_SMB_FACTOR = 1.3;
376
+
377
+ // ── DATA ARRAYS ──
378
+ const warehouseIds = v.range(1, 80).map(() => `WH_${v.uid(6)}`);
379
+ const supplierIds = v.range(1, 150).map(() => `SUP_${v.uid(6)}`);
380
+
381
+ // ── HELPER FUNCTIONS ──
382
+ function handleUserHooks(record) {
383
+ // H7: ENTERPRISE PROFILES — large warehouse_count + employee_count by tier.
384
+ if (record.company_tier === "enterprise") {
385
+ record.warehouse_count = chance.integer({ min: 5, max: 15 });
386
+ record.employee_count = chance.integer({ min: 200, max: 2000 });
387
+ } else if (record.company_tier === "mid_market") {
388
+ record.warehouse_count = chance.integer({ min: 2, max: 6 });
389
+ record.employee_count = chance.integer({ min: 20, max: 200 });
390
+ } else if (record.company_tier === "small_business") {
391
+ record.warehouse_count = chance.integer({ min: 1, max: 3 });
392
+ record.employee_count = chance.integer({ min: 5, max: 80 });
393
+ } else if (record.company_tier === "trial") {
394
+ record.warehouse_count = 1;
395
+ record.employee_count = chance.integer({ min: 1, max: 10 });
396
+ }
397
+ return record;
398
+ }
399
+
400
+ function handleEventHooks(record) {
401
+ // H2: RUSH ORDER PREMIUM — urgent purchase orders get 1.5x unit_cost.
402
+ if (record.event === "purchase order created" && record.priority === "urgent") {
403
+ record.unit_cost = Math.floor((record.unit_cost || 50) * RUSH_ORDER_COST_MULT);
404
+ }
405
+ return record;
406
+ }
407
+
408
+ function handleFunnelPostHooks(record, meta) {
409
+ // H10: ONBOARDING TIME-TO-CONVERT — enterprise 1.4x faster (0.71);
410
+ // small_business + trial 1.3x slower (1.3).
411
+ // v1.6: scoped to the Onboarding funnel only. v1.5 applied the factor to
412
+ // EVERY funnel's inter-step gaps, contradicting the documented story
413
+ // (Onboarding TTC) and silently stretching/compressing all five funnels.
414
+ if (meta?.funnel?.name !== "Onboarding") return record;
415
+ const segment = meta?.profile?.company_tier;
416
+ if (Array.isArray(record) && record.length > 1) {
417
+ const factor = (
418
+ segment === "enterprise" ? TTC_ENTERPRISE_FACTOR :
419
+ segment === "small_business" || segment === "trial" ? TTC_SMB_FACTOR :
420
+ 1.0
421
+ );
422
+ if (factor !== 1.0) {
423
+ for (let i = 1; i < record.length; i++) {
424
+ const prev = dayjs(record[i - 1].time);
425
+ const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
426
+ record[i].time = prev.add(newGap, "milliseconds").toISOString();
427
+ }
428
+ }
429
+ }
430
+ return record;
431
+ }
432
+
433
+ function handleEverythingHooks(record, meta) {
434
+ if (!record.length) return record;
435
+ const profile = meta.profile;
436
+
437
+ // ── SUPER-PROP STAMPING ──
438
+ // Stamp superProps from profile so they are consistent per-user.
439
+ if (profile) {
440
+ const plat = profile.Platform;
441
+ const plan = profile.subscription_plan;
442
+ record.forEach(e => {
443
+ if (plat) e.Platform = plat;
444
+ if (plan) e.subscription_plan = plan;
445
+ });
446
+ }
447
+
448
+ // H1: MONTH-END REPORTING SURGE — reports on calendar days 28-31 get
449
+ // 2.5x report_pages. Runs in everything (post-sessionization) so the
450
+ // day-of-month tag matches the final timestamp.
451
+ for (const e of record) {
452
+ if (e.event === "report generated") {
453
+ const dayOfMonth = new Date(e.time).getUTCDate();
454
+ if (dayOfMonth >= MONTH_END_DAY_THRESHOLD) {
455
+ e.report_pages = Math.floor((e.report_pages || 20) * MONTH_END_PAGES_MULT);
456
+ }
457
+ }
458
+ }
459
+
460
+ // H8: SMALL-BUSINESS CONVERSION DROP — small_business users lose
461
+ // ~35% of "alert configured" events (last step of Integration Setup
462
+ // funnel), simulating lower conversion without dedicated IT.
463
+ if (profile && profile.company_tier === "small_business") {
464
+ record = record.filter(e => {
465
+ if (e.event === "alert configured" && chance.bool({ likelihood: SMB_ALERT_DROP_LIKELIHOOD })) {
466
+ return false;
467
+ }
468
+ return true;
469
+ });
470
+ }
471
+
472
+ // H3: REORDER ACCURACY BY TIER — enterprise users get 10% of
473
+ // stockout alerts removed (better forecasting + safety stock).
474
+ if (profile && profile.company_tier === "enterprise") {
475
+ for (let i = record.length - 1; i >= 0; i--) {
476
+ if (record[i].event === "stockout alert" && chance.bool({ likelihood: ENTERPRISE_STOCKOUT_DROP_LIKELIHOOD })) {
477
+ record.splice(i, 1);
478
+ }
479
+ }
480
+ }
481
+
482
+ // H6: TRIAL CHURN — trial-tier users lose 50% of events after day 7
483
+ // (measured from their first event; account created is isFirstEvent so
484
+ // record[0] is the birth event).
485
+ // v1.6: keyed on company_tier === "trial". v1.5 keyed on
486
+ // record.length < 10, which at this dungeon's event rate (~145
487
+ // events/user; trial persona ~58) matched only ~0.9% of users and never
488
+ // touched actual trial users — the documented retention read (trial
489
+ // drop after week 1, broken down by company_tier) had no engineered
490
+ // signal behind it.
491
+ // Runs BEFORE H4 so the 3+-integration clone cohort is defined on
492
+ // FINAL integration counts — otherwise churned trial users keep clones
493
+ // while their output count falls below the threshold (leakage).
494
+ if (profile && profile.company_tier === "trial" && record.length > 1) {
495
+ const firstTime = dayjs(record[0].time);
496
+ const cutoff = firstTime.add(TRIAL_CHURN_CUTOFF_DAYS, "days");
497
+ for (let i = record.length - 1; i >= 0; i--) {
498
+ if (dayjs(record[i].time).isAfter(cutoff) && chance.bool({ likelihood: TRIAL_CHURN_DROP_LIKELIHOOD })) {
499
+ record.splice(i, 1);
500
+ }
501
+ }
502
+ }
503
+
504
+ // H4: INTEGRATION COMPLETION DRIVES RETENTION — users with 3+
505
+ // "integration connected" events get cloned "report generated" events.
506
+ const integrationCount = record.filter(e => e.event === "integration connected").length;
507
+ if (integrationCount >= INTEGRATION_THRESHOLD) {
508
+ const templateReport = record.find(e => e.event === "report generated");
509
+ if (templateReport) {
510
+ const integrationEvents = record.filter(e => e.event === "integration connected");
511
+ integrationEvents.forEach(ie => {
512
+ if (chance.bool({ likelihood: INTEGRATION_REPORT_CLONE_LIKELIHOOD })) {
513
+ record.push({
514
+ ...templateReport,
515
+ time: dayjs(ie.time).add(chance.integer({ min: 1, max: 5 }), "days").toISOString(),
516
+ user_id: ie.user_id,
517
+ insert_id: chance.guid(), // clones must not share the template's insert_id (Mixpanel dedup)
518
+ report_type: "integration_summary",
519
+ report_pages: chance.integer({ min: 5, max: 25 }),
520
+ });
521
+ }
522
+ });
523
+ }
524
+ }
525
+
526
+ // H5: ALERT FATIGUE — users with >30 stockout alerts get increasing
527
+ // response_time_hours starting at the 20th alert.
528
+ const alertEvents = record.filter(e => e.event === "stockout alert");
529
+ if (alertEvents.length > ALERT_FATIGUE_THRESHOLD) {
530
+ alertEvents.forEach((alert, idx) => {
531
+ if (idx >= ALERT_FATIGUE_START_IDX) {
532
+ const fatigueMultiplier = ALERT_FATIGUE_BASE_MULT + ((idx - ALERT_FATIGUE_START_IDX) / alertEvents.length) * ALERT_FATIGUE_RAMP_MULT;
533
+ alert.response_time_hours = Math.floor((alert.response_time_hours || 4) * fatigueMultiplier);
534
+ }
535
+ });
536
+ }
537
+
538
+ // H9: INVENTORY-CHECK MAGIC NUMBER (no flags) — sweet 5-15 inventory
539
+ // checks → PO quantity x1.4; over 16+ → drop 60% of PO created events.
540
+ const invCheckCount = record.filter(e => e.event === "inventory checked").length;
541
+ if (invCheckCount >= INVENTORY_SWEET_MIN && invCheckCount <= INVENTORY_SWEET_MAX) {
542
+ record.forEach(e => {
543
+ if (e.event === "purchase order created" && typeof e.quantity === "number") {
544
+ e.quantity = Math.round(e.quantity * INVENTORY_PO_QUANTITY_BOOST);
545
+ }
546
+ });
547
+ } else if (invCheckCount >= INVENTORY_OVER_THRESHOLD) {
548
+ for (let i = record.length - 1; i >= 0; i--) {
549
+ if (record[i].event === "purchase order created" && chance.bool({ likelihood: INVENTORY_OVER_PO_DROP_LIKELIHOOD })) {
550
+ record.splice(i, 1);
551
+ }
552
+ }
553
+ }
554
+
555
+ return record;
556
+ }
557
+
558
+ // ── CONFIG ──
559
+ /** @type {Config} */
560
+ const config = {
561
+ version: 2,
562
+ seed: SEED,
563
+ datasetStart: DATASET_START,
564
+ datasetEnd: DATASET_END,
565
+ avgEventsPerUserPerDay: EVENTS_PER_DAY,
566
+ numUsers: NUM_USERS,
567
+ format: "json",
568
+ gzip: true,
569
+ credentials: {
570
+ token,
571
+ },
572
+ switches: {
573
+ hasSessionIds: true,
574
+ alsoInferFunnels: false,
575
+ hasLocation: true,
576
+ hasAndroidDevices: false,
577
+ hasIOSDevices: false,
578
+ hasDesktopDevices: true,
579
+ hasBrowser: true,
580
+ hasCampaigns: false,
581
+ isAnonymous: false,
582
+ hasAdSpend: false,
583
+ hasAvatar: true,
584
+ },
585
+ identity: {
586
+ avgDevicePerUser: 2,
587
+ },
588
+ concurrency: 1,
589
+ writeToDisk: false,
590
+ scdProps: {
591
+ company_tier: {
592
+ values: ["trial", "starter", "professional", "enterprise"],
593
+ frequency: "month",
594
+ timing: "fuzzy",
595
+ max: 6
596
+ }
597
+ },
598
+ mirrorProps: {},
599
+ lookupTables: [],
600
+
601
+ // -- Events (17) --------------------------------------------------
602
+ events: [
603
+ {
604
+ event: "account created",
605
+ weight: 1,
606
+ isFirstEvent: true,
607
+ isAuthEvent: true,
608
+ properties: {
609
+ referral_source: ["organic", "partner_referral", "google_search", "trade_show", "linkedin"],
610
+ },
611
+ },
612
+ {
613
+ event: "inventory checked",
614
+ weight: 8,
615
+ isStrictEvent: false,
616
+ properties: {
617
+ warehouse_id: chance.pickone.bind(chance, warehouseIds),
618
+ sku_category: ["electronics", "apparel", "food_beverage", "pharma", "raw_materials", "packaging", "automotive_parts"],
619
+ stock_level: u.weighNumRange(0, 5000, 0.4, 500),
620
+ reorder_point: u.weighNumRange(10, 500, 0.5, 100),
621
+ reorder_method: ["manual"],
622
+ },
623
+ },
624
+ {
625
+ event: "stockout alert",
626
+ weight: 5,
627
+ isStrictEvent: false,
628
+ properties: {
629
+ warehouse_id: chance.pickone.bind(chance, warehouseIds),
630
+ sku_category: ["electronics", "apparel", "food_beverage", "pharma", "raw_materials", "packaging"],
631
+ items_affected: u.weighNumRange(1, 50, 0.4, 5),
632
+ severity: ["low", "medium", "medium", "high", "critical"],
633
+ response_time_hours: u.weighNumRange(0.5, 48, 0.3, 4),
634
+ },
635
+ },
636
+ {
637
+ event: "purchase order created",
638
+ weight: 5,
639
+ isStrictEvent: false,
640
+ properties: {
641
+ supplier_id: chance.pickone.bind(chance, supplierIds),
642
+ sku_category: ["electronics", "apparel", "food_beverage", "pharma", "raw_materials", "packaging", "automotive_parts"],
643
+ quantity: u.weighNumRange(10, 5000, 0.3, 200),
644
+ unit_cost: u.weighNumRange(5, 200, 0.4, 50),
645
+ priority: ["standard", "standard", "standard", "expedited", "urgent"],
646
+ estimated_delivery_days: u.weighNumRange(1, 30, 0.4, 7),
647
+ },
648
+ },
649
+ {
650
+ event: "order received",
651
+ weight: 4,
652
+ properties: {
653
+ warehouse_id: chance.pickone.bind(chance, warehouseIds),
654
+ supplier_id: chance.pickone.bind(chance, supplierIds),
655
+ quantity_received: u.weighNumRange(10, 5000, 0.3, 200),
656
+ quantity_damaged: u.weighNumRange(0, 20, 0.2),
657
+ receiving_time_hours: u.weighNumRange(0.5, 8, 0.5, 2),
658
+ },
659
+ },
660
+ {
661
+ event: "shipment tracked",
662
+ weight: 6,
663
+ properties: {
664
+ carrier: ["fedex", "ups", "dhl", "usps", "freight_line", "regional_courier"],
665
+ shipment_status: ["in_transit", "in_transit", "in_transit", "delivered", "delayed", "exception"],
666
+ origin_warehouse: chance.pickone.bind(chance, warehouseIds),
667
+ estimated_days: u.weighNumRange(1, 14, 0.4, 3),
668
+ weight_kg: u.weighNumRange(1, 2000, 0.3, 50),
669
+ },
670
+ },
671
+ {
672
+ event: "supplier contacted",
673
+ weight: 3,
674
+ properties: {
675
+ supplier_id: chance.pickone.bind(chance, supplierIds),
676
+ contact_reason: ["price_negotiation", "order_status", "quality_issue", "new_product", "contract_renewal", "returns"],
677
+ contact_method: ["email", "email", "phone", "portal"],
678
+ supplier_access: ["email_only"],
679
+ },
680
+ },
681
+ {
682
+ event: "integration connected",
683
+ weight: 2,
684
+ isStrictEvent: false,
685
+ properties: {
686
+ integration_type: ["erp", "accounting", "shipping", "ecommerce", "crm", "bi_tool"],
687
+ integration_name: ["SAP", "QuickBooks", "ShipStation", "Shopify", "Salesforce", "NetSuite", "Xero"],
688
+ setup_time_minutes: u.weighNumRange(5, 120, 0.4, 30),
689
+ },
690
+ },
691
+ {
692
+ event: "report generated",
693
+ weight: 4,
694
+ isStrictEvent: false,
695
+ properties: {
696
+ report_type: ["inventory_summary", "order_history", "supplier_performance", "cost_analysis", "forecast", "compliance"],
697
+ report_pages: u.weighNumRange(1, 50, 0.4, 20),
698
+ export_format: ["pdf", "csv", "xlsx"],
699
+ time_range_days: [7, 14, 30, 30, 90],
700
+ },
701
+ },
702
+ {
703
+ event: "alert configured",
704
+ weight: 2,
705
+ isStrictEvent: false,
706
+ properties: {
707
+ alert_type: ["low_stock", "delivery_delay", "price_change", "quality_threshold", "expiry_warning"],
708
+ threshold_value: u.weighNumRange(1, 500, 0.4, 50),
709
+ notification_channel: ["email", "email", "sms", "push", "slack"],
710
+ },
711
+ },
712
+ {
713
+ event: "warehouse transfer",
714
+ weight: 3,
715
+ properties: {
716
+ from_warehouse: chance.pickone.bind(chance, warehouseIds),
717
+ to_warehouse: chance.pickone.bind(chance, warehouseIds),
718
+ sku_category: ["electronics", "apparel", "food_beverage", "pharma", "raw_materials"],
719
+ transfer_quantity: u.weighNumRange(10, 1000, 0.4, 100),
720
+ transfer_reason: ["rebalance", "demand_shift", "consolidation", "overflow", "seasonal"],
721
+ },
722
+ },
723
+ {
724
+ event: "quality inspection",
725
+ weight: 2,
726
+ properties: {
727
+ warehouse_id: chance.pickone.bind(chance, warehouseIds),
728
+ inspection_result: ["pass", "pass", "pass", "pass", "minor_issue", "major_issue", "fail"],
729
+ items_inspected: u.weighNumRange(10, 500, 0.4, 50),
730
+ defect_rate_pct: u.weighNumRange(0, 15, 0.3, 2),
731
+ },
732
+ },
733
+ {
734
+ event: "invoice processed",
735
+ weight: 3,
736
+ properties: {
737
+ supplier_id: chance.pickone.bind(chance, supplierIds),
738
+ invoice_amount: u.weighNumRange(100, 50000, 0.3, 2500),
739
+ payment_terms: ["net_30", "net_30", "net_60", "net_15", "on_receipt"],
740
+ payment_status: ["paid", "paid", "paid", "pending", "overdue"],
741
+ },
742
+ },
743
+ {
744
+ event: "notification received",
745
+ weight: 6,
746
+ properties: {
747
+ notification_type: ["low_stock_alert", "low_stock_alert", "delivery_update", "order_confirmation", "invoice_due", "system_update"],
748
+ channel: ["email", "email", "push", "sms"],
749
+ opened: [true, true, true, false],
750
+ },
751
+ },
752
+ {
753
+ event: "support ticket",
754
+ weight: 1,
755
+ properties: {
756
+ category: ["billing", "technical", "integration", "data_import", "feature_request", "training"],
757
+ priority: ["low", "low", "medium", "medium", "high"],
758
+ resolution_hours: u.weighNumRange(1, 72, 0.4, 12),
759
+ },
760
+ },
761
+ {
762
+ event: "app session",
763
+ weight: 7,
764
+ properties: {
765
+ session_duration_sec: u.weighNumRange(30, 3600, 0.4, 300),
766
+ pages_viewed: u.weighNumRange(1, 20, 0.5, 5),
767
+ },
768
+ },
769
+ {
770
+ event: "account deactivated",
771
+ weight: 1,
772
+ isChurnEvent: true,
773
+ returnLikelihood: 0.10,
774
+ isStrictEvent: true,
775
+ properties: {
776
+ reason: ["too_expensive", "switched_competitor", "business_closed", "missing_features", "poor_support"],
777
+ },
778
+ },
779
+ ],
780
+
781
+ // -- Funnels (5) --------------------------------------------------
782
+ funnels: [
783
+ {
784
+ name: "Onboarding",
785
+ sequence: ["account created", "inventory checked", "integration connected", "report generated"],
786
+ conversionRate: 40,
787
+ order: "sequential",
788
+ isFirstFunnel: true,
789
+ timeToConvert: 72,
790
+ weight: 3,
791
+ },
792
+ {
793
+ name: "Order Fulfillment",
794
+ sequence: ["inventory checked", "purchase order created", "order received", "shipment tracked"],
795
+ conversionRate: 35,
796
+ order: "sequential",
797
+ timeToConvert: 168,
798
+ weight: 5,
799
+ reentry: true,
800
+ },
801
+ {
802
+ name: "Supplier Management",
803
+ sequence: ["supplier contacted", "purchase order created", "invoice processed"],
804
+ conversionRate: 45,
805
+ order: "sequential",
806
+ timeToConvert: 336,
807
+ weight: 3,
808
+ },
809
+ {
810
+ name: "Integration Setup",
811
+ sequence: ["integration connected", "report generated", "alert configured"],
812
+ conversionRate: 30,
813
+ order: "sequential",
814
+ timeToConvert: 48,
815
+ weight: 2,
816
+ },
817
+ {
818
+ name: "Alert Response",
819
+ sequence: ["stockout alert", "purchase order created", "order received"],
820
+ conversionRate: 50,
821
+ order: "sequential",
822
+ timeToConvert: 120,
823
+ weight: 3,
824
+ },
825
+ ],
826
+
827
+ // -- SuperProps ----------------------------------------------------
828
+ superProps: {
829
+ Platform: ["web", "web", "desktop_app"],
830
+ subscription_plan: ["free_trial", "free_trial", "starter", "starter", "professional", "enterprise"],
831
+ },
832
+
833
+ // -- UserProps -----------------------------------------------------
834
+ userProps: {
835
+ company_tier: ["small_business"],
836
+ warehouse_count: u.weighNumRange(1, 10, 0.4, 2),
837
+ employee_count: u.weighNumRange(1, 500, 0.3, 25),
838
+ industry: ["retail", "manufacturing", "food_beverage", "pharma", "electronics"],
839
+ integration_count: [0],
840
+ Platform: ["web", "web", "desktop_app"],
841
+ subscription_plan: ["free_trial", "free_trial", "starter", "starter", "professional", "enterprise"],
842
+ },
843
+
844
+ // -- Phase 2: Personas --------------------------------------------
845
+ personas: [
846
+ {
847
+ name: "enterprise_ops",
848
+ weight: 15,
849
+ eventMultiplier: 5.0,
850
+ conversionModifier: 1.5,
851
+ churnRate: 0.01,
852
+ properties: {
853
+ company_tier: "enterprise",
854
+ segment: "enterprise_ops",
855
+ },
856
+ },
857
+ {
858
+ name: "mid_market",
859
+ weight: 35,
860
+ eventMultiplier: 1.5,
861
+ conversionModifier: 1.0,
862
+ churnRate: 0.05,
863
+ properties: {
864
+ company_tier: "mid_market",
865
+ segment: "mid_market",
866
+ },
867
+ },
868
+ {
869
+ name: "small_business",
870
+ weight: 40,
871
+ eventMultiplier: 0.8,
872
+ conversionModifier: 0.7,
873
+ churnRate: 0.10,
874
+ properties: {
875
+ company_tier: "small_business",
876
+ segment: "small_business",
877
+ },
878
+ },
879
+ {
880
+ name: "trial_explorer",
881
+ weight: 10,
882
+ eventMultiplier: 0.4,
883
+ conversionModifier: 0.3,
884
+ churnRate: 0.4,
885
+ properties: {
886
+ company_tier: "trial",
887
+ segment: "trial_explorer",
888
+ },
889
+ activeWindow: { maxDays: 14 },
890
+ },
891
+ ],
892
+
893
+ // -- Phase 2: World Events ----------------------------------------
894
+ worldEvents: [
895
+ {
896
+ name: "supply_chain_disruption",
897
+ type: "outage",
898
+ startDay: 35,
899
+ duration: 5,
900
+ volumeMultiplier: 3.0,
901
+ affectsEvents: ["stockout alert"],
902
+ injectProps: { disruption: "supply_chain_disruption" },
903
+ aftermath: { duration: 5, volumeMultiplier: 1.5 },
904
+ },
905
+ {
906
+ name: "holiday_surge",
907
+ type: "campaign",
908
+ startDay: 70,
909
+ duration: 7,
910
+ volumeMultiplier: 2.0,
911
+ affectsEvents: ["purchase order created", "order received", "shipment tracked"],
912
+ injectProps: { surge: "holiday_prep" },
913
+ },
914
+ ],
915
+
916
+ hook(record, type, meta) {
917
+ if (type === "user") return handleUserHooks(record);
918
+ if (type === "event") return handleEventHooks(record);
919
+ if (type === "funnel-post") return handleFunnelPostHooks(record, meta);
920
+ if (type === "everything") return handleEverythingHooks(record, meta);
921
+ return record;
922
+ },
923
+ };
924
+
925
+ export default config;
926
+
927
+ // ═══════════════════════════════════════════════════════════════
928
+ // STORIES — v1.6 machine-checkable verification contract
929
+ // ═══════════════════════════════════════════════════════════════
930
+ //
931
+ // Measurement doctrine (why each read is shaped the way it is):
932
+ //
933
+ // - IDENTITY. avgDevicePerUser: 2, and "account created" is both
934
+ // isAuthEvent and isFirstEvent, so born users auth on their first
935
+ // event. The ID_CTE resolves device-only rows through the profile
936
+ // device pool (stored under the legacy "anonymousIds" key).
937
+ //
938
+ // - CLONE EXCLUSION (H1 vs H4). H4's cloned reports carry
939
+ // report_type = 'integration_summary' — a value outside the organic
940
+ // pool — and uniform report_pages [5, 25] stamped AFTER H1's
941
+ // month-end multiplier runs. Clones therefore dilute any pooled
942
+ // month-end read; H1 filters them out (and uses them as its placebo
943
+ // arm: their day-of-month page ratio must be ~1.0).
944
+ //
945
+ // - ONE-SIDED DELETIONS. Stockout alerts are deleted only for
946
+ // enterprise (H3), alert-configured only for small_business (H8),
947
+ // POs only for 16+ inventory checkers (H9), trial events only after
948
+ // day 7 (H6). H6 runs BEFORE both count-threshold hooks (H4, H9), so
949
+ // output integration-connected and inventory-check counts equal the
950
+ // hook-time counts those thresholds keyed on — cohort membership is
951
+ // exactly recoverable from the output.
952
+ //
953
+ // - ACTIVITY COUPLING (H9 volume read). Inventory-check count is
954
+ // coupled to total activity, so cross-arm PO-per-user levels are
955
+ // meaningless. The read uses PO-per-inventory-check within the
956
+ // small_business tier only (constant conversionModifier), and reads
957
+ // the treated cliff against the adjacent untreated bin, with a
958
+ // flatness guard on the pre-cliff bins (measured organic gradient:
959
+ // 1.62 → 1.62 → 1.46 across bins 4-7/8-11/12-15).
960
+ //
961
+ // - RELATIVE-DAY DiD (H6). Trial users lose 50% of events after day 7
962
+ // from first event. Cross-tier LEVELS differ (activeWindow 14d,
963
+ // multipliers), but each tier's own rate(day 8-13)/rate(day 1-6)
964
+ // cancels its level; small_business is the untreated comparator.
965
+ // Derivation: DiD = 0.5 x (organic trial ratio / organic smb ratio);
966
+ // the organic composition term is bounded [0.9, 1.2] (smb measured
967
+ // 0.91, mid 0.96, enterprise 1.06 — flat-to-mild-decline across
968
+ // personas), giving [0.45, 0.60]; band [0.44, 0.62].
969
+ //
970
+ // - EMULATOR TTC (H8, H10). Funnel-step conversion and TTC reads go
971
+ // through emulateBreakdown's timeToConvert (Mixpanel-aligned greedy
972
+ // in-window pairing). H10's window = 72h generative x 1.3 max
973
+ // stretch = 93.6h so slow-arm conversions are not right-censored
974
+ // into a fake speedup. Only born-in-dataset users (~12%) have
975
+ // "account created" inside the window — cohorts are structurally
976
+ // ~1/8 of numUsers; minCohort reflects that.
977
+ //
978
+ // - PERSONA CONVERSION GAP (H8). small_business carries an organic
979
+ // conversionModifier gap vs mid_market on EVERY funnel. The placebo
980
+ // assertion pins that organic gap on the untreated Supplier
981
+ // Management funnel (measured 0.73; Alert Response cross-check
982
+ // 0.85); the treated Integration Setup ratio must sit at
983
+ // 0.65 x organic [0.73, 0.85] x multi-candidate attenuation
984
+ // [1.0, 1.12] = [0.47, 0.62].
985
+
986
+ const ID_CTE = `
987
+ us AS (SELECT * FROM read_json_auto('{{PREFIX}}-USERS*.json', sample_size=-1, union_by_name=true)),
988
+ dm AS (SELECT unnest("anonymousIds") AS device_id, distinct_id FROM us),
989
+ ev AS (SELECT coalesce(m.distinct_id::VARCHAR, e.user_id::VARCHAR, e.device_id::VARCHAR) AS uid,
990
+ e.time::TIMESTAMP AS t, e.*
991
+ FROM read_json_auto('{{PREFIX}}-EVENTS*.json', sample_size=-1, union_by_name=true) e
992
+ LEFT JOIN dm m ON e.device_id = m.device_id)`;
993
+
994
+ const PU_CTE = `
995
+ pu AS (SELECT uid, count(*) AS total,
996
+ count(*) FILTER (WHERE event = 'inventory checked') AS inv,
997
+ count(*) FILTER (WHERE event = 'purchase order created') AS po,
998
+ count(*) FILTER (WHERE event = 'stockout alert') AS so,
999
+ count(*) FILTER (WHERE event = 'integration connected') AS ic,
1000
+ count(*) FILTER (WHERE event = 'report generated' AND report_type = 'integration_summary') AS clones,
1001
+ min(t) AS first_t
1002
+ FROM ev GROUP BY 1)`;
1003
+
1004
+ const cellsOf = (rows, key) => Object.fromEntries((rows || []).map((r) => [r[key], r]));
1005
+
1006
+ export const stories = [
1007
+ {
1008
+ id: "H1-month-end-pages",
1009
+ hook: "H1",
1010
+ archetype: "temporal-inflection",
1011
+ narrative:
1012
+ `Reports on calendar days >= ${MONTH_END_DAY_THRESHOLD} get report_pages x${MONTH_END_PAGES_MULT} ` +
1013
+ "(floored). The read excludes H4's clones (report_type 'integration_summary' — stamped after " +
1014
+ "H1 runs, with uniform [5, 25] pages regardless of day). Organic pool mean ~22 pages, so the " +
1015
+ `floor costs ~1%: expected ratio ~${(MONTH_END_PAGES_MULT - 0.03).toFixed(2)}, band [2.25, 2.65]. ` +
1016
+ "The clones themselves are the placebo arm: their day-of-month ratio must sit in [0.88, 1.12].",
1017
+ assertions: [
1018
+ {
1019
+ breakdown: {
1020
+ type: "duckdb",
1021
+ sql: `WITH ${ID_CTE}
1022
+ SELECT CASE WHEN extract(day FROM t) >= ${MONTH_END_DAY_THRESHOLD} THEN 'me' ELSE 'mid' END AS bucket,
1023
+ count(*)::BIGINT AS user_count, avg(report_pages) AS pages
1024
+ FROM ev WHERE event = 'report generated' AND report_type <> 'integration_summary'
1025
+ GROUP BY 1`,
1026
+ },
1027
+ select: {
1028
+ me: { where: { bucket: "me" } },
1029
+ mid: { where: { bucket: "mid" } },
1030
+ },
1031
+ expect: { metric: "me.pages / mid.pages", op: "between", target: [2.25, 2.65] },
1032
+ minCohort: 5000,
1033
+ },
1034
+ {
1035
+ breakdown: {
1036
+ type: "duckdb",
1037
+ sql: `WITH ${ID_CTE}
1038
+ SELECT CASE WHEN extract(day FROM t) >= ${MONTH_END_DAY_THRESHOLD} THEN 'me' ELSE 'mid' END AS bucket,
1039
+ count(*)::BIGINT AS user_count, avg(report_pages) AS pages
1040
+ FROM ev WHERE event = 'report generated' AND report_type = 'integration_summary'
1041
+ GROUP BY 1`,
1042
+ },
1043
+ select: {
1044
+ me: { where: { bucket: "me" } },
1045
+ mid: { where: { bucket: "mid" } },
1046
+ },
1047
+ expect: { metric: "me.pages / mid.pages", op: "between", target: [0.88, 1.12] },
1048
+ minCohort: 4000,
1049
+ },
1050
+ ],
1051
+ },
1052
+ {
1053
+ id: "H2-rush-order-premium",
1054
+ hook: "H2",
1055
+ archetype: "cohort-prop-scale",
1056
+ narrative:
1057
+ `'purchase order created' with priority 'urgent' gets unit_cost x${RUSH_ORDER_COST_MULT}, floored. ` +
1058
+ "unit_cost is an iid pool draw (organic mean ~93), priority is an iid 3:1:1 pool, and no other " +
1059
+ "hook touches unit_cost — the urgent/standard mean ratio reads the knob within floor loss " +
1060
+ "(<1%). Band [1.42, 1.58]. 'expedited' is untreated: placebo band [0.94, 1.06].",
1061
+ assertions: [
1062
+ {
1063
+ breakdown: {
1064
+ type: "duckdb",
1065
+ sql: `WITH ${ID_CTE}
1066
+ SELECT priority, count(*)::BIGINT AS user_count, avg(unit_cost) AS cost
1067
+ FROM ev WHERE event = 'purchase order created' AND priority IN ('urgent', 'standard')
1068
+ GROUP BY 1`,
1069
+ },
1070
+ select: {
1071
+ urg: { where: { priority: "urgent" } },
1072
+ std: { where: { priority: "standard" } },
1073
+ },
1074
+ expect: { metric: "urg.cost / std.cost", op: "between", target: [1.42, 1.58] },
1075
+ minCohort: 20000,
1076
+ },
1077
+ {
1078
+ breakdown: {
1079
+ type: "duckdb",
1080
+ sql: `WITH ${ID_CTE}
1081
+ SELECT priority, count(*)::BIGINT AS user_count, avg(unit_cost) AS cost
1082
+ FROM ev WHERE event = 'purchase order created' AND priority IN ('expedited', 'standard')
1083
+ GROUP BY 1`,
1084
+ },
1085
+ select: {
1086
+ exp: { where: { priority: "expedited" } },
1087
+ std: { where: { priority: "standard" } },
1088
+ },
1089
+ expect: { metric: "exp.cost / std.cost", op: "between", target: [0.94, 1.06] },
1090
+ minCohort: 20000,
1091
+ },
1092
+ ],
1093
+ },
1094
+ {
1095
+ id: "H3-stockout-by-tier",
1096
+ hook: "H3",
1097
+ archetype: "cohort-count-scale",
1098
+ narrative:
1099
+ `Enterprise users get ${ENTERPRISE_STOCKOUT_DROP_LIKELIHOOD}% of stockout alerts removed. ` +
1100
+ "Per-user LEVELS are dominated by persona event multipliers (enterprise 5x), so the read is " +
1101
+ "the stockout-per-inventory-check ratio — both counts scale with the same multiplier, and " +
1102
+ "the supply-chain worldEvent (x3 stockouts, days 35-40) hits all tiers alike and cancels " +
1103
+ "cross-tier. Expected enterprise/small_business = 0.90 x organic composition (~0.98 measured), " +
1104
+ "band [0.83, 0.95]; mid_market placebo [0.93, 1.07].",
1105
+ assertions: [
1106
+ {
1107
+ breakdown: {
1108
+ type: "duckdb",
1109
+ sql: `WITH ${ID_CTE}, ${PU_CTE}
1110
+ SELECT u.company_tier AS tier, count(*)::BIGINT AS user_count,
1111
+ sum(p.so)::DOUBLE / sum(p.inv) AS ratio
1112
+ FROM pu p JOIN us u ON u.distinct_id::VARCHAR = p.uid
1113
+ GROUP BY 1`,
1114
+ },
1115
+ select: {
1116
+ ent: { where: { tier: "enterprise" } },
1117
+ smb: { where: { tier: "small_business" } },
1118
+ },
1119
+ expect: { metric: "ent.ratio / smb.ratio", op: "between", target: [0.83, 0.95] },
1120
+ minCohort: 1000,
1121
+ },
1122
+ {
1123
+ breakdown: {
1124
+ type: "duckdb",
1125
+ sql: `WITH ${ID_CTE}, ${PU_CTE}
1126
+ SELECT u.company_tier AS tier, count(*)::BIGINT AS user_count,
1127
+ sum(p.so)::DOUBLE / sum(p.inv) AS ratio
1128
+ FROM pu p JOIN us u ON u.distinct_id::VARCHAR = p.uid
1129
+ GROUP BY 1`,
1130
+ },
1131
+ select: {
1132
+ mid: { where: { tier: "mid_market" } },
1133
+ smb: { where: { tier: "small_business" } },
1134
+ },
1135
+ expect: { metric: "mid.ratio / smb.ratio", op: "between", target: [0.93, 1.07] },
1136
+ minCohort: 2500,
1137
+ },
1138
+ ],
1139
+ },
1140
+ {
1141
+ id: "H4-integration-reports",
1142
+ hook: "H4",
1143
+ archetype: "cohort-count-scale",
1144
+ narrative:
1145
+ `Users with >= ${INTEGRATION_THRESHOLD} 'integration connected' events get a cloned ` +
1146
+ `'report generated' per integration at ${INTEGRATION_REPORT_CLONE_LIKELIHOOD}% likelihood, ` +
1147
+ "+1-5 days after the integration, tagged report_type 'integration_summary'. H6 (the only " +
1148
+ "hook that deletes integrations) runs BEFORE H4, so output integration counts equal H4's " +
1149
+ "hook-time counts exactly — the clone cohort is defined on final counts. Clones landing " +
1150
+ "past dataset end are killed by the future-time guard: expected rate = 0.65 x (1 - ~2% " +
1151
+ "edge loss) = 0.637 (measured 0.6315 at iteration). Band [0.58, 0.70]. Users below the " +
1152
+ "threshold must have ZERO clones — leakage is structural, not statistical.",
1153
+ assertions: [
1154
+ {
1155
+ breakdown: {
1156
+ type: "duckdb",
1157
+ sql: `WITH ${ID_CTE}, ${PU_CTE}
1158
+ SELECT count(*)::BIGINT AS users, sum(p.clones)::DOUBLE / sum(p.ic) AS rate
1159
+ FROM pu p WHERE p.ic >= ${INTEGRATION_THRESHOLD}`,
1160
+ },
1161
+ assert: (rows) => {
1162
+ const r = rows?.[0];
1163
+ if (!r || Number(r.users) < 5000) {
1164
+ return { verdict: "WEAK", detail: `cohort too small: users=${r?.users ?? 0}` };
1165
+ }
1166
+ const rate = Number(r.rate);
1167
+ const detail = `clones-per-integration=${rate.toFixed(4)} (knob ${INTEGRATION_REPORT_CLONE_LIKELIHOOD}% x ~0.98 future-guard survival; n=${r.users})`;
1168
+ if (rate >= 0.58 && rate <= 0.70) return { verdict: "NAILED", detail };
1169
+ if (rate >= 0.54 && rate <= 0.74) return { verdict: "STRONG", detail };
1170
+ if (rate >= 0.30) return { verdict: "WEAK", detail };
1171
+ return { verdict: "NONE", detail };
1172
+ },
1173
+ },
1174
+ {
1175
+ breakdown: {
1176
+ type: "duckdb",
1177
+ sql: `WITH ${ID_CTE}, ${PU_CTE}
1178
+ SELECT count(*)::BIGINT AS users, avg((p.clones > 0)::INT) AS leak
1179
+ FROM pu p WHERE p.ic < ${INTEGRATION_THRESHOLD}`,
1180
+ },
1181
+ assert: (rows) => {
1182
+ const r = rows?.[0];
1183
+ if (!r || Number(r.users) < 800) {
1184
+ return { verdict: "WEAK", detail: `cohort too small: users=${r?.users ?? 0}` };
1185
+ }
1186
+ const leak = Number(r.leak);
1187
+ const detail = `share of <${INTEGRATION_THRESHOLD}-integration users with any clone=${leak.toFixed(4)} (n=${r.users})`;
1188
+ if (leak <= 0.01) return { verdict: "NAILED", detail };
1189
+ if (leak <= 0.03) return { verdict: "STRONG", detail };
1190
+ return { verdict: "NONE", detail };
1191
+ },
1192
+ },
1193
+ ],
1194
+ },
1195
+ {
1196
+ id: "H5-alert-fatigue",
1197
+ hook: "H5",
1198
+ archetype: "temporal-inflection",
1199
+ narrative:
1200
+ `Users with > ${ALERT_FATIGUE_THRESHOLD} stockout alerts get response_time_hours scaled on ` +
1201
+ `alerts from index ${ALERT_FATIGUE_START_IDX} on: x(1.5 + 1.5 x (idx-20)/n). Hook index is ` +
1202
+ "record order, read index is time order — iteration showed the alignment is exact (early-arm " +
1203
+ "placebo 1.000). Reading indexes >= 25 (margin past the boundary), aggregate multiplier ~2.0 " +
1204
+ "for the observed n distribution. Control = users with 20-30 alerts (never treated, same iid " +
1205
+ "response_time pool, mean ~25h). Bands: treated-late/control [1.75, 2.25]; treated-early " +
1206
+ "(idx <= 14, untreated) placebo [0.88, 1.12].",
1207
+ assertions: [
1208
+ {
1209
+ breakdown: {
1210
+ type: "duckdb",
1211
+ sql: `WITH ${ID_CTE},
1212
+ al AS (SELECT uid, response_time_hours AS rt,
1213
+ row_number() OVER (PARTITION BY uid ORDER BY t) - 1 AS idx,
1214
+ count(*) OVER (PARTITION BY uid) AS n
1215
+ FROM ev WHERE event = 'stockout alert')
1216
+ SELECT 'late' AS cell, count(DISTINCT uid)::BIGINT AS user_count, avg(rt) AS rt
1217
+ FROM al WHERE n > ${ALERT_FATIGUE_THRESHOLD} AND idx >= 25
1218
+ UNION ALL
1219
+ SELECT 'ctl', count(DISTINCT uid)::BIGINT, avg(rt)
1220
+ FROM al WHERE n BETWEEN 20 AND ${ALERT_FATIGUE_THRESHOLD}`,
1221
+ },
1222
+ select: {
1223
+ late: { where: { cell: "late" } },
1224
+ ctl: { where: { cell: "ctl" } },
1225
+ },
1226
+ expect: { metric: "late.rt / ctl.rt", op: "between", target: [1.75, 2.25] },
1227
+ minCohort: 500,
1228
+ },
1229
+ {
1230
+ breakdown: {
1231
+ type: "duckdb",
1232
+ sql: `WITH ${ID_CTE},
1233
+ al AS (SELECT uid, response_time_hours AS rt,
1234
+ row_number() OVER (PARTITION BY uid ORDER BY t) - 1 AS idx,
1235
+ count(*) OVER (PARTITION BY uid) AS n
1236
+ FROM ev WHERE event = 'stockout alert')
1237
+ SELECT 'early' AS cell, count(DISTINCT uid)::BIGINT AS user_count, avg(rt) AS rt
1238
+ FROM al WHERE n > ${ALERT_FATIGUE_THRESHOLD} AND idx <= 14
1239
+ UNION ALL
1240
+ SELECT 'ctl', count(DISTINCT uid)::BIGINT, avg(rt)
1241
+ FROM al WHERE n BETWEEN 20 AND ${ALERT_FATIGUE_THRESHOLD}`,
1242
+ },
1243
+ select: {
1244
+ early: { where: { cell: "early" } },
1245
+ ctl: { where: { cell: "ctl" } },
1246
+ },
1247
+ expect: { metric: "early.rt / ctl.rt", op: "between", target: [0.88, 1.12] },
1248
+ minCohort: 500,
1249
+ },
1250
+ ],
1251
+ },
1252
+ {
1253
+ id: "H6-trial-churn",
1254
+ hook: "H6",
1255
+ archetype: "retention-divergence",
1256
+ narrative:
1257
+ `Trial-tier users lose ${TRIAL_CHURN_DROP_LIKELIHOOD}% of events after day ` +
1258
+ `${TRIAL_CHURN_CUTOFF_DAYS} from their first event (v1.6 behavior change: v1.5 keyed on ` +
1259
+ "record.length < 10, which matched ~0.9% of users and never touched trials). Cross-tier " +
1260
+ "levels are incomparable (activeWindow 14d, 0.4x multiplier), so the read is a relative-day " +
1261
+ "DiD: each tier's own rate(day 8-13)/rate(day 1-6) cancels its level. Derivation: DiD = " +
1262
+ "0.5 x (organic trial ratio / organic smb ratio), organic term bounded [0.9, 1.2] from the " +
1263
+ "untreated tiers' spread (smb 0.91, mid 0.96, ent 1.06) => band [0.44, 0.62]. Placebo " +
1264
+ "mid_market/smb [0.92, 1.15].",
1265
+ assertions: [
1266
+ {
1267
+ breakdown: {
1268
+ type: "duckdb",
1269
+ sql: `WITH ${ID_CTE},
1270
+ fe AS (SELECT uid, min(t) AS f FROM ev GROUP BY 1),
1271
+ rd AS (SELECT e.uid, date_diff('day', fe.f, e.t) AS d FROM ev e JOIN fe ON fe.uid = e.uid)
1272
+ SELECT u.company_tier AS tier, count(DISTINCT r.uid)::BIGINT AS user_count,
1273
+ count(*) FILTER (WHERE d BETWEEN 8 AND 13)::DOUBLE / count(*) FILTER (WHERE d BETWEEN 1 AND 6) AS ratio
1274
+ FROM rd r JOIN us u ON u.distinct_id::VARCHAR = r.uid
1275
+ GROUP BY 1`,
1276
+ },
1277
+ select: {
1278
+ trial: { where: { tier: "trial" } },
1279
+ smb: { where: { tier: "small_business" } },
1280
+ },
1281
+ expect: { metric: "trial.ratio / smb.ratio", op: "between", target: [0.44, 0.62] },
1282
+ minCohort: 500,
1283
+ },
1284
+ {
1285
+ breakdown: {
1286
+ type: "duckdb",
1287
+ sql: `WITH ${ID_CTE},
1288
+ fe AS (SELECT uid, min(t) AS f FROM ev GROUP BY 1),
1289
+ rd AS (SELECT e.uid, date_diff('day', fe.f, e.t) AS d FROM ev e JOIN fe ON fe.uid = e.uid)
1290
+ SELECT u.company_tier AS tier, count(DISTINCT r.uid)::BIGINT AS user_count,
1291
+ count(*) FILTER (WHERE d BETWEEN 8 AND 13)::DOUBLE / count(*) FILTER (WHERE d BETWEEN 1 AND 6) AS ratio
1292
+ FROM rd r JOIN us u ON u.distinct_id::VARCHAR = r.uid
1293
+ GROUP BY 1`,
1294
+ },
1295
+ select: {
1296
+ mid: { where: { tier: "mid_market" } },
1297
+ smb: { where: { tier: "small_business" } },
1298
+ },
1299
+ expect: { metric: "mid.ratio / smb.ratio", op: "between", target: [0.92, 1.15] },
1300
+ minCohort: 2500,
1301
+ },
1302
+ ],
1303
+ },
1304
+ {
1305
+ id: "H7-enterprise-profiles",
1306
+ hook: "H7",
1307
+ archetype: "cohort-prop-scale",
1308
+ narrative:
1309
+ "The user hook overwrites warehouse_count and employee_count per tier with disjoint uniform " +
1310
+ "ranges: enterprise wh [5, 15] emp [200, 2000]; mid_market wh [2, 6] emp [20, 200]; " +
1311
+ "small_business wh [1, 3] emp [5, 80]; trial wh = 1 emp [1, 10]. Personas cover 100% of " +
1312
+ "users, so every profile is overwritten and the ranges are EXACT — min/max per tier must sit " +
1313
+ "inside the knob ranges with zero out-of-range profiles for NAILED.",
1314
+ assertions: [
1315
+ {
1316
+ breakdown: {
1317
+ type: "duckdb",
1318
+ sql: `WITH ${ID_CTE}
1319
+ SELECT company_tier AS tier, count(*)::BIGINT AS user_count,
1320
+ avg((CASE company_tier
1321
+ WHEN 'enterprise' THEN (warehouse_count BETWEEN 5 AND 15 AND employee_count BETWEEN 200 AND 2000)
1322
+ WHEN 'mid_market' THEN (warehouse_count BETWEEN 2 AND 6 AND employee_count BETWEEN 20 AND 200)
1323
+ WHEN 'small_business' THEN (warehouse_count BETWEEN 1 AND 3 AND employee_count BETWEEN 5 AND 80)
1324
+ WHEN 'trial' THEN (warehouse_count = 1 AND employee_count BETWEEN 1 AND 10)
1325
+ END)::INT) AS in_range
1326
+ FROM us GROUP BY 1`,
1327
+ },
1328
+ assert: (rows) => {
1329
+ const by = cellsOf(rows, "tier");
1330
+ const ent = by.enterprise, mid = by.mid_market, smb = by.small_business, tri = by.trial;
1331
+ if (!ent || !mid || !smb || !tri ||
1332
+ Number(ent.user_count) < 800 || Number(mid.user_count) < 2000 ||
1333
+ Number(smb.user_count) < 2000 || Number(tri.user_count) < 400) {
1334
+ return { verdict: "WEAK", detail: `cohort too small: ent=${ent?.user_count ?? 0} mid=${mid?.user_count ?? 0} smb=${smb?.user_count ?? 0} trial=${tri?.user_count ?? 0}` };
1335
+ }
1336
+ const shares = [ent, mid, smb, tri].map((c) => Number(c.in_range));
1337
+ const detail = `in-range shares ent=${shares[0].toFixed(4)} mid=${shares[1].toFixed(4)} smb=${shares[2].toFixed(4)} trial=${shares[3].toFixed(4)}`;
1338
+ if (shares.every((s) => s === 1)) return { verdict: "NAILED", detail };
1339
+ if (shares.every((s) => s >= 0.99)) return { verdict: "STRONG", detail };
1340
+ if (shares.every((s) => s >= 0.9)) return { verdict: "WEAK", detail };
1341
+ return { verdict: "NONE", detail };
1342
+ },
1343
+ },
1344
+ ],
1345
+ },
1346
+ {
1347
+ id: "H8-smb-conversion-drop",
1348
+ hook: "H8",
1349
+ archetype: "funnel-conversion-by-segment",
1350
+ narrative:
1351
+ `small_business users lose ${SMB_ALERT_DROP_LIKELIHOOD}% of 'alert configured' events — the ` +
1352
+ "last step of Integration Setup. Read through the emulator's greedy in-window pairing " +
1353
+ "(step2->step3 conditional conversion). small_business ALSO carries an organic " +
1354
+ "conversionModifier gap vs mid_market on every funnel, so the treated ratio is " +
1355
+ "0.65 x organic [0.73, 0.85] x multi-candidate attenuation [1.0, 1.12] = [0.47, 0.62] " +
1356
+ "(measured 0.505 at iteration); band [0.44, 0.62]. The untreated Supplier Management funnel " +
1357
+ "pins the organic gap itself: [0.68, 0.90] (measured 0.73; Alert Response cross-check 0.85). " +
1358
+ "The two bands are disjoint — the gap between them IS the engineered effect.",
1359
+ assertions: [
1360
+ {
1361
+ breakdown: {
1362
+ type: "timeToConvert",
1363
+ steps: ["integration connected", "report generated", "alert configured"],
1364
+ breakdownByUserProperty: "company_tier",
1365
+ conversionWindowMs: 48 * 3600 * 1000,
1366
+ },
1367
+ assert: (rows) => {
1368
+ const by = cellsOf(rows, "segment_value");
1369
+ const mid = by.mid_market, smb = by.small_business;
1370
+ const midAtt = Number(mid?.step_counts?.[1] ?? 0), smbAtt = Number(smb?.step_counts?.[1] ?? 0);
1371
+ if (midAtt < 1500 || smbAtt < 1500) {
1372
+ return { verdict: "WEAK", detail: `step-2 cohorts too small: mid=${midAtt} smb=${smbAtt}` };
1373
+ }
1374
+ const convM = Number(mid.step_counts[2]) / midAtt;
1375
+ const convS = Number(smb.step_counts[2]) / smbAtt;
1376
+ const ratio = convS / convM;
1377
+ const detail = `step2->3 conv smb=${convS.toFixed(4)} mid=${convM.toFixed(4)} ratio=${ratio.toFixed(3)} (attempts ${smbAtt}/${midAtt})`;
1378
+ if (ratio >= 0.44 && ratio <= 0.62) return { verdict: "NAILED", detail };
1379
+ if (ratio >= 0.38 && ratio <= 0.70) return { verdict: "STRONG", detail };
1380
+ if (ratio < 0.85) return { verdict: "WEAK", detail };
1381
+ return { verdict: ratio >= 1 ? "INVERSE" : "NONE", detail };
1382
+ },
1383
+ },
1384
+ {
1385
+ breakdown: {
1386
+ type: "timeToConvert",
1387
+ steps: ["supplier contacted", "purchase order created", "invoice processed"],
1388
+ breakdownByUserProperty: "company_tier",
1389
+ conversionWindowMs: 336 * 3600 * 1000,
1390
+ },
1391
+ assert: (rows) => {
1392
+ const by = cellsOf(rows, "segment_value");
1393
+ const mid = by.mid_market, smb = by.small_business;
1394
+ const midAtt = Number(mid?.step_counts?.[1] ?? 0), smbAtt = Number(smb?.step_counts?.[1] ?? 0);
1395
+ if (midAtt < 2000 || smbAtt < 2000) {
1396
+ return { verdict: "WEAK", detail: `step-2 cohorts too small: mid=${midAtt} smb=${smbAtt}` };
1397
+ }
1398
+ const convM = Number(mid.step_counts[2]) / midAtt;
1399
+ const convS = Number(smb.step_counts[2]) / smbAtt;
1400
+ const ratio = convS / convM;
1401
+ const detail = `placebo (untreated funnel) step2->3 conv smb=${convS.toFixed(4)} mid=${convM.toFixed(4)} ratio=${ratio.toFixed(3)} (attempts ${smbAtt}/${midAtt})`;
1402
+ if (ratio >= 0.68 && ratio <= 0.90) return { verdict: "NAILED", detail };
1403
+ if (ratio >= 0.60 && ratio <= 0.98) return { verdict: "STRONG", detail };
1404
+ if (ratio >= 0.50) return { verdict: "WEAK", detail };
1405
+ return { verdict: "NONE", detail };
1406
+ },
1407
+ },
1408
+ ],
1409
+ },
1410
+ {
1411
+ id: "H9-inventory-magic-number",
1412
+ hook: "H9",
1413
+ archetype: "frequency-sweet-spot",
1414
+ narrative:
1415
+ `Sweet spot ${INVENTORY_SWEET_MIN}-${INVENTORY_SWEET_MAX} inventory checks => PO quantity ` +
1416
+ `x${INVENTORY_PO_QUANTITY_BOOST}; ${INVENTORY_OVER_THRESHOLD}+ checks => ` +
1417
+ `${INVENTORY_OVER_PO_DROP_LIKELIHOOD}% of POs dropped. Output inventory-check counts equal ` +
1418
+ "hook-time counts (H9 reads them after H6, nothing later mutates them). Value read: " +
1419
+ "quantity is an iid pool draw, so sweet/low mean ratio reads the knob [1.30, 1.50]; " +
1420
+ "unit_cost placebo [0.94, 1.07]. Volume read: PO count is activity-coupled, so the read is " +
1421
+ "PO-per-inventory-check within small_business only, treated cliff bin 16-23 vs adjacent " +
1422
+ "untreated 12-15: keep_hat = 0.4 x organic gradient [0.83, 1.0] => band [0.31, 0.44] " +
1423
+ "(measured 0.372), guarded by pre-cliff flatness (8-11 vs 4-7 in [0.85, 1.10]).",
1424
+ assertions: [
1425
+ {
1426
+ breakdown: {
1427
+ type: "duckdb",
1428
+ sql: `WITH ${ID_CTE}, ${PU_CTE}
1429
+ SELECT CASE WHEN p.inv BETWEEN ${INVENTORY_SWEET_MIN} AND ${INVENTORY_SWEET_MAX} THEN 'sweet'
1430
+ WHEN p.inv <= ${INVENTORY_SWEET_MIN - 1} THEN 'low' END AS arm,
1431
+ count(DISTINCT p.uid)::BIGINT AS user_count, avg(e.quantity) AS qty
1432
+ FROM pu p JOIN ev e ON e.uid = p.uid AND e.event = 'purchase order created'
1433
+ WHERE p.inv <= ${INVENTORY_SWEET_MAX}
1434
+ GROUP BY 1`,
1435
+ },
1436
+ select: {
1437
+ sweet: { where: { arm: "sweet" } },
1438
+ low: { where: { arm: "low" } },
1439
+ },
1440
+ expect: { metric: "sweet.qty / low.qty", op: "between", target: [1.30, 1.50] },
1441
+ minCohort: 400,
1442
+ },
1443
+ {
1444
+ breakdown: {
1445
+ type: "duckdb",
1446
+ sql: `WITH ${ID_CTE}, ${PU_CTE}
1447
+ SELECT CASE WHEN p.inv BETWEEN 4 AND 7 THEN 'b04'
1448
+ WHEN p.inv BETWEEN 8 AND 11 THEN 'b08'
1449
+ WHEN p.inv BETWEEN 12 AND ${INVENTORY_SWEET_MAX} THEN 'b12'
1450
+ WHEN p.inv BETWEEN ${INVENTORY_OVER_THRESHOLD} AND 23 THEN 'b16' END AS bin,
1451
+ count(*)::BIGINT AS user_count, sum(p.po)::DOUBLE / sum(p.inv) AS ppi
1452
+ FROM pu p JOIN us u ON u.distinct_id::VARCHAR = p.uid
1453
+ WHERE u.company_tier = 'small_business' AND p.inv BETWEEN 4 AND 23
1454
+ GROUP BY 1`,
1455
+ },
1456
+ assert: (rows) => {
1457
+ const by = cellsOf(rows, "bin");
1458
+ const b04 = by.b04, b08 = by.b08, b12 = by.b12, b16 = by.b16;
1459
+ if (!b04 || !b08 || !b12 || !b16 ||
1460
+ Number(b04.user_count) < 250 || Number(b08.user_count) < 500 ||
1461
+ Number(b12.user_count) < 500 || Number(b16.user_count) < 800) {
1462
+ return { verdict: "WEAK", detail: `bins too small: ${[b04, b08, b12, b16].map((b) => b?.user_count ?? 0).join("/")}` };
1463
+ }
1464
+ const flat = Number(b08.ppi) / Number(b04.ppi);
1465
+ if (flat < 0.85 || flat > 1.10) {
1466
+ return { verdict: "NONE", detail: `pre-cliff gradient assumption broken: b08/b04=${flat.toFixed(3)} outside [0.85, 1.10]` };
1467
+ }
1468
+ const keep = Number(b16.ppi) / Number(b12.ppi);
1469
+ const detail = `keep_hat=${keep.toFixed(3)} (b16-23 ppi ${Number(b16.ppi).toFixed(3)} / b12-15 ppi ${Number(b12.ppi).toFixed(3)}; knob 0.4 x organic gradient [0.83, 1.0]; pre-cliff flat=${flat.toFixed(3)})`;
1470
+ if (keep >= 0.31 && keep <= 0.44) return { verdict: "NAILED", detail };
1471
+ if (keep >= 0.26 && keep <= 0.50) return { verdict: "STRONG", detail };
1472
+ if (keep < 0.70) return { verdict: "WEAK", detail };
1473
+ return { verdict: keep >= 1 ? "INVERSE" : "NONE", detail };
1474
+ },
1475
+ },
1476
+ {
1477
+ breakdown: {
1478
+ type: "duckdb",
1479
+ sql: `WITH ${ID_CTE}, ${PU_CTE}
1480
+ SELECT CASE WHEN p.inv BETWEEN ${INVENTORY_SWEET_MIN} AND ${INVENTORY_SWEET_MAX} THEN 'sweet'
1481
+ WHEN p.inv <= ${INVENTORY_SWEET_MIN - 1} THEN 'low' END AS arm,
1482
+ count(DISTINCT p.uid)::BIGINT AS user_count, avg(e.unit_cost) AS cost
1483
+ FROM pu p JOIN ev e ON e.uid = p.uid AND e.event = 'purchase order created'
1484
+ WHERE p.inv <= ${INVENTORY_SWEET_MAX}
1485
+ GROUP BY 1`,
1486
+ },
1487
+ select: {
1488
+ sweet: { where: { arm: "sweet" } },
1489
+ low: { where: { arm: "low" } },
1490
+ },
1491
+ expect: { metric: "sweet.cost / low.cost", op: "between", target: [0.94, 1.07] },
1492
+ minCohort: 400,
1493
+ },
1494
+ ],
1495
+ },
1496
+ {
1497
+ id: "H10-onboarding-ttc",
1498
+ hook: "H10",
1499
+ archetype: "funnel-ttc-by-segment",
1500
+ narrative:
1501
+ `funnel-post scales Onboarding inter-step gaps: enterprise x${TTC_ENTERPRISE_FACTOR}, ` +
1502
+ `small_business/trial x${TTC_SMB_FACTOR}, mid_market untouched (v1.6 scopes the hook to ` +
1503
+ "Onboarding only). Cross-event SQL cannot see this (greedy single-pass pairing — the " +
1504
+ "documented v1.5 limitation), so both reads use the emulator's timeToConvert at " +
1505
+ `72h x ${TTC_SMB_FACTOR} = 93.6h — the generative window times the max stretch, so ` +
1506
+ "slow-arm conversions are not right-censored into a fake speedup. Only born-in-dataset " +
1507
+ "users (~12%) have 'account created' in-window, so converter cohorts are ~1/8 scale: " +
1508
+ "minCohort 120. Window censoring and converter selection compress ratios toward 1 " +
1509
+ "(iteration: ent/mid 0.62, smb/mid 1.18): bands [0.50, 0.80] and [1.04, 1.40].",
1510
+ assertions: [
1511
+ {
1512
+ breakdown: {
1513
+ type: "timeToConvert",
1514
+ steps: ["account created", "inventory checked", "integration connected", "report generated"],
1515
+ breakdownByUserProperty: "company_tier",
1516
+ conversionWindowMs: Math.round(72 * TTC_SMB_FACTOR * 3600 * 1000),
1517
+ },
1518
+ select: {
1519
+ ent: { where: { segment_value: "enterprise" } },
1520
+ mid: { where: { segment_value: "mid_market" } },
1521
+ },
1522
+ expect: { metric: "ent.median_ttc_ms / mid.median_ttc_ms", op: "between", target: [0.50, 0.80] },
1523
+ minCohort: 120,
1524
+ },
1525
+ {
1526
+ breakdown: {
1527
+ type: "timeToConvert",
1528
+ steps: ["account created", "inventory checked", "integration connected", "report generated"],
1529
+ breakdownByUserProperty: "company_tier",
1530
+ conversionWindowMs: Math.round(72 * TTC_SMB_FACTOR * 3600 * 1000),
1531
+ },
1532
+ select: {
1533
+ smb: { where: { segment_value: "small_business" } },
1534
+ mid: { where: { segment_value: "mid_market" } },
1535
+ },
1536
+ expect: { metric: "smb.median_ttc_ms / mid.median_ttc_ms", op: "between", target: [1.04, 1.40] },
1537
+ minCohort: 120,
1538
+ },
1539
+ ],
1540
+ },
1541
+ ];