@ak--47/dungeon-master 1.4.5 → 1.5.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.
- package/.claude/skills/analyze-soup/SKILL.md +158 -0
- package/.claude/skills/create-dungeon/SKILL.md +464 -0
- package/.claude/skills/verify-dungeon/SKILL.md +157 -0
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +161 -0
- package/.claude/skills/verify-dungeon/references/report-format.md +216 -0
- package/.claude/skills/verify-dungeon/references/sql-recipes.md +857 -0
- package/.claude/skills/write-hooks/SKILL.md +468 -0
- package/CHANGELOG.md +182 -0
- package/HOOKS.md +1256 -597
- package/README.md +140 -5
- package/dungeons/technical/ad-spend.js +41 -49
- package/dungeons/technical/anonymous-users.js +38 -36
- package/dungeons/technical/array-of-object-lookup.js +136 -153
- package/dungeons/technical/datagen-v15-verify.js +87 -0
- package/dungeons/technical/experiments.js +42 -40
- package/dungeons/technical/foobar.js +114 -118
- package/dungeons/technical/group-analytics.js +42 -40
- package/dungeons/technical/hook-helpers-verify.js +69 -50
- package/dungeons/technical/identity-model-verify.js +22 -12
- package/dungeons/technical/mirror-strategies.js +37 -39
- package/dungeons/technical/nested-objects.js +119 -118
- package/dungeons/technical/pattern-aggregate-by-bin.js +21 -8
- package/dungeons/technical/pattern-attributed-by-source.js +23 -9
- package/dungeons/technical/pattern-frequency-by-frequency.js +21 -8
- package/dungeons/technical/pattern-funnel-frequency.js +30 -15
- package/dungeons/technical/pattern-ttc-by-segment.js +21 -8
- package/dungeons/technical/retention-cadence.js +115 -112
- package/dungeons/technical/sanity.js +86 -80
- package/dungeons/technical/scale-test.js +34 -38
- package/dungeons/technical/scd.js +111 -128
- package/dungeons/technical/simple.js +134 -141
- package/dungeons/technical/simplest.js +111 -65
- package/dungeons/technical/text-generation.js +110 -146
- package/dungeons/vertical/ai-platform.js +300 -333
- package/dungeons/vertical/community.js +290 -255
- package/dungeons/vertical/crypto.js +400 -391
- package/dungeons/vertical/dating.js +421 -375
- package/dungeons/vertical/devtools.js +346 -298
- package/dungeons/vertical/ecommerce.js +322 -394
- package/dungeons/vertical/education.js +380 -325
- package/dungeons/vertical/fintech.js +371 -325
- package/dungeons/vertical/fitness.js +345 -291
- package/dungeons/vertical/food-delivery.js +352 -307
- package/dungeons/vertical/gaming.js +490 -444
- package/dungeons/vertical/healthcare.js +311 -262
- package/dungeons/vertical/insurance-application.js +437 -409
- package/dungeons/vertical/logistics.js +278 -252
- package/dungeons/vertical/marketplace.js +340 -323
- package/dungeons/vertical/media.js +390 -335
- package/dungeons/vertical/real-estate.js +402 -347
- package/dungeons/vertical/sass.js +331 -333
- package/dungeons/vertical/social.js +377 -316
- package/dungeons/vertical/travel.js +302 -295
- package/index.js +64 -7
- package/lib/core/config-validator.js +378 -17
- package/lib/core/dungeon-loader.js +2 -5
- package/lib/generators/events.js +12 -13
- package/lib/generators/funnels.js +76 -2
- package/lib/hook-helpers/index.js +1 -0
- package/lib/hook-helpers/inject.js +95 -0
- package/lib/orchestrators/mixpanel-sender.js +7 -0
- package/lib/orchestrators/user-loop.js +598 -48
- package/lib/templates/defaults.js +59 -59
- package/lib/templates/macro-presets.js +53 -11
- package/lib/utils/dataset-context.js +103 -0
- package/lib/utils/retention-curve.js +140 -0
- package/lib/utils/utils.js +157 -109
- package/lib/verify/counting.js +360 -0
- package/lib/verify/emulate-breakdown.js +531 -108
- package/lib/verify/funnel-engine.js +539 -0
- package/lib/verify/identity.js +78 -0
- package/lib/verify/index.js +20 -0
- package/lib/verify/schema-validator.js +3 -1
- package/lib/verify/verify-dungeon.js +58 -0
- package/package.json +14 -3
- package/scripts/run-dungeon.mjs +12 -1
- package/types.d.ts +353 -4
- package/scripts/smoke-test-all.mjs +0 -162
|
@@ -1,56 +1,43 @@
|
|
|
1
|
-
// ──
|
|
2
|
-
const SEED = "meetcute";
|
|
3
|
-
const num_days = 120;
|
|
4
|
-
const num_users = 30_000;
|
|
5
|
-
const avg_events_per_user_per_day = 1.5;
|
|
6
|
-
let token = "your-mixpanel-token";
|
|
7
|
-
|
|
8
|
-
// ── env overrides ──
|
|
9
|
-
if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
|
|
10
|
-
|
|
1
|
+
// ── IMPORTS ──
|
|
11
2
|
import dayjs from "dayjs";
|
|
12
3
|
import utc from "dayjs/plugin/utc.js";
|
|
4
|
+
dayjs.extend(utc);
|
|
13
5
|
import "dotenv/config";
|
|
14
6
|
import * as u from "../../lib/utils/utils.js";
|
|
15
|
-
import * as v from "ak-tools";
|
|
16
|
-
|
|
17
|
-
dayjs.extend(utc);
|
|
18
|
-
const chance = u.initChance(SEED);
|
|
19
7
|
/** @typedef {import("../../types").Dungeon} Config */
|
|
20
8
|
|
|
9
|
+
// ── OVERVIEW ──
|
|
21
10
|
/*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* CORE LOOP:
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
|
47
37
|
*/
|
|
48
38
|
|
|
39
|
+
// ── HOOK STORIES ──
|
|
49
40
|
/*
|
|
50
|
-
* =====================================================================================
|
|
51
|
-
* ANALYTICS HOOKS (10 hooks)
|
|
52
|
-
* =====================================================================================
|
|
53
|
-
*
|
|
54
41
|
* NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable via
|
|
55
42
|
* behavioral cohorts, raw-prop breakdowns, or funnel analysis.
|
|
56
43
|
*
|
|
@@ -232,36 +219,393 @@ const chance = u.initChance(SEED);
|
|
|
232
219
|
* Age Range Date Conv | date funnel 40+ | 1x | 0.6x | 0.6x
|
|
233
220
|
*/
|
|
234
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 ──
|
|
235
576
|
/** @type {Config} */
|
|
236
577
|
const config = {
|
|
237
578
|
version: 2,
|
|
238
|
-
token,
|
|
239
579
|
seed: SEED,
|
|
240
|
-
datasetStart:
|
|
241
|
-
datasetEnd:
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
numUsers: num_users,
|
|
245
|
-
hasAnonIds: true,
|
|
246
|
-
avgDevicePerUser: 2,
|
|
247
|
-
hasSessionIds: true,
|
|
580
|
+
datasetStart: DATASET_START,
|
|
581
|
+
datasetEnd: DATASET_END,
|
|
582
|
+
avgEventsPerUserPerDay: EVENTS_PER_DAY,
|
|
583
|
+
numUsers: NUM_USERS,
|
|
248
584
|
format: "json",
|
|
249
585
|
gzip: true,
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
+
},
|
|
260
605
|
concurrency: 1,
|
|
261
606
|
writeToDisk: false,
|
|
262
607
|
soup: "growth",
|
|
263
608
|
|
|
264
|
-
// ── Events (17) ──────────────────────────────────────────
|
|
265
609
|
events: [
|
|
266
610
|
{
|
|
267
611
|
event: "profile created",
|
|
@@ -277,6 +621,7 @@ const config = {
|
|
|
277
621
|
{
|
|
278
622
|
event: "photo uploaded",
|
|
279
623
|
weight: 12,
|
|
624
|
+
isStrictEvent: false,
|
|
280
625
|
properties: {
|
|
281
626
|
photo_number: u.weighNumRange(1, 6, 0.5, 2),
|
|
282
627
|
has_face: [true, true, true, true, false],
|
|
@@ -300,6 +645,7 @@ const config = {
|
|
|
300
645
|
{
|
|
301
646
|
event: "swipe right",
|
|
302
647
|
weight: 10,
|
|
648
|
+
isStrictEvent: false,
|
|
303
649
|
properties: {
|
|
304
650
|
is_super_like: [false, false, false, false, false, false, false, false, false, true],
|
|
305
651
|
swipe_source: ["feed", "feed", "feed", "discover", "boost", "nearby"],
|
|
@@ -315,6 +661,7 @@ const config = {
|
|
|
315
661
|
{
|
|
316
662
|
event: "match received",
|
|
317
663
|
weight: 4,
|
|
664
|
+
isStrictEvent: false,
|
|
318
665
|
properties: {
|
|
319
666
|
match_score: u.weighNumRange(50, 100, 0.5, 75),
|
|
320
667
|
},
|
|
@@ -322,6 +669,7 @@ const config = {
|
|
|
322
669
|
{
|
|
323
670
|
event: "message sent",
|
|
324
671
|
weight: 6,
|
|
672
|
+
isStrictEvent: false,
|
|
325
673
|
properties: {
|
|
326
674
|
message_length: u.weighNumRange(1, 500, 0.3, 40),
|
|
327
675
|
has_emoji: [false, false, true, true, true],
|
|
@@ -338,6 +686,7 @@ const config = {
|
|
|
338
686
|
{
|
|
339
687
|
event: "phone number exchanged",
|
|
340
688
|
weight: 1,
|
|
689
|
+
isStrictEvent: false,
|
|
341
690
|
properties: {
|
|
342
691
|
exchange_method: ["in_chat", "in_chat", "voice_call", "video_call"],
|
|
343
692
|
},
|
|
@@ -345,6 +694,7 @@ const config = {
|
|
|
345
694
|
{
|
|
346
695
|
event: "date scheduled",
|
|
347
696
|
weight: 1,
|
|
697
|
+
isStrictEvent: false,
|
|
348
698
|
properties: {
|
|
349
699
|
venue_type: ["coffee", "dinner", "drinks", "activity", "virtual"],
|
|
350
700
|
},
|
|
@@ -359,6 +709,7 @@ const config = {
|
|
|
359
709
|
{
|
|
360
710
|
event: "premium upgrade",
|
|
361
711
|
weight: 1,
|
|
712
|
+
isStrictEvent: false,
|
|
362
713
|
properties: {
|
|
363
714
|
plan: ["Premium", "Premium", "Premium", "Elite"],
|
|
364
715
|
price_usd: [14.99, 14.99, 14.99, 29.99],
|
|
@@ -392,13 +743,13 @@ const config = {
|
|
|
392
743
|
{
|
|
393
744
|
event: "app opened",
|
|
394
745
|
weight: 8,
|
|
746
|
+
isStrictEvent: false,
|
|
395
747
|
properties: {
|
|
396
748
|
session_duration_mins: u.weighNumRange(1, 120, 0.3, 8),
|
|
397
749
|
},
|
|
398
750
|
},
|
|
399
751
|
],
|
|
400
752
|
|
|
401
|
-
// ── Funnels (4) ──────────────────────────────────────────
|
|
402
753
|
funnels: [
|
|
403
754
|
{
|
|
404
755
|
name: "Onboarding",
|
|
@@ -416,6 +767,7 @@ const config = {
|
|
|
416
767
|
order: "sequential",
|
|
417
768
|
timeToConvert: 24,
|
|
418
769
|
weight: 6,
|
|
770
|
+
reentry: true,
|
|
419
771
|
},
|
|
420
772
|
{
|
|
421
773
|
name: "Date Funnel",
|
|
@@ -424,6 +776,7 @@ const config = {
|
|
|
424
776
|
order: "sequential",
|
|
425
777
|
timeToConvert: 72,
|
|
426
778
|
weight: 3,
|
|
779
|
+
reentry: true,
|
|
427
780
|
},
|
|
428
781
|
{
|
|
429
782
|
name: "Monetization",
|
|
@@ -435,13 +788,11 @@ const config = {
|
|
|
435
788
|
},
|
|
436
789
|
],
|
|
437
790
|
|
|
438
|
-
// ── SuperProps ──────────────────────────────────────────
|
|
439
791
|
superProps: {
|
|
440
792
|
subscription: ["Free", "Free", "Free", "Premium", "Elite"],
|
|
441
793
|
Platform: ["ios", "ios", "android"],
|
|
442
794
|
},
|
|
443
795
|
|
|
444
|
-
// ── UserProps ──────────────────────────────────────────
|
|
445
796
|
userProps: {
|
|
446
797
|
subscription: ["Free", "Free", "Free", "Premium", "Elite"],
|
|
447
798
|
age_range: ["18-24", "25-29", "30-34", "35-39", "40+"],
|
|
@@ -454,7 +805,6 @@ const config = {
|
|
|
454
805
|
Platform: ["ios", "ios", "android"],
|
|
455
806
|
},
|
|
456
807
|
|
|
457
|
-
// ── SCD Props ──────────────────────────────────────────
|
|
458
808
|
scdProps: {
|
|
459
809
|
subscription_tier: {
|
|
460
810
|
values: ["Free", "Premium", "Elite"],
|
|
@@ -469,314 +819,10 @@ const config = {
|
|
|
469
819
|
mirrorProps: {},
|
|
470
820
|
lookupTables: [],
|
|
471
821
|
|
|
472
|
-
hook
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
if (type === "funnel-pre") {
|
|
477
|
-
const isDateFunnel = meta.funnel?.sequence?.includes("date scheduled");
|
|
478
|
-
if (isDateFunnel) {
|
|
479
|
-
const age = meta.profile?.age_range;
|
|
480
|
-
if (age === "25-29" || age === "30-34") {
|
|
481
|
-
record.conversionRate = Math.min(95, Math.round(record.conversionRate * 1.3));
|
|
482
|
-
} else if (age === "40+") {
|
|
483
|
-
record.conversionRate = Math.round(record.conversionRate * 0.6);
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
// HOOK 9 (T2C): MATCH FLOW TIME-TO-CONVERT (funnel-post)
|
|
489
|
-
// Elite users complete swipe→match→message funnel 1.4x faster
|
|
490
|
-
// (factor 0.71); Free users 1.4x slower (factor 1.4).
|
|
491
|
-
if (type === "funnel-post") {
|
|
492
|
-
const segment = meta?.profile?.subscription;
|
|
493
|
-
if (Array.isArray(record) && record.length > 1) {
|
|
494
|
-
const factor = (
|
|
495
|
-
segment === "Elite" ? 0.71 :
|
|
496
|
-
segment === "Free" ? 1.4 :
|
|
497
|
-
1.0
|
|
498
|
-
);
|
|
499
|
-
if (factor !== 1.0) {
|
|
500
|
-
for (let i = 1; i < record.length; i++) {
|
|
501
|
-
const prev = dayjs(record[i - 1].time);
|
|
502
|
-
const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
|
|
503
|
-
record[i].time = prev.add(newGap, "milliseconds").toISOString();
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
// ─── EVERYTHING-LEVEL HOOKS ──────────────────────────────────────
|
|
510
|
-
|
|
511
|
-
if (type === "everything") {
|
|
512
|
-
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
513
|
-
const VDAY_WINDOW_START = datasetStart.add(58, "days");
|
|
514
|
-
const VDAY_WINDOW_END = datasetStart.add(63, "days");
|
|
515
|
-
const events = record;
|
|
516
|
-
if (!events || events.length === 0) return record;
|
|
517
|
-
|
|
518
|
-
const profile = meta.profile || {};
|
|
519
|
-
|
|
520
|
-
events.forEach(e => {
|
|
521
|
-
if (profile.subscription) e.subscription = profile.subscription;
|
|
522
|
-
if (profile.platform) e.platform = profile.platform;
|
|
523
|
-
});
|
|
524
|
-
|
|
525
|
-
let photoUploadCount = 0;
|
|
526
|
-
let promptAnsweredCount = 0;
|
|
527
|
-
let hasBioUpdated = false;
|
|
528
|
-
const matchEvents = [];
|
|
529
|
-
const messageSentEvents = [];
|
|
530
|
-
const superLikeEvents = [];
|
|
531
|
-
let hasPhoneExchangedEarly = false;
|
|
532
|
-
let hasDateScheduledEarly = false;
|
|
533
|
-
let firstEventTime = null;
|
|
534
|
-
|
|
535
|
-
events.forEach(event => {
|
|
536
|
-
if (!firstEventTime || dayjs(event.time).isBefore(dayjs(firstEventTime))) {
|
|
537
|
-
firstEventTime = event.time;
|
|
538
|
-
}
|
|
539
|
-
if (event.event === "photo uploaded") photoUploadCount++;
|
|
540
|
-
if (event.event === "prompt answered") promptAnsweredCount++;
|
|
541
|
-
if (event.event === "bio updated") hasBioUpdated = true;
|
|
542
|
-
if (event.event === "match received") matchEvents.push(event);
|
|
543
|
-
if (event.event === "message sent") messageSentEvents.push(event);
|
|
544
|
-
if (event.event === "swipe right" && event.is_super_like === true) superLikeEvents.push(event);
|
|
545
|
-
});
|
|
546
|
-
|
|
547
|
-
if (firstEventTime) {
|
|
548
|
-
const earlyWindow = dayjs(firstEventTime).add(14, "days");
|
|
549
|
-
events.forEach(event => {
|
|
550
|
-
const t = dayjs(event.time);
|
|
551
|
-
if (t.isBefore(earlyWindow)) {
|
|
552
|
-
if (event.event === "phone number exchanged") hasPhoneExchangedEarly = true;
|
|
553
|
-
if (event.event === "date scheduled") hasDateScheduledEarly = true;
|
|
554
|
-
}
|
|
555
|
-
});
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
// HOOK 1 + HOOK 9: PHOTO MAGIC NUMBER (no flags)
|
|
559
|
-
// Sweet 2-5 photos → clone 2-4 extra match events per existing.
|
|
560
|
-
// Over 6+ photos → drop match_score by 35% on match received events
|
|
561
|
-
// (over-curated profile reads as fake/staged).
|
|
562
|
-
if (photoUploadCount >= 2 && photoUploadCount <= 5 && matchEvents.length > 0) {
|
|
563
|
-
const matchTemplate = matchEvents[0];
|
|
564
|
-
matchEvents.forEach(m => {
|
|
565
|
-
const extras = chance.integer({ min: 2, max: 4 });
|
|
566
|
-
for (let i = 0; i < extras; i++) {
|
|
567
|
-
events.push({
|
|
568
|
-
...matchTemplate,
|
|
569
|
-
time: dayjs(m.time).add(chance.integer({ min: 1, max: 180 }), "minutes").toISOString(),
|
|
570
|
-
user_id: m.user_id,
|
|
571
|
-
match_score: chance.integer({ min: 60, max: 98 }),
|
|
572
|
-
});
|
|
573
|
-
}
|
|
574
|
-
});
|
|
575
|
-
} else if (photoUploadCount >= 6) {
|
|
576
|
-
events.forEach(e => {
|
|
577
|
-
if (e.event === "match received" && typeof e.match_score === "number") {
|
|
578
|
-
e.match_score = Math.max(20, Math.round(e.match_score * 0.65));
|
|
579
|
-
}
|
|
580
|
-
});
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
// HOOK 2: WEEKEND SWIPE SURGE — Sunday swipes get heavy cloning
|
|
584
|
-
// to overcome the soup DOW weight deficit. Evening swipes (18-23)
|
|
585
|
-
// get 5 clones; daytime Sunday swipes get 2 clones.
|
|
586
|
-
// No flag — discover via day-of-week chart.
|
|
587
|
-
for (let idx = events.length - 1; idx >= 0; idx--) {
|
|
588
|
-
const event = events[idx];
|
|
589
|
-
if (event.event === "swipe right") {
|
|
590
|
-
const dow = new Date(event.time).getUTCDay();
|
|
591
|
-
if (dow === 0) {
|
|
592
|
-
const hr = new Date(event.time).getUTCHours();
|
|
593
|
-
const clones = (hr >= 18 && hr <= 23) ? 5 : 2;
|
|
594
|
-
const etime = dayjs(event.time);
|
|
595
|
-
for (let c = 0; c < clones; c++) {
|
|
596
|
-
events.push({
|
|
597
|
-
...event,
|
|
598
|
-
time: etime.add(chance.integer({ min: 1, max: 60 }), "minutes").toISOString(),
|
|
599
|
-
user_id: event.user_id,
|
|
600
|
-
});
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
// HOOK 3: SUPER-LIKE EFFECT — clone 3 extra match events per
|
|
607
|
-
// super-like, near in time. No flag — discover via funnel
|
|
608
|
-
// "swipe right where is_super_like=true" → "match received".
|
|
609
|
-
if (superLikeEvents.length > 0) {
|
|
610
|
-
const matchTemplate = matchEvents[0] || events[0];
|
|
611
|
-
superLikeEvents.forEach(sle => {
|
|
612
|
-
for (let i = 0; i < 3; i++) {
|
|
613
|
-
events.push({
|
|
614
|
-
...matchTemplate,
|
|
615
|
-
event: "match received",
|
|
616
|
-
time: dayjs(sle.time).add(chance.integer({ min: 5, max: 120 }), "minutes").toISOString(),
|
|
617
|
-
user_id: sle.user_id,
|
|
618
|
-
match_score: chance.integer({ min: 70, max: 99 }),
|
|
619
|
-
});
|
|
620
|
-
}
|
|
621
|
-
});
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
// HOOK 5: GHOSTING CHURN — users with match but no message within
|
|
625
|
-
// 48hrs lose 80% of post-match events. No flag.
|
|
626
|
-
// (runs BEFORE premium boost so injected premium matches survive)
|
|
627
|
-
if (matchEvents.length > 0) {
|
|
628
|
-
let hasTimely = false;
|
|
629
|
-
for (const m of matchEvents) {
|
|
630
|
-
const matchTime = dayjs(m.time);
|
|
631
|
-
const deadline = matchTime.add(48, "hours");
|
|
632
|
-
for (const msg of messageSentEvents) {
|
|
633
|
-
const msgTime = dayjs(msg.time);
|
|
634
|
-
if (msgTime.isAfter(matchTime) && msgTime.isBefore(deadline)) {
|
|
635
|
-
hasTimely = true;
|
|
636
|
-
break;
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
if (hasTimely) break;
|
|
640
|
-
}
|
|
641
|
-
if (!hasTimely) {
|
|
642
|
-
const earliestMatch = matchEvents.reduce((min, m) =>
|
|
643
|
-
dayjs(m.time).isBefore(dayjs(min.time)) ? m : min
|
|
644
|
-
);
|
|
645
|
-
const churnAfter = dayjs(earliestMatch.time);
|
|
646
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
647
|
-
if (dayjs(events[i].time).isAfter(churnAfter) && chance.bool({ likelihood: 80 })) {
|
|
648
|
-
events.splice(i, 1);
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
// HOOK 4: PREMIUM MATCH BOOST — Premium 2x, Elite 4x match events.
|
|
655
|
-
// Elite users also get profile-viewed events injected (see-who-liked-you).
|
|
656
|
-
// Reads subscription from profile. Runs AFTER ghosting churn so
|
|
657
|
-
// injected matches are not culled.
|
|
658
|
-
const sub = profile.subscription;
|
|
659
|
-
if ((sub === "Premium" || sub === "Elite") && matchEvents.length > 0) {
|
|
660
|
-
// Count surviving match events post-churn
|
|
661
|
-
const survivingMatches = events.filter(e => e.event === "match received");
|
|
662
|
-
const baseCount = survivingMatches.length || 1;
|
|
663
|
-
const targetMultiplier = sub === "Elite" ? 4 : 2;
|
|
664
|
-
const toAdd = Math.max(0, baseCount * targetMultiplier - baseCount);
|
|
665
|
-
const matchTemplate = matchEvents[0];
|
|
666
|
-
for (let i = 0; i < toAdd; i++) {
|
|
667
|
-
const sourceMatch = survivingMatches[i % survivingMatches.length] || matchTemplate;
|
|
668
|
-
events.push({
|
|
669
|
-
...matchTemplate,
|
|
670
|
-
time: dayjs(sourceMatch.time).add(chance.integer({ min: 10, max: 240 }), "minutes").toISOString(),
|
|
671
|
-
user_id: sourceMatch.user_id,
|
|
672
|
-
match_score: chance.integer({ min: 65, max: 99 }),
|
|
673
|
-
});
|
|
674
|
-
}
|
|
675
|
-
if (sub === "Elite") {
|
|
676
|
-
const viewTemplate = events.find(e => e.event === "profile viewed") || matchTemplate;
|
|
677
|
-
survivingMatches.forEach(m => {
|
|
678
|
-
events.push({
|
|
679
|
-
...viewTemplate,
|
|
680
|
-
event: "profile viewed",
|
|
681
|
-
time: dayjs(m.time).subtract(chance.integer({ min: 10, max: 120 }), "minutes").toISOString(),
|
|
682
|
-
user_id: m.user_id,
|
|
683
|
-
viewer_source: "liked_you",
|
|
684
|
-
});
|
|
685
|
-
});
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
// HOOK 6: BIO + PROMPT POWER USERS — bio + 3+ prompts → 3 extra
|
|
690
|
-
// cloned date events per existing. No flag.
|
|
691
|
-
if (hasBioUpdated && promptAnsweredCount >= 3) {
|
|
692
|
-
const dateEvents = events.filter(e => e.event === "date scheduled");
|
|
693
|
-
if (dateEvents.length > 0) {
|
|
694
|
-
const dateTemplate = dateEvents[0];
|
|
695
|
-
const venueTypes = ["coffee", "dinner", "drinks", "activity", "virtual"];
|
|
696
|
-
for (let i = 0; i < dateEvents.length * 3; i++) {
|
|
697
|
-
const sourceDate = dateEvents[i % dateEvents.length];
|
|
698
|
-
events.push({
|
|
699
|
-
...dateTemplate,
|
|
700
|
-
time: dayjs(sourceDate.time).add(chance.integer({ min: 1, max: 72 }), "hours").toISOString(),
|
|
701
|
-
user_id: sourceDate.user_id,
|
|
702
|
-
venue_type: chance.pickone(venueTypes),
|
|
703
|
-
});
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
// HOOK 7: VALENTINE'S DAY SPIKE — clone profile-created events during
|
|
709
|
-
// days 58-63 (3x volume), plus clone premium-upgrade events 5x. No flag.
|
|
710
|
-
const vdaySignups = events.filter(e =>
|
|
711
|
-
e.event === "profile created" &&
|
|
712
|
-
dayjs(e.time).isAfter(VDAY_WINDOW_START) &&
|
|
713
|
-
dayjs(e.time).isBefore(VDAY_WINDOW_END)
|
|
714
|
-
);
|
|
715
|
-
vdaySignups.forEach(signup => {
|
|
716
|
-
for (let i = 0; i < 2; i++) {
|
|
717
|
-
events.push({
|
|
718
|
-
...signup,
|
|
719
|
-
time: dayjs(signup.time).add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
|
|
720
|
-
user_id: signup.user_id,
|
|
721
|
-
});
|
|
722
|
-
}
|
|
723
|
-
});
|
|
724
|
-
|
|
725
|
-
const vdayUpgrades = events.filter(e =>
|
|
726
|
-
e.event === "premium upgrade" &&
|
|
727
|
-
dayjs(e.time).isAfter(VDAY_WINDOW_START) &&
|
|
728
|
-
dayjs(e.time).isBefore(VDAY_WINDOW_END)
|
|
729
|
-
);
|
|
730
|
-
if (vdayUpgrades.length > 0) {
|
|
731
|
-
const upgradeTemplate = vdayUpgrades[0];
|
|
732
|
-
vdayUpgrades.forEach(upgrade => {
|
|
733
|
-
for (let i = 0; i < 4; i++) {
|
|
734
|
-
events.push({
|
|
735
|
-
...upgradeTemplate,
|
|
736
|
-
time: dayjs(upgrade.time).add(chance.integer({ min: 1, max: 24 }), "hours").toISOString(),
|
|
737
|
-
user_id: upgrade.user_id,
|
|
738
|
-
plan: upgrade.plan,
|
|
739
|
-
price_usd: upgrade.price_usd,
|
|
740
|
-
});
|
|
741
|
-
}
|
|
742
|
-
});
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
// HOOK 8: OFF-APP RETENTION — users with phone-exchanged or
|
|
746
|
-
// date-scheduled in first 14 days get extra cloned app-open + swipe
|
|
747
|
-
// events past day 30. Non-milestone users lose 80% of post-day-30
|
|
748
|
-
// events. No flag.
|
|
749
|
-
if (firstEventTime) {
|
|
750
|
-
const day30 = dayjs(firstEventTime).add(30, "days");
|
|
751
|
-
const hasEarlyMilestone = hasPhoneExchangedEarly || hasDateScheduledEarly;
|
|
752
|
-
if (hasEarlyMilestone) {
|
|
753
|
-
const appOpenedTemplate = events.find(e => e.event === "app opened") || events[0];
|
|
754
|
-
const swipeTemplate = events.find(e => e.event === "swipe right") || events[0];
|
|
755
|
-
const postDay30Events = events.filter(e => dayjs(e.time).isAfter(day30));
|
|
756
|
-
if (postDay30Events.length < events.length * 0.3) {
|
|
757
|
-
const retentionCount = Math.floor(events.length * 0.3);
|
|
758
|
-
for (let i = 0; i < retentionCount; i++) {
|
|
759
|
-
const daysAfter = chance.integer({ min: 1, max: 60 });
|
|
760
|
-
const template = chance.bool({ likelihood: 50 }) ? appOpenedTemplate : swipeTemplate;
|
|
761
|
-
events.push({
|
|
762
|
-
...template,
|
|
763
|
-
time: day30.add(daysAfter, "days").add(chance.integer({ min: 0, max: 23 }), "hours").toISOString(),
|
|
764
|
-
user_id: template.user_id,
|
|
765
|
-
});
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
} else {
|
|
769
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
770
|
-
if (dayjs(events[i].time).isAfter(day30) && chance.bool({ likelihood: 80 })) {
|
|
771
|
-
events.splice(i, 1);
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
return record;
|
|
778
|
-
}
|
|
779
|
-
|
|
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);
|
|
780
826
|
return record;
|
|
781
827
|
},
|
|
782
828
|
};
|