@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,69 +1,44 @@
1
- // ── TWEAK THESE ──
2
- const SEED = "dm4-insurance";
3
- const num_days = 120;
4
- const num_users = 15_000;
5
- const avg_events_per_user_per_day = 1.2;
6
- let token = "your-mixpanel-token";
7
-
8
- // ── env overrides ──
9
- if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
10
-
1
+ // ── IMPORTS ──
11
2
  import dayjs from "dayjs";
12
3
  import utc from "dayjs/plugin/utc.js";
4
+ dayjs.extend(utc);
13
5
  import "dotenv/config";
14
6
  import * as u from "../../lib/utils/utils.js";
15
-
16
- dayjs.extend(utc);
17
- const chance = u.initChance(SEED);
18
-
19
7
  /** @typedef {import("../../types").Dungeon} Config */
20
8
 
9
+ // ── OVERVIEW ──
21
10
  /*
22
- * ===================================================================
23
- * DATASET OVERVIEW
24
- * ===================================================================
25
- *
26
- * SAFEHAVEN INSURANCE Web Application Dungeon
27
- *
28
- * SafeHaven Insurance is a modern insurance application where users
29
- * browse coverage options, request quotes, complete multi-step
30
- * applications, manage policies, file claims, and make premium payments.
31
- *
32
- * - 5,000 users over 100 days
33
- * - 600,000 events across 18 event types
34
- * - 10 hooks (version stamping, ticket volume, conversion boost, magic number,
35
- * TTC scaling, claims experiment, risk approval, doc upload retention,
36
- * renewal spike, claim-to-premium)
37
- * - 5 funnels (onboarding, application, approval, claims, renewal)
38
- * - 5 insurance types as super property (auto, home, life, health, renters)
39
- * - Deterministic app versioning (2.10 2.11 2.12 2.13)
40
- * - Platforms: web, iOS, Android
41
- *
42
- * CORE LOOP:
43
- * Users create an account, browse insurance products, and request quotes.
44
- * They start multi-step applications (personal info, coverage selection,
45
- * document upload) and submit for approval. Once approved, they activate
46
- * a policy, make premium payments, and manage renewals. If something
47
- * goes wrong, they file claims and create support tickets.
48
- *
49
- * KEY DATA STORY — VERSION 2.13 RELEASE:
50
- * The app has gone through versions 2.10 → 2.11 → 2.12 → 2.13.
51
- * Version 2.13 was released 10 days ago and fixed critical UX issues.
52
- * Two effects are visible: support ticket volume drops immediately,
53
- * and application funnel conversion improves significantly.
11
+ * NAME: SafeHaven Insurance
12
+ * APP: Modern insurance web application. Users browse coverage options,
13
+ * request quotes, complete multi-step applications, manage policies,
14
+ * file claims, and make premium payments. Spans auto / home / life /
15
+ * health / renters insurance types across web, iOS, and Android.
16
+ * SCALE: 15,000 users, ~1.8M events, 121 days (2026-01-01 → 2026-05-01)
17
+ * CORE LOOP: account created quote requested application submitted → policy activated → payment made
18
+ *
19
+ * EVENTS (18):
20
+ * page viewed (10) > application step completed (6) > quote requested (5)
21
+ * > payment made (5) > quote received (4) > application started (4)
22
+ * > claim status checked (4) > support ticket created (4) > coverage reviewed (4)
23
+ * > document uploaded (3) > application submitted (3) > support ticket resolved (3)
24
+ * > application approved (2) > policy activated (2) > claim filed (2)
25
+ * > profile updated (2) > renewal completed (2) > account created (1)
26
+ *
27
+ * FUNNELS (5):
28
+ * - Onboarding: account created page viewedquote requested (85%)
29
+ * - Application Completion: application started → step completed → document uploaded → application submitted (60%)
30
+ * - Application Approval: application submitted → application approved → policy activated (70%)
31
+ * - Claims Process: claim filed → claim status checked → support ticket created (50%, A/B experiment)
32
+ * - Policy Renewal: coverage reviewed payment made renewal completed (65%)
33
+ *
34
+ * USER PROPS: Platform, insurance_type, app_version, age_range, risk_profile, policy_count, lifetime_premium, preferred_contact
35
+ * SUPER PROPS: Platform, insurance_type, app_version
36
+ * SCD PROPS: policy_status (pending/active/lapsed/renewed, monthly fuzzy, max 8)
37
+ * GROUPS: none
54
38
  */
55
39
 
40
+ // ── HOOK STORIES ──
56
41
  /*
57
- * ===================================================================
58
- * ANALYTICS HOOKS (10 hooks)
59
- *
60
- * Hooks 1-5: Version stamping, support ticket volume drop, application
61
- * conversion boost, step-count magic number, application TTC by account type.
62
- *
63
- * Hooks 6-10: Claims experiment, risk profile approval gating, document
64
- * upload retention, end-of-quarter renewal spike, claim-to-premium increase.
65
- * ===================================================================
66
- *
67
42
  * NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable
68
43
  * via raw-prop breakdowns (date, app_version, issue_category) or
69
44
  * behavioral cohorts.
@@ -339,34 +314,406 @@ const chance = u.initChance(SEED);
339
314
  * Claim-to-Premium | premium after claim | 1x | 2.0x | +100%
340
315
  */
