@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,70 +1,46 @@
|
|
|
1
|
-
// ──
|
|
2
|
-
const SEED = "dm4-community";
|
|
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
8
|
/** @typedef {import("../../types").Dungeon} Config */
|
|
20
9
|
|
|
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
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* - discussion_mode: classic vs live (driven by Feature rollout)
|
|
10
|
+
// ── OVERVIEW ──
|
|
11
|
+
/*
|
|
12
|
+
* NAME: FanVerse
|
|
13
|
+
* APP: Fan wiki and community discussion platform where users create
|
|
14
|
+
* articles, discuss topics, moderate content, and build collaborative
|
|
15
|
+
* knowledge bases across fandoms. Core loop: sign up → search → read
|
|
16
|
+
* articles → contribute → discuss. Revenue: free / supporter ($4.99,
|
|
17
|
+
* ad-free) / pro ($12.99, analytics + badges).
|
|
18
|
+
* SCALE: 10,000 users, ~1.4M events, 121 days (2026-01-01 → 2026-05-01)
|
|
19
|
+
* CORE LOOP: account created → search performed → article viewed → article published → comment posted
|
|
20
|
+
*
|
|
21
|
+
* EVENTS (18):
|
|
22
|
+
* article viewed (9) > app session (8) > upvote given (7) > comment posted (6)
|
|
23
|
+
* > search performed (6) > notification received (6) > discussion posted (5)
|
|
24
|
+
* > article edited (4) > article published (3) > user followed (3)
|
|
25
|
+
* > wiki page created (2) > media uploaded (2) > moderation action (2)
|
|
26
|
+
* > profile updated (2) > account created (1) > support ticket created (1)
|
|
27
|
+
* > report submitted (1) > account deactivated (1)
|
|
28
|
+
*
|
|
29
|
+
* FUNNELS (5):
|
|
30
|
+
* - Onboarding Flow: account created → search performed → article viewed → discussion posted (40%)
|
|
31
|
+
* - Content Creation: article viewed → article published → comment posted (35%)
|
|
32
|
+
* - Engagement Loop: article viewed → upvote given → comment posted → discussion posted (30%)
|
|
33
|
+
* - Creator to Supporter: article published → profile updated → notification received (45%)
|
|
34
|
+
* - Moderation Pipeline: report submitted → moderation action (60%)
|
|
35
|
+
*
|
|
36
|
+
* USER PROPS: role, contributor_level, articles_created, reputation_score, preferred_hub, subscription_tier, Platform, content_hub
|
|
37
|
+
* SUPER PROPS: subscription_tier, Platform, content_hub
|
|
38
|
+
* SCD PROPS: contributor_level (newcomer/regular/trusted/admin, monthly fuzzy, max 8)
|
|
39
|
+
* GROUPS: none
|
|
52
40
|
*/
|
|
53
41
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
* ANALYTICS HOOKS (10 hooks)
|
|
57
|
-
*
|
|
58
|
-
* NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable via
|
|
59
|
-
* raw-prop breakdowns (day, content_hub, subscription_tier) or behavioral cohorts.
|
|
60
|
-
*
|
|
61
|
-
* Adds 9. CONTENT CREATION TIME-TO-CONVERT (Pro 1.3x faster, Free 1.25x slower)
|
|
62
|
-
* [funnel-post: visible only in Mixpanel funnel median TTC; cross-event
|
|
63
|
-
* MIN→MIN SQL queries do NOT show this]
|
|
64
|
-
* and 10. ARTICLE-PUBLISHED MAGIC NUMBER (sweet 2-5 → +35% upvote_count;
|
|
65
|
-
* over 6+ → drop 25% upvote events).
|
|
66
|
-
* ===================================================================
|
|
67
|
-
*
|
|
42
|
+
// ── HOOK STORIES ──
|
|
43
|
+
/*
|
|
68
44
|
* -------------------------------------------------------------------
|
|
69
45
|
* 1. WEEKEND CONTENT SURGE (event hook)
|
|
70
46
|
* -------------------------------------------------------------------
|
|
@@ -329,31 +305,258 @@ const communityIds = v.range(1, 30).map(() => `COMM_${v.uid(4)}`);
|
|
|
329
305
|
* Article-Published Magic Num | over upvote events | 1x | 0.75x | -25%
|
|
330
306
|
*/
|
|
331
307
|
|
|
308
|
+
// ── SCALE ──
|
|
309
|
+
const SEED = "dm4-community";
|
|
310
|
+
const NUM_USERS = 10_000;
|
|
311
|
+
const DATASET_START = "2026-01-01T00:00:00Z";
|
|
312
|
+
const DATASET_END = "2026-05-01T23:59:59Z";
|
|
313
|
+
const EVENTS_PER_DAY = 1.2;
|
|
314
|
+
const token = process.env.MP_TOKEN || "your-mixpanel-token";
|
|
315
|
+
|
|
316
|
+
const chance = u.initChance(SEED);
|
|
317
|
+
|
|
318
|
+
// ── KNOBS (tweak these to reshape stories) ──
|
|
319
|
+
const WEEKEND_WORD_COUNT_MULT = 1.5;
|
|
320
|
+
|
|
321
|
+
const TREND_START_DAY = 35;
|
|
322
|
+
const TREND_END_DAY = 50;
|
|
323
|
+
const TREND_VIEW_MULT = 2;
|
|
324
|
+
|
|
325
|
+
const POWER_CREATOR_PUBLISH_THRESHOLD = 20;
|
|
326
|
+
const POWER_CREATOR_UPVOTE_MULT = 3;
|
|
327
|
+
|
|
328
|
+
const DISCUSSION_CLONE_LIKELIHOOD = 50;
|
|
329
|
+
|
|
330
|
+
const EDIT_WAR_THRESHOLD = 5;
|
|
331
|
+
const EDIT_WAR_QUALITY_MIN = 1.0;
|
|
332
|
+
const EDIT_WAR_QUALITY_MAX = 2.0;
|
|
333
|
+
|
|
334
|
+
const LURKER_EVENT_THRESHOLD = 5;
|
|
335
|
+
const LURKER_CHURN_CUTOFF_DAYS = 10;
|
|
336
|
+
const LURKER_DROP_LIKELIHOOD = 60;
|
|
337
|
+
|
|
338
|
+
const PRO_LIFT_FREE_DROP_LIKELIHOOD = 65;
|
|
339
|
+
|
|
340
|
+
const TTC_PRO_FACTOR = 0.77;
|
|
341
|
+
const TTC_FREE_FACTOR = 1.25;
|
|
342
|
+
|
|
343
|
+
const ARTICLE_SWEET_MIN = 2;
|
|
344
|
+
const ARTICLE_SWEET_MAX = 5;
|
|
345
|
+
const ARTICLE_OVER_THRESHOLD = 6;
|
|
346
|
+
const ARTICLE_UPVOTE_BOOST = 1.35;
|
|
347
|
+
const ARTICLE_UPVOTE_DROP_LIKELIHOOD = 25;
|
|
348
|
+
|
|
349
|
+
// ── DATA ARRAYS ──
|
|
350
|
+
// Generate consistent wiki/article IDs at module level
|
|
351
|
+
const wikiIds = v.range(1, 500).map(() => `WIKI_${v.uid(6)}`);
|
|
352
|
+
const communityIds = v.range(1, 30).map(() => `COMM_${v.uid(4)}`);
|
|
353
|
+
|
|
354
|
+
// ── HELPER FUNCTIONS ──
|
|
355
|
+
function handleUserHooks(record) {
|
|
356
|
+
// H7: CREATOR PROFILES — creators get high articles_created and reputation.
|
|
357
|
+
// Moderators get mid-range reputation. Readers/lurkers stay at defaults.
|
|
358
|
+
if (record.role === "creator") {
|
|
359
|
+
record.articles_created = chance.integer({ min: 50, max: 200 });
|
|
360
|
+
record.reputation_score = chance.integer({ min: 80, max: 100 });
|
|
361
|
+
record.contributor_level = "admin";
|
|
362
|
+
} else if (record.role === "moderator") {
|
|
363
|
+
record.articles_created = chance.integer({ min: 10, max: 50 });
|
|
364
|
+
record.reputation_score = chance.integer({ min: 40, max: 70 });
|
|
365
|
+
record.contributor_level = "trusted";
|
|
366
|
+
} else if (record.role === "contributor") {
|
|
367
|
+
record.articles_created = chance.integer({ min: 1, max: 15 });
|
|
368
|
+
record.reputation_score = chance.integer({ min: 15, max: 50 });
|
|
369
|
+
record.contributor_level = "regular";
|
|
370
|
+
} else {
|
|
371
|
+
record.articles_created = 0;
|
|
372
|
+
record.reputation_score = chance.integer({ min: 0, max: 20 });
|
|
373
|
+
record.contributor_level = "newcomer";
|
|
374
|
+
}
|
|
375
|
+
return record;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function handleFunnelPostHooks(record, meta) {
|
|
379
|
+
// H9: CONTENT CREATION TIME-TO-CONVERT — Pro/supporter complete 1.3x
|
|
380
|
+
// faster (factor 0.77); Free 1.25x slower (factor 1.25).
|
|
381
|
+
const segment = meta?.profile?.subscription_tier;
|
|
382
|
+
if (Array.isArray(record) && record.length > 1) {
|
|
383
|
+
const factor = (
|
|
384
|
+
segment === "pro" || segment === "supporter" ? TTC_PRO_FACTOR :
|
|
385
|
+
segment === "free" ? TTC_FREE_FACTOR :
|
|
386
|
+
1.0
|
|
387
|
+
);
|
|
388
|
+
if (factor !== 1.0) {
|
|
389
|
+
for (let i = 1; i < record.length; i++) {
|
|
390
|
+
const prev = dayjs(record[i - 1].time);
|
|
391
|
+
const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
|
|
392
|
+
record[i].time = prev.add(newGap, "milliseconds").toISOString();
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return record;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function handleEverythingHooks(record, meta) {
|
|
400
|
+
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
401
|
+
let events = record;
|
|
402
|
+
if (!events.length) return record;
|
|
403
|
+
const profile = meta && meta.profile ? meta.profile : {};
|
|
404
|
+
|
|
405
|
+
// -- SUPERPROP STAMPING -----------------------------------
|
|
406
|
+
// Stamp superProp values from profile onto every event so
|
|
407
|
+
// they stay consistent per-user instead of randomizing per-event.
|
|
408
|
+
events.forEach(e => {
|
|
409
|
+
if (profile.subscription_tier) e.subscription_tier = profile.subscription_tier;
|
|
410
|
+
if (profile.Platform) e.Platform = profile.Platform;
|
|
411
|
+
if (profile.content_hub) e.content_hub = profile.content_hub;
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// HOOK 1: WEEKEND CONTENT SURGE — articles on Sat/Sun get
|
|
415
|
+
// word_count 1.5x. Mutates raw prop. No flag.
|
|
416
|
+
for (const e of events) {
|
|
417
|
+
if (e.event === 'article published' || e.event === 'article viewed') {
|
|
418
|
+
const dow = new Date(e.time).getUTCDay();
|
|
419
|
+
if ((dow === 0 || dow === 6) && e.word_count) {
|
|
420
|
+
e.word_count = Math.floor(e.word_count * WEEKEND_WORD_COUNT_MULT);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// -- HOOK 2: TRENDING TOPIC WINDOW -------------------------
|
|
426
|
+
// Days 35-50: gaming hub articles get 2x view_count.
|
|
427
|
+
// Runs after superProp stamping so content_hub is the
|
|
428
|
+
// profile's consistent value, not the random event-level one.
|
|
429
|
+
const TREND_START = datasetStart.add(TREND_START_DAY, "days");
|
|
430
|
+
const TREND_END = datasetStart.add(TREND_END_DAY, "days");
|
|
431
|
+
if (profile.content_hub === "gaming") {
|
|
432
|
+
events.forEach(e => {
|
|
433
|
+
if (e.event === "article viewed") {
|
|
434
|
+
const eventTime = dayjs(e.time);
|
|
435
|
+
if (eventTime.isAfter(TREND_START) && eventTime.isBefore(TREND_END)) {
|
|
436
|
+
e.view_count = Math.floor((e.view_count || 50) * TREND_VIEW_MULT);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// -- HOOK 8: PRO SUBSCRIBER CONTENT CREATION LIFT ---------
|
|
443
|
+
// Free-tier users drop 65% of comment events to widen the funnel
|
|
444
|
+
// conversion gap to ~2x vs paid subscribers.
|
|
445
|
+
if (profile.subscription_tier !== "pro" && profile.subscription_tier !== "supporter") {
|
|
446
|
+
events = events.filter(e => {
|
|
447
|
+
if (e.event === "comment posted" && chance.bool({ likelihood: PRO_LIFT_FREE_DROP_LIKELIHOOD })) return false;
|
|
448
|
+
return true;
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// -- HOOK 3: POWER CREATOR ENGAGEMENT LIFT ----------------
|
|
453
|
+
// Users with >20 article_published events get 3x upvote_count.
|
|
454
|
+
let publishCount = 0;
|
|
455
|
+
events.forEach(e => {
|
|
456
|
+
if (e.event === "article published") publishCount++;
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
if (publishCount > POWER_CREATOR_PUBLISH_THRESHOLD) {
|
|
460
|
+
events.forEach(e => {
|
|
461
|
+
if (e.event === "upvote given" && e.upvote_count) {
|
|
462
|
+
e.upvote_count = Math.floor(e.upvote_count * POWER_CREATOR_UPVOTE_MULT);
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// -- HOOK 4: DISCUSSION DEPTH BY CONTRIBUTOR TYPE ---------
|
|
468
|
+
// Active contributors get cloned comment_posted events.
|
|
469
|
+
if (profile.segment === "active_contributor") {
|
|
470
|
+
const templateComment = events.find(e => e.event === "comment posted");
|
|
471
|
+
if (templateComment) {
|
|
472
|
+
const existingComments = events.filter(e => e.event === "comment posted");
|
|
473
|
+
existingComments.forEach(c => {
|
|
474
|
+
if (chance.bool({ likelihood: DISCUSSION_CLONE_LIKELIHOOD })) {
|
|
475
|
+
events.push({
|
|
476
|
+
...templateComment,
|
|
477
|
+
time: dayjs(c.time).add(chance.integer({ min: 1, max: 120 }), "minutes").toISOString(),
|
|
478
|
+
user_id: c.user_id,
|
|
479
|
+
is_reply: true,
|
|
480
|
+
comment_length: chance.integer({ min: 20, max: 300 }),
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// -- HOOK 5: EDIT WAR DETECTION ---------------------------
|
|
488
|
+
// Users with >5 article_edited events get reduced edit_quality.
|
|
489
|
+
const editEvents = events.filter(e => e.event === "article edited");
|
|
490
|
+
if (editEvents.length > EDIT_WAR_THRESHOLD) {
|
|
491
|
+
editEvents.forEach(e => {
|
|
492
|
+
e.edit_quality = chance.floating({ min: EDIT_WAR_QUALITY_MIN, max: EDIT_WAR_QUALITY_MAX, fixed: 1 });
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// HOOK 6: LURKER CHURN — users with <5 events lose 60% after
|
|
497
|
+
// day 10. No flag.
|
|
498
|
+
if (events.length < LURKER_EVENT_THRESHOLD && events.length > 0) {
|
|
499
|
+
const firstEventTime = dayjs(events[0].time);
|
|
500
|
+
const cutoff = firstEventTime.add(LURKER_CHURN_CUTOFF_DAYS, "days");
|
|
501
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
502
|
+
if (dayjs(events[i].time).isAfter(cutoff) && chance.bool({ likelihood: LURKER_DROP_LIKELIHOOD })) {
|
|
503
|
+
events.splice(i, 1);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// HOOK 10: ARTICLE-PUBLISHED MAGIC NUMBER (no flags)
|
|
509
|
+
// Sweet 2-5 articles published → +35% on upvote_count for
|
|
510
|
+
// upvote-given events. Over 6+ → drop 25% of upvote-given events
|
|
511
|
+
// (over-publishing dilutes signal). No flag.
|
|
512
|
+
const articleCount = events.filter(e => e.event === "article published").length;
|
|
513
|
+
if (articleCount >= ARTICLE_SWEET_MIN && articleCount <= ARTICLE_SWEET_MAX) {
|
|
514
|
+
events.forEach(e => {
|
|
515
|
+
if (e.event === "upvote given" && typeof e.upvote_count === "number") {
|
|
516
|
+
e.upvote_count = Math.round(e.upvote_count * ARTICLE_UPVOTE_BOOST);
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
} else if (articleCount >= ARTICLE_OVER_THRESHOLD) {
|
|
520
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
521
|
+
if (events[i].event === "upvote given" && chance.bool({ likelihood: ARTICLE_UPVOTE_DROP_LIKELIHOOD })) {
|
|
522
|
+
events.splice(i, 1);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return events;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// ── CONFIG ──
|
|
332
531
|
/** @type {Config} */
|
|
333
532
|
const config = {
|
|
334
533
|
version: 2,
|
|
335
|
-
token,
|
|
336
534
|
seed: SEED,
|
|
337
|
-
datasetStart:
|
|
338
|
-
datasetEnd:
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
numUsers: num_users,
|
|
342
|
-
hasAnonIds: true,
|
|
343
|
-
avgDevicePerUser: 2,
|
|
344
|
-
hasSessionIds: true,
|
|
535
|
+
datasetStart: DATASET_START,
|
|
536
|
+
datasetEnd: DATASET_END,
|
|
537
|
+
avgEventsPerUserPerDay: EVENTS_PER_DAY,
|
|
538
|
+
numUsers: NUM_USERS,
|
|
345
539
|
format: "json",
|
|
346
540
|
gzip: true,
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
541
|
+
credentials: {
|
|
542
|
+
token,
|
|
543
|
+
},
|
|
544
|
+
switches: {
|
|
545
|
+
hasSessionIds: true,
|
|
546
|
+
alsoInferFunnels: false,
|
|
547
|
+
hasLocation: true,
|
|
548
|
+
hasAndroidDevices: true,
|
|
549
|
+
hasIOSDevices: true,
|
|
550
|
+
hasDesktopDevices: true,
|
|
551
|
+
hasBrowser: false,
|
|
552
|
+
hasCampaigns: false,
|
|
553
|
+
isAnonymous: false,
|
|
554
|
+
hasAdSpend: false,
|
|
555
|
+
hasAvatar: true,
|
|
556
|
+
},
|
|
557
|
+
identity: {
|
|
558
|
+
avgDevicePerUser: 2,
|
|
559
|
+
},
|
|
357
560
|
concurrency: 1,
|
|
358
561
|
writeToDisk: false,
|
|
359
562
|
scdProps: {
|
|
@@ -752,184 +955,10 @@ const config = {
|
|
|
752
955
|
},
|
|
753
956
|
],
|
|
754
957
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
// get mid-range reputation. Readers/lurkers stay at defaults.
|
|
760
|
-
if (type === "user") {
|
|
761
|
-
if (record.role === "creator") {
|
|
762
|
-
record.articles_created = chance.integer({ min: 50, max: 200 });
|
|
763
|
-
record.reputation_score = chance.integer({ min: 80, max: 100 });
|
|
764
|
-
record.contributor_level = "admin";
|
|
765
|
-
} else if (record.role === "moderator") {
|
|
766
|
-
record.articles_created = chance.integer({ min: 10, max: 50 });
|
|
767
|
-
record.reputation_score = chance.integer({ min: 40, max: 70 });
|
|
768
|
-
record.contributor_level = "trusted";
|
|
769
|
-
} else if (record.role === "contributor") {
|
|
770
|
-
record.articles_created = chance.integer({ min: 1, max: 15 });
|
|
771
|
-
record.reputation_score = chance.integer({ min: 15, max: 50 });
|
|
772
|
-
record.contributor_level = "regular";
|
|
773
|
-
} else {
|
|
774
|
-
record.articles_created = 0;
|
|
775
|
-
record.reputation_score = chance.integer({ min: 0, max: 20 });
|
|
776
|
-
record.contributor_level = "newcomer";
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
// HOOK 9 (T2C): CONTENT CREATION TIME-TO-CONVERT (funnel-post)
|
|
781
|
-
// Pro/supporter subscribers complete the Content Creation funnel
|
|
782
|
-
// 1.3x faster (factor 0.77); Free 1.25x slower (factor 1.25).
|
|
783
|
-
if (type === "funnel-post") {
|
|
784
|
-
const segment = meta?.profile?.subscription_tier;
|
|
785
|
-
if (Array.isArray(record) && record.length > 1) {
|
|
786
|
-
const factor = (
|
|
787
|
-
segment === "pro" || segment === "supporter" ? 0.77 :
|
|
788
|
-
segment === "free" ? 1.25 :
|
|
789
|
-
1.0
|
|
790
|
-
);
|
|
791
|
-
if (factor !== 1.0) {
|
|
792
|
-
for (let i = 1; i < record.length; i++) {
|
|
793
|
-
const prev = dayjs(record[i - 1].time);
|
|
794
|
-
const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
|
|
795
|
-
record[i].time = prev.add(newGap, "milliseconds").toISOString();
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
// -- EVERYTHING HOOKS -----------------------------------------
|
|
802
|
-
if (type === "everything") {
|
|
803
|
-
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
804
|
-
let events = record;
|
|
805
|
-
if (!events.length) return record;
|
|
806
|
-
const profile = meta && meta.profile ? meta.profile : {};
|
|
807
|
-
|
|
808
|
-
// -- SUPERPROP STAMPING -----------------------------------
|
|
809
|
-
// Stamp superProp values from profile onto every event so
|
|
810
|
-
// they stay consistent per-user instead of randomizing per-event.
|
|
811
|
-
events.forEach(e => {
|
|
812
|
-
if (profile.subscription_tier) e.subscription_tier = profile.subscription_tier;
|
|
813
|
-
if (profile.Platform) e.Platform = profile.Platform;
|
|
814
|
-
if (profile.content_hub) e.content_hub = profile.content_hub;
|
|
815
|
-
});
|
|
816
|
-
|
|
817
|
-
// HOOK 1: WEEKEND CONTENT SURGE — articles on Sat/Sun get
|
|
818
|
-
// word_count 1.5x. Mutates raw prop. No flag.
|
|
819
|
-
for (const e of events) {
|
|
820
|
-
if (e.event === 'article published' || e.event === 'article viewed') {
|
|
821
|
-
const dow = new Date(e.time).getUTCDay();
|
|
822
|
-
if ((dow === 0 || dow === 6) && e.word_count) {
|
|
823
|
-
e.word_count = Math.floor(e.word_count * 1.5);
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
// -- HOOK 2: TRENDING TOPIC WINDOW -------------------------
|
|
829
|
-
// Days 35-50: gaming hub articles get 2x view_count.
|
|
830
|
-
// Runs after superProp stamping so content_hub is the
|
|
831
|
-
// profile's consistent value, not the random event-level one.
|
|
832
|
-
const TREND_START = datasetStart.add(35, "days");
|
|
833
|
-
const TREND_END = datasetStart.add(50, "days");
|
|
834
|
-
if (profile.content_hub === "gaming") {
|
|
835
|
-
events.forEach(e => {
|
|
836
|
-
if (e.event === "article viewed") {
|
|
837
|
-
const eventTime = dayjs(e.time);
|
|
838
|
-
if (eventTime.isAfter(TREND_START) && eventTime.isBefore(TREND_END)) {
|
|
839
|
-
e.view_count = Math.floor((e.view_count || 50) * 2);
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
});
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
// -- HOOK 8: PRO SUBSCRIBER CONTENT CREATION LIFT ---------
|
|
846
|
-
// Free-tier users drop 65% of comment events to widen the funnel
|
|
847
|
-
// conversion gap to ~2x vs paid subscribers.
|
|
848
|
-
if (profile.subscription_tier !== "pro" && profile.subscription_tier !== "supporter") {
|
|
849
|
-
events = events.filter(e => {
|
|
850
|
-
if (e.event === "comment posted" && chance.bool({ likelihood: 65 })) return false;
|
|
851
|
-
return true;
|
|
852
|
-
});
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
// -- HOOK 3: POWER CREATOR ENGAGEMENT LIFT ----------------
|
|
856
|
-
// Users with >20 article_published events get 3x upvote_count.
|
|
857
|
-
let publishCount = 0;
|
|
858
|
-
events.forEach(e => {
|
|
859
|
-
if (e.event === "article published") publishCount++;
|
|
860
|
-
});
|
|
861
|
-
|
|
862
|
-
if (publishCount > 20) {
|
|
863
|
-
events.forEach(e => {
|
|
864
|
-
if (e.event === "upvote given" && e.upvote_count) {
|
|
865
|
-
e.upvote_count = Math.floor(e.upvote_count * 3);
|
|
866
|
-
}
|
|
867
|
-
});
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
// -- HOOK 4: DISCUSSION DEPTH BY CONTRIBUTOR TYPE ---------
|
|
871
|
-
// Active contributors get cloned comment_posted events.
|
|
872
|
-
if (profile.segment === "active_contributor") {
|
|
873
|
-
const templateComment = events.find(e => e.event === "comment posted");
|
|
874
|
-
if (templateComment) {
|
|
875
|
-
const existingComments = events.filter(e => e.event === "comment posted");
|
|
876
|
-
existingComments.forEach(c => {
|
|
877
|
-
if (chance.bool({ likelihood: 50 })) {
|
|
878
|
-
events.push({
|
|
879
|
-
...templateComment,
|
|
880
|
-
time: dayjs(c.time).add(chance.integer({ min: 1, max: 120 }), "minutes").toISOString(),
|
|
881
|
-
user_id: c.user_id,
|
|
882
|
-
is_reply: true,
|
|
883
|
-
comment_length: chance.integer({ min: 20, max: 300 }),
|
|
884
|
-
});
|
|
885
|
-
}
|
|
886
|
-
});
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
// -- HOOK 5: EDIT WAR DETECTION ---------------------------
|
|
891
|
-
// Users with >5 article_edited events get reduced edit_quality.
|
|
892
|
-
const editEvents = events.filter(e => e.event === "article edited");
|
|
893
|
-
if (editEvents.length > 5) {
|
|
894
|
-
editEvents.forEach(e => {
|
|
895
|
-
e.edit_quality = chance.floating({ min: 1.0, max: 2.0, fixed: 1 });
|
|
896
|
-
});
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
// HOOK 6: LURKER CHURN — users with <5 events lose 60% after
|
|
900
|
-
// day 10. No flag.
|
|
901
|
-
if (events.length < 5 && events.length > 0) {
|
|
902
|
-
const firstEventTime = dayjs(events[0].time);
|
|
903
|
-
const cutoff = firstEventTime.add(10, "days");
|
|
904
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
905
|
-
if (dayjs(events[i].time).isAfter(cutoff) && chance.bool({ likelihood: 60 })) {
|
|
906
|
-
events.splice(i, 1);
|
|
907
|
-
}
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
// HOOK 10: ARTICLE-PUBLISHED MAGIC NUMBER (no flags)
|
|
912
|
-
// Sweet 2-5 articles published → +35% on upvote_count for
|
|
913
|
-
// upvote-given events. Over 6+ → drop 25% of upvote-given events
|
|
914
|
-
// (over-publishing dilutes signal). No flag.
|
|
915
|
-
const articleCount = events.filter(e => e.event === "article published").length;
|
|
916
|
-
if (articleCount >= 2 && articleCount <= 5) {
|
|
917
|
-
events.forEach(e => {
|
|
918
|
-
if (e.event === "upvote given" && typeof e.upvote_count === "number") {
|
|
919
|
-
e.upvote_count = Math.round(e.upvote_count * 1.35);
|
|
920
|
-
}
|
|
921
|
-
});
|
|
922
|
-
} else if (articleCount >= 6) {
|
|
923
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
924
|
-
if (events[i].event === "upvote given" && chance.bool({ likelihood: 25 })) {
|
|
925
|
-
events.splice(i, 1);
|
|
926
|
-
}
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
return events;
|
|
931
|
-
}
|
|
932
|
-
|
|
958
|
+
hook(record, type, meta) {
|
|
959
|
+
if (type === "user") return handleUserHooks(record);
|
|
960
|
+
if (type === "funnel-post") return handleFunnelPostHooks(record, meta);
|
|
961
|
+
if (type === "everything") return handleEverythingHooks(record, meta);
|
|
933
962
|
return record;
|
|
934
963
|
},
|
|
935
964
|
};
|