@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,62 +1,49 @@
1
- // ── TWEAK THESE ──
2
- const SEED = "dm4-healthcare";
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 * as v from "ak-tools";
16
8
  import { findFirstSequence, scaleFunnelTTC } from "../../lib/hook-helpers/timing.js";
17
-
18
- dayjs.extend(utc);
19
- const chance = u.initChance(SEED);
20
9
  /** @typedef {import("../../types").Dungeon} Config */
21
10
 
22
- // Generate consistent doctor/clinic IDs at module level
23
- const doctorIds = v.range(1, 120).map(() => `DR_${v.uid(6)}`);
24
- const clinicIds = v.range(1, 25).map(() => `CLINIC_${v.uid(4)}`);
25
-
26
- /**
27
- * ═══════════════════════════════════════════════════════════════
28
- * DATASET OVERVIEW
29
- * ═══════════════════════════════════════════════════════════════
30
- *
31
- * MedConnect — a telehealth platform connecting doctors, nurses,
32
- * and patients through virtual consultations, prescriptions,
33
- * and secure messaging.
34
- *
35
- * - 5,000 users over 100 days, ~600K events
36
- * - Multi-role system: doctors (5%), nurses (10%), patients (85%)
37
- * - Core loop: sign up → book appointment → consultation → prescription → follow-up
38
- * - Revenue: free (2 visits/month), basic ($9.99, 14-day trial), premium ($29.99 unlimited)
39
- *
40
- * Advanced Features:
41
- * - Personas: 5 archetypes (doctor, nurse, patient_active, patient_occasional, patient_churner)
42
- * - Subscription: 3-tier revenue lifecycle with trial conversion
43
- * - Geo: US/EU/LATAM with timezone-aware scheduling and regional compliance
44
- * - Features: video_consultation (day 30) and ai_symptom_checker (day 60)
45
- *
46
- * Key entities:
47
- * - doctor_id: assigned doctor for a consultation
48
- * - clinic_id: virtual clinic grouping
49
- * - condition_type: medical specialty area
50
- * - consultation_mode: phone vs video (driven by Feature rollout)
11
+ // ── OVERVIEW ──
12
+ /*
13
+ * NAME: MedConnect
14
+ * APP: Telehealth platform connecting doctors, nurses, and patients
15
+ * through virtual consultations, prescriptions, and secure
16
+ * messaging. Multi-role system; subscription tiers (free/basic/
17
+ * premium); feature rollouts for video consultation and AI
18
+ * symptom checker; geo-aware (US/EU/LATAM).
19
+ * SCALE: 10,000 users, ~1.2M events, 121 days (2026-01-01 → 2026-05-01)
20
+ * CORE LOOP: sign up → symptom search → book appointment → consultation → prescription → follow-up
21
+ *
22
+ * EVENTS (18):
23
+ * app session (8) > symptom search (7) > appointment booked (6) > notification received (6)
24
+ * > consultation completed (5) > message sent (5) > prescription issued (4)
25
+ * > health record accessed (4) > prescription refill (3) > follow up scheduled (3)
26
+ * > lab results viewed (3) > payment processed (3) > insurance verified (2)
27
+ * > provider rated (2) > profile updated (2) > account created (1)
28
+ * > support ticket created (1) > account deactivated (1)
29
+ *
30
+ * FUNNELS (5):
31
+ * - Onboarding Flow: account created → insurance verified → symptom search → appointment booked (45%)
32
+ * - Booking to Consultation: symptom search → appointment booked → consultation completed (40%)
33
+ * - Full Care Journey: appointment booked → consultation completed → prescription issued → follow up scheduled (30%)
34
+ * - Prescription Lifecycle: prescription issued → prescription refill → payment processed (55%)
35
+ * - Patient Satisfaction: consultation completed → provider rated → follow up scheduled (25%)
36
+ *
37
+ * USER PROPS: role, specialty, years_experience, preferred_language, has_chronic_condition, age_range, subscription_tier, Platform
38
+ * SUPER PROPS: subscription_tier, Platform
39
+ * SCD PROPS: care_plan (preventive/routine/chronic/acute, monthly fuzzy, max 8)
40
+ * GROUPS: none
51
41
  */
