@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,55 +1,45 @@
|
|
|
1
|
-
// ──
|
|
2
|
-
const SEED = "dm4-fitness";
|
|
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
|
-
|
|
16
|
-
dayjs.extend(utc);
|
|
17
|
-
const chance = u.initChance(SEED);
|
|
18
7
|
/** @typedef {import("../../types").Dungeon} Config */
|
|
19
8
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
9
|
+
// ── OVERVIEW ──
|
|
10
|
+
/*
|
|
11
|
+
* NAME: FitQuest
|
|
12
|
+
* APP: Fitness & wellness app for workout tracking, meal planning,
|
|
13
|
+
* social fitness challenges, and AI coaching. Core loop: sign
|
|
14
|
+
* up → plan workout → complete workout → track progress.
|
|
15
|
+
* Revenue: free / monthly ($12.99, 7-day trial) / annual
|
|
16
|
+
* ($99.99) / family ($149.99).
|
|
17
|
+
* SCALE: 10,000 users, ~1.4M events, 121 days (2026-01-01 → 2026-05-01)
|
|
18
|
+
* CORE LOOP: account created → workout planned → workout completed → progress checked
|
|
19
|
+
*
|
|
20
|
+
* EVENTS (18):
|
|
21
|
+
* workout completed (8) > app session (8) > meal logged (7) > workout planned (6)
|
|
22
|
+
* > progress checked (5) > notification received (5) > leaderboard viewed (4)
|
|
23
|
+
* > nutrition plan viewed (4) > challenge joined (3) > coach session (3)
|
|
24
|
+
* > heart rate recorded (3) > achievement unlocked (2) > friend added (2)
|
|
25
|
+
* > challenge completed (2) > subscription managed (2) > profile updated (2)
|
|
26
|
+
* > account created (1) > account deactivated (1)
|
|
27
|
+
*
|
|
28
|
+
* FUNNELS (5):
|
|
29
|
+
* - Onboarding: account created → profile updated → workout planned → workout completed (45%)
|
|
30
|
+
* - Workout Loop: workout planned → workout completed → progress checked (45%, reentry)
|
|
31
|
+
* - Social Engagement: friend added → leaderboard viewed → challenge joined (35%)
|
|
32
|
+
* - Challenge Completion: challenge joined → workout completed → challenge completed → achievement unlocked (30%)
|
|
33
|
+
* - Coaching Path: coach session → workout planned → workout completed → progress checked (50%)
|
|
34
|
+
*
|
|
35
|
+
* USER PROPS: fitness_level, segment, streak_days, total_workouts, preferred_workout, goal, Platform, workout_type, subscription_tier
|
|
36
|
+
* SUPER PROPS: Platform, workout_type, subscription_tier
|
|
37
|
+
* SCD PROPS: fitness_level (beginner/intermediate/advanced/elite, monthly fuzzy, max 8)
|
|
38
|
+
* GROUPS: none
|
|
46
39
|
*/
|
|
47
40
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
* ANALYTICS HOOKS (10 hooks)
|
|
51
|
-
* ═══════════════════════════════════════════════════════════════
|
|
52
|
-
*
|
|
41
|
+
// ── HOOK STORIES ──
|
|
42
|
+
/*
|
|
53
43
|
* NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable
|
|
54
44
|
* via raw-prop breakdowns (HOD, day, segment) or behavioral cohorts.
|
|
55
45
|
*
|
|
@@ -310,31 +300,309 @@ const chance = u.initChance(SEED);
|
|
|
310
300
|
* Workout Magic Number | over D30+ post/pre | 1x | 0.29x | -71%
|
|
311
301
|
*/
|
|
312
302
|
|
|
303
|
+
// ── SCALE ──
|
|
304
|
+
const SEED = "dm4-fitness";
|
|
305
|
+
const NUM_USERS = 10_000;
|
|
306
|
+
const DATASET_START = "2026-01-01T00:00:00Z";
|
|
307
|
+
const DATASET_END = "2026-05-01T23:59:59Z";
|
|
308
|
+
const EVENTS_PER_DAY = 1.2;
|
|
309
|
+
const token = process.env.MP_TOKEN || "your-mixpanel-token";
|
|
310
|
+
|
|
311
|
+
const chance = u.initChance(SEED);
|
|
312
|
+
|
|
313
|
+
// ── KNOBS (tweak these to reshape stories) ──
|
|
314
|
+
const MORNING_HOUR_START = 5;
|
|
315
|
+
const MORNING_HOUR_END = 9;
|
|
316
|
+
const MORNING_CALORIE_MULT = 1.3;
|
|
317
|
+
|
|
318
|
+
const AI_LAUNCH_DAY = 35;
|
|
319
|
+
const AI_ADOPTION_LIKELIHOOD = 40;
|
|
320
|
+
const AI_DURATION_MULT = 1.2;
|
|
321
|
+
|
|
322
|
+
const GROUP_LAUNCH_DAY = 55;
|
|
323
|
+
const GROUP_ADOPTION_LIKELIHOOD = 30;
|
|
324
|
+
|
|
325
|
+
const STREAK_MIN_WORKOUTS = 2;
|
|
326
|
+
const STREAK_LINEAR_CAP = 3; // workouts 2-4 (count - 1 capped to 3) → 1 achievement each
|
|
327
|
+
const STREAK_SUPER_LINEAR_MULT = 4; // workouts 5+ → 4 achievements each
|
|
328
|
+
|
|
329
|
+
const SOCIAL_FRIEND_THRESHOLD = 3;
|
|
330
|
+
const SOCIAL_CHALLENGE_CLONE_FACTOR = 0.5;
|
|
331
|
+
|
|
332
|
+
const RESOLVER_EVENT_THRESHOLD = 30;
|
|
333
|
+
const RESOLVER_CLIFF_DAYS = 14;
|
|
334
|
+
const RESOLVER_DROP_LIKELIHOOD = 70;
|
|
335
|
+
|
|
336
|
+
const COACH_SESSION_SATISFACTION_MIN = 4.0;
|
|
337
|
+
const COACH_SESSION_SATISFACTION_MAX = 5.0;
|
|
338
|
+
|
|
339
|
+
const COACH_TOTAL_WORKOUTS_MIN = 200;
|
|
340
|
+
const COACH_TOTAL_WORKOUTS_MAX = 500;
|
|
341
|
+
const COACH_STREAK_DAYS_MIN = 60;
|
|
342
|
+
const COACH_STREAK_DAYS_MAX = 365;
|
|
343
|
+
|
|
344
|
+
const ANNUAL_FUNNEL_FREE_DROP_LIKELIHOOD = 30;
|
|
345
|
+
|
|
346
|
+
const TTC_ANNUAL_FACTOR = 0.77;
|
|
347
|
+
const TTC_FREE_FACTOR = 1.25;
|
|
348
|
+
|
|
349
|
+
const WORKOUT_SWEET_MIN = 12;
|
|
350
|
+
const WORKOUT_SWEET_MAX = 14;
|
|
351
|
+
const WORKOUT_OVER_THRESHOLD = 15;
|
|
352
|
+
const WORKOUT_DURATION_BOOST = 1.35;
|
|
353
|
+
const WORKOUT_OVER_CUTOFF_DAYS = 30;
|
|
354
|
+
const WORKOUT_OVER_DROP_LIKELIHOOD = 65;
|
|
355
|
+
|
|
356
|
+
// ── HELPER FUNCTIONS ──
|
|
357
|
+
function handleUserHooks(record) {
|
|
358
|
+
// H7: COACH PROFILE ENRICHMENT — coach segment users get high
|
|
359
|
+
// total_workouts + streak_days. Also assign subscription_tier by segment.
|
|
360
|
+
if (record.segment === "coach") {
|
|
361
|
+
record.total_workouts = chance.integer({ min: COACH_TOTAL_WORKOUTS_MIN, max: COACH_TOTAL_WORKOUTS_MAX });
|
|
362
|
+
record.streak_days = chance.integer({ min: COACH_STREAK_DAYS_MIN, max: COACH_STREAK_DAYS_MAX });
|
|
363
|
+
}
|
|
364
|
+
// Subscription tier: athletes/coaches → annual/family; social → monthly; casual/resolver → mostly free
|
|
365
|
+
if (record.segment === "athlete") {
|
|
366
|
+
record.subscription_tier = chance.pickone(["annual", "annual", "family", "monthly"]);
|
|
367
|
+
} else if (record.segment === "coach") {
|
|
368
|
+
record.subscription_tier = chance.pickone(["annual", "family", "family"]);
|
|
369
|
+
} else if (record.segment === "social") {
|
|
370
|
+
record.subscription_tier = chance.pickone(["monthly", "monthly", "annual", "free"]);
|
|
371
|
+
} else if (record.segment === "resolver") {
|
|
372
|
+
record.subscription_tier = chance.pickone(["free", "free", "free", "monthly"]);
|
|
373
|
+
} else {
|
|
374
|
+
record.subscription_tier = chance.pickone(["free", "free", "monthly"]);
|
|
375
|
+
}
|
|
376
|
+
return record;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function handleFunnelPostHooks(record, meta) {
|
|
380
|
+
// H9: WORKOUT LOOP TIME-TO-CONVERT — Annual/family complete 1.3x faster
|
|
381
|
+
// (factor 0.77); Free 1.25x slower (factor 1.25).
|
|
382
|
+
const tier = meta?.profile?.subscription_tier;
|
|
383
|
+
if (Array.isArray(record) && record.length > 1) {
|
|
384
|
+
const factor = (
|
|
385
|
+
tier === "annual" || tier === "family" ? TTC_ANNUAL_FACTOR :
|
|
386
|
+
tier === "free" ? TTC_FREE_FACTOR :
|
|
387
|
+
1.0
|
|
388
|
+
);
|
|
389
|
+
if (factor !== 1.0) {
|
|
390
|
+
for (let i = 1; i < record.length; i++) {
|
|
391
|
+
const prev = dayjs(record[i - 1].time);
|
|
392
|
+
const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
|
|
393
|
+
record[i].time = prev.add(newGap, "milliseconds").toISOString();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return record;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function handleEverythingHooks(record, meta) {
|
|
401
|
+
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
402
|
+
let events = record;
|
|
403
|
+
if (!events.length) return record;
|
|
404
|
+
|
|
405
|
+
// ── SUPERPROP STAMPING ──────────────────────────
|
|
406
|
+
// Stamp superProps from profile so they are consistent per user.
|
|
407
|
+
if (meta && meta.profile) {
|
|
408
|
+
const p = meta.profile;
|
|
409
|
+
events.forEach(e => {
|
|
410
|
+
if (p.Platform) e.Platform = p.Platform;
|
|
411
|
+
if (p.workout_type) e.workout_type = p.workout_type;
|
|
412
|
+
if (p.subscription_tier) e.subscription_tier = p.subscription_tier;
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// HOOK 1: MORNING WORKOUT BOOST — 5AM-9AM UTC workouts get
|
|
417
|
+
// calories_burned 1.3x. No flag — analyst breaks down by HOD.
|
|
418
|
+
events.forEach(e => {
|
|
419
|
+
if (e.event === "workout completed") {
|
|
420
|
+
const hour = new Date(e.time).getUTCHours();
|
|
421
|
+
if (hour >= MORNING_HOUR_START && hour < MORNING_HOUR_END && e.calories_burned) {
|
|
422
|
+
e.calories_burned = Math.floor(e.calories_burned * MORNING_CALORIE_MULT);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// ── HOOK 2: POST-LAUNCH AI COACHING LIFT ────────────
|
|
428
|
+
// After day 35, ~40% of workouts switch to ai_assisted coaching,
|
|
429
|
+
// then ai_assisted workouts get 1.2x duration.
|
|
430
|
+
const AI_LAUNCH = datasetStart.add(AI_LAUNCH_DAY, "days");
|
|
431
|
+
events.forEach(e => {
|
|
432
|
+
if ((e.event === "workout completed" || e.event === "workout planned") &&
|
|
433
|
+
dayjs(e.time).isAfter(AI_LAUNCH)) {
|
|
434
|
+
// Adopt ai_assisted for ~40% of post-launch workouts
|
|
435
|
+
if (chance.bool({ likelihood: AI_ADOPTION_LIKELIHOOD })) {
|
|
436
|
+
e.coaching_mode = "ai_assisted";
|
|
437
|
+
}
|
|
438
|
+
// AI-assisted workouts get 1.2x duration
|
|
439
|
+
if (e.coaching_mode === "ai_assisted") {
|
|
440
|
+
if (e.duration_minutes) {
|
|
441
|
+
e.duration_minutes = Math.floor(e.duration_minutes * AI_DURATION_MULT);
|
|
442
|
+
}
|
|
443
|
+
if (e.planned_duration_minutes) {
|
|
444
|
+
e.planned_duration_minutes = Math.floor(e.planned_duration_minutes * AI_DURATION_MULT);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
// ── GROUP CHALLENGES ADOPTION ────────────────────
|
|
451
|
+
// After day 55, ~30% of challenge events switch to group mode.
|
|
452
|
+
const GROUP_LAUNCH = datasetStart.add(GROUP_LAUNCH_DAY, "days");
|
|
453
|
+
events.forEach(e => {
|
|
454
|
+
if ((e.event === "challenge joined" || e.event === "workout completed") &&
|
|
455
|
+
dayjs(e.time).isAfter(GROUP_LAUNCH) &&
|
|
456
|
+
chance.bool({ likelihood: GROUP_ADOPTION_LIKELIHOOD })) {
|
|
457
|
+
e.challenge_mode = "group";
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
// ── HOOK 8: ANNUAL SUBSCRIBER CONVERSION FILTER ─
|
|
462
|
+
// Free/monthly-tier users drop ~30% of "progress checked"
|
|
463
|
+
// (last step of Workout Loop funnel) to simulate lower conversion.
|
|
464
|
+
if (meta && meta.profile) {
|
|
465
|
+
const tier = meta.profile.subscription_tier;
|
|
466
|
+
if (tier !== "annual" && tier !== "family" && chance.bool({ likelihood: ANNUAL_FUNNEL_FREE_DROP_LIKELIHOOD })) {
|
|
467
|
+
record = record.filter(e => e.event !== "progress checked");
|
|
468
|
+
events = record;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ── HOOK 3: STREAK RETENTION ─────────────────────
|
|
473
|
+
// Users with >=2 workouts get streak_days updated and
|
|
474
|
+
// cloned achievement events. Achievements scale super-
|
|
475
|
+
// linearly: 1 per workout for workouts 2-4, then 4 per
|
|
476
|
+
// workout beyond that.
|
|
477
|
+
const workoutEvents = events.filter(e => e.event === "workout completed");
|
|
478
|
+
if (workoutEvents.length >= STREAK_MIN_WORKOUTS) {
|
|
479
|
+
// Update profile streak_days via a profile update event
|
|
480
|
+
if (meta && meta.profile) {
|
|
481
|
+
meta.profile.streak_days = workoutEvents.length;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Super-linear achievement scaling:
|
|
485
|
+
// workouts 2-4: 1 achievement each
|
|
486
|
+
// workouts 5+: 4 achievements each
|
|
487
|
+
const templateAchievement = events.find(e => e.event === "achievement unlocked");
|
|
488
|
+
if (templateAchievement) {
|
|
489
|
+
let achievementCount = Math.min(workoutEvents.length - 1, STREAK_LINEAR_CAP); // 1 each for workouts 2-4
|
|
490
|
+
if (workoutEvents.length > 4) {
|
|
491
|
+
achievementCount += (workoutEvents.length - 4) * STREAK_SUPER_LINEAR_MULT; // 4 each for workouts 5+
|
|
492
|
+
}
|
|
493
|
+
for (let a = 0; a < achievementCount; a++) {
|
|
494
|
+
const srcIdx = Math.min(a, workoutEvents.length - 1);
|
|
495
|
+
const sourceEvent = workoutEvents[srcIdx];
|
|
496
|
+
events.push({
|
|
497
|
+
...templateAchievement,
|
|
498
|
+
time: dayjs(sourceEvent.time).add(chance.integer({ min: 1, max: 60 }), "minutes").toISOString(),
|
|
499
|
+
user_id: sourceEvent.user_id,
|
|
500
|
+
achievement_type: "streak_milestone",
|
|
501
|
+
streak_days_at_unlock: a + 2,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ── HOOK 4: SOCIAL CHALLENGE COMPLETION ──────────
|
|
508
|
+
// Users with >=3 friend_added events get 1.5x challenge completions.
|
|
509
|
+
const friendCount = events.filter(e => e.event === "friend added").length;
|
|
510
|
+
if (friendCount >= SOCIAL_FRIEND_THRESHOLD) {
|
|
511
|
+
const templateChallenge = events.find(e => e.event === "challenge completed");
|
|
512
|
+
if (templateChallenge) {
|
|
513
|
+
const challengeCompletions = events.filter(e => e.event === "challenge completed");
|
|
514
|
+
const extraCount = Math.max(1, Math.floor(challengeCompletions.length * SOCIAL_CHALLENGE_CLONE_FACTOR));
|
|
515
|
+
for (let i = 0; i < extraCount; i++) {
|
|
516
|
+
const source = challengeCompletions[i % challengeCompletions.length];
|
|
517
|
+
events.push({
|
|
518
|
+
...templateChallenge,
|
|
519
|
+
time: dayjs(source.time).add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
|
|
520
|
+
user_id: source.user_id,
|
|
521
|
+
challenge_type: source.challenge_type,
|
|
522
|
+
completion_pct: chance.integer({ min: 80, max: 100 }),
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ── HOOK 5: RESOLVER CHURN CLIFF ─────────────────
|
|
529
|
+
// Resolver segment users with <30 events lose 70% after day 14.
|
|
530
|
+
if (meta && meta.profile && meta.profile.segment === "resolver" && events.length < RESOLVER_EVENT_THRESHOLD) {
|
|
531
|
+
const CHURN_CLIFF = datasetStart.add(RESOLVER_CLIFF_DAYS, "days");
|
|
532
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
533
|
+
const eventTime = dayjs(events[i].time);
|
|
534
|
+
if (eventTime.isAfter(CHURN_CLIFF) && chance.bool({ likelihood: RESOLVER_DROP_LIKELIHOOD })) {
|
|
535
|
+
events.splice(i, 1);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// HOOK 6: COACH SESSION QUALITY — coach-session satisfaction 4-5.
|
|
541
|
+
const hasCoachSessions = events.some(e => e.event === "coach session");
|
|
542
|
+
if (hasCoachSessions) {
|
|
543
|
+
events.forEach(e => {
|
|
544
|
+
if (e.event === "coach session") {
|
|
545
|
+
e.satisfaction_score = chance.floating({ min: COACH_SESSION_SATISFACTION_MIN, max: COACH_SESSION_SATISFACTION_MAX, fixed: 1 });
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// HOOK 10: WORKOUT-COUNT MAGIC NUMBER (no flags)
|
|
551
|
+
// Sweet 12-14 workouts → +35% on workout duration_minutes (peak
|
|
552
|
+
// progression). Over 15+ → drop 65% of post-day-30 non-workout
|
|
553
|
+
// events (overtraining → churn). Workout events are preserved
|
|
554
|
+
// so the bucket categorization stays consistent.
|
|
555
|
+
const workoutCount = events.filter(e => e.event === "workout completed").length;
|
|
556
|
+
if (workoutCount >= WORKOUT_SWEET_MIN && workoutCount <= WORKOUT_SWEET_MAX) {
|
|
557
|
+
events.forEach(e => {
|
|
558
|
+
if (e.event === "workout completed" && typeof e.duration_minutes === "number") {
|
|
559
|
+
e.duration_minutes = Math.round(e.duration_minutes * WORKOUT_DURATION_BOOST);
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
} else if (workoutCount >= WORKOUT_OVER_THRESHOLD) {
|
|
563
|
+
const day30 = datasetStart.add(WORKOUT_OVER_CUTOFF_DAYS, "days");
|
|
564
|
+
const preserveEvents = new Set(["workout completed", "progress checked"]);
|
|
565
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
566
|
+
if (!preserveEvents.has(events[i].event) &&
|
|
567
|
+
dayjs(events[i].time).isAfter(day30) && chance.bool({ likelihood: WORKOUT_OVER_DROP_LIKELIHOOD })) {
|
|
568
|
+
events.splice(i, 1);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return record;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ── CONFIG ──
|
|
313
577
|
/** @type {Config} */
|
|
314
578
|
const config = {
|
|
315
579
|
version: 2,
|
|
316
|
-
token,
|
|
317
580
|
seed: SEED,
|
|
318
|
-
datasetStart:
|
|
319
|
-
datasetEnd:
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
numUsers: num_users,
|
|
323
|
-
hasAnonIds: true,
|
|
324
|
-
avgDevicePerUser: 3,
|
|
325
|
-
hasSessionIds: true,
|
|
581
|
+
datasetStart: DATASET_START,
|
|
582
|
+
datasetEnd: DATASET_END,
|
|
583
|
+
avgEventsPerUserPerDay: EVENTS_PER_DAY,
|
|
584
|
+
numUsers: NUM_USERS,
|
|
326
585
|
format: "json",
|
|
327
586
|
gzip: true,
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
587
|
+
credentials: {
|
|
588
|
+
token,
|
|
589
|
+
},
|
|
590
|
+
switches: {
|
|
591
|
+
hasSessionIds: true,
|
|
592
|
+
alsoInferFunnels: false,
|
|
593
|
+
hasLocation: true,
|
|
594
|
+
hasAndroidDevices: true,
|
|
595
|
+
hasIOSDevices: true,
|
|
596
|
+
hasDesktopDevices: false,
|
|
597
|
+
hasBrowser: false,
|
|
598
|
+
hasCampaigns: false,
|
|
599
|
+
isAnonymous: false,
|
|
600
|
+
hasAdSpend: false,
|
|
601
|
+
hasAvatar: true,
|
|
602
|
+
},
|
|
603
|
+
identity: {
|
|
604
|
+
avgDevicePerUser: 3,
|
|
605
|
+
},
|
|
338
606
|
concurrency: 1,
|
|
339
607
|
writeToDisk: false,
|
|
340
608
|
scdProps: {
|
|
@@ -656,234 +924,10 @@ const config = {
|
|
|
656
924
|
reactivationChance: 0.02,
|
|
657
925
|
},
|
|
658
926
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
// Also assign subscription_tier by segment (replacing deprecated subscription feature).
|
|
664
|
-
if (type === "user") {
|
|
665
|
-
if (record.segment === "coach") {
|
|
666
|
-
record.total_workouts = chance.integer({ min: 200, max: 500 });
|
|
667
|
-
record.streak_days = chance.integer({ min: 60, max: 365 });
|
|
668
|
-
}
|
|
669
|
-
// Subscription tier: athletes/coaches → annual/family; social → monthly; casual/resolver → mostly free
|
|
670
|
-
if (record.segment === "athlete") {
|
|
671
|
-
record.subscription_tier = chance.pickone(["annual", "annual", "family", "monthly"]);
|
|
672
|
-
} else if (record.segment === "coach") {
|
|
673
|
-
record.subscription_tier = chance.pickone(["annual", "family", "family"]);
|
|
674
|
-
} else if (record.segment === "social") {
|
|
675
|
-
record.subscription_tier = chance.pickone(["monthly", "monthly", "annual", "free"]);
|
|
676
|
-
} else if (record.segment === "resolver") {
|
|
677
|
-
record.subscription_tier = chance.pickone(["free", "free", "free", "monthly"]);
|
|
678
|
-
} else {
|
|
679
|
-
record.subscription_tier = chance.pickone(["free", "free", "monthly"]);
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
// HOOK 9 (T2C): WORKOUT LOOP TIME-TO-CONVERT (funnel-post)
|
|
684
|
-
// Annual subscribers complete the Workout Loop funnel 1.3x faster
|
|
685
|
-
// (factor 0.77); Free users 1.25x slower (factor 1.25).
|
|
686
|
-
if (type === "funnel-post") {
|
|
687
|
-
const tier = meta?.profile?.subscription_tier;
|
|
688
|
-
if (Array.isArray(record) && record.length > 1) {
|
|
689
|
-
const factor = (
|
|
690
|
-
tier === "annual" || tier === "family" ? 0.77 :
|
|
691
|
-
tier === "free" ? 1.25 :
|
|
692
|
-
1.0
|
|
693
|
-
);
|
|
694
|
-
if (factor !== 1.0) {
|
|
695
|
-
for (let i = 1; i < record.length; i++) {
|
|
696
|
-
const prev = dayjs(record[i - 1].time);
|
|
697
|
-
const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
|
|
698
|
-
record[i].time = prev.add(newGap, "milliseconds").toISOString();
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
// (HOOK 1 & 2 moved to everything hook — temporal and hour checks
|
|
705
|
-
// must run after bunchIntoSessions redistributes timestamps)
|
|
706
|
-
|
|
707
|
-
// ── EVERYTHING HOOKS ─────────────────────────────────
|
|
708
|
-
if (type === "everything") {
|
|
709
|
-
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
710
|
-
let events = record;
|
|
711
|
-
if (!events.length) return record;
|
|
712
|
-
|
|
713
|
-
// ── SUPERPROP STAMPING ──────────────────────────
|
|
714
|
-
// Stamp superProps from profile so they are consistent per user.
|
|
715
|
-
if (meta && meta.profile) {
|
|
716
|
-
const p = meta.profile;
|
|
717
|
-
events.forEach(e => {
|
|
718
|
-
if (p.Platform) e.Platform = p.Platform;
|
|
719
|
-
if (p.workout_type) e.workout_type = p.workout_type;
|
|
720
|
-
if (p.subscription_tier) e.subscription_tier = p.subscription_tier;
|
|
721
|
-
});
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
// HOOK 1: MORNING WORKOUT BOOST — 5AM-9AM UTC workouts get
|
|
725
|
-
// calories_burned 1.3x. No flag — analyst breaks down by HOD.
|
|
726
|
-
events.forEach(e => {
|
|
727
|
-
if (e.event === "workout completed") {
|
|
728
|
-
const hour = new Date(e.time).getUTCHours();
|
|
729
|
-
if (hour >= 5 && hour < 9 && e.calories_burned) {
|
|
730
|
-
e.calories_burned = Math.floor(e.calories_burned * 1.3);
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
});
|
|
734
|
-
|
|
735
|
-
// ── HOOK 2: POST-LAUNCH AI COACHING LIFT ────────────
|
|
736
|
-
// After day 35, ~40% of workouts switch to ai_assisted coaching,
|
|
737
|
-
// then ai_assisted workouts get 1.2x duration.
|
|
738
|
-
// (Replaces deprecated 'features' config; temporal check in everything per L1)
|
|
739
|
-
const AI_LAUNCH = datasetStart.add(35, "days");
|
|
740
|
-
events.forEach(e => {
|
|
741
|
-
if ((e.event === "workout completed" || e.event === "workout planned") &&
|
|
742
|
-
dayjs(e.time).isAfter(AI_LAUNCH)) {
|
|
743
|
-
// Adopt ai_assisted for ~40% of post-launch workouts
|
|
744
|
-
if (chance.bool({ likelihood: 40 })) {
|
|
745
|
-
e.coaching_mode = "ai_assisted";
|
|
746
|
-
}
|
|
747
|
-
// AI-assisted workouts get 1.2x duration
|
|
748
|
-
if (e.coaching_mode === "ai_assisted") {
|
|
749
|
-
if (e.duration_minutes) {
|
|
750
|
-
e.duration_minutes = Math.floor(e.duration_minutes * 1.2);
|
|
751
|
-
}
|
|
752
|
-
if (e.planned_duration_minutes) {
|
|
753
|
-
e.planned_duration_minutes = Math.floor(e.planned_duration_minutes * 1.2);
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
});
|
|
758
|
-
|
|
759
|
-
// ── GROUP CHALLENGES ADOPTION ────────────────────
|
|
760
|
-
// After day 55, ~30% of challenge events switch to group mode.
|
|
761
|
-
// (Replaces deprecated 'features' config)
|
|
762
|
-
const GROUP_LAUNCH = datasetStart.add(55, "days");
|
|
763
|
-
events.forEach(e => {
|
|
764
|
-
if ((e.event === "challenge joined" || e.event === "workout completed") &&
|
|
765
|
-
dayjs(e.time).isAfter(GROUP_LAUNCH) &&
|
|
766
|
-
chance.bool({ likelihood: 30 })) {
|
|
767
|
-
e.challenge_mode = "group";
|
|
768
|
-
}
|
|
769
|
-
});
|
|
770
|
-
|
|
771
|
-
// ── HOOK 8: ANNUAL SUBSCRIBER CONVERSION FILTER ─
|
|
772
|
-
// Free/monthly-tier users drop ~30% of "progress checked"
|
|
773
|
-
// (last step of Workout Loop funnel) to simulate lower conversion.
|
|
774
|
-
if (meta && meta.profile) {
|
|
775
|
-
const tier = meta.profile.subscription_tier;
|
|
776
|
-
if (tier !== "annual" && tier !== "family" && chance.bool({ likelihood: 30 })) {
|
|
777
|
-
record = record.filter(e => e.event !== "progress checked");
|
|
778
|
-
events = record;
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
// ── HOOK 3: STREAK RETENTION ─────────────────────
|
|
783
|
-
// Users with >=2 workouts get streak_days updated and
|
|
784
|
-
// cloned achievement events. Achievements scale super-
|
|
785
|
-
// linearly: 1 per workout for workouts 2-4, then 4 per
|
|
786
|
-
// workout beyond that. This amplifies the gap between
|
|
787
|
-
// high-workout segments (athlete/coach) and casual users.
|
|
788
|
-
const workoutEvents = events.filter(e => e.event === "workout completed");
|
|
789
|
-
if (workoutEvents.length >= 2) {
|
|
790
|
-
// Update profile streak_days via a profile update event
|
|
791
|
-
if (meta && meta.profile) {
|
|
792
|
-
meta.profile.streak_days = workoutEvents.length;
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
// Super-linear achievement scaling:
|
|
796
|
-
// workouts 2-4: 1 achievement each
|
|
797
|
-
// workouts 5+: 4 achievements each
|
|
798
|
-
const templateAchievement = events.find(e => e.event === "achievement unlocked");
|
|
799
|
-
if (templateAchievement) {
|
|
800
|
-
let achievementCount = Math.min(workoutEvents.length - 1, 3); // 1 each for workouts 2-4
|
|
801
|
-
if (workoutEvents.length > 4) {
|
|
802
|
-
achievementCount += (workoutEvents.length - 4) * 4; // 4 each for workouts 5+
|
|
803
|
-
}
|
|
804
|
-
for (let a = 0; a < achievementCount; a++) {
|
|
805
|
-
const srcIdx = Math.min(a, workoutEvents.length - 1);
|
|
806
|
-
const sourceEvent = workoutEvents[srcIdx];
|
|
807
|
-
events.push({
|
|
808
|
-
...templateAchievement,
|
|
809
|
-
time: dayjs(sourceEvent.time).add(chance.integer({ min: 1, max: 60 }), "minutes").toISOString(),
|
|
810
|
-
user_id: sourceEvent.user_id,
|
|
811
|
-
achievement_type: "streak_milestone",
|
|
812
|
-
streak_days_at_unlock: a + 2,
|
|
813
|
-
});
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
// ── HOOK 4: SOCIAL CHALLENGE COMPLETION ──────────
|
|
819
|
-
// Users with >=3 friend_added events get 1.5x challenge completions.
|
|
820
|
-
const friendCount = events.filter(e => e.event === "friend added").length;
|
|
821
|
-
if (friendCount >= 3) {
|
|
822
|
-
const templateChallenge = events.find(e => e.event === "challenge completed");
|
|
823
|
-
if (templateChallenge) {
|
|
824
|
-
const challengeCompletions = events.filter(e => e.event === "challenge completed");
|
|
825
|
-
const extraCount = Math.max(1, Math.floor(challengeCompletions.length * 0.5));
|
|
826
|
-
for (let i = 0; i < extraCount; i++) {
|
|
827
|
-
const source = challengeCompletions[i % challengeCompletions.length];
|
|
828
|
-
events.push({
|
|
829
|
-
...templateChallenge,
|
|
830
|
-
time: dayjs(source.time).add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
|
|
831
|
-
user_id: source.user_id,
|
|
832
|
-
challenge_type: source.challenge_type,
|
|
833
|
-
completion_pct: chance.integer({ min: 80, max: 100 }),
|
|
834
|
-
});
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
// ── HOOK 5: RESOLVER CHURN CLIFF ─────────────────
|
|
840
|
-
// Resolver segment users with <30 events lose 70% after day 14.
|
|
841
|
-
if (meta && meta.profile && meta.profile.segment === "resolver" && events.length < 30) {
|
|
842
|
-
const CHURN_CLIFF = datasetStart.add(14, "days");
|
|
843
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
844
|
-
const eventTime = dayjs(events[i].time);
|
|
845
|
-
if (eventTime.isAfter(CHURN_CLIFF) && chance.bool({ likelihood: 70 })) {
|
|
846
|
-
events.splice(i, 1);
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
// HOOK 6: COACH SESSION QUALITY — coach-session satisfaction 4-5.
|
|
852
|
-
const hasCoachSessions = events.some(e => e.event === "coach session");
|
|
853
|
-
if (hasCoachSessions) {
|
|
854
|
-
events.forEach(e => {
|
|
855
|
-
if (e.event === "coach session") {
|
|
856
|
-
e.satisfaction_score = chance.floating({ min: 4.0, max: 5.0, fixed: 1 });
|
|
857
|
-
}
|
|
858
|
-
});
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
// HOOK 10: WORKOUT-COUNT MAGIC NUMBER (no flags)
|
|
862
|
-
// Sweet 12-14 workouts → +35% on workout duration_minutes (peak
|
|
863
|
-
// progression). Over 15+ → drop 65% of post-day-30 non-workout
|
|
864
|
-
// events (overtraining → churn). Workout events are preserved
|
|
865
|
-
// so the bucket categorization stays consistent.
|
|
866
|
-
const workoutCount = events.filter(e => e.event === "workout completed").length;
|
|
867
|
-
if (workoutCount >= 12 && workoutCount <= 14) {
|
|
868
|
-
events.forEach(e => {
|
|
869
|
-
if (e.event === "workout completed" && typeof e.duration_minutes === "number") {
|
|
870
|
-
e.duration_minutes = Math.round(e.duration_minutes * 1.35);
|
|
871
|
-
}
|
|
872
|
-
});
|
|
873
|
-
} else if (workoutCount >= 15) {
|
|
874
|
-
const day30 = datasetStart.add(30, "days");
|
|
875
|
-
const preserveEvents = new Set(["workout completed", "progress checked"]);
|
|
876
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
877
|
-
if (!preserveEvents.has(events[i].event) &&
|
|
878
|
-
dayjs(events[i].time).isAfter(day30) && chance.bool({ likelihood: 65 })) {
|
|
879
|
-
events.splice(i, 1);
|
|
880
|
-
}
|
|
881
|
-
}
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
return record;
|
|
885
|
-
}
|
|
886
|
-
|
|
927
|
+
hook(record, type, meta) {
|
|
928
|
+
if (type === "user") return handleUserHooks(record);
|
|
929
|
+
if (type === "funnel-post") return handleFunnelPostHooks(record, meta);
|
|
930
|
+
if (type === "everything") return handleEverythingHooks(record, meta);
|
|
887
931
|
return record;
|
|
888
932
|
},
|
|
889
933
|
};
|