341
316
 
342
- // ── H10 closure state: tracks users who filed a claim ──
317
+ // ── SCALE ──
318
+ const SEED = "dm4-insurance";
319
+ const NUM_USERS = 15_000;
320
+ const DATASET_START = "2026-01-01T00:00:00Z";
321
+ const DATASET_END = "2026-05-01T23:59:59Z";
322
+ const EVENTS_PER_DAY = 1.2;
323
+ const token = process.env.MP_TOKEN || "your-mixpanel-token";
324
+
325
+ const chance = u.initChance(SEED);
326
+
327
+ // ── KNOBS (tweak these to reshape stories) ──
328
+ // H1: Version stamping cutoffs (days from datasetStart / before datasetEnd)
329
+ const V211_DAY = 30;
330
+ const V212_DAY = 60;
331
+ const V213_DAYS_BEFORE_END = 10;
332
+
333
+ // H2: Support ticket volume
334
+ const TICKET_INJECT_MIN = 2;
335
+ const TICKET_INJECT_MAX = 3;
336
+ const TICKET_REMOVAL_BASE_PCT = 30;
337
+ const TICKET_REMOVAL_PER_DAY_PCT = 5.5;
338
+ const TICKET_REMOVAL_CAP_PCT = 85;
339
+
340
+ // H3: Application conversion boost
341
+ const PRE_V213_APPROVAL_DROP_LIKELIHOOD = 95;
342
+
343
+ // H4: Application-step magic number
344
+ const STEP_SWEET_MIN = 8;
345
+ const STEP_SWEET_MAX = 14;
346
+ const STEP_OVER_THRESHOLD = 15;
347
+ const STEP_PREMIUM_BOOST = 1.35;
348
+ const STEP_OVER_DROP_LIKELIHOOD = 40;
349
+
350
+ // H5: Application TTC target offsets (hours from first application started)
351
+ const TTC_BUSINESS_HOURS = 36;
352
+ const TTC_INDIVIDUAL_HOURS = 48;
353
+ const TTC_FAMILY_HOURS = 63;
354
+
355
+ // H7: Risk profile approval multipliers
356
+ const RISK_LOW_CONV_MULT = 1.8;
357
+ const RISK_HIGH_CONV_MULT = 0.3;
358
+ const RISK_CONV_CAP = 95;
359
+
360
+ // H8: Document upload retention
361
+ const DOC_RETENTION_MIN = 3;
362
+ const DOC_RETENTION_WINDOW_DAYS = 14;
363
+ const DOC_RETENTION_CUTOFF_DAYS = 30;
364
+ const DOC_RETENTION_DROP_LIKELIHOOD = 75;
365
+
366
+ // H9: End-of-quarter renewal spike (days from datasetStart)
367
+ const RENEWAL_SPIKE_START_DAY = 85;
368
+ const RENEWAL_SPIKE_END_DAY = 95;
369
+ const RENEWAL_CLONE_COUNT = 2; // 2 extra clones per renewal → 3x volume
370
+ const COVERAGE_CLONE_COUNT = 1; // 1 extra clone per coverage review → 2x volume
371
+
372
+ // H10: Claim-to-premium increase
373
+ const POST_CLAIM_PREMIUM_MULT = 2.0;
374
+
375
+ // ── HOOK STATE ──
376
+ // H10 closure: tracks users who filed a claim (one-shot consumption)
343
377
  const claimFilers = new Map();
344
378
 
