@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,57 +1,51 @@
|
|
|
1
|
-
// ──
|
|
2
|
-
const SEED = "harness-media";
|
|
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
8
|
import { findFirstSequence, scaleFunnelTTC } from "../../lib/hook-helpers/timing.js";
|
|
17
|
-
|
|
18
|
-
dayjs.extend(utc);
|
|
19
|
-
const chance = u.initChance(SEED);
|
|
20
|
-
|
|
21
9
|
/** @typedef {import("../../types").Dungeon} Config */
|
|
22
10
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
11
|
+
// ── OVERVIEW ──
|
|
12
|
+
/*
|
|
13
|
+
* NAME: StreamVault
|
|
14
|
+
* APP: Netflix/Hulu-style video streaming platform. Users browse a
|
|
15
|
+
* catalog of movies, series, documentaries and specials, manage
|
|
16
|
+
* watchlists, watch with configurable quality/subtitle/speed,
|
|
17
|
+
* rate and share content, and switch household profiles (main,
|
|
18
|
+
* kids, partner, guest) under a single account.
|
|
19
|
+
* SCALE: 10,000 users, ~600K events, 121 days (2026-01-01 → 2026-05-01)
|
|
20
|
+
* CORE LOOP: account created → content browsed → content selected → playback started → playback completed
|
|
21
|
+
*
|
|
22
|
+
* EVENTS (17):
|
|
23
|
+
* content browsed (20) > playback started (18) > content selected (15)
|
|
24
|
+
* > playback completed (12) > playback paused (10) > recommendation clicked (9)
|
|
25
|
+
* > watchlist added (8) > ad impression (8) > search performed (7)
|
|
26
|
+
* > content rated (6) > download started (5) > profile switched (4)
|
|
27
|
+
* > subtitle toggled (4) > watchlist removed (3) > share content (3)
|
|
28
|
+
* > subscription changed (2) > account created (1)
|
|
29
|
+
*
|
|
30
|
+
* FUNNELS (9):
|
|
31
|
+
* - Account to Playback: account created → content browsed → playback started (80%)
|
|
32
|
+
* - Core Viewing Loop: content browsed → content selected → playback started → playback completed (55%)
|
|
33
|
+
* - Recommendation Driven: recommendation clicked → playback started → playback completed → content rated (35%)
|
|
34
|
+
* - Search Discovery: search performed → content selected → playback started (50%)
|
|
35
|
+
* - Watchlist Management: content browsed → watchlist added → content selected → playback started (40%)
|
|
36
|
+
* - Profile + Subtitle: profile switched → subtitle toggled → playback started → playback completed (45%)
|
|
37
|
+
* - Ad Experience: ad impression → playback started → playback paused (60%)
|
|
38
|
+
* - Share + Download: playback completed → share content → download started (25%)
|
|
39
|
+
* - Subscription Change: content browsed → subscription changed (15%)
|
|
40
|
+
*
|
|
41
|
+
* USER PROPS: preferred_genre, avg_session_duration_min, total_watch_hours, profiles_count, downloads_enabled, subscription_plan, device_type
|
|
42
|
+
* SUPER PROPS: subscription_plan, device_type
|
|
43
|
+
* SCD PROPS: subscription_plan (free/basic/standard/premium, monthly fuzzy, max 6)
|
|
44
|
+
* GROUPS: none
|
|
44
45
|
*/
|
|
45
46
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
* ANALYTICS HOOKS (10 hooks)
|
|
49
|
-
*
|
|
50
|
-
* Adds 10. CORE VIEWING LOOP: premium 0.67x watch_duration_min, free 1.4x
|
|
51
|
-
* (property scaling in everything hook). Discover via Insights → playback
|
|
52
|
-
* completed → Avg watch_duration_min → breakdown subscription_plan.
|
|
53
|
-
* ═══════════════════════════════════════════════════════════════════════════════
|
|
54
|
-
*
|
|
47
|
+
// ── HOOK STORIES ──
|
|
48
|
+
/*
|
|
55
49
|
* NOTE: All cohort effects are HIDDEN — discoverable only via behavioral cohorts
|
|
56
50
|
* (count an event per user, then measure downstream). No cohort flag is stamped
|
|
57
51
|
* on events. Time-window patterns mutate config-defined props or drop events.
|
|
@@ -294,35 +288,355 @@ const chance = u.initChance(SEED);
|
|
|
294
288
|
* Core Viewing Loop | free/premium duration | 1x | >= 2x | 2.09x
|
|
295
289
|
*/
|
|
296
290
|
|
|
297
|
-
//
|
|
291
|
+
// ── SCALE ──
|
|
292
|
+
const SEED = "harness-media";
|
|
293
|
+
const NUM_USERS = 10_000;
|
|
294
|
+
const DATASET_START = "2026-01-01T00:00:00Z";
|
|
295
|
+
const DATASET_END = "2026-05-01T23:59:59Z";
|
|
296
|
+
const EVENTS_PER_DAY = 1.2;
|
|
297
|
+
const token = process.env.MP_TOKEN || "your-mixpanel-token";
|
|
298
|
+
|
|
299
|
+
const chance = u.initChance(SEED);
|
|
300
|
+
|
|
301
|
+
// ── KNOBS (tweak these to reshape stories) ──
|
|
302
|
+
const KIDS_RESTRICT_LIKELIHOOD = 15;
|
|
303
|
+
|
|
304
|
+
const PLAN_TTC_PREMIUM = 0.67;
|
|
305
|
+
const PLAN_TTC_FREE = 1.4;
|
|
306
|
+
|
|
307
|
+
const BLOCKBUSTER_START_DAY = 50;
|
|
308
|
+
const BLOCKBUSTER_END_DAY = 65;
|
|
309
|
+
const BLOCKBUSTER_SWAP_LIKELIHOOD = 20;
|
|
310
|
+
const BLOCKBUSTER_RATING_LIKELIHOOD = 20;
|
|
311
|
+
|
|
312
|
+
const WEEKEND_WATCH_MULT = 1.5;
|
|
313
|
+
|
|
314
|
+
const DOC_DROP_LIKELIHOOD = 25;
|
|
315
|
+
|
|
316
|
+
const REC_IMPROVEMENT_DAY = 60;
|
|
317
|
+
const REC_IMPROVEMENT_DROP_LIKELIHOOD = 30;
|
|
318
|
+
|
|
319
|
+
const BINGE_STREAK_THRESHOLD = 3;
|
|
320
|
+
const BINGE_PAUSE_DROP_LIKELIHOOD = 60;
|
|
321
|
+
const BINGE_CLONE_LIKELIHOOD = 40;
|
|
322
|
+
|
|
323
|
+
const SUBTITLE_COMPLETION_MULT = 1.25;
|
|
324
|
+
const SUBTITLE_DURATION_MULT = 1.15;
|
|
325
|
+
const SUBTITLE_CLONE_FACTOR = 0.2;
|
|
326
|
+
|
|
327
|
+
const REC_SWEET_MIN = 4;
|
|
328
|
+
const REC_SWEET_MAX = 6;
|
|
329
|
+
const REC_OVER_THRESHOLD = 7;
|
|
330
|
+
const REC_SWEET_DURATION_MULT = 1.25;
|
|
331
|
+
const REC_OVER_DURATION_MULT = 0.5;
|
|
332
|
+
const REC_OVER_DROP_LIKELIHOOD = 55;
|
|
333
|
+
|
|
334
|
+
const AD_FATIGUE_THRESHOLD = 5;
|
|
335
|
+
const AD_FATIGUE_CUTOFF_DAYS = 45;
|
|
336
|
+
const AD_FATIGUE_KEEP_MODULO = 20;
|
|
337
|
+
|
|
338
|
+
// ── DATA ARRAYS ──
|
|
298
339
|
const contentIds = v.range(1, 501).map(n => `content_${v.uid(8)}`);
|
|
299
340
|
const blockbusterId = `blockbuster_${v.uid(8)}`;
|
|
300
341
|
|
|
342
|
+
// ── HELPER FUNCTIONS ──
|
|
343
|
+
function handleEventHooks(record) {
|
|
344
|
+
// H6: KIDS PROFILE SAFETY — 15% of selections/starts get genre restricted
|
|
345
|
+
// to animation or documentary. Mutates existing genre prop.
|
|
346
|
+
if (chance.bool({ likelihood: KIDS_RESTRICT_LIKELIHOOD })) {
|
|
347
|
+
if (record.event === "content selected" || record.event === "playback started") {
|
|
348
|
+
record.genre = chance.pickone(["animation", "documentary"]);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return record;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function handleEverythingHooks(record, meta) {
|
|
355
|
+
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
356
|
+
const userEvents = record;
|
|
357
|
+
if (!userEvents || userEvents.length === 0) return record;
|
|
358
|
+
|
|
359
|
+
const profile = meta.profile;
|
|
360
|
+
|
|
361
|
+
// Stamp superProps from profile (consistent per user)
|
|
362
|
+
const stampPlan = profile && profile.subscription_plan ? profile.subscription_plan : undefined;
|
|
363
|
+
const stampDevice = profile && profile.device_type ? profile.device_type : undefined;
|
|
364
|
+
if (stampPlan || stampDevice) {
|
|
365
|
+
userEvents.forEach(e => {
|
|
366
|
+
if (stampPlan) e.subscription_plan = stampPlan;
|
|
367
|
+
if (stampDevice) e.device_type = stampDevice;
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// HOOK 10: CORE VIEWING LOOP TTC — premium 0.67x, free 1.4x.
|
|
372
|
+
// Timestamp shift for Mixpanel funnel TTC + property scale for
|
|
373
|
+
// Insights. Applied first so weekend/subtitle/rec-click hooks
|
|
374
|
+
// amplify from the plan-adjusted base.
|
|
375
|
+
{
|
|
376
|
+
const plan = profile ? profile.subscription_plan : "free";
|
|
377
|
+
const ttcFactor = plan === "premium" ? PLAN_TTC_PREMIUM : plan === "free" ? PLAN_TTC_FREE : 1.0;
|
|
378
|
+
if (ttcFactor !== 1.0) {
|
|
379
|
+
// Timestamp shift: affects Mixpanel funnel TTC
|
|
380
|
+
const viewSeq = findFirstSequence(
|
|
381
|
+
userEvents,
|
|
382
|
+
["content browsed", "content selected", "playback started", "playback completed"],
|
|
383
|
+
60 * 24 * 7
|
|
384
|
+
);
|
|
385
|
+
if (viewSeq) scaleFunnelTTC(viewSeq, ttcFactor);
|
|
386
|
+
// Property scale: affects Insights AVG reports
|
|
387
|
+
for (const e of userEvents) {
|
|
388
|
+
if (e.event === "playback completed" && typeof e.watch_duration_min === "number") {
|
|
389
|
+
e.watch_duration_min = Math.round(e.watch_duration_min * ttcFactor * 10) / 10;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Hook #5: NEW RELEASE SPIKE — days 50-65, 20% of selections/starts
|
|
396
|
+
// switch to the blockbuster id. Mutates existing content_id/content_type props.
|
|
397
|
+
const BLOCKBUSTER_START = datasetStart.add(BLOCKBUSTER_START_DAY, "days");
|
|
398
|
+
const BLOCKBUSTER_END = datasetStart.add(BLOCKBUSTER_END_DAY, "days");
|
|
399
|
+
for (const e of userEvents) {
|
|
400
|
+
const eventTime = dayjs(e.time);
|
|
401
|
+
if (eventTime.isAfter(BLOCKBUSTER_START) && eventTime.isBefore(BLOCKBUSTER_END)) {
|
|
402
|
+
if ((e.event === "content selected" || e.event === "playback started") && chance.bool({ likelihood: BLOCKBUSTER_SWAP_LIKELIHOOD })) {
|
|
403
|
+
e.content_type = "movie";
|
|
404
|
+
e.content_id = blockbusterId;
|
|
405
|
+
}
|
|
406
|
+
if (e.event === "content rated" && chance.bool({ likelihood: BLOCKBUSTER_RATING_LIKELIHOOD })) {
|
|
407
|
+
e.rating = chance.integer({ min: 4, max: 5 });
|
|
408
|
+
e.content_id = blockbusterId;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Hook #10: CORE VIEWING LOOP — subscription_plan property scaling.
|
|
414
|
+
// Premium users watch more efficiently (shorter durations), free users
|
|
415
|
+
// linger (longer durations). Scales watch_duration_min on playback
|
|
416
|
+
// completed events BEFORE H3/H8/H9 so each subsequent hook amplifies
|
|
417
|
+
// from the plan-adjusted base.
|
|
418
|
+
// Discover via: Insights → playback completed → Avg watch_duration_min → breakdown subscription_plan.
|
|
419
|
+
{
|
|
420
|
+
const plan = stampPlan;
|
|
421
|
+
const factor = (
|
|
422
|
+
plan === "premium" ? PLAN_TTC_PREMIUM :
|
|
423
|
+
plan === "free" ? PLAN_TTC_FREE :
|
|
424
|
+
1.0
|
|
425
|
+
);
|
|
426
|
+
if (factor !== 1.0) {
|
|
427
|
+
for (const e of userEvents) {
|
|
428
|
+
if (e.event === "playback completed" && typeof e.watch_duration_min === "number") {
|
|
429
|
+
e.watch_duration_min = Math.round(e.watch_duration_min * factor);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Hook #3: WEEKEND VS WEEKDAY — 1.5x watch_duration_min on weekends.
|
|
436
|
+
// No flag — analyst breaks down by day of week.
|
|
437
|
+
for (const e of userEvents) {
|
|
438
|
+
const dow = new Date(e.time).getUTCDay();
|
|
439
|
+
if ((dow === 0 || dow === 6) && e.event === "playback completed" && typeof e.watch_duration_min === "number") {
|
|
440
|
+
e.watch_duration_min = Math.round(e.watch_duration_min * WEEKEND_WATCH_MULT);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Hook #1: GENRE FUNNEL CONVERSION — drop 25% of documentary playback
|
|
445
|
+
// completed events to depress documentary funnel conversion. Raw genre check.
|
|
446
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
447
|
+
const evt = userEvents[i];
|
|
448
|
+
if (evt.event === "playback completed" && evt.genre === "documentary" && chance.bool({ likelihood: DOC_DROP_LIKELIHOOD })) {
|
|
449
|
+
userEvents.splice(i, 1);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Hook #7: RECOMMENDATION ENGINE IMPROVEMENT — drop 30% of pre-day-60
|
|
454
|
+
// content rated events. Raw time check.
|
|
455
|
+
const IMPROVEMENT_DATE = datasetStart.add(REC_IMPROVEMENT_DAY, "days");
|
|
456
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
457
|
+
const evt = userEvents[i];
|
|
458
|
+
if (evt.event === "content rated" && dayjs(evt.time).isBefore(IMPROVEMENT_DATE) && chance.bool({ likelihood: REC_IMPROVEMENT_DROP_LIKELIHOOD })) {
|
|
459
|
+
userEvents.splice(i, 1);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const firstEventTime = dayjs(userEvents[0].time);
|
|
464
|
+
|
|
465
|
+
// Identify behavioral patterns (no flags written)
|
|
466
|
+
let consecutiveCompletions = 0;
|
|
467
|
+
let maxConsecutiveCompletions = 0;
|
|
468
|
+
let earlyAdCount = 0;
|
|
469
|
+
let hasSubtitlesEnabled = false;
|
|
470
|
+
let recClickCount = 0;
|
|
471
|
+
|
|
472
|
+
const adCutoff = firstEventTime.add(AD_FATIGUE_CUTOFF_DAYS, "days");
|
|
473
|
+
userEvents.forEach((event, idx) => {
|
|
474
|
+
if (event.event === "playback completed") {
|
|
475
|
+
consecutiveCompletions++;
|
|
476
|
+
if (consecutiveCompletions > maxConsecutiveCompletions) {
|
|
477
|
+
maxConsecutiveCompletions = consecutiveCompletions;
|
|
478
|
+
}
|
|
479
|
+
} else if (event.event !== "playback started") {
|
|
480
|
+
consecutiveCompletions = 0;
|
|
481
|
+
}
|
|
482
|
+
if (event.event === "ad impression" && dayjs(event.time).isBefore(adCutoff)) earlyAdCount++;
|
|
483
|
+
if (event.event === "subtitle toggled" && event.action === "enabled") hasSubtitlesEnabled = true;
|
|
484
|
+
if (event.event === "recommendation clicked") recClickCount++;
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
const isBingeWatcher = maxConsecutiveCompletions >= BINGE_STREAK_THRESHOLD;
|
|
488
|
+
|
|
489
|
+
// Hook #2: BINGE-WATCHING — drop pauses, inject extra start+complete pairs.
|
|
490
|
+
// Cloned events use unique offset timestamps. No flag.
|
|
491
|
+
if (isBingeWatcher) {
|
|
492
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
493
|
+
const event = userEvents[i];
|
|
494
|
+
const eventTime = dayjs(event.time);
|
|
495
|
+
|
|
496
|
+
if (event.event === "playback paused" && chance.bool({ likelihood: BINGE_PAUSE_DROP_LIKELIHOOD })) {
|
|
497
|
+
userEvents.splice(i, 1);
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (event.event === "playback completed" && chance.bool({ likelihood: BINGE_CLONE_LIKELIHOOD })) {
|
|
502
|
+
const nextContentId = chance.pickone(contentIds);
|
|
503
|
+
const startTemplate = userEvents.find(e => e.event === "playback started");
|
|
504
|
+
const extraStart = {
|
|
505
|
+
...(startTemplate || event),
|
|
506
|
+
event: "playback started",
|
|
507
|
+
time: eventTime.add(chance.integer({ min: 1, max: 5 }), "minutes").toISOString(),
|
|
508
|
+
user_id: event.user_id,
|
|
509
|
+
content_id: nextContentId,
|
|
510
|
+
content_type: "series",
|
|
511
|
+
playback_quality: event.playback_quality || "1080p",
|
|
512
|
+
};
|
|
513
|
+
const extraComplete = {
|
|
514
|
+
...event,
|
|
515
|
+
time: eventTime.add(chance.integer({ min: 25, max: 60 }), "minutes").toISOString(),
|
|
516
|
+
user_id: event.user_id,
|
|
517
|
+
content_id: nextContentId,
|
|
518
|
+
content_type: "series",
|
|
519
|
+
watch_duration_min: chance.integer({ min: 20, max: 55 }),
|
|
520
|
+
completion_percent: chance.integer({ min: 90, max: 100 }),
|
|
521
|
+
};
|
|
522
|
+
userEvents.splice(i + 1, 0, extraStart, extraComplete);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Hook #8: SUBTITLE USERS WATCH MORE — 1.25x completion_percent (cap 100),
|
|
528
|
+
// 1.15x watch_duration_min, plus 20% extra cloned playback completions.
|
|
529
|
+
// No flag — discover via cohort builder on subtitle-toggled-enabled.
|
|
530
|
+
if (hasSubtitlesEnabled) {
|
|
531
|
+
for (let i = 0; i < userEvents.length; i++) {
|
|
532
|
+
const event = userEvents[i];
|
|
533
|
+
if (event.event === "playback completed") {
|
|
534
|
+
if (event.completion_percent) {
|
|
535
|
+
event.completion_percent = Math.min(100, Math.round(event.completion_percent * SUBTITLE_COMPLETION_MULT));
|
|
536
|
+
}
|
|
537
|
+
if (event.watch_duration_min) {
|
|
538
|
+
event.watch_duration_min = Math.round(event.watch_duration_min * SUBTITLE_DURATION_MULT);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const completionEvents = userEvents.filter(e => e.event === "playback completed");
|
|
544
|
+
const extraCount = Math.floor(completionEvents.length * SUBTITLE_CLONE_FACTOR);
|
|
545
|
+
for (let j = 0; j < extraCount; j++) {
|
|
546
|
+
const templateEvent = chance.pickone(completionEvents);
|
|
547
|
+
const templateTime = dayjs(templateEvent.time);
|
|
548
|
+
const extraCompletion = {
|
|
549
|
+
...templateEvent,
|
|
550
|
+
time: templateTime.add(chance.integer({ min: 30, max: 180 }), "minutes").toISOString(),
|
|
551
|
+
user_id: templateEvent.user_id,
|
|
552
|
+
content_id: chance.pickone(contentIds),
|
|
553
|
+
content_type: chance.pickone(["movie", "series", "documentary"]),
|
|
554
|
+
watch_duration_min: chance.integer({ min: 25, max: 120 }),
|
|
555
|
+
completion_percent: chance.integer({ min: 80, max: 100 }),
|
|
556
|
+
};
|
|
557
|
+
userEvents.push(extraCompletion);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Hook #9: RECOMMENDATION-CLICKED MAGIC NUMBER (no flags)
|
|
562
|
+
// Sweet 4-6 rec clicks → +25% watch_duration_min on playback completed.
|
|
563
|
+
// Over 7+ → halve watch_duration_min AND drop 55% of playback completed
|
|
564
|
+
// events (rec fatigue). Aggressive suppression overcomes the inherent
|
|
565
|
+
// engagement confound (high-rec-click users are naturally more active).
|
|
566
|
+
if (recClickCount >= REC_SWEET_MIN && recClickCount <= REC_SWEET_MAX) {
|
|
567
|
+
userEvents.forEach(e => {
|
|
568
|
+
if (e.event === "playback completed" && typeof e.watch_duration_min === "number") {
|
|
569
|
+
e.watch_duration_min = Math.round(e.watch_duration_min * REC_SWEET_DURATION_MULT);
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
} else if (recClickCount >= REC_OVER_THRESHOLD) {
|
|
573
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
574
|
+
const evt = userEvents[i];
|
|
575
|
+
if (evt.event === "playback completed") {
|
|
576
|
+
// Halve watch duration for surviving events
|
|
577
|
+
if (typeof evt.watch_duration_min === "number") {
|
|
578
|
+
evt.watch_duration_min = Math.round(evt.watch_duration_min * REC_OVER_DURATION_MULT);
|
|
579
|
+
}
|
|
580
|
+
// Drop 55% of completions
|
|
581
|
+
if (chance.bool({ likelihood: REC_OVER_DROP_LIKELIHOOD })) {
|
|
582
|
+
userEvents.splice(i, 1);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Hook #4: AD FATIGUE CHURN — users w/ 5+ early ad impressions lose
|
|
589
|
+
// nearly all events after day 45. Runs LAST so event-adding hooks
|
|
590
|
+
// (binge-watching, subtitle) can't re-inflate the post-d45 count.
|
|
591
|
+
// Applies to ALL tiers (ad fatigue affects anyone exposed to heavy ads,
|
|
592
|
+
// regardless of plan). 95% drop overcomes the ~3x activity confound.
|
|
593
|
+
if (earlyAdCount >= AD_FATIGUE_THRESHOLD) {
|
|
594
|
+
const churnCutoff = firstEventTime.add(AD_FATIGUE_CUTOFF_DAYS, "days");
|
|
595
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
596
|
+
const evt = userEvents[i];
|
|
597
|
+
if (dayjs(evt.time).isAfter(churnCutoff)) {
|
|
598
|
+
// Keep only ~5% of post-d45 events (drop 95%)
|
|
599
|
+
const keep = (i % AD_FATIGUE_KEEP_MODULO) === 0;
|
|
600
|
+
if (!keep) {
|
|
601
|
+
userEvents.splice(i, 1);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return record;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// ── CONFIG ──
|
|
301
611
|
/** @type {Config} */
|
|
302
612
|
const config = {
|
|
303
613
|
version: 2,
|
|
304
|
-
token,
|
|
305
614
|
seed: SEED,
|
|
306
|
-
datasetStart:
|
|
307
|
-
datasetEnd:
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
numUsers: num_users,
|
|
311
|
-
hasAnonIds: true,
|
|
312
|
-
avgDevicePerUser: 2,
|
|
313
|
-
hasSessionIds: true,
|
|
615
|
+
datasetStart: DATASET_START,
|
|
616
|
+
datasetEnd: DATASET_END,
|
|
617
|
+
avgEventsPerUserPerDay: EVENTS_PER_DAY,
|
|
618
|
+
numUsers: NUM_USERS,
|
|
314
619
|
format: "json",
|
|
315
620
|
gzip: true,
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
621
|
+
credentials: {
|
|
622
|
+
token,
|
|
623
|
+
},
|
|
624
|
+
switches: {
|
|
625
|
+
hasSessionIds: true,
|
|
626
|
+
alsoInferFunnels: false,
|
|
627
|
+
hasLocation: true,
|
|
628
|
+
hasAndroidDevices: true,
|
|
629
|
+
hasIOSDevices: true,
|
|
630
|
+
hasDesktopDevices: true,
|
|
631
|
+
hasBrowser: false,
|
|
632
|
+
hasCampaigns: false,
|
|
633
|
+
isAnonymous: false,
|
|
634
|
+
hasAdSpend: false,
|
|
635
|
+
hasAvatar: true,
|
|
636
|
+
},
|
|
637
|
+
identity: {
|
|
638
|
+
avgDevicePerUser: 2,
|
|
639
|
+
},
|
|
326
640
|
concurrency: 1,
|
|
327
641
|
writeToDisk: false,
|
|
328
642
|
|
|
@@ -580,278 +894,11 @@ const config = {
|
|
|
580
894
|
|
|
581
895
|
lookupTables: [],
|
|
582
896
|
|
|
583
|
-
hook
|
|
584
|
-
if (type === "event")
|
|
585
|
-
|
|
586
|
-
// restricted to animation or documentary. Mutates existing genre prop.
|
|
587
|
-
if (chance.bool({ likelihood: 15 })) {
|
|
588
|
-
if (record.event === "content selected" || record.event === "playback started") {
|
|
589
|
-
record.genre = chance.pickone(["animation", "documentary"]);
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
return record;
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
if (type === "everything") {
|
|
597
|
-
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
598
|
-
const userEvents = record;
|
|
599
|
-
if (!userEvents || userEvents.length === 0) return record;
|
|
600
|
-
|
|
601
|
-
const profile = meta.profile;
|
|
602
|
-
|
|
603
|
-
// Stamp superProps from profile (consistent per user)
|
|
604
|
-
const stampPlan = profile && profile.subscription_plan ? profile.subscription_plan : undefined;
|
|
605
|
-
const stampDevice = profile && profile.device_type ? profile.device_type : undefined;
|
|
606
|
-
if (stampPlan || stampDevice) {
|
|
607
|
-
userEvents.forEach(e => {
|
|
608
|
-
if (stampPlan) e.subscription_plan = stampPlan;
|
|
609
|
-
if (stampDevice) e.device_type = stampDevice;
|
|
610
|
-
});
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
// HOOK 10: CORE VIEWING LOOP TTC — premium 0.67x, free 1.4x.
|
|
614
|
-
// Timestamp shift for Mixpanel funnel TTC + property scale for
|
|
615
|
-
// Insights. Applied first so weekend/subtitle/rec-click hooks
|
|
616
|
-
// amplify from the plan-adjusted base.
|
|
617
|
-
{
|
|
618
|
-
const plan = profile ? profile.subscription_plan : "free";
|
|
619
|
-
const ttcFactor = plan === "premium" ? 0.67 : plan === "free" ? 1.4 : 1.0;
|
|
620
|
-
if (ttcFactor !== 1.0) {
|
|
621
|
-
// Timestamp shift: affects Mixpanel funnel TTC
|
|
622
|
-
const viewSeq = findFirstSequence(
|
|
623
|
-
userEvents,
|
|
624
|
-
["content browsed", "content selected", "playback started", "playback completed"],
|
|
625
|
-
60 * 24 * 7
|
|
626
|
-
);
|
|
627
|
-
if (viewSeq) scaleFunnelTTC(viewSeq, ttcFactor);
|
|
628
|
-
// Property scale: affects Insights AVG reports
|
|
629
|
-
for (const e of userEvents) {
|
|
630
|
-
if (e.event === "playback completed" && typeof e.watch_duration_min === "number") {
|
|
631
|
-
e.watch_duration_min = Math.round(e.watch_duration_min * ttcFactor * 10) / 10;
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
// Hook #5: NEW RELEASE SPIKE — days 50-65, 20% of selections/starts
|
|
638
|
-
// switch to the blockbuster id. Mutates existing content_id/content_type props.
|
|
639
|
-
// (Moved from event hook to everything hook per L1: temporal checks belong here.)
|
|
640
|
-
const BLOCKBUSTER_START = datasetStart.add(50, 'days');
|
|
641
|
-
const BLOCKBUSTER_END = datasetStart.add(65, 'days');
|
|
642
|
-
for (const e of userEvents) {
|
|
643
|
-
const eventTime = dayjs(e.time);
|
|
644
|
-
if (eventTime.isAfter(BLOCKBUSTER_START) && eventTime.isBefore(BLOCKBUSTER_END)) {
|
|
645
|
-
if ((e.event === "content selected" || e.event === "playback started") && chance.bool({ likelihood: 20 })) {
|
|
646
|
-
e.content_type = "movie";
|
|
647
|
-
e.content_id = blockbusterId;
|
|
648
|
-
}
|
|
649
|
-
if (e.event === "content rated" && chance.bool({ likelihood: 20 })) {
|
|
650
|
-
e.rating = chance.integer({ min: 4, max: 5 });
|
|
651
|
-
e.content_id = blockbusterId;
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
// Hook #10: CORE VIEWING LOOP — subscription_plan property scaling.
|
|
657
|
-
// Premium users watch more efficiently (shorter durations), free users
|
|
658
|
-
// linger (longer durations). Scales watch_duration_min on playback
|
|
659
|
-
// completed events BEFORE H3/H8/H9 so each subsequent hook amplifies
|
|
660
|
-
// from the plan-adjusted base.
|
|
661
|
-
// Discover via: Insights → playback completed → Avg watch_duration_min → breakdown subscription_plan.
|
|
662
|
-
{
|
|
663
|
-
const plan = stampPlan;
|
|
664
|
-
const factor = (
|
|
665
|
-
plan === "premium" ? 0.67 :
|
|
666
|
-
plan === "free" ? 1.4 :
|
|
667
|
-
1.0
|
|
668
|
-
);
|
|
669
|
-
if (factor !== 1.0) {
|
|
670
|
-
for (const e of userEvents) {
|
|
671
|
-
if (e.event === "playback completed" && typeof e.watch_duration_min === "number") {
|
|
672
|
-
e.watch_duration_min = Math.round(e.watch_duration_min * factor);
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
// Hook #3: WEEKEND VS WEEKDAY — 1.5x watch_duration_min on weekends.
|
|
679
|
-
// No flag — analyst breaks down by day of week.
|
|
680
|
-
for (const e of userEvents) {
|
|
681
|
-
const dow = new Date(e.time).getUTCDay();
|
|
682
|
-
if ((dow === 0 || dow === 6) && e.event === "playback completed" && typeof e.watch_duration_min === "number") {
|
|
683
|
-
e.watch_duration_min = Math.round(e.watch_duration_min * 1.5);
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
// Hook #1: GENRE FUNNEL CONVERSION — drop 25% of documentary playback
|
|
688
|
-
// completed events to depress documentary funnel conversion. Raw genre check.
|
|
689
|
-
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
690
|
-
const evt = userEvents[i];
|
|
691
|
-
if (evt.event === "playback completed" && evt.genre === "documentary" && chance.bool({ likelihood: 25 })) {
|
|
692
|
-
userEvents.splice(i, 1);
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
// Hook #7: RECOMMENDATION ENGINE IMPROVEMENT — drop 30% of pre-day-60
|
|
697
|
-
// content rated events. Raw time check.
|
|
698
|
-
const IMPROVEMENT_DATE = datasetStart.add(60, 'days');
|
|
699
|
-
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
700
|
-
const evt = userEvents[i];
|
|
701
|
-
if (evt.event === "content rated" && dayjs(evt.time).isBefore(IMPROVEMENT_DATE) && chance.bool({ likelihood: 30 })) {
|
|
702
|
-
userEvents.splice(i, 1);
|
|
703
|
-
}
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
const firstEventTime = dayjs(userEvents[0].time);
|
|
707
|
-
|
|
708
|
-
// Identify behavioral patterns (no flags written)
|
|
709
|
-
let consecutiveCompletions = 0;
|
|
710
|
-
let maxConsecutiveCompletions = 0;
|
|
711
|
-
let earlyAdCount = 0;
|
|
712
|
-
let hasSubtitlesEnabled = false;
|
|
713
|
-
let recClickCount = 0;
|
|
714
|
-
|
|
715
|
-
const adCutoff = firstEventTime.add(45, 'days');
|
|
716
|
-
userEvents.forEach((event, idx) => {
|
|
717
|
-
if (event.event === "playback completed") {
|
|
718
|
-
consecutiveCompletions++;
|
|
719
|
-
if (consecutiveCompletions > maxConsecutiveCompletions) {
|
|
720
|
-
maxConsecutiveCompletions = consecutiveCompletions;
|
|
721
|
-
}
|
|
722
|
-
} else if (event.event !== "playback started") {
|
|
723
|
-
consecutiveCompletions = 0;
|
|
724
|
-
}
|
|
725
|
-
if (event.event === "ad impression" && dayjs(event.time).isBefore(adCutoff)) earlyAdCount++;
|
|
726
|
-
if (event.event === "subtitle toggled" && event.action === "enabled") hasSubtitlesEnabled = true;
|
|
727
|
-
if (event.event === "recommendation clicked") recClickCount++;
|
|
728
|
-
});
|
|
729
|
-
|
|
730
|
-
const isBingeWatcher = maxConsecutiveCompletions >= 3;
|
|
731
|
-
|
|
732
|
-
// Hook #2: BINGE-WATCHING — drop pauses, inject extra start+complete pairs.
|
|
733
|
-
// Cloned events use unique offset timestamps. No flag.
|
|
734
|
-
if (isBingeWatcher) {
|
|
735
|
-
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
736
|
-
const event = userEvents[i];
|
|
737
|
-
const eventTime = dayjs(event.time);
|
|
738
|
-
|
|
739
|
-
if (event.event === "playback paused" && chance.bool({ likelihood: 60 })) {
|
|
740
|
-
userEvents.splice(i, 1);
|
|
741
|
-
continue;
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
if (event.event === "playback completed" && chance.bool({ likelihood: 40 })) {
|
|
745
|
-
const nextContentId = chance.pickone(contentIds);
|
|
746
|
-
const startTemplate = userEvents.find(e => e.event === "playback started");
|
|
747
|
-
const extraStart = {
|
|
748
|
-
...(startTemplate || event),
|
|
749
|
-
event: "playback started",
|
|
750
|
-
time: eventTime.add(chance.integer({ min: 1, max: 5 }), 'minutes').toISOString(),
|
|
751
|
-
user_id: event.user_id,
|
|
752
|
-
content_id: nextContentId,
|
|
753
|
-
content_type: "series",
|
|
754
|
-
playback_quality: event.playback_quality || "1080p",
|
|
755
|
-
};
|
|
756
|
-
const extraComplete = {
|
|
757
|
-
...event,
|
|
758
|
-
time: eventTime.add(chance.integer({ min: 25, max: 60 }), 'minutes').toISOString(),
|
|
759
|
-
user_id: event.user_id,
|
|
760
|
-
content_id: nextContentId,
|
|
761
|
-
content_type: "series",
|
|
762
|
-
watch_duration_min: chance.integer({ min: 20, max: 55 }),
|
|
763
|
-
completion_percent: chance.integer({ min: 90, max: 100 }),
|
|
764
|
-
};
|
|
765
|
-
userEvents.splice(i + 1, 0, extraStart, extraComplete);
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
// Hook #8: SUBTITLE USERS WATCH MORE — 1.25x completion_percent (cap 100),
|
|
771
|
-
// 1.15x watch_duration_min, plus 20% extra cloned playback completions.
|
|
772
|
-
// No flag — discover via cohort builder on subtitle-toggled-enabled.
|
|
773
|
-
if (hasSubtitlesEnabled) {
|
|
774
|
-
for (let i = 0; i < userEvents.length; i++) {
|
|
775
|
-
const event = userEvents[i];
|
|
776
|
-
if (event.event === "playback completed") {
|
|
777
|
-
if (event.completion_percent) {
|
|
778
|
-
event.completion_percent = Math.min(100, Math.round(event.completion_percent * 1.25));
|
|
779
|
-
}
|
|
780
|
-
if (event.watch_duration_min) {
|
|
781
|
-
event.watch_duration_min = Math.round(event.watch_duration_min * 1.15);
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
const completionEvents = userEvents.filter(e => e.event === "playback completed");
|
|
787
|
-
const extraCount = Math.floor(completionEvents.length * 0.2);
|
|
788
|
-
for (let j = 0; j < extraCount; j++) {
|
|
789
|
-
const templateEvent = chance.pickone(completionEvents);
|
|
790
|
-
const templateTime = dayjs(templateEvent.time);
|
|
791
|
-
const extraCompletion = {
|
|
792
|
-
...templateEvent,
|
|
793
|
-
time: templateTime.add(chance.integer({ min: 30, max: 180 }), 'minutes').toISOString(),
|
|
794
|
-
user_id: templateEvent.user_id,
|
|
795
|
-
content_id: chance.pickone(contentIds),
|
|
796
|
-
content_type: chance.pickone(["movie", "series", "documentary"]),
|
|
797
|
-
watch_duration_min: chance.integer({ min: 25, max: 120 }),
|
|
798
|
-
completion_percent: chance.integer({ min: 80, max: 100 }),
|
|
799
|
-
};
|
|
800
|
-
userEvents.push(extraCompletion);
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
// Hook #9: RECOMMENDATION-CLICKED MAGIC NUMBER (no flags)
|
|
805
|
-
// Sweet 4-6 rec clicks → +25% watch_duration_min on playback completed.
|
|
806
|
-
// Over 7+ → halve watch_duration_min AND drop 55% of playback completed
|
|
807
|
-
// events (rec fatigue). Aggressive suppression overcomes the inherent
|
|
808
|
-
// engagement confound (high-rec-click users are naturally more active).
|
|
809
|
-
if (recClickCount >= 4 && recClickCount <= 6) {
|
|
810
|
-
userEvents.forEach(e => {
|
|
811
|
-
if (e.event === 'playback completed' && typeof e.watch_duration_min === 'number') {
|
|
812
|
-
e.watch_duration_min = Math.round(e.watch_duration_min * 1.25);
|
|
813
|
-
}
|
|
814
|
-
});
|
|
815
|
-
} else if (recClickCount >= 7) {
|
|
816
|
-
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
817
|
-
const evt = userEvents[i];
|
|
818
|
-
if (evt.event === 'playback completed') {
|
|
819
|
-
// Halve watch duration for surviving events
|
|
820
|
-
if (typeof evt.watch_duration_min === 'number') {
|
|
821
|
-
evt.watch_duration_min = Math.round(evt.watch_duration_min * 0.5);
|
|
822
|
-
}
|
|
823
|
-
// Drop 55% of completions
|
|
824
|
-
if (chance.bool({ likelihood: 55 })) {
|
|
825
|
-
userEvents.splice(i, 1);
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
// Hook #4: AD FATIGUE CHURN — users w/ 5+ early ad impressions lose
|
|
832
|
-
// nearly all events after day 45. Runs LAST so event-adding hooks
|
|
833
|
-
// (binge-watching, subtitle) can't re-inflate the post-d45 count.
|
|
834
|
-
// Applies to ALL tiers (ad fatigue affects anyone exposed to heavy ads,
|
|
835
|
-
// regardless of plan). 95% drop overcomes the ~3x activity confound.
|
|
836
|
-
if (earlyAdCount >= 5) {
|
|
837
|
-
const churnCutoff = firstEventTime.add(45, 'days');
|
|
838
|
-
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
839
|
-
const evt = userEvents[i];
|
|
840
|
-
if (dayjs(evt.time).isAfter(churnCutoff)) {
|
|
841
|
-
// Keep only ~5% of post-d45 events (drop 95%)
|
|
842
|
-
const keep = (i % 20) === 0;
|
|
843
|
-
if (!keep) {
|
|
844
|
-
userEvents.splice(i, 1);
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
return record;
|
|
851
|
-
}
|
|
852
|
-
|
|
897
|
+
hook(record, type, meta) {
|
|
898
|
+
if (type === "event") return handleEventHooks(record);
|
|
899
|
+
if (type === "everything") return handleEverythingHooks(record, meta);
|
|
853
900
|
return record;
|
|
854
|
-
}
|
|
901
|
+
},
|
|
855
902
|
};
|
|
856
903
|
|
|
857
904
|
export default config;
|