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