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

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 (149) 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/powertools/SKILL.md +75 -0
  6. package/.claude/skills/powertools/pt.mjs +91 -0
  7. package/.claude/skills/powertools/snapshot-project.mjs +124 -0
  8. package/.claude/skills/verify-dungeon/SKILL.md +56 -25
  9. package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
  10. package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
  11. package/.claude/skills/write-hooks/SKILL.md +156 -18
  12. package/CHANGELOG.md +224 -0
  13. package/HOOKS.md +566 -112
  14. package/README.md +30 -9
  15. package/docs/guides/1.3.0-upgrade-guide.md +262 -0
  16. package/docs/guides/1.3.1-upgrade-guide.md +328 -0
  17. package/docs/guides/1.4.0-upgrade-guide.md +130 -0
  18. package/docs/guides/1.4.1-upgrade-guide.md +125 -0
  19. package/docs/guides/1.4.3-upgrade-guide.md +200 -0
  20. package/docs/guides/1.4.4-upgrade-guide.md +95 -0
  21. package/docs/guides/1.5.0-upgrade-guide.md +426 -0
  22. package/docs/guides/1.5.1-upgrade-guide.md +178 -0
  23. package/docs/guides/1.5.3-upgrade-guide.md +117 -0
  24. package/docs/guides/1.6.0-upgrade-guide.md +204 -0
  25. package/dungeons/technical/stories-verify.js +152 -0
  26. package/dungeons/vertical/README.md +154 -0
  27. package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
  28. package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
  29. package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
  30. package/dungeons/vertical/community/community.js +1589 -0
  31. package/dungeons/vertical/community/community.sql +216 -0
  32. package/dungeons/vertical/community/community.verify.mjs +72 -0
  33. package/dungeons/vertical/crypto/crypto.js +1483 -0
  34. package/dungeons/vertical/crypto/crypto.sql +194 -0
  35. package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
  36. package/dungeons/vertical/dating/dating.js +1540 -0
  37. package/dungeons/vertical/dating/dating.sql +288 -0
  38. package/dungeons/vertical/dating/dating.verify.mjs +72 -0
  39. package/dungeons/vertical/devtools/devtools.js +1524 -0
  40. package/dungeons/vertical/devtools/devtools.sql +161 -0
  41. package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
  42. package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
  43. package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
  44. package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
  45. package/dungeons/vertical/education/education.js +1763 -0
  46. package/dungeons/vertical/education/education.sql +243 -0
  47. package/dungeons/vertical/education/education.verify.mjs +75 -0
  48. package/dungeons/vertical/fintech/fintech.js +1410 -0
  49. package/dungeons/vertical/fintech/fintech.sql +175 -0
  50. package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
  51. package/dungeons/vertical/fitness/fitness.js +1495 -0
  52. package/dungeons/vertical/fitness/fitness.sql +206 -0
  53. package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
  54. package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
  55. package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
  56. package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
  57. package/dungeons/vertical/gaming/gaming.js +1741 -0
  58. package/dungeons/vertical/gaming/gaming.sql +249 -0
  59. package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
  60. package/dungeons/vertical/healthcare/healthcare.js +1488 -0
  61. package/dungeons/vertical/healthcare/healthcare.sql +193 -0
  62. package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
  63. package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
  64. package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
  65. package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
  66. package/dungeons/vertical/logistics/logistics.js +1541 -0
  67. package/dungeons/vertical/logistics/logistics.sql +205 -0
  68. package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
  69. package/dungeons/vertical/marketplace/marketplace.js +1438 -0
  70. package/dungeons/vertical/marketplace/marketplace.sql +148 -0
  71. package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
  72. package/dungeons/vertical/{media.js → media/media.js} +557 -58
  73. package/dungeons/vertical/media/media.sql +173 -0
  74. package/dungeons/vertical/media/media.verify.mjs +74 -0
  75. package/dungeons/vertical/real-estate/real-estate.js +1629 -0
  76. package/dungeons/vertical/real-estate/real-estate.sql +167 -0
  77. package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
  78. package/dungeons/vertical/sass/sass.js +1733 -0
  79. package/dungeons/vertical/sass/sass.sql +173 -0
  80. package/dungeons/vertical/sass/sass.verify.mjs +74 -0
  81. package/dungeons/vertical/social/social.js +1456 -0
  82. package/dungeons/vertical/social/social.sql +160 -0
  83. package/dungeons/vertical/social/social.verify.mjs +76 -0
  84. package/dungeons/vertical/streaming/streaming.js +677 -0
  85. package/dungeons/vertical/streaming/streaming.sql +101 -0
  86. package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
  87. package/dungeons/vertical/support-desk/support-desk.js +741 -0
  88. package/dungeons/vertical/support-desk/support-desk.sql +137 -0
  89. package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
  90. package/dungeons/vertical/travel/travel.js +1603 -0
  91. package/dungeons/vertical/travel/travel.sql +236 -0
  92. package/dungeons/vertical/travel/travel.verify.mjs +72 -0
  93. package/index.js +7 -3
  94. package/lib/core/config-validator.js +37 -2
  95. package/lib/core/context.js +4 -2
  96. package/lib/core/dungeon-loader.js +20 -15
  97. package/lib/core/dungeon-to-json.js +22 -4
  98. package/lib/core/storage.js +3 -3
  99. package/lib/generators/funnels.js +36 -9
  100. package/lib/hook-helpers/cohort.js +53 -0
  101. package/lib/hook-helpers/index.js +8 -0
  102. package/lib/hook-helpers/mutate.js +1 -1
  103. package/lib/hook-helpers/shape.js +319 -0
  104. package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
  105. package/lib/hook-patterns/attributed-by-source.js +60 -58
  106. package/lib/hook-patterns/frequency-by-frequency.js +14 -4
  107. package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
  108. package/lib/hook-patterns/index.js +1 -1
  109. package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
  110. package/lib/orchestrators/user-loop.js +41 -0
  111. package/lib/templates/story-spec.schema.json +175 -0
  112. package/lib/utils/json-evaluator.js +1 -1
  113. package/lib/utils/utils.js +151 -59
  114. package/lib/verify/coerce.js +186 -0
  115. package/lib/verify/counting.js +270 -32
  116. package/lib/verify/emulate-breakdown.js +1285 -203
  117. package/lib/verify/first-time.js +89 -0
  118. package/lib/verify/flows.js +679 -0
  119. package/lib/verify/formula.js +259 -0
  120. package/lib/verify/funnel-engine.js +697 -113
  121. package/lib/verify/index.js +32 -4
  122. package/lib/verify/sessionize.js +275 -0
  123. package/lib/verify/story-runner.js +483 -0
  124. package/lib/verify/verify-dungeon.js +59 -37
  125. package/package.json +2 -1
  126. package/scripts/extract-dungeon-schema.mjs +2 -2
  127. package/scripts/run-many.mjs +2 -2
  128. package/scripts/verify-runner.mjs +2 -2
  129. package/scripts/verify-stories.mjs +244 -0
  130. package/types.d.ts +298 -30
  131. package/dungeons/vertical/ai-platform.js +0 -818
  132. package/dungeons/vertical/community.js +0 -966
  133. package/dungeons/vertical/crypto.js +0 -895
  134. package/dungeons/vertical/dating.js +0 -830
  135. package/dungeons/vertical/devtools.js +0 -1050
  136. package/dungeons/vertical/ecommerce.js +0 -811
  137. package/dungeons/vertical/education.js +0 -964
  138. package/dungeons/vertical/fintech.js +0 -946
  139. package/dungeons/vertical/fitness.js +0 -935
  140. package/dungeons/vertical/food-delivery.js +0 -934
  141. package/dungeons/vertical/gaming.js +0 -1120
  142. package/dungeons/vertical/healthcare.js +0 -945
  143. package/dungeons/vertical/insurance-application.js +0 -1039
  144. package/dungeons/vertical/logistics.js +0 -986
  145. package/dungeons/vertical/marketplace.js +0 -1019
  146. package/dungeons/vertical/real-estate.js +0 -872
  147. package/dungeons/vertical/sass.js +0 -995
  148. package/dungeons/vertical/social.js +0 -904
  149. package/dungeons/vertical/travel.js +0 -930
