@ak--47/dungeon-master 1.5.0 → 1.5.2
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/create-dungeon/SKILL.md +139 -46
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +31 -6
- package/.claude/skills/verify-dungeon/references/sql-recipes.md +44 -25
- package/.claude/skills/write-hooks/SKILL.md +31 -3
- package/CHANGELOG.md +85 -0
- package/HOOKS.md +13 -0
- 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 +24 -11
- 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 +54 -62
- package/dungeons/technical/text-generation.js +110 -146
- package/dungeons/vertical/ai-platform.js +296 -333
- package/dungeons/vertical/community.js +284 -255
- package/dungeons/vertical/crypto.js +395 -391
- package/dungeons/vertical/dating.js +411 -378
- package/dungeons/vertical/devtools.js +336 -298
- package/dungeons/vertical/ecommerce.js +316 -394
- package/dungeons/vertical/education.js +369 -325
- package/dungeons/vertical/fintech.js +358 -325
- package/dungeons/vertical/fitness.js +335 -291
- package/dungeons/vertical/food-delivery.js +343 -307
- package/dungeons/vertical/gaming.js +480 -444
- package/dungeons/vertical/healthcare.js +306 -262
- package/dungeons/vertical/insurance-application.js +427 -409
- package/dungeons/vertical/logistics.js +271 -252
- package/dungeons/vertical/marketplace.js +333 -323
- package/dungeons/vertical/media.js +382 -335
- package/dungeons/vertical/real-estate.js +395 -346
- package/dungeons/vertical/sass.js +319 -333
- package/dungeons/vertical/social.js +368 -316
- package/dungeons/vertical/travel.js +297 -295
- package/index.js +46 -4
- package/lib/core/config-validator.js +126 -28
- package/lib/generators/funnels.js +4 -1
- package/lib/orchestrators/mixpanel-sender.js +7 -0
- package/lib/orchestrators/user-loop.js +132 -31
- package/lib/templates/defaults.js +59 -59
- package/lib/templates/macro-presets.js +14 -2
- package/lib/utils/dataset-context.js +103 -0
- package/lib/utils/retention-curve.js +140 -0
- package/lib/utils/utils.js +149 -38
- package/lib/verify/counting.js +40 -0
- package/lib/verify/emulate-breakdown.js +20 -1
- package/lib/verify/index.js +1 -0
- package/lib/verify/schema-validator.js +3 -1
- package/package.json +11 -2
- package/scripts/run-dungeon.mjs +12 -1
- package/types.d.ts +117 -1
|
@@ -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",
|
|
@@ -406,7 +750,6 @@ const config = {
|
|
|
406
750
|
},
|
|
407
751
|
],
|
|
408
752
|
|
|
409
|
-
// ── Funnels (4) ──────────────────────────────────────────
|
|
410
753
|
funnels: [
|
|
411
754
|
{
|
|
412
755
|
name: "Onboarding",
|
|
@@ -445,13 +788,11 @@ const config = {
|
|
|
445
788
|
},
|
|
446
789
|
],
|
|
447
790
|
|
|
448
|
-
// ── SuperProps ──────────────────────────────────────────
|
|
449
791
|
superProps: {
|
|
450
792
|
subscription: ["Free", "Free", "Free", "Premium", "Elite"],
|
|
451
793
|
Platform: ["ios", "ios", "android"],
|
|
452
794
|
},
|
|
453
795
|
|
|
454
|
-
// ── UserProps ──────────────────────────────────────────
|
|
455
796
|
userProps: {
|
|
456
797
|
subscription: ["Free", "Free", "Free", "Premium", "Elite"],
|
|
457
798
|
age_range: ["18-24", "25-29", "30-34", "35-39", "40+"],
|
|
@@ -464,7 +805,6 @@ const config = {
|
|
|
464
805
|
Platform: ["ios", "ios", "android"],
|
|
465
806
|
},
|
|
466
807
|
|
|
467
|
-
// ── SCD Props ──────────────────────────────────────────
|
|
468
808
|
scdProps: {
|
|
469
809
|
subscription_tier: {
|
|
470
810
|
values: ["Free", "Premium", "Elite"],
|
|
@@ -479,317 +819,10 @@ const config = {
|
|
|
479
819
|
mirrorProps: {},
|
|
480
820
|
lookupTables: [],
|
|
481
821
|
|
|
482
|
-
hook
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
if (type === "funnel-pre") {
|
|
487
|
-
const isDateFunnel = meta.funnel?.sequence?.includes("date scheduled");
|
|
488
|
-
if (isDateFunnel) {
|
|
489
|
-
const age = meta.profile?.age_range;
|
|
490
|
-
if (age === "25-29" || age === "30-34") {
|
|
491
|
-
record.conversionRate = Math.min(95, Math.round(record.conversionRate * 1.3));
|
|
492
|
-
} else if (age === "40+") {
|
|
493
|
-
record.conversionRate = Math.round(record.conversionRate * 0.6);
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
// HOOK 9 (T2C): MATCH FLOW TIME-TO-CONVERT (funnel-post)
|
|
499
|
-
// Elite users complete swipe→match→message funnel 1.4x faster
|
|
500
|
-
// (factor 0.71); Free users 1.4x slower (factor 1.4).
|
|
501
|
-
if (type === "funnel-post") {
|
|
502
|
-
const segment = meta?.profile?.subscription;
|
|
503
|
-
if (Array.isArray(record) && record.length > 1) {
|
|
504
|
-
const factor = (
|
|
505
|
-
segment === "Elite" ? 0.71 :
|
|
506
|
-
segment === "Free" ? 1.4 :
|
|
507
|
-
1.0
|
|
508
|
-
);
|
|
509
|
-
if (factor !== 1.0) {
|
|
510
|
-
for (let i = 1; i < record.length; i++) {
|
|
511
|
-
const prev = dayjs(record[i - 1].time);
|
|
512
|
-
const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
|
|
513
|
-
record[i].time = prev.add(newGap, "milliseconds").toISOString();
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
// ─── EVERYTHING-LEVEL HOOKS ──────────────────────────────────────
|
|
520
|
-
|
|
521
|
-
if (type === "everything") {
|
|
522
|
-
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
523
|
-
const VDAY_WINDOW_START = datasetStart.add(58, "days");
|
|
524
|
-
const VDAY_WINDOW_END = datasetStart.add(63, "days");
|
|
525
|
-
const events = record;
|
|
526
|
-
if (!events || events.length === 0) return record;
|
|
527
|
-
|
|
528
|
-
const profile = meta.profile || {};
|
|
529
|
-
|
|
530
|
-
events.forEach(e => {
|
|
531
|
-
if (profile.subscription) e.subscription = profile.subscription;
|
|
532
|
-
if (profile.platform) e.platform = profile.platform;
|
|
533
|
-
});
|
|
534
|
-
|
|
535
|
-
let photoUploadCount = 0;
|
|
536
|
-
let promptAnsweredCount = 0;
|
|
537
|
-
let hasBioUpdated = false;
|
|
538
|
-
const matchEvents = [];
|
|
539
|
-
const messageSentEvents = [];
|
|
540
|
-
const superLikeEvents = [];
|
|
541
|
-
let hasPhoneExchangedEarly = false;
|
|
542
|
-
let hasDateScheduledEarly = false;
|
|
543
|
-
let firstEventTime = null;
|
|
544
|
-
|
|
545
|
-
events.forEach(event => {
|
|
546
|
-
if (!firstEventTime || dayjs(event.time).isBefore(dayjs(firstEventTime))) {
|
|
547
|
-
firstEventTime = event.time;
|
|
548
|
-
}
|
|
549
|
-
if (event.event === "photo uploaded") photoUploadCount++;
|
|
550
|
-
if (event.event === "prompt answered") promptAnsweredCount++;
|
|
551
|
-
if (event.event === "bio updated") hasBioUpdated = true;
|
|
552
|
-
if (event.event === "match received") matchEvents.push(event);
|
|
553
|
-
if (event.event === "message sent") messageSentEvents.push(event);
|
|
554
|
-
if (event.event === "swipe right" && event.is_super_like === true) superLikeEvents.push(event);
|
|
555
|
-
});
|
|
556
|
-
|
|
557
|
-
if (firstEventTime) {
|
|
558
|
-
const earlyWindow = dayjs(firstEventTime).add(14, "days");
|
|
559
|
-
events.forEach(event => {
|
|
560
|
-
const t = dayjs(event.time);
|
|
561
|
-
if (t.isBefore(earlyWindow)) {
|
|
562
|
-
if (event.event === "phone number exchanged") hasPhoneExchangedEarly = true;
|
|
563
|
-
if (event.event === "date scheduled") hasDateScheduledEarly = true;
|
|
564
|
-
}
|
|
565
|
-
});
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
// HOOK 1: PHOTO MAGIC NUMBER (sweet 2-5 photos → clone 2-4 extra matches)
|
|
569
|
-
// Over-6 score reduction is applied AT THE END of this hook so it also
|
|
570
|
-
// affects matches injected by HOOK 4 (premium boost).
|
|
571
|
-
if (photoUploadCount >= 2 && photoUploadCount <= 5 && matchEvents.length > 0) {
|
|
572
|
-
const matchTemplate = matchEvents[0];
|
|
573
|
-
matchEvents.forEach(m => {
|
|
574
|
-
const extras = chance.integer({ min: 2, max: 4 });
|
|
575
|
-
for (let i = 0; i < extras; i++) {
|
|
576
|
-
events.push({
|
|
577
|
-
...matchTemplate,
|
|
578
|
-
time: dayjs(m.time).add(chance.integer({ min: 1, max: 180 }), "minutes").toISOString(),
|
|
579
|
-
user_id: m.user_id,
|
|
580
|
-
match_score: chance.integer({ min: 60, max: 98 }),
|
|
581
|
-
});
|
|
582
|
-
}
|
|
583
|
-
});
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
// HOOK 2: WEEKEND SWIPE SURGE — Sunday swipes get heavy cloning
|
|
587
|
-
// to overcome the soup DOW weight deficit. Evening swipes (18-23)
|
|
588
|
-
// get 5 clones; daytime Sunday swipes get 2 clones.
|
|
589
|
-
// No flag — discover via day-of-week chart.
|
|
590
|
-
for (let idx = events.length - 1; idx >= 0; idx--) {
|
|
591
|
-
const event = events[idx];
|
|
592
|
-
if (event.event === "swipe right") {
|
|
593
|
-
const dow = new Date(event.time).getUTCDay();
|
|
594
|
-
if (dow === 0) {
|
|
595
|
-
const hr = new Date(event.time).getUTCHours();
|
|
596
|
-
const clones = (hr >= 18 && hr <= 23) ? 5 : 2;
|
|
597
|
-
const etime = dayjs(event.time);
|
|
598
|
-
for (let c = 0; c < clones; c++) {
|
|
599
|
-
events.push({
|
|
600
|
-
...event,
|
|
601
|
-
time: etime.add(chance.integer({ min: 1, max: 60 }), "minutes").toISOString(),
|
|
602
|
-
user_id: event.user_id,
|
|
603
|
-
});
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
// HOOK 3: SUPER-LIKE EFFECT — clone 3 extra match events per
|
|
610
|
-
// super-like, near in time. No flag — discover via funnel
|
|
611
|
-
// "swipe right where is_super_like=true" → "match received".
|
|
612
|
-
if (superLikeEvents.length > 0) {
|
|
613
|
-
const matchTemplate = matchEvents[0] || events[0];
|
|
614
|
-
superLikeEvents.forEach(sle => {
|
|
615
|
-
for (let i = 0; i < 3; i++) {
|
|
616
|
-
events.push({
|
|
617
|
-
...matchTemplate,
|
|
618
|
-
event: "match received",
|
|
619
|
-
time: dayjs(sle.time).add(chance.integer({ min: 5, max: 120 }), "minutes").toISOString(),
|
|
620
|
-
user_id: sle.user_id,
|
|
621
|
-
match_score: chance.integer({ min: 70, max: 99 }),
|
|
622
|
-
});
|
|
623
|
-
}
|
|
624
|
-
});
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
// HOOK 5: GHOSTING CHURN — users with match but no message within
|
|
628
|
-
// 48hrs lose 80% of post-match events. No flag.
|
|
629
|
-
// (runs BEFORE premium boost so injected premium matches survive)
|
|
630
|
-
if (matchEvents.length > 0) {
|
|
631
|
-
let hasTimely = false;
|
|
632
|
-
for (const m of matchEvents) {
|
|
633
|
-
const matchTime = dayjs(m.time);
|
|
634
|
-
const deadline = matchTime.add(48, "hours");
|
|
635
|
-
for (const msg of messageSentEvents) {
|
|
636
|
-
const msgTime = dayjs(msg.time);
|
|
637
|
-
if (msgTime.isAfter(matchTime) && msgTime.isBefore(deadline)) {
|
|
638
|
-
hasTimely = true;
|
|
639
|
-
break;
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
if (hasTimely) break;
|
|
643
|
-
}
|
|
644
|
-
if (!hasTimely) {
|
|
645
|
-
const earliestMatch = matchEvents.reduce((min, m) =>
|
|
646
|
-
dayjs(m.time).isBefore(dayjs(min.time)) ? m : min
|
|
647
|
-
);
|
|
648
|
-
const churnAfter = dayjs(earliestMatch.time);
|
|
649
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
650
|
-
if (dayjs(events[i].time).isAfter(churnAfter) && chance.bool({ likelihood: 80 })) {
|
|
651
|
-
events.splice(i, 1);
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
// HOOK 4: PREMIUM MATCH BOOST — Premium 2x, Elite 4x match events.
|
|
658
|
-
// Elite users also get profile-viewed events injected (see-who-liked-you).
|
|
659
|
-
// Reads subscription from profile. Runs AFTER ghosting churn so
|
|
660
|
-
// injected matches are not culled.
|
|
661
|
-
const sub = profile.subscription;
|
|
662
|
-
if ((sub === "Premium" || sub === "Elite") && matchEvents.length > 0) {
|
|
663
|
-
// Count surviving match events post-churn
|
|
664
|
-
const survivingMatches = events.filter(e => e.event === "match received");
|
|
665
|
-
const baseCount = survivingMatches.length || 1;
|
|
666
|
-
const targetMultiplier = sub === "Elite" ? 4 : 2;
|
|
667
|
-
const toAdd = Math.max(0, baseCount * targetMultiplier - baseCount);
|
|
668
|
-
const matchTemplate = matchEvents[0];
|
|
669
|
-
for (let i = 0; i < toAdd; i++) {
|
|
670
|
-
const sourceMatch = survivingMatches[i % survivingMatches.length] || matchTemplate;
|
|
671
|
-
events.push({
|
|
672
|
-
...matchTemplate,
|
|
673
|
-
time: dayjs(sourceMatch.time).add(chance.integer({ min: 10, max: 240 }), "minutes").toISOString(),
|
|
674
|
-
user_id: sourceMatch.user_id,
|
|
675
|
-
match_score: chance.integer({ min: 65, max: 99 }),
|
|
676
|
-
});
|
|
677
|
-
}
|
|
678
|
-
if (sub === "Elite") {
|
|
679
|
-
const viewTemplate = events.find(e => e.event === "profile viewed") || matchTemplate;
|
|
680
|
-
survivingMatches.forEach(m => {
|
|
681
|
-
events.push({
|
|
682
|
-
...viewTemplate,
|
|
683
|
-
event: "profile viewed",
|
|
684
|
-
time: dayjs(m.time).subtract(chance.integer({ min: 10, max: 120 }), "minutes").toISOString(),
|
|
685
|
-
user_id: m.user_id,
|
|
686
|
-
viewer_source: "liked_you",
|
|
687
|
-
});
|
|
688
|
-
});
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
// HOOK 6: BIO + PROMPT POWER USERS — bio + 3+ prompts → 3 extra
|
|
693
|
-
// cloned date events per existing. No flag.
|
|
694
|
-
if (hasBioUpdated && promptAnsweredCount >= 3) {
|
|
695
|
-
const dateEvents = events.filter(e => e.event === "date scheduled");
|
|
696
|
-
if (dateEvents.length > 0) {
|
|
697
|
-
const dateTemplate = dateEvents[0];
|
|
698
|
-
const venueTypes = ["coffee", "dinner", "drinks", "activity", "virtual"];
|
|
699
|
-
for (let i = 0; i < dateEvents.length * 3; i++) {
|
|
700
|
-
const sourceDate = dateEvents[i % dateEvents.length];
|
|
701
|
-
events.push({
|
|
702
|
-
...dateTemplate,
|
|
703
|
-
time: dayjs(sourceDate.time).add(chance.integer({ min: 1, max: 72 }), "hours").toISOString(),
|
|
704
|
-
user_id: sourceDate.user_id,
|
|
705
|
-
venue_type: chance.pickone(venueTypes),
|
|
706
|
-
});
|
|
707
|
-
}
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
// HOOK 7: VALENTINE'S DAY SPIKE — clone profile-created events during
|
|
712
|
-
// days 58-63 (3x volume), plus clone premium-upgrade events 5x. No flag.
|
|
713
|
-
const vdaySignups = events.filter(e =>
|
|
714
|
-
e.event === "profile created" &&
|
|
715
|
-
dayjs(e.time).isAfter(VDAY_WINDOW_START) &&
|
|
716
|
-
dayjs(e.time).isBefore(VDAY_WINDOW_END)
|
|
717
|
-
);
|
|
718
|
-
vdaySignups.forEach(signup => {
|
|
719
|
-
for (let i = 0; i < 2; i++) {
|
|
720
|
-
events.push({
|
|
721
|
-
...signup,
|
|
722
|
-
time: dayjs(signup.time).add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
|
|
723
|
-
user_id: signup.user_id,
|
|
724
|
-
});
|
|
725
|
-
}
|
|
726
|
-
});
|
|
727
|
-
|
|
728
|
-
const vdayUpgrades = events.filter(e =>
|
|
729
|
-
e.event === "premium upgrade" &&
|
|
730
|
-
dayjs(e.time).isAfter(VDAY_WINDOW_START) &&
|
|
731
|
-
dayjs(e.time).isBefore(VDAY_WINDOW_END)
|
|
732
|
-
);
|
|
733
|
-
if (vdayUpgrades.length > 0) {
|
|
734
|
-
const upgradeTemplate = vdayUpgrades[0];
|
|
735
|
-
vdayUpgrades.forEach(upgrade => {
|
|
736
|
-
for (let i = 0; i < 4; i++) {
|
|
737
|
-
events.push({
|
|
738
|
-
...upgradeTemplate,
|
|
739
|
-
time: dayjs(upgrade.time).add(chance.integer({ min: 1, max: 24 }), "hours").toISOString(),
|
|
740
|
-
user_id: upgrade.user_id,
|
|
741
|
-
plan: upgrade.plan,
|
|
742
|
-
price_usd: upgrade.price_usd,
|
|
743
|
-
});
|
|
744
|
-
}
|
|
745
|
-
});
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
// HOOK 1b: PHOTO MAGIC NUMBER — over-6 score reduction (applied LAST so
|
|
749
|
-
// it also affects matches injected by HOOK 4 premium boost).
|
|
750
|
-
if (photoUploadCount >= 6) {
|
|
751
|
-
events.forEach(e => {
|
|
752
|
-
if (e.event === "match received" && typeof e.match_score === "number") {
|
|
753
|
-
e.match_score = Math.max(20, Math.round(e.match_score * 0.65));
|
|
754
|
-
}
|
|
755
|
-
});
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
// HOOK 8: OFF-APP RETENTION — users with phone-exchanged or
|
|
759
|
-
// date-scheduled in first 14 days get extra cloned app-open + swipe
|
|
760
|
-
// events past day 30. Non-milestone users lose 80% of post-day-30
|
|
761
|
-
// events. No flag.
|
|
762
|
-
if (firstEventTime) {
|
|
763
|
-
const day30 = dayjs(firstEventTime).add(30, "days");
|
|
764
|
-
const hasEarlyMilestone = hasPhoneExchangedEarly || hasDateScheduledEarly;
|
|
765
|
-
if (hasEarlyMilestone) {
|
|
766
|
-
const appOpenedTemplate = events.find(e => e.event === "app opened") || events[0];
|
|
767
|
-
const swipeTemplate = events.find(e => e.event === "swipe right") || events[0];
|
|
768
|
-
const postDay30Events = events.filter(e => dayjs(e.time).isAfter(day30));
|
|
769
|
-
if (postDay30Events.length < events.length * 0.3) {
|
|
770
|
-
const retentionCount = Math.floor(events.length * 0.3);
|
|
771
|
-
for (let i = 0; i < retentionCount; i++) {
|
|
772
|
-
const daysAfter = chance.integer({ min: 1, max: 60 });
|
|
773
|
-
const template = chance.bool({ likelihood: 50 }) ? appOpenedTemplate : swipeTemplate;
|
|
774
|
-
events.push({
|
|
775
|
-
...template,
|
|
776
|
-
time: day30.add(daysAfter, "days").add(chance.integer({ min: 0, max: 23 }), "hours").toISOString(),
|
|
777
|
-
user_id: template.user_id,
|
|
778
|
-
});
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
} else {
|
|
782
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
783
|
-
if (dayjs(events[i].time).isAfter(day30) && chance.bool({ likelihood: 80 })) {
|
|
784
|
-
events.splice(i, 1);
|
|
785
|
-
}
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
return record;
|
|
791
|
-
}
|
|
792
|
-
|
|
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);
|
|
793
826
|
return record;
|
|
794
827
|
},
|
|
795
828
|
};
|