@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,1410 @@
|
|
|
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 { findFirstSequence, scaleFunnelTTC } from "@ak--47/dungeon-master/hook-helpers";
|
|
8
|
+
/** @typedef {import("../../../types").Dungeon} Config */
|
|
9
|
+
|
|
10
|
+
// ── OVERVIEW ──
|
|
11
|
+
/*
|
|
12
|
+
* NAME: NexBank
|
|
13
|
+
* APP: Chime/Revolut-style neobank app. Users open accounts (personal or
|
|
14
|
+
* business), transact across 7 merchant categories, send transfers,
|
|
15
|
+
* pay bills, set budgets, invest, apply for loans, and earn
|
|
16
|
+
* tier-scaled rewards. Core loop runs from onboarding through daily
|
|
17
|
+
* banking, financial planning, investments, and rewards.
|
|
18
|
+
* SCALE: 10,000 users, ~1.4M events, 121 days (2026-01-01 → 2026-05-01)
|
|
19
|
+
* CORE LOOP: account opened → app session → balance checked → transaction completed
|
|
20
|
+
*
|
|
21
|
+
* EVENTS (19):
|
|
22
|
+
* app session (20) > transaction completed (18) > balance checked (15)
|
|
23
|
+
* > notification opened (10) > transfer sent (8) > bill paid (6)
|
|
24
|
+
* > investment made (4) > reward redeemed (4) > budget alert (4)
|
|
25
|
+
* > budget created (3) > savings goal set (3) > support contacted (3)
|
|
26
|
+
* > card locked (2) > dispute filed (2) > loan applied (2) > premium upgraded (2)
|
|
27
|
+
* > account opened (1) > loan approved (1) > bill payment missed (1)
|
|
28
|
+
*
|
|
29
|
+
* FUNNELS (8):
|
|
30
|
+
* - Onboarding: account opened → app session → balance checked (85%)
|
|
31
|
+
* - Daily Banking: app session → balance checked → transaction completed (80%)
|
|
32
|
+
* - Transfers: app session → transfer sent → notification opened (50%)
|
|
33
|
+
* - Bill Payment: app session → bill paid → notification opened (60%)
|
|
34
|
+
* - Financial Planning: budget created → budget alert → savings goal set (40%)
|
|
35
|
+
* - Investment: balance checked → investment made → reward redeemed (30%)
|
|
36
|
+
* - Support: support contacted → card locked → dispute filed (35%)
|
|
37
|
+
* - Lending: loan applied → loan approved → premium upgraded (25%)
|
|
38
|
+
*
|
|
39
|
+
* USER PROPS: account_tier, Platform, credit_score_range, income_bracket, account_age_months, total_balance, has_direct_deposit, account_segment, employee_count, annual_revenue, industry, age_range, life_stage
|
|
40
|
+
* SUPER PROPS: account_tier, Platform
|
|
41
|
+
* SCD PROPS: risk_category (low/medium/high/critical, household_id-scoped, monthly fixed, max 8)
|
|
42
|
+
* (account_tier is deliberately NOT an SCD: the everything hook pins
|
|
43
|
+
* each event's account_tier to the user's profile tier so H7/H9
|
|
44
|
+
* breakdowns are coherent — a changing tier would contradict that.)
|
|
45
|
+
* GROUPS: household_id (500 households)
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
// ── HOOK STORIES ──
|
|
49
|
+
/*
|
|
50
|
+
* NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable
|
|
51
|
+
* via behavioral cohorts, raw-prop breakdowns (date, account_tier),
|
|
52
|
+
* or funnel time-to-convert.
|
|
53
|
+
*
|
|
54
|
+
* ---------------------------------------------------------------
|
|
55
|
+
* Hook 1 — PERSONAL VS BUSINESS ACCOUNTS (user)
|
|
56
|
+
*
|
|
57
|
+
* PATTERN: 20% of accounts are business (employee_count, revenue,
|
|
58
|
+
* industry attached) and 80% are personal (age_range, life_stage).
|
|
59
|
+
* Account segment shapes downstream transaction sizes.
|
|
60
|
+
*
|
|
61
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
62
|
+
*
|
|
63
|
+
* Report 1: Account Segment Mix
|
|
64
|
+
* - Report type: Insights
|
|
65
|
+
* - Event: any event
|
|
66
|
+
* - Measure: Unique users
|
|
67
|
+
* - Breakdown: "account_segment"
|
|
68
|
+
* - Expected: ~80% personal, ~20% business
|
|
69
|
+
*
|
|
70
|
+
* Report 2: Transaction Size by Segment
|
|
71
|
+
* - Report type: Insights
|
|
72
|
+
* - Event: "transaction completed"
|
|
73
|
+
* - Measure: Average of "amount"
|
|
74
|
+
* - Breakdown: "account_segment"
|
|
75
|
+
* - Expected: business avg amount ~ 4x personal avg amount
|
|
76
|
+
*
|
|
77
|
+
* REAL-WORLD ANALOGUE: Neobanks serve both consumers and small
|
|
78
|
+
* businesses with the same core product, but business activity is
|
|
79
|
+
* meaningfully higher value per transaction.
|
|
80
|
+
*
|
|
81
|
+
* ---------------------------------------------------------------
|
|
82
|
+
* Hook 2 — PAYDAY PATTERNS (everything)
|
|
83
|
+
*
|
|
84
|
+
* PATTERN: Direct deposit transactions are 3x larger on the 1st and
|
|
85
|
+
* 15th of the month. Transfers are ~1.6x larger on the 1st-3rd and
|
|
86
|
+
* 15th-17th (60% likelihood × 2x boost). No flag — discover via day-of-month
|
|
87
|
+
* breakdown on raw amount.
|
|
88
|
+
*
|
|
89
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
90
|
+
*
|
|
91
|
+
* Report 1: Direct Deposit Size by Day of Month
|
|
92
|
+
* - Report type: Insights
|
|
93
|
+
* - Event: "transaction completed"
|
|
94
|
+
* - Measure: Average of "amount"
|
|
95
|
+
* - Filter: "transaction_type" = "direct_deposit"
|
|
96
|
+
* - Breakdown: day of month
|
|
97
|
+
* - Expected: 1st and 15th avg ~ 3x other days
|
|
98
|
+
*
|
|
99
|
+
* Report 2: Post-Payday Transfer Spending by Day of Month
|
|
100
|
+
* - Report type: Insights
|
|
101
|
+
* - Event: "transfer sent"
|
|
102
|
+
* - Measure: Average of "amount"
|
|
103
|
+
* - Breakdown: day of month
|
|
104
|
+
* - Expected: 1-3 and 15-17 avg ~ 1.6x other days
|
|
105
|
+
*
|
|
106
|
+
* REAL-WORLD ANALOGUE: Bi-monthly payroll cycles drive predictable
|
|
107
|
+
* spikes in deposit and outbound spending volume.
|
|
108
|
+
*
|
|
109
|
+
* ---------------------------------------------------------------
|
|
110
|
+
* Hook 3 — FRAUD DETECTION (everything)
|
|
111
|
+
*
|
|
112
|
+
* PATTERN: ~3% of users experience a fraud burst at the timeline
|
|
113
|
+
* midpoint: 3-5 rapid high-value transactions, then card locked
|
|
114
|
+
* (reason="suspicious_activity"), dispute filed (reason="unauthorized"),
|
|
115
|
+
* and support contacted (issue_type="card"). No flag — derive cohort
|
|
116
|
+
* by joining users who had all three event types within ~1 hour.
|
|
117
|
+
*
|
|
118
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
119
|
+
*
|
|
120
|
+
* Report 1: Fraud Cohort
|
|
121
|
+
* - Report type: Cohort builder
|
|
122
|
+
* - Filter: did 3+ "transaction completed" (purchase, credit) inside one
|
|
123
|
+
* hour, THEN "card locked" with reason="suspicious_activity" AND
|
|
124
|
+
* "dispute filed" with reason="unauthorized" within 1 hour
|
|
125
|
+
* - Expected: ~3% of SUPPORT-HISTORY users (users with card-locked +
|
|
126
|
+
* dispute-filed events). The lock+dispute pair alone is NOT a fraud
|
|
127
|
+
* signature — the organic Support funnel (support contacted → card
|
|
128
|
+
* locked → dispute filed) emits adjacent lock/dispute pairs whose
|
|
129
|
+
* random reasons collide ~4x more often than the hook fires. The
|
|
130
|
+
* preceding txn burst is what organic funnels cannot produce. The
|
|
131
|
+
* denominator is support-history users because the hook clones its
|
|
132
|
+
* lock/dispute from the user's own organic events — users without
|
|
133
|
+
* both templates are picked but leave no signature (~38% at this
|
|
134
|
+
* schema, measured; the factor cancels when you scope the cohort
|
|
135
|
+
* to support-history users).
|
|
136
|
+
*
|
|
137
|
+
* Report 2: Fraud Resolution Funnel
|
|
138
|
+
* - Report type: Funnels
|
|
139
|
+
* - Steps: "card locked" -> "dispute filed" -> "support contacted"
|
|
140
|
+
* - Filter: card locked.reason = "suspicious_activity"
|
|
141
|
+
* - Expected: high completion across all three resolution steps
|
|
142
|
+
*
|
|
143
|
+
* REAL-WORLD ANALOGUE: A small but consistent slice of accounts
|
|
144
|
+
* triggers fraud pipelines every cycle, generating the bulk of
|
|
145
|
+
* dispute and support load.
|
|
146
|
+
*
|
|
147
|
+
* ---------------------------------------------------------------
|
|
148
|
+
* Hook 4 — LOW BALANCE CHURN (everything)
|
|
149
|
+
*
|
|
150
|
+
* PATTERN: Users with 3+ "balance checked" events where account_balance
|
|
151
|
+
* < $8K lose 50% of their events after day 30. No flag — derive cohort
|
|
152
|
+
* by counting low-balance checks per user. (account_balance centers ~$25K
|
|
153
|
+
* — weighNumRange(0, 50000) mean — so the $8K threshold puts ~10% of
|
|
154
|
+
* checks under it and the 3+-check cohort at a minority of users; a $15K
|
|
155
|
+
* threshold would sweep in the majority and erase the contrast.)
|
|
156
|
+
*
|
|
157
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
158
|
+
*
|
|
159
|
+
* Report 1: Activity by Low Balance Cohort
|
|
160
|
+
* - Cohort A: users with >= 3 "balance checked" where account_balance < 8000
|
|
161
|
+
* - Cohort B: users with < 3
|
|
162
|
+
* - Event: any event
|
|
163
|
+
* - Measure: Total per user, line chart by day
|
|
164
|
+
* - Expected: A growth post-d30 ~ 0.5x B's growth (suppressed)
|
|
165
|
+
*
|
|
166
|
+
* Report 2: Activity Decline Timeline
|
|
167
|
+
* - Report type: Insights (with cohort A above)
|
|
168
|
+
* - Event: any event
|
|
169
|
+
* - Measure: Total
|
|
170
|
+
* - Line chart by day
|
|
171
|
+
* - Expected: post-d30/pre-d30 event ratio ~ 3.0 for B (91 post days /
|
|
172
|
+
* 30 pre days) vs ~ 1.5 for A (post-d30 events halved) — A/B ~ 0.5.
|
|
173
|
+
* Cohorts are classified from OUTPUT counts, so some churned users whose
|
|
174
|
+
* dropped events took them under 3 visible low checks land in B,
|
|
175
|
+
* pulling the observed A/B slightly above 0.5.
|
|
176
|
+
*
|
|
177
|
+
* REAL-WORLD ANALOGUE: Customers running thin balances lose trust
|
|
178
|
+
* in the platform and migrate their primary banking elsewhere.
|
|
179
|
+
*
|
|
180
|
+
* ---------------------------------------------------------------
|
|
181
|
+
* Hook 5 — BUDGET DISCIPLINE (everything)
|
|
182
|
+
*
|
|
183
|
+
* PATTERN: Disciplined budgeters — users with 3+ "budget created"
|
|
184
|
+
* events (~77% of users; median is ~6 budgets) — get 2x savings
|
|
185
|
+
* contributions, 1.5x investment amounts, and extra cloned savings
|
|
186
|
+
* goal events. No flag — derive cohort behaviorally. (The gate is 3+,
|
|
187
|
+
* not 1+: nearly every user creates at least one budget, so a 1+ gate
|
|
188
|
+
* leaves a ~0.4% control cohort — unmeasurable. The 0-2 band is ~14%
|
|
189
|
+
* of users: a real comparison group.)
|
|
190
|
+
*
|
|
191
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
192
|
+
*
|
|
193
|
+
* Report 1: Savings Contribution by Budget Cohort
|
|
194
|
+
* - Cohort A: users with >= 3 "budget created" events
|
|
195
|
+
* - Cohort B: users with 0-2
|
|
196
|
+
* - Event: "savings goal set"
|
|
197
|
+
* - Measure: Average of "monthly_contribution"
|
|
198
|
+
* - Expected: A ~ 2x B
|
|
199
|
+
*
|
|
200
|
+
* Report 2: Investment Size by Budget Cohort
|
|
201
|
+
* - Cohort A vs B (as above)
|
|
202
|
+
* - Event: "investment made"
|
|
203
|
+
* - Measure: Average of "amount"
|
|
204
|
+
* - Expected: A ~ 1.5x B
|
|
205
|
+
*
|
|
206
|
+
* REAL-WORLD ANALOGUE: Active budget tooling correlates strongly
|
|
207
|
+
* with healthier savings rates and broader product adoption.
|
|
208
|
+
*
|
|
209
|
+
* ---------------------------------------------------------------
|
|
210
|
+
* Hook 6 — AUTO-PAY LOYALTY (event)
|
|
211
|
+
*
|
|
212
|
+
* PATTERN: Manual payers (auto_pay=false) miss 30% of their bill
|
|
213
|
+
* payments — those events are renamed to "bill payment missed".
|
|
214
|
+
* Auto-pay users never miss.
|
|
215
|
+
*
|
|
216
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
217
|
+
*
|
|
218
|
+
* Report 1: Bill Outcomes by Manual vs Auto-Pay
|
|
219
|
+
* - Report type: Insights
|
|
220
|
+
* - Events: "bill paid" and "bill payment missed"
|
|
221
|
+
* - Measure: Total
|
|
222
|
+
* - Expected: missed events appear only for manual payers, ~30% rate
|
|
223
|
+
*
|
|
224
|
+
* Report 2: Bill Completion Rate by Auto-Pay
|
|
225
|
+
* - Report type: Insights
|
|
226
|
+
* - Event: "bill paid"
|
|
227
|
+
* - Measure: Total
|
|
228
|
+
* - Breakdown: "auto_pay"
|
|
229
|
+
* - Expected: auto_pay=false ~ 70% completion vs auto_pay=true ~ 100%
|
|
230
|
+
*
|
|
231
|
+
* REAL-WORLD ANALOGUE: Auto-pay locks users into a frictionless
|
|
232
|
+
* payment cadence that virtually eliminates missed bills.
|
|
233
|
+
*
|
|
234
|
+
* ---------------------------------------------------------------
|
|
235
|
+
* Hook 7 — PREMIUM TIER VALUE (everything)
|
|
236
|
+
*
|
|
237
|
+
* PATTERN: Premium-tier users get 3x reward values and 2x sell
|
|
238
|
+
* returns on investments. Plus tier gets 1.5x rewards. No flag —
|
|
239
|
+
* mutates raw "value"/"amount" properties; discover via account_tier breakdown.
|
|
240
|
+
*
|
|
241
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
242
|
+
*
|
|
243
|
+
* Report 1: Reward Value by Tier
|
|
244
|
+
* - Report type: Insights
|
|
245
|
+
* - Event: "reward redeemed"
|
|
246
|
+
* - Measure: Average of "value"
|
|
247
|
+
* - Breakdown: "account_tier"
|
|
248
|
+
* - Expected: Premium ~ 3x Basic avg, Plus ~ 1.5x Basic avg
|
|
249
|
+
*
|
|
250
|
+
* Report 2: Investment Sell Amount by Tier
|
|
251
|
+
* - Report type: Insights
|
|
252
|
+
* - Event: "investment made"
|
|
253
|
+
* - Measure: Average of "amount"
|
|
254
|
+
* - Filter: "action" = "sell"
|
|
255
|
+
* - Breakdown: "account_tier"
|
|
256
|
+
* - Expected: Premium ~ 2x baseline; Basic/Plus baseline
|
|
257
|
+
*
|
|
258
|
+
* REAL-WORLD ANALOGUE: Premium subscription tiers justify their
|
|
259
|
+
* price by delivering visibly better cashback and investment perks.
|
|
260
|
+
*
|
|
261
|
+
* ---------------------------------------------------------------
|
|
262
|
+
* Hook 8 — MONTH-END ANXIETY (everything)
|
|
263
|
+
*
|
|
264
|
+
* PATTERN: On days >= 28 of the calendar month, app sessions run
|
|
265
|
+
* 40% longer and reported balances are 30% lower. No flag — discover
|
|
266
|
+
* via day-of-month breakdown.
|
|
267
|
+
*
|
|
268
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
269
|
+
*
|
|
270
|
+
* Report 1: Session Duration by Day of Month
|
|
271
|
+
* - Report type: Insights
|
|
272
|
+
* - Event: "app session"
|
|
273
|
+
* - Measure: Average of "session_duration_sec"
|
|
274
|
+
* - Breakdown: day of month
|
|
275
|
+
* - Expected: days >= 28 ~ 1.4x other days
|
|
276
|
+
*
|
|
277
|
+
* Report 2: Balance by Day of Month
|
|
278
|
+
* - Report type: Insights
|
|
279
|
+
* - Event: "balance checked"
|
|
280
|
+
* - Measure: Average of "account_balance"
|
|
281
|
+
* - Breakdown: day of month
|
|
282
|
+
* - Expected: days >= 28 ~ 0.7x other days
|
|
283
|
+
*
|
|
284
|
+
* REAL-WORLD ANALOGUE: Users obsessively check balances at month
|
|
285
|
+
* end as bills hit and runway tightens.
|
|
286
|
+
*
|
|
287
|
+
* ---------------------------------------------------------------
|
|
288
|
+
* Hook 9 — ONBOARDING TIME-TO-CONVERT (everything)
|
|
289
|
+
*
|
|
290
|
+
* PATTERN: Premium tier users complete the Onboarding funnel 1.5x
|
|
291
|
+
* faster (factor 0.67); Basic users 1.33x slower (factor 1.33).
|
|
292
|
+
* Applied in the everything hook via findFirstSequence + scaleFunnelTTC,
|
|
293
|
+
* so the effect is visible in both Mixpanel funnels and cross-event
|
|
294
|
+
* MIN→MIN SQL queries.
|
|
295
|
+
*
|
|
296
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
297
|
+
*
|
|
298
|
+
* Report 1: Onboarding Median Time-to-Convert by Tier
|
|
299
|
+
* - Funnels > "account opened" -> "app session" -> "balance checked"
|
|
300
|
+
* - Measure: MEDIAN time to convert (not average — TTC is heavy-tailed
|
|
301
|
+
* with a 30-day window, and converter cohorts are small enough that a
|
|
302
|
+
* single multi-day straggler dominates the mean; the median ratio
|
|
303
|
+
* recovers the exact engineered factors)
|
|
304
|
+
* - Breakdown: account_tier
|
|
305
|
+
* - Expected: median basic/premium ~ 1.33/0.67 ≈ 2x; plus sits between
|
|
306
|
+
*
|
|
307
|
+
* ---------------------------------------------------------------
|
|
308
|
+
* Hook 10 — TRANSACTION-COUNT MAGIC NUMBER (everything)
|
|
309
|
+
*
|
|
310
|
+
* PATTERN: Sweet 12-19 transactions/user → +40% on investment-made
|
|
311
|
+
* amount (engaged transactor compounds wealth). Over 20+ → drop 20%
|
|
312
|
+
* of premium-upgraded events. No flag. (Bands measured from the actual
|
|
313
|
+
* per-user txn distribution: median 12, p75 17, p90 20, max ~33 —
|
|
314
|
+
* sweet brackets the median-to-p85 mass ~40%, over is the top ~12%;
|
|
315
|
+
* the pre-calibration 20-35/36+ bands left the over band EMPTY.)
|
|
316
|
+
*
|
|
317
|
+
* HOW TO FIND IT IN MIXPANEL:
|
|
318
|
+
*
|
|
319
|
+
* Report 1: Avg Investment Amount by Transaction Bucket
|
|
320
|
+
* - Cohort A: users with 12-19 "transaction completed"
|
|
321
|
+
* - Cohort B: users with 1-11
|
|
322
|
+
* - Event: "investment made"
|
|
323
|
+
* - Measure: Average of "amount"
|
|
324
|
+
* - Expected: A ~ 1.4x B
|
|
325
|
+
*
|
|
326
|
+
* Report 2: Premium Upgrade RATE on Heavy Transactors
|
|
327
|
+
* - Cohort C: users with >= 20 "transaction completed"
|
|
328
|
+
* - Cohort A: users with 12-19
|
|
329
|
+
* - Formula: total "premium upgraded" / total NON-TRANSACTION events,
|
|
330
|
+
* per cohort
|
|
331
|
+
* - Expected: C's upgrade share ~ 0.8x A's. (Raw upgrades-per-user RISES
|
|
332
|
+
* with activity — heavier users emit more of every event — and the
|
|
333
|
+
* over band is SELECTED for high txn counts, which mechanically tilts
|
|
334
|
+
* its event mix toward transactions. Normalizing by non-transaction
|
|
335
|
+
* events removes both distortions; what remains is the 20% drop.)
|
|
336
|
+
*
|
|
337
|
+
* REAL-WORLD ANALOGUE: Engaged transactors invest more; over-active
|
|
338
|
+
* already extract value without upgrading.
|
|
339
|
+
*
|
|
340
|
+
* ===================================================================
|
|
341
|
+
* EXPECTED METRICS SUMMARY
|
|
342
|
+
* ===================================================================
|
|
343
|
+
*
|
|
344
|
+
* Hook | Metric | Baseline | Effect | Ratio
|
|
345
|
+
* ----------------------|-----------------------|----------|-----------|------
|
|
346
|
+
* Personal vs Business | Avg transaction amt | 1x | 4x | 4x
|
|
347
|
+
* Payday Patterns | Deposit amt 1st/15th | 1x | 3x | 3x
|
|
348
|
+
* Fraud Detection | Burst-sig users / | 0% | ~3% | --
|
|
349
|
+
* | support-history users| | |
|
|
350
|
+
* Low Balance Churn | D30+ events (cohort) | 1x | 0.5x | -50%
|
|
351
|
+
* Budget Discipline | Savings contribution | 1x | 2x | 2x
|
|
352
|
+
* | (3+ budgets vs 0-2) | | |
|
|
353
|
+
* Auto-Pay Loyalty | missed/paid ratio | 0 | ~0.22 | 0.18/0.82
|
|
354
|
+
* Premium Tier Value | Reward value (Premium)| 1x | 3x | 3x
|
|
355
|
+
* Month-End Anxiety | Session duration d28+ | 1x | 1.4x | 1.4x
|
|
356
|
+
* Onboarding T2C (H9) | MEDIAN TTC basic/prem | 1x | 1.33/0.67 | ~2x
|
|
357
|
+
* Txn-Count Magic Num | sweet investment amt | 1x | 1.4x | 1.4x
|
|
358
|
+
* Txn-Count Magic Num | over upgrade share | 1x | 0.8x | -20%
|
|
359
|
+
*/
|
|
360
|
+
|
|
361
|
+
// ── SCALE ──
|
|
362
|
+
const SEED = "harness-fintech";
|
|
363
|
+
const NUM_USERS = 10_000;
|
|
364
|
+
const DATASET_START = "2026-01-01T00:00:00Z";
|
|
365
|
+
const DATASET_END = "2026-05-01T23:59:59Z";
|
|
366
|
+
const EVENTS_PER_DAY = 1.2;
|
|
367
|
+
const token = process.env.MP_TOKEN || "your-mixpanel-token";
|
|
368
|
+
|
|
369
|
+
const chance = u.initChance(SEED);
|
|
370
|
+
|
|
371
|
+
// ── KNOBS (tweak these to reshape stories) ──
|
|
372
|
+
// H1: Personal vs Business
|
|
373
|
+
const BUSINESS_LIKELIHOOD = 20;
|
|
374
|
+
const BUSINESS_TXN_MULT = 4;
|
|
375
|
+
|
|
376
|
+
// H2: Payday Patterns
|
|
377
|
+
const PAYDAY_DEPOSIT_MULT = 3;
|
|
378
|
+
const PAYDAY_TRANSFER_MULT = 2.0;
|
|
379
|
+
const PAYDAY_TRANSFER_LIKELIHOOD = 60;
|
|
380
|
+
|
|
381
|
+
// H3: Fraud Detection — fires for ~3% of users, but only leaves a signature
|
|
382
|
+
// when the user has organic card-locked AND dispute-filed template events to
|
|
383
|
+
// clone (~62% of users, measured — they come from the Support funnel). The
|
|
384
|
+
// honest detectable rate is therefore ~3% of SUPPORT-HISTORY users, which is
|
|
385
|
+
// what the story asserts (the template factor cancels in that denominator).
|
|
386
|
+
// The bare lock+dispute pair is NOT usable as the detector: organic Support
|
|
387
|
+
// funnels emit adjacent pairs whose random reasons collide ~4x more often
|
|
388
|
+
// than the hook fires. The preceding 3-txn burst is the discriminator.
|
|
389
|
+
const FRAUD_LIKELIHOOD = 3;
|
|
390
|
+
const FRAUD_BURST_MIN = 3;
|
|
391
|
+
const FRAUD_BURST_MAX = 5;
|
|
392
|
+
const FRAUD_AMOUNT_MIN = 500;
|
|
393
|
+
const FRAUD_AMOUNT_MAX = 3000;
|
|
394
|
+
|
|
395
|
+
// H4: Low Balance Churn — account_balance is weighNumRange(0, 50000), a
|
|
396
|
+
// normal centered ~$25K (sd ≈ $12.5K). $8K ≈ 10th percentile per check, so
|
|
397
|
+
// the 3+-low-check cohort stays a minority with real contrast against the
|
|
398
|
+
// rest. ($15K would capture ~22% per check and sweep in most active users.)
|
|
399
|
+
const LOW_BALANCE_THRESHOLD = 8000;
|
|
400
|
+
const LOW_BALANCE_CHECK_THRESHOLD = 3;
|
|
401
|
+
const LOW_BALANCE_CHURN_CUTOFF_DAYS = 30;
|
|
402
|
+
const LOW_BALANCE_DROP_LIKELIHOOD = 50;
|
|
403
|
+
|
|
404
|
+
// H5: Budget Discipline — gate is 3+ budgets, not 1+: budget-created lands
|
|
405
|
+
// ~5.6 events/user (median 6, measured), so only ~0.4% of users have ZERO
|
|
406
|
+
// budgets — no control group. The 0-2 band is ~14% of users (measured at
|
|
407
|
+
// 1500-user iteration), a real comparison cohort.
|
|
408
|
+
const BUDGET_DISCIPLINE_MIN = 3;
|
|
409
|
+
const BUDGET_SAVINGS_MULT = 2;
|
|
410
|
+
const BUDGET_INVESTMENT_MULT = 1.5;
|
|
411
|
+
const BUDGET_CLONE_LIKELIHOOD = 50;
|
|
412
|
+
|
|
413
|
+
// H6: Auto-Pay Loyalty
|
|
414
|
+
const MISSED_BILL_LIKELIHOOD = 30;
|
|
415
|
+
|
|
416
|
+
// H7: Premium Tier Value
|
|
417
|
+
const PREMIUM_REWARD_MULT = 3;
|
|
418
|
+
const PLUS_REWARD_MULT = 1.5;
|
|
419
|
+
const PREMIUM_INVEST_SELL_MULT = 2;
|
|
420
|
+
|
|
421
|
+
// H8: Month-End Anxiety
|
|
422
|
+
const MONTH_END_DAY_THRESHOLD = 28;
|
|
423
|
+
const MONTH_END_SESSION_MULT = 1.4;
|
|
424
|
+
const MONTH_END_BALANCE_MULT = 0.7;
|
|
425
|
+
|
|
426
|
+
// H9: Onboarding TTC
|
|
427
|
+
const TTC_PREMIUM_FACTOR = 0.67;
|
|
428
|
+
const TTC_BASIC_FACTOR = 1.33;
|
|
429
|
+
const TTC_MAX_GAP_MINUTES = 60 * 24 * 30; // 30-day max gap between steps
|
|
430
|
+
|
|
431
|
+
// H10: Transaction-Count Magic Number — bands MEASURED from the per-user txn
|
|
432
|
+
// distribution at the 1500-user iteration (per-user density is independent
|
|
433
|
+
// of numUsers): median 12, p75 17, p90 20, max ~33. Sweet 12-19 brackets the
|
|
434
|
+
// median-to-p85 mass (~40% of users); over 20+ is the top ~12%. The earlier
|
|
435
|
+
// weight-arithmetic estimate (~24 median → 20-35/36+ bands) left the over
|
|
436
|
+
// band EMPTY — bands must come from the measured distribution, not from
|
|
437
|
+
// event-weight arithmetic.
|
|
438
|
+
const TXN_SWEET_MIN = 12;
|
|
439
|
+
const TXN_SWEET_MAX = 19;
|
|
440
|
+
const TXN_OVER_THRESHOLD = 20;
|
|
441
|
+
const TXN_INVESTMENT_BOOST = 1.4;
|
|
442
|
+
const TXN_PREMIUM_DROP_LIKELIHOOD = 20;
|
|
443
|
+
|
|
444
|
+
// ── HELPER FUNCTIONS ──
|
|
445
|
+
function handleUserHooks(record) {
|
|
446
|
+
// H1: PERSONAL VS BUSINESS ACCOUNTS — role-based attrs.
|
|
447
|
+
const isBusiness = chance.bool({ likelihood: BUSINESS_LIKELIHOOD });
|
|
448
|
+
if (isBusiness) {
|
|
449
|
+
record.account_segment = "business";
|
|
450
|
+
record.employee_count = chance.integer({ min: 5, max: 500 });
|
|
451
|
+
record.annual_revenue = chance.integer({ min: 100000, max: 10000000 });
|
|
452
|
+
record.industry = chance.pickone(["tech", "retail", "food", "services", "healthcare"]);
|
|
453
|
+
} else {
|
|
454
|
+
record.account_segment = "personal";
|
|
455
|
+
record.age_range = `${chance.pickone([18, 25, 35, 45, 55])}-${chance.pickone([24, 34, 44, 54, 65])}`;
|
|
456
|
+
record.life_stage = chance.pickone(["student", "early_career", "established", "pre_retirement", "retired"]);
|
|
457
|
+
}
|
|
458
|
+
return record;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function handleEventHooks(record) {
|
|
462
|
+
// H6: AUTO-PAY LOYALTY — manual bill-paid events have 30% chance of
|
|
463
|
+
// becoming "bill payment missed". Mutates event name.
|
|
464
|
+
if (record.event === "bill paid" && record.auto_pay !== true && chance.bool({ likelihood: MISSED_BILL_LIKELIHOOD })) {
|
|
465
|
+
record.event = "bill payment missed";
|
|
466
|
+
}
|
|
467
|
+
return record;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function handleEverythingHooks(record, meta) {
|
|
471
|
+
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
472
|
+
const userEvents = record;
|
|
473
|
+
const profile = meta.profile;
|
|
474
|
+
|
|
475
|
+
userEvents.forEach(e => {
|
|
476
|
+
e.account_tier = profile.account_tier;
|
|
477
|
+
e.Platform = profile.Platform;
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// H9: ONBOARDING TIME-TO-CONVERT — Premium 1.5x faster (factor 0.67);
|
|
481
|
+
// Basic 1.33x slower (factor 1.33). Finds first onboarding sequence
|
|
482
|
+
// and scales the inter-step gaps.
|
|
483
|
+
{
|
|
484
|
+
const ttcFactor = (
|
|
485
|
+
profile.account_tier === "premium" ? TTC_PREMIUM_FACTOR :
|
|
486
|
+
profile.account_tier === "basic" ? TTC_BASIC_FACTOR :
|
|
487
|
+
1.0
|
|
488
|
+
);
|
|
489
|
+
if (ttcFactor !== 1.0) {
|
|
490
|
+
const onboardingSeq = findFirstSequence(
|
|
491
|
+
userEvents,
|
|
492
|
+
["account opened", "app session", "balance checked"],
|
|
493
|
+
TTC_MAX_GAP_MINUTES
|
|
494
|
+
);
|
|
495
|
+
if (onboardingSeq) {
|
|
496
|
+
scaleFunnelTTC(onboardingSeq, ttcFactor);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// H1B: PERSONAL VS BUSINESS — business segment txns 4x larger
|
|
502
|
+
// (per Report 2 in JSDoc: business ~ $200, personal ~ $50).
|
|
503
|
+
if (profile.account_segment === "business") {
|
|
504
|
+
userEvents.forEach(e => {
|
|
505
|
+
if (e.event === "transaction completed" && typeof e.amount === "number") {
|
|
506
|
+
e.amount = Math.floor(e.amount * BUSINESS_TXN_MULT);
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// H2: PAYDAY PATTERNS — 1st & 15th: direct_deposit amount 3x.
|
|
512
|
+
// Days 1-3 and 15-17: 60% of transfers get amount 2x. No flag.
|
|
513
|
+
for (const e of userEvents) {
|
|
514
|
+
const dayOfMonth = new Date(e.time).getUTCDate();
|
|
515
|
+
if (e.event === "transaction completed" && e.transaction_type === "direct_deposit") {
|
|
516
|
+
if (dayOfMonth === 1 || dayOfMonth === 15) {
|
|
517
|
+
e.amount = Math.floor((e.amount || 50) * PAYDAY_DEPOSIT_MULT);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (e.event === "transfer sent") {
|
|
521
|
+
const isPaydayWindow = (dayOfMonth >= 1 && dayOfMonth <= 3) || (dayOfMonth >= 15 && dayOfMonth <= 17);
|
|
522
|
+
if (isPaydayWindow && chance.bool({ likelihood: PAYDAY_TRANSFER_LIKELIHOOD })) {
|
|
523
|
+
e.amount = Math.floor((e.amount || 200) * PAYDAY_TRANSFER_MULT);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// H8: MONTH-END ANXIETY — days >= 28: app_session duration 1.4x;
|
|
529
|
+
// balance_checked account_balance 0.7x. Mutates raw props.
|
|
530
|
+
for (const e of userEvents) {
|
|
531
|
+
const dayOfMonth = new Date(e.time).getUTCDate();
|
|
532
|
+
if (dayOfMonth >= MONTH_END_DAY_THRESHOLD) {
|
|
533
|
+
if (e.event === "app session") {
|
|
534
|
+
e.session_duration_sec = Math.floor((e.session_duration_sec || 60) * MONTH_END_SESSION_MULT);
|
|
535
|
+
}
|
|
536
|
+
if (e.event === "balance checked") {
|
|
537
|
+
e.account_balance = Math.floor((e.account_balance || 2500) * MONTH_END_BALANCE_MULT);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// H7: PREMIUM TIER VALUE — Premium 3x reward value + 2x investment-sell
|
|
543
|
+
// amount; Plus 1.5x reward value. Reads tier from profile. No flag.
|
|
544
|
+
const tier = profile.account_tier;
|
|
545
|
+
userEvents.forEach(e => {
|
|
546
|
+
if (e.event === "reward redeemed") {
|
|
547
|
+
if (tier === "premium") e.value = Math.floor((e.value || 10) * PREMIUM_REWARD_MULT);
|
|
548
|
+
else if (tier === "plus") e.value = Math.floor((e.value || 10) * PLUS_REWARD_MULT);
|
|
549
|
+
}
|
|
550
|
+
if (e.event === "investment made" && e.action === "sell" && tier === "premium") {
|
|
551
|
+
e.amount = Math.floor((e.amount || 250) * PREMIUM_INVEST_SELL_MULT);
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
// H3: FRAUD DETECTION — ~3% of users get fraud burst (3-5 rapid
|
|
556
|
+
// high-value transactions + card locked + dispute + support contacted)
|
|
557
|
+
// at timeline midpoint. No flag — discover via cohort builder on users
|
|
558
|
+
// with card-locked + dispute-filed.
|
|
559
|
+
if (chance.bool({ likelihood: FRAUD_LIKELIHOOD }) && userEvents.length >= 2) {
|
|
560
|
+
const midIdx = Math.floor(userEvents.length / 2);
|
|
561
|
+
const midEvent = userEvents[midIdx];
|
|
562
|
+
const midTime = dayjs(midEvent.time);
|
|
563
|
+
const distinctId = midEvent.user_id;
|
|
564
|
+
const burstCount = chance.integer({ min: FRAUD_BURST_MIN, max: FRAUD_BURST_MAX });
|
|
565
|
+
const fraudEvents = [];
|
|
566
|
+
const txnTemplate = userEvents.find(e => e.event === "transaction completed");
|
|
567
|
+
const cardTemplate = userEvents.find(e => e.event === "card locked");
|
|
568
|
+
const disputeTemplate = userEvents.find(e => e.event === "dispute filed");
|
|
569
|
+
const supportTemplate = userEvents.find(e => e.event === "support contacted");
|
|
570
|
+
|
|
571
|
+
for (let i = 0; i < burstCount; i++) {
|
|
572
|
+
if (txnTemplate) {
|
|
573
|
+
fraudEvents.push({
|
|
574
|
+
...txnTemplate,
|
|
575
|
+
time: midTime.add(i * 10, "minutes").toISOString(),
|
|
576
|
+
user_id: distinctId,
|
|
577
|
+
transaction_type: "purchase",
|
|
578
|
+
amount: chance.integer({ min: FRAUD_AMOUNT_MIN, max: FRAUD_AMOUNT_MAX }),
|
|
579
|
+
merchant_category: chance.pickone(["online", "retail"]),
|
|
580
|
+
payment_method: "credit",
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
if (cardTemplate) fraudEvents.push({
|
|
585
|
+
...cardTemplate,
|
|
586
|
+
time: midTime.add(burstCount * 10 + 5, "minutes").toISOString(),
|
|
587
|
+
user_id: distinctId,
|
|
588
|
+
reason: "suspicious_activity",
|
|
589
|
+
});
|
|
590
|
+
if (disputeTemplate) fraudEvents.push({
|
|
591
|
+
...disputeTemplate,
|
|
592
|
+
time: midTime.add(burstCount * 10 + 30, "minutes").toISOString(),
|
|
593
|
+
user_id: distinctId,
|
|
594
|
+
dispute_amount: chance.integer({ min: FRAUD_AMOUNT_MIN, max: FRAUD_AMOUNT_MAX }),
|
|
595
|
+
reason: "unauthorized",
|
|
596
|
+
});
|
|
597
|
+
if (supportTemplate) fraudEvents.push({
|
|
598
|
+
...supportTemplate,
|
|
599
|
+
time: midTime.add(burstCount * 10 + 45, "minutes").toISOString(),
|
|
600
|
+
user_id: distinctId,
|
|
601
|
+
channel: "phone",
|
|
602
|
+
issue_type: "card",
|
|
603
|
+
resolved: true,
|
|
604
|
+
});
|
|
605
|
+
userEvents.splice(midIdx + 1, 0, ...fraudEvents);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// H4: LOW BALANCE CHURN — users with 3+ balance checks under $8K lose
|
|
609
|
+
// 50% of post-day-30 events. No flag.
|
|
610
|
+
const lowBalanceChecks = userEvents.filter(e =>
|
|
611
|
+
e.event === "balance checked" && (e.account_balance || 0) < LOW_BALANCE_THRESHOLD
|
|
612
|
+
).length;
|
|
613
|
+
if (lowBalanceChecks >= LOW_BALANCE_CHECK_THRESHOLD) {
|
|
614
|
+
const dayCutoff = datasetStart.add(LOW_BALANCE_CHURN_CUTOFF_DAYS, "days");
|
|
615
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
616
|
+
if (dayjs(userEvents[i].time).isAfter(dayCutoff) && chance.bool({ likelihood: LOW_BALANCE_DROP_LIKELIHOOD })) {
|
|
617
|
+
userEvents.splice(i, 1);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// H5: BUDGET DISCIPLINE — users with 3+ budget-created events get
|
|
623
|
+
// savings 2x, investment amounts 1.5x, and extra cloned savings-goal
|
|
624
|
+
// events. No flag. Clones are collected first and pushed BEFORE the
|
|
625
|
+
// mutate pass (splicing at idx+1 inside forEach would revisit the clone
|
|
626
|
+
// and double-mutate it). Clones inherit the template's raw
|
|
627
|
+
// monthly_contribution so the single ×2 pass applies uniformly and the
|
|
628
|
+
// cohort ratio stays an exact 2x against light-budget (0-2) users.
|
|
629
|
+
const budgetCount = userEvents.filter(e => e.event === "budget created").length;
|
|
630
|
+
if (budgetCount >= BUDGET_DISCIPLINE_MIN) {
|
|
631
|
+
const savingsTemplate = userEvents.find(e => e.event === "savings goal set");
|
|
632
|
+
if (savingsTemplate) {
|
|
633
|
+
const clones = [];
|
|
634
|
+
for (const event of userEvents) {
|
|
635
|
+
if (event.event === "budget created" && chance.bool({ likelihood: BUDGET_CLONE_LIKELIHOOD })) {
|
|
636
|
+
clones.push({
|
|
637
|
+
...savingsTemplate,
|
|
638
|
+
time: dayjs(event.time).add(chance.integer({ min: 1, max: 7 }), "days").toISOString(),
|
|
639
|
+
user_id: event.user_id,
|
|
640
|
+
goal_type: chance.pickone(["emergency", "vacation", "car", "home"]),
|
|
641
|
+
target_amount: chance.integer({ min: 1000, max: 20000 }),
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
userEvents.push(...clones); // engine auto-sorts by time after `everything`
|
|
646
|
+
}
|
|
647
|
+
for (const event of userEvents) {
|
|
648
|
+
if (event.event === "savings goal set") {
|
|
649
|
+
event.monthly_contribution = Math.floor((event.monthly_contribution || 200) * BUDGET_SAVINGS_MULT);
|
|
650
|
+
}
|
|
651
|
+
if (event.event === "investment made") {
|
|
652
|
+
event.amount = Math.floor((event.amount || 250) * BUDGET_INVESTMENT_MULT);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// H10: TRANSACTION-COUNT MAGIC NUMBER (no flags)
|
|
658
|
+
// Sweet 12-19 transactions/user → +40% on investment_made amount.
|
|
659
|
+
// Over 20+ → drop 20% of premium-upgraded events.
|
|
660
|
+
const txnCount = userEvents.filter(e => e.event === "transaction completed").length;
|
|
661
|
+
if (txnCount >= TXN_SWEET_MIN && txnCount <= TXN_SWEET_MAX) {
|
|
662
|
+
userEvents.forEach(e => {
|
|
663
|
+
if (e.event === "investment made" && typeof e.amount === "number") {
|
|
664
|
+
e.amount = Math.round(e.amount * TXN_INVESTMENT_BOOST);
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
} else if (txnCount >= TXN_OVER_THRESHOLD) {
|
|
668
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
669
|
+
if (userEvents[i].event === "premium upgraded" && chance.bool({ likelihood: TXN_PREMIUM_DROP_LIKELIHOOD })) {
|
|
670
|
+
userEvents.splice(i, 1);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
return record;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// ── CONFIG ──
|
|
679
|
+
/** @type {Config} */
|
|
680
|
+
const config = {
|
|
681
|
+
version: 2,
|
|
682
|
+
seed: SEED,
|
|
683
|
+
datasetStart: DATASET_START,
|
|
684
|
+
datasetEnd: DATASET_END,
|
|
685
|
+
avgEventsPerUserPerDay: EVENTS_PER_DAY,
|
|
686
|
+
numUsers: NUM_USERS,
|
|
687
|
+
format: "json",
|
|
688
|
+
gzip: true,
|
|
689
|
+
credentials: {
|
|
690
|
+
token,
|
|
691
|
+
},
|
|
692
|
+
switches: {
|
|
693
|
+
hasSessionIds: true,
|
|
694
|
+
alsoInferFunnels: false,
|
|
695
|
+
hasLocation: true,
|
|
696
|
+
hasAndroidDevices: true,
|
|
697
|
+
hasIOSDevices: true,
|
|
698
|
+
hasDesktopDevices: true,
|
|
699
|
+
hasBrowser: false,
|
|
700
|
+
hasCampaigns: false,
|
|
701
|
+
isAnonymous: false,
|
|
702
|
+
hasAdSpend: false,
|
|
703
|
+
hasAvatar: true,
|
|
704
|
+
},
|
|
705
|
+
identity: {
|
|
706
|
+
avgDevicePerUser: 2,
|
|
707
|
+
},
|
|
708
|
+
concurrency: 1,
|
|
709
|
+
writeToDisk: false,
|
|
710
|
+
|
|
711
|
+
scdProps: {
|
|
712
|
+
// account_tier is deliberately NOT an SCD — the everything hook stamps
|
|
713
|
+
// every event with the user's profile tier (H7/H9 depend on a stable
|
|
714
|
+
// per-user tier), which an SCD timeline would silently contradict.
|
|
715
|
+
risk_category: {
|
|
716
|
+
values: ["low", "medium", "high", "critical"],
|
|
717
|
+
frequency: "month",
|
|
718
|
+
timing: "fixed",
|
|
719
|
+
max: 8,
|
|
720
|
+
type: "household_id"
|
|
721
|
+
}
|
|
722
|
+
},
|
|
723
|
+
|
|
724
|
+
funnels: [
|
|
725
|
+
{
|
|
726
|
+
sequence: ["account opened", "app session", "balance checked"],
|
|
727
|
+
isFirstFunnel: true,
|
|
728
|
+
conversionRate: 85,
|
|
729
|
+
timeToConvert: 0.25,
|
|
730
|
+
},
|
|
731
|
+
{
|
|
732
|
+
// Daily banking: check balance, view transactions - most common activity
|
|
733
|
+
sequence: ["app session", "balance checked", "transaction completed"],
|
|
734
|
+
conversionRate: 80,
|
|
735
|
+
timeToConvert: 0.5,
|
|
736
|
+
weight: 5,
|
|
737
|
+
},
|
|
738
|
+
{
|
|
739
|
+
// Transfers and notifications
|
|
740
|
+
sequence: ["app session", "transfer sent", "notification opened"],
|
|
741
|
+
conversionRate: 50,
|
|
742
|
+
timeToConvert: 1,
|
|
743
|
+
weight: 3,
|
|
744
|
+
},
|
|
745
|
+
{
|
|
746
|
+
// Bill payment flow
|
|
747
|
+
sequence: ["app session", "bill paid", "notification opened"],
|
|
748
|
+
conversionRate: 60,
|
|
749
|
+
timeToConvert: 1,
|
|
750
|
+
weight: 3,
|
|
751
|
+
},
|
|
752
|
+
{
|
|
753
|
+
// Financial planning: budgets and savings
|
|
754
|
+
sequence: ["budget created", "budget alert", "savings goal set"],
|
|
755
|
+
conversionRate: 40,
|
|
756
|
+
timeToConvert: 12,
|
|
757
|
+
weight: 2,
|
|
758
|
+
},
|
|
759
|
+
{
|
|
760
|
+
// Investment and rewards
|
|
761
|
+
sequence: ["balance checked", "investment made", "reward redeemed"],
|
|
762
|
+
conversionRate: 30,
|
|
763
|
+
timeToConvert: 5,
|
|
764
|
+
weight: 2,
|
|
765
|
+
},
|
|
766
|
+
{
|
|
767
|
+
// Support and account management
|
|
768
|
+
sequence: ["support contacted", "card locked", "dispute filed"],
|
|
769
|
+
conversionRate: 35,
|
|
770
|
+
timeToConvert: 2,
|
|
771
|
+
weight: 1,
|
|
772
|
+
},
|
|
773
|
+
{
|
|
774
|
+
// Lending flow
|
|
775
|
+
sequence: ["loan applied", "loan approved", "premium upgraded"],
|
|
776
|
+
conversionRate: 25,
|
|
777
|
+
timeToConvert: 10,
|
|
778
|
+
weight: 1,
|
|
779
|
+
},
|
|
780
|
+
],
|
|
781
|
+
|
|
782
|
+
events: [
|
|
783
|
+
{
|
|
784
|
+
event: "account opened",
|
|
785
|
+
weight: 1,
|
|
786
|
+
isFirstEvent: true,
|
|
787
|
+
isAuthEvent: true,
|
|
788
|
+
properties: {
|
|
789
|
+
"account_type": ["personal", "business", "personal"],
|
|
790
|
+
"signup_channel": ["app", "web", "referral", "branch"],
|
|
791
|
+
}
|
|
792
|
+
},
|
|
793
|
+
{
|
|
794
|
+
event: "app session",
|
|
795
|
+
weight: 20,
|
|
796
|
+
isStrictEvent: false,
|
|
797
|
+
properties: {
|
|
798
|
+
"session_duration_sec": u.weighNumRange(10, 600, 0.3, 60),
|
|
799
|
+
"pages_viewed": u.weighNumRange(1, 15, 0.5, 3),
|
|
800
|
+
}
|
|
801
|
+
},
|
|
802
|
+
{
|
|
803
|
+
event: "balance checked",
|
|
804
|
+
weight: 15,
|
|
805
|
+
isStrictEvent: false,
|
|
806
|
+
properties: {
|
|
807
|
+
"account_balance": u.weighNumRange(0, 50000, 0.8, 2500),
|
|
808
|
+
"account_type": ["checking", "savings", "investment"],
|
|
809
|
+
}
|
|
810
|
+
},
|
|
811
|
+
{
|
|
812
|
+
event: "transaction completed",
|
|
813
|
+
weight: 18,
|
|
814
|
+
isStrictEvent: false,
|
|
815
|
+
properties: {
|
|
816
|
+
"transaction_type": ["purchase", "atm", "direct_deposit", "refund"],
|
|
817
|
+
"amount": u.weighNumRange(1, 5000, 0.3, 50),
|
|
818
|
+
"merchant_category": ["grocery", "restaurant", "gas", "retail", "online", "subscription", "utilities"],
|
|
819
|
+
"payment_method": ["debit", "credit", "contactless", "online"],
|
|
820
|
+
}
|
|
821
|
+
},
|
|
822
|
+
{
|
|
823
|
+
event: "transfer sent",
|
|
824
|
+
weight: 8,
|
|
825
|
+
isStrictEvent: false,
|
|
826
|
+
properties: {
|
|
827
|
+
"transfer_type": ["internal", "external", "p2p", "wire"],
|
|
828
|
+
"amount": u.weighNumRange(10, 10000, 0.3, 200),
|
|
829
|
+
"recipient_type": ["friend", "family", "business", "self"],
|
|
830
|
+
}
|
|
831
|
+
},
|
|
832
|
+
{
|
|
833
|
+
event: "bill paid",
|
|
834
|
+
weight: 6,
|
|
835
|
+
isStrictEvent: false,
|
|
836
|
+
properties: {
|
|
837
|
+
"bill_type": ["rent", "utilities", "phone", "insurance", "subscription", "loan_payment"],
|
|
838
|
+
"amount": u.weighNumRange(20, 3000, 0.5, 150),
|
|
839
|
+
"auto_pay": [false, false, false, true, true],
|
|
840
|
+
}
|
|
841
|
+
},
|
|
842
|
+
{
|
|
843
|
+
event: "bill payment missed",
|
|
844
|
+
weight: 1,
|
|
845
|
+
isStrictEvent: true, // hook-only: created by Hook 6 from manual bill-paid events
|
|
846
|
+
properties: {
|
|
847
|
+
"bill_type": ["rent", "utilities", "phone", "insurance", "subscription", "loan_payment"],
|
|
848
|
+
"amount": u.weighNumRange(20, 3000, 0.5, 150),
|
|
849
|
+
"auto_pay": [false], // carried over from the renamed "bill paid" event; always false (auto-pay never misses)
|
|
850
|
+
}
|
|
851
|
+
},
|
|
852
|
+
{
|
|
853
|
+
event: "budget created",
|
|
854
|
+
weight: 3,
|
|
855
|
+
isStrictEvent: false,
|
|
856
|
+
properties: {
|
|
857
|
+
"category": ["food", "transport", "entertainment", "shopping", "bills", "savings"],
|
|
858
|
+
"monthly_limit": u.weighNumRange(50, 2000, 0.5, 300),
|
|
859
|
+
}
|
|
860
|
+
},
|
|
861
|
+
{
|
|
862
|
+
event: "budget alert",
|
|
863
|
+
weight: 4,
|
|
864
|
+
properties: {
|
|
865
|
+
"alert_type": ["approaching_limit", "exceeded", "on_track"],
|
|
866
|
+
"percent_used": u.weighNumRange(50, 150, 1, 90),
|
|
867
|
+
}
|
|
868
|
+
},
|
|
869
|
+
{
|
|
870
|
+
event: "savings goal set",
|
|
871
|
+
weight: 3,
|
|
872
|
+
isStrictEvent: false,
|
|
873
|
+
properties: {
|
|
874
|
+
"goal_type": ["emergency", "vacation", "car", "home", "education", "retirement"],
|
|
875
|
+
"target_amount": u.weighNumRange(500, 50000, 0.3, 5000),
|
|
876
|
+
"monthly_contribution": u.weighNumRange(25, 2000, 0.5, 200),
|
|
877
|
+
}
|
|
878
|
+
},
|
|
879
|
+
{
|
|
880
|
+
event: "investment made",
|
|
881
|
+
weight: 4,
|
|
882
|
+
isStrictEvent: false,
|
|
883
|
+
properties: {
|
|
884
|
+
"investment_type": ["stocks", "etf", "crypto", "bonds", "mutual_fund"],
|
|
885
|
+
"amount": u.weighNumRange(10, 10000, 0.3, 250),
|
|
886
|
+
"action": ["buy", "sell", "buy"],
|
|
887
|
+
}
|
|
888
|
+
},
|
|
889
|
+
{
|
|
890
|
+
event: "card locked",
|
|
891
|
+
weight: 2,
|
|
892
|
+
isStrictEvent: false,
|
|
893
|
+
properties: {
|
|
894
|
+
"reason": ["lost", "stolen", "suspicious_activity", "travel"],
|
|
895
|
+
}
|
|
896
|
+
},
|
|
897
|
+
{
|
|
898
|
+
event: "dispute filed",
|
|
899
|
+
weight: 2,
|
|
900
|
+
isStrictEvent: false,
|
|
901
|
+
properties: {
|
|
902
|
+
"dispute_amount": u.weighNumRange(10, 2000, 0.5, 100),
|
|
903
|
+
"reason": ["unauthorized", "duplicate", "not_received", "damaged", "wrong_amount"],
|
|
904
|
+
}
|
|
905
|
+
},
|
|
906
|
+
{
|
|
907
|
+
event: "loan applied",
|
|
908
|
+
weight: 2,
|
|
909
|
+
properties: {
|
|
910
|
+
"loan_type": ["personal", "auto", "home", "student", "business"],
|
|
911
|
+
"requested_amount": u.weighNumRange(1000, 100000, 0.3, 10000),
|
|
912
|
+
}
|
|
913
|
+
},
|
|
914
|
+
{
|
|
915
|
+
event: "loan approved",
|
|
916
|
+
weight: 1,
|
|
917
|
+
properties: {
|
|
918
|
+
"loan_type": ["personal", "auto", "home", "student", "business"],
|
|
919
|
+
"approved_amount": u.weighNumRange(1000, 100000, 0.3, 10000),
|
|
920
|
+
"interest_rate": u.weighNumRange(3, 25, 1, 8),
|
|
921
|
+
}
|
|
922
|
+
},
|
|
923
|
+
{
|
|
924
|
+
event: "premium upgraded",
|
|
925
|
+
weight: 2,
|
|
926
|
+
isStrictEvent: false,
|
|
927
|
+
properties: {
|
|
928
|
+
"old_tier": ["basic", "plus", "premium"],
|
|
929
|
+
"new_tier": ["plus", "premium", "premium"],
|
|
930
|
+
"monthly_fee": [4.99, 9.99, 14.99],
|
|
931
|
+
}
|
|
932
|
+
},
|
|
933
|
+
{
|
|
934
|
+
event: "support contacted",
|
|
935
|
+
weight: 3,
|
|
936
|
+
isStrictEvent: false,
|
|
937
|
+
properties: {
|
|
938
|
+
"channel": ["chat", "phone", "email", "in_app"],
|
|
939
|
+
"issue_type": ["transaction", "account", "card", "transfer", "technical"],
|
|
940
|
+
"resolved": [false, true, true, true, true],
|
|
941
|
+
}
|
|
942
|
+
},
|
|
943
|
+
{
|
|
944
|
+
event: "notification opened",
|
|
945
|
+
weight: 10,
|
|
946
|
+
properties: {
|
|
947
|
+
"notification_type": ["transaction", "low_balance", "bill_due", "reward", "security", "promo"],
|
|
948
|
+
"action_taken": [false, false, true, true, true],
|
|
949
|
+
}
|
|
950
|
+
},
|
|
951
|
+
{
|
|
952
|
+
event: "reward redeemed",
|
|
953
|
+
weight: 4,
|
|
954
|
+
isStrictEvent: false,
|
|
955
|
+
properties: {
|
|
956
|
+
"reward_type": ["cashback", "points", "discount", "partner_offer"],
|
|
957
|
+
"value": u.weighNumRange(1, 100, 0.5, 10),
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
],
|
|
961
|
+
|
|
962
|
+
superProps: {
|
|
963
|
+
account_tier: ["basic", "basic", "basic", "plus", "plus", "premium"],
|
|
964
|
+
Platform: ["ios", "android", "web"],
|
|
965
|
+
},
|
|
966
|
+
|
|
967
|
+
userProps: {
|
|
968
|
+
account_tier: ["basic", "basic", "basic", "plus", "plus", "premium"],
|
|
969
|
+
Platform: ["ios", "android", "web"],
|
|
970
|
+
"credit_score_range": ["300-579", "580-669", "670-739", "740-799", "800-850"],
|
|
971
|
+
"income_bracket": ["under_30k", "30k_50k", "50k_75k", "75k_100k", "100k_150k", "over_150k"],
|
|
972
|
+
"account_age_months": u.weighNumRange(1, 60, 0.5, 12),
|
|
973
|
+
"total_balance": u.weighNumRange(0, 100000, 0.3, 5000),
|
|
974
|
+
"has_direct_deposit": [false, false, true, true, true],
|
|
975
|
+
"account_segment": ["personal"],
|
|
976
|
+
"employee_count": [0],
|
|
977
|
+
"annual_revenue": [0],
|
|
978
|
+
"industry": [""],
|
|
979
|
+
"age_range": [""],
|
|
980
|
+
"life_stage": [""],
|
|
981
|
+
},
|
|
982
|
+
|
|
983
|
+
groupKeys: [
|
|
984
|
+
["household_id", 500, ["transaction completed", "transfer sent", "bill paid", "savings goal set"]],
|
|
985
|
+
],
|
|
986
|
+
|
|
987
|
+
groupProps: {
|
|
988
|
+
household_id: {
|
|
989
|
+
"household_size": u.weighNumRange(1, 6),
|
|
990
|
+
"combined_income": u.weighNumRange(20000, 300000, 0.3, 75000),
|
|
991
|
+
"financial_health_score": u.weighNumRange(1, 100, 1, 65),
|
|
992
|
+
"primary_bank": ["NexBank_only", "multi_bank", "NexBank_only"],
|
|
993
|
+
}
|
|
994
|
+
},
|
|
995
|
+
|
|
996
|
+
lookupTables: [],
|
|
997
|
+
|
|
998
|
+
hook(record, type, meta) {
|
|
999
|
+
if (type === "user") return handleUserHooks(record);
|
|
1000
|
+
if (type === "event") return handleEventHooks(record);
|
|
1001
|
+
if (type === "everything") return handleEverythingHooks(record, meta);
|
|
1002
|
+
return record;
|
|
1003
|
+
}
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
// ── STORIES ──
|
|
1007
|
+
// Machine-checkable contract for the 10 hooks above. Thresholds derive from
|
|
1008
|
+
// the knob constants (and the declared property distributions), never from
|
|
1009
|
+
// observed output. duckdb assertions run in disk mode only
|
|
1010
|
+
// (scripts/verify-stories.mjs after scripts/verify-runner.mjs).
|
|
1011
|
+
|
|
1012
|
+
const EV = `read_json_auto('{{PREFIX}}-EVENTS*.json', sample_size=-1, union_by_name=true)`;
|
|
1013
|
+
const US = `read_json_auto('{{PREFIX}}-USERS*.json', sample_size=-1, union_by_name=true)`;
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* Five-tier verdict for a cohort ratio measured by a custom assert:
|
|
1017
|
+
* NAILED within ±10% of target, STRONG past floor, WEAK direction-correct,
|
|
1018
|
+
* INVERSE wrong side of 1, NONE exactly neutral. Mirrors verdictFor() for
|
|
1019
|
+
* op '>=' — needed because avg_aggregate is value-like and the select
|
|
1020
|
+
* grammar can't sum it across the multi-row freq>=1 selection.
|
|
1021
|
+
*/
|
|
1022
|
+
function ratioVerdict(ratio, target, floor, detail, smallestCohort, minCohort) {
|
|
1023
|
+
if (!Number.isFinite(ratio)) return { pass: false, verdict: "NONE", detail: `ratio not computable — ${detail}` };
|
|
1024
|
+
let verdict;
|
|
1025
|
+
if (Math.abs(ratio - target) <= 0.1 * target) verdict = "NAILED";
|
|
1026
|
+
else if (ratio >= floor) verdict = "STRONG";
|
|
1027
|
+
else if (ratio > 1) verdict = "WEAK";
|
|
1028
|
+
else if (ratio < 1) verdict = "INVERSE";
|
|
1029
|
+
else verdict = "NONE";
|
|
1030
|
+
if ((verdict === "NAILED" || verdict === "STRONG") && smallestCohort < minCohort) {
|
|
1031
|
+
verdict = "WEAK";
|
|
1032
|
+
detail += ` — capped: smallest cohort ${smallestCohort} < minCohort ${minCohort}`;
|
|
1033
|
+
}
|
|
1034
|
+
return { pass: verdict === "NAILED" || verdict === "STRONG", verdict, detail };
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/** @type {import("../../../types").DungeonStory[]} */
|
|
1038
|
+
export const stories = [
|
|
1039
|
+
{
|
|
1040
|
+
id: "H1-business-txn-4x",
|
|
1041
|
+
hook: "H1",
|
|
1042
|
+
archetype: "cohort-prop-scale",
|
|
1043
|
+
narrative: `${BUSINESS_LIKELIHOOD}% of accounts are business; their transaction amounts run ${BUSINESS_TXN_MULT}x personal (H1 user attrs + H1B everything-mult)`,
|
|
1044
|
+
assertions: [
|
|
1045
|
+
{
|
|
1046
|
+
// user mix: 20/80 → business/personal user ratio 0.25
|
|
1047
|
+
breakdown: {
|
|
1048
|
+
type: "duckdb",
|
|
1049
|
+
sql: `SELECT account_segment AS seg, count(*) AS user_count FROM ${US} GROUP BY 1`,
|
|
1050
|
+
},
|
|
1051
|
+
select: {
|
|
1052
|
+
business: { where: { seg: "business" } },
|
|
1053
|
+
personal: { where: { seg: "personal" } },
|
|
1054
|
+
},
|
|
1055
|
+
expect: { metric: "business.user_count / personal.user_count", op: "between", target: [0.22, 0.28] },
|
|
1056
|
+
minCohort: 200,
|
|
1057
|
+
},
|
|
1058
|
+
{
|
|
1059
|
+
// amount ratio: BUSINESS_TXN_MULT = 4 exactly; floor 3 absorbs the
|
|
1060
|
+
// H3 fraud-burst txns (fresh $500-3000 amounts spliced AFTER the
|
|
1061
|
+
// H1B multiply, so they dilute the business avg slightly)
|
|
1062
|
+
breakdown: {
|
|
1063
|
+
type: "duckdb",
|
|
1064
|
+
sql: `WITH seg AS (SELECT distinct_id AS user_id, account_segment FROM ${US})
|
|
1065
|
+
SELECT s.account_segment AS seg, avg(e.amount) AS avg_amount, count(DISTINCT e.user_id) AS user_count
|
|
1066
|
+
FROM ${EV} e JOIN seg s USING (user_id)
|
|
1067
|
+
WHERE e.event = 'transaction completed' AND e.amount IS NOT NULL GROUP BY 1`,
|
|
1068
|
+
},
|
|
1069
|
+
select: {
|
|
1070
|
+
business: { where: { seg: "business" } },
|
|
1071
|
+
personal: { where: { seg: "personal" } },
|
|
1072
|
+
},
|
|
1073
|
+
expect: { metric: "business.avg_amount / personal.avg_amount", op: ">=", target: BUSINESS_TXN_MULT, floor: 3 },
|
|
1074
|
+
minCohort: 200,
|
|
1075
|
+
},
|
|
1076
|
+
],
|
|
1077
|
+
},
|
|
1078
|
+
{
|
|
1079
|
+
id: "H2-payday-amounts",
|
|
1080
|
+
hook: "H2",
|
|
1081
|
+
archetype: "temporal-inflection",
|
|
1082
|
+
narrative: `direct deposits ${PAYDAY_DEPOSIT_MULT}x on the 1st/15th; transfers avg ${(PAYDAY_TRANSFER_LIKELIHOOD / 100) * PAYDAY_TRANSFER_MULT + (1 - PAYDAY_TRANSFER_LIKELIHOOD / 100)}x in the 1-3/15-17 windows (${PAYDAY_TRANSFER_LIKELIHOOD}% of transfers x${PAYDAY_TRANSFER_MULT})`,
|
|
1083
|
+
assertions: [
|
|
1084
|
+
{
|
|
1085
|
+
breakdown: {
|
|
1086
|
+
type: "duckdb",
|
|
1087
|
+
sql: `SELECT CASE WHEN EXTRACT(DAY FROM time::TIMESTAMP) IN (1, 15) THEN 'payday' ELSE 'other' END AS bucket,
|
|
1088
|
+
avg(amount) AS avg_amount, count(*) AS event_count
|
|
1089
|
+
FROM ${EV} WHERE event = 'transaction completed' AND transaction_type = 'direct_deposit' AND amount IS NOT NULL GROUP BY 1`,
|
|
1090
|
+
},
|
|
1091
|
+
select: {
|
|
1092
|
+
payday: { where: { bucket: "payday" } },
|
|
1093
|
+
other: { where: { bucket: "other" } },
|
|
1094
|
+
},
|
|
1095
|
+
// PAYDAY_DEPOSIT_MULT = 3 exactly (every 1st/15th deposit tripled)
|
|
1096
|
+
expect: { metric: "payday.avg_amount / other.avg_amount", op: ">=", target: PAYDAY_DEPOSIT_MULT, floor: 2.5 },
|
|
1097
|
+
},
|
|
1098
|
+
{
|
|
1099
|
+
breakdown: {
|
|
1100
|
+
type: "duckdb",
|
|
1101
|
+
sql: `SELECT CASE WHEN EXTRACT(DAY FROM time::TIMESTAMP) IN (1, 2, 3, 15, 16, 17) THEN 'payday_window' ELSE 'other' END AS bucket,
|
|
1102
|
+
avg(amount) AS avg_amount, count(*) AS event_count
|
|
1103
|
+
FROM ${EV} WHERE event = 'transfer sent' AND amount IS NOT NULL GROUP BY 1`,
|
|
1104
|
+
},
|
|
1105
|
+
select: {
|
|
1106
|
+
window: { where: { bucket: "payday_window" } },
|
|
1107
|
+
other: { where: { bucket: "other" } },
|
|
1108
|
+
},
|
|
1109
|
+
// E[mult] = 0.6*2 + 0.4*1 = 1.6
|
|
1110
|
+
expect: { metric: "window.avg_amount / other.avg_amount", op: ">=", target: 1.6, floor: 1.4 },
|
|
1111
|
+
},
|
|
1112
|
+
],
|
|
1113
|
+
},
|
|
1114
|
+
{
|
|
1115
|
+
id: "H3-fraud-cohort-share",
|
|
1116
|
+
hook: "H3",
|
|
1117
|
+
archetype: "bespoke",
|
|
1118
|
+
narrative: `~${FRAUD_LIKELIHOOD}% of SUPPORT-HISTORY users (those with organic card-locked + dispute-filed templates) carry the full fraud signature: ${FRAUD_BURST_MIN}+ rapid credit purchases, then a suspicious-activity lock and an unauthorized dispute within 1h. The bare lock+dispute pair is NOT the detector — organic Support-funnel pairs with colliding reasons outnumber the hook ~4:1. Scoping the denominator to support-history users cancels the template-availability factor (~0.62), so the target is FRAUD_LIKELIHOOD itself`,
|
|
1119
|
+
assertions: [
|
|
1120
|
+
{
|
|
1121
|
+
breakdown: {
|
|
1122
|
+
type: "duckdb",
|
|
1123
|
+
// Numerator: users with >= FRAUD_BURST_MIN credit purchases in the
|
|
1124
|
+
// 65 min before a suspicious lock, plus an unauthorized dispute
|
|
1125
|
+
// within 1h after it (hook stamps lock at burst+5..55 min, dispute
|
|
1126
|
+
// 25 min later). Denominator: users with both template event types
|
|
1127
|
+
// (any reason) — the only users the hook can leave a signature on.
|
|
1128
|
+
sql: `WITH locks AS (SELECT user_id, epoch(time::TIMESTAMP) AS t FROM ${EV} WHERE event = 'card locked' AND reason = 'suspicious_activity'),
|
|
1129
|
+
disputes AS (SELECT user_id, epoch(time::TIMESTAMP) AS t FROM ${EV} WHERE event = 'dispute filed' AND reason = 'unauthorized'),
|
|
1130
|
+
txns AS (SELECT user_id, epoch(time::TIMESTAMP) AS t FROM ${EV} WHERE event = 'transaction completed' AND transaction_type = 'purchase' AND payment_method = 'credit'),
|
|
1131
|
+
burst_locks AS (
|
|
1132
|
+
SELECT l.user_id, l.t FROM locks l JOIN txns x ON x.user_id = l.user_id AND x.t BETWEEN l.t - 3900 AND l.t
|
|
1133
|
+
GROUP BY 1, 2 HAVING count(*) >= ${FRAUD_BURST_MIN}
|
|
1134
|
+
),
|
|
1135
|
+
sig AS (SELECT DISTINCT b.user_id FROM burst_locks b JOIN disputes d ON d.user_id = b.user_id AND d.t - b.t BETWEEN 0 AND 3600),
|
|
1136
|
+
hist AS (SELECT count(*) AS n FROM (
|
|
1137
|
+
SELECT user_id FROM ${EV} WHERE event = 'card locked' GROUP BY 1
|
|
1138
|
+
INTERSECT
|
|
1139
|
+
SELECT user_id FROM ${EV} WHERE event = 'dispute filed' GROUP BY 1
|
|
1140
|
+
))
|
|
1141
|
+
SELECT 'fraud' AS grp, (SELECT count(*) FROM sig) AS user_count, (SELECT count(*) FROM sig)::DOUBLE / (SELECT n FROM hist) AS fraction`,
|
|
1142
|
+
},
|
|
1143
|
+
select: { fraud: { where: { grp: "fraud" } } },
|
|
1144
|
+
// Target = FRAUD_LIKELIHOOD/100 = 0.03. At full fidelity (10K users,
|
|
1145
|
+
// ~300 picks x 0.62 templates x ~0.91 surviving H4 churn ≈ 170 sig
|
|
1146
|
+
// users / ~6200 support-history users ≈ 0.027; binomial sd ~0.002)
|
|
1147
|
+
// the band is ±3-4sd. At 1500-user iteration sd is ~3x wider —
|
|
1148
|
+
// an iteration miss on a low draw is expected; judge at full scale.
|
|
1149
|
+
expect: { metric: "fraud.fraction", op: "between", target: [0.02, 0.04] },
|
|
1150
|
+
minCohort: 100,
|
|
1151
|
+
},
|
|
1152
|
+
],
|
|
1153
|
+
},
|
|
1154
|
+
{
|
|
1155
|
+
id: "H4-lowbal-churn-suppression",
|
|
1156
|
+
hook: "H4",
|
|
1157
|
+
archetype: "retention-divergence",
|
|
1158
|
+
narrative: `users with ${LOW_BALANCE_CHECK_THRESHOLD}+ balance checks under $${LOW_BALANCE_THRESHOLD} lose ${LOW_BALANCE_DROP_LIKELIHOOD}% of post-day-${LOW_BALANCE_CHURN_CUTOFF_DAYS} events — their post/pre event ratio runs ~0.5x the healthy cohort's. Band [0.4, 0.65]: cohorts are classified from OUTPUT counts, so churned users whose dropped events fell under 3 visible low checks dilute the healthy side, pulling the ratio above the raw 0.5`,
|
|
1159
|
+
assertions: [
|
|
1160
|
+
{
|
|
1161
|
+
breakdown: {
|
|
1162
|
+
type: "duckdb",
|
|
1163
|
+
sql: `WITH ev AS (SELECT user_id, event, time::TIMESTAMP AS t, TRY_CAST(account_balance AS DOUBLE) AS bal FROM ${EV}),
|
|
1164
|
+
cutoff AS (SELECT min(t) + INTERVAL ${LOW_BALANCE_CHURN_CUTOFF_DAYS} DAY AS c FROM ev),
|
|
1165
|
+
low AS (SELECT user_id, count(*) FILTER (WHERE event = 'balance checked' AND bal < ${LOW_BALANCE_THRESHOLD}) AS low_checks FROM ev GROUP BY 1)
|
|
1166
|
+
SELECT CASE WHEN l.low_checks >= ${LOW_BALANCE_CHECK_THRESHOLD} THEN 'lowbal' ELSE 'healthy' END AS grp,
|
|
1167
|
+
count(DISTINCT e.user_id) AS user_count,
|
|
1168
|
+
(count(*) FILTER (WHERE e.t >= (SELECT c FROM cutoff)))::DOUBLE / nullif(count(*) FILTER (WHERE e.t < (SELECT c FROM cutoff)), 0) AS post_pre
|
|
1169
|
+
FROM ev e JOIN low l USING (user_id) GROUP BY 1`,
|
|
1170
|
+
},
|
|
1171
|
+
select: {
|
|
1172
|
+
lowbal: { where: { grp: "lowbal" } },
|
|
1173
|
+
healthy: { where: { grp: "healthy" } },
|
|
1174
|
+
},
|
|
1175
|
+
expect: { metric: "lowbal.post_pre / healthy.post_pre", op: "between", target: [0.4, 0.65] },
|
|
1176
|
+
minCohort: 300,
|
|
1177
|
+
},
|
|
1178
|
+
],
|
|
1179
|
+
},
|
|
1180
|
+
{
|
|
1181
|
+
id: "H5-budget-discipline",
|
|
1182
|
+
hook: "H5",
|
|
1183
|
+
archetype: "cohort-prop-scale",
|
|
1184
|
+
narrative: `disciplined budgeters (${BUDGET_DISCIPLINE_MIN}+ budget-created events, ~77% of users) get ${BUDGET_SAVINGS_MULT}x savings contributions and ${BUDGET_INVESTMENT_MULT}x investment amounts vs light budgeters (0-${BUDGET_DISCIPLINE_MIN - 1}, ~14% — measured; a 1+ gate would leave a ~0.4% control group, unmeasurable)`,
|
|
1185
|
+
assertions: [
|
|
1186
|
+
{
|
|
1187
|
+
// avg_aggregate is value-like → custom assert does the user_count-
|
|
1188
|
+
// weighted mean on each side of the discipline gate (select grammar
|
|
1189
|
+
// can't aggregate a value-like column across rows).
|
|
1190
|
+
breakdown: { type: "aggregatePerUser", event: "savings goal set", property: "monthly_contribution", agg: "avg", breakdownByFrequencyOf: "budget created" },
|
|
1191
|
+
assert: (rows) => {
|
|
1192
|
+
const lo = (rows || []).filter(r => r.breakdown_freq < BUDGET_DISCIPLINE_MIN);
|
|
1193
|
+
const hi = (rows || []).filter(r => r.breakdown_freq >= BUDGET_DISCIPLINE_MIN);
|
|
1194
|
+
if (!lo.length || !hi.length) return { pass: false, verdict: "NONE", detail: `missing cohort: light=${lo.length} disciplined=${hi.length} rows` };
|
|
1195
|
+
const wmean = rs => rs.reduce((s, r) => s + r.avg_aggregate * r.user_count, 0) / rs.reduce((s, r) => s + r.user_count, 0);
|
|
1196
|
+
const usersLo = lo.reduce((s, r) => s + r.user_count, 0);
|
|
1197
|
+
const usersHi = hi.reduce((s, r) => s + r.user_count, 0);
|
|
1198
|
+
const ratio = wmean(hi) / wmean(lo);
|
|
1199
|
+
return ratioVerdict(ratio, BUDGET_SAVINGS_MULT, 1.7,
|
|
1200
|
+
`disciplined avg=${wmean(hi).toFixed(0)} (${usersHi}u) vs light=${wmean(lo).toFixed(0)} (${usersLo}u) ratio=${ratio.toFixed(2)}`,
|
|
1201
|
+
Math.min(usersLo, usersHi), 100);
|
|
1202
|
+
},
|
|
1203
|
+
},
|
|
1204
|
+
{
|
|
1205
|
+
breakdown: { type: "aggregatePerUser", event: "investment made", property: "amount", agg: "avg", breakdownByFrequencyOf: "budget created" },
|
|
1206
|
+
assert: (rows) => {
|
|
1207
|
+
const lo = (rows || []).filter(r => r.breakdown_freq < BUDGET_DISCIPLINE_MIN);
|
|
1208
|
+
const hi = (rows || []).filter(r => r.breakdown_freq >= BUDGET_DISCIPLINE_MIN);
|
|
1209
|
+
if (!lo.length || !hi.length) return { pass: false, verdict: "NONE", detail: `missing cohort: light=${lo.length} disciplined=${hi.length} rows` };
|
|
1210
|
+
const wmean = rs => rs.reduce((s, r) => s + r.avg_aggregate * r.user_count, 0) / rs.reduce((s, r) => s + r.user_count, 0);
|
|
1211
|
+
const usersLo = lo.reduce((s, r) => s + r.user_count, 0);
|
|
1212
|
+
const usersHi = hi.reduce((s, r) => s + r.user_count, 0);
|
|
1213
|
+
const ratio = wmean(hi) / wmean(lo);
|
|
1214
|
+
// H7 sell-mult and H10 sweet-band boost hit both cohorts alike
|
|
1215
|
+
// (tier and txn count are independent of budget count) —
|
|
1216
|
+
// floor 1.3 absorbs the residual mix noise
|
|
1217
|
+
return ratioVerdict(ratio, BUDGET_INVESTMENT_MULT, 1.3,
|
|
1218
|
+
`disciplined avg=${wmean(hi).toFixed(0)} (${usersHi}u) vs light=${wmean(lo).toFixed(0)} (${usersLo}u) ratio=${ratio.toFixed(2)}`,
|
|
1219
|
+
Math.min(usersLo, usersHi), 100);
|
|
1220
|
+
},
|
|
1221
|
+
},
|
|
1222
|
+
],
|
|
1223
|
+
},
|
|
1224
|
+
{
|
|
1225
|
+
id: "H6-autopay-missed-share",
|
|
1226
|
+
hook: "H6",
|
|
1227
|
+
archetype: "composition-drift",
|
|
1228
|
+
narrative: `manual payers (60% of bills, auto_pay enum 3/5 false) miss ${MISSED_BILL_LIKELIHOOD}% of payments → missed/paid = (0.6*0.3)/(1-0.18) ~ 0.22`,
|
|
1229
|
+
assertions: [
|
|
1230
|
+
{
|
|
1231
|
+
breakdown: { type: "eventBreakdown", breakdownProperty: "event" },
|
|
1232
|
+
select: {
|
|
1233
|
+
missed: { where: { value: "bill payment missed" } },
|
|
1234
|
+
paid: { where: { value: "bill paid" } },
|
|
1235
|
+
},
|
|
1236
|
+
expect: { metric: "missed.count / paid.count", op: "between", target: [0.19, 0.25] },
|
|
1237
|
+
},
|
|
1238
|
+
],
|
|
1239
|
+
},
|
|
1240
|
+
{
|
|
1241
|
+
id: "H7-premium-tier-value",
|
|
1242
|
+
hook: "H7",
|
|
1243
|
+
archetype: "cohort-prop-scale",
|
|
1244
|
+
narrative: `premium gets ${PREMIUM_REWARD_MULT}x reward value and ${PREMIUM_INVEST_SELL_MULT}x investment-sell amounts; plus gets ${PLUS_REWARD_MULT}x rewards. account_tier is hook-pinned per user, so event-property breakdowns are clean`,
|
|
1245
|
+
assertions: [
|
|
1246
|
+
{
|
|
1247
|
+
breakdown: {
|
|
1248
|
+
type: "duckdb",
|
|
1249
|
+
sql: `SELECT account_tier AS tier, avg(TRY_CAST("value" AS DOUBLE)) AS avg_value, count(DISTINCT user_id) AS user_count
|
|
1250
|
+
FROM ${EV} WHERE event = 'reward redeemed' AND "value" IS NOT NULL GROUP BY 1`,
|
|
1251
|
+
},
|
|
1252
|
+
select: {
|
|
1253
|
+
premium: { where: { tier: "premium" } },
|
|
1254
|
+
basic: { where: { tier: "basic" } },
|
|
1255
|
+
},
|
|
1256
|
+
expect: { metric: "premium.avg_value / basic.avg_value", op: ">=", target: PREMIUM_REWARD_MULT, floor: 2.5 },
|
|
1257
|
+
minCohort: 150,
|
|
1258
|
+
},
|
|
1259
|
+
{
|
|
1260
|
+
breakdown: {
|
|
1261
|
+
type: "duckdb",
|
|
1262
|
+
sql: `SELECT account_tier AS tier, avg(TRY_CAST("value" AS DOUBLE)) AS avg_value, count(DISTINCT user_id) AS user_count
|
|
1263
|
+
FROM ${EV} WHERE event = 'reward redeemed' AND "value" IS NOT NULL GROUP BY 1`,
|
|
1264
|
+
},
|
|
1265
|
+
select: {
|
|
1266
|
+
plus: { where: { tier: "plus" } },
|
|
1267
|
+
basic: { where: { tier: "basic" } },
|
|
1268
|
+
},
|
|
1269
|
+
expect: { metric: "plus.avg_value / basic.avg_value", op: ">=", target: PLUS_REWARD_MULT, floor: 1.3 },
|
|
1270
|
+
minCohort: 150,
|
|
1271
|
+
},
|
|
1272
|
+
{
|
|
1273
|
+
breakdown: {
|
|
1274
|
+
type: "duckdb",
|
|
1275
|
+
sql: `SELECT account_tier AS tier, avg(amount) AS avg_amount, count(DISTINCT user_id) AS user_count
|
|
1276
|
+
FROM ${EV} WHERE event = 'investment made' AND action = 'sell' AND amount IS NOT NULL GROUP BY 1`,
|
|
1277
|
+
},
|
|
1278
|
+
select: {
|
|
1279
|
+
premium: { where: { tier: "premium" } },
|
|
1280
|
+
basic: { where: { tier: "basic" } },
|
|
1281
|
+
},
|
|
1282
|
+
// H5 (x1.5) and H10 (x1.4) investment boosts are tier-independent —
|
|
1283
|
+
// they multiply both cohorts alike; floor 1.6 absorbs the noise
|
|
1284
|
+
expect: { metric: "premium.avg_amount / basic.avg_amount", op: ">=", target: PREMIUM_INVEST_SELL_MULT, floor: 1.6 },
|
|
1285
|
+
minCohort: 150,
|
|
1286
|
+
},
|
|
1287
|
+
],
|
|
1288
|
+
},
|
|
1289
|
+
{
|
|
1290
|
+
id: "H8-month-end-anxiety",
|
|
1291
|
+
hook: "H8",
|
|
1292
|
+
archetype: "temporal-inflection",
|
|
1293
|
+
narrative: `days >= ${MONTH_END_DAY_THRESHOLD}: session durations ${MONTH_END_SESSION_MULT}x, reported balances ${MONTH_END_BALANCE_MULT}x`,
|
|
1294
|
+
assertions: [
|
|
1295
|
+
{
|
|
1296
|
+
breakdown: {
|
|
1297
|
+
type: "duckdb",
|
|
1298
|
+
sql: `SELECT CASE WHEN EXTRACT(DAY FROM time::TIMESTAMP) >= ${MONTH_END_DAY_THRESHOLD} THEN 'monthEnd' ELSE 'other' END AS bucket,
|
|
1299
|
+
avg(session_duration_sec) AS avg_duration, count(*) AS event_count
|
|
1300
|
+
FROM ${EV} WHERE event = 'app session' AND session_duration_sec IS NOT NULL GROUP BY 1`,
|
|
1301
|
+
},
|
|
1302
|
+
select: {
|
|
1303
|
+
monthEnd: { where: { bucket: "monthEnd" } },
|
|
1304
|
+
other: { where: { bucket: "other" } },
|
|
1305
|
+
},
|
|
1306
|
+
expect: { metric: "monthEnd.avg_duration / other.avg_duration", op: ">=", target: MONTH_END_SESSION_MULT, floor: 1.25 },
|
|
1307
|
+
},
|
|
1308
|
+
{
|
|
1309
|
+
breakdown: {
|
|
1310
|
+
type: "duckdb",
|
|
1311
|
+
sql: `SELECT CASE WHEN EXTRACT(DAY FROM time::TIMESTAMP) >= ${MONTH_END_DAY_THRESHOLD} THEN 'monthEnd' ELSE 'other' END AS bucket,
|
|
1312
|
+
avg(TRY_CAST(account_balance AS DOUBLE)) AS avg_balance, count(*) AS event_count
|
|
1313
|
+
FROM ${EV} WHERE event = 'balance checked' AND account_balance IS NOT NULL GROUP BY 1`,
|
|
1314
|
+
},
|
|
1315
|
+
select: {
|
|
1316
|
+
monthEnd: { where: { bucket: "monthEnd" } },
|
|
1317
|
+
other: { where: { bucket: "other" } },
|
|
1318
|
+
},
|
|
1319
|
+
expect: { metric: "monthEnd.avg_balance / other.avg_balance", op: "<=", target: MONTH_END_BALANCE_MULT, floor: 0.8 },
|
|
1320
|
+
},
|
|
1321
|
+
],
|
|
1322
|
+
},
|
|
1323
|
+
{
|
|
1324
|
+
id: "H9-onboarding-ttc-by-tier",
|
|
1325
|
+
hook: "H9",
|
|
1326
|
+
archetype: "funnel-ttc-by-segment",
|
|
1327
|
+
narrative: `onboarding TTC scaled per tier: basic x${TTC_BASIC_FACTOR}, premium x${TTC_PREMIUM_FACTOR} → basic/premium MEDIAN TTC ratio = ${(TTC_BASIC_FACTOR / TTC_PREMIUM_FACTOR).toFixed(3)}, plus (x1.0) between them. Median, not avg: TTC is heavy-tailed under a 30-day window and converter cohorts are small (only born-in users run onboarding) — at iteration scale a single straggler made plus's AVG exceed basic's; the median ratio recovers the exact factors`,
|
|
1328
|
+
assertions: [
|
|
1329
|
+
{
|
|
1330
|
+
breakdown: {
|
|
1331
|
+
type: "timeToConvert",
|
|
1332
|
+
steps: ["account opened", "app session", "balance checked"],
|
|
1333
|
+
breakdownByUserProperty: "account_tier",
|
|
1334
|
+
conversionWindowMs: 30 * 86400000, // funnel default conversionWindowDays
|
|
1335
|
+
},
|
|
1336
|
+
select: {
|
|
1337
|
+
basic: { where: { segment_value: "basic" } },
|
|
1338
|
+
premium: { where: { segment_value: "premium" } },
|
|
1339
|
+
},
|
|
1340
|
+
// multiplicative gap scaling multiplies the median exactly →
|
|
1341
|
+
// median ratio = TTC_BASIC_FACTOR / TTC_PREMIUM_FACTOR ≈ 1.985
|
|
1342
|
+
expect: { metric: "basic.median_ttc_ms / premium.median_ttc_ms", op: ">=", target: TTC_BASIC_FACTOR / TTC_PREMIUM_FACTOR, floor: 1.5 },
|
|
1343
|
+
minCohort: 100,
|
|
1344
|
+
},
|
|
1345
|
+
{
|
|
1346
|
+
breakdown: {
|
|
1347
|
+
type: "timeToConvert",
|
|
1348
|
+
steps: ["account opened", "app session", "balance checked"],
|
|
1349
|
+
breakdownByUserProperty: "account_tier",
|
|
1350
|
+
conversionWindowMs: 30 * 86400000,
|
|
1351
|
+
},
|
|
1352
|
+
select: {
|
|
1353
|
+
plus: { where: { segment_value: "plus" } },
|
|
1354
|
+
premium: { where: { segment_value: "premium" } },
|
|
1355
|
+
},
|
|
1356
|
+
// plus is unscaled (factor 1.0) → plus/premium = 1/0.67 ≈ 1.49;
|
|
1357
|
+
// ordering check that premium is genuinely fastest
|
|
1358
|
+
expect: { metric: "plus.median_ttc_ms / premium.median_ttc_ms", op: ">=", target: 1 / TTC_PREMIUM_FACTOR, floor: 1.2 },
|
|
1359
|
+
minCohort: 100,
|
|
1360
|
+
},
|
|
1361
|
+
],
|
|
1362
|
+
},
|
|
1363
|
+
{
|
|
1364
|
+
id: "H10-txn-magic-number",
|
|
1365
|
+
hook: "H10",
|
|
1366
|
+
archetype: "frequency-sweet-spot",
|
|
1367
|
+
narrative: `sweet-band users (${TXN_SWEET_MIN}-${TXN_SWEET_MAX} txns) invest ${TXN_INVESTMENT_BOOST}x vs the 1-${TXN_SWEET_MIN - 1} band; over-band (${TXN_OVER_THRESHOLD}+) loses ${TXN_PREMIUM_DROP_LIKELIHOOD}% of premium upgrades — visible as upgrade share of NON-TXN events (raw upgrades-per-user rises with activity, and the over band is selected for high txn counts, tilting its event mix toward txns; normalizing by non-txn events removes both distortions)`,
|
|
1368
|
+
assertions: [
|
|
1369
|
+
{
|
|
1370
|
+
breakdown: {
|
|
1371
|
+
type: "duckdb",
|
|
1372
|
+
sql: `WITH txn AS (SELECT user_id, count(*) AS n FROM ${EV} WHERE event = 'transaction completed' GROUP BY 1),
|
|
1373
|
+
bands AS (SELECT user_id, CASE WHEN n BETWEEN ${TXN_SWEET_MIN} AND ${TXN_SWEET_MAX} THEN 'sweet' WHEN n < ${TXN_SWEET_MIN} THEN 'low' ELSE 'over' END AS band FROM txn)
|
|
1374
|
+
SELECT b.band, avg(e.amount) AS avg_amount, count(DISTINCT e.user_id) AS user_count
|
|
1375
|
+
FROM ${EV} e JOIN bands b USING (user_id)
|
|
1376
|
+
WHERE e.event = 'investment made' AND e.amount IS NOT NULL GROUP BY 1`,
|
|
1377
|
+
},
|
|
1378
|
+
select: {
|
|
1379
|
+
sweet: { where: { band: "sweet" } },
|
|
1380
|
+
low: { where: { band: "low" } },
|
|
1381
|
+
},
|
|
1382
|
+
// TXN_INVESTMENT_BOOST = 1.4; floor 1.2 absorbs band-mix noise
|
|
1383
|
+
// (H5's x1.5 is near-universal in both bands)
|
|
1384
|
+
expect: { metric: "sweet.avg_amount / low.avg_amount", op: ">=", target: TXN_INVESTMENT_BOOST, floor: 1.2 },
|
|
1385
|
+
minCohort: 300,
|
|
1386
|
+
},
|
|
1387
|
+
{
|
|
1388
|
+
breakdown: {
|
|
1389
|
+
type: "duckdb",
|
|
1390
|
+
// share of NON-TXN events: the over band is selected for high txn
|
|
1391
|
+
// counts, which mechanically tilts its mix toward txns — dividing
|
|
1392
|
+
// by non-txn events removes the tilt so only the 20% drop remains
|
|
1393
|
+
sql: `WITH txn AS (SELECT user_id, count(*) AS n FROM ${EV} WHERE event = 'transaction completed' GROUP BY 1),
|
|
1394
|
+
bands AS (SELECT user_id, CASE WHEN n BETWEEN ${TXN_SWEET_MIN} AND ${TXN_SWEET_MAX} THEN 'sweet' WHEN n < ${TXN_SWEET_MIN} THEN 'low' ELSE 'over' END AS band FROM txn)
|
|
1395
|
+
SELECT b.band, (count(*) FILTER (WHERE e.event = 'premium upgraded'))::DOUBLE / (count(*) FILTER (WHERE e.event != 'transaction completed')) AS upgrade_share, count(DISTINCT e.user_id) AS user_count
|
|
1396
|
+
FROM ${EV} e JOIN bands b USING (user_id) GROUP BY 1`,
|
|
1397
|
+
},
|
|
1398
|
+
select: {
|
|
1399
|
+
over: { where: { band: "over" } },
|
|
1400
|
+
sweet: { where: { band: "sweet" } },
|
|
1401
|
+
},
|
|
1402
|
+
// drop knob = 20% → share ratio 0.8; STRONG bound 0.9
|
|
1403
|
+
expect: { metric: "over.upgrade_share / sweet.upgrade_share", op: "<=", target: 1 - TXN_PREMIUM_DROP_LIKELIHOOD / 100, floor: 0.9 },
|
|
1404
|
+
minCohort: 300,
|
|
1405
|
+
},
|
|
1406
|
+
],
|
|
1407
|
+
},
|
|
1408
|
+
];
|
|
1409
|
+
|
|
1410
|
+
export default config;
|