@@ -1,830 +0,0 @@
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 "../../lib/utils/utils.js";
7
- /** @typedef {import("../../types").Dungeon} Config */
8
-
9
- // ── OVERVIEW ──
10
- /*
11
- * NAME: MeetCute
12
- * APP: Swipe-based dating app (Hinge/Tinder-style) with profile prompts,
13
- * photo verification, matchmaking, messaging, premium tiers. Users
14
- * create a profile with photos and prompts, swipe on potential
15
- * matches, message, exchange numbers, and schedule dates. Premium
16
- * subscribers get boosts, super-likes, and see-who-liked-you.
17
- * SCALE: 30,000 users, ~720K events, 121 days (2026-01-01 → 2026-05-01)
18
- * CORE LOOP: profile created → photo uploaded → swipe right → match received → message sent → phone number exchanged → date scheduled
19
- *
20
- * EVENTS (17):
21
- * photo uploaded (12) > swipe right (10) > swipe left (8) > app opened (8)
22
- * > message sent (6) > message received (5) > profile viewed (5) > match received (4)
23
- * > prompt answered (3) > bio updated (2) > boost activated (2)
24
- * > profile created (1) > phone number exchanged (1) > date scheduled (1)
25
- * > premium upgrade (1) > premium cancelled (1) > report user (1)
26
- *
27
- * FUNNELS (4):
28
- * - Onboarding: profile created → photo uploaded → swipe right (75%)
29
- * - Match Flow: swipe right → match received → message sent (50%, reentry)
30
- * - Date Funnel: message sent → phone number exchanged → date scheduled (25%, reentry)
31
- * - Monetization: app opened → boost activated → premium upgrade (20%)
32
- *
33
- * USER PROPS: subscription, age_range, gender, looking_for, photo_count, total_matches, total_messages_sent, profile_completeness, Platform
34
- * SUPER PROPS: subscription, Platform
35
- * SCD PROPS: subscription_tier (Free/Premium/Elite, monthly fuzzy, max 6)
36
- * GROUPS: none
37
- */
38
-
39
- // ── HOOK STORIES ──
40
- /*
41
- * NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable via
42
- * behavioral cohorts, raw-prop breakdowns, or funnel analysis.
43
- *
44
- * -------------------------------------------------------------------------------------
45
- * 1. PHOTO MAGIC NUMBER (everything)
46
- * -------------------------------------------------------------------------------------
47
- * PATTERN: Sweet 2-5 photos uploaded → 2-4 extra cloned match-received events
48
- * per existing match. Over 6+ photos → match_score drops 35% on match-received
49
- * events (over-curated profile reads as fake; quality matches don't trust it).
50
- * No flag.
51
- *
52
- * HOW TO FIND IT IN MIXPANEL:
53
- * Report 1: Matches per User by Photo-Count Bucket
54
- * - Cohort A: users with 2-5 "photo uploaded"
55
- * - Cohort B: users with 0-1
56
- * - Event: "match received" → Total per user
57
- * - Expected: A ~ 2-3x B
58
- *
59
- * Report 2: Avg match_score on Heavy Photo Uploaders
60
- * - Cohort C: users with >= 6 "photo uploaded"
61
- * - Cohort A: users with 2-5
62
- * - Event: "match received" → AVG of match_score
63
- * - Expected: C ~ 0.65x A (35% lower match quality)
64
- *
65
- * REAL-WORLD ANALOGUE: A few good photos signal authenticity; too many
66
- * curated shots read as catfish or staged.
67
- *
68
- * -------------------------------------------------------------------------------------
69
- * 2. WEEKEND SWIPE SURGE (everything)
70
- * -------------------------------------------------------------------------------------
71
- * PATTERN: Sunday swipes get heavy cloning (evening 5x, daytime 2x)
72
- * to overcome soup DOW weight deficit. No flag.
73
- *
74
- * HOW TO FIND IT IN MIXPANEL:
75
- * Report 1: Swipe Volume by Day of Week
76
- * - Insights > "swipe right" → Total → Breakdown: Day of Week
77
- * - Expected: Sunday taller than other days
78
- *
79
- * REAL-WORLD ANALOGUE: Sunday Scaries drive swipe activity.
80
- *
81
- * -------------------------------------------------------------------------------------
82
- * 3. SUPER-LIKE EFFECT (everything)
83
- * -------------------------------------------------------------------------------------
84
- * PATTERN: Each is_super_like=true swipe clones 3 extra match-received events
85
- * within 5-120 minutes. No flag.
86
- *
87
- * HOW TO FIND IT IN MIXPANEL:
88
- * Report 1: Super-Like to Match Funnel
89
- * - Funnels > "swipe right" (filter: is_super_like=true) → "match received"
90
- * - vs same with is_super_like=false
91
- * - Expected: super-like funnel ~ 3x higher conversion
92
- *
93
- * REAL-WORLD ANALOGUE: Super-likes dramatically lift match rates.
94
- *
95
- * -------------------------------------------------------------------------------------
96
- * 4. PREMIUM MATCH BOOST (everything)
97
- * -------------------------------------------------------------------------------------
98
- * PATTERN: Premium subscribers get 2x match events; Elite get 4x + cloned
99
- * profile-viewed events (see-who-liked-you). Reads subscription from profile.
100
- *
101
- * HOW TO FIND IT IN MIXPANEL:
102
- * Report 1: Matches by Subscription Tier
103
- * - Insights > "match received" → Total per user → Breakdown: subscription
104
- * - Expected: Free ~ 1x, Premium ~ 2x, Elite ~ 4x
105
- *
106
- * REAL-WORLD ANALOGUE: Premium tiers boost visibility.
107
- *
108
- * -------------------------------------------------------------------------------------
109
- * 5. GHOSTING CHURN (everything)
110
- * -------------------------------------------------------------------------------------
111
- * PATTERN: Users with match-received but no message-sent within 48 hours lose
112
- * 80% of post-match events. No flag.
113
- *
114
- * HOW TO FIND IT IN MIXPANEL:
115
- * Report 1: Retention by Messaging Behavior
116
- * - Retention starting "match received"
117
- * - Cohort A: users with >= 1 "message sent" within 48 hours of match
118
- * - Cohort B: rest
119
- * - Expected: B drops sharply
120
- *
121
- * REAL-WORLD ANALOGUE: Non-responders churn.
122
- *
123
- * -------------------------------------------------------------------------------------
124
- * 6. BIO + PROMPT POWER USERS (everything)
125
- * -------------------------------------------------------------------------------------
126
- * PATTERN: Users with bio-updated AND 3+ prompt-answered events get 3 extra
127
- * cloned date-scheduled events per existing. No flag.
128
- *
129
- * HOW TO FIND IT IN MIXPANEL:
130
- * Report 1: Dates per User by Profile Effort
131
- * - Cohort A: users with bio-updated AND >= 3 "prompt answered"
132
- * - Cohort B: rest
133
- * - Event: "date scheduled" → Total per user
134
- * - Expected: A ~ 4x B
135
- *
136
- * REAL-WORLD ANALOGUE: Profile effort signals serious intent.
137
- *
138
- * -------------------------------------------------------------------------------------
139
- * 7. VALENTINE'S DAY SPIKE (everything)
140
- * -------------------------------------------------------------------------------------
141
- * PATTERN: Days 58-63 (V-Day window): profile-created events cloned 3x and
142
- * premium-upgrade events cloned 5x. No flag — discover via line chart by day.
143
- *
144
- * HOW TO FIND IT IN MIXPANEL:
145
- * Report 1: Signup Volume Over Time
146
- * - Insights > "profile created" → Total → Line by day
147
- * - Expected: spike ~ 3x days 58-63
148
- *
149
- * REAL-WORLD ANALOGUE: Love is expensive.
150
- *
151
- * -------------------------------------------------------------------------------------
152
- * 8. OFF-APP RETENTION (everything)
153
- * -------------------------------------------------------------------------------------
154
- * PATTERN: Users with phone-exchanged OR date-scheduled in first 14 days
155
- * get extra cloned app-open + swipe events past day 30. Non-milestone
156
- * users lose 80% of post-day-30 events. No flag.
157
- *
158
- * HOW TO FIND IT IN MIXPANEL:
159
- * Report 1: Retention by Milestone Cohort
160
- * - Cohort A: users with phone-exchanged OR date-scheduled in first 14 days
161
- * - Cohort B: rest
162
- * - Expected: A ~ 60% D30 retention vs B ~ 20%
163
- *
164
- * REAL-WORLD ANALOGUE: Once they find someone IRL, app becomes irrelevant.
165
- *
166
- * -------------------------------------------------------------------------------------
167
- * 9. MATCH FLOW TIME-TO-CONVERT (funnel-post)
168
- * -------------------------------------------------------------------------------------
169
- * PATTERN: Elite users complete swipe→match→message funnel 1.4x faster
170
- * (factor 0.71 on inter-event gaps); Free users 1.4x slower (factor 1.4).
171
- *
172
- * HOW TO FIND IT IN MIXPANEL:
173
- * Report 1: Match Flow Median Time-to-Convert by Tier
174
- * - Funnels > "swipe right" → "match received" → "message sent"
175
- * - Measure: Median time to convert
176
- * - Breakdown: subscription
177
- * - Expected: Elite ~ 0.71x; Free ~ 1.4x
178
- *
179
- * NOTE (funnel-post measurement): visible only via Mixpanel funnel
180
- * median TTC. Cross-event MIN→MIN SQL queries on raw events do NOT
181
- * show this — funnel-post adjusts gaps within funnel instances, not
182
- * across the user's full event history.
183
- *
184
- * REAL-WORLD ANALOGUE: Premium notifications + boost surface matches faster.
185
- *
186
- * -------------------------------------------------------------------------------------
187
- * 10. AGE RANGE AFFECTS DATE CONVERSION (funnel-pre)
188
- * -------------------------------------------------------------------------------------
189
- * PATTERN: On the Date Funnel (message sent → phone number exchanged →
190
- * date scheduled), users aged 25-34 convert at 1.3x baseline; 40+ at 0.6x.
191
- * Scoped to the funnel containing "date scheduled".
192
- *
193
- * HOW TO FIND IT IN MIXPANEL:
194
- * Report 1: Date Funnel Conversion by Age Range
195
- * - Funnels > "message sent" → "phone number exchanged" → "date scheduled"
196
- * - Breakdown: age_range
197
- * - Expected: 25-29 / 30-34 ~ 1.3x baseline; 40+ ~ 0.6x
198
- *
199
- * REAL-WORLD ANALOGUE: Peak dating age ranges convert faster to in-person
200
- * dates; older users are more selective.
201
- *
202
- * =====================================================================================
203
- * EXPECTED METRICS SUMMARY
204
- * =====================================================================================
205
- *
206
- * Hook | Metric | Baseline | Effect | Ratio
207
- * ----------------------------|----------------------|----------|----------|------
208
- * Photo Magic Number | sweet matches/user | 1x | ~ 1.7x | 1.7x
209
- * Photo Magic Number | over matches/user | 1x | 0.7x | -30%
210
- * Weekend Swipe Surge | Sunday swipes | 1x | ~ 1.5x | 1.5x
211
- * Super-Like Effect | matches from super | 1x | 3x | 3x
212
- * Premium Match Boost | matches (Elite) | 1x | 4x | 4x
213
- * Ghosting Churn | post-match retention | 100% | 20% | 0.2x
214
- * Bio + Prompt Power Users | dates/user | 1x | 4x | 4x
215
- * Valentine's Day Spike | signups days 58-63 | 1x | 3x | 3x
216
- * Off-App Retention | D30 retention | ~ 20% | ~ 60% | 3x
217
- * Match Flow T2C | median min by tier | 1x | 0.71x/1.4x| ~ 2x range
218
- * Age Range Date Conv | date funnel 25-34 | 1x | 1.3x | 1.3x
219
- * Age Range Date Conv | date funnel 40+ | 1x | 0.6x | 0.6x
220
- */
221
-
222
- // ── SCALE ──
223
- const SEED = "meetcute";
224
- const NUM_USERS = 30_000;
225
- const DATASET_START = "2026-01-01T00:00:00Z";
226
- const DATASET_END = "2026-05-01T23:59:59Z";
227
- const EVENTS_PER_DAY = 1.5;
228
- const token = process.env.MP_TOKEN || "your-mixpanel-token";
229
-
230
- const chance = u.initChance(SEED);
231
-
232
- // ── KNOBS (tweak these to reshape stories) ──
233
- const PHOTO_SWEET_MIN = 2;
234
- const PHOTO_SWEET_MAX = 5;
235
- const PHOTO_OVER_THRESHOLD = 6;
236
- const PHOTO_OVER_SCORE_FACTOR = 0.65;
237
-
238
- const SUNDAY_EVENING_CLONES = 5;
239
- const SUNDAY_DAYTIME_CLONES = 2;
240
-
241
- const SUPER_LIKE_MATCH_CLONES = 3;
242
-
243
- const PREMIUM_MATCH_MULT = 2;
244
- const ELITE_MATCH_MULT = 4;
245
-
246
- const GHOSTING_WINDOW_HOURS = 48;
247
- const GHOSTING_DROP_LIKELIHOOD = 80;
248
-
249
- const BIO_PROMPT_THRESHOLD = 3;
250
- const BIO_PROMPT_DATE_CLONE_MULT = 3;
251
-
252
- const VDAY_WINDOW_START_DAY = 58;
253
- const VDAY_WINDOW_END_DAY = 63;
254
- const VDAY_SIGNUP_CLONES = 2;
255
- const VDAY_UPGRADE_CLONES = 4;
256
-
257
- const MILESTONE_WINDOW_DAYS = 14;
258
- const RETENTION_CUTOFF_DAYS = 30;
259
- const RETENTION_TARGET_PCT = 0.3;
260
- const OFFAPP_DROP_LIKELIHOOD = 80;
261
-
262
- const FUNNEL_TTC_ELITE = 0.71;
263
- const FUNNEL_TTC_FREE = 1.4;
264
-
265
- const AGE_CONV_BOOST = 1.3;
266
- const AGE_CONV_DROP = 0.6;
267
-
268
- // ── HELPER FUNCTIONS ──
269
- function handleFunnelPreHooks(record, meta) {
270
- // H10: Age range affects date conversion — 25-34 +30%, 40+ -40%
271
- const isDateFunnel = meta.funnel?.sequence?.includes("date scheduled");
272
- if (isDateFunnel) {
273
- const age = meta.profile?.age_range;
274
- if (age === "25-29" || age === "30-34") {
275
- record.conversionRate = Math.min(95, Math.round(record.conversionRate * AGE_CONV_BOOST));
276
- } else if (age === "40+") {
277
- record.conversionRate = Math.round(record.conversionRate * AGE_CONV_DROP);
278
- }
279
- }
280
- return record;
281
- }
282
-
283
- function handleFunnelPostHooks(record, meta) {
284
- // H9: Match Flow TTC scaled by subscription tier
285
- const segment = meta?.profile?.subscription;
286
- if (Array.isArray(record) && record.length > 1) {
287
- const factor = (
288
- segment === "Elite" ? FUNNEL_TTC_ELITE :
289
- segment === "Free" ? FUNNEL_TTC_FREE :
290
- 1.0
291
- );
292
- if (factor !== 1.0) {
293
- for (let i = 1; i < record.length; i++) {
294
- const prev = dayjs(record[i - 1].time);
295
- const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
296
- record[i].time = prev.add(newGap, "milliseconds").toISOString();
297
- }
298
- }
299
- }
300
- return record;
301
- }
302
-
303
- function handleEverythingHooks(record, meta) {
304
- const datasetStart = dayjs.unix(meta.datasetStart);
305
- const VDAY_WINDOW_START = datasetStart.add(VDAY_WINDOW_START_DAY, "days");
306
- const VDAY_WINDOW_END = datasetStart.add(VDAY_WINDOW_END_DAY, "days");
307
- const events = record;
308
- if (!events || events.length === 0) return record;
309
-
310
- const profile = meta.profile || {};
311
-
312
- events.forEach(e => {
313
- if (profile.subscription) e.subscription = profile.subscription;
314
- if (profile.platform) e.platform = profile.platform;
315
- });
316
-
317
- let photoUploadCount = 0;
318
- let promptAnsweredCount = 0;
319
- let hasBioUpdated = false;
320
- const matchEvents = [];
321
- const messageSentEvents = [];
322
- const superLikeEvents = [];
323
- let hasPhoneExchangedEarly = false;
324
- let hasDateScheduledEarly = false;
325
- let firstEventTime = null;
326
-
327
- events.forEach(event => {
328
- if (!firstEventTime || dayjs(event.time).isBefore(dayjs(firstEventTime))) {
329
- firstEventTime = event.time;
330
- }
331
- if (event.event === "photo uploaded") photoUploadCount++;
332
- if (event.event === "prompt answered") promptAnsweredCount++;
333
- if (event.event === "bio updated") hasBioUpdated = true;
334
- if (event.event === "match received") matchEvents.push(event);
335
- if (event.event === "message sent") messageSentEvents.push(event);
336
- if (event.event === "swipe right" && event.is_super_like === true) superLikeEvents.push(event);
337
- });
338
-
339
- if (firstEventTime) {
340
- const earlyWindow = dayjs(firstEventTime).add(MILESTONE_WINDOW_DAYS, "days");
341
- events.forEach(event => {
342
- const t = dayjs(event.time);
343
- if (t.isBefore(earlyWindow)) {
344
- if (event.event === "phone number exchanged") hasPhoneExchangedEarly = true;
345
- if (event.event === "date scheduled") hasDateScheduledEarly = true;
346
- }
347
- });
348
- }
349
-
350
- // H1: PHOTO MAGIC NUMBER (sweet 2-5 photos → clone 2-4 extra matches)
351
- // Over-6 score reduction is applied AT THE END of this hook so it also
352
- // affects matches injected by H4 (premium boost).
353
- if (photoUploadCount >= PHOTO_SWEET_MIN && photoUploadCount <= PHOTO_SWEET_MAX && matchEvents.length > 0) {
354
- const matchTemplate = matchEvents[0];
355
- matchEvents.forEach(m => {
356
- const extras = chance.integer({ min: 2, max: 4 });
357
- for (let i = 0; i < extras; i++) {
358
- events.push({
359
- ...matchTemplate,
360
- time: dayjs(m.time).add(chance.integer({ min: 1, max: 180 }), "minutes").toISOString(),
361
- user_id: m.user_id,
362
- match_score: chance.integer({ min: 60, max: 98 }),
363
- });
364
- }
365
- });
366
- }
367
-
368
- // H2: WEEKEND SWIPE SURGE — Sunday swipes get heavy cloning
369
- // to overcome the soup DOW weight deficit. Evening swipes (18-23)
370
- // get 5 clones; daytime Sunday swipes get 2 clones.
371
- // No flag — discover via day-of-week chart.
372
- for (let idx = events.length - 1; idx >= 0; idx--) {
373
- const event = events[idx];
374
- if (event.event === "swipe right") {
375
- const dow = new Date(event.time).getUTCDay();
376
- if (dow === 0) {
377
- const hr = new Date(event.time).getUTCHours();
378
- const clones = (hr >= 18 && hr <= 23) ? SUNDAY_EVENING_CLONES : SUNDAY_DAYTIME_CLONES;
379
- const etime = dayjs(event.time);
380
- for (let c = 0; c < clones; c++) {
381
- events.push({
382
- ...event,
383
- time: etime.add(chance.integer({ min: 1, max: 60 }), "minutes").toISOString(),
384
- user_id: event.user_id,
385
- });
386
- }
387
- }
388
- }
389
- }
390
-
391
- // H3: SUPER-LIKE EFFECT — clone 3 extra match events per
392
- // super-like, near in time. No flag — discover via funnel
393
- // "swipe right where is_super_like=true" → "match received".
394
- if (superLikeEvents.length > 0) {
395
- const matchTemplate = matchEvents[0] || events[0];
396
- superLikeEvents.forEach(sle => {
397
- for (let i = 0; i < SUPER_LIKE_MATCH_CLONES; i++) {
398
- events.push({
399
- ...matchTemplate,
400
- event: "match received",
401
- time: dayjs(sle.time).add(chance.integer({ min: 5, max: 120 }), "minutes").toISOString(),
402
- user_id: sle.user_id,
403
- match_score: chance.integer({ min: 70, max: 99 }),
404
- });
405
- }
406
- });
407
- }
408
-
409
- // H5: GHOSTING CHURN — users with match but no message within
410
- // 48hrs lose 80% of post-match events. No flag.
411
- // (runs BEFORE premium boost so injected premium matches survive)
412
- if (matchEvents.length > 0) {
413
- let hasTimely = false;
414
- for (const m of matchEvents) {
415
- const matchTime = dayjs(m.time);
416
- const deadline = matchTime.add(GHOSTING_WINDOW_HOURS, "hours");
417
- for (const msg of messageSentEvents) {
418
- const msgTime = dayjs(msg.time);
419
- if (msgTime.isAfter(matchTime) && msgTime.isBefore(deadline)) {
420
- hasTimely = true;
421
- break;
422
- }
423
- }
424
- if (hasTimely) break;
425
- }
426
- if (!hasTimely) {
427
- const earliestMatch = matchEvents.reduce((min, m) =>
428
- dayjs(m.time).isBefore(dayjs(min.time)) ? m : min
429
- );
430
- const churnAfter = dayjs(earliestMatch.time);
431
- for (let i = events.length - 1; i >= 0; i--) {
432
- if (dayjs(events[i].time).isAfter(churnAfter) && chance.bool({ likelihood: GHOSTING_DROP_LIKELIHOOD })) {
433
- events.splice(i, 1);
434
- }
435
- }
436
- }
437
- }
438
-
439
- // H4: PREMIUM MATCH BOOST — Premium 2x, Elite 4x match events.
440
- // Elite users also get profile-viewed events injected (see-who-liked-you).
441
- // Reads subscription from profile. Runs AFTER ghosting churn so
442
- // injected matches are not culled.
443
- const sub = profile.subscription;
444
- if ((sub === "Premium" || sub === "Elite") && matchEvents.length > 0) {
445
- // Count surviving match events post-churn
446
- const survivingMatches = events.filter(e => e.event === "match received");
447
- const baseCount = survivingMatches.length || 1;
448
- const targetMultiplier = sub === "Elite" ? ELITE_MATCH_MULT : PREMIUM_MATCH_MULT;
449
- const toAdd = Math.max(0, baseCount * targetMultiplier - baseCount);
450
- const matchTemplate = matchEvents[0];
451
- for (let i = 0; i < toAdd; i++) {
452
- const sourceMatch = survivingMatches[i % survivingMatches.length] || matchTemplate;
453
- events.push({
454
- ...matchTemplate,
455
- time: dayjs(sourceMatch.time).add(chance.integer({ min: 10, max: 240 }), "minutes").toISOString(),
456
- user_id: sourceMatch.user_id,
457
- match_score: chance.integer({ min: 65, max: 99 }),
458
- });
459
- }
460
- if (sub === "Elite") {
461
- const viewTemplate = events.find(e => e.event === "profile viewed") || matchTemplate;
462
- survivingMatches.forEach(m => {
463
- events.push({
464
- ...viewTemplate,
465
- event: "profile viewed",
466
- time: dayjs(m.time).subtract(chance.integer({ min: 10, max: 120 }), "minutes").toISOString(),
467
- user_id: m.user_id,
468
- viewer_source: "liked_you",
469
- });
470
- });
471
- }
472
- }
473
-
474
- // H6: BIO + PROMPT POWER USERS — bio + 3+ prompts → 3 extra
475
- // cloned date events per existing. No flag.
476
- if (hasBioUpdated && promptAnsweredCount >= BIO_PROMPT_THRESHOLD) {
477
- const dateEvents = events.filter(e => e.event === "date scheduled");
478
- if (dateEvents.length > 0) {
479
- const dateTemplate = dateEvents[0];
480
- const venueTypes = ["coffee", "dinner", "drinks", "activity", "virtual"];
481
- for (let i = 0; i < dateEvents.length * BIO_PROMPT_DATE_CLONE_MULT; i++) {
482
- const sourceDate = dateEvents[i % dateEvents.length];
483
- events.push({
484
- ...dateTemplate,
485
- time: dayjs(sourceDate.time).add(chance.integer({ min: 1, max: 72 }), "hours").toISOString(),
486
- user_id: sourceDate.user_id,
487
- venue_type: chance.pickone(venueTypes),
488
- });
489
- }
490
- }
491
- }
492
-
493
- // H7: VALENTINE'S DAY SPIKE — clone profile-created events during
494
- // days 58-63 (3x volume), plus clone premium-upgrade events 5x. No flag.
495
- const vdaySignups = events.filter(e =>
496
- e.event === "profile created" &&
497
- dayjs(e.time).isAfter(VDAY_WINDOW_START) &&
498
- dayjs(e.time).isBefore(VDAY_WINDOW_END)
499
- );
500
- vdaySignups.forEach(signup => {
501
- for (let i = 0; i < VDAY_SIGNUP_CLONES; i++) {
502
- events.push({
503
- ...signup,
504
- time: dayjs(signup.time).add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
505
- user_id: signup.user_id,
506
- });
507
- }
508
- });
509
-
510
- const vdayUpgrades = events.filter(e =>
511
- e.event === "premium upgrade" &&
512
- dayjs(e.time).isAfter(VDAY_WINDOW_START) &&
513
- dayjs(e.time).isBefore(VDAY_WINDOW_END)
514
- );
515
- if (vdayUpgrades.length > 0) {
516
- const upgradeTemplate = vdayUpgrades[0];
517
- vdayUpgrades.forEach(upgrade => {
518
- for (let i = 0; i < VDAY_UPGRADE_CLONES; i++) {
519
- events.push({
520
- ...upgradeTemplate,
521
- time: dayjs(upgrade.time).add(chance.integer({ min: 1, max: 24 }), "hours").toISOString(),
522
- user_id: upgrade.user_id,
523
- plan: upgrade.plan,
524
- price_usd: upgrade.price_usd,
525
- });
526
- }
527
- });
528
- }
529
-
530
- // H1b: PHOTO MAGIC NUMBER — over-6 score reduction (applied LAST so
531
- // it also affects matches injected by H4 premium boost).
532
- if (photoUploadCount >= PHOTO_OVER_THRESHOLD) {
533
- events.forEach(e => {
534
- if (e.event === "match received" && typeof e.match_score === "number") {
535
- e.match_score = Math.max(20, Math.round(e.match_score * PHOTO_OVER_SCORE_FACTOR));
536
- }
537
- });
538
- }
539
-
540
- // H8: OFF-APP RETENTION — users with phone-exchanged or
541
- // date-scheduled in first 14 days get extra cloned app-open + swipe
542
- // events past day 30. Non-milestone users lose 80% of post-day-30
543
- // events. No flag.
544
- if (firstEventTime) {
545
- const day30 = dayjs(firstEventTime).add(RETENTION_CUTOFF_DAYS, "days");
546
- const hasEarlyMilestone = hasPhoneExchangedEarly || hasDateScheduledEarly;
547
- if (hasEarlyMilestone) {
548
- const appOpenedTemplate = events.find(e => e.event === "app opened") || events[0];
549
- const swipeTemplate = events.find(e => e.event === "swipe right") || events[0];
550
- const postDay30Events = events.filter(e => dayjs(e.time).isAfter(day30));
551
- if (postDay30Events.length < events.length * RETENTION_TARGET_PCT) {
552
- const retentionCount = Math.floor(events.length * RETENTION_TARGET_PCT);
553
- for (let i = 0; i < retentionCount; i++) {
554
- const daysAfter = chance.integer({ min: 1, max: 60 });
555
- const template = chance.bool({ likelihood: 50 }) ? appOpenedTemplate : swipeTemplate;
556
- events.push({
557
- ...template,
558
- time: day30.add(daysAfter, "days").add(chance.integer({ min: 0, max: 23 }), "hours").toISOString(),
559
- user_id: template.user_id,
560
- });
561
- }
562
- }
563
- } else {
564
- for (let i = events.length - 1; i >= 0; i--) {
565
- if (dayjs(events[i].time).isAfter(day30) && chance.bool({ likelihood: OFFAPP_DROP_LIKELIHOOD })) {
566
- events.splice(i, 1);
567
- }
568
- }
569
- }
570
- }
571
-
572
- return record;
573
- }
574
-
575
- // ── CONFIG ──
576
- /** @type {Config} */
577
- const config = {
578
- version: 2,
579
- seed: SEED,
580
- datasetStart: DATASET_START,
581
- datasetEnd: DATASET_END,
582
- avgEventsPerUserPerDay: EVENTS_PER_DAY,
583
- numUsers: NUM_USERS,
584
- format: "json",
585
- gzip: true,
586
- credentials: {
587
- token,
588
- },
589
- switches: {
590
- hasSessionIds: true,
591
- alsoInferFunnels: false,
592
- hasLocation: true,
593
- hasAndroidDevices: true,
594
- hasIOSDevices: true,
595
- hasDesktopDevices: false,
596
- hasBrowser: false,
597
- hasCampaigns: false,
598
- isAnonymous: false,
599
- hasAdSpend: false,
600
- hasAvatar: true,
601
- },
602
- identity: {
603
- avgDevicePerUser: 2,
604
- },
605
- concurrency: 1,
606
- writeToDisk: false,
607
- soup: "growth",
608
-
609
- events: [
610
- {
611
- event: "profile created",
612
- weight: 1,
613
- isFirstEvent: true,
614
- isAuthEvent: true,
615
- properties: {
616
- age_range: ["18-24", "25-29", "30-34", "35-39", "40+"],
617
- gender: ["Male", "Male", "Female", "Female", "Non-binary"],
618
- looking_for: ["Men", "Women", "Everyone"],
619
- },
620
- },
621
- {
622
- event: "photo uploaded",
623
- weight: 12,
624
- isStrictEvent: false,
625
- properties: {
626
- photo_number: u.weighNumRange(1, 6, 0.5, 2),
627
- has_face: [true, true, true, true, false],
628
- },
629
- },
630
- {
631
- event: "bio updated",
632
- weight: 2,
633
- properties: {
634
- bio_length: u.weighNumRange(10, 500, 0.4, 120),
635
- },
636
- },
637
- {
638
- event: "prompt answered",
639
- weight: 3,
640
- properties: {
641
- prompt_type: ["icebreaker", "opinion", "hypothetical", "personal", "creative"],
642
- answer_length: u.weighNumRange(10, 300, 0.4, 80),
643
- },
644
- },
645
- {
646
- event: "swipe right",
647
- weight: 10,
648
- isStrictEvent: false,
649
- properties: {
650
- is_super_like: [false, false, false, false, false, false, false, false, false, true],
651
- swipe_source: ["feed", "feed", "feed", "discover", "boost", "nearby"],
652
- },
653
- },
654
- {
655
- event: "swipe left",
656
- weight: 8,
657
- properties: {
658
- swipe_source: ["feed", "feed", "feed", "discover", "boost", "nearby"],
659
- },
660
- },
661
- {
662
- event: "match received",
663
- weight: 4,
664
- isStrictEvent: false,
665
- properties: {
666
- match_score: u.weighNumRange(50, 100, 0.5, 75),
667
- },
668
- },
669
- {
670
- event: "message sent",
671
- weight: 6,
672
- isStrictEvent: false,
673
- properties: {
674
- message_length: u.weighNumRange(1, 500, 0.3, 40),
675
- has_emoji: [false, false, true, true, true],
676
- response_time_mins: u.weighNumRange(1, 1440, 0.3, 30),
677
- },
678
- },
679
- {
680
- event: "message received",
681
- weight: 5,
682
- properties: {
683
- message_length: u.weighNumRange(1, 500, 0.3, 50),
684
- },
685
- },
686
- {
687
- event: "phone number exchanged",
688
- weight: 1,
689
- isStrictEvent: false,
690
- properties: {
691
- exchange_method: ["in_chat", "in_chat", "voice_call", "video_call"],
692
- },
693
- },
694
- {
695
- event: "date scheduled",
696
- weight: 1,
697
- isStrictEvent: false,
698
- properties: {
699
- venue_type: ["coffee", "dinner", "drinks", "activity", "virtual"],
700
- },
701
- },
702
- {
703
- event: "profile viewed",
704
- weight: 5,
705
- properties: {
706
- viewer_source: ["feed", "discover", "liked_you", "nearby"],
707
- },
708
- },
709
- {
710
- event: "premium upgrade",
711
- weight: 1,
712
- isStrictEvent: false,
713
- properties: {
714
- plan: ["Premium", "Premium", "Premium", "Elite"],
715
- price_usd: [14.99, 14.99, 14.99, 29.99],
716
- },
717
- },
718
- {
719
- event: "premium cancelled",
720
- weight: 1,
721
- isStrictEvent: true,
722
- properties: {
723
- cancel_reason: ["found_someone", "too_expensive", "not_enough_matches", "bad_experience", "taking_a_break"],
724
- subscription_duration_days: u.weighNumRange(7, 365, 0.3, 30),
725
- },
726
- },
727
- {
728
- event: "boost activated",
729
- weight: 2,
730
- properties: {
731
- boost_type: ["standard", "super"],
732
- boost_duration_mins: [15, 30, 30, 60],
733
- },
734
- },
735
- {
736
- event: "report user",
737
- weight: 1,
738
- isStrictEvent: true,
739
- properties: {
740
- report_reason: ["fake_profile", "inappropriate_photos", "harassment", "spam", "underage"],
741
- },
742
- },
743
- {
744
- event: "app opened",
745
- weight: 8,
746
- isStrictEvent: false,
747
- properties: {
748
- session_duration_mins: u.weighNumRange(1, 120, 0.3, 8),
749
- },
750
- },
751
- ],
752
-
753
- funnels: [
754
- {
755
- name: "Onboarding",
756
- sequence: ["profile created", "photo uploaded", "swipe right"],
757
- conversionRate: 75,
758
- order: "sequential",
759
- isFirstFunnel: true,
760
- timeToConvert: 1,
761
- weight: 3,
762
- },
763
- {
764
- name: "Match Flow",
765
- sequence: ["swipe right", "match received", "message sent"],
766
- conversionRate: 50,
767
- order: "sequential",
768
- timeToConvert: 24,
769
- weight: 6,
770
- reentry: true,
771
- },
772
- {
773
- name: "Date Funnel",
774
- sequence: ["message sent", "phone number exchanged", "date scheduled"],
775
- conversionRate: 25,
776
- order: "sequential",
777
- timeToConvert: 72,
778
- weight: 3,
779
- reentry: true,
780
- },
781
- {
782
- name: "Monetization",
783
- sequence: ["app opened", "boost activated", "premium upgrade"],
784
- conversionRate: 20,
785
- order: "sequential",
786
- timeToConvert: 48,
787
- weight: 2,
788
- },
789
- ],
790
-
791
- superProps: {
792
- subscription: ["Free", "Free", "Free", "Premium", "Elite"],
793
- Platform: ["ios", "ios", "android"],
794
- },
795
-
796
- userProps: {
797
- subscription: ["Free", "Free", "Free", "Premium", "Elite"],
798
- age_range: ["18-24", "25-29", "30-34", "35-39", "40+"],
799
- gender: ["Male", "Male", "Female", "Female", "Non-binary"],
800
- looking_for: ["Men", "Women", "Everyone"],
801
- photo_count: u.weighNumRange(0, 8, 0.4, 3),
802
- total_matches: u.weighNumRange(0, 200, 0.3, 15),
803
- total_messages_sent: u.weighNumRange(0, 500, 0.3, 30),
804
- profile_completeness: ["incomplete", "incomplete", "basic", "basic", "complete"],
805
- Platform: ["ios", "ios", "android"],
806
- },
807
-
808
- scdProps: {
809
- subscription_tier: {
810
- values: ["Free", "Premium", "Elite"],
811
- frequency: "month",
812
- timing: "fuzzy",
813
- max: 6,
814
- },
815
- },
816
-
817
- groupKeys: [],
818
- groupProps: {},
819
- mirrorProps: {},
820
- lookupTables: [],
821
-
822
- hook(record, type, meta) {
823
- if (type === "funnel-pre") return handleFunnelPreHooks(record, meta);
824
- if (type === "funnel-post") return handleFunnelPostHooks(record, meta);
825
- if (type === "everything") return handleEverythingHooks(record, meta);
826
- return record;
827
- },
828
- };
829
-
830
- export default config;