379
+ // ── HELPER FUNCTIONS ──
380
+ function handleFunnelPreHooks(record, meta) {
381
+ // H7: RISK PROFILE AFFECTS APPROVAL — low-risk 1.8x, high-risk 0.3x conversion
382
+ const isApprovalFunnel = meta.funnel?.sequence?.includes("application approved");
383
+ if (isApprovalFunnel) {
384
+ const risk = meta.profile?.risk_profile;
385
+ if (risk === "low") record.conversionRate = Math.min(RISK_CONV_CAP, Math.round(record.conversionRate * RISK_LOW_CONV_MULT));
386
+ else if (risk === "high") record.conversionRate = Math.round(record.conversionRate * RISK_HIGH_CONV_MULT);
387
+ }
388
+ return record;
389
+ }
390
+
391
+ function handleEventHooks(record) {
392
+ // H10: CLAIM-TO-PREMIUM INCREASE — premium doubled on first payment after claim
393
+ if (record.event === "claim filed") {
394
+ claimFilers.set(record.user_id, true);
395
+ }
396
+ if (record.event === "payment made" && claimFilers.has(record.user_id)) {
397
+ record.premium_amount = Math.round((record.premium_amount || 500) * POST_CLAIM_PREMIUM_MULT);
398
+ claimFilers.delete(record.user_id);
399
+ }
400
+ return record;
401
+ }
402
+
403
+ function handleEverythingHooks(record, meta) {
404
+ const datasetStart = dayjs.unix(meta.datasetStart);
405
+ const datasetEnd = dayjs.unix(meta.datasetEnd);
406
+ const V211_DATE = datasetStart.add(V211_DAY, "days");
407
+ const V212_DATE = datasetStart.add(V212_DAY, "days");
408
+ const V213_DATE = datasetEnd.subtract(V213_DAYS_BEFORE_END, "days");
409
+ const userEvents = record;
410
+ if (userEvents.length === 0) return record;
411
+
412
+ // Stamp superProps from profile for consistency
413
+ const profile = meta.profile;
414
+ userEvents.forEach(e => {
415
+ e.Platform = profile.Platform;
416
+ e.insurance_type = profile.insurance_type;
417
+ e.app_version = profile.app_version;
418
+ });
419
+
420
+ // Find a user_id from any existing event
421
+ const userId =
422
+ userEvents.find((e) => e.user_id)?.user_id ||
423
+ userEvents[0]?.device_id;
424
+
425
+ // ─── Hook #5: APPLICATION COMPLETION TIME-TO-CONVERT ───
426
+ // Runs FIRST: adjusts timestamps before version stamping.
427
+ // Business accounts complete the application funnel faster (0.74x),
428
+ // family accounts slower (1.3x). Individual stays at baseline.
429
+ // Assigns account_type deterministically per user via djb2 hash
430
+ // (independent of engine RNG). Places each "application approved"
431
+ // event at a fixed offset from the first "application started".
432
+ {
433
+ // djb2 hash of userId → deterministic cohort
434
+ let h = 5381;
435
+ for (let i = 0; i < userId.length; i++) {
436
+ h = ((h << 5) + h + userId.charCodeAt(i)) | 0;
437
+ }
438
+ const acctTypes = ["individual", "family", "business"];
439
+ const userAccountType = acctTypes[((h % 3) + 3) % 3];
440
+ // Stamp account created events with the deterministic type
441
+ for (const evt of userEvents) {
442
+ if (evt.event === "account created") {
443
+ evt.account_type = userAccountType;
444
+ }
445
+ }
446
+ userEvents.sort((a, b) => new Date(a.time) - new Date(b.time));
447
+ // Collect all "application started" times
448
+ const startedTimes = [];
449
+ for (const evt of userEvents) {
450
+ if (evt.event === "application started") {
451
+ startedTimes.push(dayjs(evt.time).valueOf());
452
+ }
453
+ }
454
+ if (startedTimes.length > 0) {
455
+ const firstStartTime = startedTimes[0];
456
+ // Target hours: biz=36, indiv=48, family=63
457
+ // Ratios: biz/indiv≈0.75, family/indiv≈1.31
458
+ const targetHours = (
459
+ userAccountType === "business" ? TTC_BUSINESS_HOURS :
460
+ userAccountType === "family" ? TTC_FAMILY_HOURS :
461
+ TTC_INDIVIDUAL_HOURS
462
+ );
463
+ const targetMs = targetHours * 3600000;
464
+ for (const evt of userEvents) {
465
+ if (evt.event !== "application approved") continue;
466
+ const evtTime = dayjs(evt.time).valueOf();
467
+ const origGap = evtTime - firstStartTime;
468
+ // Small jitter from original gap (mod 4h) for variance
469
+ const jitter = origGap > 0 ? (origGap % (4 * 3600000)) : 0;
470
+ evt.time = dayjs(firstStartTime + targetMs + jitter).toISOString();
471
+ }
472
+ }
473
+ }
474
+
475
+ // ─── Hook #2: SUPPORT TICKET VOLUME ───
476
+ // PRE-V2.13: Inject 2-3 extra support tickets with bug-related categories
477
+ const preV213Tickets = userEvents.filter(
478
+ (e) =>
479
+ e.event === "support ticket created" &&
480
+ dayjs(e.time).isBefore(V213_DATE)
481
+ );
482
+
483
+ if (preV213Tickets.length > 0) {
484
+ const extraCount = chance.integer({ min: TICKET_INJECT_MIN, max: TICKET_INJECT_MAX });
485
+ const bugCategories = [
486
+ "form_crash",
487
+ "login_error",
488
+ "page_timeout",
489
+ "payment_failure",
490
+ ];
491
+
492
+ for (let i = 0; i < extraCount; i++) {
493
+ // Pick a random pre-v2.13 ticket to base timing on
494
+ const sourceTicket = chance.pickone(preV213Tickets);
495
+ const sourceTime = dayjs(sourceTicket.time);
496
+ // Offset by a few hours to days
497
+ const offsetHours = chance.integer({ min: 1, max: 72 });
498
+ let newTime = sourceTime.add(offsetHours, "hours");
499
+ // Ensure it stays before v2.13
500
+ if (newTime.isAfter(V213_DATE)) {
501
+ newTime = V213_DATE.subtract(
502
+ chance.integer({ min: 1, max: 48 }),
503
+ "hours"
504
+ );
505
+ }
506
+
507
+ // Compute app_version for injected event
508
+ let injectedVersion;
509
+ if (newTime.isBefore(V211_DATE)) {
510
+ injectedVersion = "2.10";
511
+ } else if (newTime.isBefore(V212_DATE)) {
512
+ injectedVersion = "2.11";
513
+ } else {
514
+ injectedVersion = "2.12"; // always pre-v2.13
515
+ }
516
+
517
+ userEvents.push({
518
+ ...sourceTicket,
519
+ time: newTime.toISOString(),
520
+ user_id: userId,
521
+ app_version: injectedVersion,
522
+ issue_category: chance.pickone(bugCategories),
523
+ priority: chance.pickone(["medium", "high", "high"]),
524
+ channel: chance.pickone([
525
+ "chat",
526
+ "phone",
527
+ "email",
528
+ "web_form",
529
+ ]),
530
+ });
531
+ }
532
+ }
533
+
534
+ // POST-V2.13: Remove support tickets with increasing probability
535
+ // Day 1 after release: ~30% removal
536
+ // Day 5: ~60% removal
537
+ // Day 10: ~85% removal
538
+ for (let i = userEvents.length - 1; i >= 0; i--) {
539
+ const evt = userEvents[i];
540
+ if (evt.event === "support ticket created") {
541
+ const evtTime = dayjs(evt.time);
542
+ if (evtTime.isAfter(V213_DATE)) {
543
+ const daysSinceRelease = evtTime.diff(
544
+ V213_DATE,
545
+ "days",
546
+ true
547
+ );
548
+ // Linear ramp: 30% base + 5.5% per day → ~85% at day 10
549
+ const removalLikelihood = Math.min(
550
+ TICKET_REMOVAL_CAP_PCT,
551
+ TICKET_REMOVAL_BASE_PCT + daysSinceRelease * TICKET_REMOVAL_PER_DAY_PCT
552
+ );
553
+ if (chance.bool({ likelihood: removalLikelihood })) {
554
+ userEvents.splice(i, 1);
555
+ }
556
+ }
557
+ }
558
+ }
559
+
560
+ // ─── Hook #3: APPLICATION CONVERSION BOOST ───
561
+ // PRE-V2.13: Remove ALL application approved + policy activated events
562
+ // for ~95% of users (per-user gating, not per-event). This produces
563
+ // visible funnel completion gap pre-v2.13 vs post-v2.13.
564
+ if (chance.bool({ likelihood: PRE_V213_APPROVAL_DROP_LIKELIHOOD })) {
565
+ for (let i = userEvents.length - 1; i >= 0; i--) {
566
+ const evt = userEvents[i];
567
+ if (
568
+ (evt.event === "application approved" ||
569
+ evt.event === "policy activated") &&
570
+ dayjs(evt.time).isBefore(V213_DATE)
571
+ ) {
572
+ userEvents.splice(i, 1);
573
+ }
574
+ }
575
+ }
576
+
577
+ // Hook 4: APPLICATION-STEP MAGIC NUMBER (no flags)
578
+ // Sweet 8-14 application step completed → +35% on approved_premium
579
+ // for application approved events. Over 15+ → drop 40% of
580
+ // application approved events (fraud filter triggers).
581
+ const stepCount = userEvents.filter(e => e.event === "application step completed").length;
582
+ if (stepCount >= STEP_SWEET_MIN && stepCount <= STEP_SWEET_MAX) {
583
+ userEvents.forEach(e => {
584
+ if (e.event === "application approved" && typeof e.approved_premium === "number") {
585
+ e.approved_premium = Math.round(e.approved_premium * STEP_PREMIUM_BOOST);
586
+ }
587
+ });
588
+ } else if (stepCount >= STEP_OVER_THRESHOLD) {
589
+ for (let i = userEvents.length - 1; i >= 0; i--) {
590
+ if (userEvents[i].event === "application approved" && chance.bool({ likelihood: STEP_OVER_DROP_LIKELIHOOD })) {
591
+ userEvents.splice(i, 1);
592
+ }
593
+ }
594
+ }
595
+
596
+ // ─── Hook #8: DOCUMENT UPLOAD RETENTION ───
597
+ // Users with 3+ "document uploaded" in first 14 days retain;
598
+ // others lose 75% of events post-day-30.
599
+ if (meta.userIsBornInDataset) {
600
+ const firstT = userEvents[0]?.time;
601
+ if (firstT) {
602
+ const window14 = dayjs(firstT).add(DOC_RETENTION_WINDOW_DAYS, "days").toISOString();
603
+ const docUploads = userEvents.filter(
604
+ (e) => e.event === "document uploaded" && e.time <= window14
605
+ ).length;
606
+ if (docUploads < DOC_RETENTION_MIN) {
607
+ const cutoff = dayjs(firstT).add(DOC_RETENTION_CUTOFF_DAYS, "days");
608
+ for (let i = userEvents.length - 1; i >= 0; i--) {
609
+ if (
610
+ dayjs(userEvents[i].time).isAfter(cutoff) &&
611
+ chance.bool({ likelihood: DOC_RETENTION_DROP_LIKELIHOOD })
612
+ ) {
613
+ userEvents.splice(i, 1);
614
+ }
615
+ }
616
+ }
617
+ }
618
+ }
619
+
620
+ // ─── Hook #9: END-OF-QUARTER RENEWAL SPIKE ───
621
+ // Days 85-95 get 3x renewal clones + 2x coverage reviewed clones
622
+ {
623
+ const spikeStart = datasetStart.add(RENEWAL_SPIKE_START_DAY, "days");
624
+ const spikeEnd = datasetStart.add(RENEWAL_SPIKE_END_DAY, "days");
625
+ const clones = [];
626
+ userEvents.forEach((e) => {
627
+ const t = dayjs(e.time);
628
+ if (t.isAfter(spikeStart) && t.isBefore(spikeEnd)) {
629
+ if (e.event === "renewal completed") {
630
+ for (let c = 0; c < RENEWAL_CLONE_COUNT; c++) {
631
+ clones.push({
632
+ ...e,
633
+ time: t
634
+ .add(
635
+ chance.integer({ min: 5, max: 240 }),
636
+ "minutes"
637
+ )
638
+ .toISOString(),
639
+ insert_id: chance.guid(),
640
+ });
641
+ }
642
+ }
643
+ if (e.event === "coverage reviewed") {
644
+ for (let c = 0; c < COVERAGE_CLONE_COUNT; c++) {
645
+ clones.push({
646
+ ...e,
647
+ time: t
648
+ .add(
649
+ chance.integer({ min: 5, max: 120 }),
650
+ "minutes"
651
+ )
652
+ .toISOString(),
653
+ insert_id: chance.guid(),
654
+ });
655
+ }
656
+ }
657
+ }
658
+ });
659
+ if (clones.length) userEvents.push(...clones);
660
+ }
661
+
662
+ // ─── Hook #1: VERSION STAMPING ───
663
+ // Runs LAST: stamps app_version based on FINAL timestamps
664
+ // (after Hook #5 TTC scaling has adjusted event times).
665
+ // v2.10 (days 0-30) → v2.11 (30-60) → v2.12 (60-90) → v2.13 (last 10 days)
666
+ for (const evt of userEvents) {
667
+ const eventTime = dayjs(evt.time);
668
+ if (eventTime.isBefore(V211_DATE)) {
669
+ evt.app_version = "2.10";
670
+ } else if (eventTime.isBefore(V212_DATE)) {
671
+ evt.app_version = "2.11";
672
+ } else if (eventTime.isBefore(V213_DATE)) {
673
+ evt.app_version = "2.12";
674
+ } else {
675
+ evt.app_version = "2.13";
676
+ }
677
+ }
678
+
679
+ // Sort events by time after injection/removal/shifting
680
+ userEvents.sort(
681
+ (a, b) => new Date(a.time) - new Date(b.time)
682
+ );
683
+
684
+ return record;
685
+ }
686
+
687
+ // ── CONFIG ──
345
688
  /** @type {Config} */
