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