@ak--47/dungeon-master 1.5.3 → 1.6.0

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 (146) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +21 -12
  2. package/.claude/skills/create-dungeon/SKILL.md +65 -5
  3. package/.claude/skills/create-project/SKILL.md +96 -0
  4. package/.claude/skills/create-project/provision.mjs +336 -0
  5. package/.claude/skills/verify-dungeon/SKILL.md +56 -25
  6. package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
  7. package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
  8. package/.claude/skills/write-hooks/SKILL.md +156 -18
  9. package/CHANGELOG.md +211 -0
  10. package/HOOKS.md +566 -112
  11. package/README.md +28 -9
  12. package/docs/guides/1.3.0-upgrade-guide.md +262 -0
  13. package/docs/guides/1.3.1-upgrade-guide.md +328 -0
  14. package/docs/guides/1.4.0-upgrade-guide.md +130 -0
  15. package/docs/guides/1.4.1-upgrade-guide.md +125 -0
  16. package/docs/guides/1.4.3-upgrade-guide.md +200 -0
  17. package/docs/guides/1.4.4-upgrade-guide.md +95 -0
  18. package/docs/guides/1.5.0-upgrade-guide.md +426 -0
  19. package/docs/guides/1.5.1-upgrade-guide.md +178 -0
  20. package/docs/guides/1.5.3-upgrade-guide.md +117 -0
  21. package/docs/guides/1.6.0-upgrade-guide.md +204 -0
  22. package/dungeons/technical/stories-verify.js +152 -0
  23. package/dungeons/vertical/README.md +154 -0
  24. package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
  25. package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
  26. package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
  27. package/dungeons/vertical/community/community.js +1589 -0
  28. package/dungeons/vertical/community/community.sql +216 -0
  29. package/dungeons/vertical/community/community.verify.mjs +72 -0
  30. package/dungeons/vertical/crypto/crypto.js +1483 -0
  31. package/dungeons/vertical/crypto/crypto.sql +194 -0
  32. package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
  33. package/dungeons/vertical/dating/dating.js +1540 -0
  34. package/dungeons/vertical/dating/dating.sql +288 -0
  35. package/dungeons/vertical/dating/dating.verify.mjs +72 -0
  36. package/dungeons/vertical/devtools/devtools.js +1524 -0
  37. package/dungeons/vertical/devtools/devtools.sql +161 -0
  38. package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
  39. package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
  40. package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
  41. package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
  42. package/dungeons/vertical/education/education.js +1763 -0
  43. package/dungeons/vertical/education/education.sql +243 -0
  44. package/dungeons/vertical/education/education.verify.mjs +75 -0
  45. package/dungeons/vertical/fintech/fintech.js +1410 -0
  46. package/dungeons/vertical/fintech/fintech.sql +175 -0
  47. package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
  48. package/dungeons/vertical/fitness/fitness.js +1495 -0
  49. package/dungeons/vertical/fitness/fitness.sql +206 -0
  50. package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
  51. package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
  52. package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
  53. package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
  54. package/dungeons/vertical/gaming/gaming.js +1741 -0
  55. package/dungeons/vertical/gaming/gaming.sql +249 -0
  56. package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
  57. package/dungeons/vertical/healthcare/healthcare.js +1488 -0
  58. package/dungeons/vertical/healthcare/healthcare.sql +193 -0
  59. package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
  60. package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
  61. package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
  62. package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
  63. package/dungeons/vertical/logistics/logistics.js +1541 -0
  64. package/dungeons/vertical/logistics/logistics.sql +205 -0
  65. package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
  66. package/dungeons/vertical/marketplace/marketplace.js +1438 -0
  67. package/dungeons/vertical/marketplace/marketplace.sql +148 -0
  68. package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
  69. package/dungeons/vertical/{media.js → media/media.js} +557 -58
  70. package/dungeons/vertical/media/media.sql +173 -0
  71. package/dungeons/vertical/media/media.verify.mjs +74 -0
  72. package/dungeons/vertical/real-estate/real-estate.js +1629 -0
  73. package/dungeons/vertical/real-estate/real-estate.sql +167 -0
  74. package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
  75. package/dungeons/vertical/sass/sass.js +1733 -0
  76. package/dungeons/vertical/sass/sass.sql +173 -0
  77. package/dungeons/vertical/sass/sass.verify.mjs +74 -0
  78. package/dungeons/vertical/social/social.js +1456 -0
  79. package/dungeons/vertical/social/social.sql +160 -0
  80. package/dungeons/vertical/social/social.verify.mjs +76 -0
  81. package/dungeons/vertical/streaming/streaming.js +677 -0
  82. package/dungeons/vertical/streaming/streaming.sql +101 -0
  83. package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
  84. package/dungeons/vertical/support-desk/support-desk.js +741 -0
  85. package/dungeons/vertical/support-desk/support-desk.sql +137 -0
  86. package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
  87. package/dungeons/vertical/travel/travel.js +1603 -0
  88. package/dungeons/vertical/travel/travel.sql +236 -0
  89. package/dungeons/vertical/travel/travel.verify.mjs +72 -0
  90. package/index.js +2 -2
  91. package/lib/core/config-validator.js +37 -2
  92. package/lib/core/context.js +4 -2
  93. package/lib/core/dungeon-loader.js +20 -15
  94. package/lib/core/dungeon-to-json.js +22 -4
  95. package/lib/core/storage.js +3 -3
  96. package/lib/generators/funnels.js +36 -9
  97. package/lib/hook-helpers/cohort.js +53 -0
  98. package/lib/hook-helpers/index.js +8 -0
  99. package/lib/hook-helpers/mutate.js +1 -1
  100. package/lib/hook-helpers/shape.js +319 -0
  101. package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
  102. package/lib/hook-patterns/attributed-by-source.js +60 -58
  103. package/lib/hook-patterns/frequency-by-frequency.js +14 -4
  104. package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
  105. package/lib/hook-patterns/index.js +1 -1
  106. package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
  107. package/lib/orchestrators/user-loop.js +41 -0
  108. package/lib/templates/story-spec.schema.json +175 -0
  109. package/lib/utils/json-evaluator.js +1 -1
  110. package/lib/utils/utils.js +5 -4
  111. package/lib/verify/coerce.js +186 -0
  112. package/lib/verify/counting.js +270 -32
  113. package/lib/verify/emulate-breakdown.js +1285 -203
  114. package/lib/verify/first-time.js +89 -0
  115. package/lib/verify/flows.js +679 -0
  116. package/lib/verify/formula.js +259 -0
  117. package/lib/verify/funnel-engine.js +697 -113
  118. package/lib/verify/index.js +32 -4
  119. package/lib/verify/sessionize.js +275 -0
  120. package/lib/verify/story-runner.js +483 -0
  121. package/lib/verify/verify-dungeon.js +59 -37
  122. package/package.json +3 -2
  123. package/scripts/extract-dungeon-schema.mjs +2 -2
  124. package/scripts/run-many.mjs +2 -2
  125. package/scripts/verify-runner.mjs +2 -2
  126. package/scripts/verify-stories.mjs +244 -0
  127. package/types.d.ts +296 -30
  128. package/dungeons/vertical/ai-platform.js +0 -818
  129. package/dungeons/vertical/community.js +0 -966
  130. package/dungeons/vertical/crypto.js +0 -895
  131. package/dungeons/vertical/dating.js +0 -830
  132. package/dungeons/vertical/devtools.js +0 -1050
  133. package/dungeons/vertical/ecommerce.js +0 -811
  134. package/dungeons/vertical/education.js +0 -964
  135. package/dungeons/vertical/fintech.js +0 -946
  136. package/dungeons/vertical/fitness.js +0 -935
  137. package/dungeons/vertical/food-delivery.js +0 -934
  138. package/dungeons/vertical/gaming.js +0 -1120
  139. package/dungeons/vertical/healthcare.js +0 -945
  140. package/dungeons/vertical/insurance-application.js +0 -1039
  141. package/dungeons/vertical/logistics.js +0 -986
  142. package/dungeons/vertical/marketplace.js +0 -1019
  143. package/dungeons/vertical/real-estate.js +0 -872
  144. package/dungeons/vertical/sass.js +0 -995
  145. package/dungeons/vertical/social.js +0 -904
  146. package/dungeons/vertical/travel.js +0 -930
