@ak--47/dungeon-master 1.4.5 → 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 (78) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +158 -0
  2. package/.claude/skills/create-dungeon/SKILL.md +464 -0
  3. package/.claude/skills/verify-dungeon/SKILL.md +157 -0
  4. package/.claude/skills/verify-dungeon/references/counting-semantics.md +161 -0
  5. package/.claude/skills/verify-dungeon/references/report-format.md +216 -0
  6. package/.claude/skills/verify-dungeon/references/sql-recipes.md +857 -0
  7. package/.claude/skills/write-hooks/SKILL.md +468 -0
  8. package/CHANGELOG.md +182 -0
  9. package/HOOKS.md +1256 -597
  10. package/README.md +140 -5
  11. package/dungeons/technical/ad-spend.js +41 -49
  12. package/dungeons/technical/anonymous-users.js +38 -36
  13. package/dungeons/technical/array-of-object-lookup.js +136 -153
  14. package/dungeons/technical/datagen-v15-verify.js +87 -0
  15. package/dungeons/technical/experiments.js +42 -40
  16. package/dungeons/technical/foobar.js +114 -118
  17. package/dungeons/technical/group-analytics.js +42 -40
  18. package/dungeons/technical/hook-helpers-verify.js +69 -50
  19. package/dungeons/technical/identity-model-verify.js +22 -12
  20. package/dungeons/technical/mirror-strategies.js +37 -39
  21. package/dungeons/technical/nested-objects.js +119 -118
  22. package/dungeons/technical/pattern-aggregate-by-bin.js +21 -8
  23. package/dungeons/technical/pattern-attributed-by-source.js +23 -9
  24. package/dungeons/technical/pattern-frequency-by-frequency.js +21 -8
  25. package/dungeons/technical/pattern-funnel-frequency.js +30 -15
  26. package/dungeons/technical/pattern-ttc-by-segment.js +21 -8
  27. package/dungeons/technical/retention-cadence.js +115 -112
  28. package/dungeons/technical/sanity.js +86 -80
  29. package/dungeons/technical/scale-test.js +34 -38
  30. package/dungeons/technical/scd.js +111 -128
  31. package/dungeons/technical/simple.js +134 -141
  32. package/dungeons/technical/simplest.js +111 -65
  33. package/dungeons/technical/text-generation.js +110 -146
  34. package/dungeons/vertical/ai-platform.js +300 -333
  35. package/dungeons/vertical/community.js +290 -255
  36. package/dungeons/vertical/crypto.js +400 -391
  37. package/dungeons/vertical/dating.js +421 -375
  38. package/dungeons/vertical/devtools.js +346 -298
  39. package/dungeons/vertical/ecommerce.js +322 -394
  40. package/dungeons/vertical/education.js +380 -325
  41. package/dungeons/vertical/fintech.js +371 -325
  42. package/dungeons/vertical/fitness.js +345 -291
  43. package/dungeons/vertical/food-delivery.js +352 -307
  44. package/dungeons/vertical/gaming.js +490 -444
  45. package/dungeons/vertical/healthcare.js +311 -262
  46. package/dungeons/vertical/insurance-application.js +437 -409
  47. package/dungeons/vertical/logistics.js +278 -252
  48. package/dungeons/vertical/marketplace.js +340 -323
  49. package/dungeons/vertical/media.js +390 -335
  50. package/dungeons/vertical/real-estate.js +402 -347
  51. package/dungeons/vertical/sass.js +331 -333
  52. package/dungeons/vertical/social.js +377 -316
  53. package/dungeons/vertical/travel.js +302 -295
  54. package/index.js +64 -7
  55. package/lib/core/config-validator.js +378 -17
  56. package/lib/core/dungeon-loader.js +2 -5
  57. package/lib/generators/events.js +12 -13
  58. package/lib/generators/funnels.js +76 -2
  59. package/lib/hook-helpers/index.js +1 -0
  60. package/lib/hook-helpers/inject.js +95 -0
  61. package/lib/orchestrators/mixpanel-sender.js +7 -0
  62. package/lib/orchestrators/user-loop.js +598 -48
  63. package/lib/templates/defaults.js +59 -59
  64. package/lib/templates/macro-presets.js +53 -11
  65. package/lib/utils/dataset-context.js +103 -0
  66. package/lib/utils/retention-curve.js +140 -0
  67. package/lib/utils/utils.js +157 -109
  68. package/lib/verify/counting.js +360 -0
  69. package/lib/verify/emulate-breakdown.js +531 -108
  70. package/lib/verify/funnel-engine.js +539 -0
  71. package/lib/verify/identity.js +78 -0
  72. package/lib/verify/index.js +20 -0
  73. package/lib/verify/schema-validator.js +3 -1
  74. package/lib/verify/verify-dungeon.js +58 -0
  75. package/package.json +14 -3
  76. package/scripts/run-dungeon.mjs +12 -1
  77. package/types.d.ts +353 -4
  78. package/scripts/smoke-test-all.mjs +0 -162
