@ak--47/dungeon-master 1.5.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/HOOKS.md +13 -0
  3. package/dungeons/technical/ad-spend.js +41 -49
  4. package/dungeons/technical/anonymous-users.js +38 -36
  5. package/dungeons/technical/array-of-object-lookup.js +136 -153
  6. package/dungeons/technical/datagen-v15-verify.js +24 -11
  7. package/dungeons/technical/experiments.js +42 -40
  8. package/dungeons/technical/foobar.js +114 -118
  9. package/dungeons/technical/group-analytics.js +42 -40
  10. package/dungeons/technical/hook-helpers-verify.js +69 -50
  11. package/dungeons/technical/identity-model-verify.js +22 -12
  12. package/dungeons/technical/mirror-strategies.js +37 -39
  13. package/dungeons/technical/nested-objects.js +119 -118
  14. package/dungeons/technical/pattern-aggregate-by-bin.js +21 -8
  15. package/dungeons/technical/pattern-attributed-by-source.js +23 -9
  16. package/dungeons/technical/pattern-frequency-by-frequency.js +21 -8
  17. package/dungeons/technical/pattern-funnel-frequency.js +30 -15
  18. package/dungeons/technical/pattern-ttc-by-segment.js +21 -8
  19. package/dungeons/technical/retention-cadence.js +115 -112
  20. package/dungeons/technical/sanity.js +86 -80
  21. package/dungeons/technical/scale-test.js +34 -38
  22. package/dungeons/technical/scd.js +111 -128
  23. package/dungeons/technical/simple.js +134 -141
  24. package/dungeons/technical/simplest.js +54 -62
  25. package/dungeons/technical/text-generation.js +110 -146
  26. package/dungeons/vertical/ai-platform.js +296 -333
  27. package/dungeons/vertical/community.js +284 -255
  28. package/dungeons/vertical/crypto.js +395 -391
  29. package/dungeons/vertical/dating.js +411 -378
  30. package/dungeons/vertical/devtools.js +336 -298
  31. package/dungeons/vertical/ecommerce.js +316 -394
  32. package/dungeons/vertical/education.js +369 -325
  33. package/dungeons/vertical/fintech.js +358 -325
  34. package/dungeons/vertical/fitness.js +335 -291
  35. package/dungeons/vertical/food-delivery.js +343 -307
  36. package/dungeons/vertical/gaming.js +480 -444
  37. package/dungeons/vertical/healthcare.js +306 -262
  38. package/dungeons/vertical/insurance-application.js +427 -409
  39. package/dungeons/vertical/logistics.js +271 -252
  40. package/dungeons/vertical/marketplace.js +333 -323
  41. package/dungeons/vertical/media.js +382 -335
  42. package/dungeons/vertical/real-estate.js +395 -346
  43. package/dungeons/vertical/sass.js +319 -333
  44. package/dungeons/vertical/social.js +368 -316
  45. package/dungeons/vertical/travel.js +297 -295
  46. package/index.js +46 -4
  47. package/lib/core/config-validator.js +126 -28
  48. package/lib/generators/funnels.js +4 -1
  49. package/lib/orchestrators/mixpanel-sender.js +7 -0
  50. package/lib/orchestrators/user-loop.js +132 -31
  51. package/lib/templates/defaults.js +59 -59
  52. package/lib/templates/macro-presets.js +14 -2
  53. package/lib/utils/dataset-context.js +103 -0
  54. package/lib/utils/retention-curve.js +140 -0
  55. package/lib/utils/utils.js +149 -38
  56. package/lib/verify/counting.js +40 -0
  57. package/lib/verify/emulate-breakdown.js +20 -1
  58. package/lib/verify/index.js +1 -0
  59. package/lib/verify/schema-validator.js +3 -1
  60. package/package.json +11 -2
  61. package/scripts/run-dungeon.mjs +12 -1
  62. package/types.d.ts +117 -1
@@ -1,60 +1,54 @@
1
- // ── TWEAK THESE ──
2
- const SEED = "harness-fintech";
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 { findFirstSequence, scaleFunnelTTC } from "../../lib/hook-helpers/timing.js";
16
-
17
- dayjs.extend(utc);
18
- const chance = u.initChance(SEED);
19
8
  /** @typedef {import("../../types").Dungeon} Config */
20
9
 
