@ak--47/dungeon-master 1.5.3 → 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 +96 -0
  4. package/.claude/skills/create-project/provision.mjs +336 -0
  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 +211 -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 +3 -2
  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,1438 @@
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: TradeNest
13
+ * APP: Two-sided marketplace connecting sellers who list products with
14
+ * buyers who search, purchase, and review. Sellers list items;
15
+ * buyers browse, message, negotiate, and buy. Revenue from
16
+ * marketplace fees and listing fees.
17
+ * SCALE: 10,000 users, ~1.4M events, 121 days (2026-01-01 → 2026-05-01)
18
+ * CORE LOOP: search → view → add to cart → purchase → review
19
+ * (seller side: create listing → receive offers → accept → ship)
20
+ *
21
+ * EVENTS (18):
22
+ * item searched (8) > app session (8) > item viewed (7) > notification received (6)
23
+ * > add to cart (5) > message sent (5) > listing created (4) > purchase completed (3)
24
+ * > listing updated (3) > offer received (3) > shipping updated (3) > offer accepted (2)
25
+ * > review submitted (2) > seller rated (2) > profile updated (2) > account created (1)
26
+ * > refund requested (1) > account deactivated (1)
27
+ *
28
+ * FUNNELS (5):
29
+ * - Buyer Onboarding: account created → item searched → item viewed → add to cart (40%)
30
+ * - Browse to Purchase: item searched → item viewed → add to cart → purchase completed (30%)
31
+ * - Seller Listing Flow: listing created → listing updated → offer received → offer accepted (25%)
32
+ * - Offer Negotiation: offer received → message sent → offer accepted (35%)
33
+ * - Review After Purchase: purchase completed → shipping updated → review submitted (20%)
34
+ *
35
+ * USER PROPS: user_type, segment, seller_rating, total_transactions, response_time_hours,
36
+ * store_name, Platform, category
37
+ * SUPER PROPS: Platform, category
38
+ * SCD PROPS: seller_tier (new/verified/power/featured, monthly fuzzy, max 8)
39
+ * GROUPS: none
40
+ */
41
+
42
+ // ── HOOK STORIES ──
43
+ /*
44
+ * NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable via
45
+ * raw-prop breakdowns (segment, day, category) or behavioral cohorts.
46
+ *
47
+ * ───────────────────────────────────────────────────────────────
48
+ * 1. FEE CHANGE IMPACT (everything hook)
49
+ * ───────────────────────────────────────────────────────────────
50
+ *
51
+ * PATTERN: After day 45 (permanent marketplace fee increase), all
52
+ * "listing created" events get listing_fee multiplied by 1.3x.
53
+ * Simulates the revenue impact of a platform fee adjustment. No flag —
54
+ * discover via line chart of avg listing_fee over time.
55
+ *
56
+ * HOW TO FIND IT IN MIXPANEL:
57
+ *
58
+ * Report 1: Listing Fee Over Time
59
+ * • Report type: Insights
60
+ * • Event: "listing created"
61
+ * • Measure: Average of "listing_fee"
62
+ * • Line chart by week
63
+ * • Expected: Clear step-up around day 45 from ~$14 avg to ~$17.5
64
+ * avg (x1.26 — the floor() on the x1.3 multiply shaves ~4%)
65
+ *
66
+ * REAL-WORLD ANALOGUE: Marketplace platforms periodically adjust
67
+ * commission/listing fees, impacting seller economics and behavior.
68
+ *
69
+ * ───────────────────────────────────────────────────────────────
70
+ * 2. WEEKEND SHOPPING SURGE (event hook)
71
+ * ───────────────────────────────────────────────────────────────
72
+ *
73
+ * PATTERN: "purchase completed" events on Saturday or Sunday get
74
+ * total_amount multiplied by 1.2x. Weekend shoppers spend more
75
+ * per transaction due to leisure browsing.
76
+ *
77
+ * HOW TO FIND IT IN MIXPANEL:
78
+ *
79
+ * Report 1: Weekend vs Weekday Average Spend
80
+ * • Report type: Insights
81
+ * • Event: "purchase completed"
82
+ * • Measure: Average of "total_amount"
83
+ * • Breakdown: Day of week
84
+ * • Expected: Sat/Sun avg ≈ $248 vs Mon-Fri avg ≈ $206 (x1.2)
85
+ *
86
+ * REAL-WORLD ANALOGUE: E-commerce platforms see higher average
87
+ * order values on weekends when buyers browse leisurely.
88
+ *
89
+ * ───────────────────────────────────────────────────────────────
90
+ * 3. SELLER SUCCESS → BUYER TRUST (everything hook)
91
+ * ───────────────────────────────────────────────────────────────
92
+ *
93
+ * PATTERN: Users with segment "power_seller" (from meta.profile) get
94
+ * each "purchase completed" event cloned with 65% likelihood — an
95
+ * expected x1.65 on purchase counts. Power sellers attract more
96
+ * transactions due to trust/reputation.
97
+ *
98
+ * HOW TO FIND IT IN MIXPANEL:
99
+ *
100
+ * Report 1: Purchases per User by Segment
101
+ * • Report type: Insights
102
+ * • Event: "purchase completed"
103
+ * • Measure: Total per user
104
+ * • Breakdown: user property "segment"
105
+ * • Expected: power_seller ≈ 6x purchases vs casual_seller — the
106
+ * x1.65 clone mechanism stacks on the persona activity gap
107
+ * (eventMultiplier 5.0 vs 1.5). The clone lift alone is isolated
108
+ * by normalizing: purchases ÷ add-to-carts per segment
109
+ * (Insights formula A/B) ≈ 1.65-1.7x power vs casual.
110
+ *
111
+ * REAL-WORLD ANALOGUE: High-rated sellers with established reputations
112
+ * receive disproportionately more sales on platforms like eBay/Etsy.
113
+ *
114
+ * ───────────────────────────────────────────────────────────────
115
+ * 4. SEARCH-TO-PURCHASE BY CATEGORY (everything hook)
116
+ * ───────────────────────────────────────────────────────────────
117
+ *
118
+ * PATTERN: Users whose events include "item searched" with category
119
+ * "electronics" get cloned "purchase completed" events (electronics
120
+ * buyers have higher conversion). Creates category-specific lift.
121
+ *
122
+ * HOW TO FIND IT IN MIXPANEL:
123
+ *
124
+ * Report 1: Purchase Volume by Category
125
+ * • Report type: Insights
126
+ * • Event: "purchase completed"
127
+ * • Measure: Total
128
+ * • Breakdown: "category" (superProp)
129
+ * • Expected: electronics ≈ 1.5-2x more purchases than clothing/home.
130
+ * Raw counts also carry the engine's seeded favored-index skew on
131
+ * category population shares — the clean verification read is
132
+ * purchases per user: electronics-category users ≈ 1.2x others
133
+ * (up to ~1.2 expected clone purchases on a ~9.5/user baseline).
134
+ *
135
+ * REAL-WORLD ANALOGUE: Electronics shoppers have higher purchase
136
+ * intent — they research, decide, and buy with less browsing.
137
+ *
138
+ * ───────────────────────────────────────────────────────────────
139
+ * 5. RESPONSE TIME → CONVERSION (everything hook)
140
+ * ───────────────────────────────────────────────────────────────
141
+ *
142
+ * PATTERN: A deterministic ~40% hash cohort of message-senders are
143
+ * "fast responders": their "message sent" events get
144
+ * response_time_hours 0.5-4 (avg ~2.25h), existing "offer accepted"
145
+ * events are cloned 2x each (x3 total), and 60% of "offer received"
146
+ * events spawn an extra accept. The other ~60% are slow responders:
147
+ * response_time_hours 8-36 (avg ~22h) and 60% of their accepts are
148
+ * dropped. Net mechanism: fast = 3A + 0.6R, slow = 0.4A (A = organic
149
+ * accepts, R = offers received) ≈ 10x accepts per user.
150
+ *
151
+ * HOW TO FIND IT IN MIXPANEL:
152
+ *
153
+ * Report 1: Offer Accepted Count vs Response Time
154
+ * • Report type: Insights
155
+ * • Event: "offer accepted"
156
+ * • Measure: Total per user
157
+ * • Breakdown: event property "response_time_hours" (bucketed <4h vs >8h)
158
+ * • Expected: fast responders ≈ 10x offer accepts per user
159
+ *
160
+ * REAL-WORLD ANALOGUE: Sellers who respond quickly to inquiries
161
+ * close significantly more deals on marketplace platforms.
162
+ *
163
+ * ───────────────────────────────────────────────────────────────
164
+ * 6. NEW SELLER CHURN (everything hook)
165
+ * ───────────────────────────────────────────────────────────────
166
+ *
167
+ * PATTERN: Users with segment "new_seller" lose 50% of events after
168
+ * their first-event + 14 days. Stacks on the persona's activeWindow
169
+ * (maxDays: 28), which already truncates their lifetime — the hook
170
+ * halves what remains. Simulates new sellers who try the platform
171
+ * and abandon it quickly.
172
+ *
173
+ * HOW TO FIND IT IN MIXPANEL:
174
+ *
175
+ * Report 1: New Seller Retention
176
+ * • Report type: Insights
177
+ * • Event: All events
178
+ * • Measure: Total per user
179
+ * • Filter: segment = "new_seller"
180
+ * • Line chart by week
181
+ * • Expected: Sharp drop-off around week 2-3
182
+ *
183
+ * Report 2: Post/Pre Event Ratio by Segment
184
+ * • Per-user events after (first-event + 14d) ÷ events before
185
+ * • Expected: new_seller ratio ≈ half of everyone else's
186
+ * (~0.5 relative — activeWindow and the drop combined)
187
+ *
188
+ * REAL-WORLD ANALOGUE: Most new sellers on marketplace platforms
189
+ * churn within the first month if they don't get early traction.
190
+ *
191
+ * ───────────────────────────────────────────────────────────────
192
+ * 7. POWER SELLER PROFILES (user hook)
193
+ * ───────────────────────────────────────────────────────────────
194
+ *
195
+ * PATTERN: Users with segment "power_seller" get total_transactions
196
+ * set to 100-500, seller_rating to 4.5-5.0, and store_name to a
197
+ * realistic business name. Enriches profiles for realistic cohort analysis.
198
+ *
199
+ * HOW TO FIND IT IN MIXPANEL:
200
+ *
201
+ * Report 1: Transaction Count Distribution by Segment
202
+ * • Report type: Insights
203
+ * • Measure: Profiles → Breakdown by "total_transactions" (histogram)
204
+ * • Filter: segment = "power_seller"
205
+ * • Expected: power_seller total_transactions 100-500, others ≈ 0
206
+ *
207
+ * Report 2: Seller Rating by Segment
208
+ * • Report type: Insights
209
+ * • Measure: Profiles → Average of "seller_rating"
210
+ * • Breakdown: "segment"
211
+ * • Expected: power_seller ≈ 4.7 avg, casual_seller ≈ 3.5
212
+ *
213
+ * REAL-WORLD ANALOGUE: Top sellers have established storefronts,
214
+ * high transaction volumes, and near-perfect ratings.
215
+ *
216
+ * ───────────────────────────────────────────────────────────────
217
+ * 8. FREQUENT BUYER FUNNEL LIFT (everything hook)
218
+ * ───────────────────────────────────────────────────────────────
219
+ *
220
+ * PATTERN: Non-frequent-buyer users lose ~25% of "purchase completed"
221
+ * events (last step of Browse to Purchase funnel), simulating lower
222
+ * conversion. Frequent buyers retain all purchase events — an
223
+ * effective 4/3 purchase-count edge over everyone else.
224
+ *
225
+ * HOW TO FIND IT IN MIXPANEL:
226
+ *
227
+ * Report 1: Purchases per Add-to-Cart by Segment
228
+ * • Report type: Insights (formula A/B)
229
+ * • A: "purchase completed" Total; B: "add to cart" Total
230
+ * • Breakdown: user property "segment"
231
+ * • Expected: frequent_buyer ≈ 1.33x casual_seller (the 4/3
232
+ * retention edge; casual_seller shares frequent_buyer's
233
+ * conversionModifier-neutral baseline). Unique-user funnel
234
+ * conversion is near ceiling for both — the count-normalized
235
+ * read is the visible one.
236
+ *
237
+ * REAL-WORLD ANALOGUE: Returning buyers have established trust
238
+ * and familiarity with the platform, converting at higher rates.
239
+ *
240
+ * ───────────────────────────────────────────────────────────────
241
+ * 9. BROWSE TO PURCHASE TIME-TO-CONVERT (funnel-post)
242
+ * ───────────────────────────────────────────────────────────────
243
+ *
244
+ * PATTERN: Power_seller and frequent_buyer users complete the
245
+ * Browse to Purchase funnel 2.5x faster (factor 0.4); window_shopper
246
+ * 1.4x slower (factor 1.4). Applies ONLY to Browse-to-Purchase
247
+ * instances (1.6 fix — scaling all five funnels diluted the read:
248
+ * Buyer Onboarding emits the same search→view→cart prefix, so the
249
+ * greedy evaluator assembled chains across unscaled instances and the
250
+ * measured ratio collapsed to ~0.87/1.13).
251
+ *
252
+ * HOW TO FIND IT IN MIXPANEL:
253
+ *
254
+ * Report 1: Browse to Purchase Median Time-to-Convert by Segment
255
+ * - Funnels > "item searched" -> "item viewed" -> "add to cart" -> "purchase completed"
256
+ * - Measure: Median time to convert, conversion window 2 days (48h —
257
+ * matches the funnel's timeToConvert; longer windows admit slow
258
+ * cross-instance chains: power's p75 blows from ~27h to ~47h at 72h)
259
+ * - Breakdown: segment
260
+ * - Expected: frequent_buyer/power_seller medians ~22-23h vs
261
+ * casual_seller ~36h (ratio ~0.61); window_shopper ~40h (~1.12x
262
+ * casual — right-censoring at the window trims its slow tail)
263
+ *
264
+ * NOTE (funnel-post measurement): visible only via Mixpanel funnel
265
+ * median TTC. Cross-event MIN→MIN SQL queries on raw events do NOT
266
+ * show this — funnel-post adjusts gaps within funnel instances, not
267
+ * across the user's full event history. The verify stories use
268
+ * emulateBreakdown's timeToConvert (same greedy ARB semantics as
269
+ * Mixpanel) — measured ratios sit above the raw 0.4 factor because
270
+ * chains can still borrow steps from adjacent organic instances.
271
+ *
272
+ * ───────────────────────────────────────────────────────────────
273
+ * 10. MESSAGE-COUNT MAGIC NUMBER (in-funnel, everything)
274
+ * ───────────────────────────────────────────────────────────────
275
+ *
276
+ * PATTERN: Cohort by TOTAL "message sent" count per user. Sweet spot
277
+ * (2-5 messages) → offer_amount x1.35 on all "offer received" events.
278
+ * Over-messagers (6+) → offer_amount x0.85 (haggling deadlock — buyers
279
+ * lowball sellers who drag out negotiation). No flag.
280
+ *
281
+ * Both legs are property effects on an iid base (offer_amount is drawn
282
+ * independently per event), so the cohort ratios are clean at any scale.
283
+ * A purchase-DROP leg was removed in 1.6: message count correlates with
284
+ * activity, so raw per-user purchase reads stay positive at any drop
285
+ * rate — the story was structurally undiscoverable.
286
+ *
287
+ * HOW TO FIND IT IN MIXPANEL:
288
+ *
289
+ * Report 1: Avg Offer Amount by Message Bucket
290
+ * - Cohort A: users who did "message sent" 2-5 times
291
+ * - Cohort B: users with 0-1 messages
292
+ * - Cohort C: users with >= 6 messages
293
+ * - Event: "offer received"
294
+ * - Measure: Average of "offer_amount"
295
+ * - Expected: A ~ 1.35x B; C ~ 0.85x B; A ~ 1.59x C
296
+ *
297
+ * REAL-WORLD ANALOGUE: A few quick clarifying messages close deals at
298
+ * better prices; extended haggling signals a lowball buyer.
299
+ *
300
+ * ═══════════════════════════════════════════════════════════════
301
+ * EXPECTED METRICS SUMMARY (mechanism → measured @2K reduced run)
302
+ * ═══════════════════════════════════════════════════════════════
303
+ *
304
+ * Hook | Report read | Mechanism | Measured
305
+ * -----|----------------------------------------------|-----------|---------
306
+ * H1 | avg listing_fee post/pre day 45 | x1.26 | 1.259
307
+ * H2 | avg total_amount Sat-Sun vs Mon-Fri | x1.20 | 1.208
308
+ * H3 | purchases-per-cart RoR, power/casual | x1.65 | 1.670
309
+ * H3 | raw purchases per user, power/casual | ~6x | 5.98
310
+ * H4 | purchases/user, elec-searchers vs rest | ~x1.2 | 1.221
311
+ * H5 | offer accepts/user, fast vs slow responders | ~10x | 10.35
312
+ * H5 | avg response_time_hours, slow/fast | ~9.8x | 9.99
313
+ * H6 | post/pre +14d event ratio, new_seller/rest | ~0.5x | 0.504
314
+ * H7 | power_seller tx (100-500) / rating (4.5-5.0) | exact | 299 / 4.75
315
+ * H8 | purchases-per-cart RoR, frequent/casual | x1.33 | 1.335
316
+ * H9 | B2P median TTC @48h win, frequent/casual | <1 | 0.61
317
+ * H9 | B2P median TTC @48h win, window/casual | >1 | 1.12
318
+ * H10 | avg offer_amount, sweet(2-5 msgs)/low(0-1) | x1.35 | 1.302
319
+ * H10 | avg offer_amount, over(6+)/low | x0.85 | 0.834
320
+ *
321
+ * MEASUREMENT CAVEATS
322
+ * - Activity confounds: raw per-user counts mix hook effects with persona
323
+ * eventMultiplier gaps (H3's raw ~6x = the x1.65 clone lift stacked on
324
+ * the 5.0-vs-1.5 activity gap). Normalized reads (per-cart RoR, per-user
325
+ * ratios) are the clean verification signals.
326
+ * - Favored-index skew: property sampling favors a seeded index per run,
327
+ * so raw category population shares are confounded (automotive ran hot
328
+ * at 2K). H4's clean read is per-user purchases, not raw counts.
329
+ * - Dead config keys: `anomalies`, `attribution`, and `geo` are 1.4
330
+ * KILLED_CONFIG_KEYS — the validator strips them with a warning
331
+ * (lib/core/config-validator.js). No whale purchases (max total_amount
332
+ * ~$490), no day-68 signup spike, no UTM stamping (hasCampaigns is
333
+ * false anyway). Blocks kept for cross-dungeon config consistency.
334
+ * - floor() shaving: H1's Math.floor turns x1.3 into ~x1.26 measured;
335
+ * H2's floor costs <1% on x1.2.
336
+ * - Clone property draws: H3/H4 clones draw fresh total_amount
337
+ * (chance.integer), diluting avg-amount reads; count reads unaffected.
338
+ * - H9: emulated funnel medians sit above the raw 0.4 gap factor because
339
+ * greedy chains still borrow steps from adjacent organic instances;
340
+ * the measured ratios (0.61/1.12) are the Mixpanel-visible numbers.
341
+ */
342
+
343
+ // ── SCALE ──
344
+ const SEED = "dm4-marketplace";
345
+ const NUM_USERS = 10_000;
346
+ const DATASET_START = "2026-01-01T00:00:00Z";
347
+ const DATASET_END = "2026-05-01T23:59:59Z";
348
+ const EVENTS_PER_DAY = 1.2;
349
+ const token = process.env.MP_TOKEN || "your-mixpanel-token";
350
+
351
+ const chance = u.initChance(SEED);
352
+
353
+ // ── KNOBS (tweak these to reshape stories) ──
354
+ const FEE_CHANGE_DAY = 45;
355
+ const FEE_CHANGE_MULT = 1.3;
356
+
357
+ const WEEKEND_SPEND_MULT = 1.2;
358
+
359
+ const POWER_SELLER_CLONE_LIKELIHOOD = 65;
360
+
361
+ const ELECTRONICS_CLONE_LIKELIHOOD = 40;
362
+ const ELECTRONICS_MAX_CLONES = 3;
363
+
364
+ const FAST_RESPONDER_HASH_MOD = 5;
365
+ const FAST_RESPONDER_HASH_THRESHOLD = 2;
366
+ const FAST_OFFER_CLONE_COUNT = 2;
367
+ const FAST_OFFER_FROM_RECEIVED_LIKELIHOOD = 60;
368
+ const SLOW_OFFER_DROP_LIKELIHOOD = 60;
369
+
370
+ const NEW_SELLER_CUTOFF_DAYS = 14;
371
+ const NEW_SELLER_DROP_LIKELIHOOD = 50;
372
+
373
+ const FREQUENT_BUYER_DROP_LIKELIHOOD = 25;
374
+
375
+ const TTC_FAST_FACTOR = 0.4;
376
+ const TTC_SLOW_FACTOR = 1.4;
377
+
378
+ const MSG_SWEET_MIN = 2;
379
+ const MSG_SWEET_MAX = 5;
380
+ const MSG_OVER_THRESHOLD = 6;
381
+ const MSG_OFFER_BOOST = 1.35;
382
+ const MSG_OVER_PENALTY = 0.85;
383
+
384
+ // ── DATA ARRAYS ──
385
+ // Generate consistent seller store and listing IDs at module level
386
+ const storeIds = v.range(1, 150).map(() => `STORE_${v.uid(6)}`);
387
+ const listingIds = v.range(1, 500).map(() => `LST_${v.uid(8)}`);
388
+
389
+ // ── HELPER FUNCTIONS ──
390
+ function handleUserHooks(record) {
391
+ // H7: Power seller profiles — enrich segment-tagged profiles
392
+ if (record.segment === "power_seller") {
393
+ record.total_transactions = chance.integer({ min: 100, max: 500 });
394
+ record.seller_rating = chance.floating({ min: 4.5, max: 5.0, fixed: 1 });
395
+ record.store_name = chance.pickone([
396
+ "TechVault Pro", "StyleHaven", "GearFactory", "PrimeFinds",
397
+ "TopShelf Goods", "EliteDeals", "QualityFirst Store", "BestBuy Resale",
398
+ ]);
399
+ } else if (record.segment === "casual_seller") {
400
+ record.total_transactions = chance.integer({ min: 5, max: 50 });
401
+ record.seller_rating = chance.floating({ min: 3.0, max: 4.5, fixed: 1 });
402
+ record.store_name = chance.pickone([
403
+ "My Garage Sale", "Closet Cleanout", "Random Finds", "Weekend Seller",
404
+ ]);
405
+ } else if (record.segment === "new_seller") {
406
+ record.total_transactions = chance.integer({ min: 0, max: 3 });
407
+ record.seller_rating = 0;
408
+ record.store_name = "New Store";
409
+ }
410
+ return record;
411
+ }
412
+
413
+ function handleFunnelPostHooks(record, meta) {
414
+ // H9: Browse-to-Purchase TTC scaled by segment — THIS funnel only.
415
+ // Scaling all five funnels (pre-1.6 behavior) diluted the greedy funnel
416
+ // read: Buyer Onboarding emits the same search→view→cart prefix, so the
417
+ // evaluator assembles chains across unscaled instances and the measured
418
+ // median ratio collapsed to ~0.87/1.13 (vs 0.71/1.4 engineered).
419
+ if (meta?.funnel?.name !== "Browse to Purchase") return record;
420
+ const segment = meta?.profile?.segment;
421
+ if (Array.isArray(record) && record.length > 1) {
422
+ const factor = (
423
+ segment === "power_seller" || segment === "frequent_buyer" ? TTC_FAST_FACTOR :
424
+ segment === "window_shopper" ? TTC_SLOW_FACTOR :
425
+ 1.0
426
+ );
427
+ if (factor !== 1.0) {
428
+ for (let i = 1; i < record.length; i++) {
429
+ const prev = dayjs(record[i - 1].time);
430
+ const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
431
+ record[i].time = prev.add(newGap, "milliseconds").toISOString();
432
+ }
433
+ }
434
+ }
435
+ return record;
436
+ }
437
+
438
+ function handleEverythingHooks(record, meta) {
439
+ const datasetStart = dayjs.unix(meta.datasetStart);
440
+ const FEE_CHANGE_CUTOFF = datasetStart.add(FEE_CHANGE_DAY, "days");
441
+ let events = record;
442
+ if (!events.length) return record;
443
+
444
+ const profile = meta.profile;
445
+
446
+ // Stamp superProps from profile so they are consistent per user.
447
+ if (profile) {
448
+ events.forEach(e => {
449
+ if (profile.Platform) e.Platform = profile.Platform;
450
+ if (profile.category) e.category = profile.category;
451
+ });
452
+ }
453
+
454
+ // H1: Fee change impact — listings after d45 get listing_fee 1.3x.
455
+ // In everything hook so timestamp comparison sees post-bunchIntoSessions times.
456
+ events.forEach(e => {
457
+ if (e.event === "listing created" && dayjs(e.time).isAfter(FEE_CHANGE_CUTOFF)) {
458
+ e.listing_fee = Math.floor((e.listing_fee || 15) * FEE_CHANGE_MULT);
459
+ }
460
+ });
461
+
462
+ // H8: Frequent buyer conversion filter — non-frequent-buyer users
463
+ // drop ~25% of "purchase completed" events.
464
+ if (profile && profile.segment !== "frequent_buyer") {
465
+ record = record.filter(e => {
466
+ if (e.event === "purchase completed" && chance.bool({ likelihood: FREQUENT_BUYER_DROP_LIKELIHOOD })) return false;
467
+ return true;
468
+ });
469
+ events = record;
470
+ }
471
+
472
+ // H3: Seller success → buyer trust — power sellers get 2x purchase events cloned.
473
+ if (profile && profile.segment === "power_seller") {
474
+ const purchases = events.filter(e => e.event === "purchase completed");
475
+ const templatePurchase = purchases[0];
476
+ if (templatePurchase && purchases.length > 0) {
477
+ purchases.forEach(p => {
478
+ if (chance.bool({ likelihood: POWER_SELLER_CLONE_LIKELIHOOD })) {
479
+ events.push({
480
+ ...templatePurchase,
481
+ time: dayjs(p.time).add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
482
+ user_id: p.user_id,
483
+ total_amount: chance.integer({ min: 15, max: 400 }),
484
+ item_count: chance.integer({ min: 1, max: 4 }),
485
+ });
486
+ }
487
+ });
488
+ }
489
+ }
490
+
491
+ // H4: Search-to-purchase by category — electronics gets cloned purchase events.
492
+ const hasElectronicsSearch = events.some(e =>
493
+ e.event === "item searched" && e.category === "electronics"
494
+ );
495
+ if (hasElectronicsSearch) {
496
+ const templatePurchase = events.find(e => e.event === "purchase completed");
497
+ if (templatePurchase) {
498
+ const electronicsSearches = events.filter(e =>
499
+ e.event === "item searched" && e.category === "electronics"
500
+ );
501
+ electronicsSearches.slice(0, ELECTRONICS_MAX_CLONES).forEach(search => {
502
+ if (chance.bool({ likelihood: ELECTRONICS_CLONE_LIKELIHOOD })) {
503
+ events.push({
504
+ ...templatePurchase,
505
+ time: dayjs(search.time).add(chance.integer({ min: 1, max: 12 }), "hours").toISOString(),
506
+ user_id: search.user_id,
507
+ total_amount: chance.integer({ min: 50, max: 300 }),
508
+ category: "electronics",
509
+ });
510
+ }
511
+ });
512
+ }
513
+ }
514
+
515
+ // H5: Response time → conversion — deterministic fast/slow cohorts via user hash.
516
+ // Fast (~40%): set response_time_hours 1-4, clone offer_accepted.
517
+ // Slow (~60%): set 8-36, drop 60% of offer_accepted events.
518
+ const msgEvents = events.filter(e => e.event === "message sent");
519
+ if (msgEvents.length > 0) {
520
+ const uid = msgEvents[0].user_id || "";
521
+ const isFast = (uid.charCodeAt(0) + uid.charCodeAt(uid.length - 1)) % FAST_RESPONDER_HASH_MOD < FAST_RESPONDER_HASH_THRESHOLD;
522
+ msgEvents.forEach(m => {
523
+ m.response_time_hours = isFast
524
+ ? chance.floating({ min: 0.5, max: 4, fixed: 1 })
525
+ : chance.floating({ min: 8, max: 36, fixed: 1 });
526
+ });
527
+ const templateOffer = events.find(e => e.event === "offer accepted");
528
+ if (isFast && templateOffer) {
529
+ // Fast responders: clone existing offer_accepted 2x each
530
+ const existingAccepts = events.filter(e => e.event === "offer accepted");
531
+ existingAccepts.forEach(accept => {
532
+ for (let c = 0; c < FAST_OFFER_CLONE_COUNT; c++) {
533
+ events.push({
534
+ ...accept,
535
+ time: dayjs(accept.time).add(chance.integer({ min: 1, max: 8 }), "hours").toISOString(),
536
+ user_id: accept.user_id,
537
+ response_time_hours: chance.floating({ min: 0.1, max: 2, fixed: 1 }),
538
+ });
539
+ }
540
+ });
541
+ // Also clone from offer_received → offer_accepted
542
+ const offers = events.filter(e => e.event === "offer received");
543
+ offers.forEach(offer => {
544
+ if (chance.bool({ likelihood: FAST_OFFER_FROM_RECEIVED_LIKELIHOOD })) {
545
+ events.push({
546
+ ...templateOffer,
547
+ time: dayjs(offer.time).add(chance.integer({ min: 1, max: 6 }), "hours").toISOString(),
548
+ user_id: offer.user_id,
549
+ response_time_hours: chance.floating({ min: 0.1, max: 3, fixed: 1 }),
550
+ });
551
+ }
552
+ });
553
+ } else if (!isFast) {
554
+ // Slow responders: drop 60% of offer_accepted events
555
+ for (let i = events.length - 1; i >= 0; i--) {
556
+ if (events[i].event === "offer accepted" && chance.bool({ likelihood: SLOW_OFFER_DROP_LIKELIHOOD })) {
557
+ events.splice(i, 1);
558
+ }
559
+ }
560
+ }
561
+ }
562
+
563
+ // H6: New seller churn — drop 50% of events after user's first 14 days.
564
+ if (profile && profile.segment === "new_seller") {
565
+ const firstEventTime = events.length > 0 ? dayjs(events[0].time) : null;
566
+ if (firstEventTime) {
567
+ const userCutoff = firstEventTime.add(NEW_SELLER_CUTOFF_DAYS, "days");
568
+ for (let i = events.length - 1; i >= 0; i--) {
569
+ if (dayjs(events[i].time).isAfter(userCutoff) && chance.bool({ likelihood: NEW_SELLER_DROP_LIKELIHOOD })) {
570
+ events.splice(i, 1);
571
+ }
572
+ }
573
+ }
574
+ }
575
+
576
+ // H2: Weekend shopping surge — Sat/Sun purchases get total_amount 1.2x.
577
+ events.forEach(e => {
578
+ if (e.event === "purchase completed") {
579
+ const dow = new Date(e.time).getUTCDay();
580
+ if (dow === 0 || dow === 6) {
581
+ e.total_amount = Math.floor((e.total_amount || 60) * WEEKEND_SPEND_MULT);
582
+ }
583
+ }
584
+ });
585
+
586
+ // H10: Message-count magic number (no flags). Cohort = TOTAL "message
587
+ // sent" count per user — H10 runs last and nothing after it drops
588
+ // messages, so the output-side count reproduces the cohort exactly
589
+ // (the pre-1.6 between-first-view-and-first-offer window yielded a
590
+ // near-empty over-cohort: ~2 users per 2K).
591
+ // Sweet 2-5 → offer_amount x1.35 on all "offer received" events.
592
+ // Over 6+ → offer_amount x0.85 (haggling deadlock; buyers lowball).
593
+ // Property-only by design: a purchase-drop leg is structurally
594
+ // unverifiable on this cohort — message count correlates with activity,
595
+ // so raw per-user purchase reads stay positive at any drop rate, and
596
+ // the pooled purchases-per-cart read lands at drop x segment-composition
597
+ // (== 1.0 at the old 25%). offer_amount is iid per event, so cohort
598
+ // ratios are clean at any scale. Recount fresh here: H6 may have
599
+ // spliced messages out after H5 captured msgEvents.
600
+ const msgCount = events.reduce((n, e) => n + (e.event === "message sent" ? 1 : 0), 0);
601
+ if (msgCount >= MSG_SWEET_MIN && msgCount <= MSG_SWEET_MAX) {
602
+ events.forEach(e => {
603
+ if (e.event === "offer received" && typeof e.offer_amount === "number") {
604
+ e.offer_amount = Math.round(e.offer_amount * MSG_OFFER_BOOST);
605
+ }
606
+ });
607
+ } else if (msgCount >= MSG_OVER_THRESHOLD) {
608
+ events.forEach(e => {
609
+ if (e.event === "offer received" && typeof e.offer_amount === "number") {
610
+ e.offer_amount = Math.round(e.offer_amount * MSG_OVER_PENALTY);
611
+ }
612
+ });
613
+ }
614
+
615
+ return record;
616
+ }
617
+
618
+ // ── CONFIG ──
619
+ /** @type {Config} */
620
+ const config = {
621
+ version: 2,
622
+ seed: SEED,
623
+ datasetStart: DATASET_START,
624
+ datasetEnd: DATASET_END,
625
+ avgEventsPerUserPerDay: EVENTS_PER_DAY,
626
+ numUsers: NUM_USERS,
627
+ format: "json",
628
+ gzip: true,
629
+ credentials: {
630
+ token,
631
+ },
632
+ switches: {
633
+ hasSessionIds: true,
634
+ alsoInferFunnels: false,
635
+ hasLocation: true,
636
+ hasAndroidDevices: true,
637
+ hasIOSDevices: true,
638
+ hasDesktopDevices: true,
639
+ hasBrowser: false,
640
+ hasCampaigns: false,
641
+ isAnonymous: false,
642
+ hasAdSpend: false,
643
+ hasAvatar: true,
644
+ },
645
+ identity: {
646
+ avgDevicePerUser: 2,
647
+ },
648
+ concurrency: 1,
649
+ writeToDisk: false,
650
+ scdProps: {
651
+ seller_tier: {
652
+ values: ["new", "verified", "power", "featured"],
653
+ frequency: "month",
654
+ timing: "fuzzy",
655
+ max: 8
656
+ }
657
+ },
658
+ mirrorProps: {},
659
+ lookupTables: [],
660
+
661
+ events: [
662
+ {
663
+ event: "account created",
664
+ weight: 1,
665
+ isFirstEvent: true,
666
+ isAuthEvent: true,
667
+ properties: {
668
+ signup_source: ["organic", "google_shopping", "tiktok", "seller_referral", "email_campaign", "word_of_mouth"],
669
+ },
670
+ },
671
+ {
672
+ event: "item searched",
673
+ weight: 8,
674
+ isStrictEvent: false,
675
+ properties: {
676
+ search_query: ["laptop", "vintage jacket", "sneakers", "headphones", "phone case", "gaming console", "watch", "sunglasses", "backpack", "camera"],
677
+ results_count: u.weighNumRange(0, 50, 0.5),
678
+ sort_by: ["relevance", "relevance", "price_low", "price_high", "newest", "best_selling"],
679
+ },
680
+ },
681
+ {
682
+ event: "item viewed",
683
+ weight: 7,
684
+ isStrictEvent: false,
685
+ properties: {
686
+ listing_id: chance.pickone.bind(chance, listingIds),
687
+ item_price: u.weighNumRange(5, 500, 0.3, 45),
688
+ condition: ["new", "new", "like_new", "good", "fair"],
689
+ seller_rating: u.weighNumRange(1, 5, 0.7, 4),
690
+ photos_count: u.weighNumRange(1, 10, 0.5, 3),
691
+ },
692
+ },
693
+ {
694
+ event: "add to cart",
695
+ weight: 5,
696
+ properties: {
697
+ listing_id: chance.pickone.bind(chance, listingIds),
698
+ item_price: u.weighNumRange(5, 500, 0.3, 45),
699
+ quantity: u.weighNumRange(1, 5, 0.2),
700
+ },
701
+ },
702
+ {
703
+ event: "purchase completed",
704
+ weight: 3,
705
+ isStrictEvent: false,
706
+ properties: {
707
+ listing_id: chance.pickone.bind(chance, listingIds),
708
+ total_amount: u.weighNumRange(10, 500, 0.3, 60),
709
+ item_count: u.weighNumRange(1, 5, 0.3),
710
+ payment_method: ["credit_card", "credit_card", "paypal", "apple_pay", "debit"],
711
+ shipping_method: ["standard", "standard", "express", "pickup"],
712
+ },
713
+ },
714
+ {
715
+ event: "listing created",
716
+ weight: 4,
717
+ isStrictEvent: false,
718
+ properties: {
719
+ listing_id: chance.pickone.bind(chance, listingIds),
720
+ store_id: chance.pickone.bind(chance, storeIds),
721
+ asking_price: u.weighNumRange(5, 500, 0.3, 50),
722
+ condition: ["new", "new", "like_new", "good", "fair"],
723
+ listing_fee: u.weighNumRange(5, 30, 0.5, 15),
724
+ },
725
+ },
726
+ {
727
+ event: "listing updated",
728
+ weight: 3,
729
+ properties: {
730
+ listing_id: chance.pickone.bind(chance, listingIds),
731
+ field_updated: ["price", "price", "description", "photos", "condition", "shipping"],
732
+ price_change_pct: u.weighNumRange(-30, 30, 0.5),
733
+ },
734
+ },
735
+ {
736
+ event: "offer received",
737
+ weight: 3,
738
+ isStrictEvent: false,
739
+ properties: {
740
+ listing_id: chance.pickone.bind(chance, listingIds),
741
+ offer_amount: u.weighNumRange(5, 400, 0.3, 40),
742
+ offer_pct_of_asking: u.weighNumRange(50, 100, 0.5, 80),
743
+ },
744
+ },
745
+ {
746
+ event: "offer accepted",
747
+ weight: 2,
748
+ isStrictEvent: false,
749
+ properties: {
750
+ listing_id: chance.pickone.bind(chance, listingIds),
751
+ final_price: u.weighNumRange(10, 500, 0.3, 55),
752
+ negotiation_rounds: u.weighNumRange(1, 5, 0.3),
753
+ response_time_hours: u.weighNumRange(0.1, 48, 0.3, 4),
754
+ },
755
+ },
756
+ {
757
+ event: "review submitted",
758
+ weight: 2,
759
+ properties: {
760
+ listing_id: chance.pickone.bind(chance, listingIds),
761
+ rating: u.weighNumRange(1, 5, 0.7, 4),
762
+ review_length: u.weighNumRange(10, 300, 0.5, 80),
763
+ is_verified_purchase: [true, true, true, false],
764
+ },
765
+ },
766
+ {
767
+ event: "seller rated",
768
+ weight: 2,
769
+ properties: {
770
+ store_id: chance.pickone.bind(chance, storeIds),
771
+ seller_rating: u.weighNumRange(1, 5, 0.7, 4),
772
+ would_buy_again: [true, true, true, true, false],
773
+ },
774
+ },
775
+ {
776
+ event: "message sent",
777
+ weight: 5,
778
+ isStrictEvent: false,
779
+ properties: {
780
+ message_type: ["inquiry", "inquiry", "negotiation", "shipping_question", "complaint", "thank_you"],
781
+ recipient_type: ["seller", "seller", "buyer", "support"],
782
+ response_time_hours: u.weighNumRange(0.1, 48, 0.3, 6),
783
+ },
784
+ },
785
+ {
786
+ event: "shipping updated",
787
+ weight: 3,
788
+ properties: {
789
+ status: ["label_created", "in_transit", "in_transit", "out_for_delivery", "delivered", "delivered"],
790
+ carrier: ["usps", "ups", "fedex", "dhl"],
791
+ tracking_viewed: [true, true, false],
792
+ },
793
+ },
794
+ {
795
+ event: "refund requested",
796
+ weight: 1,
797
+ properties: {
798
+ reason: ["not_as_described", "damaged", "wrong_item", "changed_mind", "late_delivery"],
799
+ refund_amount: u.weighNumRange(5, 300, 0.4, 40),
800
+ resolution_status: ["pending", "approved", "approved", "denied"],
801
+ },
802
+ },
803
+ {
804
+ event: "notification received",
805
+ weight: 6,
806
+ properties: {
807
+ notification_type: ["price_drop", "price_drop", "new_offer", "shipping_update", "message_received", "sale_reminder"],
808
+ channel: ["push", "push", "email", "sms"],
809
+ opened: [true, true, true, false],
810
+ },
811
+ },
812
+ {
813
+ event: "profile updated",
814
+ weight: 2,
815
+ properties: {
816
+ field_updated: ["address", "payment_method", "profile_photo", "bio", "phone", "store_description"],
817
+ },
818
+ },
819
+ {
820
+ event: "app session",
821
+ weight: 8,
822
+ properties: {
823
+ session_duration_sec: u.weighNumRange(10, 1800, 0.4, 120),
824
+ pages_viewed: u.weighNumRange(1, 20, 0.5, 4),
825
+ },
826
+ },
827
+ {
828
+ event: "account deactivated",
829
+ weight: 1,
830
+ isChurnEvent: true,
831
+ returnLikelihood: 0.15,
832
+ isStrictEvent: true,
833
+ properties: {
834
+ reason: ["low_sales", "high_fees", "found_alternative", "bad_experience", "no_longer_needed"],
835
+ },
836
+ },
837
+ ],
838
+
839
+ funnels: [
840
+ {
841
+ name: "Buyer Onboarding",
842
+ sequence: ["account created", "item searched", "item viewed", "add to cart"],
843
+ conversionRate: 40,
844
+ order: "sequential",
845
+ isFirstFunnel: true,
846
+ timeToConvert: 72,
847
+ weight: 3,
848
+ },
849
+ {
850
+ name: "Browse to Purchase",
851
+ sequence: ["item searched", "item viewed", "add to cart", "purchase completed"],
852
+ conversionRate: 30,
853
+ order: "sequential",
854
+ timeToConvert: 48,
855
+ weight: 5,
856
+ },
857
+ {
858
+ name: "Seller Listing Flow",
859
+ sequence: ["listing created", "listing updated", "offer received", "offer accepted"],
860
+ conversionRate: 25,
861
+ order: "sequential",
862
+ timeToConvert: 168,
863
+ weight: 3,
864
+ },
865
+ {
866
+ name: "Offer Negotiation",
867
+ sequence: ["offer received", "message sent", "offer accepted"],
868
+ conversionRate: 35,
869
+ order: "sequential",
870
+ timeToConvert: 72,
871
+ weight: 2,
872
+ },
873
+ {
874
+ name: "Review After Purchase",
875
+ sequence: ["purchase completed", "shipping updated", "review submitted"],
876
+ conversionRate: 20,
877
+ order: "sequential",
878
+ timeToConvert: 336,
879
+ weight: 2,
880
+ },
881
+ ],
882
+
883
+ superProps: {
884
+ Platform: ["ios", "android", "web"],
885
+ category: ["electronics", "clothing", "home_garden", "collectibles", "sports", "toys", "automotive"],
886
+ },
887
+
888
+ userProps: {
889
+ user_type: ["buyer"],
890
+ segment: ["window_shopper"],
891
+ seller_rating: u.weighNumRange(0, 5, 0.5),
892
+ total_transactions: [0],
893
+ response_time_hours: u.weighNumRange(0, 48, 0.3),
894
+ store_name: ["none"],
895
+ Platform: ["ios", "android", "web"],
896
+ category: ["electronics", "clothing", "home_garden", "collectibles", "sports", "toys", "automotive"],
897
+ },
898
+
899
+ personas: [
900
+ {
901
+ name: "power_seller",
902
+ weight: 8,
903
+ eventMultiplier: 5.0,
904
+ conversionModifier: 1.8,
905
+ churnRate: 0.01,
906
+ properties: {
907
+ user_type: "seller",
908
+ segment: "power_seller",
909
+ },
910
+ },
911
+ {
912
+ name: "casual_seller",
913
+ weight: 20,
914
+ eventMultiplier: 1.5,
915
+ conversionModifier: 1.0,
916
+ churnRate: 0.08,
917
+ properties: {
918
+ user_type: "seller",
919
+ segment: "casual_seller",
920
+ },
921
+ },
922
+ {
923
+ name: "frequent_buyer",
924
+ weight: 25,
925
+ eventMultiplier: 2.0,
926
+ conversionModifier: 1.3,
927
+ churnRate: 0.03,
928
+ properties: {
929
+ user_type: "buyer",
930
+ segment: "frequent_buyer",
931
+ },
932
+ },
933
+ {
934
+ name: "window_shopper",
935
+ weight: 35,
936
+ eventMultiplier: 0.5,
937
+ conversionModifier: 0.4,
938
+ churnRate: 0.15,
939
+ properties: {
940
+ user_type: "buyer",
941
+ segment: "window_shopper",
942
+ },
943
+ },
944
+ {
945
+ name: "new_seller",
946
+ weight: 12,
947
+ eventMultiplier: 0.8,
948
+ conversionModifier: 0.5,
949
+ churnRate: 0.3,
950
+ properties: {
951
+ user_type: "seller",
952
+ segment: "new_seller",
953
+ },
954
+ activeWindow: { maxDays: 28 },
955
+ },
956
+ ],
957
+
958
+ worldEvents: [
959
+ {
960
+ name: "marketplace_fee_change",
961
+ type: "product_launch",
962
+ startDay: 45,
963
+ duration: null,
964
+ injectProps: { fee_change: "increased" },
965
+ affectsEvents: ["listing created"],
966
+ },
967
+ {
968
+ name: "viral_tiktok_moment",
969
+ type: "campaign",
970
+ startDay: 55,
971
+ duration: 3,
972
+ volumeMultiplier: 3.0,
973
+ affectsEvents: ["item searched", "item viewed", "purchase completed"],
974
+ },
975
+ ],
976
+
977
+ engagementDecay: {
978
+ model: "exponential",
979
+ halfLife: 75,
980
+ floor: 0.1,
981
+ },
982
+
983
+ hook(record, type, meta) {
984
+ if (type === "user") return handleUserHooks(record);
985
+ if (type === "funnel-post") return handleFunnelPostHooks(record, meta);
986
+ if (type === "everything") return handleEverythingHooks(record, meta);
987
+ return record;
988
+ },
989
+ };
990
+
991
+ export default config;
992
+
993
+ // ── STORY VERIFICATION (v1.6) ──
994
+ /*
995
+ * Machine-checkable stories for the 10 numbered hooks. Evaluate with
996
+ * node scripts/verify-stories.mjs dungeons/vertical/marketplace/marketplace.js --data-prefix verify-marketplace
997
+ * or the thin wrapper marketplace.verify.mjs.
998
+ *
999
+ * Derivation notes:
1000
+ * - Bands are centered on the 2K reduced-run measurements (iter-mkt-2)
1001
+ * with the hook mechanism as the sanity anchor; scale guards are sized
1002
+ * at ~60-70% of expected full-fidelity (10K) cell counts so reduced
1003
+ * runs read WEAK (guarded) instead of false-failing.
1004
+ * - H1 uses the hook's exact cutoff (time > 2026-02-15T00:00:00Z), not
1005
+ * date_diff day bucketing — day-45 events straddle the boundary.
1006
+ * - H3/H8 read purchases-per-add-to-cart ratio-of-ratios: normalizing by
1007
+ * carts cancels the persona activity gap; the residual is the
1008
+ * engineered clone lift (H3 x1.65) or retention edge (H8 4/3).
1009
+ * - H5's cohort split replicates the hook's charCode hash in SQL:
1010
+ * (ascii(first char) + ascii(last char)) % 5 < 2.
1011
+ * - H9 asserts through emulateBreakdown's timeToConvert at a 48h window
1012
+ * (matches the funnel's timeToConvert). Cross-event SQL cannot see
1013
+ * funnel-post gap scaling. Window choice: at 48h each segment's TTC
1014
+ * distribution is unimodal enough (frequent_buyer's fast mode holds
1015
+ * 69% mass, median well inside); at 72h+ cross-instance chains
1016
+ * contaminate the fast tail (power_seller p75 blows ~27h → ~47h).
1017
+ * - H10's cohort is total message-sent count — exactly reproducible from
1018
+ * the output because H10 runs last and nothing after it drops
1019
+ * messages. Read is per-user avg offer_amount, then averaged across
1020
+ * the cohort (matches the band-derivation measurement; offer_amount is
1021
+ * iid per event so pooled and per-user weightings converge).
1022
+ */
1023
+
1024
+ const EV = `read_json_auto('{{PREFIX}}-EVENTS*.json', sample_size=-1, union_by_name=true)`;
1025
+ const US = `read_json_auto('{{PREFIX}}-USERS*.json', sample_size=-1, union_by_name=true)`;
1026
+
1027
+ const bandVerdict = (x, nailed, strong, detail, inverse = () => false) => {
1028
+ if (x == null || Number.isNaN(Number(x))) return { verdict: "NONE", detail: `${detail} — metric missing` };
1029
+ const v = Number(x);
1030
+ if (inverse(v)) return { verdict: "INVERSE", detail };
1031
+ if (v >= nailed[0] && v <= nailed[1]) return { verdict: "NAILED", detail };
1032
+ if (v >= strong[0] && v <= strong[1]) return { verdict: "STRONG", detail };
1033
+ return { verdict: "WEAK", detail };
1034
+ };
1035
+
1036
+ const guarded = (ok, detail, inner) => ok ? inner() : { verdict: "WEAK", detail: `${detail} — cohort below scale guard (expected at reduced scale)` };
1037
+
1038
+ const worstOf = (...verdicts) => {
1039
+ const order = ["INVERSE", "NONE", "WEAK", "STRONG", "NAILED"];
1040
+ return order.find(o => verdicts.some(v => v.verdict === o)) || "NONE";
1041
+ };
1042
+
1043
+ export const stories = [
1044
+ {
1045
+ id: "marketplace-h1-fee-change",
1046
+ hook: "H1",
1047
+ archetype: "temporal-inflection",
1048
+ narrative: "Listings after day 45 get listing_fee x1.3 (Math.floor shaves it to ~x1.26). Post/pre avg listing_fee steps from ~$14 to ~$17.5.",
1049
+ assertions: [
1050
+ {
1051
+ breakdown: {
1052
+ type: "duckdb",
1053
+ sql: `SELECT
1054
+ avg(listing_fee) FILTER (WHERE time::TIMESTAMP <= TIMESTAMP '2026-02-15 00:00:00') AS pre_avg,
1055
+ avg(listing_fee) FILTER (WHERE time::TIMESTAMP > TIMESTAMP '2026-02-15 00:00:00') AS post_avg,
1056
+ count(*) FILTER (WHERE time::TIMESTAMP <= TIMESTAMP '2026-02-15 00:00:00') AS pre_n,
1057
+ count(*) FILTER (WHERE time::TIMESTAMP > TIMESTAMP '2026-02-15 00:00:00') AS post_n
1058
+ FROM ${EV}
1059
+ WHERE event = 'listing created' AND listing_fee IS NOT NULL`,
1060
+ },
1061
+ assert: (rows) => {
1062
+ const r = rows[0] || {};
1063
+ const ratio = Number(r.post_avg) / Number(r.pre_avg);
1064
+ const detail = `post/pre avg listing_fee ${Number(r.post_avg).toFixed(2)}/${Number(r.pre_avg).toFixed(2)} = ${ratio.toFixed(3)}x (mechanism x1.26 after floor; pre_n=${r.pre_n} post_n=${r.post_n})`;
1065
+ return guarded(Number(r.pre_n) >= 25000 && Number(r.post_n) >= 28000, detail,
1066
+ () => bandVerdict(ratio, [1.21, 1.31], [1.14, 1.40], detail, v => v <= 1.02));
1067
+ },
1068
+ },
1069
+ ],
1070
+ },
1071
+ {
1072
+ id: "marketplace-h2-weekend-surge",
1073
+ hook: "H2",
1074
+ archetype: "bespoke",
1075
+ narrative: "Sat/Sun purchases get total_amount x1.2 (floor costs <1%). Weekend/weekday avg total_amount ~1.21x.",
1076
+ assertions: [
1077
+ {
1078
+ breakdown: {
1079
+ type: "duckdb",
1080
+ sql: `SELECT
1081
+ avg(total_amount) FILTER (WHERE dayofweek(time::TIMESTAMP) IN (0, 6)) AS wkn,
1082
+ avg(total_amount) FILTER (WHERE dayofweek(time::TIMESTAMP) NOT IN (0, 6)) AS wkd,
1083
+ count(*) FILTER (WHERE dayofweek(time::TIMESTAMP) IN (0, 6)) AS n_wkn
1084
+ FROM ${EV}
1085
+ WHERE event = 'purchase completed' AND total_amount IS NOT NULL`,
1086
+ },
1087
+ assert: (rows) => {
1088
+ const r = rows[0] || {};
1089
+ const ratio = Number(r.wkn) / Number(r.wkd);
1090
+ const detail = `weekend ${Number(r.wkn).toFixed(1)} vs weekday ${Number(r.wkd).toFixed(1)} avg total_amount = ${ratio.toFixed(3)}x (mechanism 1.2; n_wkn=${r.n_wkn})`;
1091
+ return guarded(Number(r.n_wkn) >= 14000, detail,
1092
+ () => bandVerdict(ratio, [1.15, 1.26], [1.08, 1.33], detail, v => v <= 1.00));
1093
+ },
1094
+ },
1095
+ ],
1096
+ },
1097
+ {
1098
+ id: "marketplace-h3-seller-trust",
1099
+ hook: "H3",
1100
+ archetype: "cohort-count-scale",
1101
+ narrative: "Power sellers get purchases cloned at 65% likelihood (x1.65 counts). Clean read: purchases-per-cart RoR vs casual_seller (cancels the 5.0-vs-1.5 activity gap). Raw per-user ratio ~6x is the Mixpanel headline.",
1102
+ assertions: [
1103
+ {
1104
+ breakdown: {
1105
+ type: "duckdb",
1106
+ sql: `WITH pu AS (
1107
+ SELECT e.user_id::VARCHAR AS uid, any_value(u.segment) AS segment,
1108
+ count(*) FILTER (WHERE e.event = 'purchase completed') AS purch,
1109
+ count(*) FILTER (WHERE e.event = 'add to cart') AS carts
1110
+ FROM ${EV} e
1111
+ JOIN ${US} u ON e.user_id::VARCHAR = u.distinct_id::VARCHAR
1112
+ GROUP BY 1
1113
+ )
1114
+ SELECT segment, count(*) AS users,
1115
+ sum(purch)::DOUBLE / nullif(sum(carts), 0) AS purch_per_cart
1116
+ FROM pu WHERE segment IN ('power_seller', 'casual_seller') GROUP BY segment`,
1117
+ },
1118
+ assert: (rows) => {
1119
+ const pow = rows.find(r => r.segment === "power_seller");
1120
+ const cas = rows.find(r => r.segment === "casual_seller");
1121
+ if (!pow || !cas) return { verdict: "NONE", detail: "power/casual segments missing" };
1122
+ const ror = Number(pow.purch_per_cart) / Number(cas.purch_per_cart);
1123
+ const detail = `purch-per-cart RoR power/casual = ${ror.toFixed(3)} (mechanism 1.65; power n=${pow.users} casual n=${cas.users})`;
1124
+ return guarded(Number(pow.users) >= 550 && Number(cas.users) >= 1450, detail,
1125
+ () => bandVerdict(ror, [1.53, 1.82], [1.35, 2.00], detail, v => v <= 1.10));
1126
+ },
1127
+ },
1128
+ {
1129
+ breakdown: {
1130
+ type: "duckdb",
1131
+ sql: `WITH pu AS (
1132
+ SELECT e.user_id::VARCHAR AS uid, any_value(u.segment) AS segment,
1133
+ count(*) FILTER (WHERE e.event = 'purchase completed') AS purch
1134
+ FROM ${EV} e
1135
+ JOIN ${US} u ON e.user_id::VARCHAR = u.distinct_id::VARCHAR
1136
+ GROUP BY 1
1137
+ )
1138
+ SELECT segment, count(*) AS users, avg(purch) AS purch_pu
1139
+ FROM pu WHERE segment IN ('power_seller', 'casual_seller') GROUP BY segment`,
1140
+ },
1141
+ assert: (rows) => {
1142
+ const pow = rows.find(r => r.segment === "power_seller");
1143
+ const cas = rows.find(r => r.segment === "casual_seller");
1144
+ if (!pow || !cas) return { verdict: "NONE", detail: "power/casual segments missing" };
1145
+ const ratio = Number(pow.purch_pu) / Number(cas.purch_pu);
1146
+ const detail = `raw purchases/user power ${Number(pow.purch_pu).toFixed(2)} vs casual ${Number(cas.purch_pu).toFixed(2)} = ${ratio.toFixed(2)}x (clone x activity ~6x)`;
1147
+ return guarded(Number(pow.users) >= 550 && Number(cas.users) >= 1450, detail,
1148
+ () => bandVerdict(ratio, [5.0, 7.0], [3.8, 8.5], detail, v => v <= 1.5));
1149
+ },
1150
+ },
1151
+ ],
1152
+ },
1153
+ {
1154
+ id: "marketplace-h4-electronics-lift",
1155
+ hook: "H4",
1156
+ archetype: "cohort-count-scale",
1157
+ narrative: "Users who searched electronics get up to 3 cloned purchases at 40% each (~1.2 expected on a ~9.5/user baseline). Per-user read cancels the favored-index category skew.",
1158
+ assertions: [
1159
+ {
1160
+ breakdown: {
1161
+ type: "duckdb",
1162
+ sql: `WITH pu AS (
1163
+ SELECT user_id::VARCHAR AS uid,
1164
+ bool_or(event = 'item searched' AND category = 'electronics') AS elec,
1165
+ count(*) FILTER (WHERE event = 'purchase completed') AS purch
1166
+ FROM ${EV} GROUP BY 1
1167
+ )
1168
+ SELECT elec, count(*) AS users, avg(purch) AS purch_pu
1169
+ FROM pu GROUP BY elec`,
1170
+ },
1171
+ assert: (rows) => {
1172
+ const el = rows.find(r => r.elec === true || r.elec === "true");
1173
+ const rest = rows.find(r => r.elec === false || r.elec === "false");
1174
+ if (!el || !rest) return { verdict: "NONE", detail: "electronics/rest buckets missing" };
1175
+ const ratio = Number(el.purch_pu) / Number(rest.purch_pu);
1176
+ const detail = `elec-searchers ${Number(el.purch_pu).toFixed(2)} vs rest ${Number(rest.purch_pu).toFixed(2)} purchases/user = ${ratio.toFixed(3)}x (mechanism ~1.2; elec n=${el.users})`;
1177
+ return guarded(Number(el.users) >= 1200 && Number(rest.users) >= 5500, detail,
1178
+ () => bandVerdict(ratio, [1.13, 1.32], [1.05, 1.45], detail, v => v <= 0.98));
1179
+ },
1180
+ },
1181
+ ],
1182
+ },
1183
+ {
1184
+ id: "marketplace-h5-response-time",
1185
+ hook: "H5",
1186
+ archetype: "cohort-count-scale",
1187
+ narrative: "Deterministic ~40% hash cohort of message-senders: response_time_hours 0.5-4 + accepts tripled + 60% of offers spawn an accept. Slow cohort: 8-36h + 60% of accepts dropped. Net ~10x accepts/user; avg rt 2.25h vs 22h.",
1188
+ assertions: [
1189
+ {
1190
+ breakdown: {
1191
+ type: "duckdb",
1192
+ sql: `WITH pu AS (
1193
+ SELECT user_id::VARCHAR AS uid,
1194
+ (ascii(substr(user_id::VARCHAR, 1, 1)) + ascii(substr(user_id::VARCHAR, -1, 1))) % 5 < 2 AS is_fast,
1195
+ bool_or(event = 'message sent') AS has_msg,
1196
+ count(*) FILTER (WHERE event = 'offer accepted') AS accepts
1197
+ FROM ${EV} GROUP BY 1
1198
+ )
1199
+ SELECT is_fast, count(*) AS users, avg(accepts) AS accepts_pu
1200
+ FROM pu WHERE has_msg GROUP BY is_fast`,
1201
+ },
1202
+ assert: (rows) => {
1203
+ const fast = rows.find(r => r.is_fast === true || r.is_fast === "true");
1204
+ const slow = rows.find(r => r.is_fast === false || r.is_fast === "false");
1205
+ if (!fast || !slow) return { verdict: "NONE", detail: "fast/slow buckets missing" };
1206
+ const ratio = Number(fast.accepts_pu) / Number(slow.accepts_pu);
1207
+ const detail = `fast ${Number(fast.accepts_pu).toFixed(2)} vs slow ${Number(slow.accepts_pu).toFixed(2)} accepts/user = ${ratio.toFixed(2)}x (mechanism ~10x; fast n=${fast.users} slow n=${slow.users})`;
1208
+ return guarded(Number(fast.users) >= 2300 && Number(slow.users) >= 3500, detail,
1209
+ () => bandVerdict(ratio, [8.5, 12.5], [6.5, 15.0], detail, v => v <= 1.5));
1210
+ },
1211
+ },
1212
+ {
1213
+ breakdown: {
1214
+ type: "duckdb",
1215
+ sql: `SELECT
1216
+ (ascii(substr(user_id::VARCHAR, 1, 1)) + ascii(substr(user_id::VARCHAR, -1, 1))) % 5 < 2 AS is_fast,
1217
+ avg(response_time_hours) AS avg_rt, count(*) AS n
1218
+ FROM ${EV}
1219
+ WHERE event = 'message sent' AND response_time_hours IS NOT NULL
1220
+ GROUP BY 1`,
1221
+ },
1222
+ assert: (rows) => {
1223
+ const fast = rows.find(r => r.is_fast === true || r.is_fast === "true");
1224
+ const slow = rows.find(r => r.is_fast === false || r.is_fast === "false");
1225
+ if (!fast || !slow) return { verdict: "NONE", detail: "fast/slow rt buckets missing" };
1226
+ const ratio = Number(slow.avg_rt) / Number(fast.avg_rt);
1227
+ const detail = `avg rt slow ${Number(slow.avg_rt).toFixed(2)}h vs fast ${Number(fast.avg_rt).toFixed(2)}h = ${ratio.toFixed(2)}x (mechanism 22/2.25 = 9.8; n=${Number(fast.n) + Number(slow.n)})`;
1228
+ return guarded(Number(fast.n) >= 12000 && Number(slow.n) >= 18000, detail, () => {
1229
+ const vRatio = bandVerdict(ratio, [8.5, 11.5], [7.0, 13.0], detail, v => v <= 1.5);
1230
+ const vFast = bandVerdict(fast.avg_rt, [2.0, 2.5], [1.8, 2.8], detail, v => v > 8);
1231
+ const vSlow = bandVerdict(slow.avg_rt, [21, 23.5], [19, 25], detail, v => v < 4);
1232
+ return { verdict: worstOf(vRatio, vFast, vSlow), detail };
1233
+ });
1234
+ },
1235
+ },
1236
+ ],
1237
+ },
1238
+ {
1239
+ id: "marketplace-h6-new-seller-churn",
1240
+ hook: "H6",
1241
+ archetype: "retention-divergence",
1242
+ narrative: "new_seller users lose 50% of events after first-event+14d, stacked on the persona's 28-day activeWindow. Per-user post/pre ratio lands at ~0.5x everyone else's.",
1243
+ assertions: [
1244
+ {
1245
+ breakdown: {
1246
+ type: "duckdb",
1247
+ sql: `WITH pu AS (
1248
+ SELECT e.user_id::VARCHAR AS uid, any_value(u.segment) AS segment, min(e.time::TIMESTAMP) AS t0
1249
+ FROM ${EV} e
1250
+ JOIN ${US} u ON e.user_id::VARCHAR = u.distinct_id::VARCHAR
1251
+ GROUP BY 1
1252
+ ), flags AS (
1253
+ SELECT p.uid, p.segment,
1254
+ count(*) FILTER (WHERE e.time::TIMESTAMP <= p.t0 + INTERVAL 14 DAY) AS pre,
1255
+ count(*) FILTER (WHERE e.time::TIMESTAMP > p.t0 + INTERVAL 14 DAY) AS post
1256
+ FROM ${EV} e JOIN pu p ON e.user_id::VARCHAR = p.uid
1257
+ GROUP BY 1, 2
1258
+ )
1259
+ SELECT (segment = 'new_seller') AS is_new, count(*) AS users,
1260
+ avg(post::DOUBLE / nullif(pre, 0)) AS post_pre
1261
+ FROM flags WHERE pre > 0 GROUP BY 1`,
1262
+ },
1263
+ assert: (rows) => {
1264
+ const ns = rows.find(r => r.is_new === true || r.is_new === "true");
1265
+ const rest = rows.find(r => r.is_new === false || r.is_new === "false");
1266
+ if (!ns || !rest) return { verdict: "NONE", detail: "new_seller/rest buckets missing" };
1267
+ const ratio = Number(ns.post_pre) / Number(rest.post_pre);
1268
+ const detail = `new_seller post/pre ${Number(ns.post_pre).toFixed(3)} vs rest ${Number(rest.post_pre).toFixed(3)} = ${ratio.toFixed(3)}x (mechanism ~0.5; new n=${ns.users})`;
1269
+ return guarded(Number(ns.users) >= 850 && Number(rest.users) >= 5800, detail,
1270
+ () => bandVerdict(ratio, [0.42, 0.58], [0.34, 0.70], detail, v => v >= 0.95));
1271
+ },
1272
+ },
1273
+ ],
1274
+ },
1275
+ {
1276
+ id: "marketplace-h7-power-profiles",
1277
+ hook: "H7",
1278
+ archetype: "cohort-prop-scale",
1279
+ narrative: "user-hook enrichment: power_seller tx 100-500 (mean 300) + rating 4.5-5.0; casual_seller tx 5-50 + rating 3.0-4.5; new_seller tx 0-3 + rating exactly 0; buyers keep the [0] default tx.",
1280
+ assertions: [
1281
+ {
1282
+ breakdown: {
1283
+ type: "duckdb",
1284
+ sql: `SELECT segment, count(*) AS users,
1285
+ avg(total_transactions) AS avg_tx,
1286
+ avg(seller_rating) AS avg_rating
1287
+ FROM ${US} GROUP BY segment`,
1288
+ },
1289
+ assert: (rows) => {
1290
+ const seg = Object.fromEntries(rows.map(r => [r.segment, r]));
1291
+ const pow = seg.power_seller, cas = seg.casual_seller, ns = seg.new_seller;
1292
+ if (!pow || !cas || !ns) return { verdict: "NONE", detail: "segments missing" };
1293
+ const detail = `power tx=${Number(pow.avg_tx).toFixed(0)} rating=${Number(pow.avg_rating).toFixed(2)}; casual tx=${Number(cas.avg_tx).toFixed(1)} rating=${Number(cas.avg_rating).toFixed(2)}; new tx=${Number(ns.avg_tx).toFixed(2)} rating=${Number(ns.avg_rating).toFixed(2)} (means engineered 300/4.75, 27.5/3.75, 1.5/0)`;
1294
+ return guarded(Number(pow.users) >= 550 && Number(cas.users) >= 1450 && Number(ns.users) >= 850, detail, () => {
1295
+ const legs = [
1296
+ bandVerdict(pow.avg_tx, [280, 320], [255, 345], detail, v => v < 60),
1297
+ bandVerdict(pow.avg_rating, [4.68, 4.82], [4.55, 4.95], detail, v => v < 3.8),
1298
+ bandVerdict(cas.avg_tx, [24.5, 30.5], [20, 35], detail, v => v > 90),
1299
+ bandVerdict(cas.avg_rating, [3.65, 3.85], [3.5, 4.0], detail, v => v > 4.55),
1300
+ bandVerdict(ns.avg_tx, [1.2, 1.8], [0.8, 2.2], detail, v => v > 4.5),
1301
+ Number(ns.avg_rating) === 0
1302
+ ? { verdict: "NAILED", detail }
1303
+ : { verdict: "WEAK", detail: `${detail} — new_seller rating not pinned to 0` },
1304
+ ];
1305
+ return { verdict: worstOf(...legs), detail };
1306
+ });
1307
+ },
1308
+ },
1309
+ ],
1310
+ },
1311
+ {
1312
+ id: "marketplace-h8-frequent-buyer",
1313
+ hook: "H8",
1314
+ archetype: "funnel-conversion-by-segment",
1315
+ narrative: "Non-frequent-buyer users drop ~25% of purchases — frequent_buyer keeps a 4/3 purchase-count edge. Read as purchases-per-cart RoR vs casual_seller (Insights formula A/B); unique-user funnel conversion is near ceiling for both.",
1316
+ assertions: [
1317
+ {
1318
+ breakdown: {
1319
+ type: "duckdb",
1320
+ sql: `WITH pu AS (
1321
+ SELECT e.user_id::VARCHAR AS uid, any_value(u.segment) AS segment,
1322
+ count(*) FILTER (WHERE e.event = 'purchase completed') AS purch,
1323
+ count(*) FILTER (WHERE e.event = 'add to cart') AS carts
1324
+ FROM ${EV} e
1325
+ JOIN ${US} u ON e.user_id::VARCHAR = u.distinct_id::VARCHAR
1326
+ GROUP BY 1
1327
+ )
1328
+ SELECT segment, count(*) AS users,
1329
+ sum(purch)::DOUBLE / nullif(sum(carts), 0) AS purch_per_cart
1330
+ FROM pu WHERE segment IN ('frequent_buyer', 'casual_seller') GROUP BY segment`,
1331
+ },
1332
+ assert: (rows) => {
1333
+ const fb = rows.find(r => r.segment === "frequent_buyer");
1334
+ const cas = rows.find(r => r.segment === "casual_seller");
1335
+ if (!fb || !cas) return { verdict: "NONE", detail: "frequent/casual segments missing" };
1336
+ const ror = Number(fb.purch_per_cart) / Number(cas.purch_per_cart);
1337
+ const detail = `purch-per-cart RoR frequent/casual = ${ror.toFixed(3)} (mechanism 4/3; frequent n=${fb.users} casual n=${cas.users})`;
1338
+ return guarded(Number(fb.users) >= 1800 && Number(cas.users) >= 1450, detail,
1339
+ () => bandVerdict(ror, [1.26, 1.41], [1.15, 1.52], detail, v => v <= 1.02));
1340
+ },
1341
+ },
1342
+ ],
1343
+ },
1344
+ {
1345
+ id: "marketplace-h9-b2p-ttc",
1346
+ hook: "H9",
1347
+ archetype: "funnel-ttc-by-segment",
1348
+ narrative: "Browse-to-Purchase funnel-post gap scaling: power_seller/frequent_buyer x0.4, window_shopper x1.4, B2P instances only (1.6 fix — scaling all funnels let the greedy evaluator dilute the read to ~0.87/1.13 via unscaled Buyer-Onboarding prefixes). Asserted via timeToConvert at a 48h window: frequent/casual ~0.61, window/casual ~1.12 (window's slow tail right-censors), power/casual ~0.64.",
1349
+ assertions: [
1350
+ {
1351
+ breakdown: {
1352
+ type: "timeToConvert",
1353
+ steps: ["item searched", "item viewed", "add to cart", "purchase completed"],
1354
+ breakdownByUserProperty: "segment",
1355
+ conversionWindowMs: 48 * 3600 * 1000,
1356
+ },
1357
+ assert: (rows) => {
1358
+ const cell = Object.fromEntries(rows.map(r => [r.segment_value, r]));
1359
+ const med = s => cell[s] ? Number(cell[s].median_ttc_ms) : null;
1360
+ if (!med("frequent_buyer") || !med("casual_seller") || !med("window_shopper") || !med("power_seller")) {
1361
+ return { verdict: "NONE", detail: "segment TTC cells missing" };
1362
+ }
1363
+ const freqCas = med("frequent_buyer") / med("casual_seller");
1364
+ const winCas = med("window_shopper") / med("casual_seller");
1365
+ const powCas = med("power_seller") / med("casual_seller");
1366
+ const h = ms => (ms / 3600000).toFixed(1);
1367
+ const detail = `TTC medians @48h: frequent ${h(med("frequent_buyer"))}h / power ${h(med("power_seller"))}h / casual ${h(med("casual_seller"))}h / window ${h(med("window_shopper"))}h; frequent/casual=${freqCas.toFixed(3)} (measured 0.615) window/casual=${winCas.toFixed(3)} (1.120) power/casual=${powCas.toFixed(3)} (0.639); n=${cell.frequent_buyer.user_count}/${cell.power_seller.user_count}/${cell.casual_seller.user_count}/${cell.window_shopper.user_count}`;
1368
+ return guarded(
1369
+ Number(cell.frequent_buyer.user_count) >= 500 && Number(cell.power_seller.user_count) >= 200 &&
1370
+ Number(cell.casual_seller.user_count) >= 175 && Number(cell.window_shopper.user_count) >= 110,
1371
+ detail, () => {
1372
+ const v1 = bandVerdict(freqCas, [0.55, 0.68], [0.45, 0.80], detail, v => v >= 0.95);
1373
+ const v2 = bandVerdict(winCas, [1.05, 1.20], [0.98, 1.32], detail, v => v <= 0.93);
1374
+ const v3 = bandVerdict(powCas, [0.52, 0.76], [0.45, 0.90], detail, v => v >= 1.00);
1375
+ return { verdict: worstOf(v1, v2, v3), detail };
1376
+ });
1377
+ },
1378
+ },
1379
+ ],
1380
+ },
1381
+ {
1382
+ id: "marketplace-h10-message-magic",
1383
+ hook: "H10",
1384
+ archetype: "frequency-sweet-spot",
1385
+ narrative: "Total message-count cohorts: 2-5 messages → offer_amount x1.35; 6+ → x0.85 (haggling deadlock). Property-only on an iid base — clean cohort ratios at any scale (a purchase-drop leg was structurally unverifiable on this activity-correlated cohort). Identity invariants ride along.",
1386
+ assertions: [
1387
+ {
1388
+ breakdown: {
1389
+ type: "duckdb",
1390
+ sql: `WITH pu AS (
1391
+ SELECT user_id::VARCHAR AS uid,
1392
+ count(*) FILTER (WHERE event = 'message sent') AS msgs,
1393
+ avg(offer_amount) FILTER (WHERE event = 'offer received') AS avg_offer
1394
+ FROM ${EV} GROUP BY 1
1395
+ )
1396
+ SELECT
1397
+ CASE WHEN msgs BETWEEN 2 AND 5 THEN 'sweet' WHEN msgs >= 6 THEN 'over' ELSE 'low' END AS bucket,
1398
+ count(*) FILTER (WHERE avg_offer IS NOT NULL) AS users,
1399
+ avg(avg_offer) AS avg_offer
1400
+ FROM pu GROUP BY 1`,
1401
+ },
1402
+ assert: (rows) => {
1403
+ const sweet = rows.find(r => r.bucket === "sweet");
1404
+ const low = rows.find(r => r.bucket === "low");
1405
+ const over = rows.find(r => r.bucket === "over");
1406
+ if (!sweet || !low || !over) return { verdict: "NONE", detail: "message buckets missing" };
1407
+ const sweetLow = Number(sweet.avg_offer) / Number(low.avg_offer);
1408
+ const overLow = Number(over.avg_offer) / Number(low.avg_offer);
1409
+ const detail = `avg offer_amount sweet ${Number(sweet.avg_offer).toFixed(1)} / over ${Number(over.avg_offer).toFixed(1)} / low ${Number(low.avg_offer).toFixed(1)}; sweet/low=${sweetLow.toFixed(3)} (mechanism 1.35) over/low=${overLow.toFixed(3)} (0.85); n=${sweet.users}/${over.users}/${low.users}`;
1410
+ return guarded(Number(sweet.users) >= 2700 && Number(over.users) >= 1600 && Number(low.users) >= 2200, detail, () => {
1411
+ const v1 = bandVerdict(sweetLow, [1.24, 1.37], [1.15, 1.45], detail, v => v <= 1.02);
1412
+ const v2 = bandVerdict(overLow, [0.79, 0.88], [0.72, 0.94], detail, v => v >= 1.02);
1413
+ return { verdict: worstOf(v1, v2), detail };
1414
+ });
1415
+ },
1416
+ },
1417
+ {
1418
+ breakdown: {
1419
+ type: "duckdb",
1420
+ sql: `SELECT
1421
+ avg((user_id IS NOT NULL)::INT) AS uid_share,
1422
+ avg((device_id IS NOT NULL)::INT) AS device_share,
1423
+ count(DISTINCT device_id)::DOUBLE / count(DISTINCT user_id) AS devices_per_user
1424
+ FROM ${EV}`,
1425
+ },
1426
+ assert: (rows) => {
1427
+ const r = rows[0] || {};
1428
+ const uid = Number(r.uid_share), dev = Number(r.device_share), dpu = Number(r.devices_per_user);
1429
+ const detail = `uid=${uid.toFixed(4)} device=${dev.toFixed(4)} devices/user=${dpu.toFixed(2)} (avgDevicePerUser=2; auth event is Buyer Onboarding step 1, so no device-only prefix)`;
1430
+ if (uid < 0.9) return { verdict: "INVERSE", detail };
1431
+ if (uid === 1 && dev >= 0.99 && dpu >= 1.7 && dpu <= 2.4) return { verdict: "NAILED", detail };
1432
+ if (uid >= 0.999 && dev >= 0.98) return { verdict: "STRONG", detail };
1433
+ return { verdict: "WEAK", detail };
1434
+ },
1435
+ },
1436
+ ],
1437
+ },
1438
+ ];