@@ -0,0 +1,1488 @@
1
+ // ── IMPORTS ──
2
+ import dayjs from "dayjs";
3
+ import utc from "dayjs/plugin/utc.js";
4
+ dayjs.extend(utc);
5
+ import "dotenv/config";
6
+ import * as u from "@ak--47/dungeon-master/utils";
7
+ import * as v from "ak-tools";
8
+ import { findFirstSequence, scaleFunnelTTC } from "@ak--47/dungeon-master/hook-helpers";
9
+ /** @typedef {import("../../../types").Dungeon} Config */
10
+
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
41
+ */
42
+
43
+ // ── HOOK STORIES ──
44
+ /*
45
+ * NOTE: Cohort effects are HIDDEN — discoverable via raw-prop breakdowns
46
+ * (HOD, day, tier) or behavioral cohorts. One exception: H6 stamps
47
+ * no_show=true on flagged bookings (a realistic appointment-status
48
+ * property, and the only selection-free way to verify per-event thinning
49
+ * on an activity-selected cohort).
50
+ *
51
+ * ───────────────────────────────────────────────────────────────
52
+ * 1. AFTER-HOURS SURGE PRICING (event hook)
53
+ * ───────────────────────────────────────────────────────────────
54
+ *
55
+ * PATTERN: Consultations between 7PM-7AM (after-hours) have 1.5x
56
+ * higher consultation_fee. Simulates urgent care premium pricing.
57
+ *
58
+ * HOW TO FIND IT IN MIXPANEL:
59
+ *
60
+ * Report 1: After-Hours Fee Premium
61
+ * • Report type: Insights
62
+ * • Event: "consultation completed"
63
+ * • Measure: Average of "consultation_fee"
64
+ * • Breakdown: hour of day
65
+ * • Expected: hours 19-06 UTC ~ 1.5x avg fee vs hours 07-18
66
+ * (after-hours ≈ $112, business ≈ $75)
67
+ *
68
+ * REAL-WORLD ANALOGUE: Telehealth platforms charge premiums for
69
+ * after-hours urgent consultations, a key revenue driver.
70
+ *
71
+ * ───────────────────────────────────────────────────────────────
72
+ * 2. FLU SEASON SPIKE (event hook)
73
+ * ───────────────────────────────────────────────────────────────
74
+ *
75
+ * PATTERN: During days 50-70 (flu season window), appointments
76
+ * with condition_type "respiratory" get 2x the wait_time and the
77
+ * condition is forced to "respiratory" 60% of the time.
78
+ *
79
+ * HOW TO FIND IT IN MIXPANEL:
80
+ *
81
+ * Report 1: Flu Season Volume
82
+ * • Report type: Insights
83
+ * • Event: "appointment booked"
84
+ * • Measure: Total
85
+ * • Filter: condition_type = "respiratory"
86
+ * • Line chart by week
87
+ * • Expected: Clear spike during flu season window (days 50-70)
88
+ *
89
+ * Report 2: Wait Time During Flu Season
90
+ * • Report type: Insights
91
+ * • Event: "appointment booked"
92
+ * • Measure: Average of "wait_time_hours"
93
+ * • Breakdown: "condition_type"
94
+ * • Filter: time within flu season
95
+ * • Expected: respiratory ~2x wait vs other conditions
96
+ *
97
+ * REAL-WORLD ANALOGUE: Seasonal illness creates predictable surges
98
+ * in appointment demand and wait times.
99
+ *
100
+ * ───────────────────────────────────────────────────────────────
101
+ * 3. EXPERIENCED DOCTOR SATISFACTION (everything hook)
102
+ * ───────────────────────────────────────────────────────────────
103
+ *
104
+ * PATTERN: Users who had >12 consultation events get ALL their
105
+ * satisfaction_scores redrawn uniform 4.0-5.0 (avg 4.5) vs the declared
106
+ * baseline weighNumRange(1,5,mode 3) ≈ 3.0. Simulates experienced
107
+ * doctors earning better reviews.
108
+ *
109
+ * HOW TO FIND IT IN MIXPANEL:
110
+ *
111
+ * Report 1: Satisfaction by Consultation Volume
112
+ * • Report type: Insights
113
+ * • Event: "consultation completed"
114
+ * • Measure: Average of "satisfaction_score"
115
+ * • Breakdown: behavioral cohort (>12 consultations vs fewer)
116
+ * • Expected: heavy consulters ~4.5 avg vs ~3.0 baseline; every one
117
+ * of their scores sits in [4.0, 5.0]
118
+ *
119
+ * REAL-WORLD ANALOGUE: Experienced providers develop better bedside
120
+ * manner and patient communication skills over time.
121
+ *
122
+ * ───────────────────────────────────────────────────────────────
123
+ * 4. VIDEO CONSULTATION FOLLOW-UP LIFT (everything hook)
124
+ * ───────────────────────────────────────────────────────────────
125
+ *
126
+ * PATTERN: Each video-mode consultation has a 60% chance to inject one
127
+ * cloned "follow up scheduled" event 1-7 days later (stamped
128
+ * consultation_mode="video", fresh days_until_followup 3-14). Users
129
+ * without an existing follow-up to clone from are skipped.
130
+ *
131
+ * HOW TO FIND IT IN MIXPANEL:
132
+ *
133
+ * Report 1: Follow-Up Rate by Consultation Mode
134
+ * • Report type: Insights
135
+ * • Event: "follow up scheduled"
136
+ * • Measure: Total per user
137
+ * • Breakdown: "consultation_mode" (from consultation completed)
138
+ * • Expected: video-consult users carry ~+0.6 extra follow-ups per
139
+ * video consultation vs phone-only users
140
+ *
141
+ * REAL-WORLD ANALOGUE: Face-to-face (video) consultations build
142
+ * stronger patient-doctor rapport, increasing follow-up compliance.
143
+ *
144
+ * ───────────────────────────────────────────────────────────────
145
+ * 5. CHRONIC CONDITION REFILL CHAIN (everything hook)
146
+ * ───────────────────────────────────────────────────────────────
147
+ *
148
+ * PATTERN: Patients with condition_type "chronic" on any prescription
149
+ * event get additional cloned prescription_refill events injected
150
+ * every ~30 days after the original. Creates periodic refill cadence.
151
+ *
152
+ * HOW TO FIND IT IN MIXPANEL:
153
+ *
154
+ * Report 1: Refill Volume by Condition
155
+ * • Report type: Insights
156
+ * • Event: "prescription refill"
157
+ * • Measure: Total
158
+ * • Breakdown: "condition_type"
159
+ * • Expected: "chronic" should have ~3-4x more refills than others
160
+ *
161
+ * REAL-WORLD ANALOGUE: Chronic conditions (diabetes, hypertension)
162
+ * require ongoing prescriptions creating predictable refill revenue.
163
+ *
164
+ * ───────────────────────────────────────────────────────────────
165
+ * 6. OCCASIONAL PATIENT NO-SHOWS (everything hook)
166
+ * ───────────────────────────────────────────────────────────────
167
+ *
168
+ * PATTERN: Low-activity users (<15 events — overwhelmingly occasional/
169
+ * churner patients; providers generate far more) lose 25% of their
170
+ * "consultation completed" events and get no_show=true stamped on 25%
171
+ * of their "appointment booked" events. Simulates occasional patients
172
+ * who book but don't show up.
173
+ *
174
+ * HOW TO FIND IT IN MIXPANEL:
175
+ *
176
+ * Report 1: No-Show Rate
177
+ * • Report type: Insights
178
+ * • Event: "appointment booked"
179
+ * • Measure: Total, filtered no_show = true, vs Total overall
180
+ * • Expected: no-shows concentrate entirely on low-activity users
181
+ * (~25% of their bookings); zero no-shows on active users
182
+ *
183
+ * Report 2: Appointment-to-Consultation Ratio
184
+ * • Report type: Funnels
185
+ * • Steps: "appointment booked" → "consultation completed"
186
+ * • Expected: low-activity users convert visibly worse (engineered
187
+ * 25% thinning compounded by their organically lower conversion)
188
+ *
189
+ * REAL-WORLD ANALOGUE: Infrequent patients have higher no-show rates,
190
+ * a major operational cost for healthcare providers.
191
+ *
192
+ * ───────────────────────────────────────────────────────────────
193
+ * 7. DOCTOR PROFILE SPECIALIZATION (user hook)
194
+ * ───────────────────────────────────────────────────────────────
195
+ *
196
+ * PATTERN: Users with role "doctor" get specialty set to a specific
197
+ * value (from the existing array) and years_experience boosted to
198
+ * senior range (15-30). Nurses get years_experience in mid range.
199
+ *
200
+ * HOW TO FIND IT IN MIXPANEL:
201
+ *
202
+ * Report 1: Experience Distribution by Role
203
+ * • Report type: Insights
204
+ * • Event: "consultation completed"
205
+ * • Measure: Average of user property "years_experience"
206
+ * • Breakdown: user property "role"
207
+ * • Expected: doctors ≈ 22 years, nurses ≈ 8, patients ≈ 0
208
+ *
209
+ * REAL-WORLD ANALOGUE: Provider profiles have specialized expertise
210
+ * and experience levels that affect patient matching.
211
+ *
212
+ * ───────────────────────────────────────────────────────────────
213
+ * 8. FREE-TIER CONVERSION DROP (everything hook)
214
+ * ───────────────────────────────────────────────────────────────
215
+ *
216
+ * PATTERN: 30% of free-tier users (per-user coin flip) lose ALL their
217
+ * "consultation completed" events — a per-user cliff, not per-event
218
+ * thinning. Surviving free users are statistically identical to paid
219
+ * users, which makes the effect cleanly measurable: the excess
220
+ * zero-consultation share among free users reads the 30% knob directly.
221
+ *
222
+ * HOW TO FIND IT IN MIXPANEL:
223
+ *
224
+ * Report 1: Booking Conversion by Tier
225
+ * • Report type: Funnels
226
+ * • Steps: "symptom search" → "appointment booked" → "consultation completed"
227
+ * • Breakdown: "subscription_tier"
228
+ * • Expected: free ≈ 28% vs basic/premium ≈ 40% conversion
229
+ *
230
+ * REAL-WORLD ANALOGUE: Free-tier patients face longer wait times
231
+ * and limited scheduling, reducing completed consultations.
232
+ *
233
+ * ───────────────────────────────────────────────────────────────
234
+ * 9. BOOKING FUNNEL TTC BY TIER (everything hook — property scaling)
235
+ *
236
+ * PATTERN: Premium users get shorter wait times and consultation
237
+ * durations (0.67x); Free users get longer (1.4x); Basic at 1.0x.
238
+ * Scales `wait_time_hours` on "appointment booked" and
239
+ * `duration_minutes` on "consultation completed".
240
+ *
241
+ * HOW TO FIND IT IN MIXPANEL:
242
+ *
243
+ * Report 1: Wait Time by Subscription Tier
244
+ * - Insights > "appointment booked"
245
+ * - Measure: Average of "wait_time_hours"
246
+ * - Breakdown: subscription_tier
247
+ * - Expected: premium ~ 0.67x baseline; free ~ 1.4x baseline
248
+ *
249
+ * Report 2: Consultation Duration by Tier
250
+ * - Insights > "consultation completed"
251
+ * - Measure: Average of "duration_minutes"
252
+ * - Breakdown: subscription_tier
253
+ * - Expected: premium ~ 0.67x baseline; free ~ 1.4x baseline
254
+ *
255
+ * ───────────────────────────────────────────────────────────────
256
+ * 10. CONSULTATION-COUNT MAGIC NUMBER (everything)
257
+ *
258
+ * PATTERN: Sweet 3-6 consultations → +25% on consultation_fee.
259
+ * Over 7+ → days_until_followup multiplied by 1.5 (over-consulted
260
+ * patients wait 50% longer for next visit). No flag.
261
+ *
262
+ * HOW TO FIND IT IN MIXPANEL:
263
+ *
264
+ * Report 1: Avg Consultation Fee by Consult-Count Bucket
265
+ * - Cohort A: users with 3-6 "consultation completed"
266
+ * - Cohort B: users with 0-2
267
+ * - Event: "consultation completed"
268
+ * - Measure: Average of "consultation_fee"
269
+ * - Expected: A ~ 1.25x B
270
+ *
271
+ * Report 2: Follow-Up Wait Time on Heavy Consulters
272
+ * - Cohort C: users with >= 7 consultations
273
+ * - Cohort A: users with 3-6
274
+ * - Event: "follow up scheduled"
275
+ * - Measure: Average of "days_until_followup"
276
+ * - Expected: C ~ 1.5x A (longer gap before next visit)
277
+ *
278
+ * REAL-WORLD ANALOGUE: Engaged patients pay more; over-engaged
279
+ * patients hit care-fatigue and stretch the gap to next visit.
280
+ *
281
+ * ═══════════════════════════════════════════════════════════════
282
+ * EXPECTED METRICS SUMMARY
283
+ * ═══════════════════════════════════════════════════════════════
284
+ *
285
+ * Hook | Metric | Expected | Measured (full fidelity)
286
+ * ----------------------------|-------------------------------|-----------------|-------------------------
287
+ * H1 After-Hours Pricing | fee after-hours / business | 1.5x | 1.494 (avg = median)
288
+ * H2 Flu Season Spike | respiratory share in-window | 0.65 (vs 0.125) | 0.654 (out: 0.125)
289
+ * H2 Flu Season Spike | resp/other wait in-window | 2x | 1.996
290
+ * H3 Experienced Doctor Sat. | satisfaction >12-consult users| avg+median 4.5 | 4.499 / 4.500 (0 impure)
291
+ * H4 Video Follow-Up Lift | extra follow-ups per video | +0.6 within 7d | +0.588
292
+ * | consult (within-7d diff) | |
293
+ * H5 Chronic Refill Chain | surviving clones / model | ~1.0 | 1.001 (placebo 0.048)
294
+ * | expectation (survival-adj) | |
295
+ * H6 Occasional No-Shows | no_show rate, <15-event users | 0.25 (0 on rest)| 0.248 (0 impure)
296
+ * H7 Doctor Specialization | years_experience by role | 22.5 / 9 / 0 | 22.46 / 9.00 / 0 exact
297
+ * H8 Free-Tier Cliff | excess zero-consult share | 0.30 | 0.313 (survivors 0.986)
298
+ * | (z_free−z_paid)/(1−z_paid) | |
299
+ * H9 Wait/Duration by Tier | free/basic, premium/basic | 1.4x / 0.67x | 1.40/0.671, 1.40/0.670
300
+ * H9 Funnel TTC by Tier | median TTC free/basic (emu) | >1 (diluted 1.4)| 1.157 (prem/basic 0.827)
301
+ * H10 Magic Number | sweet fee / low fee (median) | 1.25x | 1.219
302
+ * H10 Magic Number | over/sweet days_until_fu | 1.5x (phone fu) | 1.500
303
+ */
304
+
305
+ // ── SCALE ──
306
+ const SEED = "dm4-healthcare";
307
+ const NUM_USERS = 10_000;
308
+ const DATASET_START = "2026-01-01T00:00:00Z";
309
+ const DATASET_END = "2026-05-01T23:59:59Z";
310
+ const EVENTS_PER_DAY = 1.2;
311
+ const token = process.env.MP_TOKEN || "your-mixpanel-token";
312
+
313
+ const chance = u.initChance(SEED);
314
+
315
+ // ── KNOBS (tweak these to reshape stories) ──
316
+ const AFTER_HOURS_START = 19;
317
+ const AFTER_HOURS_END = 7;
318
+ const AFTER_HOURS_FEE_MULT = 1.5;
319
+
320
+ const FLU_START_DAY = 50;
321
+ const FLU_END_DAY = 70;
322
+ const FLU_RESPIRATORY_LIKELIHOOD = 60;
323
+ const FLU_WAIT_MULT = 2;
324
+
325
+ const EXPERIENCED_CONSULT_THRESHOLD = 12;
326
+ const EXPERIENCED_SATISFACTION_MIN = 4.0;
327
+ const EXPERIENCED_SATISFACTION_MAX = 5.0;
328
+
329
+ const VIDEO_FOLLOWUP_LIKELIHOOD = 60;
330
+
331
+ const CHRONIC_REFILL_MIN = 2;
332
+ const CHRONIC_REFILL_MAX = 4;
333
+ const CHRONIC_REFILL_INTERVAL_DAYS = 30;
334
+
335
+ const NO_SHOW_EVENT_THRESHOLD = 15;
336
+ const NO_SHOW_DROP_LIKELIHOOD = 25;
337
+
338
+ const DOCTOR_EXPERIENCE_MIN = 15;
339
+ const DOCTOR_EXPERIENCE_MAX = 30;
340
+ const NURSE_EXPERIENCE_MIN = 3;
341
+ const NURSE_EXPERIENCE_MAX = 15;
342
+
343
+ const FREE_TIER_DROP_LIKELIHOOD = 30;
344
+
345
+ const TTC_PREMIUM_FACTOR = 0.67;
346
+ const TTC_FREE_FACTOR = 1.4;
347
+
348
+ const CONSULT_SWEET_MIN = 3;
349
+ const CONSULT_SWEET_MAX = 6;
350
+ const CONSULT_OVER_THRESHOLD = 7;
351
+ const CONSULT_FEE_BOOST = 1.25;
352
+ const CONSULT_FOLLOWUP_STRETCH = 1.5;
353
+
354
+ // ── DATA ARRAYS ──
355
+ // Generate consistent doctor/clinic IDs at module level
356
+ const doctorIds = v.range(1, 120).map(() => `DR_${v.uid(6)}`);
357
+ const clinicIds = v.range(1, 25).map(() => `CLINIC_${v.uid(4)}`);
358
+
359
+ // ── HELPER FUNCTIONS ──
360
+ function handleUserHooks(record) {
361
+ // H7: DOCTOR PROFILE SPECIALIZATION — doctors get a real specialty and
362
+ // senior years_experience. Nurses get mid-range experience. Patients
363
+ // stay at defaults.
364
+ if (record.role === "doctor") {
365
+ record.specialty = chance.pickone(["cardiology", "dermatology", "pediatrics", "psychiatry", "general_practice", "pulmonology", "endocrinology"]);
366
+ record.years_experience = chance.integer({ min: DOCTOR_EXPERIENCE_MIN, max: DOCTOR_EXPERIENCE_MAX });
367
+ } else if (record.role === "nurse") {
368
+ record.specialty = chance.pickone(["general_practice", "pediatrics", "emergency"]);
369
+ record.years_experience = chance.integer({ min: NURSE_EXPERIENCE_MIN, max: NURSE_EXPERIENCE_MAX });
370
+ } else {
371
+ record.years_experience = 0;
372
+ }
373
+ return record;
374
+ }
375
+
376
+ function handleEverythingHooks(record, meta) {
377
+ if (!record.length) return record;
378
+ const profile = meta.profile;
379
+ const datasetStart = dayjs.unix(meta.datasetStart).utc();
380
+ const FLU_START = datasetStart.add(FLU_START_DAY, "days");
381
+ const FLU_END = datasetStart.add(FLU_END_DAY, "days");
382
+
383
+ // ── SUPER-PROP STAMPING ──────────────────────────
384
+ // Stamp superProps from profile so they are consistent per-user.
385
+ if (profile) {
386
+ const tier = profile.subscription_tier;
387
+ const plat = profile.Platform;
388
+ record.forEach(e => {
389
+ if (tier) e.subscription_tier = tier;
390
+ if (plat) e.Platform = plat;
391
+ });
392
+ }
393
+
394
+ // HOOK 9: BOOKING FUNNEL TTC BY TIER (property scaling)
395
+ // Premium users get shorter wait_time_hours (0.67x) and duration_minutes (0.67x).
396
+ // Free users get longer wait_time_hours (1.4x) and duration_minutes (1.4x).
397
+ // Basic users stay at baseline. SQL-measurable via AVG(wait_time_hours) broken by tier.
398
+ if (profile) {
399
+ const userTier = profile.subscription_tier;
400
+ const ttcFactor = userTier === "premium" ? TTC_PREMIUM_FACTOR : userTier === "free" ? TTC_FREE_FACTOR : 1.0;
401
+ if (ttcFactor !== 1.0) {
402
+ // Timestamp shift: affects Mixpanel funnel TTC
403
+ const bookingSeq = findFirstSequence(
404
+ record,
405
+ ["appointment booked", "consultation completed", "follow up scheduled"],
406
+ 60 * 24 * 30
407
+ );
408
+ if (bookingSeq) scaleFunnelTTC(bookingSeq, ttcFactor);
409
+ // Property scale: affects Insights AVG reports
410
+ record.forEach(e => {
411
+ if (e.event === "appointment booked" && typeof e.wait_time_hours === "number") {
412
+ e.wait_time_hours = Math.round(e.wait_time_hours * ttcFactor * 10) / 10;
413
+ }
414
+ if (e.event === "consultation completed" && typeof e.duration_minutes === "number") {
415
+ e.duration_minutes = Math.round(e.duration_minutes * ttcFactor);
416
+ }
417
+ });
418
+ }
419
+ }
420
+
421
+ // HOOK 1: AFTER-HOURS SURGE PRICING — consultations 7PM-7AM
422
+ // UTC get consultation_fee 1.5x. No flag — discover via HOD chart.
423
+ // Only "consultation completed" declares consultation_fee ("appointment
424
+ // booked" was a dead branch — its guard on e.consultation_fee never held).
425
+ // Runs after H9's timestamp shift, so the hour check sees final times.
426
+ record.forEach(e => {
427
+ if (e.event === "consultation completed") {
428
+ const hour = new Date(e.time).getUTCHours();
429
+ if ((hour >= AFTER_HOURS_START || hour < AFTER_HOURS_END) && e.consultation_fee) {
430
+ e.consultation_fee = Math.floor(e.consultation_fee * AFTER_HOURS_FEE_MULT);
431
+ }
432
+ }
433
+ });
434
+
435
+ // HOOK 2: FLU SEASON SPIKE — d50-70 respiratory dominates, wait_time doubles.
436
+ // UTC parses throughout — a machine-local dayjs() here would move the
437
+ // window boundaries by the generating machine's TZ offset, breaking the
438
+ // same-seed-same-output determinism promise across machines.
439
+ record.forEach(e => {
440
+ if (e.event !== "appointment booked") return;
441
+ const t = dayjs.utc(e.time);
442
+ if (t.isAfter(FLU_START) && t.isBefore(FLU_END)) {
443
+ if (chance.bool({ likelihood: FLU_RESPIRATORY_LIKELIHOOD })) e.condition_type = "respiratory";
444
+ if (e.condition_type === "respiratory") {
445
+ e.wait_time_hours = Math.floor((e.wait_time_hours || 12) * FLU_WAIT_MULT);
446
+ }
447
+ }
448
+ });
449
+
450
+ // ── HOOK 8: FREE-TIER CONVERSION DROP ────────────
451
+ // Free-tier users lose ~30% of "consultation completed" events
452
+ // (last step of Booking to Consultation funnel), simulating
453
+ // lower conversion for non-paying patients.
454
+ if (profile && profile.subscription_tier === "free" && chance.bool({ likelihood: FREE_TIER_DROP_LIKELIHOOD })) {
455
+ record = record.filter(e => e.event !== "consultation completed");
456
+ }
457
+
458
+ // ── HOOK 3: EXPERIENCED DOCTOR SATISFACTION ──────
459
+ // Users with >12 consultation events get boosted satisfaction scores.
460
+ let consultCount = 0;
461
+ record.forEach(e => {
462
+ if (e.event === "consultation completed") consultCount++;
463
+ });
464
+
465
+ if (consultCount > EXPERIENCED_CONSULT_THRESHOLD) {
466
+ record.forEach(e => {
467
+ if (e.event === "consultation completed") {
468
+ e.satisfaction_score = chance.floating({ min: EXPERIENCED_SATISFACTION_MIN, max: EXPERIENCED_SATISFACTION_MAX, fixed: 1 });
469
+ }
470
+ });
471
+ }
472
+
473
+ // ── HOOK 4: VIDEO CONSULTATION FOLLOW-UP LIFT ────
474
+ // Patients with video consultations get 2x follow-up events.
475
+ const hasVideoConsult = record.some(e =>
476
+ e.event === "consultation completed" && e.consultation_mode === "video"
477
+ );
478
+ if (hasVideoConsult) {
479
+ const templateFollowUp = record.find(e => e.event === "follow up scheduled");
480
+ if (templateFollowUp) {
481
+ const videoConsults = record.filter(e =>
482
+ e.event === "consultation completed" && e.consultation_mode === "video"
483
+ );
484
+ videoConsults.forEach(vc => {
485
+ if (chance.bool({ likelihood: VIDEO_FOLLOWUP_LIKELIHOOD })) {
486
+ record.push({
487
+ ...templateFollowUp,
488
+ time: dayjs(vc.time).add(chance.integer({ min: 1, max: 7 }), "days").toISOString(),
489
+ user_id: vc.user_id,
490
+ consultation_mode: "video",
491
+ days_until_followup: chance.integer({ min: 3, max: 14 }),
492
+ // fresh insert_id: the engine stamps insert_id at generation
493
+ // (lib/generators/events.js), so a bare spread copies the
494
+ // template's id and Mixpanel's $insert_id dedupe would
495
+ // silently drop every clone after the first
496
+ insert_id: chance.guid(),
497
+ });
498
+ }
499
+ });
500
+ }
501
+ }
502
+
503
+ // ── HOOK 5: CHRONIC CONDITION REFILL CHAIN ───────
504
+ // Patients with chronic prescriptions get refills every ~30 days.
505
+ const chronicRxs = record.filter(e =>
506
+ e.event === "prescription issued" && e.condition_type === "chronic"
507
+ );
508
+ if (chronicRxs.length > 0) {
509
+ const templateRefill = record.find(e => e.event === "prescription refill");
510
+ if (templateRefill) {
511
+ chronicRxs.forEach(rx => {
512
+ const rxTime = dayjs(rx.time);
513
+ const refillsToAdd = chance.integer({ min: CHRONIC_REFILL_MIN, max: CHRONIC_REFILL_MAX });
514
+ for (let i = 1; i <= refillsToAdd; i++) {
515
+ record.push({
516
+ ...templateRefill,
517
+ // clones past datasetEnd are dropped by the engine's
518
+ // unconditional future-time guard — late-window chronic
519
+ // prescriptions keep fewer of their refills by design
520
+ time: rxTime.add(CHRONIC_REFILL_INTERVAL_DAYS * i + chance.integer({ min: -3, max: 3 }), "days").toISOString(),
521
+ user_id: rx.user_id,
522
+ condition_type: "chronic",
523
+ medication_type: "chronic_maintenance",
524
+ refill_count: i,
525
+ // fresh insert_id — same $insert_id dedupe rationale as H4
526
+ insert_id: chance.guid(),
527
+ });
528
+ }
529
+ });
530
+ }
531
+ }
532
+
533
+ // HOOK 6: OCCASIONAL PATIENT NO-SHOWS — low-activity users (< 15 events
534
+ // at this point in the pipeline, clones included) lose 25% of their
535
+ // consultations and get no_show=true stamped on 25% of their bookings
536
+ // (they booked but didn't show). no_show is DECLARED [false] on
537
+ // "appointment booked" (schema-first rule), so flipped rows are the only
538
+ // true values in the dataset. Because the flag is decided before any
539
+ // later deletion and every subsequent step only shrinks a user's stream,
540
+ // users with >= 15 output events provably carry zero no_show=true rows.
541
+ if (record.length < NO_SHOW_EVENT_THRESHOLD) {
542
+ for (let i = record.length - 1; i >= 0; i--) {
543
+ if (record[i].event === "consultation completed" && chance.bool({ likelihood: NO_SHOW_DROP_LIKELIHOOD })) {
544
+ record.splice(i, 1);
545
+ }
546
+ }
547
+ record.forEach(e => {
548
+ if (e.event === "appointment booked" && chance.bool({ likelihood: NO_SHOW_DROP_LIKELIHOOD })) {
549
+ e.no_show = true;
550
+ }
551
+ });
552
+ }
553
+
554
+ // HOOK 10: CONSULTATION-COUNT MAGIC NUMBER (no flags)
555
+ // Sweet 3-6 consultations → +25% on consultation_fee. Over 7+ →
556
+ // days_until_followup stretched 1.5x (over-consulted patients wait
557
+ // longer for the next visit). Counts run AFTER all filters (H8/H6)
558
+ // and nothing drops consultations later, so output-side consult
559
+ // counts rebuild these cohorts exactly.
560
+ const consultCt = record.filter(e => e.event === "consultation completed").length;
561
+ if (consultCt >= CONSULT_SWEET_MIN && consultCt <= CONSULT_SWEET_MAX) {
562
+ record.forEach(e => {
563
+ if (e.event === "consultation completed" && typeof e.consultation_fee === "number") {
564
+ e.consultation_fee = Math.round(e.consultation_fee * CONSULT_FEE_BOOST);
565
+ }
566
+ });
567
+ } else if (consultCt >= CONSULT_OVER_THRESHOLD) {
568
+ record.forEach(e => {
569
+ if (e.event === "follow up scheduled" && typeof e.days_until_followup === "number") {
570
+ e.days_until_followup = Math.round(e.days_until_followup * CONSULT_FOLLOWUP_STRETCH);
571
+ }
572
+ });
573
+ }
574
+
575
+ return record;
576
+ }
577
+
578
+ // ── CONFIG ──
579
+ /** @type {Config} */
580
+ const config = {
581
+ version: 2,
582
+ seed: SEED,
583
+ datasetStart: DATASET_START,
584
+ datasetEnd: DATASET_END,
585
+ avgEventsPerUserPerDay: EVENTS_PER_DAY,
586
+ numUsers: NUM_USERS,
587
+ format: "json",
588
+ gzip: true,
589
+ credentials: {
590
+ token,
591
+ },
592
+ switches: {
593
+ hasSessionIds: true,
594
+ alsoInferFunnels: false,
595
+ hasLocation: true,
596
+ hasAndroidDevices: true,
597
+ hasIOSDevices: true,
598
+ hasDesktopDevices: true,
599
+ hasBrowser: false,
600
+ hasCampaigns: false,
601
+ isAnonymous: false,
602
+ hasAdSpend: false,
603
+ hasAvatar: true,
604
+ },
605
+ identity: {
606
+ avgDevicePerUser: 2,
607
+ },
608
+ concurrency: 1,
609
+ writeToDisk: false,
610
+ scdProps: {
611
+ care_plan: {
612
+ values: ["preventive", "routine", "chronic", "acute"],
613
+ frequency: "month",
614
+ timing: "fuzzy",
615
+ max: 8
616
+ }
617
+ },
618
+ mirrorProps: {},
619
+ lookupTables: [],
620
+
621
+ // ── Events (18) ──────────────────────────────────────────
622
+ events: [
623
+ {
624
+ event: "account created",
625
+ weight: 1,
626
+ isFirstEvent: true,
627
+ isAuthEvent: true,
628
+ properties: {
629
+ referral_source: ["organic", "doctor_referral", "insurance_partner", "social_media", "search"],
630
+ },
631
+ },
632
+ {
633
+ event: "symptom search",
634
+ weight: 7,
635
+ properties: {
636
+ search_term: ["headache", "fever", "cough", "back pain", "fatigue", "anxiety", "rash", "nausea", "chest pain", "joint pain"],
637
+ results_count: u.weighNumRange(0, 25, 0.5),
638
+ },
639
+ },
640
+ {
641
+ event: "appointment booked",
642
+ weight: 6,
643
+ isStrictEvent: false,
644
+ properties: {
645
+ doctor_id: chance.pickone.bind(chance, doctorIds),
646
+ clinic_id: chance.pickone.bind(chance, clinicIds),
647
+ condition_type: ["general", "general", "general", "respiratory", "dermatology", "mental_health", "chronic", "pediatric"],
648
+ wait_time_hours: u.weighNumRange(1, 72, 0.4),
649
+ appointment_type: ["new_patient", "follow_up", "follow_up", "urgent", "routine", "routine"],
650
+ // declared false; H6 flips to true on 25% of low-activity users' bookings
651
+ no_show: [false],
652
+ },
653
+ },
654
+ {
655
+ event: "consultation completed",
656
+ weight: 5,
657
+ isStrictEvent: false,
658
+ properties: {
659
+ doctor_id: chance.pickone.bind(chance, doctorIds),
660
+ consultation_mode: ["phone", "phone", "video"],
661
+ duration_minutes: u.weighNumRange(5, 60, 0.6, 15),
662
+ consultation_fee: u.weighNumRange(25, 200, 0.4, 75),
663
+ satisfaction_score: u.weighNumRange(1, 5, 0.8, 3),
664
+ condition_type: ["general", "general", "respiratory", "dermatology", "mental_health", "chronic", "pediatric"],
665
+ },
666
+ },
667
+ {
668
+ event: "prescription issued",
669
+ weight: 4,
670
+ isStrictEvent: false,
671
+ properties: {
672
+ medication_type: ["antibiotic", "antiviral", "painkiller", "anti_inflammatory", "antidepressant", "inhaler", "topical", "chronic_maintenance"],
673
+ quantity: u.weighNumRange(1, 90, 0.3, 30),
674
+ condition_type: ["general", "respiratory", "dermatology", "mental_health", "chronic", "chronic", "pediatric"],
675
+ refill_count: u.weighNumRange(0, 3),
676
+ },
677
+ },
678
+ {
679
+ event: "prescription refill",
680
+ weight: 3,
681
+ isStrictEvent: false,
682
+ properties: {
683
+ medication_type: ["antibiotic", "antiviral", "painkiller", "anti_inflammatory", "antidepressant", "inhaler", "topical", "chronic_maintenance"],
684
+ quantity: u.weighNumRange(1, 90, 0.3, 30),
685
+ condition_type: ["general", "respiratory", "dermatology", "mental_health", "chronic", "chronic", "pediatric"],
686
+ refill_count: u.weighNumRange(1, 6),
687
+ },
688
+ },
689
+ {
690
+ event: "follow up scheduled",
691
+ weight: 3,
692
+ isStrictEvent: false,
693
+ properties: {
694
+ doctor_id: chance.pickone.bind(chance, doctorIds),
695
+ days_until_followup: u.weighNumRange(3, 30, 0.5, 7),
696
+ condition_type: ["general", "respiratory", "dermatology", "mental_health", "chronic", "pediatric"],
697
+ consultation_mode: ["phone", "phone", "video"],
698
+ },
699
+ },
700
+ {
701
+ event: "message sent",
702
+ weight: 5,
703
+ properties: {
704
+ message_type: ["question", "question", "update", "result_inquiry", "prescription_question", "scheduling"],
705
+ recipient_role: ["doctor", "doctor", "nurse", "support"],
706
+ response_time_hours: u.weighNumRange(0.1, 48, 0.3, 4),
707
+ },
708
+ },
709
+ {
710
+ event: "lab results viewed",
711
+ weight: 3,
712
+ properties: {
713
+ test_type: ["blood_panel", "urinalysis", "imaging", "allergy_test", "metabolic_panel", "thyroid"],
714
+ result_status: ["normal", "normal", "normal", "abnormal", "pending"],
715
+ },
716
+ },
717
+ {
718
+ event: "health record accessed",
719
+ weight: 4,
720
+ properties: {
721
+ record_type: ["visit_summary", "lab_results", "prescriptions", "immunizations", "billing"],
722
+ access_method: ["app", "app", "web_portal"],
723
+ },
724
+ },
725
+ {
726
+ event: "insurance verified",
727
+ weight: 2,
728
+ properties: {
729
+ insurance_type: ["private", "private", "employer", "medicare", "medicaid", "self_pay"],
730
+ verification_status: ["approved", "approved", "approved", "pending", "denied"],
731
+ copay_amount: u.weighNumRange(0, 75, 0.5, 20),
732
+ },
733
+ },
734
+ {
735
+ event: "payment processed",
736
+ weight: 3,
737
+ properties: {
738
+ amount: u.weighNumRange(10, 500, 0.3, 75),
739
+ payment_method: ["credit_card", "credit_card", "insurance_claim", "hsa_fsa", "debit"],
740
+ payment_status: ["success", "success", "success", "success", "failed"],
741
+ },
742
+ },
743
+ {
744
+ event: "notification received",
745
+ weight: 6,
746
+ properties: {
747
+ notification_type: ["appointment_reminder", "appointment_reminder", "lab_ready", "prescription_ready", "message_received", "billing"],
748
+ channel: ["push", "push", "email", "sms"],
749
+ opened: [true, true, true, false],
750
+ },
751
+ },
752
+ {
753
+ event: "provider rated",
754
+ weight: 2,
755
+ properties: {
756
+ doctor_id: chance.pickone.bind(chance, doctorIds),
757
+ rating: u.weighNumRange(1, 5, 0.7, 4),
758
+ would_recommend: [true, true, true, true, false],
759
+ },
760
+ },
761
+ {
762
+ event: "support ticket created",
763
+ weight: 1,
764
+ properties: {
765
+ category: ["billing", "technical", "scheduling", "prescription", "insurance", "other"],
766
+ priority: ["low", "low", "medium", "medium", "high"],
767
+ resolution_hours: u.weighNumRange(1, 96, 0.4, 24),
768
+ },
769
+ },
770
+ {
771
+ event: "profile updated",
772
+ weight: 2,
773
+ properties: {
774
+ field_updated: ["insurance", "address", "phone", "emergency_contact", "allergies", "medications"],
775
+ },
776
+ },
777
+ {
778
+ event: "app session",
779
+ weight: 8,
780
+ properties: {
781
+ session_duration_sec: u.weighNumRange(10, 1800, 0.4, 120),
782
+ pages_viewed: u.weighNumRange(1, 15, 0.5, 3),
783
+ },
784
+ },
785
+ {
786
+ event: "account deactivated",
787
+ weight: 1,
788
+ isChurnEvent: true,
789
+ returnLikelihood: 0.15,
790
+ isStrictEvent: true,
791
+ properties: {
792
+ reason: ["switched_provider", "cost", "no_longer_needed", "poor_experience", "insurance_change"],
793
+ },
794
+ },
795
+ ],
796
+
797
+ // ── Funnels (5) ──────────────────────────────────────────
798
+ funnels: [
799
+ {
800
+ name: "Onboarding Flow",
801
+ sequence: ["account created", "insurance verified", "symptom search", "appointment booked"],
802
+ conversionRate: 45,
803
+ order: "sequential",
804
+ isFirstFunnel: true,
805
+ timeToConvert: 72,
806
+ weight: 3,
807
+ },
808
+ {
809
+ name: "Booking to Consultation",
810
+ sequence: ["symptom search", "appointment booked", "consultation completed"],
811
+ conversionRate: 40,
812
+ order: "sequential",
813
+ timeToConvert: 48,
814
+ weight: 5,
815
+ },
816
+ {
817
+ name: "Full Care Journey",
818
+ sequence: ["appointment booked", "consultation completed", "prescription issued", "follow up scheduled"],
819
+ conversionRate: 30,
820
+ order: "sequential",
821
+ timeToConvert: 168,
822
+ weight: 3,
823
+ },
824
+ {
825
+ name: "Prescription Lifecycle",
826
+ sequence: ["prescription issued", "prescription refill", "payment processed"],
827
+ conversionRate: 55,
828
+ order: "sequential",
829
+ timeToConvert: 720,
830
+ weight: 2,
831
+ },
832
+ {
833
+ name: "Patient Satisfaction",
834
+ sequence: ["consultation completed", "provider rated", "follow up scheduled"],
835
+ conversionRate: 25,
836
+ order: "sequential",
837
+ timeToConvert: 72,
838
+ weight: 2,
839
+ },
840
+ ],
841
+
842
+ // ── SuperProps ──────────────────────────────────────────
843
+ superProps: {
844
+ subscription_tier: ["free", "free", "free", "basic", "basic", "premium"],
845
+ Platform: ["ios", "android", "web"],
846
+ },
847
+
848
+ // ── UserProps ──────────────────────────────────────────
849
+ userProps: {
850
+ role: ["patient", "patient", "patient", "patient", "patient", "patient", "patient", "patient", "nurse", "doctor"],
851
+ specialty: ["none"],
852
+ years_experience: u.weighNumRange(0, 5, 0.5),
853
+ preferred_language: ["en", "en", "en", "en", "es", "pt", "de", "fr"],
854
+ has_chronic_condition: [false, false, false, true],
855
+ age_range: ["18-25", "26-35", "26-35", "36-45", "36-45", "46-55", "56-65", "65+"],
856
+ subscription_tier: ["free", "free", "free", "basic", "basic", "premium"],
857
+ Platform: ["ios", "android", "web"],
858
+ },
859
+
860
+ // ── Personas ──────────────────────────────────
861
+ personas: [
862
+ {
863
+ name: "doctor",
864
+ weight: 5,
865
+ eventMultiplier: 5.0,
866
+ conversionModifier: 1.8,
867
+ churnRate: 0.01,
868
+ properties: {
869
+ role: "doctor",
870
+ segment: "provider",
871
+ },
872
+ },
873
+ {
874
+ name: "nurse",
875
+ weight: 10,
876
+ eventMultiplier: 3.0,
877
+ conversionModifier: 1.5,
878
+ churnRate: 0.03,
879
+ properties: {
880
+ role: "nurse",
881
+ segment: "provider",
882
+ },
883
+ },
884
+ {
885
+ name: "patient_active",
886
+ weight: 40,
887
+ eventMultiplier: 1.0,
888
+ conversionModifier: 1.0,
889
+ churnRate: 0.05,
890
+ properties: {
891
+ role: "patient",
892
+ segment: "active_patient",
893
+ },
894
+ },
895
+ {
896
+ name: "patient_occasional",
897
+ weight: 30,
898
+ eventMultiplier: 0.5,
899
+ conversionModifier: 0.7,
900
+ churnRate: 0.12,
901
+ properties: {
902
+ role: "patient",
903
+ segment: "occasional_patient",
904
+ },
905
+ },
906
+ {
907
+ name: "patient_churner",
908
+ weight: 15,
909
+ eventMultiplier: 0.3,
910
+ conversionModifier: 0.3,
911
+ churnRate: 0.4,
912
+ properties: {
913
+ role: "patient",
914
+ segment: "churner",
915
+ },
916
+ activeWindow: { maxDays: 21 },
917
+ },
918
+ ],
919
+
920
+ hook(record, type, meta) {
921
+ if (type === "user") return handleUserHooks(record);
922
+ if (type === "everything") return handleEverythingHooks(record, meta);
923
+ return record;
924
+ },
925
+ };
926
+
927
+ // ── STORIES ──────────────────────────────────────────────────────────────
928
+ // Machine-checkable contract for the 10 numbered hooks. Evaluate with:
929
+ // node scripts/verify-stories.mjs dungeons/vertical/healthcare/healthcare.js --data-prefix verify-healthcare
930
+
931
+ const EV = `read_json_auto('{{PREFIX}}-EVENTS*.json', sample_size=-1, union_by_name=true)`;
932
+ const US = `read_json_auto('{{PREFIX}}-USERS*.json', sample_size=-1, union_by_name=true)`;
933
+
934
+ // Identity prelude. account created is both isAuthEvent and isFirstEvent, so
935
+ // born users auth on their very first event and user_id should be present on
936
+ // every record; the prelude still resolves through the device pool
937
+ // (avgDevicePerUser: 2, "anonymousIds" is the legacy USERS-shard key) as
938
+ // belt-and-braces for any device-only edge.
939
+ const ID_CTE = `dmap AS (SELECT unnest("anonymousIds") AS device_id, distinct_id FROM ${US}),
940
+ ev AS (SELECT coalesce(m.distinct_id::VARCHAR, e.user_id::VARCHAR, e.device_id::VARCHAR) AS uid,
941
+ e.time::TIMESTAMP AS t, e.* FROM ${EV} e LEFT JOIN dmap m ON e.device_id = m.device_id)`;
942
+
943
+ // Temporal boundaries computed from the same knobs the hooks use (the hook
944
+ // parses in UTC, so these UTC timestamps are exact window edges)
945
+ const FLU_IN_START_TS = dayjs.utc(DATASET_START).add(FLU_START_DAY, "day").format("YYYY-MM-DD HH:mm:ss");
946
+ const FLU_IN_END_TS = dayjs.utc(DATASET_START).add(FLU_END_DAY, "day").format("YYYY-MM-DD HH:mm:ss");
947
+ const END_TS = dayjs.utc(DATASET_END).format("YYYY-MM-DD HH:mm:ss");
948
+ // H4 window guard: consultations in the last 7 days can't be credited with a
949
+ // clone that would land past datasetEnd (future-time guard drops it)
950
+ const END_MINUS_7_TS = dayjs.utc(DATASET_END).subtract(7, "day").format("YYYY-MM-DD HH:mm:ss");
951
+
952
+ // Per-user consultation counts. H10 (and H3) classify on counts taken AFTER
953
+ // all filters (H8 free-tier cliff, H6 no-show thinning) and nothing drops
954
+ // consultations later, so output-side counts rebuild the hook cohorts exactly.
955
+ const CONSULT_CTE = `cc AS (SELECT uid, count(*) AS ct FROM ev WHERE event = 'consultation completed' GROUP BY 1)`;
956
+
957
+ /** @type {import("../../../types").DungeonStory[]} */
958
+ export const stories = [
959
+ {
960
+ id: "H1-after-hours-pricing",
961
+ hook: "H1",
962
+ archetype: "temporal-inflection",
963
+ narrative: `consultations between ${AFTER_HOURS_START}:00 and ${AFTER_HOURS_END}:00 UTC carry consultation_fee × ${AFTER_HOURS_FEE_MULT}. H10's sweet-spot fee boost rides both HOD bins equally (consult-count cohorts are hour-independent), so both the avg and median ratios read the ${AFTER_HOURS_FEE_MULT} knob directly (Math.floor bias < 1%)`,
964
+ assertions: [
965
+ {
966
+ breakdown: {
967
+ type: "duckdb",
968
+ sql: `WITH ${ID_CTE}
969
+ SELECT CASE WHEN extract(hour FROM t) >= ${AFTER_HOURS_START} OR extract(hour FROM t) < ${AFTER_HOURS_END} THEN 'after' ELSE 'business' END AS grp,
970
+ count(*) AS event_count, count(DISTINCT uid) AS user_count,
971
+ avg(consultation_fee) AS avg_fee, median(consultation_fee) AS med_fee
972
+ FROM ev WHERE event = 'consultation completed' GROUP BY 1`,
973
+ },
974
+ select: { a: { where: { grp: "after" } }, b: { where: { grp: "business" } } },
975
+ expect: { metric: "a.avg_fee / b.avg_fee", op: "between", target: [1.35, 1.65] },
976
+ minCohort: 400,
977
+ },
978
+ {
979
+ breakdown: {
980
+ type: "duckdb",
981
+ sql: `WITH ${ID_CTE}
982
+ SELECT CASE WHEN extract(hour FROM t) >= ${AFTER_HOURS_START} OR extract(hour FROM t) < ${AFTER_HOURS_END} THEN 'after' ELSE 'business' END AS grp,
983
+ count(*) AS event_count, count(DISTINCT uid) AS user_count,
984
+ avg(consultation_fee) AS avg_fee, median(consultation_fee) AS med_fee
985
+ FROM ev WHERE event = 'consultation completed' GROUP BY 1`,
986
+ },
987
+ select: { a: { where: { grp: "after" } }, b: { where: { grp: "business" } } },
988
+ // scaling a whole bin scales every quantile: median ratio = knob too
989
+ expect: { metric: "a.med_fee / b.med_fee", op: "between", target: [1.35, 1.65] },
990
+ minCohort: 400,
991
+ },
992
+ ],
993
+ },
994
+ {
995
+ id: "H2-flu-season",
996
+ hook: "H2",
997
+ archetype: "temporal-inflection",
998
+ narrative: `days ${FLU_START_DAY}-${FLU_END_DAY}: bookings are forced respiratory at ${FLU_RESPIRATORY_LIKELIHOOD}%, and every in-window respiratory booking gets wait_time_hours × ${FLU_WAIT_MULT}. Expected in-window respiratory share = 0.60 + 0.40 × 1/8 = 0.65 (declared mix is 1-in-8 respiratory); out-window share stays at the declared 0.125. H9's tier scaling rides all conditions equally, so the in-window resp/other wait ratio reads the ×${FLU_WAIT_MULT} knob`,
999
+ assertions: [
1000
+ {
1001
+ breakdown: {
1002
+ type: "duckdb",
1003
+ sql: `WITH ${ID_CTE}
1004
+ SELECT CASE WHEN t > TIMESTAMP '${FLU_IN_START_TS}' AND t < TIMESTAMP '${FLU_IN_END_TS}' THEN 'in' ELSE 'out' END AS grp,
1005
+ count(*) AS event_count, count(DISTINCT uid) AS user_count,
1006
+ count(*) FILTER (WHERE condition_type = 'respiratory')::DOUBLE / count(*) AS resp_share,
1007
+ avg(wait_time_hours) FILTER (WHERE condition_type = 'respiratory') AS resp_wait,
1008
+ avg(wait_time_hours) FILTER (WHERE condition_type <> 'respiratory') AS other_wait
1009
+ FROM ev WHERE event = 'appointment booked' GROUP BY 1`,
1010
+ },
1011
+ select: { i: { where: { grp: "in" } } },
1012
+ expect: { metric: "i.resp_share", op: "between", target: [0.58, 0.72] },
1013
+ minCohort: 200,
1014
+ },
1015
+ {
1016
+ breakdown: {
1017
+ type: "duckdb",
1018
+ sql: `WITH ${ID_CTE}
1019
+ SELECT CASE WHEN t > TIMESTAMP '${FLU_IN_START_TS}' AND t < TIMESTAMP '${FLU_IN_END_TS}' THEN 'in' ELSE 'out' END AS grp,
1020
+ count(*) AS event_count, count(DISTINCT uid) AS user_count,
1021
+ count(*) FILTER (WHERE condition_type = 'respiratory')::DOUBLE / count(*) AS resp_share
1022
+ FROM ev WHERE event = 'appointment booked' GROUP BY 1`,
1023
+ },
1024
+ select: { o: { where: { grp: "out" } } },
1025
+ // purity: forcing happens only inside the window
1026
+ expect: { metric: "o.resp_share", op: "between", target: [0.09, 0.16] },
1027
+ minCohort: 200,
1028
+ },
1029
+ {
1030
+ breakdown: {
1031
+ type: "duckdb",
1032
+ sql: `WITH ${ID_CTE}
1033
+ SELECT CASE WHEN t > TIMESTAMP '${FLU_IN_START_TS}' AND t < TIMESTAMP '${FLU_IN_END_TS}' THEN 'in' ELSE 'out' END AS grp,
1034
+ count(*) AS event_count, count(DISTINCT uid) AS user_count,
1035
+ avg(wait_time_hours) FILTER (WHERE condition_type = 'respiratory') AS resp_wait,
1036
+ avg(wait_time_hours) FILTER (WHERE condition_type <> 'respiratory') AS other_wait
1037
+ FROM ev WHERE event = 'appointment booked' GROUP BY 1`,
1038
+ },
1039
+ select: { i: { where: { grp: "in" } } },
1040
+ expect: { metric: "i.resp_wait / i.other_wait", op: "between", target: [1.7, 2.35] },
1041
+ minCohort: 200,
1042
+ },
1043
+ ],
1044
+ },
1045
+ {
1046
+ id: "H3-experienced-doctor-satisfaction",
1047
+ hook: "H3",
1048
+ archetype: "cohort-prop-scale",
1049
+ narrative: `users with >${EXPERIENCED_CONSULT_THRESHOLD} consultations get every satisfaction_score redrawn uniform [${EXPERIENCED_SATISFACTION_MIN}, ${EXPERIENCED_SATISFACTION_MAX}] (avg AND median 4.5 — both quantile reads of the uniform). Purity is exact: later hooks only DELETE consultations, so any user still >${EXPERIENCED_CONSULT_THRESHOLD} in the output was boosted — all surviving scores sit in the redrawn range. No ratio-vs-baseline assertion: the declared weighNumRange(1, 5, 0.8, 3) baseline is a 3-value seeded pool (the 4th arg is POOL SIZE, not mode), so the organic mean is not derivable from the schema`,
1050
+ assertions: [
1051
+ {
1052
+ // deterministic purity — a single sub-4.0 score on an
1053
+ // output->12-consult user is a hook bug, not sampling noise
1054
+ breakdown: {
1055
+ type: "duckdb",
1056
+ sql: `WITH ${ID_CTE}, ${CONSULT_CTE}
1057
+ SELECT count(*) FILTER (WHERE e.satisfaction_score < ${EXPERIENCED_SATISFACTION_MIN}) AS below_min,
1058
+ count(*) AS scores, count(DISTINCT c.uid) AS exp_users
1059
+ FROM cc c JOIN ev e ON e.uid = c.uid AND e.event = 'consultation completed'
1060
+ WHERE c.ct > ${EXPERIENCED_CONSULT_THRESHOLD}`,
1061
+ },
1062
+ assert: (rows) => {
1063
+ const r = (rows || [])[0];
1064
+ if (!r || Number(r.exp_users) === 0) return { pass: false, verdict: "NONE", detail: "no >12-consult users" };
1065
+ const clean = Number(r.below_min) === 0;
1066
+ return {
1067
+ pass: clean,
1068
+ verdict: clean ? "NAILED" : "INVERSE",
1069
+ detail: `below-4.0 scores=${r.below_min} of ${r.scores} across ${r.exp_users} experienced users (must be 0)`,
1070
+ };
1071
+ },
1072
+ },
1073
+ {
1074
+ breakdown: {
1075
+ type: "duckdb",
1076
+ sql: `WITH ${ID_CTE}, ${CONSULT_CTE},
1077
+ lab AS (SELECT uid, CASE WHEN ct > ${EXPERIENCED_CONSULT_THRESHOLD} THEN 'exp' WHEN ct <= 9 THEN 'base' ELSE 'mid' END AS grp FROM cc)
1078
+ SELECT l.grp, count(DISTINCT l.uid) AS user_count, count(*) AS event_count, avg(e.satisfaction_score) AS avg_sat
1079
+ FROM lab l JOIN ev e ON e.uid = l.uid AND e.event = 'consultation completed' GROUP BY 1`,
1080
+ },
1081
+ select: { x: { where: { grp: "exp" } } },
1082
+ // uniform [4.0, 5.0] → 4.5
1083
+ expect: { metric: "x.avg_sat", op: "between", target: [4.35, 4.65] },
1084
+ minCohort: 30,
1085
+ },
1086
+ {
1087
+ breakdown: {
1088
+ type: "duckdb",
1089
+ sql: `WITH ${ID_CTE}, ${CONSULT_CTE}
1090
+ SELECT 'exp' AS grp, count(DISTINCT c.uid) AS user_count, count(*) AS event_count, median(e.satisfaction_score) AS med_sat
1091
+ FROM cc c JOIN ev e ON e.uid = c.uid AND e.event = 'consultation completed'
1092
+ WHERE c.ct > ${EXPERIENCED_CONSULT_THRESHOLD}`,
1093
+ },
1094
+ select: { x: { where: { grp: "exp" } } },
1095
+ // median of uniform [4.0, 5.0] = 4.5 — independent quantile read
1096
+ expect: { metric: "x.med_sat", op: "between", target: [4.35, 4.65] },
1097
+ minCohort: 30,
1098
+ },
1099
+ ],
1100
+ },
1101
+ {
1102
+ id: "H4-video-followup-lift",
1103
+ hook: "H4",
1104
+ archetype: "cohort-count-scale",
1105
+ narrative: `each video consultation has a ${VIDEO_FOLLOWUP_LIKELIHOOD}% chance to inject one cloned follow-up 1-7 days later. Per-consultation attribution: counting follow-ups within 7d after each consultation, video minus phone reads the 0.6 knob with per-EVENT attribution that cancels user-level activity selection (organic near-rates are mode-blind: a consultation's mode is an iid per-event draw, so both bins sample the same users' timelines). Attenuation: a clone can also land within 7d of a neighboring phone consultation of the same user, inflating the phone bin — hence the band floor below 0.6. Cohort restricted to users with ≥1 follow-up (clone requires an organic template) and consultations ≥7d before datasetEnd (clones past the end are future-guard dropped). Deliberately single-assertion: user-level composites (video-users vs phone-only fu-per-consult) were tested and rejected — conditioning on fus>0 inflates the low-activity phone-only group, and tier/persona sampling coupling plus the video_consultation feature's launch-gated mode mix make any user-level band underivable from knobs`,
1106
+ assertions: [
1107
+ {
1108
+ breakdown: {
1109
+ type: "duckdb",
1110
+ sql: `WITH ${ID_CTE},
1111
+ fu_users AS (SELECT DISTINCT uid FROM ev WHERE event = 'follow up scheduled'),
1112
+ cons AS (SELECT e.uid, e.t, e.consultation_mode AS mode
1113
+ FROM ev e JOIN fu_users f ON f.uid = e.uid
1114
+ WHERE e.event = 'consultation completed' AND e.t <= TIMESTAMP '${END_MINUS_7_TS}'),
1115
+ cnt AS (SELECT c.uid, c.mode, c.t, count(fu.uid) AS fu7
1116
+ FROM cons c LEFT JOIN ev fu ON fu.uid = c.uid AND fu.event = 'follow up scheduled'
1117
+ AND fu.t > c.t AND fu.t <= c.t + INTERVAL 7 DAY
1118
+ GROUP BY 1, 2, 3)
1119
+ SELECT mode AS grp, count(*) AS consults, count(DISTINCT uid) AS user_count, avg(fu7) AS avg_fu7
1120
+ FROM cnt GROUP BY 1`,
1121
+ },
1122
+ select: { v: { where: { grp: "video" } }, p: { where: { grp: "phone" } } },
1123
+ expect: { metric: "v.avg_fu7 - p.avg_fu7", op: "between", target: [0.33, 0.78] },
1124
+ minCohort: 150,
1125
+ },
1126
+ ],
1127
+ },
1128
+ {
1129
+ id: "H5-chronic-refill-chain",
1130
+ hook: "H5",
1131
+ archetype: "cohort-count-scale",
1132
+ narrative: `each chronic prescription spawns ${CHRONIC_REFILL_MIN}-${CHRONIC_REFILL_MAX} cloned refills at ~${CHRONIC_REFILL_INTERVAL_DAYS}d intervals (condition_type=chronic, medication_type=chronic_maintenance, refill_count=i); clones past datasetEnd are future-guard dropped. The assertion rebuilds the survival model per prescription from its actual date (attempt i at +${CHRONIC_REFILL_INTERVAL_DAYS}·i days; P(n≥3)=2/3, P(n≥4)=1/3 from the uniform 2-4 draw), subtracts the organic chronic∧chronic_maintenance baseline measured on non-chronic-rx users (declared mix: 2/7 × 1/8 ≈ 0.036), and checks measured clones ÷ model expectation ≈ 1. Cohort restricted to chronic-rx users with ≥1 refill (the hook needs an organic template)`,
1133
+ assertions: [
1134
+ {
1135
+ breakdown: {
1136
+ type: "duckdb",
1137
+ sql: `WITH ${ID_CTE},
1138
+ refill_users AS (SELECT DISTINCT uid FROM ev WHERE event = 'prescription refill'),
1139
+ crx AS (SELECT e.uid, e.t FROM ev e JOIN refill_users ru ON ru.uid = e.uid
1140
+ WHERE e.event = 'prescription issued' AND e.condition_type = 'chronic'),
1141
+ cohort AS (SELECT uid FROM crx GROUP BY 1),
1142
+ exp_calc AS (SELECT sum(
1143
+ CASE WHEN t + INTERVAL 30 DAY <= TIMESTAMP '${END_TS}' THEN 1.0 ELSE 0 END
1144
+ + CASE WHEN t + INTERVAL 60 DAY <= TIMESTAMP '${END_TS}' THEN 1.0 ELSE 0 END
1145
+ + (2.0/3) * (CASE WHEN t + INTERVAL 90 DAY <= TIMESTAMP '${END_TS}' THEN 1.0 ELSE 0 END)
1146
+ + (1.0/3) * (CASE WHEN t + INTERVAL 120 DAY <= TIMESTAMP '${END_TS}' THEN 1.0 ELSE 0 END)) AS expected_clones
1147
+ FROM crx),
1148
+ r AS (SELECT e.uid, (e.condition_type = 'chronic' AND e.medication_type = 'chronic_maintenance') AS is_cm,
1149
+ (c.uid IS NOT NULL) AS in_cohort
1150
+ FROM ev e LEFT JOIN cohort c ON c.uid = e.uid WHERE e.event = 'prescription refill'),
1151
+ agg AS (SELECT count(*) FILTER (WHERE in_cohort) AS t_coh,
1152
+ count(*) FILTER (WHERE in_cohort AND is_cm) AS cm_coh,
1153
+ count(*) FILTER (WHERE NOT in_cohort) AS t_non,
1154
+ count(*) FILTER (WHERE NOT in_cohort AND is_cm) AS cm_non FROM r)
1155
+ SELECT 'all' AS grp, (SELECT count(*) FROM cohort) AS user_count,
1156
+ a.cm_non::DOUBLE / nullif(a.t_non, 0) AS organic_cm_rate,
1157
+ ((a.cm_coh - (a.cm_non::DOUBLE / nullif(a.t_non, 0)) * a.t_coh)
1158
+ / (1 - (a.cm_non::DOUBLE / nullif(a.t_non, 0)))) / nullif(x.expected_clones, 0) AS clone_yield
1159
+ FROM agg a, exp_calc x`,
1160
+ },
1161
+ select: { all: { where: { grp: "all" } } },
1162
+ // ±3d jitter and boundary effects keep this near but not at 1.0
1163
+ expect: { metric: "all.clone_yield", op: "between", target: [0.7, 1.35] },
1164
+ minCohort: 80,
1165
+ },
1166
+ {
1167
+ breakdown: {
1168
+ type: "duckdb",
1169
+ sql: `WITH ${ID_CTE},
1170
+ refill_users AS (SELECT DISTINCT uid FROM ev WHERE event = 'prescription refill'),
1171
+ crx AS (SELECT e.uid, e.t FROM ev e JOIN refill_users ru ON ru.uid = e.uid
1172
+ WHERE e.event = 'prescription issued' AND e.condition_type = 'chronic'),
1173
+ cohort AS (SELECT uid FROM crx GROUP BY 1)
1174
+ SELECT 'all' AS grp, count(*) AS event_count, count(DISTINCT e.uid) AS user_count,
1175
+ count(*) FILTER (WHERE e.condition_type = 'chronic' AND e.medication_type = 'chronic_maintenance')::DOUBLE / count(*) AS cm_rate
1176
+ FROM ev e LEFT JOIN cohort c ON c.uid = e.uid
1177
+ WHERE e.event = 'prescription refill' AND c.uid IS NULL`,
1178
+ },
1179
+ select: { all: { where: { grp: "all" } } },
1180
+ // placebo: non-chronic-rx users' refills carry only the declared
1181
+ // organic chronic∧chronic_maintenance mix (2/7 × 1/8 ≈ 0.036)
1182
+ expect: { metric: "all.cm_rate", op: "between", target: [0.015, 0.06] },
1183
+ minCohort: 200,
1184
+ },
1185
+ ],
1186
+ },
1187
+ {
1188
+ id: "H6-occasional-no-shows",
1189
+ hook: "H6",
1190
+ archetype: "cohort-count-scale",
1191
+ narrative: `users with <${NO_SHOW_EVENT_THRESHOLD} events (at hook time, clones included) lose ${NO_SHOW_DROP_LIKELIHOOD}% of consultations and get no_show=true on ${NO_SHOW_DROP_LIKELIHOOD}% of bookings. The flag gives selection-free verification of a per-event effect on an activity-selected cohort: flagged ⇒ hook-count ≤ 14 ⇒ output count ≤ 14 (everything after only deletes), so users with ≥15 output events provably carry ZERO no_show=true rows (exact purity), and the no_show rate among ≤14-event users reads the knob (diluted slightly by unflagged users who slipped under 15 when future-dated clones were guard-dropped). The consultation-drop side is asserted as a direction-only composite: the flagged cohort is dominated by occasional/churner personas whose conversionModifier (0.7/0.3) organically lowers consult-per-booking, and H8's free-tier cliff skews zero-consult users into the small bin — the engineered 25% thinning is inseparable from that selection, which is exactly why the no_show flag exists`,
1192
+ assertions: [
1193
+ {
1194
+ // deterministic purity
1195
+ breakdown: {
1196
+ type: "duckdb",
1197
+ sql: `WITH ${ID_CTE},
1198
+ tot AS (SELECT uid, count(*) AS ct FROM ev GROUP BY 1)
1199
+ SELECT count(*) FILTER (WHERE e.no_show = true AND t2.ct >= ${NO_SHOW_EVENT_THRESHOLD}) AS big_noshows,
1200
+ count(*) FILTER (WHERE e.no_show = true) AS all_noshows,
1201
+ count(DISTINCT t2.uid) FILTER (WHERE t2.ct < ${NO_SHOW_EVENT_THRESHOLD}) AS small_users
1202
+ FROM ev e JOIN tot t2 ON t2.uid = e.uid WHERE e.event = 'appointment booked'`,
1203
+ },
1204
+ assert: (rows) => {
1205
+ const r = (rows || [])[0];
1206
+ if (!r || Number(r.all_noshows) === 0) return { pass: false, verdict: "NONE", detail: "no no_show=true bookings at all" };
1207
+ const clean = Number(r.big_noshows) === 0;
1208
+ return {
1209
+ pass: clean,
1210
+ verdict: clean ? "NAILED" : "INVERSE",
1211
+ detail: `no_show=true on ≥15-event users: ${r.big_noshows} of ${r.all_noshows} total (must be 0; small-bin users=${r.small_users})`,
1212
+ };
1213
+ },
1214
+ },
1215
+ {
1216
+ breakdown: {
1217
+ type: "duckdb",
1218
+ sql: `WITH ${ID_CTE},
1219
+ tot AS (SELECT uid, count(*) AS ct FROM ev GROUP BY 1),
1220
+ bk AS (SELECT e.uid, count(*) AS bookings, count(*) FILTER (WHERE e.no_show = true) AS noshows
1221
+ FROM ev e JOIN tot t2 ON t2.uid = e.uid
1222
+ WHERE e.event = 'appointment booked' AND t2.ct < ${NO_SHOW_EVENT_THRESHOLD} GROUP BY 1)
1223
+ SELECT 'small' AS grp, count(*) AS user_count,
1224
+ sum(noshows)::DOUBLE / nullif(sum(bookings), 0) AS ns_rate
1225
+ FROM bk`,
1226
+ },
1227
+ select: { s: { where: { grp: "small" } } },
1228
+ expect: { metric: "s.ns_rate", op: "between", target: [0.15, 0.3] },
1229
+ minCohort: 150,
1230
+ },
1231
+ {
1232
+ // composite direction check (selection + engineered thinning)
1233
+ breakdown: {
1234
+ type: "duckdb",
1235
+ sql: `WITH ${ID_CTE},
1236
+ tot AS (SELECT uid, count(*) AS ct FROM ev GROUP BY 1),
1237
+ per AS (SELECT t2.uid, (t2.ct >= ${NO_SHOW_EVENT_THRESHOLD}) AS big,
1238
+ count(*) FILTER (WHERE e.event = 'appointment booked') AS bk,
1239
+ count(*) FILTER (WHERE e.event = 'consultation completed') AS cons
1240
+ FROM tot t2 JOIN ev e ON e.uid = t2.uid GROUP BY 1, 2)
1241
+ SELECT CASE WHEN big THEN 'big' ELSE 'small' END AS grp, count(*) AS user_count,
1242
+ sum(cons)::DOUBLE / nullif(sum(bk), 0) AS cons_per_bk
1243
+ FROM per WHERE bk > 0 GROUP BY 1`,
1244
+ },
1245
+ select: { s: { where: { grp: "small" } }, b: { where: { grp: "big" } } },
1246
+ expect: { metric: "s.cons_per_bk / b.cons_per_bk", op: "between", target: [0.2, 0.85] },
1247
+ minCohort: 150,
1248
+ },
1249
+ ],
1250
+ },
1251
+ {
1252
+ id: "H7-doctor-specialization",
1253
+ hook: "H7",
1254
+ archetype: "cohort-prop-scale",
1255
+ narrative: `user hook: doctors get specialty from a real list and years_experience uniform [${DOCTOR_EXPERIENCE_MIN}, ${DOCTOR_EXPERIENCE_MAX}] (avg 22.5); nurses uniform [${NURSE_EXPERIENCE_MIN}, ${NURSE_EXPERIENCE_MAX}] (avg 9); patients pinned to 0. Deterministic per-role ranges — range violations are hook bugs, not noise`,
1256
+ assertions: [
1257
+ {
1258
+ breakdown: {
1259
+ type: "duckdb",
1260
+ sql: `SELECT role AS grp, count(*) AS user_count,
1261
+ avg(years_experience) AS avg_yx, min(years_experience) AS min_yx, max(years_experience) AS max_yx,
1262
+ count(*) FILTER (WHERE specialty = 'none') AS none_ct
1263
+ FROM ${US} GROUP BY 1`,
1264
+ },
1265
+ assert: (rows) => {
1266
+ const by = Object.fromEntries((rows || []).map(r => [r.grp, r]));
1267
+ const d = by.doctor, n = by.nurse, p = by.patient;
1268
+ if (!d || !n || !p) return { pass: false, verdict: "NONE", detail: `missing role rows (${(rows || []).map(r => r.grp).join(",")})` };
1269
+ const bad = [];
1270
+ if (Number(d.min_yx) < DOCTOR_EXPERIENCE_MIN || Number(d.max_yx) > DOCTOR_EXPERIENCE_MAX) bad.push(`doctor yx [${d.min_yx}, ${d.max_yx}] outside [${DOCTOR_EXPERIENCE_MIN}, ${DOCTOR_EXPERIENCE_MAX}]`);
1271
+ if (Number(d.none_ct) !== 0) bad.push(`${d.none_ct} doctors with specialty='none'`);
1272
+ if (Number(n.min_yx) < NURSE_EXPERIENCE_MIN || Number(n.max_yx) > NURSE_EXPERIENCE_MAX) bad.push(`nurse yx [${n.min_yx}, ${n.max_yx}] outside [${NURSE_EXPERIENCE_MIN}, ${NURSE_EXPERIENCE_MAX}]`);
1273
+ if (Number(p.min_yx) !== 0 || Number(p.max_yx) !== 0) bad.push(`patient yx [${p.min_yx}, ${p.max_yx}] not pinned to 0`);
1274
+ return {
1275
+ pass: bad.length === 0,
1276
+ verdict: bad.length === 0 ? "NAILED" : "INVERSE",
1277
+ detail: bad.length ? bad.join("; ") : `ranges exact: doctor [${d.min_yx}, ${d.max_yx}], nurse [${n.min_yx}, ${n.max_yx}], patient pinned 0 (${d.user_count}/${n.user_count}/${p.user_count} users)`,
1278
+ };
1279
+ },
1280
+ },
1281
+ {
1282
+ breakdown: {
1283
+ type: "duckdb",
1284
+ sql: `SELECT role AS grp, count(*) AS user_count, avg(years_experience) AS avg_yx FROM ${US} GROUP BY 1`,
1285
+ },
1286
+ select: { d: { where: { grp: "doctor" } } },
1287
+ expect: { metric: "d.avg_yx", op: "between", target: [21, 24] },
1288
+ minCohort: 40,
1289
+ },
1290
+ {
1291
+ breakdown: {
1292
+ type: "duckdb",
1293
+ sql: `SELECT role AS grp, count(*) AS user_count, avg(years_experience) AS avg_yx FROM ${US} GROUP BY 1`,
1294
+ },
1295
+ select: { n: { where: { grp: "nurse" } } },
1296
+ expect: { metric: "n.avg_yx", op: "between", target: [8, 10] },
1297
+ minCohort: 80,
1298
+ },
1299
+ ],
1300
+ },
1301
+ {
1302
+ id: "H8-free-tier-cliff",
1303
+ hook: "H8",
1304
+ archetype: "funnel-conversion-by-segment",
1305
+ narrative: `${FREE_TIER_DROP_LIKELIHOOD}% of free-tier users lose ALL consultations (per-user cliff). Estimator: (z_free − z_paid) / (1 − z_paid) where z = zero-consultation user share — the natural-zero baseline z cancels, and tier-blind processes (H6 thinning) cancel too, so the statistic reads the 0.30 knob directly. Sharp discriminator vs per-event thinning: SURVIVING free users are untouched, so their consult counts must match basic users (ratio ≈ 1.0); thinning would read ~0.7. The survivor comparison is SEGMENT-STANDARDIZED: tier and persona are sampled from the same seeded stream and come out measurably correlated (free skews occasional, premium skews provider), and persona eventModifier drives volume — raw cross-tier count comparisons are confounded by composition, standardizing on the persona-stamped segment removes it (thinning would still read ~0.7 within every segment)`,
1306
+ assertions: [
1307
+ {
1308
+ breakdown: {
1309
+ type: "duckdb",
1310
+ sql: `WITH ${ID_CTE},
1311
+ per AS (SELECT u.distinct_id::VARCHAR AS uid, u.subscription_tier AS tier FROM ${US} u),
1312
+ cons AS (SELECT uid, count(*) AS ct FROM ev WHERE event = 'consultation completed' GROUP BY 1),
1313
+ j AS (SELECT p.tier, coalesce(c.ct, 0) AS ct FROM per p LEFT JOIN cons c ON c.uid = p.uid),
1314
+ z AS (SELECT count(*) AS user_count,
1315
+ count(*) FILTER (WHERE tier = 'free' AND ct = 0)::DOUBLE / nullif(count(*) FILTER (WHERE tier = 'free'), 0) AS z_free,
1316
+ count(*) FILTER (WHERE tier <> 'free' AND ct = 0)::DOUBLE / nullif(count(*) FILTER (WHERE tier <> 'free'), 0) AS z_paid
1317
+ FROM j)
1318
+ SELECT 'all' AS grp, user_count, z_free, z_paid,
1319
+ (z_free - z_paid) / nullif(1 - z_paid, 0) AS cliff_share FROM z`,
1320
+ },
1321
+ select: { all: { where: { grp: "all" } } },
1322
+ expect: { metric: "all.cliff_share", op: "between", target: [0.24, 0.36] },
1323
+ minCohort: 500,
1324
+ },
1325
+ {
1326
+ breakdown: {
1327
+ type: "duckdb",
1328
+ sql: `WITH ${ID_CTE},
1329
+ cons AS (SELECT uid, count(*) AS ct FROM ev WHERE event = 'consultation completed' GROUP BY 1),
1330
+ surv AS (SELECT u.subscription_tier AS tier, u.segment AS seg, c.ct
1331
+ FROM ${US} u JOIN cons c ON c.uid = u.distinct_id::VARCHAR),
1332
+ seg AS (SELECT seg,
1333
+ avg(ct) FILTER (WHERE tier = 'free') AS f_avg, count(*) FILTER (WHERE tier = 'free') AS f_n,
1334
+ avg(ct) FILTER (WHERE tier = 'basic') AS b_avg, count(*) FILTER (WHERE tier = 'basic') AS b_n
1335
+ FROM surv GROUP BY 1)
1336
+ SELECT 'all' AS grp, sum(f_n + b_n)::BIGINT AS user_count,
1337
+ sum(f_n * f_avg / b_avg) / sum(f_n) AS std_ratio
1338
+ FROM seg WHERE f_avg IS NOT NULL AND b_avg IS NOT NULL AND b_n >= 10`,
1339
+ },
1340
+ select: { all: { where: { grp: "all" } } },
1341
+ // per-user cliff, not thinning: survivors untouched → ratio ≈ 1.0
1342
+ // (b_n >= 10 is a stability guard against tiny-segment blowup)
1343
+ expect: { metric: "all.std_ratio", op: "between", target: [0.9, 1.1] },
1344
+ minCohort: 500,
1345
+ },
1346
+ {
1347
+ // the documented Mixpanel funnel report, through the emulator.
1348
+ // Window = funnel's 48h × H9's max stretch 1.4 (the free-tier
1349
+ // timestamp scaling rides this funnel's booked→consult gap).
1350
+ // Composite: the cliff (×0.7) compounds with H9 window censoring
1351
+ // on free — band sits below the pure-cliff 0.70
1352
+ breakdown: {
1353
+ type: "timeToConvert",
1354
+ steps: ["symptom search", "appointment booked", "consultation completed"],
1355
+ breakdownByUserProperty: "subscription_tier",
1356
+ conversionWindowMs: Math.round(48 * TTC_FREE_FACTOR * 3600 * 1000),
1357
+ },
1358
+ assert: (rows) => {
1359
+ const by = Object.fromEntries((rows || []).map(r => [r.segment_value, r]));
1360
+ const f = by.free, b = by.basic;
1361
+ if (!f || !b) return { pass: false, verdict: "NONE", detail: `missing tier rows (${(rows || []).map(r => r.segment_value).join(",")})` };
1362
+ const cf = f.step_counts[2] / f.step_counts[0];
1363
+ const cb = b.step_counts[2] / b.step_counts[0];
1364
+ const ratio = cf / cb;
1365
+ const pass = ratio >= 0.55 && ratio <= 0.8;
1366
+ return {
1367
+ pass,
1368
+ verdict: pass ? (Math.abs(ratio - 0.7) <= 0.07 ? "NAILED" : "STRONG") : (ratio < 1 ? "WEAK" : "INVERSE"),
1369
+ detail: `funnel conversion free=${cf.toFixed(4)} basic=${cb.toFixed(4)} ratio=${ratio.toFixed(3)} (expect ~0.70, band [0.55, 0.80]; entered free=${f.step_counts[0]} basic=${b.step_counts[0]})`,
1370
+ };
1371
+ },
1372
+ },
1373
+ ],
1374
+ },
1375
+ {
1376
+ id: "H9-ttc-by-tier",
1377
+ hook: "H9",
1378
+ archetype: "funnel-ttc-by-segment",
1379
+ narrative: `premium × ${TTC_PREMIUM_FACTOR} / free × ${TTC_FREE_FACTOR} on (a) wait_time_hours and duration_minutes (iid property scale — avg ratios read the knobs exactly; H2's flu doubling is tier-blind and cancels) and (b) the first booked→consult→follow-up sequence's timestamps (scaleFunnelTTC). The TTC assertions run through the Mixpanel-aligned emulator at a 2016h conversion window = max stretch ${TTC_FREE_FACTOR} × (2 gaps × 30d per-gap cap in findFirstSequence) — the window must cover the stretched support or censoring dilutes the free tier (the ai-platform H9 lesson). Only each user's FIRST sequence is scaled and the emulator's greedy first-conversion aligns with findFirstSequence's greedy scan, but organic re-conversions still dilute the measured ratio toward 1 — bands assume ≥25% of the full effect survives`,
1380
+ assertions: [
1381
+ {
1382
+ breakdown: {
1383
+ type: "duckdb",
1384
+ sql: `WITH ${ID_CTE}
1385
+ SELECT subscription_tier AS grp, count(*) AS event_count, count(DISTINCT uid) AS user_count, avg(wait_time_hours) AS avg_wait
1386
+ FROM ev WHERE event = 'appointment booked' GROUP BY 1`,
1387
+ },
1388
+ select: { f: { where: { grp: "free" } }, b: { where: { grp: "basic" } } },
1389
+ expect: { metric: "f.avg_wait / b.avg_wait", op: "between", target: [1.26, 1.54] },
1390
+ minCohort: 300,
1391
+ },
1392
+ {
1393
+ breakdown: {
1394
+ type: "duckdb",
1395
+ sql: `WITH ${ID_CTE}
1396
+ SELECT subscription_tier AS grp, count(*) AS event_count, count(DISTINCT uid) AS user_count, avg(wait_time_hours) AS avg_wait
1397
+ FROM ev WHERE event = 'appointment booked' GROUP BY 1`,
1398
+ },
1399
+ select: { p: { where: { grp: "premium" } }, b: { where: { grp: "basic" } } },
1400
+ expect: { metric: "p.avg_wait / b.avg_wait", op: "between", target: [0.6, 0.74] },
1401
+ minCohort: 300,
1402
+ },
1403
+ {
1404
+ breakdown: {
1405
+ type: "duckdb",
1406
+ sql: `WITH ${ID_CTE}
1407
+ SELECT subscription_tier AS grp, count(*) AS event_count, count(DISTINCT uid) AS user_count, avg(duration_minutes) AS avg_dur
1408
+ FROM ev WHERE event = 'consultation completed' GROUP BY 1`,
1409
+ },
1410
+ select: { f: { where: { grp: "free" } }, b: { where: { grp: "basic" } } },
1411
+ expect: { metric: "f.avg_dur / b.avg_dur", op: "between", target: [1.26, 1.54] },
1412
+ minCohort: 300,
1413
+ },
1414
+ {
1415
+ breakdown: {
1416
+ type: "duckdb",
1417
+ sql: `WITH ${ID_CTE}
1418
+ SELECT subscription_tier AS grp, count(*) AS event_count, count(DISTINCT uid) AS user_count, avg(duration_minutes) AS avg_dur
1419
+ FROM ev WHERE event = 'consultation completed' GROUP BY 1`,
1420
+ },
1421
+ select: { p: { where: { grp: "premium" } }, b: { where: { grp: "basic" } } },
1422
+ expect: { metric: "p.avg_dur / b.avg_dur", op: "between", target: [0.6, 0.74] },
1423
+ minCohort: 300,
1424
+ },
1425
+ {
1426
+ breakdown: {
1427
+ type: "timeToConvert",
1428
+ steps: ["appointment booked", "consultation completed", "follow up scheduled"],
1429
+ breakdownByUserProperty: "subscription_tier",
1430
+ // 2016h = 1.4 × 2 gaps × 30d per-gap cap (covers stretched support)
1431
+ conversionWindowMs: 2016 * 60 * 60 * 1000,
1432
+ },
1433
+ select: { f: { where: { segment_value: "free" } }, b: { where: { segment_value: "basic" } } },
1434
+ expect: { metric: "f.median_ttc_ms / b.median_ttc_ms", op: "between", target: [1.04, 1.44] },
1435
+ minCohort: 150,
1436
+ },
1437
+ {
1438
+ breakdown: {
1439
+ type: "timeToConvert",
1440
+ steps: ["appointment booked", "consultation completed", "follow up scheduled"],
1441
+ breakdownByUserProperty: "subscription_tier",
1442
+ conversionWindowMs: 2016 * 60 * 60 * 1000,
1443
+ },
1444
+ select: { p: { where: { segment_value: "premium" } }, b: { where: { segment_value: "basic" } } },
1445
+ expect: { metric: "p.median_ttc_ms / b.median_ttc_ms", op: "between", target: [0.6, 0.97] },
1446
+ minCohort: 150,
1447
+ },
1448
+ ],
1449
+ },
1450
+ {
1451
+ id: "H10-consult-count-magic-number",
1452
+ hook: "H10",
1453
+ archetype: "frequency-sweet-spot",
1454
+ narrative: `sweet ${CONSULT_SWEET_MIN}-${CONSULT_SWEET_MAX} consultations → consultation_fee × ${CONSULT_FEE_BOOST}; over ${CONSULT_OVER_THRESHOLD}+ → days_until_followup × ${CONSULT_FOLLOWUP_STRETCH}. Both are property-only mutations on cohorts the output rebuilds exactly (counts run after all filters). Median ratios are selection-free: scaling a whole cohort's iid draws scales every quantile by the knob. H1's after-hours boost rides all count-cohorts equally (hours are count-independent). The days assertion filters to consultation_mode='phone' follow-ups — H4's injected clones are always video with a different days distribution, and the over-cohort receives more clones`,
1455
+ assertions: [
1456
+ {
1457
+ breakdown: {
1458
+ type: "duckdb",
1459
+ sql: `WITH ${ID_CTE}, ${CONSULT_CTE},
1460
+ coh AS (SELECT uid, CASE WHEN ct BETWEEN ${CONSULT_SWEET_MIN} AND ${CONSULT_SWEET_MAX} THEN 'sweet'
1461
+ WHEN ct >= ${CONSULT_OVER_THRESHOLD} THEN 'over' ELSE 'low' END AS grp FROM cc)
1462
+ SELECT c.grp, count(DISTINCT c.uid) AS user_count, count(*) AS event_count, median(e.consultation_fee) AS med_fee
1463
+ FROM coh c JOIN ev e ON e.uid = c.uid AND e.event = 'consultation completed' GROUP BY 1`,
1464
+ },
1465
+ select: { s: { where: { grp: "sweet" } }, l: { where: { grp: "low" } } },
1466
+ expect: { metric: "s.med_fee / l.med_fee", op: "between", target: [1.12, 1.4] },
1467
+ minCohort: 60,
1468
+ },
1469
+ {
1470
+ breakdown: {
1471
+ type: "duckdb",
1472
+ sql: `WITH ${ID_CTE}, ${CONSULT_CTE},
1473
+ coh AS (SELECT uid, CASE WHEN ct BETWEEN ${CONSULT_SWEET_MIN} AND ${CONSULT_SWEET_MAX} THEN 'sweet'
1474
+ WHEN ct >= ${CONSULT_OVER_THRESHOLD} THEN 'over' ELSE 'low' END AS grp FROM cc)
1475
+ SELECT c.grp, count(DISTINCT c.uid) AS user_count, count(*) AS event_count, median(e.days_until_followup) AS med_days
1476
+ FROM coh c JOIN ev e ON e.uid = c.uid AND e.event = 'follow up scheduled' AND e.consultation_mode = 'phone'
1477
+ GROUP BY 1`,
1478
+ },
1479
+ select: { o: { where: { grp: "over" } }, s: { where: { grp: "sweet" } } },
1480
+ // Math.round on small integer days adds up to ~5% bias
1481
+ expect: { metric: "o.med_days / s.med_days", op: "between", target: [1.3, 1.75] },
1482
+ minCohort: 60,
1483
+ },
1484
+ ],
1485
+ },
1486
+ ];
1487
+
1488
+ export default config;