52
42
 
53
- /**
54
- * ═══════════════════════════════════════════════════════════════
55
- * ANALYTICS HOOKS (10 hooks)
56
- *
43
+ // ── HOOK STORIES ──
44
+ /*
57
45
  * NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable
58
46
  * via raw-prop breakdowns (HOD, day, tier) or behavioral cohorts.
59
- * ═══════════════════════════════════════════════════════════════
60
47
  *
61
48
  * ───────────────────────────────────────────────────────────────
62
49
  * 1. AFTER-HOURS SURGE PRICING (event hook)
@@ -294,31 +281,281 @@ const clinicIds = v.range(1, 25).map(() => `CLINIC_${v.uid(4)}`);
294
281
  * Consult-Count Magic Number | over days_until_fu | 1x | 1.5x | +50%
295
282
  */
296
283
 
284
+ // ── SCALE ──
285
+ const SEED = "dm4-healthcare";
286
+ const NUM_USERS = 10_000;
287
+ const DATASET_START = "2026-01-01T00:00:00Z";
288
+ const DATASET_END = "2026-05-01T23:59:59Z";
289
+ const EVENTS_PER_DAY = 1.2;
290
+ const token = process.env.MP_TOKEN || "your-mixpanel-token";
291
+
292
+ const chance = u.initChance(SEED);
293
+
294
+ // ── KNOBS (tweak these to reshape stories) ──
295
+ const AFTER_HOURS_START = 19;
296
+ const AFTER_HOURS_END = 7;
297
+ const AFTER_HOURS_FEE_MULT = 1.5;
298
+
299
+ const FLU_START_DAY = 50;
300
+ const FLU_END_DAY = 70;
301
+ const FLU_RESPIRATORY_LIKELIHOOD = 60;
302
+ const FLU_WAIT_MULT = 2;
303
+
304
+ const EXPERIENCED_CONSULT_THRESHOLD = 12;
305
+ const EXPERIENCED_SATISFACTION_MIN = 4.0;
306
+ const EXPERIENCED_SATISFACTION_MAX = 5.0;
307
+
308
+ const VIDEO_FOLLOWUP_LIKELIHOOD = 60;
309
+
310
+ const CHRONIC_REFILL_MIN = 2;
311
+ const CHRONIC_REFILL_MAX = 4;
312
+ const CHRONIC_REFILL_INTERVAL_DAYS = 30;
313
+
314
+ const NO_SHOW_EVENT_THRESHOLD = 15;
315
+ const NO_SHOW_DROP_LIKELIHOOD = 25;
316
+
317
+ const DOCTOR_EXPERIENCE_MIN = 15;
318
+ const DOCTOR_EXPERIENCE_MAX = 30;
319
+ const NURSE_EXPERIENCE_MIN = 3;
320
+ const NURSE_EXPERIENCE_MAX = 15;
321
+
322
+ const FREE_TIER_DROP_LIKELIHOOD = 30;
323
+
324
+ const TTC_PREMIUM_FACTOR = 0.67;
325
+ const TTC_FREE_FACTOR = 1.4;
326
+
327
+ const CONSULT_SWEET_MIN = 3;
328
+ const CONSULT_SWEET_MAX = 6;
329
+ const CONSULT_OVER_THRESHOLD = 7;
330
+ const CONSULT_FEE_BOOST = 1.25;
331
+ const CONSULT_FOLLOWUP_STRETCH = 1.5;
332
+
333
+ // ── DATA ARRAYS ──
334
+ // Generate consistent doctor/clinic IDs at module level
335
+ const doctorIds = v.range(1, 120).map(() => `DR_${v.uid(6)}`);
336
+ const clinicIds = v.range(1, 25).map(() => `CLINIC_${v.uid(4)}`);
337
+
338
+ // ── HELPER FUNCTIONS ──
339
+ function handleUserHooks(record) {
340
+ // H7: DOCTOR PROFILE SPECIALIZATION — doctors get a real specialty and
341
+ // senior years_experience. Nurses get mid-range experience. Patients
342
+ // stay at defaults.
343
+ if (record.role === "doctor") {
344
+ record.specialty = chance.pickone(["cardiology", "dermatology", "pediatrics", "psychiatry", "general_practice", "pulmonology", "endocrinology"]);
345
+ record.years_experience = chance.integer({ min: DOCTOR_EXPERIENCE_MIN, max: DOCTOR_EXPERIENCE_MAX });
346
+ } else if (record.role === "nurse") {
347
+ record.specialty = chance.pickone(["general_practice", "pediatrics", "emergency"]);
348
+ record.years_experience = chance.integer({ min: NURSE_EXPERIENCE_MIN, max: NURSE_EXPERIENCE_MAX });
349
+ } else {
350
+ record.years_experience = 0;
351
+ }
352
+ return record;
353
+ }
354
+
355
+ function handleEverythingHooks(record, meta) {
356
+ if (!record.length) return record;
357
+ const profile = meta.profile;
358
+ const datasetStart = dayjs.unix(meta.datasetStart);
359
+ const FLU_START = datasetStart.add(FLU_START_DAY, "days");
360
+ const FLU_END = datasetStart.add(FLU_END_DAY, "days");
361
+
362
+ // ── SUPER-PROP STAMPING ──────────────────────────
363
+ // Stamp superProps from profile so they are consistent per-user.
364
+ if (profile) {
365
+ const tier = profile.subscription_tier;
366
+ const plat = profile.Platform;
367
+ record.forEach(e => {
368
+ if (tier) e.subscription_tier = tier;
369
+ if (plat) e.Platform = plat;
370
+ });
371
+ }
372
+
373
+ // HOOK 9: BOOKING FUNNEL TTC BY TIER (property scaling)
374
+ // Premium users get shorter wait_time_hours (0.67x) and duration_minutes (0.67x).
375
+ // Free users get longer wait_time_hours (1.4x) and duration_minutes (1.4x).
376
+ // Basic users stay at baseline. SQL-measurable via AVG(wait_time_hours) broken by tier.
377
+ if (profile) {
378
+ const userTier = profile.subscription_tier;
379
+ const ttcFactor = userTier === "premium" ? TTC_PREMIUM_FACTOR : userTier === "free" ? TTC_FREE_FACTOR : 1.0;
380
+ if (ttcFactor !== 1.0) {
381
+ // Timestamp shift: affects Mixpanel funnel TTC
382
+ const bookingSeq = findFirstSequence(
383
+ record,
384
+ ["appointment booked", "consultation completed", "follow up scheduled"],
385
+ 60 * 24 * 30
386
+ );
387
+ if (bookingSeq) scaleFunnelTTC(bookingSeq, ttcFactor);
388
+ // Property scale: affects Insights AVG reports
389
+ record.forEach(e => {
390
+ if (e.event === "appointment booked" && typeof e.wait_time_hours === "number") {
391
+ e.wait_time_hours = Math.round(e.wait_time_hours * ttcFactor * 10) / 10;
392
+ }
393
+ if (e.event === "consultation completed" && typeof e.duration_minutes === "number") {
394
+ e.duration_minutes = Math.round(e.duration_minutes * ttcFactor);
395
+ }
396
+ });
397
+ }
398
+ }
399
+
400
+ // HOOK 1: AFTER-HOURS SURGE PRICING — consultations 7PM-7AM
401
+ // UTC get consultation_fee 1.5x. No flag — discover via HOD chart.
402
+ record.forEach(e => {
403
+ if (e.event === "consultation completed" || e.event === "appointment booked") {
404
+ const hour = new Date(e.time).getUTCHours();
405
+ if ((hour >= AFTER_HOURS_START || hour < AFTER_HOURS_END) && e.consultation_fee) {
406
+ e.consultation_fee = Math.floor(e.consultation_fee * AFTER_HOURS_FEE_MULT);
407
+ }
408
+ }
409
+ });
410
+
411
+ // HOOK 2: FLU SEASON SPIKE — d50-70 respiratory dominates, wait_time doubles.
412
+ // Runs in everything hook so timestamp checks see post-bunchIntoSessions times.
413
+ record.forEach(e => {
414
+ if (e.event !== "appointment booked") return;
415
+ const t = dayjs(e.time);
416
+ if (t.isAfter(FLU_START) && t.isBefore(FLU_END)) {
417
+ if (chance.bool({ likelihood: FLU_RESPIRATORY_LIKELIHOOD })) e.condition_type = "respiratory";
418
+ if (e.condition_type === "respiratory") {
419
+ e.wait_time_hours = Math.floor((e.wait_time_hours || 12) * FLU_WAIT_MULT);
420
+ }
421
+ }
422
+ });
423
+
424
+ // ── HOOK 8: FREE-TIER CONVERSION DROP ────────────
425
+ // Free-tier users lose ~30% of "consultation completed" events
426
+ // (last step of Booking to Consultation funnel), simulating
427
+ // lower conversion for non-paying patients.
428
+ if (profile && profile.subscription_tier === "free" && chance.bool({ likelihood: FREE_TIER_DROP_LIKELIHOOD })) {
429
+ record = record.filter(e => e.event !== "consultation completed");
430
+ }
431
+
432
+ // ── HOOK 3: EXPERIENCED DOCTOR SATISFACTION ──────
433
+ // Users with >12 consultation events get boosted satisfaction scores.
434
+ let consultCount = 0;
435
+ record.forEach(e => {
436
+ if (e.event === "consultation completed") consultCount++;
437
+ });
438
+
439
+ if (consultCount > EXPERIENCED_CONSULT_THRESHOLD) {
440
+ record.forEach(e => {
441
+ if (e.event === "consultation completed") {
442
+ e.satisfaction_score = chance.floating({ min: EXPERIENCED_SATISFACTION_MIN, max: EXPERIENCED_SATISFACTION_MAX, fixed: 1 });
443
+ }
444
+ });
445
+ }
446
+
447
+ // ── HOOK 4: VIDEO CONSULTATION FOLLOW-UP LIFT ────
448
+ // Patients with video consultations get 2x follow-up events.
449
+ const hasVideoConsult = record.some(e =>
450
+ e.event === "consultation completed" && e.consultation_mode === "video"
451
+ );
452
+ if (hasVideoConsult) {
453
+ const templateFollowUp = record.find(e => e.event === "follow up scheduled");
454
+ if (templateFollowUp) {
455
+ const videoConsults = record.filter(e =>
456
+ e.event === "consultation completed" && e.consultation_mode === "video"
457
+ );
458
+ videoConsults.forEach(vc => {
459
+ if (chance.bool({ likelihood: VIDEO_FOLLOWUP_LIKELIHOOD })) {
460
+ record.push({
461
+ ...templateFollowUp,
462
+ time: dayjs(vc.time).add(chance.integer({ min: 1, max: 7 }), "days").toISOString(),
463
+ user_id: vc.user_id,
464
+ consultation_mode: "video",
465
+ days_until_followup: chance.integer({ min: 3, max: 14 }),
466
+ });
467
+ }
468
+ });
469
+ }
470
+ }
471
+
472
+ // ── HOOK 5: CHRONIC CONDITION REFILL CHAIN ───────
473
+ // Patients with chronic prescriptions get refills every ~30 days.
474
+ const chronicRxs = record.filter(e =>
475
+ e.event === "prescription issued" && e.condition_type === "chronic"
476
+ );
477
+ if (chronicRxs.length > 0) {
478
+ const templateRefill = record.find(e => e.event === "prescription refill");
479
+ if (templateRefill) {
480
+ chronicRxs.forEach(rx => {
481
+ const rxTime = dayjs(rx.time);
482
+ const refillsToAdd = chance.integer({ min: CHRONIC_REFILL_MIN, max: CHRONIC_REFILL_MAX });
483
+ for (let i = 1; i <= refillsToAdd; i++) {
484
+ record.push({
485
+ ...templateRefill,
486
+ time: rxTime.add(CHRONIC_REFILL_INTERVAL_DAYS * i + chance.integer({ min: -3, max: 3 }), "days").toISOString(),
487
+ user_id: rx.user_id,
488
+ condition_type: "chronic",
489
+ medication_type: "chronic_maintenance",
490
+ refill_count: i,
491
+ });
492
+ }
493
+ });
494
+ }
495
+ }
496
+
497
+ // HOOK 6: OCCASIONAL PATIENT NO-SHOWS — low-activity patients
498
+ // (< 15 events) lose 25% of consultations (they booked but didn't show).
499
+ if (record.length < NO_SHOW_EVENT_THRESHOLD) {
500
+ for (let i = record.length - 1; i >= 0; i--) {
501
+ if (record[i].event === "consultation completed" && chance.bool({ likelihood: NO_SHOW_DROP_LIKELIHOOD })) {
502
+ record.splice(i, 1);
503
+ }
504
+ }
505
+ }
506
+
507
+ // HOOK 10: CONSULTATION-COUNT MAGIC NUMBER (no flags)
508
+ // Sweet 3-6 consultations → +25% on consultation_fee. Over 7+ →
509
+ // drop 30% of "follow up scheduled" events (over-consulted →
510
+ // follow-up fatigue).
511
+ const consultCt = record.filter(e => e.event === "consultation completed").length;
512
+ if (consultCt >= CONSULT_SWEET_MIN && consultCt <= CONSULT_SWEET_MAX) {
513
+ record.forEach(e => {
514
+ if (e.event === "consultation completed" && typeof e.consultation_fee === "number") {
515
+ e.consultation_fee = Math.round(e.consultation_fee * CONSULT_FEE_BOOST);
516
+ }
517
+ });
518
+ } else if (consultCt >= CONSULT_OVER_THRESHOLD) {
519
+ record.forEach(e => {
520
+ if (e.event === "follow up scheduled" && typeof e.days_until_followup === "number") {
521
+ e.days_until_followup = Math.round(e.days_until_followup * CONSULT_FOLLOWUP_STRETCH);
522
+ }
523
+ });
524
+ }
525
+
526
+ return record;
527
+ }
528
+
529
+ // ── CONFIG ──
297
530
  /** @type {Config} */
