@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.
- package/.claude/skills/analyze-soup/SKILL.md +21 -12
- package/.claude/skills/create-dungeon/SKILL.md +65 -5
- package/.claude/skills/create-project/SKILL.md +96 -0
- package/.claude/skills/create-project/provision.mjs +336 -0
- package/.claude/skills/verify-dungeon/SKILL.md +56 -25
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
- package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
- package/.claude/skills/write-hooks/SKILL.md +156 -18
- package/CHANGELOG.md +211 -0
- package/HOOKS.md +566 -112
- package/README.md +28 -9
- package/docs/guides/1.3.0-upgrade-guide.md +262 -0
- package/docs/guides/1.3.1-upgrade-guide.md +328 -0
- package/docs/guides/1.4.0-upgrade-guide.md +130 -0
- package/docs/guides/1.4.1-upgrade-guide.md +125 -0
- package/docs/guides/1.4.3-upgrade-guide.md +200 -0
- package/docs/guides/1.4.4-upgrade-guide.md +95 -0
- package/docs/guides/1.5.0-upgrade-guide.md +426 -0
- package/docs/guides/1.5.1-upgrade-guide.md +178 -0
- package/docs/guides/1.5.3-upgrade-guide.md +117 -0
- package/docs/guides/1.6.0-upgrade-guide.md +204 -0
- package/dungeons/technical/stories-verify.js +152 -0
- package/dungeons/vertical/README.md +154 -0
- package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
- package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
- package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
- package/dungeons/vertical/community/community.js +1589 -0
- package/dungeons/vertical/community/community.sql +216 -0
- package/dungeons/vertical/community/community.verify.mjs +72 -0
- package/dungeons/vertical/crypto/crypto.js +1483 -0
- package/dungeons/vertical/crypto/crypto.sql +194 -0
- package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
- package/dungeons/vertical/dating/dating.js +1540 -0
- package/dungeons/vertical/dating/dating.sql +288 -0
- package/dungeons/vertical/dating/dating.verify.mjs +72 -0
- package/dungeons/vertical/devtools/devtools.js +1524 -0
- package/dungeons/vertical/devtools/devtools.sql +161 -0
- package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
- package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
- package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
- package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
- package/dungeons/vertical/education/education.js +1763 -0
- package/dungeons/vertical/education/education.sql +243 -0
- package/dungeons/vertical/education/education.verify.mjs +75 -0
- package/dungeons/vertical/fintech/fintech.js +1410 -0
- package/dungeons/vertical/fintech/fintech.sql +175 -0
- package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
- package/dungeons/vertical/fitness/fitness.js +1495 -0
- package/dungeons/vertical/fitness/fitness.sql +206 -0
- package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
- package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
- package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
- package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
- package/dungeons/vertical/gaming/gaming.js +1741 -0
- package/dungeons/vertical/gaming/gaming.sql +249 -0
- package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
- package/dungeons/vertical/healthcare/healthcare.js +1488 -0
- package/dungeons/vertical/healthcare/healthcare.sql +193 -0
- package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
- package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
- package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
- package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
- package/dungeons/vertical/logistics/logistics.js +1541 -0
- package/dungeons/vertical/logistics/logistics.sql +205 -0
- package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
- package/dungeons/vertical/marketplace/marketplace.js +1438 -0
- package/dungeons/vertical/marketplace/marketplace.sql +148 -0
- package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
- package/dungeons/vertical/{media.js → media/media.js} +557 -58
- package/dungeons/vertical/media/media.sql +173 -0
- package/dungeons/vertical/media/media.verify.mjs +74 -0
- package/dungeons/vertical/real-estate/real-estate.js +1629 -0
- package/dungeons/vertical/real-estate/real-estate.sql +167 -0
- package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
- package/dungeons/vertical/sass/sass.js +1733 -0
- package/dungeons/vertical/sass/sass.sql +173 -0
- package/dungeons/vertical/sass/sass.verify.mjs +74 -0
- package/dungeons/vertical/social/social.js +1456 -0
- package/dungeons/vertical/social/social.sql +160 -0
- package/dungeons/vertical/social/social.verify.mjs +76 -0
- package/dungeons/vertical/streaming/streaming.js +677 -0
- package/dungeons/vertical/streaming/streaming.sql +101 -0
- package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
- package/dungeons/vertical/support-desk/support-desk.js +741 -0
- package/dungeons/vertical/support-desk/support-desk.sql +137 -0
- package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
- package/dungeons/vertical/travel/travel.js +1603 -0
- package/dungeons/vertical/travel/travel.sql +236 -0
- package/dungeons/vertical/travel/travel.verify.mjs +72 -0
- package/index.js +2 -2
- package/lib/core/config-validator.js +37 -2
- package/lib/core/context.js +4 -2
- package/lib/core/dungeon-loader.js +20 -15
- package/lib/core/dungeon-to-json.js +22 -4
- package/lib/core/storage.js +3 -3
- package/lib/generators/funnels.js +36 -9
- package/lib/hook-helpers/cohort.js +53 -0
- package/lib/hook-helpers/index.js +8 -0
- package/lib/hook-helpers/mutate.js +1 -1
- package/lib/hook-helpers/shape.js +319 -0
- package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
- package/lib/hook-patterns/attributed-by-source.js +60 -58
- package/lib/hook-patterns/frequency-by-frequency.js +14 -4
- package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
- package/lib/hook-patterns/index.js +1 -1
- package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
- package/lib/orchestrators/user-loop.js +41 -0
- package/lib/templates/story-spec.schema.json +175 -0
- package/lib/utils/json-evaluator.js +1 -1
- package/lib/utils/utils.js +5 -4
- package/lib/verify/coerce.js +186 -0
- package/lib/verify/counting.js +270 -32
- package/lib/verify/emulate-breakdown.js +1285 -203
- package/lib/verify/first-time.js +89 -0
- package/lib/verify/flows.js +679 -0
- package/lib/verify/formula.js +259 -0
- package/lib/verify/funnel-engine.js +697 -113
- package/lib/verify/index.js +32 -4
- package/lib/verify/sessionize.js +275 -0
- package/lib/verify/story-runner.js +483 -0
- package/lib/verify/verify-dungeon.js +59 -37
- package/package.json +3 -2
- package/scripts/extract-dungeon-schema.mjs +2 -2
- package/scripts/run-many.mjs +2 -2
- package/scripts/verify-runner.mjs +2 -2
- package/scripts/verify-stories.mjs +244 -0
- package/types.d.ts +296 -30
- package/dungeons/vertical/ai-platform.js +0 -818
- package/dungeons/vertical/community.js +0 -966
- package/dungeons/vertical/crypto.js +0 -895
- package/dungeons/vertical/dating.js +0 -830
- package/dungeons/vertical/devtools.js +0 -1050
- package/dungeons/vertical/ecommerce.js +0 -811
- package/dungeons/vertical/education.js +0 -964
- package/dungeons/vertical/fintech.js +0 -946
- package/dungeons/vertical/fitness.js +0 -935
- package/dungeons/vertical/food-delivery.js +0 -934
- package/dungeons/vertical/gaming.js +0 -1120
- package/dungeons/vertical/healthcare.js +0 -945
- package/dungeons/vertical/insurance-application.js +0 -1039
- package/dungeons/vertical/logistics.js +0 -986
- package/dungeons/vertical/marketplace.js +0 -1019
- package/dungeons/vertical/real-estate.js +0 -872
- package/dungeons/vertical/sass.js +0 -995
- package/dungeons/vertical/social.js +0 -904
- 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;
|