@ak--47/dungeon-master 1.5.4 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +21 -12
  2. package/.claude/skills/create-dungeon/SKILL.md +65 -5
  3. package/.claude/skills/create-project/SKILL.md +1 -1
  4. package/.claude/skills/create-project/provision.mjs +57 -10
  5. package/.claude/skills/verify-dungeon/SKILL.md +56 -25
  6. package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
  7. package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
  8. package/.claude/skills/write-hooks/SKILL.md +156 -18
  9. package/CHANGELOG.md +183 -0
  10. package/HOOKS.md +566 -112
  11. package/README.md +28 -9
  12. package/docs/guides/1.3.0-upgrade-guide.md +262 -0
  13. package/docs/guides/1.3.1-upgrade-guide.md +328 -0
  14. package/docs/guides/1.4.0-upgrade-guide.md +130 -0
  15. package/docs/guides/1.4.1-upgrade-guide.md +125 -0
  16. package/docs/guides/1.4.3-upgrade-guide.md +200 -0
  17. package/docs/guides/1.4.4-upgrade-guide.md +95 -0
  18. package/docs/guides/1.5.0-upgrade-guide.md +426 -0
  19. package/docs/guides/1.5.1-upgrade-guide.md +178 -0
  20. package/docs/guides/1.5.3-upgrade-guide.md +117 -0
  21. package/docs/guides/1.6.0-upgrade-guide.md +204 -0
  22. package/dungeons/technical/stories-verify.js +152 -0
  23. package/dungeons/vertical/README.md +154 -0
  24. package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
  25. package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
  26. package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
  27. package/dungeons/vertical/community/community.js +1589 -0
  28. package/dungeons/vertical/community/community.sql +216 -0
  29. package/dungeons/vertical/community/community.verify.mjs +72 -0
  30. package/dungeons/vertical/crypto/crypto.js +1483 -0
  31. package/dungeons/vertical/crypto/crypto.sql +194 -0
  32. package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
  33. package/dungeons/vertical/dating/dating.js +1540 -0
  34. package/dungeons/vertical/dating/dating.sql +288 -0
  35. package/dungeons/vertical/dating/dating.verify.mjs +72 -0
  36. package/dungeons/vertical/devtools/devtools.js +1524 -0
  37. package/dungeons/vertical/devtools/devtools.sql +161 -0
  38. package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
  39. package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
  40. package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
  41. package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
  42. package/dungeons/vertical/education/education.js +1763 -0
  43. package/dungeons/vertical/education/education.sql +243 -0
  44. package/dungeons/vertical/education/education.verify.mjs +75 -0
  45. package/dungeons/vertical/fintech/fintech.js +1410 -0
  46. package/dungeons/vertical/fintech/fintech.sql +175 -0
  47. package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
  48. package/dungeons/vertical/fitness/fitness.js +1495 -0
  49. package/dungeons/vertical/fitness/fitness.sql +206 -0
  50. package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
  51. package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
  52. package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
  53. package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
  54. package/dungeons/vertical/gaming/gaming.js +1741 -0
  55. package/dungeons/vertical/gaming/gaming.sql +249 -0
  56. package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
  57. package/dungeons/vertical/healthcare/healthcare.js +1488 -0
  58. package/dungeons/vertical/healthcare/healthcare.sql +193 -0
  59. package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
  60. package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
  61. package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
  62. package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
  63. package/dungeons/vertical/logistics/logistics.js +1541 -0
  64. package/dungeons/vertical/logistics/logistics.sql +205 -0
  65. package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
  66. package/dungeons/vertical/marketplace/marketplace.js +1438 -0
  67. package/dungeons/vertical/marketplace/marketplace.sql +148 -0
  68. package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
  69. package/dungeons/vertical/{media.js → media/media.js} +557 -58
  70. package/dungeons/vertical/media/media.sql +173 -0
  71. package/dungeons/vertical/media/media.verify.mjs +74 -0
  72. package/dungeons/vertical/real-estate/real-estate.js +1629 -0
  73. package/dungeons/vertical/real-estate/real-estate.sql +167 -0
  74. package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
  75. package/dungeons/vertical/sass/sass.js +1733 -0
  76. package/dungeons/vertical/sass/sass.sql +173 -0
  77. package/dungeons/vertical/sass/sass.verify.mjs +74 -0
  78. package/dungeons/vertical/social/social.js +1456 -0
  79. package/dungeons/vertical/social/social.sql +160 -0
  80. package/dungeons/vertical/social/social.verify.mjs +76 -0
  81. package/dungeons/vertical/streaming/streaming.js +677 -0
  82. package/dungeons/vertical/streaming/streaming.sql +101 -0
  83. package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
  84. package/dungeons/vertical/support-desk/support-desk.js +741 -0
  85. package/dungeons/vertical/support-desk/support-desk.sql +137 -0
  86. package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
  87. package/dungeons/vertical/travel/travel.js +1603 -0
  88. package/dungeons/vertical/travel/travel.sql +236 -0
  89. package/dungeons/vertical/travel/travel.verify.mjs +72 -0
  90. package/index.js +2 -2
  91. package/lib/core/config-validator.js +37 -2
  92. package/lib/core/context.js +4 -2
  93. package/lib/core/dungeon-loader.js +20 -15
  94. package/lib/core/dungeon-to-json.js +22 -4
  95. package/lib/core/storage.js +3 -3
  96. package/lib/generators/funnels.js +36 -9
  97. package/lib/hook-helpers/cohort.js +53 -0
  98. package/lib/hook-helpers/index.js +8 -0
  99. package/lib/hook-helpers/mutate.js +1 -1
  100. package/lib/hook-helpers/shape.js +319 -0
  101. package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
  102. package/lib/hook-patterns/attributed-by-source.js +60 -58
  103. package/lib/hook-patterns/frequency-by-frequency.js +14 -4
  104. package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
  105. package/lib/hook-patterns/index.js +1 -1
  106. package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
  107. package/lib/orchestrators/user-loop.js +41 -0
  108. package/lib/templates/story-spec.schema.json +175 -0
  109. package/lib/utils/json-evaluator.js +1 -1
  110. package/lib/utils/utils.js +5 -4
  111. package/lib/verify/coerce.js +186 -0
  112. package/lib/verify/counting.js +270 -32
  113. package/lib/verify/emulate-breakdown.js +1285 -203
  114. package/lib/verify/first-time.js +89 -0
  115. package/lib/verify/flows.js +679 -0
  116. package/lib/verify/formula.js +259 -0
  117. package/lib/verify/funnel-engine.js +697 -113
  118. package/lib/verify/index.js +32 -4
  119. package/lib/verify/sessionize.js +275 -0
  120. package/lib/verify/story-runner.js +483 -0
  121. package/lib/verify/verify-dungeon.js +59 -37
  122. package/package.json +2 -1
  123. package/scripts/extract-dungeon-schema.mjs +2 -2
  124. package/scripts/run-many.mjs +2 -2
  125. package/scripts/verify-runner.mjs +2 -2
  126. package/scripts/verify-stories.mjs +244 -0
  127. package/types.d.ts +296 -30
  128. package/dungeons/vertical/ai-platform.js +0 -818
  129. package/dungeons/vertical/community.js +0 -966
  130. package/dungeons/vertical/crypto.js +0 -895
  131. package/dungeons/vertical/dating.js +0 -830
  132. package/dungeons/vertical/devtools.js +0 -1050
  133. package/dungeons/vertical/ecommerce.js +0 -811
  134. package/dungeons/vertical/education.js +0 -964
  135. package/dungeons/vertical/fintech.js +0 -946
  136. package/dungeons/vertical/fitness.js +0 -935
  137. package/dungeons/vertical/food-delivery.js +0 -934
  138. package/dungeons/vertical/gaming.js +0 -1120
  139. package/dungeons/vertical/healthcare.js +0 -945
  140. package/dungeons/vertical/insurance-application.js +0 -1039
  141. package/dungeons/vertical/logistics.js +0 -986
  142. package/dungeons/vertical/marketplace.js +0 -1019
  143. package/dungeons/vertical/real-estate.js +0 -872
  144. package/dungeons/vertical/sass.js +0 -995
  145. package/dungeons/vertical/social.js +0 -904
  146. package/dungeons/vertical/travel.js +0 -930
@@ -0,0 +1,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;