@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.
Files changed (66) hide show
  1. package/.claude/skills/create-dungeon/SKILL.md +139 -46
  2. package/.claude/skills/verify-dungeon/references/counting-semantics.md +31 -6
  3. package/.claude/skills/verify-dungeon/references/sql-recipes.md +44 -25
  4. package/.claude/skills/write-hooks/SKILL.md +31 -3
  5. package/CHANGELOG.md +85 -0
  6. package/HOOKS.md +13 -0
  7. package/dungeons/technical/ad-spend.js +41 -49
  8. package/dungeons/technical/anonymous-users.js +38 -36
  9. package/dungeons/technical/array-of-object-lookup.js +136 -153
  10. package/dungeons/technical/datagen-v15-verify.js +24 -11
  11. package/dungeons/technical/experiments.js +42 -40
  12. package/dungeons/technical/foobar.js +114 -118
  13. package/dungeons/technical/group-analytics.js +42 -40
  14. package/dungeons/technical/hook-helpers-verify.js +69 -50
  15. package/dungeons/technical/identity-model-verify.js +22 -12
  16. package/dungeons/technical/mirror-strategies.js +37 -39
  17. package/dungeons/technical/nested-objects.js +119 -118
  18. package/dungeons/technical/pattern-aggregate-by-bin.js +21 -8
  19. package/dungeons/technical/pattern-attributed-by-source.js +23 -9
  20. package/dungeons/technical/pattern-frequency-by-frequency.js +21 -8
  21. package/dungeons/technical/pattern-funnel-frequency.js +30 -15
  22. package/dungeons/technical/pattern-ttc-by-segment.js +21 -8
  23. package/dungeons/technical/retention-cadence.js +115 -112
  24. package/dungeons/technical/sanity.js +86 -80
  25. package/dungeons/technical/scale-test.js +34 -38
  26. package/dungeons/technical/scd.js +111 -128
  27. package/dungeons/technical/simple.js +134 -141
  28. package/dungeons/technical/simplest.js +54 -62
  29. package/dungeons/technical/text-generation.js +110 -146
  30. package/dungeons/vertical/ai-platform.js +296 -333
  31. package/dungeons/vertical/community.js +284 -255
  32. package/dungeons/vertical/crypto.js +395 -391
  33. package/dungeons/vertical/dating.js +411 -378
  34. package/dungeons/vertical/devtools.js +336 -298
  35. package/dungeons/vertical/ecommerce.js +316 -394
  36. package/dungeons/vertical/education.js +369 -325
  37. package/dungeons/vertical/fintech.js +358 -325
  38. package/dungeons/vertical/fitness.js +335 -291
  39. package/dungeons/vertical/food-delivery.js +343 -307
  40. package/dungeons/vertical/gaming.js +480 -444
  41. package/dungeons/vertical/healthcare.js +306 -262
  42. package/dungeons/vertical/insurance-application.js +427 -409
  43. package/dungeons/vertical/logistics.js +271 -252
  44. package/dungeons/vertical/marketplace.js +333 -323
  45. package/dungeons/vertical/media.js +382 -335
  46. package/dungeons/vertical/real-estate.js +395 -346
  47. package/dungeons/vertical/sass.js +319 -333
  48. package/dungeons/vertical/social.js +368 -316
  49. package/dungeons/vertical/travel.js +297 -295
  50. package/index.js +46 -4
  51. package/lib/core/config-validator.js +126 -28
  52. package/lib/generators/funnels.js +4 -1
  53. package/lib/orchestrators/mixpanel-sender.js +7 -0
  54. package/lib/orchestrators/user-loop.js +132 -31
  55. package/lib/templates/defaults.js +59 -59
  56. package/lib/templates/macro-presets.js +14 -2
  57. package/lib/utils/dataset-context.js +103 -0
  58. package/lib/utils/retention-curve.js +140 -0
  59. package/lib/utils/utils.js +149 -38
  60. package/lib/verify/counting.js +40 -0
  61. package/lib/verify/emulate-breakdown.js +20 -1
  62. package/lib/verify/index.js +1 -0
  63. package/lib/verify/schema-validator.js +3 -1
  64. package/package.json +11 -2
  65. package/scripts/run-dungeon.mjs +12 -1
  66. package/types.d.ts +117 -1
@@ -1,58 +1,41 @@
1
- // ── TWEAK THESE ──
2
- const SEED = "coinnest";
3
- const num_days = 120;
4
- const num_users = 10_000;
5
- const avg_events_per_user_per_day = 0.83;
6
- let token = "your-mixpanel-token";
7
-
8
- // ── env overrides ──
9
- if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
10
-
1
+ // ── IMPORTS ──
11
2
  import dayjs from "dayjs";
12
3
  import utc from "dayjs/plugin/utc.js";
4
+ dayjs.extend(utc);
13
5
  import "dotenv/config";
14
6
  import * as u from "../../lib/utils/utils.js";