346
689
  const config = {
347
690
  version: 2,
348
- token,
349
691
  seed: SEED,
350
- datasetStart: "2026-01-01T00:00:00Z",
351
- datasetEnd: "2026-05-01T23:59:59Z",
352
- // numDays: num_days,
353
- avgEventsPerUserPerDay: avg_events_per_user_per_day,
354
- numUsers: num_users,
355
- hasAnonIds: true,
356
- avgDevicePerUser: 2,
357
- hasSessionIds: true,
692
+ datasetStart: DATASET_START,
693
+ datasetEnd: DATASET_END,
694
+ avgEventsPerUserPerDay: EVENTS_PER_DAY,
695
+ numUsers: NUM_USERS,
358
696
  format: "json",
359
697
  gzip: true,
360
- alsoInferFunnels: false,
361
- hasLocation: true,
362
- hasAndroidDevices: true,
363
- hasIOSDevices: true,
364
- hasDesktopDevices: true,
365
- hasBrowser: false,
366
- hasCampaigns: false,
367
- isAnonymous: false,
368
- hasAdSpend: false,
369
- hasAvatar: true,
698
+ credentials: {
699
+ token,
700
+ },
701
+ switches: {
702
+ hasSessionIds: true,
703
+ alsoInferFunnels: false,
704
+ hasLocation: true,
705
+ hasAndroidDevices: true,
706
+ hasIOSDevices: true,
707
+ hasDesktopDevices: true,
708
+ hasBrowser: false,
709
+ hasCampaigns: false,
710
+ isAnonymous: false,
711
+ hasAdSpend: false,
712
+ hasAvatar: true,
713
+ },
714
+ identity: {
715
+ avgDevicePerUser: 2,
716
+ },
370
717
  concurrency: 1,
371
718
  writeToDisk: false,
372
719
 
@@ -381,7 +728,6 @@ const config = {
381
728
  mirrorProps: {},
382
729
  lookupTables: [],
383
730
 
384
- // ── Events ──
385
731
  events: [
386
732
  {
387
733
  event: "account created",
@@ -604,7 +950,6 @@ const config = {
604
950
  },
605
951
  ],
606
952
 
607
- // ── Funnels ──
608
953
  funnels: [
609
954
  {
610
955
  name: "Onboarding",
@@ -666,14 +1011,12 @@ const config = {
666
1011
  },
667
1012
  ],
668
1013
 
669
- // ── Super Props (on every event) ──
670
1014
  superProps: {
671
1015
  Platform: ["web", "ios", "android"],
672
1016
  insurance_type: ["auto", "home", "life", "health", "renters"],
673
1017
  app_version: ["2.10"],
674
1018
  },
675
1019
 
676
- // ── User Props (set once per user) ──
677
1020
  userProps: {
678
1021
  Platform: ["web", "ios", "android"],
679
1022
  insurance_type: ["auto", "home", "life", "health", "renters"],
@@ -685,335 +1028,10 @@ const config = {
685
1028
  preferred_contact: ["email", "phone", "app_notification"],
686
1029
  },
687
1030
 
688
- // ── Hook Function ──
689
- /**
690
- * ARCHITECTED ANALYTICS HOOKS (10 total)
691
- *
692
- * This hook function creates 10 deliberate patterns in the data:
693
- *
694
- * 1. VERSION STAMPING (everything): Deterministic app_version based on timestamp.
695
- * 2. SUPPORT TICKET VOLUME (everything): Inflated pre-v2.13, progressive drop post.
696
- * 3. APPLICATION CONVERSION BOOST (everything): Pre-v2.13 approval drop.
697
- * 4. STEP-COUNT MAGIC NUMBER (everything): 8-14 steps = +35% premium; 15+ = -40% approvals.
698
- * 5. APPLICATION TTC (everything): Business 0.74x faster, family 1.3x slower.
699
- * 6. CLAIMS EXPERIMENT (funnel config): A/B test on claims funnel (engine-handled).
700
- * 7. RISK PROFILE APPROVAL (funnel-pre): Low-risk 1.8x, high-risk 0.3x conversion.
701
- * 8. DOCUMENT UPLOAD RETENTION (everything): 3+ uploads in 14d = retain; else -75% post-d30.
702
- * 9. RENEWAL SPIKE (everything): Days 85-95 get 3x renewals, 2x coverage reviews.
703
- * 10. CLAIM-TO-PREMIUM (event): Premium 2.0x on first payment after claim filed.
704
- */
705
- hook: function (record, type, meta) {
706
- // =============================================================
707
- // H7: RISK PROFILE AFFECTS APPROVAL (funnel-pre)
708
- // =============================================================
709
- if (type === "funnel-pre") {
710
- const isApprovalFunnel = meta.funnel?.sequence?.includes("application approved");
711
- if (isApprovalFunnel) {
712
- const risk = meta.profile?.risk_profile;
713
- if (risk === "low") record.conversionRate = Math.min(95, Math.round(record.conversionRate * 1.8));
714
- else if (risk === "high") record.conversionRate = Math.round(record.conversionRate * 0.3);
715
- }
716
- }
717
-
718
- // =============================================================
719
- // H10: CLAIM-TO-PREMIUM INCREASE (event — closure Map)
720
- // =============================================================
721
- if (type === "event") {
722
- if (record.event === "claim filed") {
723
- claimFilers.set(record.user_id, true);
724
- }
725
- if (record.event === "payment made" && claimFilers.has(record.user_id)) {
726
- record.premium_amount = Math.round((record.premium_amount || 500) * 2.0);
727
- claimFilers.delete(record.user_id);
728
- }
729
- }
730
-
731
- // =============================================================
732
- // Everything hooks: H5 first (timestamps), then H2-H4, then
733
- // H8-H9, then H1 (version stamping LAST).
734
- // =============================================================
735
- if (type === "everything") {
736
- const datasetStart = dayjs.unix(meta.datasetStart);
737
- const datasetEnd = dayjs.unix(meta.datasetEnd);
738
- const V211_DATE = datasetStart.add(30, "days");
739
- const V212_DATE = datasetStart.add(60, "days");
740
- const V213_DATE = datasetEnd.subtract(10, "days");
741
- const userEvents = record;
742
- if (userEvents.length === 0) return record;
743
-
744
- // Stamp superProps from profile for consistency
745
- const profile = meta.profile;
746
- userEvents.forEach(e => {
747
- e.Platform = profile.Platform;
748
- e.insurance_type = profile.insurance_type;
749
- e.app_version = profile.app_version;
750
- });
751
-
752
- // Find a user_id from any existing event
753
- const userId =
754
- userEvents.find((e) => e.user_id)?.user_id ||
755
- userEvents[0]?.device_id;
756
-
757
- // ─── Hook #5: APPLICATION COMPLETION TIME-TO-CONVERT ───
758
- // Runs FIRST: adjusts timestamps before version stamping.
759
- // Business accounts complete the application funnel faster (0.74x),
760
- // family accounts slower (1.3x). Individual stays at baseline.
761
- // Assigns account_type deterministically per user via djb2 hash
762
- // (independent of engine RNG). Places each "application approved"
763
- // event at a fixed offset from the first "application started".
764
- {
765
- // djb2 hash of userId → deterministic cohort
766
- let h = 5381;
767
- for (let i = 0; i < userId.length; i++) {
768
- h = ((h << 5) + h + userId.charCodeAt(i)) | 0;
769
- }
770
- const acctTypes = ["individual", "family", "business"];
771
- const userAccountType = acctTypes[((h % 3) + 3) % 3];
772
- // Stamp account created events with the deterministic type
773
- for (const evt of userEvents) {
774
- if (evt.event === "account created") {
775
- evt.account_type = userAccountType;
776
- }
777
- }
778
- userEvents.sort((a, b) => new Date(a.time) - new Date(b.time));
779
- // Collect all "application started" times
780
- const startedTimes = [];
781
- for (const evt of userEvents) {
782
- if (evt.event === "application started") {
783
- startedTimes.push(dayjs(evt.time).valueOf());
784
- }
785
- }
786
- if (startedTimes.length > 0) {
787
- const firstStartTime = startedTimes[0];
788
- // Target hours: biz=36, indiv=48, family=63
789
- // Ratios: biz/indiv≈0.75, family/indiv≈1.31
790
- const targetHours = (
791
- userAccountType === "business" ? 36 :
792
- userAccountType === "family" ? 63 :
793
- 48
794
- );
795
- const targetMs = targetHours * 3600000;
796
- for (const evt of userEvents) {
797
- if (evt.event !== "application approved") continue;
798
- const evtTime = dayjs(evt.time).valueOf();
799
- const origGap = evtTime - firstStartTime;
800
- // Small jitter from original gap (mod 4h) for variance
801
- const jitter = origGap > 0 ? (origGap % (4 * 3600000)) : 0;
802
- evt.time = dayjs(firstStartTime + targetMs + jitter).toISOString();
803
- }
804
- }
805
- }
806
-
807
- // ─── Hook #2: SUPPORT TICKET VOLUME ───
808
- // PRE-V2.13: Inject 2-3 extra support tickets with bug-related categories
809
- const preV213Tickets = userEvents.filter(
810
- (e) =>
811
- e.event === "support ticket created" &&
812
- dayjs(e.time).isBefore(V213_DATE)
813
- );
814
-
815
- if (preV213Tickets.length > 0) {
816
- const extraCount = chance.integer({ min: 2, max: 3 });
817
- const bugCategories = [
818
- "form_crash",
819
- "login_error",
820
- "page_timeout",
821
- "payment_failure",
822
- ];
823
-
824
- for (let i = 0; i < extraCount; i++) {
825
- // Pick a random pre-v2.13 ticket to base timing on
826
- const sourceTicket = chance.pickone(preV213Tickets);
827
- const sourceTime = dayjs(sourceTicket.time);
828
- // Offset by a few hours to days
829
- const offsetHours = chance.integer({ min: 1, max: 72 });
830
- let newTime = sourceTime.add(offsetHours, "hours");
831
- // Ensure it stays before v2.13
832
- if (newTime.isAfter(V213_DATE)) {
833
- newTime = V213_DATE.subtract(
834
- chance.integer({ min: 1, max: 48 }),
835
- "hours"
836
- );
837
- }
838
-
839
- // Compute app_version for injected event
840
- let injectedVersion;
841
- if (newTime.isBefore(V211_DATE)) {
842
- injectedVersion = "2.10";
843
- } else if (newTime.isBefore(V212_DATE)) {
844
- injectedVersion = "2.11";
845
- } else {
846
- injectedVersion = "2.12"; // always pre-v2.13
847
- }
848
-
849
- userEvents.push({
850
- ...sourceTicket,
851
- time: newTime.toISOString(),
852
- user_id: userId,
853
- app_version: injectedVersion,
854
- issue_category: chance.pickone(bugCategories),
855
- priority: chance.pickone(["medium", "high", "high"]),
856
- channel: chance.pickone([
857
- "chat",
858
- "phone",
859
- "email",
860
- "web_form",
861
- ]),
862
- });
863
- }
864
- }
865
-
866
- // POST-V2.13: Remove support tickets with increasing probability
867
- // Day 1 after release: ~30% removal
868
- // Day 5: ~60% removal
869
- // Day 10: ~85% removal
870
- for (let i = userEvents.length - 1; i >= 0; i--) {
871
- const evt = userEvents[i];
872
- if (evt.event === "support ticket created") {
873
- const evtTime = dayjs(evt.time);
874
- if (evtTime.isAfter(V213_DATE)) {
875
- const daysSinceRelease = evtTime.diff(
876
- V213_DATE,
877
- "days",
878
- true
879
- );
880
- // Linear ramp: 30% base + 5.5% per day → ~85% at day 10
881
- const removalLikelihood = Math.min(
882
- 85,
883
- 30 + daysSinceRelease * 5.5
884
- );
885
- if (chance.bool({ likelihood: removalLikelihood })) {
886
- userEvents.splice(i, 1);
887
- }
888
- }
889
- }
890
- }
891
-
892
- // ─── Hook #3: APPLICATION CONVERSION BOOST ───
893
- // PRE-V2.13: Remove ALL application approved + policy activated events
894
- // for ~80% of users (per-user gating, not per-event). This produces
895
- // visible funnel completion gap pre-v2.13 vs post-v2.13.
896
- if (chance.bool({ likelihood: 95 })) {
897
- for (let i = userEvents.length - 1; i >= 0; i--) {
898
- const evt = userEvents[i];
899
- if (
900
- (evt.event === "application approved" ||
901
- evt.event === "policy activated") &&
902
- dayjs(evt.time).isBefore(V213_DATE)
903
- ) {
904
- userEvents.splice(i, 1);
905
- }
906
- }
907
- }
908
-
909
- // Hook 4: APPLICATION-STEP MAGIC NUMBER (no flags)
910
- // Sweet 8-14 application step completed → +35% on approved_premium
911
- // for application approved events. Over 15+ → drop 40% of
912
- // application approved events (fraud filter triggers).
913
- const stepCount = userEvents.filter(e => e.event === "application step completed").length;
914
- if (stepCount >= 8 && stepCount <= 14) {
915
- userEvents.forEach(e => {
916
- if (e.event === "application approved" && typeof e.approved_premium === "number") {
917
- e.approved_premium = Math.round(e.approved_premium * 1.35);
918
- }
919
- });
920
- } else if (stepCount >= 15) {
921
- for (let i = userEvents.length - 1; i >= 0; i--) {
922
- if (userEvents[i].event === "application approved" && chance.bool({ likelihood: 40 })) {
923
- userEvents.splice(i, 1);
924
- }
925
- }
926
- }
927
-
928
- // ─── Hook #8: DOCUMENT UPLOAD RETENTION ───
929
- // Users with 3+ "document uploaded" in first 14 days retain;
930
- // others lose 75% of events post-day-30.
931
- if (meta.userIsBornInDataset) {
932
- const firstT = userEvents[0]?.time;
933
- if (firstT) {
934
- const window14 = dayjs(firstT).add(14, "days").toISOString();
935
- const docUploads = userEvents.filter(
936
- (e) => e.event === "document uploaded" && e.time <= window14
937
- ).length;
938
- if (docUploads < 3) {
939
- const cutoff = dayjs(firstT).add(30, "days");
940
- for (let i = userEvents.length - 1; i >= 0; i--) {
941
- if (
942
- dayjs(userEvents[i].time).isAfter(cutoff) &&
943
- chance.bool({ likelihood: 75 })
944
- ) {
945
- userEvents.splice(i, 1);
946
- }
947
- }
948
- }
949
- }
950
- }
951
-
952
- // ─── Hook #9: END-OF-QUARTER RENEWAL SPIKE ───
953
- // Days 85-95 get 3x renewal clones + 2x coverage reviewed clones
954
- {
955
- const spikeStart = datasetStart.add(85, "days");
956
- const spikeEnd = datasetStart.add(95, "days");
957
- const clones = [];
958
- userEvents.forEach((e) => {
959
- const t = dayjs(e.time);
960
- if (t.isAfter(spikeStart) && t.isBefore(spikeEnd)) {
961
- if (e.event === "renewal completed") {
962
- for (let c = 0; c < 2; c++) {
963
- clones.push({
964
- ...e,
965
- time: t
966
- .add(
967
- chance.integer({ min: 5, max: 240 }),
968
- "minutes"
969
- )
970
- .toISOString(),
971
- insert_id: chance.guid(),
972
- });
973
- }
974
- }
975
- if (e.event === "coverage reviewed") {
976
- clones.push({
977
- ...e,
978
- time: t
979
- .add(
980
- chance.integer({ min: 5, max: 120 }),
981
- "minutes"
982
- )
983
- .toISOString(),
984
- insert_id: chance.guid(),
985
- });
986
- }
987
- }
988
- });
989
- if (clones.length) userEvents.push(...clones);
990
- }
991
-
992
- // ─── Hook #1: VERSION STAMPING ───
993
- // Runs LAST: stamps app_version based on FINAL timestamps
994
- // (after Hook #5 TTC scaling has adjusted event times).
995
- // v2.10 (days 0-30) → v2.11 (30-60) → v2.12 (60-90) → v2.13 (last 10 days)
996
- for (const evt of userEvents) {
997
- const eventTime = dayjs(evt.time);
998
- if (eventTime.isBefore(V211_DATE)) {
999
- evt.app_version = "2.10";
1000
- } else if (eventTime.isBefore(V212_DATE)) {
1001
- evt.app_version = "2.11";
1002
- } else if (eventTime.isBefore(V213_DATE)) {
1003
- evt.app_version = "2.12";
1004
- } else {
1005
- evt.app_version = "2.13";
1006
- }
1007
- }
1008
-
1009
- // Sort events by time after injection/removal/shifting
1010
- userEvents.sort(
1011
- (a, b) => new Date(a.time) - new Date(b.time)
1012
- );
1013
-
1014
- return record;
1015
- }
1016
-
1031
+ hook(record, type, meta) {
1032
+ if (type === "funnel-pre") return handleFunnelPreHooks(record, meta);
1033
+ if (type === "event") return handleEventHooks(record);
1034
+ if (type === "everything") return handleEverythingHooks(record, meta);
1017
1035
  return record;
1018
1036
  },
1019
1037
  };