@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,51 @@
|
|
|
1
|
-
// ──
|
|
2
|
-
const SEED = "harness-social";
|
|
3
|
-
const num_days = 120;
|
|
4
|
-
const num_users = 10_000;
|
|
5
|
-
const avg_events_per_user_per_day = 1.2;
|
|
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
7
|
import * as v from "ak-tools";
|
|
16
|
-
|
|
17
|
-
dayjs.extend(utc);
|
|
18
|
-
const chance = u.initChance(SEED);
|
|
19
|
-
|
|
20
8
|
/** @typedef {import("../../types").Dungeon} Config */
|
|
21
9
|
|
|
10
|
+
// ── OVERVIEW ──
|
|
22
11
|
/*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
12
|
+
* NAME: Chirp
|
|
13
|
+
* APP: Twitter+Instagram-style social media platform with algorithmic
|
|
14
|
+
* feed, creator monetization, communities, and direct messaging.
|
|
15
|
+
* Power users become "creators" with subscriber tiers; native ads
|
|
16
|
+
* woven into feed and story placements.
|
|
17
|
+
* SCALE: 10,000 users, ~1.4M events, 121 days (2026-01-01 → 2026-05-01)
|
|
18
|
+
* CORE LOOP: account created → profile updated → post created → consume + engage
|
|
19
|
+
*
|
|
20
|
+
* EVENTS (18):
|
|
21
|
+
* post viewed (30) > post liked (18) > story viewed (15) > post created (12)
|
|
22
|
+
* > notification received (12) > comment posted (10) > ad viewed (10)
|
|
23
|
+
* > user followed (8) > dm sent (8) > search performed (7) > post shared (6)
|
|
24
|
+
* > story created (5) > profile updated (3) > user unfollowed (2)
|
|
25
|
+
* > ad clicked (2) > creator subscription started (2) > report submitted (1)
|
|
26
|
+
* > account created (1)
|
|
27
|
+
*
|
|
28
|
+
* FUNNELS (8):
|
|
29
|
+
* - Onboarding: account created → profile updated → post created (70%)
|
|
30
|
+
* - Feed Engagement: post viewed → post liked → comment posted (45%)
|
|
31
|
+
* - Content Cycle: post created → post viewed → post liked → post shared (30%)
|
|
32
|
+
* - Stories: story created → story viewed → dm sent (40%)
|
|
33
|
+
* - Discovery: search performed → post viewed → user followed (35%)
|
|
34
|
+
* - Notifications: notification received → post viewed → post liked (50%)
|
|
35
|
+
* - Creator Monetize: profile updated → creator subscription started → post created (15%)
|
|
36
|
+
* - Ad Moderation: ad viewed → ad clicked → report submitted (20%)
|
|
37
|
+
*
|
|
38
|
+
* USER PROPS: app_version, account_type, follower_count, following_count,
|
|
39
|
+
* bio_length, verified, content_niche
|
|
40
|
+
* SUPER PROPS: app_version, account_type
|
|
41
|
+
* SCD PROPS: account_type (personal/creator/business/verified, monthly fuzzy, max 6),
|
|
42
|
+
* community_status (new/growing/established/featured, monthly fixed,
|
|
43
|
+
* max 6, community_id-scoped)
|
|
44
|
+
* GROUPS: community_id (100 communities)
|
|
42
45
|
*/
|
|
43
46
|
|
|
47
|
+
// ── HOOK STORIES ──
|
|
44
48
|
/*
|
|
45
|
-
* =====================================================================================
|
|
46
|
-
* ANALYTICS HOOKS (10 hooks)
|
|
47
|
-
*
|
|
48
|
-
* Adds 10. ONBOARDING TIME-TO-CONVERT: creator/business 0.71x faster, personal
|
|
49
|
-
* 1.25x slower (funnel-post). Discover via Onboarding funnel median TTC by account_type.
|
|
50
|
-
* NOTE (funnel-post measurement): visible only via Mixpanel funnel median TTC.
|
|
51
|
-
* Cross-event MIN→MIN SQL queries on raw events do NOT show this.
|
|
52
|
-
* =====================================================================================
|
|
53
|
-
*
|
|
54
49
|
* NOTE: All cohort effects are HIDDEN — discoverable only via behavioral cohorts
|
|
55
50
|
* (count an event per user, then measure downstream). No cohort flag is stamped
|
|
56
51
|
* on events. Algorithm-change source flips and engagement-bait short durations
|
|
@@ -272,6 +267,9 @@ const chance = u.initChance(SEED);
|
|
|
272
267
|
* - Breakdown: "account_type" (superProp)
|
|
273
268
|
* - Expected: creator/business ~ 0.71x baseline; personal ~ 1.25x baseline
|
|
274
269
|
*
|
|
270
|
+
* NOTE (funnel-post measurement): visible only via Mixpanel funnel median
|
|
271
|
+
* TTC. Cross-event MIN→MIN SQL queries on raw events do NOT show this.
|
|
272
|
+
*
|
|
275
273
|
* REAL-WORLD ANALOGUE: Creators and businesses arrive with clear intent
|
|
276
274
|
* and complete profile setup faster; personal users browse casually and
|
|
277
275
|
* take longer to commit to their first post.
|
|
@@ -296,35 +294,341 @@ const chance = u.initChance(SEED);
|
|
|
296
294
|
* =====================================================================================
|
|
297
295
|
*/
|
|
298
296
|
|
|
297
|
+
// ── SCALE ──
|
|
298
|
+
const SEED = "harness-social";
|
|
299
|
+
const NUM_USERS = 10_000;
|
|
300
|
+
const DATASET_START = "2026-01-01T00:00:00Z";
|
|
301
|
+
const DATASET_END = "2026-05-01T23:59:59Z";
|
|
302
|
+
const EVENTS_PER_DAY = 1.2;
|
|
303
|
+
const token = process.env.MP_TOKEN || "your-mixpanel-token";
|
|
304
|
+
|
|
305
|
+
const chance = u.initChance(SEED);
|
|
306
|
+
|
|
307
|
+
// ── KNOBS (tweak these to reshape stories) ──
|
|
308
|
+
const VIRAL_POST_THRESHOLD = 10;
|
|
309
|
+
const VIRAL_USER_LIKELIHOOD = 5;
|
|
310
|
+
const VIRAL_CLONES_MIN = 60;
|
|
311
|
+
const VIRAL_CLONES_MAX = 120;
|
|
312
|
+
|
|
313
|
+
const FOLLOW_SNOWBALL_THRESHOLD = 5;
|
|
314
|
+
const FOLLOW_SNOWBALL_LIKELIHOOD = 50;
|
|
315
|
+
|
|
316
|
+
const ALGORITHM_CHANGE_DAY = 45;
|
|
317
|
+
const ALGORITHM_FLIP_LIKELIHOOD = 70;
|
|
318
|
+
|
|
319
|
+
const ENGAGEMENT_BAIT_LIKELIHOOD = 20;
|
|
320
|
+
|
|
321
|
+
const REENGAGEMENT_START_DAY = 30;
|
|
322
|
+
const REENGAGEMENT_LIKELIHOOD = 30;
|
|
323
|
+
|
|
324
|
+
const CREATOR_POST_CLONES = 2;
|
|
325
|
+
const CREATOR_STORY_CLONES = 2;
|
|
326
|
+
const CREATOR_VIEW_CLONE_LIKELIHOOD = 25;
|
|
327
|
+
|
|
328
|
+
const TOXICITY_THRESHOLD = 2;
|
|
329
|
+
const TOXICITY_CUTOFF_DAYS = 30;
|
|
330
|
+
const TOXICITY_DROP_LIKELIHOOD = 60;
|
|
331
|
+
|
|
332
|
+
const WEEKEND_CLONE_LIKELIHOOD = 30;
|
|
333
|
+
|
|
334
|
+
const POST_SWEET_MIN = 3;
|
|
335
|
+
const POST_SWEET_MAX = 7;
|
|
336
|
+
const POST_OVER_THRESHOLD = 8;
|
|
337
|
+
const POST_SWEET_COMMENT_BOOST = 1.4;
|
|
338
|
+
const POST_OVER_COMMENT_FACTOR = 0.7;
|
|
339
|
+
|
|
340
|
+
const ONBOARDING_TTC_FAST = 0.71;
|
|
341
|
+
const ONBOARDING_TTC_SLOW = 1.25;
|
|
342
|
+
|
|
343
|
+
// ── DATA ARRAYS ──
|
|
299
344
|
// Generate consistent post IDs for lookup tables
|
|
300
345
|
const postIds = v.range(1, 1001).map(n => `post_${v.uid(8)}`);
|
|
301
346
|
|
|
347
|
+
// ── HELPER FUNCTIONS ──
|
|
348
|
+
function handleFunnelPostHooks(record, meta) {
|
|
349
|
+
// H10: ONBOARDING TIME-TO-CONVERT — creator/business 0.71x faster,
|
|
350
|
+
// personal 1.25x slower. Scales inter-step gaps via timestamp rewrite.
|
|
351
|
+
const segment = meta?.profile?.account_type;
|
|
352
|
+
if (Array.isArray(record) && record.length > 1) {
|
|
353
|
+
const factor = (
|
|
354
|
+
segment === "creator" || segment === "business" ? ONBOARDING_TTC_FAST :
|
|
355
|
+
segment === "personal" ? ONBOARDING_TTC_SLOW :
|
|
356
|
+
1.0
|
|
357
|
+
);
|
|
358
|
+
if (factor !== 1.0) {
|
|
359
|
+
for (let i = 1; i < record.length; i++) {
|
|
360
|
+
const prev = dayjs(record[i - 1].time);
|
|
361
|
+
const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
|
|
362
|
+
record[i].time = prev.add(newGap, "milliseconds").toISOString();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return record;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function handleEverythingHooks(record, meta) {
|
|
370
|
+
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
371
|
+
const userEvents = record;
|
|
372
|
+
if (!userEvents || userEvents.length === 0) return record;
|
|
373
|
+
|
|
374
|
+
// Stamp superProps from profile for consistency
|
|
375
|
+
const profile = meta.profile;
|
|
376
|
+
userEvents.forEach(e => {
|
|
377
|
+
e.app_version = profile.app_version;
|
|
378
|
+
e.account_type = profile.account_type;
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// First pass: identify behavioral patterns (no flags written)
|
|
382
|
+
let postCreatedCount = 0;
|
|
383
|
+
let followReceivedCount = 0;
|
|
384
|
+
let reportSubmittedCount = 0;
|
|
385
|
+
let hasCreatorSubscription = false;
|
|
386
|
+
let isViralCreator = false;
|
|
387
|
+
|
|
388
|
+
userEvents.forEach((event) => {
|
|
389
|
+
if (event.event === "post created") postCreatedCount++;
|
|
390
|
+
if (event.event === "user followed") followReceivedCount++;
|
|
391
|
+
if (event.event === "report submitted") reportSubmittedCount++;
|
|
392
|
+
if (event.event === "creator subscription started") hasCreatorSubscription = true;
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
if (postCreatedCount >= VIRAL_POST_THRESHOLD && chance.bool({ likelihood: VIRAL_USER_LIKELIHOOD })) {
|
|
396
|
+
isViralCreator = true;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Second pass: inject cloned events (no behavioral cohort flags)
|
|
400
|
+
for (let idx = userEvents.length - 1; idx >= 0; idx--) {
|
|
401
|
+
const event = userEvents[idx];
|
|
402
|
+
const eventTime = dayjs(event.time);
|
|
403
|
+
|
|
404
|
+
// H1: VIRAL CONTENT CASCADE — clone 60-120 view/like/share per post.
|
|
405
|
+
// Discovery: bin users by post-created count, observe per-user view/like/share volume.
|
|
406
|
+
if (isViralCreator && event.event === "post created") {
|
|
407
|
+
const viralViews = chance.integer({ min: VIRAL_CLONES_MIN, max: VIRAL_CLONES_MAX });
|
|
408
|
+
const viralLikes = chance.integer({ min: VIRAL_CLONES_MIN, max: VIRAL_CLONES_MAX });
|
|
409
|
+
const viralShares = chance.integer({ min: VIRAL_CLONES_MIN, max: VIRAL_CLONES_MAX });
|
|
410
|
+
const injected = [];
|
|
411
|
+
|
|
412
|
+
const viewTemplate = userEvents.find(e => e.event === "post viewed");
|
|
413
|
+
const likeTemplate = userEvents.find(e => e.event === "post liked");
|
|
414
|
+
const shareTemplate = userEvents.find(e => e.event === "post shared");
|
|
415
|
+
|
|
416
|
+
for (let i = 0; i < viralViews; i++) {
|
|
417
|
+
injected.push({
|
|
418
|
+
...(viewTemplate || event),
|
|
419
|
+
event: "post viewed",
|
|
420
|
+
time: eventTime.add(chance.integer({ min: 1, max: 180 }), 'minutes').toISOString(),
|
|
421
|
+
user_id: event.user_id,
|
|
422
|
+
post_type: event.post_type || "text",
|
|
423
|
+
source: chance.pickone(["feed", "explore", "search"]),
|
|
424
|
+
view_duration_sec: chance.integer({ min: 5, max: 90 }),
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
for (let i = 0; i < viralLikes; i++) {
|
|
428
|
+
injected.push({
|
|
429
|
+
...(likeTemplate || event),
|
|
430
|
+
event: "post liked",
|
|
431
|
+
time: eventTime.add(chance.integer({ min: 2, max: 240 }), 'minutes').toISOString(),
|
|
432
|
+
user_id: event.user_id,
|
|
433
|
+
post_type: event.post_type || "text",
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
for (let i = 0; i < viralShares; i++) {
|
|
437
|
+
injected.push({
|
|
438
|
+
...(shareTemplate || event),
|
|
439
|
+
event: "post shared",
|
|
440
|
+
time: eventTime.add(chance.integer({ min: 5, max: 300 }), 'minutes').toISOString(),
|
|
441
|
+
user_id: event.user_id,
|
|
442
|
+
share_destination: chance.pickone(["repost", "dm", "external", "copy_link"]),
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
userEvents.splice(idx + 1, 0, ...injected);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// H2: FOLLOW-BACK SNOWBALL — extra post + comment per user-followed cluster.
|
|
450
|
+
// Discovery: cohort users with >=5 user-followed events, compare posts/user.
|
|
451
|
+
if (followReceivedCount >= FOLLOW_SNOWBALL_THRESHOLD && event.event === "post created") {
|
|
452
|
+
if (chance.bool({ likelihood: FOLLOW_SNOWBALL_LIKELIHOOD })) {
|
|
453
|
+
const commentTemplate = userEvents.find(e => e.event === "comment posted");
|
|
454
|
+
const duplicatePost = {
|
|
455
|
+
...event,
|
|
456
|
+
time: eventTime.add(chance.integer({ min: 30, max: 240 }), 'minutes').toISOString(),
|
|
457
|
+
user_id: event.user_id,
|
|
458
|
+
post_type: chance.pickone(["text", "image", "video"]),
|
|
459
|
+
character_count: chance.integer({ min: 10, max: 280 }),
|
|
460
|
+
has_media: chance.bool({ likelihood: 60 }),
|
|
461
|
+
hashtag_count: chance.integer({ min: 0, max: 5 }),
|
|
462
|
+
};
|
|
463
|
+
const extraComment = {
|
|
464
|
+
...(commentTemplate || event),
|
|
465
|
+
event: "comment posted",
|
|
466
|
+
time: eventTime.add(chance.integer({ min: 10, max: 120 }), 'minutes').toISOString(),
|
|
467
|
+
user_id: event.user_id,
|
|
468
|
+
comment_length: chance.integer({ min: 5, max: 200 }),
|
|
469
|
+
has_mention: chance.bool({ likelihood: 40 }),
|
|
470
|
+
};
|
|
471
|
+
userEvents.splice(idx + 1, 0, duplicatePost, extraComment);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// H6: CREATOR MONETIZATION — 3x post/story rate for subscribers.
|
|
476
|
+
// Discovery: cohort users with creator-subscription-started event, compare posts/user.
|
|
477
|
+
if (hasCreatorSubscription && event.event === "post created") {
|
|
478
|
+
for (let i = 0; i < CREATOR_POST_CLONES; i++) {
|
|
479
|
+
const extraPost = {
|
|
480
|
+
...event,
|
|
481
|
+
time: eventTime.add(chance.integer({ min: 1, max: 12 }), 'hours').toISOString(),
|
|
482
|
+
user_id: event.user_id,
|
|
483
|
+
post_type: chance.pickone(["text", "image", "video", "link"]),
|
|
484
|
+
character_count: chance.integer({ min: 20, max: 280 }),
|
|
485
|
+
has_media: chance.bool({ likelihood: 70 }),
|
|
486
|
+
hashtag_count: chance.integer({ min: 1, max: 8 }),
|
|
487
|
+
};
|
|
488
|
+
userEvents.splice(idx + 1, 0, extraPost);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
if (hasCreatorSubscription && event.event === "story created") {
|
|
492
|
+
for (let i = 0; i < CREATOR_STORY_CLONES; i++) {
|
|
493
|
+
const extraStory = {
|
|
494
|
+
...event,
|
|
495
|
+
time: eventTime.add(chance.integer({ min: 1, max: 8 }), 'hours').toISOString(),
|
|
496
|
+
user_id: event.user_id,
|
|
497
|
+
story_type: chance.pickone(["photo", "video", "text"]),
|
|
498
|
+
has_filter: chance.bool({ likelihood: 60 }),
|
|
499
|
+
has_sticker: chance.bool({ likelihood: 40 }),
|
|
500
|
+
};
|
|
501
|
+
userEvents.splice(idx + 1, 0, extraStory);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
if (hasCreatorSubscription && event.event === "post viewed") {
|
|
505
|
+
if (chance.bool({ likelihood: CREATOR_VIEW_CLONE_LIKELIHOOD })) {
|
|
506
|
+
const analyticsView = {
|
|
507
|
+
...event,
|
|
508
|
+
time: eventTime.add(chance.integer({ min: 1, max: 30 }), 'minutes').toISOString(),
|
|
509
|
+
user_id: event.user_id,
|
|
510
|
+
post_type: event.post_type || "text",
|
|
511
|
+
source: "profile",
|
|
512
|
+
view_duration_sec: chance.integer({ min: 10, max: 60 }),
|
|
513
|
+
};
|
|
514
|
+
userEvents.splice(idx + 1, 0, analyticsView);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// H8: WEEKEND CONTENT SURGE — duplicate weekend posts/stories with offset.
|
|
520
|
+
// Discovery: line chart by day-of-week shows Sat/Sun bump.
|
|
521
|
+
for (let idx = userEvents.length - 1; idx >= 0; idx--) {
|
|
522
|
+
const event = userEvents[idx];
|
|
523
|
+
if (event.event === "post created" || event.event === "story created") {
|
|
524
|
+
const dow = new Date(event.time).getUTCDay();
|
|
525
|
+
if ((dow === 0 || dow === 6) && chance.bool({ likelihood: WEEKEND_CLONE_LIKELIHOOD })) {
|
|
526
|
+
const etime = dayjs(event.time);
|
|
527
|
+
const dup = {
|
|
528
|
+
...event,
|
|
529
|
+
time: etime.add(chance.integer({ min: 1, max: 3 }), 'hours').toISOString(),
|
|
530
|
+
};
|
|
531
|
+
userEvents.splice(idx + 1, 0, dup);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// H3: ALGORITHM CHANGE — day 45 flips feed → explore on post viewed.
|
|
537
|
+
// H4: ENGAGEMENT BAIT — 20% of post-viewed events get crushed duration.
|
|
538
|
+
// H5: NOTIFICATION RE-ENGAGEMENT — after day 30, 30% of views → notification.
|
|
539
|
+
// All three run AFTER injection passes so they apply to cloned events too.
|
|
540
|
+
const algorithmChangeDay = datasetStart.add(ALGORITHM_CHANGE_DAY, 'days');
|
|
541
|
+
const reengagementStart = datasetStart.add(REENGAGEMENT_START_DAY, 'days');
|
|
542
|
+
userEvents.forEach(e => {
|
|
543
|
+
if (e.event === "post viewed") {
|
|
544
|
+
const eventTime = dayjs(e.time);
|
|
545
|
+
|
|
546
|
+
// H3: Algorithm Change
|
|
547
|
+
if (eventTime.isAfter(algorithmChangeDay)) {
|
|
548
|
+
if (chance.bool({ likelihood: ALGORITHM_FLIP_LIKELIHOOD })) {
|
|
549
|
+
e.source = "explore";
|
|
550
|
+
}
|
|
551
|
+
} else {
|
|
552
|
+
if (chance.bool({ likelihood: ALGORITHM_FLIP_LIKELIHOOD })) {
|
|
553
|
+
e.source = "feed";
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// H4: Engagement Bait — 20% crushed view duration
|
|
558
|
+
if (chance.bool({ likelihood: ENGAGEMENT_BAIT_LIKELIHOOD })) {
|
|
559
|
+
e.view_duration_sec = chance.integer({ min: 1, max: 5 });
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// H5: Notification Re-engagement (runs after #3 so can override)
|
|
563
|
+
if (eventTime.isAfter(reengagementStart) && chance.bool({ likelihood: REENGAGEMENT_LIKELIHOOD })) {
|
|
564
|
+
e.source = "notification";
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
// H7: TOXICITY CHURN — drop 60% of activity after day 30 for high reporters.
|
|
570
|
+
// Discovery: cohort users with >=2 report-submitted events, observe retention drop.
|
|
571
|
+
if (reportSubmittedCount >= TOXICITY_THRESHOLD) {
|
|
572
|
+
const churnCutoff = datasetStart.add(TOXICITY_CUTOFF_DAYS, 'days');
|
|
573
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
574
|
+
const evt = userEvents[i];
|
|
575
|
+
if (dayjs(evt.time).isAfter(churnCutoff) && chance.bool({ likelihood: TOXICITY_DROP_LIKELIHOOD })) {
|
|
576
|
+
userEvents.splice(i, 1);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// H9: POST-CREATED MAGIC NUMBER (no flags)
|
|
582
|
+
// Sweet 3-7 posts → +40% on comment_length on the user's comment events.
|
|
583
|
+
// Over 8+ → -30% comment_length (engagement burnout).
|
|
584
|
+
if (postCreatedCount >= POST_SWEET_MIN && postCreatedCount <= POST_SWEET_MAX) {
|
|
585
|
+
userEvents.forEach(e => {
|
|
586
|
+
if (e.event === 'comment posted' && typeof e.comment_length === 'number') {
|
|
587
|
+
e.comment_length = Math.round(e.comment_length * POST_SWEET_COMMENT_BOOST);
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
} else if (postCreatedCount >= POST_OVER_THRESHOLD) {
|
|
591
|
+
userEvents.forEach(e => {
|
|
592
|
+
if (e.event === 'comment posted' && typeof e.comment_length === 'number') {
|
|
593
|
+
e.comment_length = Math.round(e.comment_length * POST_OVER_COMMENT_FACTOR);
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
return record;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ── CONFIG ──
|
|
302
602
|
/** @type {Config} */
|
|
303
603
|
const config = {
|
|
304
604
|
version: 2,
|
|
305
|
-
token,
|
|
306
605
|
seed: SEED,
|
|
307
|
-
datasetStart:
|
|
308
|
-
datasetEnd:
|
|
606
|
+
datasetStart: DATASET_START,
|
|
607
|
+
datasetEnd: DATASET_END,
|
|
309
608
|
soup: { dayOfWeekWeights: [1.0, 1.0, 1.0, 1.0, 1.0, 1.2, 1.2] },
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
numUsers: num_users,
|
|
313
|
-
hasAnonIds: true,
|
|
314
|
-
avgDevicePerUser: 2,
|
|
315
|
-
hasSessionIds: true,
|
|
609
|
+
avgEventsPerUserPerDay: EVENTS_PER_DAY,
|
|
610
|
+
numUsers: NUM_USERS,
|
|
316
611
|
format: "json",
|
|
317
612
|
gzip: true,
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
613
|
+
credentials: {
|
|
614
|
+
token,
|
|
615
|
+
},
|
|
616
|
+
switches: {
|
|
617
|
+
hasSessionIds: true,
|
|
618
|
+
alsoInferFunnels: false,
|
|
619
|
+
hasLocation: true,
|
|
620
|
+
hasAndroidDevices: true,
|
|
621
|
+
hasIOSDevices: true,
|
|
622
|
+
hasDesktopDevices: true,
|
|
623
|
+
hasBrowser: false,
|
|
624
|
+
hasCampaigns: false,
|
|
625
|
+
isAnonymous: false,
|
|
626
|
+
hasAdSpend: false,
|
|
627
|
+
hasAvatar: true,
|
|
628
|
+
},
|
|
629
|
+
identity: {
|
|
630
|
+
avgDevicePerUser: 2,
|
|
631
|
+
},
|
|
328
632
|
concurrency: 1,
|
|
329
633
|
writeToDisk: false,
|
|
330
634
|
scdProps: {
|
|
@@ -415,6 +719,7 @@ const config = {
|
|
|
415
719
|
{
|
|
416
720
|
event: "post created",
|
|
417
721
|
weight: 12,
|
|
722
|
+
isStrictEvent: false,
|
|
418
723
|
properties: {
|
|
419
724
|
"post_type": ["text", "image", "video", "poll", "link"],
|
|
420
725
|
"character_count": u.weighNumRange(1, 280),
|
|
@@ -425,6 +730,7 @@ const config = {
|
|
|
425
730
|
{
|
|
426
731
|
event: "post viewed",
|
|
427
732
|
weight: 30,
|
|
733
|
+
isStrictEvent: false,
|
|
428
734
|
properties: {
|
|
429
735
|
"post_type": ["text", "image", "video", "poll", "link"],
|
|
430
736
|
"view_duration_sec": u.weighNumRange(1, 120, 0.3, 5),
|
|
@@ -434,6 +740,7 @@ const config = {
|
|
|
434
740
|
{
|
|
435
741
|
event: "post liked",
|
|
436
742
|
weight: 18,
|
|
743
|
+
isStrictEvent: false,
|
|
437
744
|
properties: {
|
|
438
745
|
"post_type": ["text", "image", "video", "poll", "link"],
|
|
439
746
|
}
|
|
@@ -441,6 +748,7 @@ const config = {
|
|
|
441
748
|
{
|
|
442
749
|
event: "post shared",
|
|
443
750
|
weight: 6,
|
|
751
|
+
isStrictEvent: false,
|
|
444
752
|
properties: {
|
|
445
753
|
"share_destination": ["repost", "dm", "external", "copy_link"],
|
|
446
754
|
}
|
|
@@ -448,6 +756,7 @@ const config = {
|
|
|
448
756
|
{
|
|
449
757
|
event: "comment posted",
|
|
450
758
|
weight: 10,
|
|
759
|
+
isStrictEvent: false,
|
|
451
760
|
properties: {
|
|
452
761
|
"comment_length": u.weighNumRange(1, 500, 0.3, 20),
|
|
453
762
|
"has_mention": [true, false, false],
|
|
@@ -456,6 +765,7 @@ const config = {
|
|
|
456
765
|
{
|
|
457
766
|
event: "user followed",
|
|
458
767
|
weight: 8,
|
|
768
|
+
isStrictEvent: false,
|
|
459
769
|
properties: {
|
|
460
770
|
"discovery_source": ["suggested", "search", "post", "profile", "mutual"],
|
|
461
771
|
}
|
|
@@ -470,6 +780,7 @@ const config = {
|
|
|
470
780
|
{
|
|
471
781
|
event: "story viewed",
|
|
472
782
|
weight: 15,
|
|
783
|
+
isStrictEvent: false,
|
|
473
784
|
properties: {
|
|
474
785
|
"story_type": ["photo", "video", "text"],
|
|
475
786
|
"view_duration_sec": u.weighNumRange(1, 30, 0.5, 5),
|
|
@@ -479,6 +790,7 @@ const config = {
|
|
|
479
790
|
{
|
|
480
791
|
event: "story created",
|
|
481
792
|
weight: 5,
|
|
793
|
+
isStrictEvent: false,
|
|
482
794
|
properties: {
|
|
483
795
|
"story_type": ["photo", "video", "text"],
|
|
484
796
|
"has_filter": [true, false],
|
|
@@ -504,6 +816,7 @@ const config = {
|
|
|
504
816
|
{
|
|
505
817
|
event: "dm sent",
|
|
506
818
|
weight: 8,
|
|
819
|
+
isStrictEvent: false,
|
|
507
820
|
properties: {
|
|
508
821
|
"message_type": ["text", "image", "voice", "link"],
|
|
509
822
|
"conversation_length": u.weighNumRange(1, 100),
|
|
@@ -581,261 +894,9 @@ const config = {
|
|
|
581
894
|
|
|
582
895
|
lookupTables: [],
|
|
583
896
|
|
|
584
|
-
hook
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
// faster (factor 0.71); personal accounts 1.25x slower (factor 1.25).
|
|
588
|
-
if (type === "funnel-post") {
|
|
589
|
-
const segment = meta?.profile?.account_type;
|
|
590
|
-
if (Array.isArray(record) && record.length > 1) {
|
|
591
|
-
const factor = (
|
|
592
|
-
segment === "creator" || segment === "business" ? 0.71 :
|
|
593
|
-
segment === "personal" ? 1.25 :
|
|
594
|
-
1.0
|
|
595
|
-
);
|
|
596
|
-
if (factor !== 1.0) {
|
|
597
|
-
for (let i = 1; i < record.length; i++) {
|
|
598
|
-
const prev = dayjs(record[i - 1].time);
|
|
599
|
-
const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
|
|
600
|
-
record[i].time = prev.add(newGap, "milliseconds").toISOString();
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
// ─── EVERYTHING-LEVEL HOOKS ──────────────────────────────────────
|
|
608
|
-
|
|
609
|
-
if (type === "everything") {
|
|
610
|
-
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
611
|
-
const userEvents = record;
|
|
612
|
-
if (!userEvents || userEvents.length === 0) return record;
|
|
613
|
-
|
|
614
|
-
// Stamp superProps from profile for consistency
|
|
615
|
-
const profile = meta.profile;
|
|
616
|
-
userEvents.forEach(e => {
|
|
617
|
-
e.app_version = profile.app_version;
|
|
618
|
-
e.account_type = profile.account_type;
|
|
619
|
-
});
|
|
620
|
-
|
|
621
|
-
// First pass: identify behavioral patterns (no flags written)
|
|
622
|
-
let postCreatedCount = 0;
|
|
623
|
-
let followReceivedCount = 0;
|
|
624
|
-
let reportSubmittedCount = 0;
|
|
625
|
-
let hasCreatorSubscription = false;
|
|
626
|
-
let isViralCreator = false;
|
|
627
|
-
|
|
628
|
-
userEvents.forEach((event) => {
|
|
629
|
-
if (event.event === "post created") postCreatedCount++;
|
|
630
|
-
if (event.event === "user followed") followReceivedCount++;
|
|
631
|
-
if (event.event === "report submitted") reportSubmittedCount++;
|
|
632
|
-
if (event.event === "creator subscription started") hasCreatorSubscription = true;
|
|
633
|
-
});
|
|
634
|
-
|
|
635
|
-
if (postCreatedCount >= 10 && chance.bool({ likelihood: 5 })) {
|
|
636
|
-
isViralCreator = true;
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
// Second pass: inject cloned events (no behavioral cohort flags)
|
|
640
|
-
for (let idx = userEvents.length - 1; idx >= 0; idx--) {
|
|
641
|
-
const event = userEvents[idx];
|
|
642
|
-
const eventTime = dayjs(event.time);
|
|
643
|
-
|
|
644
|
-
// Hook #1: VIRAL CONTENT CASCADE — clone 10-20 view/like/share per post.
|
|
645
|
-
// Discovery: bin users by post-created count, observe per-user view/like/share volume.
|
|
646
|
-
if (isViralCreator && event.event === "post created") {
|
|
647
|
-
const viralViews = chance.integer({ min: 60, max: 120 });
|
|
648
|
-
const viralLikes = chance.integer({ min: 60, max: 120 });
|
|
649
|
-
const viralShares = chance.integer({ min: 60, max: 120 });
|
|
650
|
-
const injected = [];
|
|
651
|
-
|
|
652
|
-
const viewTemplate = userEvents.find(e => e.event === "post viewed");
|
|
653
|
-
const likeTemplate = userEvents.find(e => e.event === "post liked");
|
|
654
|
-
const shareTemplate = userEvents.find(e => e.event === "post shared");
|
|
655
|
-
|
|
656
|
-
for (let i = 0; i < viralViews; i++) {
|
|
657
|
-
injected.push({
|
|
658
|
-
...(viewTemplate || event),
|
|
659
|
-
event: "post viewed",
|
|
660
|
-
time: eventTime.add(chance.integer({ min: 1, max: 180 }), 'minutes').toISOString(),
|
|
661
|
-
user_id: event.user_id,
|
|
662
|
-
post_type: event.post_type || "text",
|
|
663
|
-
source: chance.pickone(["feed", "explore", "search"]),
|
|
664
|
-
view_duration_sec: chance.integer({ min: 5, max: 90 }),
|
|
665
|
-
});
|
|
666
|
-
}
|
|
667
|
-
for (let i = 0; i < viralLikes; i++) {
|
|
668
|
-
injected.push({
|
|
669
|
-
...(likeTemplate || event),
|
|
670
|
-
event: "post liked",
|
|
671
|
-
time: eventTime.add(chance.integer({ min: 2, max: 240 }), 'minutes').toISOString(),
|
|
672
|
-
user_id: event.user_id,
|
|
673
|
-
post_type: event.post_type || "text",
|
|
674
|
-
});
|
|
675
|
-
}
|
|
676
|
-
for (let i = 0; i < viralShares; i++) {
|
|
677
|
-
injected.push({
|
|
678
|
-
...(shareTemplate || event),
|
|
679
|
-
event: "post shared",
|
|
680
|
-
time: eventTime.add(chance.integer({ min: 5, max: 300 }), 'minutes').toISOString(),
|
|
681
|
-
user_id: event.user_id,
|
|
682
|
-
share_destination: chance.pickone(["repost", "dm", "external", "copy_link"]),
|
|
683
|
-
});
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
userEvents.splice(idx + 1, 0, ...injected);
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
// Hook #2: FOLLOW-BACK SNOWBALL — extra post + comment per user-followed cluster.
|
|
690
|
-
// Discovery: cohort users with >=5 user-followed events, compare posts/user.
|
|
691
|
-
if (followReceivedCount >= 5 && event.event === "post created") {
|
|
692
|
-
if (chance.bool({ likelihood: 50 })) {
|
|
693
|
-
const commentTemplate = userEvents.find(e => e.event === "comment posted");
|
|
694
|
-
const duplicatePost = {
|
|
695
|
-
...event,
|
|
696
|
-
time: eventTime.add(chance.integer({ min: 30, max: 240 }), 'minutes').toISOString(),
|
|
697
|
-
user_id: event.user_id,
|
|
698
|
-
post_type: chance.pickone(["text", "image", "video"]),
|
|
699
|
-
character_count: chance.integer({ min: 10, max: 280 }),
|
|
700
|
-
has_media: chance.bool({ likelihood: 60 }),
|
|
701
|
-
hashtag_count: chance.integer({ min: 0, max: 5 }),
|
|
702
|
-
};
|
|
703
|
-
const extraComment = {
|
|
704
|
-
...(commentTemplate || event),
|
|
705
|
-
event: "comment posted",
|
|
706
|
-
time: eventTime.add(chance.integer({ min: 10, max: 120 }), 'minutes').toISOString(),
|
|
707
|
-
user_id: event.user_id,
|
|
708
|
-
comment_length: chance.integer({ min: 5, max: 200 }),
|
|
709
|
-
has_mention: chance.bool({ likelihood: 40 }),
|
|
710
|
-
};
|
|
711
|
-
userEvents.splice(idx + 1, 0, duplicatePost, extraComment);
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
// Hook #6: CREATOR MONETIZATION — 3x post/story rate for subscribers.
|
|
716
|
-
// Discovery: cohort users with creator-subscription-started event, compare posts/user.
|
|
717
|
-
if (hasCreatorSubscription && event.event === "post created") {
|
|
718
|
-
for (let i = 0; i < 2; i++) {
|
|
719
|
-
const extraPost = {
|
|
720
|
-
...event,
|
|
721
|
-
time: eventTime.add(chance.integer({ min: 1, max: 12 }), 'hours').toISOString(),
|
|
722
|
-
user_id: event.user_id,
|
|
723
|
-
post_type: chance.pickone(["text", "image", "video", "link"]),
|
|
724
|
-
character_count: chance.integer({ min: 20, max: 280 }),
|
|
725
|
-
has_media: chance.bool({ likelihood: 70 }),
|
|
726
|
-
hashtag_count: chance.integer({ min: 1, max: 8 }),
|
|
727
|
-
};
|
|
728
|
-
userEvents.splice(idx + 1, 0, extraPost);
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
if (hasCreatorSubscription && event.event === "story created") {
|
|
732
|
-
for (let i = 0; i < 2; i++) {
|
|
733
|
-
const extraStory = {
|
|
734
|
-
...event,
|
|
735
|
-
time: eventTime.add(chance.integer({ min: 1, max: 8 }), 'hours').toISOString(),
|
|
736
|
-
user_id: event.user_id,
|
|
737
|
-
story_type: chance.pickone(["photo", "video", "text"]),
|
|
738
|
-
has_filter: chance.bool({ likelihood: 60 }),
|
|
739
|
-
has_sticker: chance.bool({ likelihood: 40 }),
|
|
740
|
-
};
|
|
741
|
-
userEvents.splice(idx + 1, 0, extraStory);
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
if (hasCreatorSubscription && event.event === "post viewed") {
|
|
745
|
-
if (chance.bool({ likelihood: 25 })) {
|
|
746
|
-
const analyticsView = {
|
|
747
|
-
...event,
|
|
748
|
-
time: eventTime.add(chance.integer({ min: 1, max: 30 }), 'minutes').toISOString(),
|
|
749
|
-
user_id: event.user_id,
|
|
750
|
-
post_type: event.post_type || "text",
|
|
751
|
-
source: "profile",
|
|
752
|
-
view_duration_sec: chance.integer({ min: 10, max: 60 }),
|
|
753
|
-
};
|
|
754
|
-
userEvents.splice(idx + 1, 0, analyticsView);
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
// Hook #8: WEEKEND CONTENT SURGE — duplicate weekend posts/stories with offset.
|
|
760
|
-
// Discovery: line chart by day-of-week shows Sat/Sun bump.
|
|
761
|
-
for (let idx = userEvents.length - 1; idx >= 0; idx--) {
|
|
762
|
-
const event = userEvents[idx];
|
|
763
|
-
if (event.event === "post created" || event.event === "story created") {
|
|
764
|
-
const dow = new Date(event.time).getUTCDay();
|
|
765
|
-
if ((dow === 0 || dow === 6) && chance.bool({ likelihood: 30 })) {
|
|
766
|
-
const etime = dayjs(event.time);
|
|
767
|
-
const dup = {
|
|
768
|
-
...event,
|
|
769
|
-
time: etime.add(chance.integer({ min: 1, max: 3 }), 'hours').toISOString(),
|
|
770
|
-
};
|
|
771
|
-
userEvents.splice(idx + 1, 0, dup);
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
// Hook #3: ALGORITHM CHANGE — day 45 flips feed → explore on post viewed.
|
|
777
|
-
// Hook #4: ENGAGEMENT BAIT — 20% of post-viewed events get crushed duration.
|
|
778
|
-
// Hook #5: NOTIFICATION RE-ENGAGEMENT — after day 30, 30% of views → notification.
|
|
779
|
-
// All three run AFTER injection passes so they apply to cloned events too.
|
|
780
|
-
const ALGORITHM_CHANGE_DAY = datasetStart.add(45, 'days');
|
|
781
|
-
const REENGAGEMENT_START = datasetStart.add(30, 'days');
|
|
782
|
-
userEvents.forEach(e => {
|
|
783
|
-
if (e.event === "post viewed") {
|
|
784
|
-
const eventTime = dayjs(e.time);
|
|
785
|
-
|
|
786
|
-
// Hook #3: Algorithm Change
|
|
787
|
-
if (eventTime.isAfter(ALGORITHM_CHANGE_DAY)) {
|
|
788
|
-
if (chance.bool({ likelihood: 70 })) {
|
|
789
|
-
e.source = "explore";
|
|
790
|
-
}
|
|
791
|
-
} else {
|
|
792
|
-
if (chance.bool({ likelihood: 70 })) {
|
|
793
|
-
e.source = "feed";
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
// Hook #4: Engagement Bait — 20% crushed view duration
|
|
798
|
-
if (chance.bool({ likelihood: 20 })) {
|
|
799
|
-
e.view_duration_sec = chance.integer({ min: 1, max: 5 });
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
// Hook #5: Notification Re-engagement (runs after #3 so can override)
|
|
803
|
-
if (eventTime.isAfter(REENGAGEMENT_START) && chance.bool({ likelihood: 30 })) {
|
|
804
|
-
e.source = "notification";
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
});
|
|
808
|
-
|
|
809
|
-
// Hook #7: TOXICITY CHURN — drop 60% of activity after day 30 for high reporters.
|
|
810
|
-
// Discovery: cohort users with >=2 report-submitted events, observe retention drop.
|
|
811
|
-
if (reportSubmittedCount >= 2) {
|
|
812
|
-
const churnCutoff = datasetStart.add(30, 'days');
|
|
813
|
-
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
814
|
-
const evt = userEvents[i];
|
|
815
|
-
if (dayjs(evt.time).isAfter(churnCutoff) && chance.bool({ likelihood: 60 })) {
|
|
816
|
-
userEvents.splice(i, 1);
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
// Hook #9: POST-CREATED MAGIC NUMBER (no flags)
|
|
822
|
-
// Sweet 3-7 posts → +40% on comment_length on the user's comment events.
|
|
823
|
-
// Over 8+ → drop 30% of like/comment events (engagement burnout).
|
|
824
|
-
if (postCreatedCount >= 3 && postCreatedCount <= 7) {
|
|
825
|
-
userEvents.forEach(e => {
|
|
826
|
-
if (e.event === 'comment posted' && typeof e.comment_length === 'number') {
|
|
827
|
-
e.comment_length = Math.round(e.comment_length * 1.4);
|
|
828
|
-
}
|
|
829
|
-
});
|
|
830
|
-
} else if (postCreatedCount >= 8) {
|
|
831
|
-
userEvents.forEach(e => {
|
|
832
|
-
if (e.event === 'comment posted' && typeof e.comment_length === 'number') {
|
|
833
|
-
e.comment_length = Math.round(e.comment_length * 0.7);
|
|
834
|
-
}
|
|
835
|
-
});
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
|
-
|
|
897
|
+
hook(record, type, meta) {
|
|
898
|
+
if (type === "funnel-post") return handleFunnelPostHooks(record, meta);
|
|
899
|
+
if (type === "everything") return handleEverythingHooks(record, meta);
|
|
839
900
|
return record;
|
|
840
901
|
}
|
|
841
902
|
};
|