15
- import * as v from "ak-tools";
16
-
17
- dayjs.extend(utc);
18
- const chance = u.initChance(SEED);
19
-
20
- /** @typedef {import("../../types").Dungeon} Config */
21
-
22
- /**
23
- * ===================================================================
24
- * DATASET OVERVIEW
25
- * ===================================================================
26
- *
27
- * CoinNest — a DeFi cryptocurrency exchange platform. Users connect
28
- * wallets, swap tokens, stake for yield, mint NFTs, claim airdrops,
29
- * and trade. Features KYC verification, pro trading tiers, and
30
- * portfolio tracking.
31
- *
32
- * Scale: 6,000 users · 600K events · 120 days · 16 event types
33
- *
34
- * Core loop: wallet connected → KYC → deposit → swap → stake/unstake
35
- * → portfolio viewed → withdrawal
36
- *
37
- * Funnels:
7
+ /** @typedef {import("../../types").Dungeon} Config */
8
+
9
+ // ── OVERVIEW ──
10
+ /*
11
+ * NAME: CoinNest
12
+ * APP: DeFi cryptocurrency exchange platform. Users connect wallets,
13
+ * swap tokens, stake for yield, mint NFTs, claim airdrops, and
14
+ * trade. Features KYC verification, pro trading tiers, and
15
+ * portfolio tracking.
16
+ * SCALE: 10,000 users, ~600K events, 121 days (2026-01-01 → 2026-05-01)
17
+ * CORE LOOP: wallet connected → KYC → deposit → swap → stake/unstake → portfolio viewed → withdrawal
18
+ *
19
+ * EVENTS (16):
20
+ * swap (10) > portfolio viewed (8) > deposit (5) > stake (4) > withdrawal (3)
21
+ * > price alert set (3) > limit order placed (3) > transfer (3) > kyc started (2)
22
+ * > unstake (2) > claim airdrop (2) > nft mint (2) > referral sent (2)
23
+ * > wallet connected (1) > kyc completed (1)
24
+ *
25
+ * FUNNELS (3):
38
26
  * - Onboarding: wallet connected → kyc started → deposit (60%)
39
- * - Trading: deposit → swap → withdrawal (70%)
40
- * - DeFi: swap → stake → claim airdrop (35%)
27
+ * - Trading: deposit → swap → withdrawal (70%)
28
+ * - DeFi: swap → stake → claim airdrop (35%)
41
29
  *
42
- * Tiers: Standard (75%) / Pro (25%)
43
- * Chains: Ethereum, Solana, Base, Arbitrum, Polygon
44
- * Wallets: MetaMask, Phantom, Coinbase Wallet, Rainbow, WalletConnect
30
+ * USER PROPS: trading_tier, preferred_chain, wallet_type, total_trade_volume, portfolio_value, kyc_status
31
+ * SUPER PROPS: trading_tier, preferred_chain, wallet_type
32
+ * SCD PROPS: trading_tier (Standard/Pro, monthly fuzzy, max 4)
33
+ * GROUPS: none
45
34
  */
46
35
 
47
- /**
48
- * ===================================================================
49
- * ANALYTICS HOOKS (11 hooks)
50
- * ===================================================================
51
- *
52
- * NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable
53
- * via behavioral cohorts (count event per user, observe trade_amount
54
- * distribution) or raw-prop breakdowns (token_pair, day, trading_tier).
55
- *
36
+ // ── HOOK STORIES ──
37
+ /*
38
+ * ---------------------------------------------------------------
56
39
  * 1. WHALE WALLETS (everything)
57
40
  *
58
41
  * PATTERN: 2% of wallets (deterministic via id hash) get swap
@@ -314,7 +297,66 @@ const chance = u.initChance(SEED);
314
297
  * Early Staker Retain | post-d40 events (non) | 1x | 0.4x | -60%
315
298
  */
316
299
 