21
- /**
22
- * ===================================================================
23
- * DATASET OVERVIEW
24
- * ===================================================================
25
- *
26
- * NexBank — a Chime/Revolut-style neobank app. Users open accounts
27
- * (personal or business), transact across 7 merchant categories,
28
- * send transfers, pay bills, set budgets, invest, apply for loans,
29
- * and earn tier-scaled rewards.
30
- *
31
- * Scale: 5,000 users · 600K events · 100 days · 18 event types
32
- * Groups: 500 households
33
- * Tiers: Basic (free) / Plus ($4.99/mo) / Premium ($14.99/mo)
34
- *
35
- * Core loop: onboarding → daily banking → financial planning →
36
- * budgets & savings → investments → rewards & monetization
37
- *
38
- * Funnels:
39
- * - Onboarding: account opened → app session → balance checked
40
- * - Daily banking: app session → balance checked → transaction
41
- * - Transfers: app session → transfer sent → notification opened
42
- * - Bill payment: app session → bill paid → notification opened
43
- * - Financial planning: budget created → budget alert → savings goal
44
- * - Investment: balance checked → investment made → reward redeemed
45
- * - Support: support contacted → card locked → dispute filed
46
- * - Lending: loan applied → loan approved → premium upgraded
10
+ // ── OVERVIEW ──
11
+ /*
12
+ * NAME: NexBank
13
+ * APP: Chime/Revolut-style neobank app. Users open accounts (personal or
14
+ * business), transact across 7 merchant categories, send transfers,
15
+ * pay bills, set budgets, invest, apply for loans, and earn
16
+ * tier-scaled rewards. Core loop runs from onboarding through daily
17
+ * banking, financial planning, investments, and rewards.
18
+ * SCALE: 10,000 users, ~1.4M events, 121 days (2026-01-01 → 2026-05-01)
19
+ * CORE LOOP: account opened → app session → balance checked → transaction completed
20
+ *
21
+ * EVENTS (19):
22
+ * app session (20) > transaction completed (18) > balance checked (15)
23
+ * > notification opened (10) > transfer sent (8) > bill paid (6)
24
+ * > investment made (4) > reward redeemed (4) > budget alert (4)
25
+ * > budget created (3) > savings goal set (3) > support contacted (3)
26
+ * > card locked (2) > dispute filed (2) > loan applied (2) > premium upgraded (2)
27
+ * > account opened (1) > loan approved (1) > bill payment missed (1)
28
+ *
29
+ * FUNNELS (8):
30
+ * - Onboarding: account opened → app session → balance checked (85%)
31
+ * - Daily Banking: app session → balance checked → transaction completed (80%)
32
+ * - Transfers: app session → transfer sent → notification opened (50%)
33
+ * - Bill Payment: app session → bill paid → notification opened (60%)
34
+ * - Financial Planning: budget created → budget alert → savings goal set (40%)
35
+ * - Investment: balance checked → investment made → reward redeemed (30%)
36
+ * - Support: support contacted → card locked → dispute filed (35%)
37
+ * - Lending: loan applied → loan approved → premium upgraded (25%)
38
+ *
39
+ * USER PROPS: account_tier, Platform, credit_score_range, income_bracket, account_age_months, total_balance, has_direct_deposit, account_segment, employee_count, annual_revenue, industry, age_range, life_stage
40
+ * SUPER PROPS: account_tier, Platform
41
+ * SCD PROPS: account_tier (basic/plus/premium, monthly fuzzy, max 6), risk_category (low/medium/high/critical, household_id-scoped, monthly fixed, max 8)
42
+ * GROUPS: household_id (500 households)
47
43
  */
48
44
 
49
- /**
50
- * ===================================================================
51
- * ANALYTICS HOOKS (10 hooks)
52
- * ===================================================================
53
- *
45
+ // ── HOOK STORIES ──
46
+ /*
54
47
  * NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable
55
48
  * via behavioral cohorts, raw-prop breakdowns (date, account_tier),
56
49
  * or funnel time-to-convert.
57
50
  *
51
+ * ---------------------------------------------------------------
58
52
  * 1. PERSONAL VS BUSINESS ACCOUNTS (user)
59
53
  *
60
54
  * PATTERN: 20% of accounts are business (employee_count, revenue,
@@ -214,7 +208,7 @@ const chance = u.initChance(SEED);
214
208
  * payment cadence that virtually eliminates missed bills.
215
209
  *
216
210
  * ---------------------------------------------------------------
217
- * 7. PREMIUM TIER VALUE (event)
211
+ * 7. PREMIUM TIER VALUE (everything)
218
212
  *
219
213
  * PATTERN: Premium-tier users get 3x reward values and 2x sell
220
214
  * returns on investments. Plus tier gets 1.5x rewards. No flag —
@@ -266,23 +260,6 @@ const chance = u.initChance(SEED);
266
260
  * REAL-WORLD ANALOGUE: Users obsessively check balances at month
267
261
  * end as bills hit and runway tightens.
268
262
  *
269
- * ===================================================================
270
- * ADVANCED ANALYSIS IDEAS
271
- * ===================================================================
272
- *
273
- * Cross-hook patterns:
274
- * - Budget + Low Balance: Do budget creators avoid low-balance churn?
275
- * - Premium + Auto-Pay: Do premium users adopt auto-pay more?
276
- * - Fraud + Churn: Do fraud victims churn more? Does resolution help?
277
- * - Payday + Month-End: Do payday spenders run out by month-end?
278
- * - Business vs Personal Fraud: Are business accounts more targeted?
279
- *
280
- * Cohort analysis:
281
- * - By account_tier: upgrade paths, value realization
282
- * - By signup_channel: referral retention vs organic
283
- * - By income_bracket: feature adoption by income
284
- * - By credit_score_range: loan approvals, tier adoption
285
- *
286
263
  * ---------------------------------------------------------------
287
264
  * 9. ONBOARDING TIME-TO-CONVERT (everything)
288
265
  *
@@ -345,33 +322,327 @@ const chance = u.initChance(SEED);
345
322
  * Txn-Count Magic Num | over premium upgrades | 1x | 0.8x | -20%
346
323
  */