@@ -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 paidnotification opened
43
- * - Financial planning: budget created budget alertsavings goal
44
- * - Investment: balance checked investment madereward redeemed
45
- * - Support: support contacted card lockeddispute filed
46
- * - Lending: loan applied loan approvedpremium 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 checkedtransaction completed (80%)
32
+ * - Transfers: app sessiontransfer sentnotification opened (50%)
33
+ * - Bill Payment: app sessionbill paidnotification opened (60%)
34
+ * - Financial Planning: budget createdbudget alertsavings goal set (40%)
35
+ * - Investment: balance checkedinvestment madereward 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
 
@@ -463,6 +734,7 @@ const config = {
463
734
  {
464
735
  event: "app session",
465
736
  weight: 20,
737
+ isStrictEvent: false,
466
738
  properties: {
467
739
  "session_duration_sec": u.weighNumRange(10, 600, 0.3, 60),
468
740
  "pages_viewed": u.weighNumRange(1, 15, 0.5, 3),
@@ -471,6 +743,7 @@ const config = {
471
743
  {
472
744
  event: "balance checked",
473
745
  weight: 15,
746
+ isStrictEvent: false,
474
747
  properties: {
475
748
  "account_balance": u.weighNumRange(0, 50000, 0.8, 2500),
476
749
  "account_type": ["checking", "savings", "investment"],
@@ -479,6 +752,7 @@ const config = {
479
752
  {
480
753
  event: "transaction completed",
481
754
  weight: 18,
755
+ isStrictEvent: false,
482
756
  properties: {
483
757
  "transaction_type": ["purchase", "atm", "direct_deposit", "refund"],
484
758
  "amount": u.weighNumRange(1, 5000, 0.3, 50),
@@ -489,6 +763,7 @@ const config = {
489
763
  {
490
764
  event: "transfer sent",
491
765
  weight: 8,
766
+ isStrictEvent: false,
492
767
  properties: {
493
768
  "transfer_type": ["internal", "external", "p2p", "wire"],
494
769
  "amount": u.weighNumRange(10, 10000, 0.3, 200),
@@ -498,6 +773,7 @@ const config = {
498
773
  {
499
774
  event: "bill paid",
500
775
  weight: 6,
776
+ isStrictEvent: false,
501
777
  properties: {
502
778
  "bill_type": ["rent", "utilities", "phone", "insurance", "subscription", "loan_payment"],
503
779
  "amount": u.weighNumRange(20, 3000, 0.5, 150),
@@ -516,6 +792,7 @@ const config = {
516
792
  {
517
793
  event: "budget created",
518
794
  weight: 3,
795
+ isStrictEvent: false,
519
796
  properties: {
520
797
  "category": ["food", "transport", "entertainment", "shopping", "bills", "savings"],
521
798
  "monthly_limit": u.weighNumRange(50, 2000, 0.5, 300),
@@ -532,6 +809,7 @@ const config = {
532
809
  {
533
810
  event: "savings goal set",
534
811
  weight: 3,
812
+ isStrictEvent: false,
535
813
  properties: {
536
814
  "goal_type": ["emergency", "vacation", "car", "home", "education", "retirement"],
537
815
  "target_amount": u.weighNumRange(500, 50000, 0.3, 5000),
@@ -541,6 +819,7 @@ const config = {
541
819
  {
542
820
  event: "investment made",
543
821
  weight: 4,
822
+ isStrictEvent: false,
544
823
  properties: {
545
824
  "investment_type": ["stocks", "etf", "crypto", "bonds", "mutual_fund"],
546
825
  "amount": u.weighNumRange(10, 10000, 0.3, 250),
@@ -550,6 +829,7 @@ const config = {
550
829
  {
551
830
  event: "card locked",
552
831
  weight: 2,
832
+ isStrictEvent: false,
553
833
  properties: {
554
834
  "reason": ["lost", "stolen", "suspicious_activity", "travel"],
555
835
  }
@@ -557,6 +837,7 @@ const config = {
557
837
  {
558
838
  event: "dispute filed",
559
839
  weight: 2,
840
+ isStrictEvent: false,
560
841
  properties: {
561
842
  "dispute_amount": u.weighNumRange(10, 2000, 0.5, 100),
562
843
  "reason": ["unauthorized", "duplicate", "not_received", "damaged", "wrong_amount"],
@@ -582,6 +863,7 @@ const config = {
582
863
  {
583
864
  event: "premium upgraded",
584
865
  weight: 2,
866
+ isStrictEvent: false,
585
867
  properties: {
586
868
  "old_tier": ["basic", "plus", "premium"],
587
869
  "new_tier": ["plus", "premium", "premium"],
@@ -591,6 +873,7 @@ const config = {
591
873
  {
592
874
  event: "support contacted",
593
875
  weight: 3,
876
+ isStrictEvent: false,
594
877
  properties: {
595
878
  "channel": ["chat", "phone", "email", "in_app"],
596
879
  "issue_type": ["transaction", "account", "card", "transfer", "technical"],
@@ -608,6 +891,7 @@ const config = {
608
891
  {
609
892
  event: "reward redeemed",
610
893
  weight: 4,
894
+ isStrictEvent: false,
611
895
  properties: {
612
896
  "reward_type": ["cashback", "points", "discount", "partner_offer"],
613
897
  "value": u.weighNumRange(1, 100, 0.5, 10),
@@ -651,248 +935,10 @@ const config = {
651
935
 
652
936
  lookupTables: [],
653
937
 
654
- /**
655
- * ARCHITECTED ANALYTICS HOOKS
656
- *
657
- * This hook function creates 8 deliberate patterns in the data:
658
- *
659
- * 1. PERSONAL VS BUSINESS: Business accounts get employee_count, revenue; personal get age_range, life_stage
660
- * 2. PAYDAY PATTERNS: Transactions spike on 1st/15th with bigger deposits and post-payday spending (everything hook — runs after sessionization)
661
- * 3. FRAUD DETECTION: 3% of users experience a fraud burst (rapid high-value txns -> card lock -> dispute -> support)
662
- * 4. LOW BALANCE CHURN: Users with chronic low balances (<$15K) lose 50% of activity after day 30
663
- * 5. BUDGET DISCIPLINE: Budget creators save 2x more and invest 1.5x more
664
- * 6. AUTO-PAY LOYALTY: Auto-pay users never miss bills; manual payers miss 30%
665
- * 7. PREMIUM TIER VALUE: Premium users get 3x rewards; Plus users get 1.5x; Premium investors get 2x returns
666
- * 8. MONTH-END ANXIETY: Last 3 days of month see 40% longer sessions and 30% lower balances (everything hook — runs after sessionization)
667
- */
668
- hook: function (record, type, meta) {
669
- // HOOK 1: PERSONAL VS BUSINESS ACCOUNTS (user) — role-based attrs.
670
- if (type === "user") {
671
- const isBusiness = chance.bool({ likelihood: 20 });
672
- if (isBusiness) {
673
- record.account_segment = "business";
674
- record.employee_count = chance.integer({ min: 5, max: 500 });
675
- record.annual_revenue = chance.integer({ min: 100000, max: 10000000 });
676
- record.industry = chance.pickone(["tech", "retail", "food", "services", "healthcare"]);
677
- } else {
678
- record.account_segment = "personal";
679
- record.age_range = `${chance.pickone([18, 25, 35, 45, 55])}-${chance.pickone([24, 34, 44, 54, 65])}`;
680
- record.life_stage = chance.pickone(["student", "early_career", "established", "pre_retirement", "retired"]);
681
- }
682
- }
683
-
684
- // HOOK 6: AUTO-PAY LOYALTY (event) — manual bill-paid events have
685
- // 30% chance of becoming "bill payment missed". Mutates event name.
686
- if (type === "event") {
687
- if (record.event === "bill paid" && record.auto_pay !== true && chance.bool({ likelihood: 30 })) {
688
- record.event = "bill payment missed";
689
- }
690
- }
691
-
692
- if (type === "everything") {
693
- const datasetStart = dayjs.unix(meta.datasetStart);
694
- const userEvents = record;
695
- const profile = meta.profile;
696
-
697
- userEvents.forEach(e => {
698
- e.account_tier = profile.account_tier;
699
- e.Platform = profile.Platform;
700
- });
701
-
702
- // HOOK 9 (T2C): ONBOARDING TIME-TO-CONVERT (everything)
703
- // Premium tier completes Onboarding funnel 1.5x faster (factor 0.67);
704
- // Basic users 1.33x slower (factor 1.33). Finds the first occurrence
705
- // of the onboarding sequence in the user's events and scales the gaps.
706
- {
707
- const ttcFactor = (
708
- profile.account_tier === "premium" ? 0.67 :
709
- profile.account_tier === "basic" ? 1.33 :
710
- 1.0
711
- );
712
- if (ttcFactor !== 1.0) {
713
- const onboardingSeq = findFirstSequence(
714
- userEvents,
715
- ["account opened", "app session", "balance checked"],
716
- 60 * 24 * 30 // 30-day max gap between steps
717
- );
718
- if (onboardingSeq) {
719
- scaleFunnelTTC(onboardingSeq, ttcFactor);
720
- }
721
- }
722
- }
723
-
724
- // HOOK 1B: PERSONAL VS BUSINESS — business segment txns 4x larger
725
- // (per Report 2 in JSDoc: business ~ $200, personal ~ $50).
726
- if (profile.account_segment === "business") {
727
- userEvents.forEach(e => {
728
- if (e.event === "transaction completed" && typeof e.amount === "number") {
729
- e.amount = Math.floor(e.amount * 4);
730
- }
731
- });
732
- }
733
-
734
- // HOOK 2: PAYDAY PATTERNS — 1st & 15th: direct_deposit amount 3x.
735
- // Days 1-3 and 15-17: 60% of transfers get amount 2x. No flag.
736
- for (const e of userEvents) {
737
- const dayOfMonth = new Date(e.time).getUTCDate();
738
- if (e.event === "transaction completed" && e.transaction_type === "direct_deposit") {
739
- if (dayOfMonth === 1 || dayOfMonth === 15) {
740
- e.amount = Math.floor((e.amount || 50) * 3);
741
- }
742
- }
743
- if (e.event === "transfer sent") {
744
- const isPaydayWindow = (dayOfMonth >= 1 && dayOfMonth <= 3) || (dayOfMonth >= 15 && dayOfMonth <= 17);
745
- if (isPaydayWindow && chance.bool({ likelihood: 60 })) {
746
- e.amount = Math.floor((e.amount || 200) * 2.0);
747
- }
748
- }
749
- }
750
-
751
- // HOOK 8: MONTH-END ANXIETY — days >= 28: app_session duration
752
- // 1.4x; balance_checked account_balance 0.7x. Mutates raw props.
753
- for (const e of userEvents) {
754
- const dayOfMonth = new Date(e.time).getUTCDate();
755
- if (dayOfMonth >= 28) {
756
- if (e.event === "app session") {
757
- e.session_duration_sec = Math.floor((e.session_duration_sec || 60) * 1.4);
758
- }
759
- if (e.event === "balance checked") {
760
- e.account_balance = Math.floor((e.account_balance || 2500) * 0.7);
761
- }
762
- }
763
- }
764
-
765
- // HOOK 7: PREMIUM TIER VALUE — Premium 3x reward value + 2x
766
- // investment-sell amount; Plus 1.5x reward value. Reads tier
767
- // from profile. No flag.
768
- const tier = profile.account_tier;
769
- userEvents.forEach(e => {
770
- if (e.event === "reward redeemed") {
771
- if (tier === "premium") e.value = Math.floor((e.value || 10) * 3);
772
- else if (tier === "plus") e.value = Math.floor((e.value || 10) * 1.5);
773
- }
774
- if (e.event === "investment made" && e.action === "sell" && tier === "premium") {
775
- e.amount = Math.floor((e.amount || 250) * 2);
776
- }
777
- });
778
-
779
- // HOOK 3: FRAUD DETECTION — 3% of users get fraud burst
780
- // (3-5 rapid high-value transactions + card locked + dispute +
781
- // support contacted) at timeline midpoint. No flag — discover
782
- // via cohort builder on users with card-locked + dispute-filed.
783
- if (chance.bool({ likelihood: 15 }) && userEvents.length >= 2) {
784
- const midIdx = Math.floor(userEvents.length / 2);
785
- const midEvent = userEvents[midIdx];
786
- const midTime = dayjs(midEvent.time);
787
- const distinctId = midEvent.user_id;
788
- const burstCount = chance.integer({ min: 3, max: 5 });
789
- const fraudEvents = [];
790
- const txnTemplate = userEvents.find(e => e.event === "transaction completed");
791
- const cardTemplate = userEvents.find(e => e.event === "card locked");
792
- const disputeTemplate = userEvents.find(e => e.event === "dispute filed");
793
- const supportTemplate = userEvents.find(e => e.event === "support contacted");
794
-
795
- for (let i = 0; i < burstCount; i++) {
796
- if (txnTemplate) {
797
- fraudEvents.push({
798
- ...txnTemplate,
799
- time: midTime.add(i * 10, "minutes").toISOString(),
800
- user_id: distinctId,
801
- transaction_type: "purchase",
802
- amount: chance.integer({ min: 500, max: 3000 }),
803
- merchant_category: chance.pickone(["online", "retail"]),
804
- payment_method: "credit",
805
- });
806
- }
807
- }
808
- if (cardTemplate) fraudEvents.push({
809
- ...cardTemplate,
810
- time: midTime.add(burstCount * 10 + 5, "minutes").toISOString(),
811
- user_id: distinctId,
812
- reason: "suspicious_activity",
813
- });
814
- if (disputeTemplate) fraudEvents.push({
815
- ...disputeTemplate,
816
- time: midTime.add(burstCount * 10 + 30, "minutes").toISOString(),
817
- user_id: distinctId,
818
- dispute_amount: chance.integer({ min: 500, max: 3000 }),
819
- reason: "unauthorized",
820
- });
821
- if (supportTemplate) fraudEvents.push({
822
- ...supportTemplate,
823
- time: midTime.add(burstCount * 10 + 45, "minutes").toISOString(),
824
- user_id: distinctId,
825
- channel: "phone",
826
- issue_type: "card",
827
- resolved: true,
828
- });
829
- userEvents.splice(midIdx + 1, 0, ...fraudEvents);
830
- }
831
-
832
- // HOOK 4: LOW BALANCE CHURN — users with 3+ balance checks
833
- // under $15K lose 50% of post-day-30 events. No flag.
834
- const lowBalanceChecks = userEvents.filter(e =>
835
- e.event === "balance checked" && (e.account_balance || 0) < 15000
836
- ).length;
837
- if (lowBalanceChecks >= 3) {
838
- const day30 = datasetStart.add(30, "days");
839
- for (let i = userEvents.length - 1; i >= 0; i--) {
840
- if (dayjs(userEvents[i].time).isAfter(day30) && chance.bool({ likelihood: 50 })) {
841
- userEvents.splice(i, 1);
842
- }
843
- }
844
- }
845
-
846
- // HOOK 5: BUDGET DISCIPLINE — users with any budget-created event
847
- // get savings 2x, investment amounts 1.5x, and extra cloned
848
- // savings-goal events. No flag.
849
- const hasBudget = userEvents.some(e => e.event === "budget created");
850
- if (hasBudget) {
851
- userEvents.forEach((event, idx) => {
852
- const eventTime = dayjs(event.time);
853
- if (event.event === "savings goal set") {
854
- event.monthly_contribution = Math.floor((event.monthly_contribution || 200) * 2);
855
- }
856
- if (event.event === "investment made") {
857
- event.amount = Math.floor((event.amount || 250) * 1.5);
858
- }
859
- if (event.event === "budget created" && chance.bool({ likelihood: 50 })) {
860
- const savingsTemplate = userEvents.find(e => e.event === "savings goal set");
861
- if (savingsTemplate) {
862
- userEvents.splice(idx + 1, 0, {
863
- ...savingsTemplate,
864
- time: eventTime.add(chance.integer({ min: 1, max: 7 }), "days").toISOString(),
865
- user_id: event.user_id,
866
- goal_type: chance.pickone(["emergency", "vacation", "car", "home"]),
867
- target_amount: chance.integer({ min: 1000, max: 20000 }),
868
- monthly_contribution: chance.integer({ min: 100, max: 800 }),
869
- });
870
- }
871
- }
872
- });
873
- }
874
-
875
- // HOOK 10: TRANSACTION-COUNT MAGIC NUMBER (no flags)
876
- // Sweet 6-10 transactions/user → +40% on investment_made amount
877
- // (engaged transactor compounds wealth). Over 11+ → drop 20% of
878
- // premium-upgraded events (already engaged; less upgrade pressure).
879
- const txnCount = userEvents.filter(e => e.event === "transaction completed").length;
880
- if (txnCount >= 6 && txnCount <= 10) {
881
- userEvents.forEach(e => {
882
- if (e.event === "investment made" && typeof e.amount === "number") {
883
- e.amount = Math.round(e.amount * 1.4);
884
- }
885
- });
886
- } else if (txnCount >= 11) {
887
- // Drop 20% of premium-upgraded events for heavy transactors
888
- for (let i = userEvents.length - 1; i >= 0; i--) {
889
- if (userEvents[i].event === "premium upgraded" && chance.bool({ likelihood: 20 })) {
890
- userEvents.splice(i, 1);
891
- }
892
- }
893
- }
894
- }
895
-
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);
896
942
  return record;
897
943
  }
898
944
  };