317
- // Token pairs used in swap events
300
+ // ── SCALE ──
301
+ const SEED = "coinnest";
302
+ const NUM_USERS = 10_000;
303
+ const DATASET_START = "2026-01-01T00:00:00Z";
304
+ const DATASET_END = "2026-05-01T23:59:59Z";
305
+ const EVENTS_PER_DAY = 0.83;
306
+ const token = process.env.MP_TOKEN || "your-mixpanel-token";
307
+
308
+ const chance = u.initChance(SEED);
309
+
310
+ // ── KNOBS (tweak these to reshape stories) ──
311
+ const WHALE_HASH_MOD = 50;
312
+ const WHALE_AMOUNT_MULT = 50;
313
+
314
+ const GAS_SPIKE_START_DAY = 35;
315
+ const GAS_SPIKE_END_DAY = 38;
316
+ const GAS_SPIKE_MULT = 10;
317
+ const GAS_SPIKE_FAIL_LIKELIHOOD = 40;
318
+
319
+ const MOON_LAUNCH_DAY = 50;
320
+ const MOON_FLIP_LIKELIHOOD = 25;
321
+ const MOON_CLONE_COUNT = 4;
322
+
323
+ const AIRDROP_BOT_HASH_MOD = 25;
324
+ const AIRDROP_DROP_LIKELIHOOD = 95;
325
+
326
+ const KYC_DEPOSIT_MULT = 4;
327
+ const KYC_SWAP_CLONE_COUNT = 7;
328
+
329
+ const STAKE_EARLY_WINDOW_DAYS = 14;
330
+ const STAKE_RETENTION_CUTOFF_DAYS = 60;
331
+ const STAKE_NON_STAKER_DROP_LIKELIHOOD = 85;
332
+ const STAKE_INJECT_SWAP_COUNT = 8;
333
+ const STAKE_INJECT_PORTFOLIO_COUNT = 4;
334
+
335
+ const PRO_MAKER_FEE = 0.05;
336
+ const STANDARD_MAKER_FEE = 0.30;
337
+ const PRO_SWAP_CLONE_COUNT = 5;
338
+
339
+ const SCAM_WINDOW_START_DAY = 10;
340
+ const SCAM_WINDOW_END_DAY = 70;
341
+ const SCAM_FLIP_LIKELIHOOD = 2;
342
+ const SCAM_DROP_LIKELIHOOD = 80;
343
+
344
+ const FUNNEL_TTC_PRO = 0.7;
345
+ const FUNNEL_TTC_STANDARD = 1.3;
346
+
347
+ const SWAP_SWEET_MIN = 8;
348
+ const SWAP_SWEET_MAX = 20;
349
+ const SWAP_OVER_THRESHOLD = 21;
350
+ const SWAP_STAKE_BOOST = 1.3;
351
+ const SWAP_OVER_PORTFOLIO_DROP_LIKELIHOOD = 75;
352
+ const SWAP_OVER_PORTFOLIO_VALUE_FACTOR = 0.5;
353
+
354
+ const EARLY_STAKE_MIN = 2;
355
+ const EARLY_STAKE_WINDOW_DAYS = 10;
356
+ const EARLY_STAKE_CUTOFF_DAYS = 40;
357
+ const EARLY_STAKE_DROP_LIKELIHOOD = 60;
358
+
359
+ // ── DATA ARRAYS ──
318
360
  const TOKEN_PAIRS = [
319
361
  "ETH/USDC", "ETH/USDT", "BTC/USDC", "SOL/USDC",
320
362
  "MATIC/ETH", "ARB/ETH", "OP/USDC", "AVAX/USDC",
@@ -335,31 +377,314 @@ const NFT_COLLECTIONS = [
335
377
  "CryptoPunks", "Art Blocks"
336
378
  ];
337
379
 
380
+ // ── HELPER FUNCTIONS ──
381
+ function handleFunnelPostHooks(record, meta) {
382
+ // H9: Trading funnel TTC scaled by tier (Pro faster, Standard slower)
383
+ const segment = meta?.profile?.trading_tier;
384
+ if (Array.isArray(record) && record.length > 1) {
385
+ const factor = (
386
+ segment === "Pro" ? FUNNEL_TTC_PRO :
387
+ segment === "Standard" ? FUNNEL_TTC_STANDARD :
388
+ 1.0
389
+ );
390
+ if (factor !== 1.0) {
391
+ for (let i = 1; i < record.length; i++) {
392
+ const prev = dayjs(record[i - 1].time);
393
+ const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
394
+ record[i].time = prev.add(newGap, "milliseconds").toISOString();
395
+ }
396
+ }
397
+ }
398
+ return record;
399
+ }
400
+
401
+ function handleEverythingHooks(record, meta) {
402
+ const datasetStart = dayjs.unix(meta.datasetStart);
403
+ const userEvents = record;
404
+ const profile = meta.profile;
405
+
406
+ // Stamp superProps from profile for consistency
407
+ userEvents.forEach(e => {
408
+ e.trading_tier = profile.trading_tier;
409
+ e.preferred_chain = profile.preferred_chain;
410
+ e.wallet_type = profile.wallet_type;
411
+ });
412
+
413
+ // H1: Whale wallets — 2% of users (charCodeAt(0) % 50 === 0) get 50x swap volume
414
+ const userId = userEvents.length > 0 ? (userEvents[0].user_id || userEvents[0].distinct_id || "") : "";
415
+ const isWhale = userId.length > 0 && userId.charCodeAt(0) % WHALE_HASH_MOD === 0;
416
+ if (isWhale) {
417
+ userEvents.forEach(e => {
418
+ if (e.event === "swap") {
419
+ e.trade_amount_usd = Math.round((e.trade_amount_usd || 200) * WHALE_AMOUNT_MULT);
420
+ }
421
+ });
422
+ }
423
+
424
+ // H3: Token launch surge — after d50, 25% of swaps flip token_pair to MOON
425
+ const moonLaunchDay = datasetStart.add(MOON_LAUNCH_DAY, "days");
426
+ userEvents.forEach(e => {
427
+ if (e.event === "swap" && dayjs(e.time).isAfter(moonLaunchDay) && chance.bool({ likelihood: MOON_FLIP_LIKELIHOOD })) {
428
+ e.token_pair = chance.pickone(["MOON/USDC", "MOON/ETH", "ETH/MOON"]);
429
+ }
430
+ });
431
+
432
+ // H3 (cont): Clone 4 extra MOON-pair swaps per existing MOON swap with unique offset
433
+ const moonSwaps = userEvents.filter(e =>
434
+ e.event === "swap" &&
435
+ typeof e.token_pair === "string" &&
436
+ e.token_pair.includes("MOON")
437
+ );
438
+ if (moonSwaps.length > 0) {
439
+ const clonedMoonEvents = [];
440
+ moonSwaps.forEach(moonEvt => {
441
+ const moonTime = dayjs(moonEvt.time);
442
+ for (let i = 0; i < MOON_CLONE_COUNT; i++) {
443
+ clonedMoonEvents.push({
444
+ ...moonEvt,
445
+ time: moonTime.add(chance.integer({ min: 1, max: 72 }), "hours").toISOString(),
446
+ user_id: moonEvt.user_id,
447
+ trade_amount_usd: Math.round((moonEvt.trade_amount_usd || 200) * chance.floating({ min: 0.5, max: 2.0 })),
448
+ });
449
+ }
450
+ });
451
+ userEvents.push(...clonedMoonEvents);
452
+ }
453
+
454
+ // H4: Airdrop hunter churn — 4% of users (charCodeAt(1) % 25 === 0) who claim
455
+ // an airdrop lose 95% of subsequent events after first claim
456
+ const isAirdropBot = userId.length > 1 && userId.charCodeAt(1) % AIRDROP_BOT_HASH_MOD === 0;
457
+ const hasAirdropClaim = userEvents.some(e => e.event === "claim airdrop");
458
+ if (isAirdropBot && hasAirdropClaim) {
459
+ const firstClaim = userEvents.find(e => e.event === "claim airdrop");
460
+ if (firstClaim) {
461
+ const claimTime = dayjs(firstClaim.time);
462
+ for (let i = userEvents.length - 1; i >= 0; i--) {
463
+ const evt = userEvents[i];
464
+ if (dayjs(evt.time).isAfter(claimTime) && evt.event !== "claim airdrop") {
465
+ if (chance.bool({ likelihood: AIRDROP_DROP_LIKELIHOOD })) {
466
+ userEvents.splice(i, 1);
467
+ }
468
+ }
469
+ }
470
+ }
471
+ }
472
+
473
+ // H5: KYC funnel completion — post-KYC deposits 4x boost + 7 extra cloned swaps
474
+ const kycCompleted = userEvents.find(e => e.event === "kyc completed");
475
+ if (kycCompleted) {
476
+ const kycTime = dayjs(kycCompleted.time);
477
+ userEvents.forEach(e => {
478
+ if (dayjs(e.time).isAfter(kycTime) && e.event === "deposit") {
479
+ e.deposit_amount_usd = Math.round((e.deposit_amount_usd || 500) * KYC_DEPOSIT_MULT);
480
+ }
481
+ });
482
+
483
+ const postKycSwaps = userEvents.filter(e =>
484
+ e.event === "swap" && dayjs(e.time).isAfter(kycTime)
485
+ );
486
+ const clonedKycSwaps = [];
487
+ postKycSwaps.forEach(swapEvt => {
488
+ const swapTime = dayjs(swapEvt.time);
489
+ for (let i = 0; i < KYC_SWAP_CLONE_COUNT; i++) {
490
+ clonedKycSwaps.push({
491
+ ...swapEvt,
492
+ time: swapTime.add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
493
+ user_id: swapEvt.user_id,
494
+ trade_amount_usd: Math.round((swapEvt.trade_amount_usd || 200) * chance.floating({ min: 0.8, max: 1.5 })),
495
+ });
496
+ }
497
+ });
498
+ userEvents.push(...clonedKycSwaps);
499
+ }
500
+
501
+ // H6: Stake-to-retain — early stakers get post-D60 events injected; non-stakers lose 85%
502
+ const earlyStakeDay = datasetStart.add(STAKE_EARLY_WINDOW_DAYS, "days");
503
+ const stakeRetentionCutoff = datasetStart.add(STAKE_RETENTION_CUTOFF_DAYS, "days");
504
+ const stakedEarly = userEvents.some(e =>
505
+ e.event === "stake" && dayjs(e.time).isBefore(earlyStakeDay)
506
+ );
507
+ if (stakedEarly) {
508
+ const postCutoffEvents = userEvents.filter(e => dayjs(e.time).isAfter(stakeRetentionCutoff));
509
+ if (postCutoffEvents.length < 5) {
510
+ const swapTemplate = userEvents.find(e => e.event === "swap");
511
+ const portfolioTemplate = userEvents.find(e => e.event === "portfolio viewed");
512
+ if (swapTemplate) {
513
+ for (let i = 0; i < STAKE_INJECT_SWAP_COUNT; i++) {
514
+ userEvents.push({
515
+ ...swapTemplate,
516
+ time: stakeRetentionCutoff.add(chance.integer({ min: 1, max: 55 }), "days").toISOString(),
517
+ user_id: swapTemplate.user_id,
518
+ trade_amount_usd: Math.round((swapTemplate.trade_amount_usd || 200) * chance.floating({ min: 0.5, max: 2.0 })),
519
+ });
520
+ }
521
+ }
522
+ if (portfolioTemplate) {
523
+ for (let i = 0; i < STAKE_INJECT_PORTFOLIO_COUNT; i++) {
524
+ userEvents.push({
525
+ ...portfolioTemplate,
526
+ time: stakeRetentionCutoff.add(chance.integer({ min: 1, max: 55 }), "days").toISOString(),
527
+ user_id: portfolioTemplate.user_id,
528
+ });
529
+ }
530
+ }
531
+ }
532
+ } else {
533
+ // Non-stakers: drop 85% of post-D60 events to simulate ~15% retention
534
+ for (let i = userEvents.length - 1; i >= 0; i--) {
535
+ const evt = userEvents[i];
536
+ if (dayjs(evt.time).isAfter(stakeRetentionCutoff)) {
537
+ if (chance.bool({ likelihood: STAKE_NON_STAKER_DROP_LIKELIHOOD })) {
538
+ userEvents.splice(i, 1);
539
+ }
540
+ }
541
+ }
542
+ }
543
+
544
+ // H7: Pro tier maker fees — Pro=0.05, Standard=0.30; Pro gets 5 extra cloned swaps
545
+ if (profile.trading_tier === "Pro") {
546
+ userEvents.forEach(e => {
547
+ if (e.event === "swap") e.maker_fee_pct = PRO_MAKER_FEE;
548
+ });
549
+ const proSwaps = userEvents.filter(e => e.event === "swap");
550
+ const clonedProSwaps = [];
551
+ proSwaps.forEach(swapEvt => {
552
+ const swapTime = dayjs(swapEvt.time);
553
+ for (let i = 0; i < PRO_SWAP_CLONE_COUNT; i++) {
554
+ clonedProSwaps.push({
555
+ ...swapEvt,
556
+ time: swapTime.add(chance.integer({ min: 1, max: 96 }), "hours").toISOString(),
557
+ user_id: swapEvt.user_id,
558
+ trade_amount_usd: Math.round((swapEvt.trade_amount_usd || 200) * chance.floating({ min: 0.5, max: 3.0 })),
559
+ maker_fee_pct: PRO_MAKER_FEE,
560
+ });
561
+ }
562
+ });
563
+ userEvents.push(...clonedProSwaps);
564
+ } else {
565
+ userEvents.forEach(e => {
566
+ if (e.event === "swap") e.maker_fee_pct = STANDARD_MAKER_FEE;
567
+ });
568
+ }
569
+
570
+ // H8: Rug-pull aftermath — ~2% of pre-day-70 swaps flipped to SCAM; victims lose 80% post-D70
571
+ const scamCutoff = datasetStart.add(SCAM_WINDOW_END_DAY, "days");
572
+ let hadScam = false;
573
+ userEvents.forEach(e => {
574
+ if (e.event === "swap") {
575
+ const swapDay = dayjs(e.time).diff(datasetStart, "day");
576
+ if (swapDay >= SCAM_WINDOW_START_DAY && swapDay < SCAM_WINDOW_END_DAY && chance.bool({ likelihood: SCAM_FLIP_LIKELIHOOD })) {
577
+ e.token_pair = chance.pickone(["SCAM/USDC", "SCAM/ETH", "ETH/SCAM"]);
578
+ hadScam = true;
579
+ }
580
+ }
581
+ });
582
+ if (hadScam) {
583
+ for (let i = userEvents.length - 1; i >= 0; i--) {
584
+ const evt = userEvents[i];
585
+ if (dayjs(evt.time).isAfter(scamCutoff) && chance.bool({ likelihood: SCAM_DROP_LIKELIHOOD })) {
586
+ userEvents.splice(i, 1);
587
+ }
588
+ }
589
+ }
590
+
591
+ // H10: Swap-count magic number — sweet 8-20 boosts stake; over 21+ drops portfolio views
592
+ const swapCount = userEvents.filter(e => e.event === "swap").length;
593
+ if (swapCount >= SWAP_SWEET_MIN && swapCount <= SWAP_SWEET_MAX) {
594
+ const stakeTemplate = userEvents.find(e => e.event === "stake");
595
+ if (stakeTemplate) {
596
+ const stakes = userEvents.filter(e => e.event === "stake");
597
+ stakes.forEach(s => {
598
+ if (typeof s.amount_usd === "number") s.amount_usd = Math.round(s.amount_usd * SWAP_STAKE_BOOST);
599
+ const extras = chance.integer({ min: 1, max: 2 });
600
+ for (let k = 0; k < extras; k++) {
601
+ userEvents.push({
602
+ ...s,
603
+ time: dayjs(s.time).add(chance.integer({ min: 5, max: 360 }), "minutes").toISOString(),
604
+ user_id: s.user_id,
605
+ });
606
+ }
607
+ });
608
+ }
609
+ } else if (swapCount >= SWAP_OVER_THRESHOLD) {
610
+ // Over-active traders ignore portfolio review: drop 75%, halve total_value_usd on survivors
611
+ for (let i = userEvents.length - 1; i >= 0; i--) {
612
+ if (userEvents[i].event === "portfolio viewed") {
613
+ if (chance.bool({ likelihood: SWAP_OVER_PORTFOLIO_DROP_LIKELIHOOD })) {
614
+ userEvents.splice(i, 1);
615
+ } else {
616
+ userEvents[i].total_value_usd = Math.round((userEvents[i].total_value_usd || 5000) * SWAP_OVER_PORTFOLIO_VALUE_FACTOR);
617
+ }
618
+ }
619
+ }
620
+ }
621
+
622
+ // H11: Early staker retention — born-in users with <2 early stakes lose 60% post-D40
623
+ if (meta.userIsBornInDataset) {
624
+ const firstT = userEvents[0]?.time;
625
+ if (firstT) {
626
+ const window10 = dayjs(firstT).add(EARLY_STAKE_WINDOW_DAYS, "days").toISOString();
627
+ const earlyStakes = userEvents.filter(e => e.event === "stake" && e.time <= window10).length;
628
+ if (earlyStakes < EARLY_STAKE_MIN) {
629
+ const cutoff = dayjs(firstT).add(EARLY_STAKE_CUTOFF_DAYS, "days");
630
+ for (let i = userEvents.length - 1; i >= 0; i--) {
631
+ if (dayjs(userEvents[i].time).isAfter(cutoff) && chance.bool({ likelihood: EARLY_STAKE_DROP_LIKELIHOOD })) {
632
+ userEvents.splice(i, 1);
633
+ }
634
+ }
635
+ }
636
+ }
637
+ }
638
+
639
+ // H2: Gas price spike — days 35-37 swap+transfer gas_fee_usd 10x; 40% of swaps fail.
640
+ // Runs AFTER all cloning to catch events that land in the spike window from clone offsets.
641
+ const gasSpikeStart = datasetStart.add(GAS_SPIKE_START_DAY, "days");
642
+ const gasSpikeEnd = datasetStart.add(GAS_SPIKE_END_DAY, "days");
643
+ userEvents.forEach(e => {
644
+ if (e.event !== "swap" && e.event !== "transfer") return;
645
+ const t = dayjs(e.time);
646
+ if (t.isAfter(gasSpikeStart) && t.isBefore(gasSpikeEnd)) {
647
+ e.gas_fee_usd = Math.round((e.gas_fee_usd || 5) * GAS_SPIKE_MULT);
648
+ if (e.event === "swap" && chance.bool({ likelihood: GAS_SPIKE_FAIL_LIKELIHOOD })) {
649
+ e.swap_status = "failed";
650
+ }
651
+ }
652
+ });
653
+
654
+ userEvents.sort((a, b) => new Date(a.time) - new Date(b.time));
655
+ return record;
656
+ }
657
+
658
+ // ── CONFIG ──
338
659
  /** @type {Config} */
339
660
  const config = {
340
661
  version: 2,
341
- token,
342
662
  seed: SEED,
343
- datasetStart: "2026-01-01T00:00:00Z",
344
- datasetEnd: "2026-05-01T23:59:59Z",
345
- // numDays: num_days,
346
- avgEventsPerUserPerDay: avg_events_per_user_per_day,
347
- numUsers: num_users,
348
- hasAnonIds: true,
349
- avgDevicePerUser: 2,
350
- hasSessionIds: true,
663
+ datasetStart: DATASET_START,
664
+ datasetEnd: DATASET_END,
665
+ avgEventsPerUserPerDay: EVENTS_PER_DAY,
666
+ numUsers: NUM_USERS,
351
667
  format: "json",
352
668
  gzip: true,
353
- alsoInferFunnels: false,
354
- hasLocation: true,
355
- hasAndroidDevices: false,
356
- hasIOSDevices: true,
357
- hasDesktopDevices: true,
358
- hasBrowser: true,
359
- hasCampaigns: false,
360
- isAnonymous: false,
361
- hasAdSpend: false,
362
- hasAvatar: true,
669
+ credentials: {
670
+ token,
671
+ },
672
+ switches: {
673
+ hasSessionIds: true,
674
+ alsoInferFunnels: false,
675
+ hasLocation: true,
676
+ hasAndroidDevices: false,
677
+ hasIOSDevices: true,
678
+ hasDesktopDevices: true,
679
+ hasBrowser: true,
680
+ hasCampaigns: false,
681
+ isAnonymous: false,
682
+ hasAdSpend: false,
683
+ hasAvatar: true,
684
+ },
685
+ identity: {
686
+ avgDevicePerUser: 2,
687
+ },
363
688
 
364
689
  concurrency: 1,
365
690
  writeToDisk: false,
@@ -560,330 +885,9 @@ const config = {
560
885
  groupProps: {},
561
886
  lookupTables: [],
562
887
 
563
- hook: function (record, type, meta) {
564
- // HOOKS 2 + 3: GAS PRICE SPIKE and TOKEN LAUNCH SURGE moved to everything
565
- // hook below event hook fires before bunchIntoSessions reshuffles timestamps,
566
- // causing day-window tags to leak across the boundary.
567
-
568
- // HOOK 9 (T2C): TRADING FUNNEL TIME-TO-CONVERT (funnel-post)
569
- // Pro tier users complete deposit→swap→withdrawal funnel 1.4x faster
570
- // (factor 0.7 on inter-event gaps); Standard 1.3x slower (factor 1.3).
571
- if (type === "funnel-post") {
572
- const segment = meta?.profile?.trading_tier;
573
- if (Array.isArray(record) && record.length > 1) {
574
- const factor = (
575
- segment === "Pro" ? 0.7 :
576
- segment === "Standard" ? 1.3 :
577
- 1.0
578
- );
579
- if (factor !== 1.0) {
580
- for (let i = 1; i < record.length; i++) {
581
- const prev = dayjs(record[i - 1].time);
582
- const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
583
- record[i].time = prev.add(newGap, "milliseconds").toISOString();
584
- }
585
- }
586
- }
587
- }
588
-
589
- if (type === "everything") {
590
- const datasetStart = dayjs.unix(meta.datasetStart);
591
- const userEvents = record;
592
- const profile = meta.profile;
593
-
594
- // Stamp superProps from profile for consistency
595
- userEvents.forEach(e => {
596
- e.trading_tier = profile.trading_tier;
597
- e.preferred_chain = profile.preferred_chain;
598
- e.wallet_type = profile.wallet_type;
599
- });
600
-
601
- const firstEventTime = userEvents.length > 0 ? dayjs(userEvents[0].time) : null;
602
-
603
- // HOOK 1: WHALE WALLETS — 2% of users (charCodeAt(0) % 50 === 0)
604
- // get swap trade_amount_usd boosted 50x. No flag.
605
- const userId = userEvents.length > 0 ? (userEvents[0].user_id || userEvents[0].distinct_id || "") : "";
606
- const isWhale = userId.length > 0 && userId.charCodeAt(0) % 50 === 0;
607
-
608
- if (isWhale) {
609
- userEvents.forEach(e => {
610
- if (e.event === "swap") {
611
- e.trade_amount_usd = Math.round((e.trade_amount_usd || 200) * 50);
612
- }
613
- });
614
- }
615
-
616
- // HOOK 2 moved to end (after all cloning) for full coverage
617
-
618
- // HOOK 3: TOKEN LAUNCH SURGE — after d50, 25% of swaps flip token_pair to MOON.
619
- const day50 = datasetStart.add(50, "days");
620
- userEvents.forEach(e => {
621
- if (e.event === "swap" && dayjs(e.time).isAfter(day50) && chance.bool({ likelihood: 25 })) {
622
- e.token_pair = chance.pickone(["MOON/USDC", "MOON/ETH", "ETH/MOON"]);
623
- }
624
- });
625
-
626
- // HOOK 3 (cont): TOKEN LAUNCH SURGE — clone 4 extra MOON-pair
627
- // swap events per existing MOON swap. Cloned with unique offset.
628
- const moonSwaps = userEvents.filter(e =>
629
- e.event === "swap" &&
630
- typeof e.token_pair === "string" &&
631
- e.token_pair.includes("MOON")
632
- );
633
- if (moonSwaps.length > 0) {
634
- const clonedMoonEvents = [];
635
- moonSwaps.forEach(moonEvt => {
636
- const moonTime = dayjs(moonEvt.time);
637
- for (let i = 0; i < 4; i++) {
638
- clonedMoonEvents.push({
639
- ...moonEvt,
640
- time: moonTime.add(chance.integer({ min: 1, max: 72 }), "hours").toISOString(),
641
- user_id: moonEvt.user_id,
642
- trade_amount_usd: Math.round((moonEvt.trade_amount_usd || 200) * chance.floating({ min: 0.5, max: 2.0 })),
643
- });
644
- }
645
- });
646
- userEvents.push(...clonedMoonEvents);
647
- }
648
-
649
- // -----------------------------------------------------------
650
- // Hook #4: AIRDROP HUNTER CHURN
651
- // 4% of users are deterministically tagged as airdrop-farming bots
652
- // via charCodeAt(1) % 25 === 0. Bots that have ever claimed an airdrop
653
- // lose 95% of events after their first claim. (Original cohort
654
- // "claimed-but-never-swapped" was empty because every claimer also
655
- // has organic swap events at this scale.)
656
- // -----------------------------------------------------------
657
- const isAirdropBot = userId.length > 1 && userId.charCodeAt(1) % 25 === 0;
658
- const hasAirdropClaim = userEvents.some(e => e.event === "claim airdrop");
659
-
660
- if (isAirdropBot && hasAirdropClaim) {
661
- const firstClaim = userEvents.find(e => e.event === "claim airdrop");
662
- if (firstClaim) {
663
- const claimTime = dayjs(firstClaim.time);
664
- for (let i = userEvents.length - 1; i >= 0; i--) {
665
- const evt = userEvents[i];
666
- if (dayjs(evt.time).isAfter(claimTime) && evt.event !== "claim airdrop") {
667
- if (chance.bool({ likelihood: 95 })) {
668
- userEvents.splice(i, 1);
669
- }
670
- }
671
- }
672
- }
673
- }
674
-
675
- // HOOK 5: KYC FUNNEL COMPLETION — users with kyc-completed
676
- // get post-KYC deposit_amount_usd boosted 4x and 7 extra cloned
677
- // swaps per existing post-KYC swap. No flag.
678
- const kycCompleted = userEvents.find(e => e.event === "kyc completed");
679
- if (kycCompleted) {
680
- const kycTime = dayjs(kycCompleted.time);
681
-
682
- userEvents.forEach(e => {
683
- if (dayjs(e.time).isAfter(kycTime) && e.event === "deposit") {
684
- e.deposit_amount_usd = Math.round((e.deposit_amount_usd || 500) * 4);
685
- }
686
- });
687
-
688
- const postKycSwaps = userEvents.filter(e =>
689
- e.event === "swap" && dayjs(e.time).isAfter(kycTime)
690
- );
691
- const clonedKycSwaps = [];
692
- postKycSwaps.forEach(swapEvt => {
693
- const swapTime = dayjs(swapEvt.time);
694
- for (let i = 0; i < 7; i++) {
695
- clonedKycSwaps.push({
696
- ...swapEvt,
697
- time: swapTime.add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
698
- user_id: swapEvt.user_id,
699
- trade_amount_usd: Math.round((swapEvt.trade_amount_usd || 200) * chance.floating({ min: 0.8, max: 1.5 })),
700
- });
701
- }
702
- });
703
- userEvents.push(...clonedKycSwaps);
704
- }
705
-
706
- // -----------------------------------------------------------
707
- // Hook #6: STAKE-TO-RETAIN
708
- // Users who stake any token within the first 14 days of the
709
- // dataset have 70% D60 retention (events injected beyond D60).
710
- // Non-stakers lose events after day 60 to simulate 15% retention.
711
- // -----------------------------------------------------------
712
- const day14 = datasetStart.add(14, "days");
713
- const day60 = datasetStart.add(60, "days");
714
-
715
- const stakedEarly = userEvents.some(e =>
716
- e.event === "stake" && dayjs(e.time).isBefore(day14)
717
- );
718
-
719
- if (stakedEarly) {
720
- // 70% retention: inject late-stage events for stakers (no flag)
721
- const postD60Events = userEvents.filter(e => dayjs(e.time).isAfter(day60));
722
- if (postD60Events.length < 5) {
723
- const swapTemplate = userEvents.find(e => e.event === "swap");
724
- const portfolioTemplate = userEvents.find(e => e.event === "portfolio viewed");
725
- if (swapTemplate) {
726
- for (let i = 0; i < 8; i++) {
727
- userEvents.push({
728
- ...swapTemplate,
729
- time: day60.add(chance.integer({ min: 1, max: 55 }), "days").toISOString(),
730
- user_id: swapTemplate.user_id,
731
- trade_amount_usd: Math.round((swapTemplate.trade_amount_usd || 200) * chance.floating({ min: 0.5, max: 2.0 })),
732
- });
733
- }
734
- }
735
- if (portfolioTemplate) {
736
- for (let i = 0; i < 4; i++) {
737
- userEvents.push({
738
- ...portfolioTemplate,
739
- time: day60.add(chance.integer({ min: 1, max: 55 }), "days").toISOString(),
740
- user_id: portfolioTemplate.user_id,
741
- });
742
- }
743
- }
744
- }
745
- } else {
746
- // Non-stakers: 15% retention past D60 — remove 85% of post-D60 events
747
- for (let i = userEvents.length - 1; i >= 0; i--) {
748
- const evt = userEvents[i];
749
- if (dayjs(evt.time).isAfter(day60)) {
750
- if (chance.bool({ likelihood: 85 })) {
751
- userEvents.splice(i, 1);
752
- }
753
- }
754
- }
755
- }
756
-
757
- // HOOK 7: PRO TIER MAKER FEES — Pro users pay maker_fee_pct=0.05
758
- // (vs Standard 0.30) and get 5 extra cloned swaps. Mutates raw
759
- // maker_fee_pct prop. Reads trading_tier from profile.
760
- if (profile.trading_tier === "Pro") {
761
- userEvents.forEach(e => {
762
- if (e.event === "swap") e.maker_fee_pct = 0.05;
763
- });
764
-
765
- const proSwaps = userEvents.filter(e => e.event === "swap");
766
- const clonedProSwaps = [];
767
- proSwaps.forEach(swapEvt => {
768
- const swapTime = dayjs(swapEvt.time);
769
- for (let i = 0; i < 5; i++) {
770
- clonedProSwaps.push({
771
- ...swapEvt,
772
- time: swapTime.add(chance.integer({ min: 1, max: 96 }), "hours").toISOString(),
773
- user_id: swapEvt.user_id,
774
- trade_amount_usd: Math.round((swapEvt.trade_amount_usd || 200) * chance.floating({ min: 0.5, max: 3.0 })),
775
- maker_fee_pct: 0.05,
776
- });
777
- }
778
- });
779
- userEvents.push(...clonedProSwaps);
780
- } else {
781
- userEvents.forEach(e => {
782
- if (e.event === "swap") e.maker_fee_pct = 0.30;
783
- });
784
- }
785
-
786
- // -----------------------------------------------------------
787
- // Hook #8: RUG-PULL AFTERMATH
788
- // Day 70, "SCAM" token rugs. Users who swapped SCAM token
789
- // before day 70 lose 80% of events after day 70. No flag —
790
- // derive cohort by detecting users with token_pair containing "SCAM".
791
- // -----------------------------------------------------------
792
- const day70 = datasetStart.add(70, "days");
793
-
794
- let hadScam = false;
795
- userEvents.forEach(e => {
796
- if (e.event === "swap") {
797
- const swapDay = dayjs(e.time).diff(datasetStart, "day");
798
- if (swapDay >= 10 && swapDay < 70 && chance.bool({ likelihood: 2 })) {
799
- e.token_pair = chance.pickone(["SCAM/USDC", "SCAM/ETH", "ETH/SCAM"]);
800
- hadScam = true;
801
- }
802
- }
803
- });
804
-
805
- if (hadScam) {
806
- for (let i = userEvents.length - 1; i >= 0; i--) {
807
- const evt = userEvents[i];
808
- if (dayjs(evt.time).isAfter(day70) && chance.bool({ likelihood: 80 })) {
809
- userEvents.splice(i, 1);
810
- }
811
- }
812
- }
813
-
814
- // HOOK 10: SWAP-COUNT MAGIC NUMBER (no flags)
815
- // Sweet 8-20 swaps → +30% on stake amount_usd; clone 1-2 extra
816
- // stake events per existing. Over 21+ → drop 75% of portfolio viewed events and halve
817
- // total_value_usd on survivors (overcomes H6 injection overlap).
818
- const swapCount = userEvents.filter(e => e.event === "swap").length;
819
- if (swapCount >= 8 && swapCount <= 20) {
820
- const stakeTemplate = userEvents.find(e => e.event === "stake");
821
- if (stakeTemplate) {
822
- const stakes = userEvents.filter(e => e.event === "stake");
823
- stakes.forEach(s => {
824
- if (typeof s.amount_usd === "number") s.amount_usd = Math.round(s.amount_usd * 1.3);
825
- const extras = chance.integer({ min: 1, max: 2 });
826
- for (let k = 0; k < extras; k++) {
827
- userEvents.push({
828
- ...s,
829
- time: dayjs(s.time).add(chance.integer({ min: 5, max: 360 }), "minutes").toISOString(),
830
- user_id: s.user_id,
831
- });
832
- }
833
- });
834
- }
835
- } else if (swapCount >= 21) {
836
- // Over-active traders ignore portfolio review.
837
- // Drop aggressively (75%) to overcome H6 stake-to-retain injection,
838
- // and halve total_value_usd on survivors to sharpen the signal.
839
- for (let i = userEvents.length - 1; i >= 0; i--) {
840
- if (userEvents[i].event === "portfolio viewed") {
841
- if (chance.bool({ likelihood: 75 })) {
842
- userEvents.splice(i, 1);
843
- } else {
844
- userEvents[i].total_value_usd = Math.round((userEvents[i].total_value_usd || 5000) * 0.5);
845
- }
846
- }
847
- }
848
- }
849
-
850
- // HOOK 11: EARLY STAKER RETENTION — users with 2+ "stake" events in first
851
- // 10 days retain; others lose 60% of post-day-40 events.
852
- if (meta.userIsBornInDataset) {
853
- const firstT = userEvents[0]?.time;
854
- if (firstT) {
855
- const window10 = dayjs(firstT).add(10, 'days').toISOString();
856
- const earlyStakes = userEvents.filter(e => e.event === 'stake' && e.time <= window10).length;
857
- if (earlyStakes < 2) {
858
- const cutoff = dayjs(firstT).add(40, 'days');
859
- for (let i = userEvents.length - 1; i >= 0; i--) {
860
- if (dayjs(userEvents[i].time).isAfter(cutoff) && chance.bool({ likelihood: 60 })) {
861
- userEvents.splice(i, 1);
862
- }
863
- }
864
- }
865
- }
866
- }
867
-
868
- // HOOK 2: GAS PRICE SPIKE — days 35-37 swap+transfer gas_fee_usd 10x,
869
- // 40% of swaps get swap_status=failed. Runs AFTER all cloning to
870
- // catch events that land in the spike window from clone offsets.
871
- const day35 = datasetStart.add(35, "days");
872
- const day38 = datasetStart.add(38, "days");
873
- userEvents.forEach(e => {
874
- if (e.event !== "swap" && e.event !== "transfer") return;
875
- const t = dayjs(e.time);
876
- if (t.isAfter(day35) && t.isBefore(day38)) {
877
- e.gas_fee_usd = Math.round((e.gas_fee_usd || 5) * 10);
878
- if (e.event === "swap" && chance.bool({ likelihood: 40 })) {
879
- e.swap_status = "failed";
880
- }
881
- }
882
- });
883
-
884
- userEvents.sort((a, b) => new Date(a.time) - new Date(b.time));
885
- }
886
-
888
+ hook(record, type, meta) {
889
+ if (type === "funnel-post") return handleFunnelPostHooks(record, meta);
890
+ if (type === "everything") return handleEverythingHooks(record, meta);
887
891
  return record;
888
892
  }
889
893
  };