347
324
 
325
+ // ── SCALE ──
326
+ const SEED = "harness-fintech";
327
+ const NUM_USERS = 10_000;
328
+ const DATASET_START = "2026-01-01T00:00:00Z";
329
+ const DATASET_END = "2026-05-01T23:59:59Z";
330
+ const EVENTS_PER_DAY = 1.2;
331
+ const token = process.env.MP_TOKEN || "your-mixpanel-token";
332
+
333
+ const chance = u.initChance(SEED);
334
+
335
+ // ── KNOBS (tweak these to reshape stories) ──
336
+ // H1: Personal vs Business
337
+ const BUSINESS_LIKELIHOOD = 20;
338
+ const BUSINESS_TXN_MULT = 4;
339
+
340
+ // H2: Payday Patterns
341
+ const PAYDAY_DEPOSIT_MULT = 3;
342
+ const PAYDAY_TRANSFER_MULT = 2.0;
343
+ const PAYDAY_TRANSFER_LIKELIHOOD = 60;
344
+
345
+ // H3: Fraud Detection
346
+ const FRAUD_LIKELIHOOD = 15;
347
+ const FRAUD_BURST_MIN = 3;
348
+ const FRAUD_BURST_MAX = 5;
349
+ const FRAUD_AMOUNT_MIN = 500;
350
+ const FRAUD_AMOUNT_MAX = 3000;
351
+
352
+ // H4: Low Balance Churn
353
+ const LOW_BALANCE_THRESHOLD = 15000;
354
+ const LOW_BALANCE_CHECK_THRESHOLD = 3;
355
+ const LOW_BALANCE_CHURN_CUTOFF_DAYS = 30;
356
+ const LOW_BALANCE_DROP_LIKELIHOOD = 50;
357
+
358
+ // H5: Budget Discipline
359
+ const BUDGET_SAVINGS_MULT = 2;
360
+ const BUDGET_INVESTMENT_MULT = 1.5;
361
+ const BUDGET_CLONE_LIKELIHOOD = 50;
362
+
363
+ // H6: Auto-Pay Loyalty
364
+ const MISSED_BILL_LIKELIHOOD = 30;
365
+
366
+ // H7: Premium Tier Value
367
+ const PREMIUM_REWARD_MULT = 3;
368
+ const PLUS_REWARD_MULT = 1.5;
369
+ const PREMIUM_INVEST_SELL_MULT = 2;
370
+
371
+ // H8: Month-End Anxiety
372
+ const MONTH_END_DAY_THRESHOLD = 28;
373
+ const MONTH_END_SESSION_MULT = 1.4;
374
+ const MONTH_END_BALANCE_MULT = 0.7;
375
+
376
+ // H9: Onboarding TTC
377
+ const TTC_PREMIUM_FACTOR = 0.67;
378
+ const TTC_BASIC_FACTOR = 1.33;
379
+ const TTC_MAX_GAP_MINUTES = 60 * 24 * 30; // 30-day max gap between steps
380
+
381
+ // H10: Transaction-Count Magic Number
382
+ const TXN_SWEET_MIN = 6;
383
+ const TXN_SWEET_MAX = 10;
384
+ const TXN_OVER_THRESHOLD = 11;
385
+ const TXN_INVESTMENT_BOOST = 1.4;
386
+ const TXN_PREMIUM_DROP_LIKELIHOOD = 20;
387
+
388
+ // ── HELPER FUNCTIONS ──
389
+ function handleUserHooks(record) {
390
+ // H1: PERSONAL VS BUSINESS ACCOUNTS — role-based attrs.
391
+ const isBusiness = chance.bool({ likelihood: BUSINESS_LIKELIHOOD });
392
+ if (isBusiness) {
393
+ record.account_segment = "business";
394
+ record.employee_count = chance.integer({ min: 5, max: 500 });
395
+ record.annual_revenue = chance.integer({ min: 100000, max: 10000000 });
396
+ record.industry = chance.pickone(["tech", "retail", "food", "services", "healthcare"]);
397
+ } else {
398
+ record.account_segment = "personal";
399
+ record.age_range = `${chance.pickone([18, 25, 35, 45, 55])}-${chance.pickone([24, 34, 44, 54, 65])}`;
400
+ record.life_stage = chance.pickone(["student", "early_career", "established", "pre_retirement", "retired"]);
401
+ }
402
+ return record;
403
+ }
404
+
405
+ function handleEventHooks(record) {
406
+ // H6: AUTO-PAY LOYALTY — manual bill-paid events have 30% chance of
407
+ // becoming "bill payment missed". Mutates event name.
408
+ if (record.event === "bill paid" && record.auto_pay !== true && chance.bool({ likelihood: MISSED_BILL_LIKELIHOOD })) {
409
+ record.event = "bill payment missed";
410
+ }
411
+ return record;
412
+ }
413
+
414
+ function handleEverythingHooks(record, meta) {
415
+ const datasetStart = dayjs.unix(meta.datasetStart);
416
+ const userEvents = record;
417
+ const profile = meta.profile;
418
+
419
+ userEvents.forEach(e => {
420
+ e.account_tier = profile.account_tier;
421
+ e.Platform = profile.Platform;
422
+ });
423
+
424
+ // H9: ONBOARDING TIME-TO-CONVERT — Premium 1.5x faster (factor 0.67);
425
+ // Basic 1.33x slower (factor 1.33). Finds first onboarding sequence
426
+ // and scales the inter-step gaps.
427
+ {
428
+ const ttcFactor = (
429
+ profile.account_tier === "premium" ? TTC_PREMIUM_FACTOR :
430
+ profile.account_tier === "basic" ? TTC_BASIC_FACTOR :
431
+ 1.0
432
+ );
433
+ if (ttcFactor !== 1.0) {
434
+ const onboardingSeq = findFirstSequence(
435
+ userEvents,
436
+ ["account opened", "app session", "balance checked"],
437
+ TTC_MAX_GAP_MINUTES
438
+ );
439
+ if (onboardingSeq) {
440
+ scaleFunnelTTC(onboardingSeq, ttcFactor);
441
+ }
442
+ }
443
+ }
444
+
445
+ // H1B: PERSONAL VS BUSINESS — business segment txns 4x larger
446
+ // (per Report 2 in JSDoc: business ~ $200, personal ~ $50).
447
+ if (profile.account_segment === "business") {
448
+ userEvents.forEach(e => {
449
+ if (e.event === "transaction completed" && typeof e.amount === "number") {
450
+ e.amount = Math.floor(e.amount * BUSINESS_TXN_MULT);
451
+ }
452
+ });
453
+ }
454
+
455
+ // H2: PAYDAY PATTERNS — 1st & 15th: direct_deposit amount 3x.
456
+ // Days 1-3 and 15-17: 60% of transfers get amount 2x. No flag.
457
+ for (const e of userEvents) {
458
+ const dayOfMonth = new Date(e.time).getUTCDate();
459
+ if (e.event === "transaction completed" && e.transaction_type === "direct_deposit") {
460
+ if (dayOfMonth === 1 || dayOfMonth === 15) {
461
+ e.amount = Math.floor((e.amount || 50) * PAYDAY_DEPOSIT_MULT);
462
+ }
463
+ }
464
+ if (e.event === "transfer sent") {
465
+ const isPaydayWindow = (dayOfMonth >= 1 && dayOfMonth <= 3) || (dayOfMonth >= 15 && dayOfMonth <= 17);
466
+ if (isPaydayWindow && chance.bool({ likelihood: PAYDAY_TRANSFER_LIKELIHOOD })) {
467
+ e.amount = Math.floor((e.amount || 200) * PAYDAY_TRANSFER_MULT);
468
+ }
469
+ }
470
+ }
471
+
472
+ // H8: MONTH-END ANXIETY — days >= 28: app_session duration 1.4x;
473
+ // balance_checked account_balance 0.7x. Mutates raw props.
474
+ for (const e of userEvents) {
475
+ const dayOfMonth = new Date(e.time).getUTCDate();
476
+ if (dayOfMonth >= MONTH_END_DAY_THRESHOLD) {
477
+ if (e.event === "app session") {
478
+ e.session_duration_sec = Math.floor((e.session_duration_sec || 60) * MONTH_END_SESSION_MULT);
479
+ }
480
+ if (e.event === "balance checked") {
481
+ e.account_balance = Math.floor((e.account_balance || 2500) * MONTH_END_BALANCE_MULT);
482
+ }
483
+ }
484
+ }
485
+
486
+ // H7: PREMIUM TIER VALUE — Premium 3x reward value + 2x investment-sell
487
+ // amount; Plus 1.5x reward value. Reads tier from profile. No flag.
488
+ const tier = profile.account_tier;
489
+ userEvents.forEach(e => {
490
+ if (e.event === "reward redeemed") {
491
+ if (tier === "premium") e.value = Math.floor((e.value || 10) * PREMIUM_REWARD_MULT);
492
+ else if (tier === "plus") e.value = Math.floor((e.value || 10) * PLUS_REWARD_MULT);
493
+ }
494
+ if (e.event === "investment made" && e.action === "sell" && tier === "premium") {
495
+ e.amount = Math.floor((e.amount || 250) * PREMIUM_INVEST_SELL_MULT);
496
+ }
497
+ });
498
+
499
+ // H3: FRAUD DETECTION — 15% of users get fraud burst (3-5 rapid
500
+ // high-value transactions + card locked + dispute + support contacted)
501
+ // at timeline midpoint. No flag — discover via cohort builder on users
502
+ // with card-locked + dispute-filed.
503
+ if (chance.bool({ likelihood: FRAUD_LIKELIHOOD }) && userEvents.length >= 2) {
504
+ const midIdx = Math.floor(userEvents.length / 2);
505
+ const midEvent = userEvents[midIdx];
506
+ const midTime = dayjs(midEvent.time);
507
+ const distinctId = midEvent.user_id;
508
+ const burstCount = chance.integer({ min: FRAUD_BURST_MIN, max: FRAUD_BURST_MAX });
509
+ const fraudEvents = [];
510
+ const txnTemplate = userEvents.find(e => e.event === "transaction completed");
511
+ const cardTemplate = userEvents.find(e => e.event === "card locked");
512
+ const disputeTemplate = userEvents.find(e => e.event === "dispute filed");
513
+ const supportTemplate = userEvents.find(e => e.event === "support contacted");
514
+
515
+ for (let i = 0; i < burstCount; i++) {
516
+ if (txnTemplate) {
517
+ fraudEvents.push({
518
+ ...txnTemplate,
519
+ time: midTime.add(i * 10, "minutes").toISOString(),
520
+ user_id: distinctId,
521
+ transaction_type: "purchase",
522
+ amount: chance.integer({ min: FRAUD_AMOUNT_MIN, max: FRAUD_AMOUNT_MAX }),
523
+ merchant_category: chance.pickone(["online", "retail"]),
524
+ payment_method: "credit",
525
+ });
526
+ }
527
+ }
528
+ if (cardTemplate) fraudEvents.push({
529
+ ...cardTemplate,
530
+ time: midTime.add(burstCount * 10 + 5, "minutes").toISOString(),
531
+ user_id: distinctId,
532
+ reason: "suspicious_activity",
533
+ });
534
+ if (disputeTemplate) fraudEvents.push({
535
+ ...disputeTemplate,
536
+ time: midTime.add(burstCount * 10 + 30, "minutes").toISOString(),
537
+ user_id: distinctId,
538
+ dispute_amount: chance.integer({ min: FRAUD_AMOUNT_MIN, max: FRAUD_AMOUNT_MAX }),
539
+ reason: "unauthorized",
540
+ });
541
+ if (supportTemplate) fraudEvents.push({
542
+ ...supportTemplate,
543
+ time: midTime.add(burstCount * 10 + 45, "minutes").toISOString(),
544
+ user_id: distinctId,
545
+ channel: "phone",
546
+ issue_type: "card",
547
+ resolved: true,
548
+ });
549
+ userEvents.splice(midIdx + 1, 0, ...fraudEvents);
550
+ }
551
+
552
+ // H4: LOW BALANCE CHURN — users with 3+ balance checks under $15K lose
553
+ // 50% of post-day-30 events. No flag.
554
+ const lowBalanceChecks = userEvents.filter(e =>
555
+ e.event === "balance checked" && (e.account_balance || 0) < LOW_BALANCE_THRESHOLD
556
+ ).length;
557
+ if (lowBalanceChecks >= LOW_BALANCE_CHECK_THRESHOLD) {
558
+ const dayCutoff = datasetStart.add(LOW_BALANCE_CHURN_CUTOFF_DAYS, "days");
559
+ for (let i = userEvents.length - 1; i >= 0; i--) {
560
+ if (dayjs(userEvents[i].time).isAfter(dayCutoff) && chance.bool({ likelihood: LOW_BALANCE_DROP_LIKELIHOOD })) {
561
+ userEvents.splice(i, 1);
562
+ }
563
+ }
564
+ }
565
+
566
+ // H5: BUDGET DISCIPLINE — users with any budget-created event get
567
+ // savings 2x, investment amounts 1.5x, and extra cloned savings-goal
568
+ // events. No flag.
569
+ const hasBudget = userEvents.some(e => e.event === "budget created");
570
+ if (hasBudget) {
571
+ userEvents.forEach((event, idx) => {
572
+ const eventTime = dayjs(event.time);
573
+ if (event.event === "savings goal set") {
574
+ event.monthly_contribution = Math.floor((event.monthly_contribution || 200) * BUDGET_SAVINGS_MULT);
575
+ }
576
+ if (event.event === "investment made") {
577
+ event.amount = Math.floor((event.amount || 250) * BUDGET_INVESTMENT_MULT);
578
+ }
579
+ if (event.event === "budget created" && chance.bool({ likelihood: BUDGET_CLONE_LIKELIHOOD })) {
580
+ const savingsTemplate = userEvents.find(e => e.event === "savings goal set");
581
+ if (savingsTemplate) {
582
+ userEvents.splice(idx + 1, 0, {
583
+ ...savingsTemplate,
584
+ time: eventTime.add(chance.integer({ min: 1, max: 7 }), "days").toISOString(),
585
+ user_id: event.user_id,
586
+ goal_type: chance.pickone(["emergency", "vacation", "car", "home"]),
587
+ target_amount: chance.integer({ min: 1000, max: 20000 }),
588
+ monthly_contribution: chance.integer({ min: 100, max: 800 }),
589
+ });
590
+ }
591
+ }
592
+ });
593
+ }
594
+
595
+ // H10: TRANSACTION-COUNT MAGIC NUMBER (no flags)
596
+ // Sweet 6-10 transactions/user → +40% on investment_made amount.
597
+ // Over 11+ → drop 20% of premium-upgraded events.
598
+ const txnCount = userEvents.filter(e => e.event === "transaction completed").length;
599
+ if (txnCount >= TXN_SWEET_MIN && txnCount <= TXN_SWEET_MAX) {
600
+ userEvents.forEach(e => {
601
+ if (e.event === "investment made" && typeof e.amount === "number") {
602
+ e.amount = Math.round(e.amount * TXN_INVESTMENT_BOOST);
603
+ }
604
+ });
605
+ } else if (txnCount >= TXN_OVER_THRESHOLD) {
606
+ for (let i = userEvents.length - 1; i >= 0; i--) {
607
+ if (userEvents[i].event === "premium upgraded" && chance.bool({ likelihood: TXN_PREMIUM_DROP_LIKELIHOOD })) {
608
+ userEvents.splice(i, 1);
609
+ }
610
+ }
611
+ }
612
+
613
+ return record;
614
+ }
615
+
616
+ // ── CONFIG ──
348
617
  /** @type {Config} */
