@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,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: {
@@ -698,202 +935,9 @@ const config = {
698
935
  },
699
936
  ],
700
937
 
701
- // ── Hook Function ──────────────────────────────────────
702
- hook: function (record, type, meta) {
703
- // ── HOOK 7: DOCTOR PROFILE SPECIALIZATION (user) ─────
704
- // Doctors get a real specialty and senior years_experience.
705
- // Nurses get mid-range experience. Patients stay at defaults.
706
- if (type === "user") {
707
- if (record.role === "doctor") {
708
- record.specialty = chance.pickone(["cardiology", "dermatology", "pediatrics", "psychiatry", "general_practice", "pulmonology", "endocrinology"]);
709
- record.years_experience = chance.integer({ min: 15, max: 30 });
710
- } else if (record.role === "nurse") {
711
- record.specialty = chance.pickone(["general_practice", "pediatrics", "emergency"]);
712
- record.years_experience = chance.integer({ min: 3, max: 15 });
713
- } else {
714
- record.years_experience = 0;
715
- }
716
- }
717
-
718
- // (HOOK 1: AFTER-HOURS SURGE PRICING moved to everything hook — hour checks
719
- // must run after bunchIntoSessions redistributes timestamps)
720
- // (HOOK 2: FLU SEASON SPIKE moved to everything hook — same reason)
721
-
722
- // ── EVERYTHING HOOKS ─────────────────────────────────
723
- if (type === "everything") {
724
- if (!record.length) return record;
725
- const profile = meta.profile;
726
- const datasetStart = dayjs.unix(meta.datasetStart);
727
- const FLU_START = datasetStart.add(50, "days");
728
- const FLU_END = datasetStart.add(70, "days");
729
-
730
- // ── SUPER-PROP STAMPING ──────────────────────────
731
- // Stamp superProps from profile so they are consistent per-user.
732
- if (profile) {
733
- const tier = profile.subscription_tier;
734
- const plat = profile.Platform;
735
- record.forEach(e => {
736
- if (tier) e.subscription_tier = tier;
737
- if (plat) e.Platform = plat;
738
- });
739
- }
740
-
741
- // HOOK 9: BOOKING FUNNEL TTC BY TIER (property scaling)
742
- // Premium users get shorter wait_time_hours (0.67x) and duration_minutes (0.67x).
743
- // Free users get longer wait_time_hours (1.4x) and duration_minutes (1.4x).
744
- // Basic users stay at baseline. SQL-measurable via AVG(wait_time_hours) broken by tier.
745
- if (profile) {
746
- const userTier = profile.subscription_tier;
747
- const ttcFactor = userTier === "premium" ? 0.67 : userTier === "free" ? 1.4 : 1.0;
748
- if (ttcFactor !== 1.0) {
749
- // Timestamp shift: affects Mixpanel funnel TTC
750
- const bookingSeq = findFirstSequence(
751
- record,
752
- ["appointment booked", "consultation completed", "follow up scheduled"],
753
- 60 * 24 * 30
754
- );
755
- if (bookingSeq) scaleFunnelTTC(bookingSeq, ttcFactor);
756
- // Property scale: affects Insights AVG reports
757
- record.forEach(e => {
758
- if (e.event === "appointment booked" && typeof e.wait_time_hours === "number") {
759
- e.wait_time_hours = Math.round(e.wait_time_hours * ttcFactor * 10) / 10;
760
- }
761
- if (e.event === "consultation completed" && typeof e.duration_minutes === "number") {
762
- e.duration_minutes = Math.round(e.duration_minutes * ttcFactor);
763
- }
764
- });
765
- }
766
- }
767
-
768
- // HOOK 1: AFTER-HOURS SURGE PRICING — consultations 7PM-7AM
769
- // UTC get consultation_fee 1.5x. No flag — discover via HOD chart.
770
- record.forEach(e => {
771
- if (e.event === "consultation completed" || e.event === "appointment booked") {
772
- const hour = new Date(e.time).getUTCHours();
773
- if ((hour >= 19 || hour < 7) && e.consultation_fee) {
774
- e.consultation_fee = Math.floor(e.consultation_fee * 1.5);
775
- }
776
- }
777
- });
778
-
779
- // HOOK 2: FLU SEASON SPIKE — d50-70 respiratory dominates, wait_time doubles.
780
- // Runs in everything hook so timestamp checks see post-bunchIntoSessions times.
781
- record.forEach(e => {
782
- if (e.event !== "appointment booked") return;
783
- const t = dayjs(e.time);
784
- if (t.isAfter(FLU_START) && t.isBefore(FLU_END)) {
785
- if (chance.bool({ likelihood: 60 })) e.condition_type = "respiratory";
786
- if (e.condition_type === "respiratory") {
787
- e.wait_time_hours = Math.floor((e.wait_time_hours || 12) * 2);
788
- }
789
- }
790
- });
791
-
792
- // ── HOOK 8: FREE-TIER CONVERSION DROP ────────────
793
- // Free-tier users lose ~30% of "consultation completed" events
794
- // (last step of Booking to Consultation funnel), simulating
795
- // lower conversion for non-paying patients.
796
- if (profile && profile.subscription_tier === "free" && chance.bool({ likelihood: 30 })) {
797
- record = record.filter(e => e.event !== "consultation completed");
798
- }
799
-
800
- // ── HOOK 3: EXPERIENCED DOCTOR SATISFACTION ──────
801
- // Users with >12 consultation events get boosted satisfaction scores.
802
- let consultCount = 0;
803
- record.forEach(e => {
804
- if (e.event === "consultation completed") consultCount++;
805
- });
806
-
807
- if (consultCount > 12) {
808
- record.forEach(e => {
809
- if (e.event === "consultation completed") {
810
- e.satisfaction_score = chance.floating({ min: 4.0, max: 5.0, fixed: 1 });
811
- }
812
- });
813
- }
814
-
815
- // ── HOOK 4: VIDEO CONSULTATION FOLLOW-UP LIFT ────
816
- // Patients with video consultations get 2x follow-up events.
817
- const hasVideoConsult = record.some(e =>
818
- e.event === "consultation completed" && e.consultation_mode === "video"
819
- );
820
- if (hasVideoConsult) {
821
- const templateFollowUp = record.find(e => e.event === "follow up scheduled");
822
- if (templateFollowUp) {
823
- const videoConsults = record.filter(e =>
824
- e.event === "consultation completed" && e.consultation_mode === "video"
825
- );
826
- videoConsults.forEach(vc => {
827
- if (chance.bool({ likelihood: 60 })) {
828
- record.push({
829
- ...templateFollowUp,
830
- time: dayjs(vc.time).add(chance.integer({ min: 1, max: 7 }), "days").toISOString(),
831
- user_id: vc.user_id,
832
- consultation_mode: "video",
833
- days_until_followup: chance.integer({ min: 3, max: 14 }),
834
- });
835
- }
836
- });
837
- }
838
- }
839
-
840
- // ── HOOK 5: CHRONIC CONDITION REFILL CHAIN ───────
841
- // Patients with chronic prescriptions get refills every ~30 days.
842
- const chronicRxs = record.filter(e =>
843
- e.event === "prescription issued" && e.condition_type === "chronic"
844
- );
845
- if (chronicRxs.length > 0) {
846
- const templateRefill = record.find(e => e.event === "prescription refill");
847
- if (templateRefill) {
848
- chronicRxs.forEach(rx => {
849
- const rxTime = dayjs(rx.time);
850
- const refillsToAdd = chance.integer({ min: 2, max: 4 });
851
- for (let i = 1; i <= refillsToAdd; i++) {
852
- record.push({
853
- ...templateRefill,
854
- time: rxTime.add(30 * i + chance.integer({ min: -3, max: 3 }), "days").toISOString(),
855
- user_id: rx.user_id,
856
- condition_type: "chronic",
857
- medication_type: "chronic_maintenance",
858
- refill_count: i,
859
- });
860
- }
861
- });
862
- }
863
- }
864
-
865
- // HOOK 6: OCCASIONAL PATIENT NO-SHOWS — low-activity patients
866
- // (< 15 events) lose 25% of consultations (they booked but didn't show).
867
- if (record.length < 15) {
868
- for (let i = record.length - 1; i >= 0; i--) {
869
- if (record[i].event === "consultation completed" && chance.bool({ likelihood: 25 })) {
870
- record.splice(i, 1);
871
- }
872
- }
873
- }
874
-
875
- // HOOK 10: CONSULTATION-COUNT MAGIC NUMBER (no flags)
876
- // Sweet 3-6 consultations → +25% on consultation_fee. Over 7+ →
877
- // drop 30% of "follow up scheduled" events (over-consulted →
878
- // follow-up fatigue).
879
- const consultCt = record.filter(e => e.event === "consultation completed").length;
880
- if (consultCt >= 3 && consultCt <= 6) {
881
- record.forEach(e => {
882
- if (e.event === "consultation completed" && typeof e.consultation_fee === "number") {
883
- e.consultation_fee = Math.round(e.consultation_fee * 1.25);
884
- }
885
- });
886
- } else if (consultCt >= 7) {
887
- record.forEach(e => {
888
- if (e.event === "follow up scheduled" && typeof e.days_until_followup === "number") {
889
- e.days_until_followup = Math.round(e.days_until_followup * 1.5);
890
- }
891
- });
892
- }
893
-
894
- return record;
895
- }
896
-
938
+ hook(record, type, meta) {
939
+ if (type === "user") return handleUserHooks(record);
940
+ if (type === "everything") return handleEverythingHooks(record, meta);
897
941
  return record;
898
942
  },
899
943
  };