@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,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",
@@ -430,6 +776,7 @@ const config = {
430
776
  {
431
777
  event: "application started",
432
778
  weight: 4,
779
+ isStrictEvent: false,
433
780
  properties: {
434
781
  coverage_level: ["basic", "standard", "premium"],
435
782
  estimated_premium: u.weighNumRange(30, 800, 0.3, 150),
@@ -438,6 +785,7 @@ const config = {
438
785
  {
439
786
  event: "application step completed",
440
787
  weight: 6,
788
+ isStrictEvent: false,
441
789
  properties: {
442
790
  step_name: [
443
791
  "personal_info",
@@ -454,6 +802,7 @@ const config = {
454
802
  {
455
803
  event: "document uploaded",
456
804
  weight: 3,
805
+ isStrictEvent: false,
457
806
  properties: {
458
807
  document_type: [
459
808
  "drivers_license",
@@ -476,6 +825,7 @@ const config = {
476
825
  {
477
826
  event: "application approved",
478
827
  weight: 2,
828
+ isStrictEvent: false,
479
829
  properties: {
480
830
  approved_premium: u.weighNumRange(30, 800, 0.3, 150),
481
831
  approval_time_hours: u.weighNumRange(1, 72, 0.5, 24),
@@ -484,6 +834,7 @@ const config = {
484
834
  {
485
835
  event: "policy activated",
486
836
  weight: 2,
837
+ isStrictEvent: false,
487
838
  properties: {
488
839
  policy_term_months: [6, 12, 12, 12, 24],
489
840
  effective_date_offset_days: u.weighNumRange(0, 30, 0.5, 7),
@@ -492,6 +843,7 @@ const config = {
492
843
  {
493
844
  event: "claim filed",
494
845
  weight: 2,
846
+ isStrictEvent: false,
495
847
  properties: {
496
848
  claim_type: [
497
849
  "collision",
@@ -521,6 +873,7 @@ const config = {
521
873
  {
522
874
  event: "payment made",
523
875
  weight: 5,
876
+ isStrictEvent: false,
524
877
  properties: {
525
878
  payment_method: ["credit_card", "bank_transfer", "auto_pay", "check"],
526
879
  amount: u.weighNumRange(30, 800, 0.3, 150),
@@ -531,6 +884,7 @@ const config = {
531
884
  {
532
885
  event: "support ticket created",
533
886
  weight: 4,
887
+ isStrictEvent: false,
534
888
  properties: {
535
889
  issue_category: [
536
890
  "billing",
@@ -564,6 +918,7 @@ const config = {
564
918
  {
565
919
  event: "coverage reviewed",
566
920
  weight: 4,
921
+ isStrictEvent: false,
567
922
  properties: {
568
923
  current_premium: u.weighNumRange(30, 800, 0.3, 150),
569
924
  coverage_adequate: [true, true, true, false],
@@ -586,6 +941,7 @@ const config = {
586
941
  {
587
942
  event: "renewal completed",
588
943
  weight: 2,
944
+ isStrictEvent: false,
589
945
  properties: {
590
946
  renewal_premium: u.weighNumRange(30, 800, 0.3, 150),
591
947
  premium_change_pct: u.weighNumRange(-15, 20, 1, 3),
@@ -594,7 +950,6 @@ const config = {
594
950
  },
595
951
  ],
596
952
 
597
- // ── Funnels ──
598
953
  funnels: [
599
954
  {
600
955
  name: "Onboarding",
@@ -656,14 +1011,12 @@ const config = {
656
1011
  },
657
1012
  ],
658
1013
 
659
- // ── Super Props (on every event) ──
660
1014
  superProps: {
661
1015
  Platform: ["web", "ios", "android"],
662
1016
  insurance_type: ["auto", "home", "life", "health", "renters"],
663
1017
  app_version: ["2.10"],
664
1018
  },
665
1019
 
666
- // ── User Props (set once per user) ──
667
1020
  userProps: {
668
1021
  Platform: ["web", "ios", "android"],
669
1022
  insurance_type: ["auto", "home", "life", "health", "renters"],
@@ -675,335 +1028,10 @@ const config = {
675
1028
  preferred_contact: ["email", "phone", "app_notification"],
676
1029
  },
677
1030
 
678
- // ── Hook Function ──
679
- /**
680
- * ARCHITECTED ANALYTICS HOOKS (10 total)
681
- *
682
- * This hook function creates 10 deliberate patterns in the data:
683
- *
684
- * 1. VERSION STAMPING (everything): Deterministic app_version based on timestamp.
685
- * 2. SUPPORT TICKET VOLUME (everything): Inflated pre-v2.13, progressive drop post.
686
- * 3. APPLICATION CONVERSION BOOST (everything): Pre-v2.13 approval drop.
687
- * 4. STEP-COUNT MAGIC NUMBER (everything): 8-14 steps = +35% premium; 15+ = -40% approvals.
688
- * 5. APPLICATION TTC (everything): Business 0.74x faster, family 1.3x slower.
689
- * 6. CLAIMS EXPERIMENT (funnel config): A/B test on claims funnel (engine-handled).
690
- * 7. RISK PROFILE APPROVAL (funnel-pre): Low-risk 1.8x, high-risk 0.3x conversion.
691
- * 8. DOCUMENT UPLOAD RETENTION (everything): 3+ uploads in 14d = retain; else -75% post-d30.
692
- * 9. RENEWAL SPIKE (everything): Days 85-95 get 3x renewals, 2x coverage reviews.
693
- * 10. CLAIM-TO-PREMIUM (event): Premium 2.0x on first payment after claim filed.
694
- */
695
- hook: function (record, type, meta) {
696
- // =============================================================
697
- // H7: RISK PROFILE AFFECTS APPROVAL (funnel-pre)
698
- // =============================================================
699
- if (type === "funnel-pre") {
700
- const isApprovalFunnel = meta.funnel?.sequence?.includes("application approved");
701
- if (isApprovalFunnel) {
702
- const risk = meta.profile?.risk_profile;
703
- if (risk === "low") record.conversionRate = Math.min(95, Math.round(record.conversionRate * 1.8));
704
- else if (risk === "high") record.conversionRate = Math.round(record.conversionRate * 0.3);
705
- }
706
- }
707
-
708
- // =============================================================
709
- // H10: CLAIM-TO-PREMIUM INCREASE (event — closure Map)
710
- // =============================================================
711
- if (type === "event") {
712
- if (record.event === "claim filed") {
713
- claimFilers.set(record.user_id, true);
714
- }
715
- if (record.event === "payment made" && claimFilers.has(record.user_id)) {
716
- record.premium_amount = Math.round((record.premium_amount || 500) * 2.0);
717
- claimFilers.delete(record.user_id);
718
- }
719
- }
720
-
721
- // =============================================================
722
- // Everything hooks: H5 first (timestamps), then H2-H4, then
723
- // H8-H9, then H1 (version stamping LAST).
724
- // =============================================================
725
- if (type === "everything") {
726
- const datasetStart = dayjs.unix(meta.datasetStart);
727
- const datasetEnd = dayjs.unix(meta.datasetEnd);
728
- const V211_DATE = datasetStart.add(30, "days");
729
- const V212_DATE = datasetStart.add(60, "days");
730
- const V213_DATE = datasetEnd.subtract(10, "days");
731
- const userEvents = record;
732
- if (userEvents.length === 0) return record;
733
-
734
- // Stamp superProps from profile for consistency
735
- const profile = meta.profile;
736
- userEvents.forEach(e => {
737
- e.Platform = profile.Platform;
738
- e.insurance_type = profile.insurance_type;
739
- e.app_version = profile.app_version;
740
- });
741
-
742
- // Find a user_id from any existing event
743
- const userId =
744
- userEvents.find((e) => e.user_id)?.user_id ||
745
- userEvents[0]?.device_id;
746
-
747
- // ─── Hook #5: APPLICATION COMPLETION TIME-TO-CONVERT ───
748
- // Runs FIRST: adjusts timestamps before version stamping.
749
- // Business accounts complete the application funnel faster (0.74x),
750
- // family accounts slower (1.3x). Individual stays at baseline.
751
- // Assigns account_type deterministically per user via djb2 hash
752
- // (independent of engine RNG). Places each "application approved"
753
- // event at a fixed offset from the first "application started".
754
- {
755
- // djb2 hash of userId → deterministic cohort
756
- let h = 5381;
757
- for (let i = 0; i < userId.length; i++) {
758
- h = ((h << 5) + h + userId.charCodeAt(i)) | 0;
759
- }
760
- const acctTypes = ["individual", "family", "business"];
761
- const userAccountType = acctTypes[((h % 3) + 3) % 3];
762
- // Stamp account created events with the deterministic type
763
- for (const evt of userEvents) {
764
- if (evt.event === "account created") {
765
- evt.account_type = userAccountType;
766
- }
767
- }
768
- userEvents.sort((a, b) => new Date(a.time) - new Date(b.time));
769
- // Collect all "application started" times
770
- const startedTimes = [];
771
- for (const evt of userEvents) {
772
- if (evt.event === "application started") {
773
- startedTimes.push(dayjs(evt.time).valueOf());
774
- }
775
- }
776
- if (startedTimes.length > 0) {
777
- const firstStartTime = startedTimes[0];
778
- // Target hours: biz=36, indiv=48, family=63
779
- // Ratios: biz/indiv≈0.75, family/indiv≈1.31
780
- const targetHours = (
781
- userAccountType === "business" ? 36 :
782
- userAccountType === "family" ? 63 :
783
- 48
784
- );
785
- const targetMs = targetHours * 3600000;
786
- for (const evt of userEvents) {
787
- if (evt.event !== "application approved") continue;
788
- const evtTime = dayjs(evt.time).valueOf();
789
- const origGap = evtTime - firstStartTime;
790
- // Small jitter from original gap (mod 4h) for variance
791
- const jitter = origGap > 0 ? (origGap % (4 * 3600000)) : 0;
792
- evt.time = dayjs(firstStartTime + targetMs + jitter).toISOString();
793
- }
794
- }
795
- }
796
-
797
- // ─── Hook #2: SUPPORT TICKET VOLUME ───
798
- // PRE-V2.13: Inject 2-3 extra support tickets with bug-related categories
799
- const preV213Tickets = userEvents.filter(
800
- (e) =>
801
- e.event === "support ticket created" &&
802
- dayjs(e.time).isBefore(V213_DATE)
803
- );
804
-
805
- if (preV213Tickets.length > 0) {
806
- const extraCount = chance.integer({ min: 2, max: 3 });
807
- const bugCategories = [
808
- "form_crash",
809
- "login_error",
810
- "page_timeout",
811
- "payment_failure",
812
- ];
813
-
814
- for (let i = 0; i < extraCount; i++) {
815
- // Pick a random pre-v2.13 ticket to base timing on
816
- const sourceTicket = chance.pickone(preV213Tickets);
817
- const sourceTime = dayjs(sourceTicket.time);
818
- // Offset by a few hours to days
819
- const offsetHours = chance.integer({ min: 1, max: 72 });
820
- let newTime = sourceTime.add(offsetHours, "hours");
821
- // Ensure it stays before v2.13
822
- if (newTime.isAfter(V213_DATE)) {
823
- newTime = V213_DATE.subtract(
824
- chance.integer({ min: 1, max: 48 }),
825
- "hours"
826
- );
827
- }
828
-
829
- // Compute app_version for injected event
830
- let injectedVersion;
831
- if (newTime.isBefore(V211_DATE)) {
832
- injectedVersion = "2.10";
833
- } else if (newTime.isBefore(V212_DATE)) {
834
- injectedVersion = "2.11";
835
- } else {
836
- injectedVersion = "2.12"; // always pre-v2.13
837
- }
838
-
839
- userEvents.push({
840
- ...sourceTicket,
841
- time: newTime.toISOString(),
842
- user_id: userId,
843
- app_version: injectedVersion,
844
- issue_category: chance.pickone(bugCategories),
845
- priority: chance.pickone(["medium", "high", "high"]),
846
- channel: chance.pickone([
847
- "chat",
848
- "phone",
849
- "email",
850
- "web_form",
851
- ]),
852
- });
853
- }
854
- }
855
-
856
- // POST-V2.13: Remove support tickets with increasing probability
857
- // Day 1 after release: ~30% removal
858
- // Day 5: ~60% removal
859
- // Day 10: ~85% removal
860
- for (let i = userEvents.length - 1; i >= 0; i--) {
861
- const evt = userEvents[i];
862
- if (evt.event === "support ticket created") {
863
- const evtTime = dayjs(evt.time);
864
- if (evtTime.isAfter(V213_DATE)) {
865
- const daysSinceRelease = evtTime.diff(
866
- V213_DATE,
867
- "days",
868
- true
869
- );
870
- // Linear ramp: 30% base + 5.5% per day → ~85% at day 10
871
- const removalLikelihood = Math.min(
872
- 85,
873
- 30 + daysSinceRelease * 5.5
874
- );
875
- if (chance.bool({ likelihood: removalLikelihood })) {
876
- userEvents.splice(i, 1);
877
- }
878
- }
879
- }
880
- }
881
-
882
- // ─── Hook #3: APPLICATION CONVERSION BOOST ───
883
- // PRE-V2.13: Remove ALL application approved + policy activated events
884
- // for ~80% of users (per-user gating, not per-event). This produces
885
- // visible funnel completion gap pre-v2.13 vs post-v2.13.
886
- if (chance.bool({ likelihood: 95 })) {
887
- for (let i = userEvents.length - 1; i >= 0; i--) {
888
- const evt = userEvents[i];
889
- if (
890
- (evt.event === "application approved" ||
891
- evt.event === "policy activated") &&
892
- dayjs(evt.time).isBefore(V213_DATE)
893
- ) {
894
- userEvents.splice(i, 1);
895
- }
896
- }
897
- }
898
-
899
- // Hook 4: APPLICATION-STEP MAGIC NUMBER (no flags)
900
- // Sweet 8-14 application step completed → +35% on approved_premium
901
- // for application approved events. Over 15+ → drop 40% of
902
- // application approved events (fraud filter triggers).
903
- const stepCount = userEvents.filter(e => e.event === "application step completed").length;
904
- if (stepCount >= 8 && stepCount <= 14) {
905
- userEvents.forEach(e => {
906
- if (e.event === "application approved" && typeof e.approved_premium === "number") {
907
- e.approved_premium = Math.round(e.approved_premium * 1.35);
908
- }
909
- });
910
- } else if (stepCount >= 15) {
911
- for (let i = userEvents.length - 1; i >= 0; i--) {
912
- if (userEvents[i].event === "application approved" && chance.bool({ likelihood: 40 })) {
913
- userEvents.splice(i, 1);
914
- }
915
- }
916
- }
917
-
918
- // ─── Hook #8: DOCUMENT UPLOAD RETENTION ───
919
- // Users with 3+ "document uploaded" in first 14 days retain;
920
- // others lose 75% of events post-day-30.
921
- if (meta.userIsBornInDataset) {
922
- const firstT = userEvents[0]?.time;
923
- if (firstT) {
924
- const window14 = dayjs(firstT).add(14, "days").toISOString();
925
- const docUploads = userEvents.filter(
926
- (e) => e.event === "document uploaded" && e.time <= window14
927
- ).length;
928
- if (docUploads < 3) {
929
- const cutoff = dayjs(firstT).add(30, "days");
930
- for (let i = userEvents.length - 1; i >= 0; i--) {
931
- if (
932
- dayjs(userEvents[i].time).isAfter(cutoff) &&
933
- chance.bool({ likelihood: 75 })
934
- ) {
935
- userEvents.splice(i, 1);
936
- }
937
- }
938
- }
939
- }
940
- }
941
-
942
- // ─── Hook #9: END-OF-QUARTER RENEWAL SPIKE ───
943
- // Days 85-95 get 3x renewal clones + 2x coverage reviewed clones
944
- {
945
- const spikeStart = datasetStart.add(85, "days");
946
- const spikeEnd = datasetStart.add(95, "days");
947
- const clones = [];
948
- userEvents.forEach((e) => {
949
- const t = dayjs(e.time);
950
- if (t.isAfter(spikeStart) && t.isBefore(spikeEnd)) {
951
- if (e.event === "renewal completed") {
952
- for (let c = 0; c < 2; c++) {
953
- clones.push({
954
- ...e,
955
- time: t
956
- .add(
957
- chance.integer({ min: 5, max: 240 }),
958
- "minutes"
959
- )
960
- .toISOString(),
961
- insert_id: chance.guid(),
962
- });
963
- }
964
- }
965
- if (e.event === "coverage reviewed") {
966
- clones.push({
967
- ...e,
968
- time: t
969
- .add(
970
- chance.integer({ min: 5, max: 120 }),
971
- "minutes"
972
- )
973
- .toISOString(),
974
- insert_id: chance.guid(),
975
- });
976
- }
977
- }
978
- });
979
- if (clones.length) userEvents.push(...clones);
980
- }
981
-
982
- // ─── Hook #1: VERSION STAMPING ───
983
- // Runs LAST: stamps app_version based on FINAL timestamps
984
- // (after Hook #5 TTC scaling has adjusted event times).
985
- // v2.10 (days 0-30) → v2.11 (30-60) → v2.12 (60-90) → v2.13 (last 10 days)
986
- for (const evt of userEvents) {
987
- const eventTime = dayjs(evt.time);
988
- if (eventTime.isBefore(V211_DATE)) {
989
- evt.app_version = "2.10";
990
- } else if (eventTime.isBefore(V212_DATE)) {
991
- evt.app_version = "2.11";
992
- } else if (eventTime.isBefore(V213_DATE)) {
993
- evt.app_version = "2.12";
994
- } else {
995
- evt.app_version = "2.13";
996
- }
997
- }
998
-
999
- // Sort events by time after injection/removal/shifting
1000
- userEvents.sort(
1001
- (a, b) => new Date(a.time) - new Date(b.time)
1002
- );
1003
-
1004
- return record;
1005
- }
1006
-
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);
1007
1035
  return record;
1008
1036
  },
1009
1037
  };