349
618
  const config = {
350
619
  version: 2,
351
- token,
352
620
  seed: SEED,
353
- datasetStart: "2026-01-01T00:00:00Z",
354
- datasetEnd: "2026-05-01T23:59:59Z",
355
- // numDays: num_days,
356
- avgEventsPerUserPerDay: avg_events_per_user_per_day,
357
- numUsers: num_users,
358
- hasAnonIds: true,
359
- avgDevicePerUser: 2,
360
- hasSessionIds: true,
621
+ datasetStart: DATASET_START,
622
+ datasetEnd: DATASET_END,
623
+ avgEventsPerUserPerDay: EVENTS_PER_DAY,
624
+ numUsers: NUM_USERS,
361
625
  format: "json",
362
626
  gzip: true,
363
- alsoInferFunnels: false,
364
- hasLocation: true,
365
- hasAndroidDevices: true,
366
- hasIOSDevices: true,
367
- hasDesktopDevices: true,
368
- hasBrowser: false,
369
- hasCampaigns: false,
370
- isAnonymous: false,
371
- hasAdSpend: false,
372
-
373
- hasAvatar: true,
374
-
627
+ credentials: {
628
+ token,
629
+ },
630
+ switches: {
631
+ hasSessionIds: true,
632
+ alsoInferFunnels: false,
633
+ hasLocation: true,
634
+ hasAndroidDevices: true,
635
+ hasIOSDevices: true,
636
+ hasDesktopDevices: true,
637
+ hasBrowser: false,
638
+ hasCampaigns: false,
639
+ isAnonymous: false,
640
+ hasAdSpend: false,
641
+ hasAvatar: true,
642
+ },
643
+ identity: {
644
+ avgDevicePerUser: 2,
645
+ },
375
646
  concurrency: 1,
376
647
  writeToDisk: false,
377
648
 
@@ -664,248 +935,10 @@ const config = {
664
935
 
665
936
  lookupTables: [],
666
937
 
667
- /**
668
- * ARCHITECTED ANALYTICS HOOKS
669
- *
670
- * This hook function creates 8 deliberate patterns in the data:
671
- *
672
- * 1. PERSONAL VS BUSINESS: Business accounts get employee_count, revenue; personal get age_range, life_stage
673
- * 2. PAYDAY PATTERNS: Transactions spike on 1st/15th with bigger deposits and post-payday spending (everything hook — runs after sessionization)
674
- * 3. FRAUD DETECTION: 3% of users experience a fraud burst (rapid high-value txns -> card lock -> dispute -> support)
675
- * 4. LOW BALANCE CHURN: Users with chronic low balances (<$15K) lose 50% of activity after day 30
676
- * 5. BUDGET DISCIPLINE: Budget creators save 2x more and invest 1.5x more
677
- * 6. AUTO-PAY LOYALTY: Auto-pay users never miss bills; manual payers miss 30%
678
- * 7. PREMIUM TIER VALUE: Premium users get 3x rewards; Plus users get 1.5x; Premium investors get 2x returns
679
- * 8. MONTH-END ANXIETY: Last 3 days of month see 40% longer sessions and 30% lower balances (everything hook — runs after sessionization)
680
- */
681
- hook: function (record, type, meta) {
682
- // HOOK 1: PERSONAL VS BUSINESS ACCOUNTS (user) — role-based attrs.
683
- if (type === "user") {
684
- const isBusiness = chance.bool({ likelihood: 20 });
685
- if (isBusiness) {
686
- record.account_segment = "business";
687
- record.employee_count = chance.integer({ min: 5, max: 500 });
688
- record.annual_revenue = chance.integer({ min: 100000, max: 10000000 });
689
- record.industry = chance.pickone(["tech", "retail", "food", "services", "healthcare"]);
690
- } else {
691
- record.account_segment = "personal";
692
- record.age_range = `${chance.pickone([18, 25, 35, 45, 55])}-${chance.pickone([24, 34, 44, 54, 65])}`;
693
- record.life_stage = chance.pickone(["student", "early_career", "established", "pre_retirement", "retired"]);
694
- }
695
- }
696
-
697
- // HOOK 6: AUTO-PAY LOYALTY (event) — manual bill-paid events have
698
- // 30% chance of becoming "bill payment missed". Mutates event name.
699
- if (type === "event") {
700
- if (record.event === "bill paid" && record.auto_pay !== true && chance.bool({ likelihood: 30 })) {
701
- record.event = "bill payment missed";
702
- }
703
- }
704
-
705
- if (type === "everything") {
706
- const datasetStart = dayjs.unix(meta.datasetStart);
707
- const userEvents = record;
708
- const profile = meta.profile;
709
-
710
- userEvents.forEach(e => {
711
- e.account_tier = profile.account_tier;
712
- e.Platform = profile.Platform;
713
- });
714
-
715
- // HOOK 9 (T2C): ONBOARDING TIME-TO-CONVERT (everything)
716
- // Premium tier completes Onboarding funnel 1.5x faster (factor 0.67);
717
- // Basic users 1.33x slower (factor 1.33). Finds the first occurrence
718
- // of the onboarding sequence in the user's events and scales the gaps.
719
- {
720
- const ttcFactor = (
721
- profile.account_tier === "premium" ? 0.67 :
722
- profile.account_tier === "basic" ? 1.33 :
723
- 1.0
724
- );
725
- if (ttcFactor !== 1.0) {
726
- const onboardingSeq = findFirstSequence(
727
- userEvents,
728
- ["account opened", "app session", "balance checked"],
729
- 60 * 24 * 30 // 30-day max gap between steps
730
- );
731
- if (onboardingSeq) {
732
- scaleFunnelTTC(onboardingSeq, ttcFactor);
733
- }
734
- }
735
- }
736
-
737
- // HOOK 1B: PERSONAL VS BUSINESS — business segment txns 4x larger
738
- // (per Report 2 in JSDoc: business ~ $200, personal ~ $50).
739
- if (profile.account_segment === "business") {
740
- userEvents.forEach(e => {
741
- if (e.event === "transaction completed" && typeof e.amount === "number") {
742
- e.amount = Math.floor(e.amount * 4);
743
- }
744
- });
745
- }
746
-
747
- // HOOK 2: PAYDAY PATTERNS — 1st & 15th: direct_deposit amount 3x.
748
- // Days 1-3 and 15-17: 60% of transfers get amount 2x. No flag.
749
- for (const e of userEvents) {
750
- const dayOfMonth = new Date(e.time).getUTCDate();
751
- if (e.event === "transaction completed" && e.transaction_type === "direct_deposit") {
752
- if (dayOfMonth === 1 || dayOfMonth === 15) {
753
- e.amount = Math.floor((e.amount || 50) * 3);
754
- }
755
- }
756
- if (e.event === "transfer sent") {
757
- const isPaydayWindow = (dayOfMonth >= 1 && dayOfMonth <= 3) || (dayOfMonth >= 15 && dayOfMonth <= 17);
758
- if (isPaydayWindow && chance.bool({ likelihood: 60 })) {
759
- e.amount = Math.floor((e.amount || 200) * 2.0);
760
- }
761
- }
762
- }
763
-
764
- // HOOK 8: MONTH-END ANXIETY — days >= 28: app_session duration
765
- // 1.4x; balance_checked account_balance 0.7x. Mutates raw props.
766
- for (const e of userEvents) {
767
- const dayOfMonth = new Date(e.time).getUTCDate();
768
- if (dayOfMonth >= 28) {
769
- if (e.event === "app session") {
770
- e.session_duration_sec = Math.floor((e.session_duration_sec || 60) * 1.4);
771
- }
772
- if (e.event === "balance checked") {
773
- e.account_balance = Math.floor((e.account_balance || 2500) * 0.7);
774
- }
775
- }
776
- }
777
-
778
- // HOOK 7: PREMIUM TIER VALUE — Premium 3x reward value + 2x
779
- // investment-sell amount; Plus 1.5x reward value. Reads tier
780
- // from profile. No flag.
781
- const tier = profile.account_tier;
782
- userEvents.forEach(e => {
783
- if (e.event === "reward redeemed") {
784
- if (tier === "premium") e.value = Math.floor((e.value || 10) * 3);
785
- else if (tier === "plus") e.value = Math.floor((e.value || 10) * 1.5);
786
- }
787
- if (e.event === "investment made" && e.action === "sell" && tier === "premium") {
788
- e.amount = Math.floor((e.amount || 250) * 2);
789
- }
790
- });
791
-
792
- // HOOK 3: FRAUD DETECTION — 3% of users get fraud burst
793
- // (3-5 rapid high-value transactions + card locked + dispute +
794
- // support contacted) at timeline midpoint. No flag — discover
795
- // via cohort builder on users with card-locked + dispute-filed.
796
- if (chance.bool({ likelihood: 15 }) && userEvents.length >= 2) {
797
- const midIdx = Math.floor(userEvents.length / 2);
798
- const midEvent = userEvents[midIdx];
799
- const midTime = dayjs(midEvent.time);
800
- const distinctId = midEvent.user_id;
801
- const burstCount = chance.integer({ min: 3, max: 5 });
802
- const fraudEvents = [];
803
- const txnTemplate = userEvents.find(e => e.event === "transaction completed");
804
- const cardTemplate = userEvents.find(e => e.event === "card locked");
805
- const disputeTemplate = userEvents.find(e => e.event === "dispute filed");
806
- const supportTemplate = userEvents.find(e => e.event === "support contacted");
807
-
808
- for (let i = 0; i < burstCount; i++) {
809
- if (txnTemplate) {
810
- fraudEvents.push({
811
- ...txnTemplate,
812
- time: midTime.add(i * 10, "minutes").toISOString(),
813
- user_id: distinctId,
814
- transaction_type: "purchase",
815
- amount: chance.integer({ min: 500, max: 3000 }),
816
- merchant_category: chance.pickone(["online", "retail"]),
817
- payment_method: "credit",
818
- });
819
- }
820
- }
821
- if (cardTemplate) fraudEvents.push({
822
- ...cardTemplate,
823
- time: midTime.add(burstCount * 10 + 5, "minutes").toISOString(),
824
- user_id: distinctId,
825
- reason: "suspicious_activity",
826
- });
827
- if (disputeTemplate) fraudEvents.push({
828
- ...disputeTemplate,
829
- time: midTime.add(burstCount * 10 + 30, "minutes").toISOString(),
830
- user_id: distinctId,
831
- dispute_amount: chance.integer({ min: 500, max: 3000 }),
832
- reason: "unauthorized",
833
- });
834
- if (supportTemplate) fraudEvents.push({
835
- ...supportTemplate,
836
- time: midTime.add(burstCount * 10 + 45, "minutes").toISOString(),
837
- user_id: distinctId,
838
- channel: "phone",
839
- issue_type: "card",
840
- resolved: true,
841
- });
842
- userEvents.splice(midIdx + 1, 0, ...fraudEvents);
843
- }
844
-
845
- // HOOK 4: LOW BALANCE CHURN — users with 3+ balance checks
846
- // under $15K lose 50% of post-day-30 events. No flag.
847
- const lowBalanceChecks = userEvents.filter(e =>
848
- e.event === "balance checked" && (e.account_balance || 0) < 15000
849
- ).length;
850
- if (lowBalanceChecks >= 3) {
851
- const day30 = datasetStart.add(30, "days");
852
- for (let i = userEvents.length - 1; i >= 0; i--) {
853
- if (dayjs(userEvents[i].time).isAfter(day30) && chance.bool({ likelihood: 50 })) {
854
- userEvents.splice(i, 1);
855
- }
856
- }
857
- }
858
-
859
- // HOOK 5: BUDGET DISCIPLINE — users with any budget-created event
860
- // get savings 2x, investment amounts 1.5x, and extra cloned
861
- // savings-goal events. No flag.
862
- const hasBudget = userEvents.some(e => e.event === "budget created");
863
- if (hasBudget) {
864
- userEvents.forEach((event, idx) => {
865
- const eventTime = dayjs(event.time);
866
- if (event.event === "savings goal set") {
867
- event.monthly_contribution = Math.floor((event.monthly_contribution || 200) * 2);
868
- }
869
- if (event.event === "investment made") {
870
- event.amount = Math.floor((event.amount || 250) * 1.5);
871
- }
872
- if (event.event === "budget created" && chance.bool({ likelihood: 50 })) {
873
- const savingsTemplate = userEvents.find(e => e.event === "savings goal set");
874
- if (savingsTemplate) {
875
- userEvents.splice(idx + 1, 0, {
876
- ...savingsTemplate,
877
- time: eventTime.add(chance.integer({ min: 1, max: 7 }), "days").toISOString(),
878
- user_id: event.user_id,
879
- goal_type: chance.pickone(["emergency", "vacation", "car", "home"]),
880
- target_amount: chance.integer({ min: 1000, max: 20000 }),
881
- monthly_contribution: chance.integer({ min: 100, max: 800 }),
882
- });
883
- }
884
- }
885
- });
886
- }
887
-
888
- // HOOK 10: TRANSACTION-COUNT MAGIC NUMBER (no flags)
889
- // Sweet 6-10 transactions/user → +40% on investment_made amount
890
- // (engaged transactor compounds wealth). Over 11+ → drop 20% of
891
- // premium-upgraded events (already engaged; less upgrade pressure).
892
- const txnCount = userEvents.filter(e => e.event === "transaction completed").length;
893
- if (txnCount >= 6 && txnCount <= 10) {
894
- userEvents.forEach(e => {
895
- if (e.event === "investment made" && typeof e.amount === "number") {
896
- e.amount = Math.round(e.amount * 1.4);
897
- }
898
- });
899
- } else if (txnCount >= 11) {
900
- // Drop 20% of premium-upgraded events for heavy transactors
901
- for (let i = userEvents.length - 1; i >= 0; i--) {
902
- if (userEvents[i].event === "premium upgraded" && chance.bool({ likelihood: 20 })) {
903
- userEvents.splice(i, 1);
904
- }
905
- }
906
- }
907
- }
908
-
938
+ hook(record, type, meta) {
939
+ if (type === "user") return handleUserHooks(record);
940
+ if (type === "event") return handleEventHooks(record);
941
+ if (type === "everything") return handleEverythingHooks(record, meta);
909
942
  return record;
910
943
  }
911
944
  };