@ak--47/dungeon-master 1.5.4 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/analyze-soup/SKILL.md +21 -12
- package/.claude/skills/create-dungeon/SKILL.md +65 -5
- package/.claude/skills/create-project/SKILL.md +1 -1
- package/.claude/skills/create-project/provision.mjs +57 -10
- package/.claude/skills/verify-dungeon/SKILL.md +56 -25
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
- package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
- package/.claude/skills/write-hooks/SKILL.md +156 -18
- package/CHANGELOG.md +183 -0
- package/HOOKS.md +566 -112
- package/README.md +28 -9
- package/docs/guides/1.3.0-upgrade-guide.md +262 -0
- package/docs/guides/1.3.1-upgrade-guide.md +328 -0
- package/docs/guides/1.4.0-upgrade-guide.md +130 -0
- package/docs/guides/1.4.1-upgrade-guide.md +125 -0
- package/docs/guides/1.4.3-upgrade-guide.md +200 -0
- package/docs/guides/1.4.4-upgrade-guide.md +95 -0
- package/docs/guides/1.5.0-upgrade-guide.md +426 -0
- package/docs/guides/1.5.1-upgrade-guide.md +178 -0
- package/docs/guides/1.5.3-upgrade-guide.md +117 -0
- package/docs/guides/1.6.0-upgrade-guide.md +204 -0
- package/dungeons/technical/stories-verify.js +152 -0
- package/dungeons/vertical/README.md +154 -0
- package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
- package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
- package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
- package/dungeons/vertical/community/community.js +1589 -0
- package/dungeons/vertical/community/community.sql +216 -0
- package/dungeons/vertical/community/community.verify.mjs +72 -0
- package/dungeons/vertical/crypto/crypto.js +1483 -0
- package/dungeons/vertical/crypto/crypto.sql +194 -0
- package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
- package/dungeons/vertical/dating/dating.js +1540 -0
- package/dungeons/vertical/dating/dating.sql +288 -0
- package/dungeons/vertical/dating/dating.verify.mjs +72 -0
- package/dungeons/vertical/devtools/devtools.js +1524 -0
- package/dungeons/vertical/devtools/devtools.sql +161 -0
- package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
- package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
- package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
- package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
- package/dungeons/vertical/education/education.js +1763 -0
- package/dungeons/vertical/education/education.sql +243 -0
- package/dungeons/vertical/education/education.verify.mjs +75 -0
- package/dungeons/vertical/fintech/fintech.js +1410 -0
- package/dungeons/vertical/fintech/fintech.sql +175 -0
- package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
- package/dungeons/vertical/fitness/fitness.js +1495 -0
- package/dungeons/vertical/fitness/fitness.sql +206 -0
- package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
- package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
- package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
- package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
- package/dungeons/vertical/gaming/gaming.js +1741 -0
- package/dungeons/vertical/gaming/gaming.sql +249 -0
- package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
- package/dungeons/vertical/healthcare/healthcare.js +1488 -0
- package/dungeons/vertical/healthcare/healthcare.sql +193 -0
- package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
- package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
- package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
- package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
- package/dungeons/vertical/logistics/logistics.js +1541 -0
- package/dungeons/vertical/logistics/logistics.sql +205 -0
- package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
- package/dungeons/vertical/marketplace/marketplace.js +1438 -0
- package/dungeons/vertical/marketplace/marketplace.sql +148 -0
- package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
- package/dungeons/vertical/{media.js → media/media.js} +557 -58
- package/dungeons/vertical/media/media.sql +173 -0
- package/dungeons/vertical/media/media.verify.mjs +74 -0
- package/dungeons/vertical/real-estate/real-estate.js +1629 -0
- package/dungeons/vertical/real-estate/real-estate.sql +167 -0
- package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
- package/dungeons/vertical/sass/sass.js +1733 -0
- package/dungeons/vertical/sass/sass.sql +173 -0
- package/dungeons/vertical/sass/sass.verify.mjs +74 -0
- package/dungeons/vertical/social/social.js +1456 -0
- package/dungeons/vertical/social/social.sql +160 -0
- package/dungeons/vertical/social/social.verify.mjs +76 -0
- package/dungeons/vertical/streaming/streaming.js +677 -0
- package/dungeons/vertical/streaming/streaming.sql +101 -0
- package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
- package/dungeons/vertical/support-desk/support-desk.js +741 -0
- package/dungeons/vertical/support-desk/support-desk.sql +137 -0
- package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
- package/dungeons/vertical/travel/travel.js +1603 -0
- package/dungeons/vertical/travel/travel.sql +236 -0
- package/dungeons/vertical/travel/travel.verify.mjs +72 -0
- package/index.js +2 -2
- package/lib/core/config-validator.js +37 -2
- package/lib/core/context.js +4 -2
- package/lib/core/dungeon-loader.js +20 -15
- package/lib/core/dungeon-to-json.js +22 -4
- package/lib/core/storage.js +3 -3
- package/lib/generators/funnels.js +36 -9
- package/lib/hook-helpers/cohort.js +53 -0
- package/lib/hook-helpers/index.js +8 -0
- package/lib/hook-helpers/mutate.js +1 -1
- package/lib/hook-helpers/shape.js +319 -0
- package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
- package/lib/hook-patterns/attributed-by-source.js +60 -58
- package/lib/hook-patterns/frequency-by-frequency.js +14 -4
- package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
- package/lib/hook-patterns/index.js +1 -1
- package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
- package/lib/orchestrators/user-loop.js +41 -0
- package/lib/templates/story-spec.schema.json +175 -0
- package/lib/utils/json-evaluator.js +1 -1
- package/lib/utils/utils.js +5 -4
- package/lib/verify/coerce.js +186 -0
- package/lib/verify/counting.js +270 -32
- package/lib/verify/emulate-breakdown.js +1285 -203
- package/lib/verify/first-time.js +89 -0
- package/lib/verify/flows.js +679 -0
- package/lib/verify/formula.js +259 -0
- package/lib/verify/funnel-engine.js +697 -113
- package/lib/verify/index.js +32 -4
- package/lib/verify/sessionize.js +275 -0
- package/lib/verify/story-runner.js +483 -0
- package/lib/verify/verify-dungeon.js +59 -37
- package/package.json +2 -1
- package/scripts/extract-dungeon-schema.mjs +2 -2
- package/scripts/run-many.mjs +2 -2
- package/scripts/verify-runner.mjs +2 -2
- package/scripts/verify-stories.mjs +244 -0
- package/types.d.ts +296 -30
- package/dungeons/vertical/ai-platform.js +0 -818
- package/dungeons/vertical/community.js +0 -966
- package/dungeons/vertical/crypto.js +0 -895
- package/dungeons/vertical/dating.js +0 -830
- package/dungeons/vertical/devtools.js +0 -1050
- package/dungeons/vertical/ecommerce.js +0 -811
- package/dungeons/vertical/education.js +0 -964
- package/dungeons/vertical/fintech.js +0 -946
- package/dungeons/vertical/fitness.js +0 -935
- package/dungeons/vertical/food-delivery.js +0 -934
- package/dungeons/vertical/gaming.js +0 -1120
- package/dungeons/vertical/healthcare.js +0 -945
- package/dungeons/vertical/insurance-application.js +0 -1039
- package/dungeons/vertical/logistics.js +0 -986
- package/dungeons/vertical/marketplace.js +0 -1019
- package/dungeons/vertical/real-estate.js +0 -872
- package/dungeons/vertical/sass.js +0 -995
- package/dungeons/vertical/social.js +0 -904
- package/dungeons/vertical/travel.js +0 -930
|
@@ -0,0 +1,1483 @@
|
|
|
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
|
+
/** @typedef {import("../../../types").Dungeon} Config */
|
|
8
|
+
|
|
9
|
+
// ── OVERVIEW ──
|
|
10
|
+
/*
|
|
11
|
+
* NAME: CoinNest
|
|
12
|
+
* APP: DeFi cryptocurrency exchange platform. Users connect wallets,
|
|
13
|
+
* swap tokens, stake for yield, mint NFTs, claim airdrops, and
|
|
14
|
+
* trade. Features KYC verification, pro trading tiers, and
|
|
15
|
+
* portfolio tracking.
|
|
16
|
+
* SCALE: 10,000 users, ~2.2M events, 121 days (2026-01-01 → 2026-05-01)
|
|
17
|
+
* CORE LOOP: wallet connected → KYC → deposit → swap → stake/unstake → portfolio viewed → withdrawal
|
|
18
|
+
*
|
|
19
|
+
* NOTE: MOON/* and SCAM/* token pairs never occur organically — hooks
|
|
20
|
+
* flip them onto the declared token_pair column (H3, H8). All
|
|
21
|
+
* boundary days below are UTC calendar days from 2026-01-01.
|
|
22
|
+
*
|
|
23
|
+
* EVENTS (16):
|
|
24
|
+
* swap (10) > portfolio viewed (8) > deposit (5) > stake (4) > withdrawal (3)
|
|
25
|
+
* > price alert set (3) > limit order placed (3) > transfer (3) > kyc started (2)
|
|
26
|
+
* > unstake (2) > claim airdrop (2) > nft mint (2) > referral sent (2)
|
|
27
|
+
* > wallet connected (1) > kyc completed (1)
|
|
28
|
+
*
|
|
29
|
+
* FUNNELS (3):
|
|
30
|
+
* - Onboarding: wallet connected → kyc started → deposit (60%)
|
|
31
|
+
* - Trading: deposit → swap → withdrawal (70%)
|
|
32
|
+
* - DeFi: swap → stake → claim airdrop (35%)
|
|
33
|
+
*
|
|
34
|
+
* USER PROPS: trading_tier, preferred_chain, wallet_type, total_trade_volume, portfolio_value, kyc_status
|
|
35
|
+
* SUPER PROPS: trading_tier, preferred_chain, wallet_type
|
|
36
|
+
* SCD PROPS: trading_tier (Standard/Pro, monthly fuzzy, max 4)
|
|
37
|
+
* GROUPS: none
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
// ── HOOK STORIES ──
|
|
41
|
+
/*
|
|
42
|
+
* ---------------------------------------------------------------
|
|
43
|
+
* 1. WHALE WALLETS (everything)
|
|
44
|
+
*
|
|
45
|
+
* PATTERN: ~13% of wallets (deterministic: user_id charCodeAt(0) % 50
|
|
46
|
+
* === 0 — user_ids are hex uuids, so only '2' and 'd' match, 2 of 16
|
|
47
|
+
* hex digits) get swap trade_amount_usd boosted 50x. No flag — analyst
|
|
48
|
+
* sees long-tail trade-amount distribution.
|
|
49
|
+
*
|
|
50
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
51
|
+
*
|
|
52
|
+
* Report 1: Trade Amount Distribution
|
|
53
|
+
* - Report type: Insights
|
|
54
|
+
* - Event: "swap"
|
|
55
|
+
* - Measure: Distribution of "trade_amount_usd"
|
|
56
|
+
* - Expected: heavy long tail; whale avg trade ~48x non-whale
|
|
57
|
+
* (measured 281K vs 5.8K; organic ratio 1.0)
|
|
58
|
+
*
|
|
59
|
+
* Report 2: Volume Share by Top Traders Cohort
|
|
60
|
+
* - Cohort A: top 5% by total swap volume per user
|
|
61
|
+
* - Event: "swap"
|
|
62
|
+
* - Measure: Total of "trade_amount_usd"
|
|
63
|
+
* - Expected: cohort A drives ~75% of total volume (organic ~10%)
|
|
64
|
+
*
|
|
65
|
+
* REAL-WORLD ANALOGUE: Whale wallets dominate exchange volume.
|
|
66
|
+
*
|
|
67
|
+
* ---------------------------------------------------------------
|
|
68
|
+
* 2. GAS PRICE SPIKE (event + everything)
|
|
69
|
+
*
|
|
70
|
+
* PATTERN: Days 35-37, gas_fee_usd 10x on swap + transfer events.
|
|
71
|
+
* In window, 40% of swaps get swap_status flipped to "failed". No flag.
|
|
72
|
+
*
|
|
73
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
74
|
+
*
|
|
75
|
+
* Report 1: Avg Gas Fee Over Time
|
|
76
|
+
* - Report type: Insights
|
|
77
|
+
* - Event: "swap"
|
|
78
|
+
* - Measure: Average of "gas_fee_usd"
|
|
79
|
+
* - Line chart by day
|
|
80
|
+
* - Expected: spike days 35-37 (~10x baseline; measured in-window
|
|
81
|
+
* avg 138 vs 13.7 outside)
|
|
82
|
+
*
|
|
83
|
+
* Report 2: Swap Failure Rate Over Time
|
|
84
|
+
* - Report type: Insights
|
|
85
|
+
* - Event: "swap"
|
|
86
|
+
* - Measure: Total
|
|
87
|
+
* - Filter: swap_status = "failed"
|
|
88
|
+
* - Line chart by day
|
|
89
|
+
* - Expected: in-window fail share ~0.52 vs ~0.17 baseline
|
|
90
|
+
* (40% of the non-failed remainder flipped: 0.17 + 0.4 × 0.83)
|
|
91
|
+
*
|
|
92
|
+
* REAL-WORLD ANALOGUE: Network congestion breaks transactions.
|
|
93
|
+
*
|
|
94
|
+
* ---------------------------------------------------------------
|
|
95
|
+
* 3. TOKEN LAUNCH SURGE (event + everything)
|
|
96
|
+
*
|
|
97
|
+
* PATTERN: Day 50+, 25% of swaps get token_pair flipped to a MOON
|
|
98
|
+
* pair. Each MOON swap clones 4 extra swap events with unique offset.
|
|
99
|
+
* The 25% flip compounds through the 4x cloning to ~58% of post-d50
|
|
100
|
+
* swap volume ((0.25 × 5) / (0.75 + 1.25), diluted by other hooks'
|
|
101
|
+
* non-MOON clones). No flag — discover via token_pair breakdown.
|
|
102
|
+
*
|
|
103
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
104
|
+
*
|
|
105
|
+
* Report 1: Swap Volume by Token Pair Over Time
|
|
106
|
+
* - Report type: Insights
|
|
107
|
+
* - Event: "swap"
|
|
108
|
+
* - Measure: Total
|
|
109
|
+
* - Filter: token_pair contains "MOON"
|
|
110
|
+
* - Line chart by day
|
|
111
|
+
* - Expected: exactly zero before day 50 (flips are post-launch
|
|
112
|
+
* only; clones offset forward), ~58% of swaps thereafter
|
|
113
|
+
*
|
|
114
|
+
* REAL-WORLD ANALOGUE: Meme/altcoin listings drive frenzied volume.
|
|
115
|
+
*
|
|
116
|
+
* ---------------------------------------------------------------
|
|
117
|
+
* 4. AIRDROP HUNTER CHURN (everything)
|
|
118
|
+
*
|
|
119
|
+
* PATTERN: ~14% of users (deterministic: charCodeAt(1) % 25 === 0 on
|
|
120
|
+
* hex uuids — only '2' and 'd' match) are airdrop-farming bots. After
|
|
121
|
+
* their first "claim airdrop" event, 95% of subsequent events are
|
|
122
|
+
* removed.
|
|
123
|
+
*
|
|
124
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
125
|
+
*
|
|
126
|
+
* Report 1: Retention by Airdrop Claim
|
|
127
|
+
* - Report type: Retention
|
|
128
|
+
* - Event A: "claim airdrop"
|
|
129
|
+
* - Event B: any event
|
|
130
|
+
* - Expected: bot claimers' post/pre-claim event ratio ~1.5 vs ~25
|
|
131
|
+
* for non-bot claimers (contrast ~0.06; organic contrast 1.0)
|
|
132
|
+
*
|
|
133
|
+
* Report 2: Airdrop Hunters vs Real Traders
|
|
134
|
+
* - Report type: Insights
|
|
135
|
+
* - Events: "claim airdrop" unique users vs "swap" unique users
|
|
136
|
+
* - Expected: large overlap gap — many claimers never swap again
|
|
137
|
+
*
|
|
138
|
+
* REAL-WORLD ANALOGUE: Airdrop farms are a well-known DeFi growth
|
|
139
|
+
* tax — bots claim free tokens and immediately abandon the platform.
|
|
140
|
+
*
|
|
141
|
+
* ---------------------------------------------------------------
|
|
142
|
+
* 5. KYC FUNNEL COMPLETION (everything)
|
|
143
|
+
*
|
|
144
|
+
* PATTERN: Users with kyc-completed get post-KYC deposit_amount_usd
|
|
145
|
+
* boosted 4x and 7 extra cloned swaps per existing post-KYC swap.
|
|
146
|
+
* No flag — discover via cohort builder.
|
|
147
|
+
*
|
|
148
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
149
|
+
*
|
|
150
|
+
* Report 1: Avg Deposit by KYC Cohort
|
|
151
|
+
* - Report type: Insights (with cohort)
|
|
152
|
+
* - Cohort A: users with >= 1 "kyc completed"
|
|
153
|
+
* - Cohort B: rest
|
|
154
|
+
* - Event: "deposit"
|
|
155
|
+
* - Measure: Average of "deposit_amount_usd"
|
|
156
|
+
* - Expected: A ~3.4x B (4x knob diluted: whale-boosted amounts
|
|
157
|
+
* inflate the non-KYC mean, and pre-KYC deposits are unboosted;
|
|
158
|
+
* organic ratio 1.0). Swaps/user ~7.6x from the 7x cloning
|
|
159
|
+
* (organic activity confound alone is 2.0x — KYC completers pass
|
|
160
|
+
* more funnels).
|
|
161
|
+
*
|
|
162
|
+
* REAL-WORLD ANALOGUE: KYC unlocks higher limits.
|
|
163
|
+
*
|
|
164
|
+
* ---------------------------------------------------------------
|
|
165
|
+
* 6. STAKE-TO-RETAIN (everything)
|
|
166
|
+
*
|
|
167
|
+
* PATTERN: Users who stake any token within first 14 calendar days get
|
|
168
|
+
* extra cloned swap + portfolio events past day 60. Non-stakers lose
|
|
169
|
+
* 85% of post-day-60 events — except each user's first 24 hours, which
|
|
170
|
+
* are never dropped (churn erases return visits, not the signup; the
|
|
171
|
+
* grace window keeps late-born users' onboarding funnel + auth event
|
|
172
|
+
* intact). No flag — discover via engagement cohort.
|
|
173
|
+
*
|
|
174
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
175
|
+
*
|
|
176
|
+
* Report 1: Post-D60 Volume by Early Stake Cohort
|
|
177
|
+
* - Report type: Insights (with cohort)
|
|
178
|
+
* - Cohort A: users with >= 1 "stake" in first 14 days
|
|
179
|
+
* - Cohort B: rest
|
|
180
|
+
* - Event: any event, Measure: Total per user, Filter: after day 60
|
|
181
|
+
* - Expected: A ~5.3x B events/user post-d60 (measured 153 vs 29;
|
|
182
|
+
* organic 1.1x). NOTE: binary D60 retention curves barely move —
|
|
183
|
+
* 15% survival of a high-volume history still leaves >0 events
|
|
184
|
+
* for ~96% of non-stakers. The read is volume, not presence.
|
|
185
|
+
*
|
|
186
|
+
* REAL-WORLD ANALOGUE: Staking creates skin in the game.
|
|
187
|
+
*
|
|
188
|
+
* ---------------------------------------------------------------
|
|
189
|
+
* 7. PRO TIER MAKER FEES (everything)
|
|
190
|
+
*
|
|
191
|
+
* PATTERN: Pro-tier users (profile.trading_tier, ~25% of users) get
|
|
192
|
+
* maker_fee_pct pinned to 0.05 (vs Standard 0.30) on swap events plus
|
|
193
|
+
* 5 extra cloned swaps per existing (~5.2x swaps/user measured, after
|
|
194
|
+
* compounding with other hooks' clones). The hook also pins every
|
|
195
|
+
* event's trading_tier to the profile value — organically the engine
|
|
196
|
+
* stamps superProps per-event at random, so tier splits are only
|
|
197
|
+
* meaningful in hooked data.
|
|
198
|
+
*
|
|
199
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
200
|
+
*
|
|
201
|
+
* Report 1: Avg Maker Fee by Tier
|
|
202
|
+
* - Report type: Insights
|
|
203
|
+
* - Event: "swap"
|
|
204
|
+
* - Measure: Average of "maker_fee_pct"
|
|
205
|
+
* - Breakdown: "trading_tier"
|
|
206
|
+
* - Expected: Pro = 0.05, Standard = 0.30 (exact — every swap pinned)
|
|
207
|
+
*
|
|
208
|
+
* REAL-WORLD ANALOGUE: Tiered fees retain volume traders.
|
|
209
|
+
*
|
|
210
|
+
* ---------------------------------------------------------------
|
|
211
|
+
* 8. RUG-PULL AFTERMATH (everything)
|
|
212
|
+
*
|
|
213
|
+
* PATTERN: ~2% of day-10-to-70 swaps get token_pair flipped to a SCAM
|
|
214
|
+
* pair; holders lose 80% of post-day-70 events. The flip is per-swap,
|
|
215
|
+
* but active traders carry hundreds of swaps by day 70, so the holder
|
|
216
|
+
* cohort is the MAJORITY of eventful users (~59% measured), not a
|
|
217
|
+
* small victim group. No flag.
|
|
218
|
+
*
|
|
219
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
220
|
+
*
|
|
221
|
+
* Report 1: SCAM Holder Post-D70 Collapse
|
|
222
|
+
* - Report type: Insights (with cohort)
|
|
223
|
+
* - Cohort A: users with >= 1 swap where token_pair contains "SCAM"
|
|
224
|
+
* - Cohort B: rest
|
|
225
|
+
* - Measure: post-d70 / pre-d70 event ratio per user
|
|
226
|
+
* - Expected: A ~0.21 vs B ~1.25 (contrast ~0.17; organic all-user
|
|
227
|
+
* baseline 0.93 — no SCAM pairs exist organically)
|
|
228
|
+
*
|
|
229
|
+
* REAL-WORLD ANALOGUE: Rug-pulls collapse trust.
|
|
230
|
+
*
|
|
231
|
+
* ---------------------------------------------------------------
|
|
232
|
+
* 9. ONBOARDING FUNNEL TIME-TO-CONVERT (funnel-post)
|
|
233
|
+
*
|
|
234
|
+
* PATTERN: Pro tier users complete the onboarding funnel (wallet
|
|
235
|
+
* connected → kyc started → deposit) faster (gap factor 0.7);
|
|
236
|
+
* Standard slower (factor 1.3). Mutates funnel event timestamps.
|
|
237
|
+
* Scoped to the onboarding funnel only: the trading and DeFi funnels
|
|
238
|
+
* share the swap step, and H3/H5/H7 clone swaps at arbitrary offsets,
|
|
239
|
+
* which lets greedy funnel evaluation assemble chains across unscaled
|
|
240
|
+
* clones and collapse the read. Onboarding's first step is
|
|
241
|
+
* isFirstEvent — unique per user — anchoring the evaluator to the
|
|
242
|
+
* exact instance the hook touched.
|
|
243
|
+
*
|
|
244
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
245
|
+
*
|
|
246
|
+
* Report 1: Onboarding Funnel Median Time-to-Convert by Tier
|
|
247
|
+
* - Report type: Funnels
|
|
248
|
+
* - Steps: "wallet connected" -> "kyc started" -> "deposit"
|
|
249
|
+
* - Measure: Median time to convert
|
|
250
|
+
* - Breakdown: "trading_tier"
|
|
251
|
+
* - Conversion window: 6 hours (covers the 1.3x-stretched support)
|
|
252
|
+
* - Expected: Pro/Standard median ratio ~0.74 (measured 32 vs 43
|
|
253
|
+
* min; the 0.7/1.3 knobs attenuate through median position;
|
|
254
|
+
* organic ratio 1.0). Only born-in users have this funnel
|
|
255
|
+
* (~13% of users — growth macro).
|
|
256
|
+
*
|
|
257
|
+
* REAL-WORLD ANALOGUE: Pro traders execute faster end-to-end.
|
|
258
|
+
*
|
|
259
|
+
* ---------------------------------------------------------------
|
|
260
|
+
* 10. SWAP-COUNT MAGIC NUMBER (everything)
|
|
261
|
+
*
|
|
262
|
+
* PATTERN: Users with 8-20 swaps in dataset get +30% on stake
|
|
263
|
+
* amount_usd plus 1-2 extra cloned stake events per existing.
|
|
264
|
+
* Users with 21+ swaps drop 75% of portfolio-viewed events
|
|
265
|
+
* and halve total_value_usd on survivors (over-active traders
|
|
266
|
+
* ignore portfolio review). No flag.
|
|
267
|
+
*
|
|
268
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
269
|
+
*
|
|
270
|
+
* Report 1: Avg Stake Amount by Swap-Count Bucket
|
|
271
|
+
* - Report type: Insights (with cohort)
|
|
272
|
+
* - Cohort A: users with 8-20 "swap"
|
|
273
|
+
* - Cohort B: users with 0-7
|
|
274
|
+
* - Event: "stake"
|
|
275
|
+
* - Measure: Average of "amount_usd"
|
|
276
|
+
* - Expected: A ~1.3x B (measured 1.32; organic 0.95)
|
|
277
|
+
*
|
|
278
|
+
* Report 2: Portfolio Value on Heavy Swappers
|
|
279
|
+
* - Report type: Insights (with cohort)
|
|
280
|
+
* - Cohort C: users with >= 21 "swap"
|
|
281
|
+
* - Cohort A: users with 8-20
|
|
282
|
+
* - Event: "portfolio viewed"
|
|
283
|
+
* - Measure: Average of "total_value_usd"
|
|
284
|
+
* - Expected: C ~0.50x A (the halving on survivors is the clean
|
|
285
|
+
* read; the views-per-user contrast is confounded — over-active
|
|
286
|
+
* traders organically view portfolios more, so the 75% drop only
|
|
287
|
+
* nets out to parity per user)
|
|
288
|
+
*
|
|
289
|
+
* REAL-WORLD ANALOGUE: Engaged swappers grow stake; over-active
|
|
290
|
+
* day-traders ignore long-term portfolio review.
|
|
291
|
+
*
|
|
292
|
+
* ---------------------------------------------------------------
|
|
293
|
+
* 11. EARLY STAKER RETENTION (everything — retention magic number)
|
|
294
|
+
*
|
|
295
|
+
* PATTERN: Born-in-dataset users with 2+ "stake" events in their
|
|
296
|
+
* first 10 lifetime days retain normally. Those with fewer than 2
|
|
297
|
+
* early stakes lose 60% of events past lifetime day 40. Born-in ≈
|
|
298
|
+
* has "wallet connected" (isFirstEvent). No flag — discover via
|
|
299
|
+
* behavioral cohort on early stake count.
|
|
300
|
+
*
|
|
301
|
+
* NOTE (H6 interaction): H6 runs first and drops 85% of post-d60
|
|
302
|
+
* calendar events for non-14d-stakers, which removes most late-born
|
|
303
|
+
* users' day-2-to-10 stakes. The effective early-staker cohort is
|
|
304
|
+
* therefore defined on post-H6 data (~14% of born-in users).
|
|
305
|
+
*
|
|
306
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
307
|
+
*
|
|
308
|
+
* Report 1: Post-Day-40 Event Volume by Early Stakers (born-in)
|
|
309
|
+
* - Report type: Insights (with cohort)
|
|
310
|
+
* - Cohort A: born-in users with >= 2 "stake" in first 10 days
|
|
311
|
+
* - Cohort B: born-in users with 0-1
|
|
312
|
+
* - Event: any event, Measure: Total per user, after lifetime d40
|
|
313
|
+
* - Expected: A ~1.3x B (organic baseline is 0.52 — INVERSE: most
|
|
314
|
+
* born-in users lack 40d of runway, so the hook must flip the
|
|
315
|
+
* sign, not just widen a gap)
|
|
316
|
+
*
|
|
317
|
+
* REAL-WORLD ANALOGUE: Users who stake early have skin in the game
|
|
318
|
+
* and stick around; the "magic number" for retention is 2 stakes
|
|
319
|
+
* in the first 10 days.
|
|
320
|
+
*
|
|
321
|
+
* ===================================================================
|
|
322
|
+
* MEASURED METRICS SUMMARY (2K reduced run vs organic counterfactual)
|
|
323
|
+
* ===================================================================
|
|
324
|
+
*
|
|
325
|
+
* Hook | Metric | Organic | Hooked
|
|
326
|
+
* ----------------------|---------------------------------|---------|-------
|
|
327
|
+
* 1 Whale Wallets | whale/non avg trade amount | 1.0x | ~48x
|
|
328
|
+
* 1 Whale Wallets | top-5% volume share | 0.10 | ~0.75
|
|
329
|
+
* 2 Gas Price Spike | in/out-window avg gas ratio | 1.0x | ~10x
|
|
330
|
+
* 2 Gas Price Spike | in-window swap fail share | 0.17 | ~0.52
|
|
331
|
+
* 3 Token Launch Surge | post-d50 MOON swap share | 0 | ~0.58
|
|
332
|
+
* 4 Airdrop Churn | claimer post/pre contrast (b/n) | ~1.0 | ~0.06
|
|
333
|
+
* 5 KYC Completion | avg deposit ratio (kyc/non) | 1.0x | ~3.4x
|
|
334
|
+
* 5 KYC Completion | swaps/user ratio (kyc/non) | 2.0x | ~7.6x
|
|
335
|
+
* 6 Stake-to-Retain | post-d60 events/user ratio | 1.1x | ~5.3x
|
|
336
|
+
* 7 Pro Tier Fees | maker_fee_pct (Pro / Standard) | .30/.30 | .05/.30
|
|
337
|
+
* 7 Pro Tier Fees | swaps/user ratio (Pro/Std) | n/a | ~5.2x
|
|
338
|
+
* 8 Rug-Pull Aftermath | post/pre-d70 contrast (scam/non)| n/a | ~0.17
|
|
339
|
+
* 9 Onboarding TTC | Pro/Std median TTC @6h window | ~1.0 | ~0.74
|
|
340
|
+
* 10 Swap Magic Number | sweet/low avg stake amount | 0.95 | ~1.32
|
|
341
|
+
* 10 Swap Magic Number | over/sweet avg portfolio value | 1.00 | ~0.50
|
|
342
|
+
* 11 Early Staker Retain| born-in early/non post-d40 ratio| 0.52 | ~1.31
|
|
343
|
+
*
|
|
344
|
+
* Identity: uid_share 1.0, device_share ≥0.999, devices/user ~2.06.
|
|
345
|
+
* Organic "n/a" = cohort does not exist organically (no SCAM pairs;
|
|
346
|
+
* tier stamped per-event at random).
|
|
347
|
+
*/
|
|
348
|
+
|
|
349
|
+
// ── SCALE ──
|
|
350
|
+
const SEED = "coinnest";
|
|
351
|
+
const NUM_USERS = 10_000;
|
|
352
|
+
const DATASET_START = "2026-01-01T00:00:00Z";
|
|
353
|
+
const DATASET_END = "2026-05-01T23:59:59Z";
|
|
354
|
+
const EVENTS_PER_DAY = 0.83;
|
|
355
|
+
const token = process.env.MP_TOKEN || "your-mixpanel-token";
|
|
356
|
+
|
|
357
|
+
const chance = u.initChance(SEED);
|
|
358
|
+
|
|
359
|
+
// ── KNOBS (tweak these to reshape stories) ──
|
|
360
|
+
const WHALE_HASH_MOD = 50;
|
|
361
|
+
const WHALE_AMOUNT_MULT = 50;
|
|
362
|
+
|
|
363
|
+
const GAS_SPIKE_START_DAY = 35;
|
|
364
|
+
const GAS_SPIKE_END_DAY = 38;
|
|
365
|
+
const GAS_SPIKE_MULT = 10;
|
|
366
|
+
const GAS_SPIKE_FAIL_LIKELIHOOD = 40;
|
|
367
|
+
|
|
368
|
+
const MOON_LAUNCH_DAY = 50;
|
|
369
|
+
const MOON_FLIP_LIKELIHOOD = 25;
|
|
370
|
+
const MOON_CLONE_COUNT = 4;
|
|
371
|
+
|
|
372
|
+
const AIRDROP_BOT_HASH_MOD = 25;
|
|
373
|
+
const AIRDROP_DROP_LIKELIHOOD = 95;
|
|
374
|
+
|
|
375
|
+
const KYC_DEPOSIT_MULT = 4;
|
|
376
|
+
const KYC_SWAP_CLONE_COUNT = 7;
|
|
377
|
+
|
|
378
|
+
const STAKE_EARLY_WINDOW_DAYS = 14;
|
|
379
|
+
const STAKE_RETENTION_CUTOFF_DAYS = 60;
|
|
380
|
+
const STAKE_NON_STAKER_DROP_LIKELIHOOD = 85;
|
|
381
|
+
const STAKE_INJECT_SWAP_COUNT = 8;
|
|
382
|
+
const STAKE_INJECT_PORTFOLIO_COUNT = 4;
|
|
383
|
+
|
|
384
|
+
const PRO_MAKER_FEE = 0.05;
|
|
385
|
+
const STANDARD_MAKER_FEE = 0.30;
|
|
386
|
+
const PRO_SWAP_CLONE_COUNT = 5;
|
|
387
|
+
|
|
388
|
+
const SCAM_WINDOW_START_DAY = 10;
|
|
389
|
+
const SCAM_WINDOW_END_DAY = 70;
|
|
390
|
+
const SCAM_FLIP_LIKELIHOOD = 2;
|
|
391
|
+
const SCAM_DROP_LIKELIHOOD = 80;
|
|
392
|
+
|
|
393
|
+
const FUNNEL_TTC_PRO = 0.7;
|
|
394
|
+
const FUNNEL_TTC_STANDARD = 1.3;
|
|
395
|
+
|
|
396
|
+
const SWAP_SWEET_MIN = 8;
|
|
397
|
+
const SWAP_SWEET_MAX = 20;
|
|
398
|
+
const SWAP_OVER_THRESHOLD = 21;
|
|
399
|
+
const SWAP_STAKE_BOOST = 1.3;
|
|
400
|
+
const SWAP_OVER_PORTFOLIO_DROP_LIKELIHOOD = 75;
|
|
401
|
+
const SWAP_OVER_PORTFOLIO_VALUE_FACTOR = 0.5;
|
|
402
|
+
|
|
403
|
+
const EARLY_STAKE_MIN = 2;
|
|
404
|
+
const EARLY_STAKE_WINDOW_DAYS = 10;
|
|
405
|
+
const EARLY_STAKE_CUTOFF_DAYS = 40;
|
|
406
|
+
const EARLY_STAKE_DROP_LIKELIHOOD = 60;
|
|
407
|
+
|
|
408
|
+
// ── DATA ARRAYS ──
|
|
409
|
+
const TOKEN_PAIRS = [
|
|
410
|
+
"ETH/USDC", "ETH/USDT", "BTC/USDC", "SOL/USDC",
|
|
411
|
+
"MATIC/ETH", "ARB/ETH", "OP/USDC", "AVAX/USDC",
|
|
412
|
+
"LINK/ETH", "UNI/USDC", "AAVE/ETH", "CRV/USDC",
|
|
413
|
+
"DOGE/USDT", "PEPE/ETH", "APE/USDC"
|
|
414
|
+
];
|
|
415
|
+
|
|
416
|
+
const AIRDROP_NAMES = [
|
|
417
|
+
"LayerZero Season 1", "zkSync Drop", "Starknet STRK",
|
|
418
|
+
"Arbitrum ARB", "Optimism OP", "Blur Season 3",
|
|
419
|
+
"EigenLayer Points", "Celestia TIA", "Jupiter JUP",
|
|
420
|
+
"Wormhole W"
|
|
421
|
+
];
|
|
422
|
+
|
|
423
|
+
const NFT_COLLECTIONS = [
|
|
424
|
+
"Bored Apes", "Azuki", "DeGods", "Pudgy Penguins",
|
|
425
|
+
"Milady", "Doodles", "CloneX", "Moonbirds",
|
|
426
|
+
"CryptoPunks", "Art Blocks"
|
|
427
|
+
];
|
|
428
|
+
|
|
429
|
+
// ── HELPER FUNCTIONS ──
|
|
430
|
+
function handleFunnelPostHooks(record, meta) {
|
|
431
|
+
// H9: onboarding funnel TTC scaled by tier (Pro faster, Standard slower).
|
|
432
|
+
// Scoped to the ONBOARDING funnel only. The trading and DeFi funnels share
|
|
433
|
+
// the swap step, and H3/H5/H7 clone swaps at arbitrary offsets — scaling
|
|
434
|
+
// those instances lets greedy evaluation (Mixpanel funnels + the emulator)
|
|
435
|
+
// assemble chains across unscaled clones, collapsing the read. Onboarding's
|
|
436
|
+
// first step (wallet connected, isFirstEvent) is unique per user, anchoring
|
|
437
|
+
// the evaluator to the exact instance this hook touched.
|
|
438
|
+
if (meta?.funnel?.sequence?.[0] !== "wallet connected") return record;
|
|
439
|
+
const segment = meta?.profile?.trading_tier;
|
|
440
|
+
if (Array.isArray(record) && record.length > 1) {
|
|
441
|
+
const factor = (
|
|
442
|
+
segment === "Pro" ? FUNNEL_TTC_PRO :
|
|
443
|
+
segment === "Standard" ? FUNNEL_TTC_STANDARD :
|
|
444
|
+
1.0
|
|
445
|
+
);
|
|
446
|
+
if (factor !== 1.0) {
|
|
447
|
+
for (let i = 1; i < record.length; i++) {
|
|
448
|
+
const prev = dayjs.utc(record[i - 1].time);
|
|
449
|
+
const newGap = Math.round(dayjs.utc(record[i].time).diff(prev) * factor);
|
|
450
|
+
record[i].time = prev.add(newGap, "milliseconds").toISOString();
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return record;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function handleEverythingHooks(record, meta) {
|
|
458
|
+
// All boundary arithmetic in UTC: local-mode dayjs would place day-N
|
|
459
|
+
// boundaries at machine-local midnight and shift them across the 2026-03-08
|
|
460
|
+
// US DST transition — output would differ by machine timezone, breaking the
|
|
461
|
+
// seeded byte-identical guarantee.
|
|
462
|
+
const datasetStart = dayjs.unix(meta.datasetStart).utc();
|
|
463
|
+
const userEvents = record;
|
|
464
|
+
const profile = meta.profile;
|
|
465
|
+
|
|
466
|
+
// Stamp superProps from profile for consistency
|
|
467
|
+
userEvents.forEach(e => {
|
|
468
|
+
e.trading_tier = profile.trading_tier;
|
|
469
|
+
e.preferred_chain = profile.preferred_chain;
|
|
470
|
+
e.wallet_type = profile.wallet_type;
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// H1: Whale wallets — 2% of users (charCodeAt(0) % 50 === 0) get 50x swap volume
|
|
474
|
+
const userId = userEvents.length > 0 ? (userEvents[0].user_id || userEvents[0].distinct_id || "") : "";
|
|
475
|
+
const isWhale = userId.length > 0 && userId.charCodeAt(0) % WHALE_HASH_MOD === 0;
|
|
476
|
+
if (isWhale) {
|
|
477
|
+
userEvents.forEach(e => {
|
|
478
|
+
if (e.event === "swap") {
|
|
479
|
+
e.trade_amount_usd = Math.round((e.trade_amount_usd || 200) * WHALE_AMOUNT_MULT);
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// H3: Token launch surge — after d50, 25% of swaps flip token_pair to MOON
|
|
485
|
+
const moonLaunchDay = datasetStart.add(MOON_LAUNCH_DAY, "days");
|
|
486
|
+
userEvents.forEach(e => {
|
|
487
|
+
if (e.event === "swap" && dayjs.utc(e.time).isAfter(moonLaunchDay) && chance.bool({ likelihood: MOON_FLIP_LIKELIHOOD })) {
|
|
488
|
+
e.token_pair = chance.pickone(["MOON/USDC", "MOON/ETH", "ETH/MOON"]);
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// H3 (cont): Clone 4 extra MOON-pair swaps per existing MOON swap with unique offset
|
|
493
|
+
const moonSwaps = userEvents.filter(e =>
|
|
494
|
+
e.event === "swap" &&
|
|
495
|
+
typeof e.token_pair === "string" &&
|
|
496
|
+
e.token_pair.includes("MOON")
|
|
497
|
+
);
|
|
498
|
+
if (moonSwaps.length > 0) {
|
|
499
|
+
const clonedMoonEvents = [];
|
|
500
|
+
moonSwaps.forEach(moonEvt => {
|
|
501
|
+
const moonTime = dayjs.utc(moonEvt.time);
|
|
502
|
+
for (let i = 0; i < MOON_CLONE_COUNT; i++) {
|
|
503
|
+
clonedMoonEvents.push({
|
|
504
|
+
...moonEvt,
|
|
505
|
+
time: moonTime.add(chance.integer({ min: 1, max: 72 }), "hours").toISOString(),
|
|
506
|
+
user_id: moonEvt.user_id,
|
|
507
|
+
trade_amount_usd: Math.round((moonEvt.trade_amount_usd || 200) * chance.floating({ min: 0.5, max: 2.0 })),
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
userEvents.push(...clonedMoonEvents);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// H4: Airdrop hunter churn — 4% of users (charCodeAt(1) % 25 === 0) who claim
|
|
515
|
+
// an airdrop lose 95% of subsequent events after first claim
|
|
516
|
+
const isAirdropBot = userId.length > 1 && userId.charCodeAt(1) % AIRDROP_BOT_HASH_MOD === 0;
|
|
517
|
+
const hasAirdropClaim = userEvents.some(e => e.event === "claim airdrop");
|
|
518
|
+
if (isAirdropBot && hasAirdropClaim) {
|
|
519
|
+
const firstClaim = userEvents.find(e => e.event === "claim airdrop");
|
|
520
|
+
if (firstClaim) {
|
|
521
|
+
const claimTime = dayjs.utc(firstClaim.time);
|
|
522
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
523
|
+
const evt = userEvents[i];
|
|
524
|
+
if (dayjs.utc(evt.time).isAfter(claimTime) && evt.event !== "claim airdrop") {
|
|
525
|
+
if (chance.bool({ likelihood: AIRDROP_DROP_LIKELIHOOD })) {
|
|
526
|
+
userEvents.splice(i, 1);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// H5: KYC funnel completion — post-KYC deposits 4x boost + 7 extra cloned swaps
|
|
534
|
+
const kycCompleted = userEvents.find(e => e.event === "kyc completed");
|
|
535
|
+
if (kycCompleted) {
|
|
536
|
+
const kycTime = dayjs.utc(kycCompleted.time);
|
|
537
|
+
userEvents.forEach(e => {
|
|
538
|
+
if (dayjs.utc(e.time).isAfter(kycTime) && e.event === "deposit") {
|
|
539
|
+
e.deposit_amount_usd = Math.round((e.deposit_amount_usd || 500) * KYC_DEPOSIT_MULT);
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
const postKycSwaps = userEvents.filter(e =>
|
|
544
|
+
e.event === "swap" && dayjs.utc(e.time).isAfter(kycTime)
|
|
545
|
+
);
|
|
546
|
+
const clonedKycSwaps = [];
|
|
547
|
+
postKycSwaps.forEach(swapEvt => {
|
|
548
|
+
const swapTime = dayjs.utc(swapEvt.time);
|
|
549
|
+
for (let i = 0; i < KYC_SWAP_CLONE_COUNT; i++) {
|
|
550
|
+
clonedKycSwaps.push({
|
|
551
|
+
...swapEvt,
|
|
552
|
+
time: swapTime.add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
|
|
553
|
+
user_id: swapEvt.user_id,
|
|
554
|
+
trade_amount_usd: Math.round((swapEvt.trade_amount_usd || 200) * chance.floating({ min: 0.8, max: 1.5 })),
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
userEvents.push(...clonedKycSwaps);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// H6: Stake-to-retain — early stakers get post-D60 events injected; non-stakers lose 85%
|
|
562
|
+
const earlyStakeDay = datasetStart.add(STAKE_EARLY_WINDOW_DAYS, "days");
|
|
563
|
+
const stakeRetentionCutoff = datasetStart.add(STAKE_RETENTION_CUTOFF_DAYS, "days");
|
|
564
|
+
const stakedEarly = userEvents.some(e =>
|
|
565
|
+
e.event === "stake" && dayjs.utc(e.time).isBefore(earlyStakeDay)
|
|
566
|
+
);
|
|
567
|
+
if (stakedEarly) {
|
|
568
|
+
const postCutoffEvents = userEvents.filter(e => dayjs.utc(e.time).isAfter(stakeRetentionCutoff));
|
|
569
|
+
if (postCutoffEvents.length < 5) {
|
|
570
|
+
const swapTemplate = userEvents.find(e => e.event === "swap");
|
|
571
|
+
const portfolioTemplate = userEvents.find(e => e.event === "portfolio viewed");
|
|
572
|
+
if (swapTemplate) {
|
|
573
|
+
for (let i = 0; i < STAKE_INJECT_SWAP_COUNT; i++) {
|
|
574
|
+
userEvents.push({
|
|
575
|
+
...swapTemplate,
|
|
576
|
+
time: stakeRetentionCutoff.add(chance.integer({ min: 1, max: 55 }), "days").toISOString(),
|
|
577
|
+
user_id: swapTemplate.user_id,
|
|
578
|
+
trade_amount_usd: Math.round((swapTemplate.trade_amount_usd || 200) * chance.floating({ min: 0.5, max: 2.0 })),
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (portfolioTemplate) {
|
|
583
|
+
for (let i = 0; i < STAKE_INJECT_PORTFOLIO_COUNT; i++) {
|
|
584
|
+
userEvents.push({
|
|
585
|
+
...portfolioTemplate,
|
|
586
|
+
time: stakeRetentionCutoff.add(chance.integer({ min: 1, max: 55 }), "days").toISOString(),
|
|
587
|
+
user_id: portfolioTemplate.user_id,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
} else {
|
|
593
|
+
// Non-stakers: drop 85% of post-D60 events to simulate ~15% retention.
|
|
594
|
+
// First-24h grace window: churn erases return visits, never the signup
|
|
595
|
+
// itself. Without it, growth-macro users born after D60 (the majority)
|
|
596
|
+
// lose their entire onboarding history — wallet connected (auth event),
|
|
597
|
+
// kyc started, deposit — collapsing the H9 onboarding funnel population
|
|
598
|
+
// and breaking identity stitching for late-born users.
|
|
599
|
+
const nonStakerFirstT = userEvents.length > 0 ? dayjs.utc(userEvents[0].time) : null;
|
|
600
|
+
const onboardingGraceEnd = nonStakerFirstT ? nonStakerFirstT.add(24, "hours") : null;
|
|
601
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
602
|
+
const evt = userEvents[i];
|
|
603
|
+
if (dayjs.utc(evt.time).isAfter(stakeRetentionCutoff)) {
|
|
604
|
+
if (onboardingGraceEnd && !dayjs.utc(evt.time).isAfter(onboardingGraceEnd)) continue;
|
|
605
|
+
if (chance.bool({ likelihood: STAKE_NON_STAKER_DROP_LIKELIHOOD })) {
|
|
606
|
+
userEvents.splice(i, 1);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// H7: Pro tier maker fees — Pro=0.05, Standard=0.30; Pro gets 5 extra cloned swaps
|
|
613
|
+
if (profile.trading_tier === "Pro") {
|
|
614
|
+
userEvents.forEach(e => {
|
|
615
|
+
if (e.event === "swap") e.maker_fee_pct = PRO_MAKER_FEE;
|
|
616
|
+
});
|
|
617
|
+
const proSwaps = userEvents.filter(e => e.event === "swap");
|
|
618
|
+
const clonedProSwaps = [];
|
|
619
|
+
proSwaps.forEach(swapEvt => {
|
|
620
|
+
const swapTime = dayjs.utc(swapEvt.time);
|
|
621
|
+
for (let i = 0; i < PRO_SWAP_CLONE_COUNT; i++) {
|
|
622
|
+
clonedProSwaps.push({
|
|
623
|
+
...swapEvt,
|
|
624
|
+
time: swapTime.add(chance.integer({ min: 1, max: 96 }), "hours").toISOString(),
|
|
625
|
+
user_id: swapEvt.user_id,
|
|
626
|
+
trade_amount_usd: Math.round((swapEvt.trade_amount_usd || 200) * chance.floating({ min: 0.5, max: 3.0 })),
|
|
627
|
+
maker_fee_pct: PRO_MAKER_FEE,
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
userEvents.push(...clonedProSwaps);
|
|
632
|
+
} else {
|
|
633
|
+
userEvents.forEach(e => {
|
|
634
|
+
if (e.event === "swap") e.maker_fee_pct = STANDARD_MAKER_FEE;
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// H8: Rug-pull aftermath — ~2% of pre-day-70 swaps flipped to SCAM; victims lose 80% post-D70
|
|
639
|
+
const scamCutoff = datasetStart.add(SCAM_WINDOW_END_DAY, "days");
|
|
640
|
+
let hadScam = false;
|
|
641
|
+
userEvents.forEach(e => {
|
|
642
|
+
if (e.event === "swap") {
|
|
643
|
+
const swapDay = dayjs.utc(e.time).diff(datasetStart, "day");
|
|
644
|
+
if (swapDay >= SCAM_WINDOW_START_DAY && swapDay < SCAM_WINDOW_END_DAY && chance.bool({ likelihood: SCAM_FLIP_LIKELIHOOD })) {
|
|
645
|
+
e.token_pair = chance.pickone(["SCAM/USDC", "SCAM/ETH", "ETH/SCAM"]);
|
|
646
|
+
hadScam = true;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
if (hadScam) {
|
|
651
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
652
|
+
const evt = userEvents[i];
|
|
653
|
+
if (dayjs.utc(evt.time).isAfter(scamCutoff) && chance.bool({ likelihood: SCAM_DROP_LIKELIHOOD })) {
|
|
654
|
+
userEvents.splice(i, 1);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// H10: Swap-count magic number — sweet 8-20 boosts stake; over 21+ drops portfolio views
|
|
660
|
+
const swapCount = userEvents.filter(e => e.event === "swap").length;
|
|
661
|
+
if (swapCount >= SWAP_SWEET_MIN && swapCount <= SWAP_SWEET_MAX) {
|
|
662
|
+
const stakeTemplate = userEvents.find(e => e.event === "stake");
|
|
663
|
+
if (stakeTemplate) {
|
|
664
|
+
const stakes = userEvents.filter(e => e.event === "stake");
|
|
665
|
+
stakes.forEach(s => {
|
|
666
|
+
if (typeof s.amount_usd === "number") s.amount_usd = Math.round(s.amount_usd * SWAP_STAKE_BOOST);
|
|
667
|
+
const extras = chance.integer({ min: 1, max: 2 });
|
|
668
|
+
for (let k = 0; k < extras; k++) {
|
|
669
|
+
userEvents.push({
|
|
670
|
+
...s,
|
|
671
|
+
time: dayjs.utc(s.time).add(chance.integer({ min: 5, max: 360 }), "minutes").toISOString(),
|
|
672
|
+
user_id: s.user_id,
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
} else if (swapCount >= SWAP_OVER_THRESHOLD) {
|
|
678
|
+
// Over-active traders ignore portfolio review: drop 75%, halve total_value_usd on survivors
|
|
679
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
680
|
+
if (userEvents[i].event === "portfolio viewed") {
|
|
681
|
+
if (chance.bool({ likelihood: SWAP_OVER_PORTFOLIO_DROP_LIKELIHOOD })) {
|
|
682
|
+
userEvents.splice(i, 1);
|
|
683
|
+
} else {
|
|
684
|
+
userEvents[i].total_value_usd = Math.round((userEvents[i].total_value_usd || 5000) * SWAP_OVER_PORTFOLIO_VALUE_FACTOR);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// H11: Early staker retention — born-in users with <2 early stakes lose 60% post-D40
|
|
691
|
+
if (meta.userIsBornInDataset) {
|
|
692
|
+
const firstT = userEvents[0]?.time;
|
|
693
|
+
if (firstT) {
|
|
694
|
+
const window10 = dayjs.utc(firstT).add(EARLY_STAKE_WINDOW_DAYS, "days").toISOString();
|
|
695
|
+
const earlyStakes = userEvents.filter(e => e.event === "stake" && e.time <= window10).length;
|
|
696
|
+
if (earlyStakes < EARLY_STAKE_MIN) {
|
|
697
|
+
const cutoff = dayjs.utc(firstT).add(EARLY_STAKE_CUTOFF_DAYS, "days");
|
|
698
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
699
|
+
if (dayjs.utc(userEvents[i].time).isAfter(cutoff) && chance.bool({ likelihood: EARLY_STAKE_DROP_LIKELIHOOD })) {
|
|
700
|
+
userEvents.splice(i, 1);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// H2: Gas price spike — days 35-37 swap+transfer gas_fee_usd 10x; 40% of swaps fail.
|
|
708
|
+
// Runs AFTER all cloning to catch events that land in the spike window from clone offsets.
|
|
709
|
+
const gasSpikeStart = datasetStart.add(GAS_SPIKE_START_DAY, "days");
|
|
710
|
+
const gasSpikeEnd = datasetStart.add(GAS_SPIKE_END_DAY, "days");
|
|
711
|
+
userEvents.forEach(e => {
|
|
712
|
+
if (e.event !== "swap" && e.event !== "transfer") return;
|
|
713
|
+
const t = dayjs.utc(e.time);
|
|
714
|
+
if (t.isAfter(gasSpikeStart) && t.isBefore(gasSpikeEnd)) {
|
|
715
|
+
e.gas_fee_usd = Math.round((e.gas_fee_usd || 5) * GAS_SPIKE_MULT);
|
|
716
|
+
if (e.event === "swap" && chance.bool({ likelihood: GAS_SPIKE_FAIL_LIKELIHOOD })) {
|
|
717
|
+
e.swap_status = "failed";
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
userEvents.sort((a, b) => new Date(a.time) - new Date(b.time));
|
|
723
|
+
return record;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// ── CONFIG ──
|
|
727
|
+
/** @type {Config} */
|
|
728
|
+
const config = {
|
|
729
|
+
version: 2,
|
|
730
|
+
seed: SEED,
|
|
731
|
+
datasetStart: DATASET_START,
|
|
732
|
+
datasetEnd: DATASET_END,
|
|
733
|
+
avgEventsPerUserPerDay: EVENTS_PER_DAY,
|
|
734
|
+
numUsers: NUM_USERS,
|
|
735
|
+
format: "json",
|
|
736
|
+
gzip: true,
|
|
737
|
+
credentials: {
|
|
738
|
+
token,
|
|
739
|
+
},
|
|
740
|
+
switches: {
|
|
741
|
+
hasSessionIds: true,
|
|
742
|
+
alsoInferFunnels: false,
|
|
743
|
+
hasLocation: true,
|
|
744
|
+
hasAndroidDevices: false,
|
|
745
|
+
hasIOSDevices: true,
|
|
746
|
+
hasDesktopDevices: true,
|
|
747
|
+
hasBrowser: true,
|
|
748
|
+
hasCampaigns: false,
|
|
749
|
+
isAnonymous: false,
|
|
750
|
+
hasAdSpend: false,
|
|
751
|
+
hasAvatar: true,
|
|
752
|
+
},
|
|
753
|
+
identity: {
|
|
754
|
+
avgDevicePerUser: 2,
|
|
755
|
+
},
|
|
756
|
+
|
|
757
|
+
concurrency: 1,
|
|
758
|
+
writeToDisk: false,
|
|
759
|
+
|
|
760
|
+
soup: "growth",
|
|
761
|
+
|
|
762
|
+
scdProps: {
|
|
763
|
+
trading_tier: {
|
|
764
|
+
values: ["Standard", "Pro"],
|
|
765
|
+
frequency: "month",
|
|
766
|
+
timing: "fuzzy",
|
|
767
|
+
max: 4
|
|
768
|
+
}
|
|
769
|
+
},
|
|
770
|
+
|
|
771
|
+
funnels: [
|
|
772
|
+
{
|
|
773
|
+
sequence: ["wallet connected", "kyc started", "deposit"],
|
|
774
|
+
isFirstFunnel: true,
|
|
775
|
+
conversionRate: 60,
|
|
776
|
+
timeToConvert: 1,
|
|
777
|
+
},
|
|
778
|
+
{
|
|
779
|
+
sequence: ["deposit", "swap", "withdrawal"],
|
|
780
|
+
conversionRate: 70,
|
|
781
|
+
timeToConvert: 2,
|
|
782
|
+
weight: 5,
|
|
783
|
+
},
|
|
784
|
+
{
|
|
785
|
+
sequence: ["swap", "stake", "claim airdrop"],
|
|
786
|
+
conversionRate: 35,
|
|
787
|
+
timeToConvert: 7,
|
|
788
|
+
weight: 3,
|
|
789
|
+
},
|
|
790
|
+
],
|
|
791
|
+
|
|
792
|
+
events: [
|
|
793
|
+
{
|
|
794
|
+
event: "wallet connected",
|
|
795
|
+
weight: 1,
|
|
796
|
+
isFirstEvent: true,
|
|
797
|
+
isAuthEvent: true,
|
|
798
|
+
properties: {
|
|
799
|
+
wallet_type: ["MetaMask", "Phantom", "Coinbase Wallet", "Rainbow", "WalletConnect"],
|
|
800
|
+
chain: ["Ethereum", "Solana", "Base", "Arbitrum", "Polygon"],
|
|
801
|
+
}
|
|
802
|
+
},
|
|
803
|
+
{
|
|
804
|
+
event: "kyc started",
|
|
805
|
+
weight: 2,
|
|
806
|
+
properties: {
|
|
807
|
+
document_type: ["passport", "drivers_license", "national_id"],
|
|
808
|
+
verification_method: ["selfie", "video", "document_scan"],
|
|
809
|
+
}
|
|
810
|
+
},
|
|
811
|
+
{
|
|
812
|
+
event: "kyc completed",
|
|
813
|
+
weight: 1,
|
|
814
|
+
properties: {
|
|
815
|
+
document_type: ["passport", "drivers_license", "national_id"],
|
|
816
|
+
verification_method: ["selfie", "video", "document_scan"],
|
|
817
|
+
}
|
|
818
|
+
},
|
|
819
|
+
{
|
|
820
|
+
event: "deposit",
|
|
821
|
+
weight: 5,
|
|
822
|
+
isStrictEvent: false,
|
|
823
|
+
properties: {
|
|
824
|
+
token: ["ETH", "USDC", "USDT", "SOL", "BTC"],
|
|
825
|
+
deposit_amount_usd: u.weighNumRange(10, 5000, 0.3, 500),
|
|
826
|
+
chain: ["Ethereum", "Solana", "Base", "Arbitrum", "Polygon"],
|
|
827
|
+
deposit_method: ["wallet_transfer", "bridge", "on_ramp", "exchange_transfer"],
|
|
828
|
+
}
|
|
829
|
+
},
|
|
830
|
+
{
|
|
831
|
+
event: "withdrawal",
|
|
832
|
+
weight: 3,
|
|
833
|
+
isStrictEvent: false,
|
|
834
|
+
properties: {
|
|
835
|
+
token: ["ETH", "USDC", "USDT", "SOL", "BTC"],
|
|
836
|
+
amount_usd: u.weighNumRange(10, 5000, 0.3, 400),
|
|
837
|
+
chain: ["Ethereum", "Solana", "Base", "Arbitrum", "Polygon"],
|
|
838
|
+
withdrawal_method: ["wallet_transfer", "bridge", "off_ramp", "exchange_transfer"],
|
|
839
|
+
}
|
|
840
|
+
},
|
|
841
|
+
{
|
|
842
|
+
event: "swap",
|
|
843
|
+
weight: 10,
|
|
844
|
+
isStrictEvent: false,
|
|
845
|
+
properties: {
|
|
846
|
+
token_pair: TOKEN_PAIRS,
|
|
847
|
+
trade_amount_usd: u.weighNumRange(10, 10000, 0.3, 200),
|
|
848
|
+
gas_fee_usd: u.weighNumRange(0.5, 30, 0.3, 5),
|
|
849
|
+
swap_status: ["completed", "completed", "completed", "completed", "pending", "failed"],
|
|
850
|
+
slippage_pct: u.weighNumRange(0.01, 5, 0.3, 0.5),
|
|
851
|
+
maker_fee_pct: [0.30],
|
|
852
|
+
}
|
|
853
|
+
},
|
|
854
|
+
{
|
|
855
|
+
event: "stake",
|
|
856
|
+
weight: 4,
|
|
857
|
+
isStrictEvent: false,
|
|
858
|
+
properties: {
|
|
859
|
+
token: ["ETH", "SOL", "MATIC", "AVAX", "ATOM", "DOT"],
|
|
860
|
+
amount_usd: u.weighNumRange(50, 10000, 0.3, 500),
|
|
861
|
+
apy_pct: u.weighNumRange(2, 25, 0.5, 8),
|
|
862
|
+
lock_period_days: [7, 14, 30, 60, 90, 180],
|
|
863
|
+
}
|
|
864
|
+
},
|
|
865
|
+
{
|
|
866
|
+
event: "unstake",
|
|
867
|
+
weight: 2,
|
|
868
|
+
properties: {
|
|
869
|
+
token: ["ETH", "SOL", "MATIC", "AVAX", "ATOM", "DOT"],
|
|
870
|
+
amount_usd: u.weighNumRange(50, 10000, 0.3, 500),
|
|
871
|
+
apy_pct: u.weighNumRange(2, 25, 0.5, 8),
|
|
872
|
+
lock_period_days: [7, 14, 30, 60, 90, 180],
|
|
873
|
+
}
|
|
874
|
+
},
|
|
875
|
+
{
|
|
876
|
+
event: "claim airdrop",
|
|
877
|
+
weight: 2,
|
|
878
|
+
isStrictEvent: false,
|
|
879
|
+
properties: {
|
|
880
|
+
airdrop_name: AIRDROP_NAMES,
|
|
881
|
+
token_amount: u.weighNumRange(10, 10000, 0.3, 500),
|
|
882
|
+
airdrop_value_usd: u.weighNumRange(5, 2000, 0.3, 100),
|
|
883
|
+
}
|
|
884
|
+
},
|
|
885
|
+
{
|
|
886
|
+
event: "nft mint",
|
|
887
|
+
weight: 2,
|
|
888
|
+
properties: {
|
|
889
|
+
collection: NFT_COLLECTIONS,
|
|
890
|
+
mint_price_usd: u.weighNumRange(10, 2000, 0.3, 150),
|
|
891
|
+
chain: ["Ethereum", "Solana", "Base", "Polygon"],
|
|
892
|
+
}
|
|
893
|
+
},
|
|
894
|
+
{
|
|
895
|
+
event: "portfolio viewed",
|
|
896
|
+
weight: 8,
|
|
897
|
+
properties: {
|
|
898
|
+
total_value_usd: u.weighNumRange(100, 100000, 0.3, 5000),
|
|
899
|
+
}
|
|
900
|
+
},
|
|
901
|
+
{
|
|
902
|
+
event: "price alert set",
|
|
903
|
+
weight: 3,
|
|
904
|
+
properties: {
|
|
905
|
+
token: ["ETH", "BTC", "SOL", "MATIC", "AVAX", "LINK", "UNI", "AAVE"],
|
|
906
|
+
alert_type: ["above", "below", "percent_change"],
|
|
907
|
+
}
|
|
908
|
+
},
|
|
909
|
+
{
|
|
910
|
+
event: "referral sent",
|
|
911
|
+
weight: 2,
|
|
912
|
+
properties: {
|
|
913
|
+
referral_method: ["link", "email", "twitter", "discord", "telegram"],
|
|
914
|
+
}
|
|
915
|
+
},
|
|
916
|
+
{
|
|
917
|
+
event: "limit order placed",
|
|
918
|
+
weight: 3,
|
|
919
|
+
properties: {
|
|
920
|
+
token_pair: TOKEN_PAIRS,
|
|
921
|
+
order_type: ["limit_buy", "limit_sell", "stop_loss", "take_profit"],
|
|
922
|
+
price_usd: u.weighNumRange(10, 50000, 0.3, 1000),
|
|
923
|
+
}
|
|
924
|
+
},
|
|
925
|
+
{
|
|
926
|
+
event: "transfer",
|
|
927
|
+
weight: 3,
|
|
928
|
+
properties: {
|
|
929
|
+
token: ["ETH", "USDC", "USDT", "SOL", "BTC"],
|
|
930
|
+
amount_usd: u.weighNumRange(10, 5000, 0.3, 300),
|
|
931
|
+
gas_fee_usd: u.weighNumRange(0.5, 30, 0.3, 5),
|
|
932
|
+
destination_type: ["wallet", "exchange", "contract", "ens_name"],
|
|
933
|
+
}
|
|
934
|
+
},
|
|
935
|
+
],
|
|
936
|
+
|
|
937
|
+
superProps: {
|
|
938
|
+
trading_tier: ["Standard", "Standard", "Standard", "Pro"],
|
|
939
|
+
preferred_chain: ["Ethereum", "Solana", "Base", "Arbitrum", "Polygon"],
|
|
940
|
+
wallet_type: ["MetaMask", "Phantom", "Coinbase Wallet", "Rainbow", "WalletConnect"],
|
|
941
|
+
},
|
|
942
|
+
|
|
943
|
+
userProps: {
|
|
944
|
+
trading_tier: ["Standard", "Standard", "Standard", "Pro"],
|
|
945
|
+
preferred_chain: ["Ethereum", "Solana", "Base", "Arbitrum", "Polygon"],
|
|
946
|
+
wallet_type: ["MetaMask", "Phantom", "Coinbase Wallet", "Rainbow", "WalletConnect"],
|
|
947
|
+
total_trade_volume: u.weighNumRange(0, 500000, 0.3, 5000),
|
|
948
|
+
portfolio_value: u.weighNumRange(0, 200000, 0.3, 3000),
|
|
949
|
+
kyc_status: ["pending"],
|
|
950
|
+
},
|
|
951
|
+
|
|
952
|
+
groupKeys: [],
|
|
953
|
+
groupProps: {},
|
|
954
|
+
lookupTables: [],
|
|
955
|
+
|
|
956
|
+
hook(record, type, meta) {
|
|
957
|
+
if (type === "funnel-post") return handleFunnelPostHooks(record, meta);
|
|
958
|
+
if (type === "everything") return handleEverythingHooks(record, meta);
|
|
959
|
+
return record;
|
|
960
|
+
}
|
|
961
|
+
};
|
|
962
|
+
|
|
963
|
+
export default config;
|
|
964
|
+
|
|
965
|
+
// ── STORIES ──
|
|
966
|
+
/*
|
|
967
|
+
* Derivation notes (2K reduced run iter-crypto-1 vs organic counterfactual
|
|
968
|
+
* iter-crypto-0, hook overridden to identity; full fidelity = 10K users,
|
|
969
|
+
* expected populations ≈ 5x the 2K numbers; scale guards at ~50% of that):
|
|
970
|
+
*
|
|
971
|
+
* - h1: whale/non avg trade amount 48.4x (organic 0.99); top-5% volume
|
|
972
|
+
* share 0.747 (organic 0.104). Whale cohort = hex uuid charCodeAt(0)
|
|
973
|
+
* % 50 === 0 → chars '2','d' → ~13.2% of users.
|
|
974
|
+
* - h2: in-window (d35-38 UTC) avg gas 138.3 vs 13.7 out → 10.1x
|
|
975
|
+
* (organic 1.01); in-window swap fail share 0.516 vs 0.175 baseline
|
|
976
|
+
* (organic in-window 0.158).
|
|
977
|
+
* - h3: post-d50 MOON share 0.577; pre-d50 exactly 0 both runs (flips
|
|
978
|
+
* are post-launch only, clones offset forward — structural zero).
|
|
979
|
+
* - h4: bot claimers post/pre 1.54 vs non-bot 25.01 → contrast 0.062
|
|
980
|
+
* (organic contrast 1.02).
|
|
981
|
+
* - h5: kyc/non avg deposit 8429/2503 = 3.37x (organic 1.02); swaps/user
|
|
982
|
+
* 224.7/29.5 = 7.63x (organic activity confound 2.02x).
|
|
983
|
+
* - h6: early-staker/non post-d60 events/user 152.8/29.0 = 5.27x
|
|
984
|
+
* (organic 1.10x). Binary D60 presence barely moves (0.96 vs 1.0) —
|
|
985
|
+
* volume is the read, not retention curves.
|
|
986
|
+
* - h7: fees exact 0.05/0.30 (organic 0.30 both — fee split fully
|
|
987
|
+
* hook-made); Pro/Std swaps/user 469/90 = 5.21x. Organic tier cells
|
|
988
|
+
* are meaningless (engine stamps superProps per-event at random;
|
|
989
|
+
* hook pins to profile).
|
|
990
|
+
* - h9: emulator timeToConvert @6h window Pro/Std median ratio 0.745
|
|
991
|
+
* (organic 1.01), stable across 1h-24h windows; converters 156/260
|
|
992
|
+
* born-in at 2K.
|
|
993
|
+
* - h10: sweet(8-20 swaps)/low stake amount 1.320 (organic 0.95 —
|
|
994
|
+
* slightly inverse); over/sweet avg portfolio total_value_usd 0.501
|
|
995
|
+
* (organic 0.999) — the 0.5 halving knob reads exactly.
|
|
996
|
+
* - h11: born-in early(≥2 stakes in 10d)/non post-d40 ratio 1.314
|
|
997
|
+
* (organic 0.521 — INVERSE organically; hook must flip the sign).
|
|
998
|
+
* Cohort defined on post-H6 data (H6 eats late-born early stakes).
|
|
999
|
+
* - identity: uid_share 1.0, device_share 0.9992, devices/user 2.064
|
|
1000
|
+
* (avgDevicePerUser: 2).
|
|
1001
|
+
*/
|
|
1002
|
+
|
|
1003
|
+
const EV = `read_json_auto('{{PREFIX}}-EVENTS*.json', sample_size=-1, union_by_name=true)`;
|
|
1004
|
+
|
|
1005
|
+
const bandVerdict = (x, nailed, strong, detail, inverse = () => false) => {
|
|
1006
|
+
if (x == null || Number.isNaN(Number(x))) return { verdict: "NONE", detail: `${detail} — metric missing` };
|
|
1007
|
+
const v = Number(x);
|
|
1008
|
+
if (inverse(v)) return { verdict: "INVERSE", detail };
|
|
1009
|
+
if (v >= nailed[0] && v <= nailed[1]) return { verdict: "NAILED", detail };
|
|
1010
|
+
if (v >= strong[0] && v <= strong[1]) return { verdict: "STRONG", detail };
|
|
1011
|
+
return { verdict: "WEAK", detail };
|
|
1012
|
+
};
|
|
1013
|
+
|
|
1014
|
+
const guarded = (ok, detail, inner) => ok ? inner() : { verdict: "WEAK", detail: `${detail} — cohort below scale guard (expected at reduced scale)` };
|
|
1015
|
+
|
|
1016
|
+
const worstOf = (...verdicts) => {
|
|
1017
|
+
const order = ["INVERSE", "NONE", "WEAK", "STRONG", "NAILED"];
|
|
1018
|
+
const worst = order.find(o => verdicts.some(v => v.verdict === o)) || "NONE";
|
|
1019
|
+
return { verdict: worst, detail: verdicts.map(v => v.detail).join("; ") };
|
|
1020
|
+
};
|
|
1021
|
+
|
|
1022
|
+
const cellsOf = (rows, key) => Object.fromEntries((rows || []).map(r => [r[key], r]));
|
|
1023
|
+
|
|
1024
|
+
export const stories = [
|
|
1025
|
+
{
|
|
1026
|
+
id: "crypto-h1-whale-wallets",
|
|
1027
|
+
hook: "H1",
|
|
1028
|
+
archetype: "cohort-count-scale",
|
|
1029
|
+
narrative: "~13% of wallets (deterministic uuid hash) trade at 50x amounts: whale/non avg trade ratio ~48x and the top 5% of traders carry ~75% of total volume (organic: ratio 1.0, share 0.10).",
|
|
1030
|
+
assertions: [
|
|
1031
|
+
{
|
|
1032
|
+
breakdown: {
|
|
1033
|
+
type: "duckdb",
|
|
1034
|
+
sql: `WITH pu AS (
|
|
1035
|
+
SELECT user_id::VARCHAR AS uid,
|
|
1036
|
+
(ascii(substr(user_id::VARCHAR, 1, 1)) % 50 = 0) AS whale,
|
|
1037
|
+
AVG(trade_amount_usd) AS avg_amt
|
|
1038
|
+
FROM ${EV} WHERE event = 'swap' AND user_id IS NOT NULL GROUP BY 1, 2
|
|
1039
|
+
)
|
|
1040
|
+
SELECT CASE WHEN whale THEN 'whale' ELSE 'non' END AS cohort, COUNT(*) AS users, AVG(avg_amt) AS amt
|
|
1041
|
+
FROM pu GROUP BY 1`,
|
|
1042
|
+
},
|
|
1043
|
+
// Custom assert (spec P3.1): per-cohort scale guards (whale/non user
|
|
1044
|
+
// floors) + custom INVERSE threshold (≤5 ≈ organic 1.0x, not the
|
|
1045
|
+
// ratio-neutral 1) — the declarative expect grammar (single metric,
|
|
1046
|
+
// one op) has neither guards nor custom inverse.
|
|
1047
|
+
assert: (rows) => {
|
|
1048
|
+
const c = cellsOf(rows, "cohort");
|
|
1049
|
+
if (!c.whale || !c.non) return { verdict: "NONE", detail: "whale/non cohorts missing" };
|
|
1050
|
+
const ratio = Number(c.whale.amt) / Number(c.non.amt);
|
|
1051
|
+
return guarded(Number(c.whale.users) >= 650 && Number(c.non.users) >= 4200,
|
|
1052
|
+
`whale ${c.whale.users}u / non ${c.non.users}u`,
|
|
1053
|
+
() => bandVerdict(ratio, [40, 57], [30, 70],
|
|
1054
|
+
`whale/non avg trade amount ${ratio.toFixed(1)}x (expect ~48x, organic 1.0x)`,
|
|
1055
|
+
v => v <= 5));
|
|
1056
|
+
},
|
|
1057
|
+
},
|
|
1058
|
+
{
|
|
1059
|
+
breakdown: {
|
|
1060
|
+
type: "duckdb",
|
|
1061
|
+
sql: `WITH pu AS (
|
|
1062
|
+
SELECT user_id::VARCHAR AS uid, SUM(trade_amount_usd) AS vol
|
|
1063
|
+
FROM ${EV} WHERE event = 'swap' AND user_id IS NOT NULL GROUP BY 1
|
|
1064
|
+
), ranked AS (
|
|
1065
|
+
SELECT vol, ROW_NUMBER() OVER (ORDER BY vol DESC) AS rn, COUNT(*) OVER () AS n, SUM(vol) OVER () AS tot FROM pu
|
|
1066
|
+
)
|
|
1067
|
+
SELECT SUM(vol)::DOUBLE / MAX(tot) AS top5_share, MAX(n)::BIGINT AS traders FROM ranked WHERE rn <= CEIL(n * 0.05)`,
|
|
1068
|
+
},
|
|
1069
|
+
// Custom assert (spec P3.1): trader-count scale guard + custom
|
|
1070
|
+
// INVERSE (share ≤0.20 ≈ organic 0.10) — inexpressible in the
|
|
1071
|
+
// single-metric/single-op expect grammar.
|
|
1072
|
+
assert: (rows) => {
|
|
1073
|
+
const r = rows?.[0];
|
|
1074
|
+
if (!r) return { verdict: "NONE", detail: "top5 query returned no rows" };
|
|
1075
|
+
return guarded(Number(r.traders) >= 5000, `${r.traders} traders`,
|
|
1076
|
+
() => bandVerdict(Number(r.top5_share), [0.68, 0.80], [0.60, 0.85],
|
|
1077
|
+
`top-5% volume share ${Number(r.top5_share).toFixed(3)} (expect ~0.75, organic 0.10)`,
|
|
1078
|
+
v => v <= 0.20));
|
|
1079
|
+
},
|
|
1080
|
+
},
|
|
1081
|
+
],
|
|
1082
|
+
},
|
|
1083
|
+
{
|
|
1084
|
+
id: "crypto-h2-gas-spike",
|
|
1085
|
+
hook: "H2",
|
|
1086
|
+
archetype: "temporal-inflection",
|
|
1087
|
+
narrative: "Days 35-37 network congestion: swap+transfer gas fees ~10x baseline and in-window swap failure share jumps to ~0.52 from ~0.17 (organic: 1.0x, 0.16).",
|
|
1088
|
+
assertions: [
|
|
1089
|
+
{
|
|
1090
|
+
breakdown: {
|
|
1091
|
+
type: "duckdb",
|
|
1092
|
+
sql: `SELECT
|
|
1093
|
+
CASE WHEN time::TIMESTAMP > TIMESTAMP '2026-02-05 00:00:00' AND time::TIMESTAMP < TIMESTAMP '2026-02-08 00:00:00' THEN 'in' ELSE 'out' END AS win,
|
|
1094
|
+
COUNT(*) FILTER (WHERE event = 'swap') AS swaps,
|
|
1095
|
+
AVG(gas_fee_usd) AS gas,
|
|
1096
|
+
AVG((swap_status = 'failed')::INT) FILTER (WHERE event = 'swap') AS fail_share
|
|
1097
|
+
FROM ${EV} WHERE event IN ('swap', 'transfer') GROUP BY 1`,
|
|
1098
|
+
},
|
|
1099
|
+
// Custom assert (spec P3.1): two legs (gas ratio + fail share) under
|
|
1100
|
+
// one scale guard, combined via worstOf — the expect grammar carries
|
|
1101
|
+
// a single metric and cannot compose legs.
|
|
1102
|
+
assert: (rows) => {
|
|
1103
|
+
const c = cellsOf(rows, "win");
|
|
1104
|
+
if (!c.in || !c.out) return { verdict: "NONE", detail: "in/out window cells missing" };
|
|
1105
|
+
return guarded(Number(c.in.swaps) >= 29000, `${c.in.swaps} in-window swaps`, () => {
|
|
1106
|
+
const gasRatio = Number(c.in.gas) / Number(c.out.gas);
|
|
1107
|
+
const legGas = bandVerdict(gasRatio, [8.5, 11.5], [7, 13],
|
|
1108
|
+
`in/out gas ratio ${gasRatio.toFixed(2)} (expect ~10.1, organic 1.0)`, v => v <= 1.5);
|
|
1109
|
+
const legFail = bandVerdict(Number(c.in.fail_share), [0.45, 0.57], [0.40, 0.63],
|
|
1110
|
+
`in-window fail share ${Number(c.in.fail_share).toFixed(3)} (expect ~0.52, organic 0.16)`, v => v <= 0.22);
|
|
1111
|
+
return worstOf(legGas, legFail);
|
|
1112
|
+
});
|
|
1113
|
+
},
|
|
1114
|
+
},
|
|
1115
|
+
],
|
|
1116
|
+
},
|
|
1117
|
+
{
|
|
1118
|
+
id: "crypto-h3-token-launch",
|
|
1119
|
+
hook: "H3",
|
|
1120
|
+
archetype: "temporal-inflection",
|
|
1121
|
+
narrative: "MOON token launches day 50: zero MOON swaps before (structural — flips are post-launch, clones offset forward), ~58% of swap volume after (25% flip compounded by 4x cloning).",
|
|
1122
|
+
assertions: [
|
|
1123
|
+
{
|
|
1124
|
+
breakdown: {
|
|
1125
|
+
type: "duckdb",
|
|
1126
|
+
sql: `SELECT CASE WHEN time::TIMESTAMP > TIMESTAMP '2026-02-20 00:00:00' THEN 'post' ELSE 'pre' END AS win,
|
|
1127
|
+
COUNT(*) AS swaps, AVG((token_pair LIKE '%MOON%')::INT) AS moon_share
|
|
1128
|
+
FROM ${EV} WHERE event = 'swap' GROUP BY 1`,
|
|
1129
|
+
},
|
|
1130
|
+
// Custom assert (spec P3.1): structural-zero branch (pre-launch MOON
|
|
1131
|
+
// share must be EXACTLY ~0 → NAILED; >0.05 → INVERSE) plus a post-
|
|
1132
|
+
// launch band leg via worstOf — no declarative op expresses
|
|
1133
|
+
// structural exactness or multi-leg combination.
|
|
1134
|
+
assert: (rows) => {
|
|
1135
|
+
const c = cellsOf(rows, "win");
|
|
1136
|
+
if (!c.post || !c.pre) return { verdict: "NONE", detail: "pre/post windows missing" };
|
|
1137
|
+
return guarded(Number(c.post.swaps) >= 550000 && Number(c.pre.swaps) >= 340000,
|
|
1138
|
+
`pre ${c.pre.swaps} / post ${c.post.swaps} swaps`, () => {
|
|
1139
|
+
const legPost = bandVerdict(Number(c.post.moon_share), [0.50, 0.65], [0.42, 0.70],
|
|
1140
|
+
`post-d50 MOON share ${Number(c.post.moon_share).toFixed(3)} (expect ~0.58)`, v => v <= 0.05);
|
|
1141
|
+
const preShare = Number(c.pre.moon_share);
|
|
1142
|
+
const legPre = preShare <= 0.001
|
|
1143
|
+
? { verdict: "NAILED", detail: `pre-d50 MOON share ${preShare} (structural zero)` }
|
|
1144
|
+
: preShare > 0.05
|
|
1145
|
+
? { verdict: "INVERSE", detail: `pre-d50 MOON share ${preShare} — MOON exists before launch` }
|
|
1146
|
+
: { verdict: "WEAK", detail: `pre-d50 MOON share ${preShare} — nonzero` };
|
|
1147
|
+
return worstOf(legPost, legPre);
|
|
1148
|
+
});
|
|
1149
|
+
},
|
|
1150
|
+
},
|
|
1151
|
+
],
|
|
1152
|
+
},
|
|
1153
|
+
{
|
|
1154
|
+
id: "crypto-h4-airdrop-bots",
|
|
1155
|
+
hook: "H4",
|
|
1156
|
+
archetype: "retention-divergence",
|
|
1157
|
+
narrative: "~14% of users are airdrop-farming bots (uuid hash): after first claim they lose 95% of activity. Bot claimers' post/pre-claim ratio ~1.5 vs ~25 for non-bot claimers — contrast ~0.06 (organic 1.0).",
|
|
1158
|
+
assertions: [
|
|
1159
|
+
{
|
|
1160
|
+
breakdown: {
|
|
1161
|
+
type: "duckdb",
|
|
1162
|
+
sql: `WITH claims AS (
|
|
1163
|
+
SELECT user_id::VARCHAR AS uid, MIN(time::TIMESTAMP) AS t0
|
|
1164
|
+
FROM ${EV} WHERE event = 'claim airdrop' AND user_id IS NOT NULL GROUP BY 1
|
|
1165
|
+
), pu AS (
|
|
1166
|
+
SELECT e.user_id::VARCHAR AS uid,
|
|
1167
|
+
(ascii(substr(e.user_id::VARCHAR, 2, 1)) % 25 = 0) AS bot,
|
|
1168
|
+
COUNT(*) FILTER (WHERE e.time::TIMESTAMP <= c.t0) AS pre_ev,
|
|
1169
|
+
COUNT(*) FILTER (WHERE e.time::TIMESTAMP > c.t0) AS post_ev
|
|
1170
|
+
FROM ${EV} e JOIN claims c ON e.user_id::VARCHAR = c.uid GROUP BY 1, 2
|
|
1171
|
+
)
|
|
1172
|
+
SELECT CASE WHEN bot THEN 'bot' ELSE 'non' END AS cohort, COUNT(*) AS users,
|
|
1173
|
+
AVG(post_ev::DOUBLE / GREATEST(pre_ev, 1)) AS post_pre
|
|
1174
|
+
FROM pu GROUP BY 1`,
|
|
1175
|
+
},
|
|
1176
|
+
// Custom assert (spec P3.1): contrast-of-ratios metric + per-cohort
|
|
1177
|
+
// scale guards + custom INVERSE (≥0.6 toward organic 1.0) — beyond
|
|
1178
|
+
// the single-metric expect grammar.
|
|
1179
|
+
assert: (rows) => {
|
|
1180
|
+
const c = cellsOf(rows, "cohort");
|
|
1181
|
+
if (!c.bot || !c.non) return { verdict: "NONE", detail: "bot/non claimer cohorts missing" };
|
|
1182
|
+
const contrast = Number(c.bot.post_pre) / Number(c.non.post_pre);
|
|
1183
|
+
return guarded(Number(c.bot.users) >= 500 && Number(c.non.users) >= 3200,
|
|
1184
|
+
`bot ${c.bot.users}u / non ${c.non.users}u`,
|
|
1185
|
+
() => bandVerdict(contrast, [0.03, 0.10], [0.02, 0.15],
|
|
1186
|
+
`bot/non post-pre contrast ${contrast.toFixed(3)} (expect ~0.06, organic 1.0)`,
|
|
1187
|
+
v => v >= 0.6));
|
|
1188
|
+
},
|
|
1189
|
+
},
|
|
1190
|
+
],
|
|
1191
|
+
},
|
|
1192
|
+
{
|
|
1193
|
+
id: "crypto-h5-kyc-completion",
|
|
1194
|
+
hook: "H5",
|
|
1195
|
+
archetype: "cohort-prop-scale",
|
|
1196
|
+
narrative: "KYC completers deposit ~3.4x more per deposit (4x knob diluted by whale inflation of the non-KYC mean) and swap ~7.6x more per user (7x cloning over a 2.0x organic activity confound).",
|
|
1197
|
+
assertions: [
|
|
1198
|
+
{
|
|
1199
|
+
breakdown: {
|
|
1200
|
+
type: "duckdb",
|
|
1201
|
+
sql: `WITH kyc AS (
|
|
1202
|
+
SELECT user_id::VARCHAR AS uid, MIN(time::TIMESTAMP) AS kt
|
|
1203
|
+
FROM ${EV} WHERE event = 'kyc completed' AND user_id IS NOT NULL GROUP BY 1
|
|
1204
|
+
), pu AS (
|
|
1205
|
+
SELECT e.user_id::VARCHAR AS uid, (k.uid IS NOT NULL) AS has_kyc,
|
|
1206
|
+
AVG(e.deposit_amount_usd) FILTER (WHERE e.event = 'deposit' AND (k.uid IS NULL OR e.time::TIMESTAMP > k.kt)) AS dep,
|
|
1207
|
+
COUNT(*) FILTER (WHERE e.event = 'swap') AS swaps
|
|
1208
|
+
FROM ${EV} e LEFT JOIN kyc k ON e.user_id::VARCHAR = k.uid
|
|
1209
|
+
WHERE e.user_id IS NOT NULL GROUP BY 1, 2
|
|
1210
|
+
)
|
|
1211
|
+
SELECT CASE WHEN has_kyc THEN 'kyc' ELSE 'non' END AS cohort, COUNT(*) AS users,
|
|
1212
|
+
AVG(dep) AS avg_deposit, AVG(swaps) AS swaps_per_user
|
|
1213
|
+
FROM pu GROUP BY 1`,
|
|
1214
|
+
},
|
|
1215
|
+
// Custom assert (spec P3.1): two ratio legs (deposit + swaps/user)
|
|
1216
|
+
// under one scale guard via worstOf, each with its own custom
|
|
1217
|
+
// INVERSE (organic baselines 1.0x and 2.0x differ) — the expect
|
|
1218
|
+
// grammar cannot compose legs or carry non-neutral inverses.
|
|
1219
|
+
assert: (rows) => {
|
|
1220
|
+
const c = cellsOf(rows, "cohort");
|
|
1221
|
+
if (!c.kyc || !c.non) return { verdict: "NONE", detail: "kyc/non cohorts missing" };
|
|
1222
|
+
return guarded(Number(c.kyc.users) >= 3800 && Number(c.non.users) >= 1100,
|
|
1223
|
+
`kyc ${c.kyc.users}u / non ${c.non.users}u`, () => {
|
|
1224
|
+
const depRatio = Number(c.kyc.avg_deposit) / Number(c.non.avg_deposit);
|
|
1225
|
+
const swapRatio = Number(c.kyc.swaps_per_user) / Number(c.non.swaps_per_user);
|
|
1226
|
+
const legDep = bandVerdict(depRatio, [2.9, 3.9], [2.4, 4.5],
|
|
1227
|
+
`kyc/non avg deposit ${depRatio.toFixed(2)}x (expect ~3.4x, organic 1.0x)`, v => v <= 1.3);
|
|
1228
|
+
const legSwap = bandVerdict(swapRatio, [6.3, 9.0], [5.0, 10.5],
|
|
1229
|
+
`kyc/non swaps/user ${swapRatio.toFixed(2)}x (expect ~7.6x, organic 2.0x)`, v => v <= 2.5);
|
|
1230
|
+
return worstOf(legDep, legSwap);
|
|
1231
|
+
});
|
|
1232
|
+
},
|
|
1233
|
+
},
|
|
1234
|
+
],
|
|
1235
|
+
},
|
|
1236
|
+
{
|
|
1237
|
+
id: "crypto-h6-stake-to-retain",
|
|
1238
|
+
hook: "H6",
|
|
1239
|
+
archetype: "retention-divergence",
|
|
1240
|
+
narrative: "Users who stake in the first 14 calendar days keep ~5.3x the post-d60 event volume of non-stakers (organic 1.1x). Non-stakers lose 85% of post-d60 events, first-24h onboarding grace excepted.",
|
|
1241
|
+
assertions: [
|
|
1242
|
+
{
|
|
1243
|
+
breakdown: {
|
|
1244
|
+
type: "duckdb",
|
|
1245
|
+
sql: `WITH pu AS (
|
|
1246
|
+
SELECT user_id::VARCHAR AS uid,
|
|
1247
|
+
BOOL_OR(event = 'stake' AND time::TIMESTAMP < TIMESTAMP '2026-01-15 00:00:00') AS early_staker,
|
|
1248
|
+
COUNT(*) FILTER (WHERE time::TIMESTAMP > TIMESTAMP '2026-03-02 00:00:00') AS post60
|
|
1249
|
+
FROM ${EV} WHERE user_id IS NOT NULL GROUP BY 1
|
|
1250
|
+
)
|
|
1251
|
+
SELECT CASE WHEN early_staker THEN 'staker' ELSE 'non' END AS cohort, COUNT(*) AS users, AVG(post60) AS post60_per_user
|
|
1252
|
+
FROM pu GROUP BY 1`,
|
|
1253
|
+
},
|
|
1254
|
+
// Custom assert (spec P3.1): per-cohort scale guards + custom
|
|
1255
|
+
// INVERSE (≤1.4 — organic baseline is 1.1x, not the ratio-neutral
|
|
1256
|
+
// 1) — neither expressible declaratively.
|
|
1257
|
+
assert: (rows) => {
|
|
1258
|
+
const c = cellsOf(rows, "cohort");
|
|
1259
|
+
if (!c.staker || !c.non) return { verdict: "NONE", detail: "staker/non cohorts missing" };
|
|
1260
|
+
const ratio = Number(c.staker.post60_per_user) / Number(c.non.post60_per_user);
|
|
1261
|
+
return guarded(Number(c.staker.users) >= 2100 && Number(c.non.users) >= 2800,
|
|
1262
|
+
`staker ${c.staker.users}u / non ${c.non.users}u`,
|
|
1263
|
+
() => bandVerdict(ratio, [4.5, 6.2], [3.8, 7.0],
|
|
1264
|
+
`staker/non post-d60 events/user ${ratio.toFixed(2)}x (expect ~5.3x, organic 1.1x)`,
|
|
1265
|
+
v => v <= 1.4));
|
|
1266
|
+
},
|
|
1267
|
+
},
|
|
1268
|
+
],
|
|
1269
|
+
},
|
|
1270
|
+
{
|
|
1271
|
+
id: "crypto-h7-pro-tier-fees",
|
|
1272
|
+
hook: "H7",
|
|
1273
|
+
archetype: "cohort-prop-scale",
|
|
1274
|
+
narrative: "Pro tier pays 0.05 maker fee vs Standard 0.30 (exact — every swap pinned; organic is 0.30 for both) and swaps ~5.2x more per user from 5x cloning.",
|
|
1275
|
+
assertions: [
|
|
1276
|
+
{
|
|
1277
|
+
breakdown: {
|
|
1278
|
+
type: "duckdb",
|
|
1279
|
+
sql: `SELECT trading_tier AS tier, COUNT(DISTINCT user_id::VARCHAR) AS users,
|
|
1280
|
+
AVG(maker_fee_pct) AS fee,
|
|
1281
|
+
COUNT(*)::DOUBLE / COUNT(DISTINCT user_id::VARCHAR) AS swaps_per_user
|
|
1282
|
+
FROM ${EV} WHERE event = 'swap' AND user_id IS NOT NULL GROUP BY 1`,
|
|
1283
|
+
},
|
|
1284
|
+
// Custom assert (spec P3.1): exact-pin ladder (|fee − knob| ≤ 0.005
|
|
1285
|
+
// → NAILED, near-exact → STRONG) + ordering INVERSE (Pro ≥ Standard)
|
|
1286
|
+
// + a second swaps-ratio leg via worstOf — exact-pin grading and
|
|
1287
|
+
// leg composition have no declarative equivalent.
|
|
1288
|
+
assert: (rows) => {
|
|
1289
|
+
const c = cellsOf(rows, "tier");
|
|
1290
|
+
if (!c.Pro || !c.Standard) return { verdict: "NONE", detail: "Pro/Standard tiers missing" };
|
|
1291
|
+
return guarded(Number(c.Pro.users) >= 1200 && Number(c.Standard.users) >= 3600,
|
|
1292
|
+
`Pro ${c.Pro.users}u / Standard ${c.Standard.users}u`, () => {
|
|
1293
|
+
const proFee = Number(c.Pro.fee), stdFee = Number(c.Standard.fee);
|
|
1294
|
+
const legFee = proFee >= stdFee
|
|
1295
|
+
? { verdict: "INVERSE", detail: `Pro fee ${proFee.toFixed(3)} >= Standard ${stdFee.toFixed(3)}` }
|
|
1296
|
+
: Math.abs(proFee - 0.05) <= 0.005 && Math.abs(stdFee - 0.30) <= 0.005
|
|
1297
|
+
? { verdict: "NAILED", detail: `fees exact: Pro ${proFee.toFixed(3)} / Standard ${stdFee.toFixed(3)}` }
|
|
1298
|
+
: proFee <= 0.10 && stdFee >= 0.25
|
|
1299
|
+
? { verdict: "STRONG", detail: `fees near-exact: Pro ${proFee.toFixed(3)} / Standard ${stdFee.toFixed(3)}` }
|
|
1300
|
+
: { verdict: "WEAK", detail: `fees off: Pro ${proFee.toFixed(3)} / Standard ${stdFee.toFixed(3)}` };
|
|
1301
|
+
const swapRatio = Number(c.Pro.swaps_per_user) / Number(c.Standard.swaps_per_user);
|
|
1302
|
+
const legSwaps = bandVerdict(swapRatio, [4.4, 6.1], [3.7, 7.0],
|
|
1303
|
+
`Pro/Std swaps/user ${swapRatio.toFixed(2)}x (expect ~5.2x)`, v => v <= 1.2);
|
|
1304
|
+
return worstOf(legFee, legSwaps);
|
|
1305
|
+
});
|
|
1306
|
+
},
|
|
1307
|
+
},
|
|
1308
|
+
],
|
|
1309
|
+
},
|
|
1310
|
+
{
|
|
1311
|
+
id: "crypto-h8-rugpull-aftermath",
|
|
1312
|
+
hook: "H8",
|
|
1313
|
+
archetype: "retention-divergence",
|
|
1314
|
+
narrative: "SCAM token holders (majority cohort — 2%/swap flip catches ~59% of eventful users) collapse after day 70: post/pre-d70 per-user ratio ~0.21 vs ~1.25 for non-holders, contrast ~0.17 (no SCAM pairs exist organically).",
|
|
1315
|
+
assertions: [
|
|
1316
|
+
{
|
|
1317
|
+
breakdown: {
|
|
1318
|
+
type: "duckdb",
|
|
1319
|
+
sql: `WITH pu AS (
|
|
1320
|
+
SELECT user_id::VARCHAR AS uid,
|
|
1321
|
+
BOOL_OR(event = 'swap' AND token_pair LIKE '%SCAM%') AS scam,
|
|
1322
|
+
COUNT(*) FILTER (WHERE time::TIMESTAMP <= TIMESTAMP '2026-03-12 00:00:00') AS pre_ev,
|
|
1323
|
+
COUNT(*) FILTER (WHERE time::TIMESTAMP > TIMESTAMP '2026-03-12 00:00:00') AS post_ev
|
|
1324
|
+
FROM ${EV} WHERE user_id IS NOT NULL GROUP BY 1
|
|
1325
|
+
)
|
|
1326
|
+
SELECT CASE WHEN scam THEN 'scam' ELSE 'non' END AS cohort, COUNT(*) AS users,
|
|
1327
|
+
AVG(post_ev::DOUBLE / pre_ev) AS post_pre
|
|
1328
|
+
FROM pu WHERE pre_ev >= 5 GROUP BY 1`,
|
|
1329
|
+
},
|
|
1330
|
+
// Custom assert (spec P3.1): contrast-of-ratios + per-cohort scale
|
|
1331
|
+
// guards + custom INVERSE (≥0.75; no organic SCAM cohort exists, so
|
|
1332
|
+
// the neutral point is undefined) — beyond the expect grammar.
|
|
1333
|
+
assert: (rows) => {
|
|
1334
|
+
const c = cellsOf(rows, "cohort");
|
|
1335
|
+
if (!c.scam || !c.non) return { verdict: "NONE", detail: "scam/non cohorts missing" };
|
|
1336
|
+
const contrast = Number(c.scam.post_pre) / Number(c.non.post_pre);
|
|
1337
|
+
return guarded(Number(c.scam.users) >= 2900 && Number(c.non.users) >= 1500,
|
|
1338
|
+
`scam ${c.scam.users}u / non ${c.non.users}u`,
|
|
1339
|
+
() => bandVerdict(contrast, [0.12, 0.23], [0.08, 0.30],
|
|
1340
|
+
`scam/non post-pre-d70 contrast ${contrast.toFixed(3)} (expect ~0.17, organic n/a)`,
|
|
1341
|
+
v => v >= 0.75));
|
|
1342
|
+
},
|
|
1343
|
+
},
|
|
1344
|
+
],
|
|
1345
|
+
},
|
|
1346
|
+
{
|
|
1347
|
+
id: "crypto-h9-onboarding-ttc",
|
|
1348
|
+
hook: "H9",
|
|
1349
|
+
archetype: "funnel-ttc-by-segment",
|
|
1350
|
+
narrative: "Pro tier completes onboarding (wallet connected → kyc started → deposit) faster: median TTC ratio Pro/Standard ~0.74 at a 6h conversion window (0.7/1.3 gap knobs attenuated through median position; organic 1.0). Scoped to onboarding — its isFirstEvent first step anchors greedy evaluation to the exact instance the hook touched.",
|
|
1351
|
+
assertions: [
|
|
1352
|
+
{
|
|
1353
|
+
breakdown: {
|
|
1354
|
+
type: "timeToConvert",
|
|
1355
|
+
steps: ["wallet connected", "kyc started", "deposit"],
|
|
1356
|
+
breakdownByUserProperty: "trading_tier",
|
|
1357
|
+
conversionWindowMs: 6 * 3600 * 1000,
|
|
1358
|
+
},
|
|
1359
|
+
// Custom assert (spec P3.1): ratio of two segments' median TTC +
|
|
1360
|
+
// converter-count scale guards + custom INVERSE (≥0.97 ≈ organic
|
|
1361
|
+
// 1.0) — the expect grammar's single metric can't divide two
|
|
1362
|
+
// breakdown cells under guards.
|
|
1363
|
+
assert: (rows) => {
|
|
1364
|
+
const c = cellsOf(rows, "segment_value");
|
|
1365
|
+
if (!c.Pro || !c.Standard) return { verdict: "NONE", detail: "Pro/Standard TTC segments missing" };
|
|
1366
|
+
const ratio = Number(c.Pro.median_ttc_ms) / Number(c.Standard.median_ttc_ms);
|
|
1367
|
+
return guarded(Number(c.Pro.user_count) >= 70 && Number(c.Standard.user_count) >= 320,
|
|
1368
|
+
`Pro ${c.Pro.user_count} / Standard ${c.Standard.user_count} converters`,
|
|
1369
|
+
() => bandVerdict(ratio, [0.66, 0.82], [0.60, 0.90],
|
|
1370
|
+
`Pro/Std median TTC ratio ${ratio.toFixed(3)} @6h (expect ~0.74, organic 1.0)`,
|
|
1371
|
+
v => v >= 0.97));
|
|
1372
|
+
},
|
|
1373
|
+
},
|
|
1374
|
+
],
|
|
1375
|
+
},
|
|
1376
|
+
{
|
|
1377
|
+
id: "crypto-h10-swap-magic-number",
|
|
1378
|
+
hook: "H10",
|
|
1379
|
+
archetype: "frequency-sweet-spot",
|
|
1380
|
+
narrative: "8-20 swaps is the stake sweet spot: sweet/low avg stake amount ~1.32x (organic 0.95 — slightly inverse). Over-active traders (21+) show halved portfolio values: over/sweet avg total_value_usd ~0.50 (organic 1.00). Views-per-user is NOT the read — activity confound nets the 75% drop to parity.",
|
|
1381
|
+
assertions: [
|
|
1382
|
+
{
|
|
1383
|
+
breakdown: {
|
|
1384
|
+
type: "duckdb",
|
|
1385
|
+
sql: `WITH pu AS (
|
|
1386
|
+
SELECT user_id::VARCHAR AS uid,
|
|
1387
|
+
COUNT(*) FILTER (WHERE event = 'swap') AS swaps,
|
|
1388
|
+
AVG(amount_usd) FILTER (WHERE event = 'stake') AS stake_amt,
|
|
1389
|
+
AVG(total_value_usd) FILTER (WHERE event = 'portfolio viewed') AS pv_val
|
|
1390
|
+
FROM ${EV} WHERE user_id IS NOT NULL GROUP BY 1
|
|
1391
|
+
)
|
|
1392
|
+
SELECT CASE WHEN swaps >= 21 THEN 'over' WHEN swaps >= 8 THEN 'sweet' ELSE 'low' END AS bucket,
|
|
1393
|
+
COUNT(*) AS users, AVG(stake_amt) AS stake_amt, AVG(pv_val) AS pv_value
|
|
1394
|
+
FROM pu GROUP BY 1`,
|
|
1395
|
+
},
|
|
1396
|
+
// Custom assert (spec P3.1): three-bucket scale guard + two legs
|
|
1397
|
+
// (stake ratio, portfolio-value ratio) with opposite-direction
|
|
1398
|
+
// INVERSE thresholds via worstOf — no single-metric op composes
|
|
1399
|
+
// this.
|
|
1400
|
+
assert: (rows) => {
|
|
1401
|
+
const c = cellsOf(rows, "bucket");
|
|
1402
|
+
if (!c.sweet || !c.low || !c.over) return { verdict: "NONE", detail: "swap-count buckets missing" };
|
|
1403
|
+
return guarded(Number(c.sweet.users) >= 480 && Number(c.low.users) >= 500 && Number(c.over.users) >= 4000,
|
|
1404
|
+
`low ${c.low.users}u / sweet ${c.sweet.users}u / over ${c.over.users}u`, () => {
|
|
1405
|
+
const stakeRatio = Number(c.sweet.stake_amt) / Number(c.low.stake_amt);
|
|
1406
|
+
const pvRatio = Number(c.over.pv_value) / Number(c.sweet.pv_value);
|
|
1407
|
+
const legStake = bandVerdict(stakeRatio, [1.20, 1.45], [1.10, 1.60],
|
|
1408
|
+
`sweet/low stake amount ${stakeRatio.toFixed(3)}x (expect ~1.32x, organic 0.95x)`, v => v <= 1.00);
|
|
1409
|
+
const legPv = bandVerdict(pvRatio, [0.44, 0.56], [0.38, 0.65],
|
|
1410
|
+
`over/sweet portfolio value ${pvRatio.toFixed(3)} (expect ~0.50, organic 1.00)`, v => v >= 0.90);
|
|
1411
|
+
return worstOf(legStake, legPv);
|
|
1412
|
+
});
|
|
1413
|
+
},
|
|
1414
|
+
},
|
|
1415
|
+
],
|
|
1416
|
+
},
|
|
1417
|
+
{
|
|
1418
|
+
id: "crypto-h11-early-staker-retention",
|
|
1419
|
+
hook: "H11",
|
|
1420
|
+
archetype: "retention-divergence",
|
|
1421
|
+
narrative: "Born-in users with 2+ stakes in their first 10 days out-retain the rest: post-d40 events/user ratio ~1.31 vs an INVERSE organic baseline of 0.52 (most born-in users lack 40d runway) — the hook flips the sign. Last assertion checks identity-model invariants.",
|
|
1422
|
+
assertions: [
|
|
1423
|
+
{
|
|
1424
|
+
breakdown: {
|
|
1425
|
+
type: "duckdb",
|
|
1426
|
+
sql: `WITH born AS (
|
|
1427
|
+
SELECT DISTINCT user_id::VARCHAR AS uid FROM ${EV} WHERE event = 'wallet connected' AND user_id IS NOT NULL
|
|
1428
|
+
), firsts AS (
|
|
1429
|
+
SELECT user_id::VARCHAR AS uid, MIN(time::TIMESTAMP) AS t0 FROM ${EV} WHERE user_id IS NOT NULL GROUP BY 1
|
|
1430
|
+
), pu AS (
|
|
1431
|
+
SELECT e.user_id::VARCHAR AS uid,
|
|
1432
|
+
COUNT(*) FILTER (WHERE e.event = 'stake' AND e.time::TIMESTAMP <= f.t0 + INTERVAL 10 DAY) AS early_stakes,
|
|
1433
|
+
COUNT(*) FILTER (WHERE e.time::TIMESTAMP > f.t0 + INTERVAL 40 DAY) AS post40
|
|
1434
|
+
FROM ${EV} e
|
|
1435
|
+
JOIN firsts f ON e.user_id::VARCHAR = f.uid
|
|
1436
|
+
JOIN born b ON e.user_id::VARCHAR = b.uid
|
|
1437
|
+
GROUP BY 1
|
|
1438
|
+
)
|
|
1439
|
+
SELECT CASE WHEN early_stakes >= 2 THEN 'early' ELSE 'non' END AS cohort, COUNT(*) AS users, AVG(post40) AS post40_per_user
|
|
1440
|
+
FROM pu GROUP BY 1`,
|
|
1441
|
+
},
|
|
1442
|
+
// Custom assert (spec P3.1): custom INVERSE at ≤0.85 — the organic
|
|
1443
|
+
// baseline (0.52) is itself on the inverse side of neutral 1, so
|
|
1444
|
+
// the hook's sign-flip semantics need a hand-set threshold; plus
|
|
1445
|
+
// per-cohort scale guards. Neither fits the expect grammar.
|
|
1446
|
+
assert: (rows) => {
|
|
1447
|
+
const c = cellsOf(rows, "cohort");
|
|
1448
|
+
if (!c.early || !c.non) return { verdict: "NONE", detail: "early/non born-in cohorts missing" };
|
|
1449
|
+
const ratio = Number(c.early.post40_per_user) / Number(c.non.post40_per_user);
|
|
1450
|
+
return guarded(Number(c.early.users) >= 90 && Number(c.non.users) >= 550,
|
|
1451
|
+
`early ${c.early.users}u / non ${c.non.users}u`,
|
|
1452
|
+
() => bandVerdict(ratio, [1.15, 1.55], [1.02, 1.80],
|
|
1453
|
+
`born-in early/non post-d40 ratio ${ratio.toFixed(3)} (expect ~1.31, organic 0.52 INVERSE)`,
|
|
1454
|
+
v => v <= 0.85));
|
|
1455
|
+
},
|
|
1456
|
+
},
|
|
1457
|
+
{
|
|
1458
|
+
breakdown: {
|
|
1459
|
+
type: "duckdb",
|
|
1460
|
+
sql: `SELECT COUNT(*) AS n,
|
|
1461
|
+
AVG((user_id IS NOT NULL)::INT) AS uid_share,
|
|
1462
|
+
AVG((device_id IS NOT NULL)::INT) AS device_share,
|
|
1463
|
+
COUNT(DISTINCT device_id)::DOUBLE / COUNT(DISTINCT user_id) AS devices_per_user
|
|
1464
|
+
FROM ${EV}`,
|
|
1465
|
+
},
|
|
1466
|
+
// Custom assert (spec P3.1): identity-model invariant ladder over
|
|
1467
|
+
// four metrics at once (uid_share exact 1, device_share floor,
|
|
1468
|
+
// devices/user band) with exact-pin NAILED conditions — the expect
|
|
1469
|
+
// grammar carries one metric per assertion.
|
|
1470
|
+
assert: (rows) => {
|
|
1471
|
+
const r = rows?.[0];
|
|
1472
|
+
if (!r) return { verdict: "NONE", detail: "identity query returned no rows" };
|
|
1473
|
+
const uid = Number(r.uid_share), dev = Number(r.device_share), dpu = Number(r.devices_per_user);
|
|
1474
|
+
const detail = `uid_share ${uid.toFixed(4)}, device_share ${dev.toFixed(4)}, devices/user ${dpu.toFixed(2)} over ${r.n} events`;
|
|
1475
|
+
if (uid < 0.9) return { verdict: "INVERSE", detail };
|
|
1476
|
+
if (uid === 1 && dev >= 0.99 && dpu >= 1.6 && dpu <= 2.4) return { verdict: "NAILED", detail };
|
|
1477
|
+
if (uid >= 0.999 && dev >= 0.98) return { verdict: "STRONG", detail };
|
|
1478
|
+
return { verdict: "WEAK", detail };
|
|
1479
|
+
},
|
|
1480
|
+
},
|
|
1481
|
+
],
|
|
1482
|
+
},
|
|
1483
|
+
];
|