298
531
  const config = {
299
532
  version: 2,
300
- token,
301
533
  seed: SEED,
302
- datasetStart: "2026-01-01T00:00:00Z",
303
- datasetEnd: "2026-05-01T23:59:59Z",
304
- // numDays: num_days,
305
- avgEventsPerUserPerDay: avg_events_per_user_per_day,
306
- numUsers: num_users,
307
- hasAnonIds: true,
308
- avgDevicePerUser: 2,
309
- hasSessionIds: true,
534
+ datasetStart: DATASET_START,
535
+ datasetEnd: DATASET_END,
536
+ avgEventsPerUserPerDay: EVENTS_PER_DAY,
537
+ numUsers: NUM_USERS,
310
538
  format: "json",
311
539
  gzip: true,
312
- alsoInferFunnels: false,
313
- hasLocation: true,
314
- hasAndroidDevices: true,
315
- hasIOSDevices: true,
316
- hasDesktopDevices: true,
317
- hasBrowser: false,
318
- hasCampaigns: false,
319
- isAnonymous: false,
320
- hasAdSpend: false,
321
- hasAvatar: true,
540
+ credentials: {
541
+ token,
542
+ },
543
+ switches: {
544
+ hasSessionIds: true,
545
+ alsoInferFunnels: false,
546
+ hasLocation: true,
547
+ hasAndroidDevices: true,
548
+ hasIOSDevices: true,
549
+ hasDesktopDevices: true,
550
+ hasBrowser: false,
551
+ hasCampaigns: false,
552
+ isAnonymous: false,
553
+ hasAdSpend: false,
554
+ hasAvatar: true,
555
+ },
556
+ identity: {
557
+ avgDevicePerUser: 2,
558
+ },
322
559
  concurrency: 1,
323
560
  writeToDisk: false,
324
561
  scdProps: {
@@ -354,6 +591,7 @@ const config = {
354
591
  {
355
592
  event: "appointment booked",
356
593
  weight: 6,
594
+ isStrictEvent: false,
357
595
  properties: {
358
596
  doctor_id: chance.pickone.bind(chance, doctorIds),
359
597
  clinic_id: chance.pickone.bind(chance, clinicIds),
@@ -365,6 +603,7 @@ const config = {
365
603
  {
366
604
  event: "consultation completed",
367
605
  weight: 5,
606
+ isStrictEvent: false,
368
607
  properties: {
369
608
  doctor_id: chance.pickone.bind(chance, doctorIds),
370
609
  consultation_mode: ["phone", "phone", "video"],
@@ -377,6 +616,7 @@ const config = {
377
616
  {
378
617
  event: "prescription issued",
379
618
  weight: 4,
619
+ isStrictEvent: false,
380
620
  properties: {
381
621
  medication_type: ["antibiotic", "antiviral", "painkiller", "anti_inflammatory", "antidepressant", "inhaler", "topical", "chronic_maintenance"],
382
622
  quantity: u.weighNumRange(1, 90, 0.3, 30),
@@ -387,6 +627,7 @@ const config = {
387
627
  {
388
628
  event: "prescription refill",
389
629
  weight: 3,
630
+ isStrictEvent: false,
390
631
  properties: {
391
632
  medication_type: ["antibiotic", "antiviral", "painkiller", "anti_inflammatory", "antidepressant", "inhaler", "topical", "chronic_maintenance"],
392
633
  quantity: u.weighNumRange(1, 90, 0.3, 30),
@@ -397,6 +638,7 @@ const config = {
397
638
  {
398
639
  event: "follow up scheduled",
399
640
  weight: 3,
641
+ isStrictEvent: false,
400
642
  properties: {
401
643
  doctor_id: chance.pickone.bind(chance, doctorIds),
402
644
  days_until_followup: u.weighNumRange(3, 30, 0.5, 7),
@@ -693,202 +935,9 @@ const config = {
693
935
  },
694
936
  ],
695
937
 
696
- // ── Hook Function ──────────────────────────────────────
697
- hook: function (record, type, meta) {
698
- // ── HOOK 7: DOCTOR PROFILE SPECIALIZATION (user) ─────
699
- // Doctors get a real specialty and senior years_experience.
700
- // Nurses get mid-range experience. Patients stay at defaults.
701
- if (type === "user") {
702
- if (record.role === "doctor") {
703
- record.specialty = chance.pickone(["cardiology", "dermatology", "pediatrics", "psychiatry", "general_practice", "pulmonology", "endocrinology"]);
704
- record.years_experience = chance.integer({ min: 15, max: 30 });
705
- } else if (record.role === "nurse") {
706
- record.specialty = chance.pickone(["general_practice", "pediatrics", "emergency"]);
707
- record.years_experience = chance.integer({ min: 3, max: 15 });
708
- } else {
709
- record.years_experience = 0;
710
- }
711
- }
712
-
713
- // (HOOK 1: AFTER-HOURS SURGE PRICING moved to everything hook — hour checks
714
- // must run after bunchIntoSessions redistributes timestamps)
715
- // (HOOK 2: FLU SEASON SPIKE moved to everything hook — same reason)
716
-
717
- // ── EVERYTHING HOOKS ─────────────────────────────────
718
- if (type === "everything") {
719
- if (!record.length) return record;
720
- const profile = meta.profile;
721
- const datasetStart = dayjs.unix(meta.datasetStart);
722
- const FLU_START = datasetStart.add(50, "days");
723
- const FLU_END = datasetStart.add(70, "days");
724
-
725
- // ── SUPER-PROP STAMPING ──────────────────────────
726
- // Stamp superProps from profile so they are consistent per-user.
727
- if (profile) {
728
- const tier = profile.subscription_tier;
729
- const plat = profile.Platform;
730
- record.forEach(e => {
731
- if (tier) e.subscription_tier = tier;
732
- if (plat) e.Platform = plat;
733
- });
734
- }
735
-
736
- // HOOK 9: BOOKING FUNNEL TTC BY TIER (property scaling)
737
- // Premium users get shorter wait_time_hours (0.67x) and duration_minutes (0.67x).
738
- // Free users get longer wait_time_hours (1.4x) and duration_minutes (1.4x).
739
- // Basic users stay at baseline. SQL-measurable via AVG(wait_time_hours) broken by tier.
740
- if (profile) {
741
- const userTier = profile.subscription_tier;
742
- const ttcFactor = userTier === "premium" ? 0.67 : userTier === "free" ? 1.4 : 1.0;
743
- if (ttcFactor !== 1.0) {
744
- // Timestamp shift: affects Mixpanel funnel TTC
745
- const bookingSeq = findFirstSequence(
746
- record,
747
- ["appointment booked", "consultation completed", "follow up scheduled"],
748
- 60 * 24 * 30
749
- );
750
- if (bookingSeq) scaleFunnelTTC(bookingSeq, ttcFactor);
751
- // Property scale: affects Insights AVG reports
752
- record.forEach(e => {
753
- if (e.event === "appointment booked" && typeof e.wait_time_hours === "number") {
754
- e.wait_time_hours = Math.round(e.wait_time_hours * ttcFactor * 10) / 10;
755
- }
756
- if (e.event === "consultation completed" && typeof e.duration_minutes === "number") {
757
- e.duration_minutes = Math.round(e.duration_minutes * ttcFactor);
758
- }
759
- });
760
- }
761
- }
762
-
763
- // HOOK 1: AFTER-HOURS SURGE PRICING — consultations 7PM-7AM
764
- // UTC get consultation_fee 1.5x. No flag — discover via HOD chart.
765
- record.forEach(e => {
766
- if (e.event === "consultation completed" || e.event === "appointment booked") {
767
- const hour = new Date(e.time).getUTCHours();
768
- if ((hour >= 19 || hour < 7) && e.consultation_fee) {
769
- e.consultation_fee = Math.floor(e.consultation_fee * 1.5);
770
- }
771
- }
772
- });
773
-
774
- // HOOK 2: FLU SEASON SPIKE — d50-70 respiratory dominates, wait_time doubles.
775
- // Runs in everything hook so timestamp checks see post-bunchIntoSessions times.
776
- record.forEach(e => {
777
- if (e.event !== "appointment booked") return;
778
- const t = dayjs(e.time);
779
- if (t.isAfter(FLU_START) && t.isBefore(FLU_END)) {
780
- if (chance.bool({ likelihood: 60 })) e.condition_type = "respiratory";
781
- if (e.condition_type === "respiratory") {
782
- e.wait_time_hours = Math.floor((e.wait_time_hours || 12) * 2);
783
- }
784
- }
785
- });
786
-
787
- // ── HOOK 8: FREE-TIER CONVERSION DROP ────────────
788
- // Free-tier users lose ~30% of "consultation completed" events
789
- // (last step of Booking to Consultation funnel), simulating
790
- // lower conversion for non-paying patients.
791
- if (profile && profile.subscription_tier === "free" && chance.bool({ likelihood: 30 })) {
792
- record = record.filter(e => e.event !== "consultation completed");
793
- }
794
-
795
- // ── HOOK 3: EXPERIENCED DOCTOR SATISFACTION ──────
796
- // Users with >12 consultation events get boosted satisfaction scores.
797
- let consultCount = 0;
798
- record.forEach(e => {
799
- if (e.event === "consultation completed") consultCount++;
800
- });
801
-
802
- if (consultCount > 12) {
803
- record.forEach(e => {
804
- if (e.event === "consultation completed") {
805
- e.satisfaction_score = chance.floating({ min: 4.0, max: 5.0, fixed: 1 });
806
- }
807
- });
808
- }
809
-
810
- // ── HOOK 4: VIDEO CONSULTATION FOLLOW-UP LIFT ────
811
- // Patients with video consultations get 2x follow-up events.
812
- const hasVideoConsult = record.some(e =>
813
- e.event === "consultation completed" && e.consultation_mode === "video"
814
- );
815
- if (hasVideoConsult) {
816
- const templateFollowUp = record.find(e => e.event === "follow up scheduled");
817
- if (templateFollowUp) {
818
- const videoConsults = record.filter(e =>
819
- e.event === "consultation completed" && e.consultation_mode === "video"
820
- );
821
- videoConsults.forEach(vc => {
822
- if (chance.bool({ likelihood: 60 })) {
823
- record.push({
824
- ...templateFollowUp,
825
- time: dayjs(vc.time).add(chance.integer({ min: 1, max: 7 }), "days").toISOString(),
826
- user_id: vc.user_id,
827
- consultation_mode: "video",
828
- days_until_followup: chance.integer({ min: 3, max: 14 }),
829
- });
830
- }
831
- });
832
- }
833
- }
834
-
835
- // ── HOOK 5: CHRONIC CONDITION REFILL CHAIN ───────
836
- // Patients with chronic prescriptions get refills every ~30 days.
837
- const chronicRxs = record.filter(e =>
838
- e.event === "prescription issued" && e.condition_type === "chronic"
839
- );
840
- if (chronicRxs.length > 0) {
841
- const templateRefill = record.find(e => e.event === "prescription refill");
842
- if (templateRefill) {
843
- chronicRxs.forEach(rx => {
844
- const rxTime = dayjs(rx.time);
845
- const refillsToAdd = chance.integer({ min: 2, max: 4 });
846
- for (let i = 1; i <= refillsToAdd; i++) {
847
- record.push({
848
- ...templateRefill,
849
- time: rxTime.add(30 * i + chance.integer({ min: -3, max: 3 }), "days").toISOString(),
850
- user_id: rx.user_id,
851
- condition_type: "chronic",
852
- medication_type: "chronic_maintenance",
853
- refill_count: i,
854
- });
855
- }
856
- });
857
- }
858
- }
859
-
860
- // HOOK 6: OCCASIONAL PATIENT NO-SHOWS — low-activity patients
861
- // (< 15 events) lose 25% of consultations (they booked but didn't show).
862
- if (record.length < 15) {
863
- for (let i = record.length - 1; i >= 0; i--) {
864
- if (record[i].event === "consultation completed" && chance.bool({ likelihood: 25 })) {
865
- record.splice(i, 1);
866
- }
867
- }
868
- }
869
-
870
- // HOOK 10: CONSULTATION-COUNT MAGIC NUMBER (no flags)
871
- // Sweet 3-6 consultations → +25% on consultation_fee. Over 7+ →
872
- // drop 30% of "follow up scheduled" events (over-consulted →
873
- // follow-up fatigue).
874
- const consultCt = record.filter(e => e.event === "consultation completed").length;
875
- if (consultCt >= 3 && consultCt <= 6) {
876
- record.forEach(e => {
877
- if (e.event === "consultation completed" && typeof e.consultation_fee === "number") {
878
- e.consultation_fee = Math.round(e.consultation_fee * 1.25);
879
- }
880
- });
881
- } else if (consultCt >= 7) {
882
- record.forEach(e => {
883
- if (e.event === "follow up scheduled" && typeof e.days_until_followup === "number") {
884
- e.days_until_followup = Math.round(e.days_until_followup * 1.5);
885
- }
886
- });
887
- }
888
-
889
- return record;
890
- }
891
-
938
+ hook(record, type, meta) {
939
+ if (type === "user") return handleUserHooks(record);
940
+ if (type === "everything") return handleEverythingHooks(record, meta);
892
941
  return record;
893
942
  },
894
943
  };