@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,69 +1,44 @@
|
|
|
1
|
-
// ──
|
|
2
|
-
const SEED = "dm4-insurance";
|
|
3
|
-
const num_days = 120;
|
|
4
|
-
const num_users = 15_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
|
-
|
|
16
|
-
dayjs.extend(utc);
|
|
17
|
-
const chance = u.initChance(SEED);
|
|
18
|
-
|
|
19
7
|
/** @typedef {import("../../types").Dungeon} Config */
|
|
20
8
|
|
|
9
|
+
// ── OVERVIEW ──
|
|
21
10
|
/*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* renewal
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* KEY DATA STORY — VERSION 2.13 RELEASE:
|
|
50
|
-
* The app has gone through versions 2.10 → 2.11 → 2.12 → 2.13.
|
|
51
|
-
* Version 2.13 was released 10 days ago and fixed critical UX issues.
|
|
52
|
-
* Two effects are visible: support ticket volume drops immediately,
|
|
53
|
-
* and application funnel conversion improves significantly.
|
|
11
|
+
* NAME: SafeHaven Insurance
|
|
12
|
+
* APP: Modern insurance web application. Users browse coverage options,
|
|
13
|
+
* request quotes, complete multi-step applications, manage policies,
|
|
14
|
+
* file claims, and make premium payments. Spans auto / home / life /
|
|
15
|
+
* health / renters insurance types across web, iOS, and Android.
|
|
16
|
+
* SCALE: 15,000 users, ~1.8M events, 121 days (2026-01-01 → 2026-05-01)
|
|
17
|
+
* CORE LOOP: account created → quote requested → application submitted → policy activated → payment made
|
|
18
|
+
*
|
|
19
|
+
* EVENTS (18):
|
|
20
|
+
* page viewed (10) > application step completed (6) > quote requested (5)
|
|
21
|
+
* > payment made (5) > quote received (4) > application started (4)
|
|
22
|
+
* > claim status checked (4) > support ticket created (4) > coverage reviewed (4)
|
|
23
|
+
* > document uploaded (3) > application submitted (3) > support ticket resolved (3)
|
|
24
|
+
* > application approved (2) > policy activated (2) > claim filed (2)
|
|
25
|
+
* > profile updated (2) > renewal completed (2) > account created (1)
|
|
26
|
+
*
|
|
27
|
+
* FUNNELS (5):
|
|
28
|
+
* - Onboarding: account created → page viewed → quote requested (85%)
|
|
29
|
+
* - Application Completion: application started → step completed → document uploaded → application submitted (60%)
|
|
30
|
+
* - Application Approval: application submitted → application approved → policy activated (70%)
|
|
31
|
+
* - Claims Process: claim filed → claim status checked → support ticket created (50%, A/B experiment)
|
|
32
|
+
* - Policy Renewal: coverage reviewed → payment made → renewal completed (65%)
|
|
33
|
+
*
|
|
34
|
+
* USER PROPS: Platform, insurance_type, app_version, age_range, risk_profile, policy_count, lifetime_premium, preferred_contact
|
|
35
|
+
* SUPER PROPS: Platform, insurance_type, app_version
|
|
36
|
+
* SCD PROPS: policy_status (pending/active/lapsed/renewed, monthly fuzzy, max 8)
|
|
37
|
+
* GROUPS: none
|
|
54
38
|
*/
|
|
55
39
|
|
|
40
|
+
// ── HOOK STORIES ──
|
|
56
41
|
/*
|
|
57
|
-
* ===================================================================
|
|
58
|
-
* ANALYTICS HOOKS (10 hooks)
|
|
59
|
-
*
|
|
60
|
-
* Hooks 1-5: Version stamping, support ticket volume drop, application
|
|
61
|
-
* conversion boost, step-count magic number, application TTC by account type.
|
|
62
|
-
*
|
|
63
|
-
* Hooks 6-10: Claims experiment, risk profile approval gating, document
|
|
64
|
-
* upload retention, end-of-quarter renewal spike, claim-to-premium increase.
|
|
65
|
-
* ===================================================================
|
|
66
|
-
*
|
|
67
42
|
* NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable
|
|
68
43
|
* via raw-prop breakdowns (date, app_version, issue_category) or
|
|
69
44
|
* behavioral cohorts.
|
|
@@ -339,34 +314,406 @@ const chance = u.initChance(SEED);
|
|
|
339
314
|
* Claim-to-Premium | premium after claim | 1x | 2.0x | +100%
|
|
340
315
|
*/
|
|
341
316
|
|
|
342
|
-
// ──
|
|
317
|
+
// ── SCALE ──
|
|
318
|
+
const SEED = "dm4-insurance";
|
|
319
|
+
const NUM_USERS = 15_000;
|
|
320
|
+
const DATASET_START = "2026-01-01T00:00:00Z";
|
|
321
|
+
const DATASET_END = "2026-05-01T23:59:59Z";
|
|
322
|
+
const EVENTS_PER_DAY = 1.2;
|
|
323
|
+
const token = process.env.MP_TOKEN || "your-mixpanel-token";
|
|
324
|
+
|
|
325
|
+
const chance = u.initChance(SEED);
|
|
326
|
+
|
|
327
|
+
// ── KNOBS (tweak these to reshape stories) ──
|
|
328
|
+
// H1: Version stamping cutoffs (days from datasetStart / before datasetEnd)
|
|
329
|
+
const V211_DAY = 30;
|
|
330
|
+
const V212_DAY = 60;
|
|
331
|
+
const V213_DAYS_BEFORE_END = 10;
|
|
332
|
+
|
|
333
|
+
// H2: Support ticket volume
|
|
334
|
+
const TICKET_INJECT_MIN = 2;
|
|
335
|
+
const TICKET_INJECT_MAX = 3;
|
|
336
|
+
const TICKET_REMOVAL_BASE_PCT = 30;
|
|
337
|
+
const TICKET_REMOVAL_PER_DAY_PCT = 5.5;
|
|
338
|
+
const TICKET_REMOVAL_CAP_PCT = 85;
|
|
339
|
+
|
|
340
|
+
// H3: Application conversion boost
|
|
341
|
+
const PRE_V213_APPROVAL_DROP_LIKELIHOOD = 95;
|
|
342
|
+
|
|
343
|
+
// H4: Application-step magic number
|
|
344
|
+
const STEP_SWEET_MIN = 8;
|
|
345
|
+
const STEP_SWEET_MAX = 14;
|
|
346
|
+
const STEP_OVER_THRESHOLD = 15;
|
|
347
|
+
const STEP_PREMIUM_BOOST = 1.35;
|
|
348
|
+
const STEP_OVER_DROP_LIKELIHOOD = 40;
|
|
349
|
+
|
|
350
|
+
// H5: Application TTC target offsets (hours from first application started)
|
|
351
|
+
const TTC_BUSINESS_HOURS = 36;
|
|
352
|
+
const TTC_INDIVIDUAL_HOURS = 48;
|
|
353
|
+
const TTC_FAMILY_HOURS = 63;
|
|
354
|
+
|
|
355
|
+
// H7: Risk profile approval multipliers
|
|
356
|
+
const RISK_LOW_CONV_MULT = 1.8;
|
|
357
|
+
const RISK_HIGH_CONV_MULT = 0.3;
|
|
358
|
+
const RISK_CONV_CAP = 95;
|
|
359
|
+
|
|
360
|
+
// H8: Document upload retention
|
|
361
|
+
const DOC_RETENTION_MIN = 3;
|
|
362
|
+
const DOC_RETENTION_WINDOW_DAYS = 14;
|
|
363
|
+
const DOC_RETENTION_CUTOFF_DAYS = 30;
|
|
364
|
+
const DOC_RETENTION_DROP_LIKELIHOOD = 75;
|
|
365
|
+
|
|
366
|
+
// H9: End-of-quarter renewal spike (days from datasetStart)
|
|
367
|
+
const RENEWAL_SPIKE_START_DAY = 85;
|
|
368
|
+
const RENEWAL_SPIKE_END_DAY = 95;
|
|
369
|
+
const RENEWAL_CLONE_COUNT = 2; // 2 extra clones per renewal → 3x volume
|
|
370
|
+
const COVERAGE_CLONE_COUNT = 1; // 1 extra clone per coverage review → 2x volume
|
|
371
|
+
|
|
372
|
+
// H10: Claim-to-premium increase
|
|
373
|
+
const POST_CLAIM_PREMIUM_MULT = 2.0;
|
|
374
|
+
|
|
375
|
+
// ── HOOK STATE ──
|
|
376
|
+
// H10 closure: tracks users who filed a claim (one-shot consumption)
|
|
343
377
|
const claimFilers = new Map();
|
|
344
378
|
|
|
379
|
+
// ── HELPER FUNCTIONS ──
|
|
380
|
+
function handleFunnelPreHooks(record, meta) {
|
|
381
|
+
// H7: RISK PROFILE AFFECTS APPROVAL — low-risk 1.8x, high-risk 0.3x conversion
|
|
382
|
+
const isApprovalFunnel = meta.funnel?.sequence?.includes("application approved");
|
|
383
|
+
if (isApprovalFunnel) {
|
|
384
|
+
const risk = meta.profile?.risk_profile;
|
|
385
|
+
if (risk === "low") record.conversionRate = Math.min(RISK_CONV_CAP, Math.round(record.conversionRate * RISK_LOW_CONV_MULT));
|
|
386
|
+
else if (risk === "high") record.conversionRate = Math.round(record.conversionRate * RISK_HIGH_CONV_MULT);
|
|
387
|
+
}
|
|
388
|
+
return record;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function handleEventHooks(record) {
|
|
392
|
+
// H10: CLAIM-TO-PREMIUM INCREASE — premium doubled on first payment after claim
|
|
393
|
+
if (record.event === "claim filed") {
|
|
394
|
+
claimFilers.set(record.user_id, true);
|
|
395
|
+
}
|
|
396
|
+
if (record.event === "payment made" && claimFilers.has(record.user_id)) {
|
|
397
|
+
record.premium_amount = Math.round((record.premium_amount || 500) * POST_CLAIM_PREMIUM_MULT);
|
|
398
|
+
claimFilers.delete(record.user_id);
|
|
399
|
+
}
|
|
400
|
+
return record;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function handleEverythingHooks(record, meta) {
|
|
404
|
+
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
405
|
+
const datasetEnd = dayjs.unix(meta.datasetEnd);
|
|
406
|
+
const V211_DATE = datasetStart.add(V211_DAY, "days");
|
|
407
|
+
const V212_DATE = datasetStart.add(V212_DAY, "days");
|
|
408
|
+
const V213_DATE = datasetEnd.subtract(V213_DAYS_BEFORE_END, "days");
|
|
409
|
+
const userEvents = record;
|
|
410
|
+
if (userEvents.length === 0) return record;
|
|
411
|
+
|
|
412
|
+
// Stamp superProps from profile for consistency
|
|
413
|
+
const profile = meta.profile;
|
|
414
|
+
userEvents.forEach(e => {
|
|
415
|
+
e.Platform = profile.Platform;
|
|
416
|
+
e.insurance_type = profile.insurance_type;
|
|
417
|
+
e.app_version = profile.app_version;
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// Find a user_id from any existing event
|
|
421
|
+
const userId =
|
|
422
|
+
userEvents.find((e) => e.user_id)?.user_id ||
|
|
423
|
+
userEvents[0]?.device_id;
|
|
424
|
+
|
|
425
|
+
// ─── Hook #5: APPLICATION COMPLETION TIME-TO-CONVERT ───
|
|
426
|
+
// Runs FIRST: adjusts timestamps before version stamping.
|
|
427
|
+
// Business accounts complete the application funnel faster (0.74x),
|
|
428
|
+
// family accounts slower (1.3x). Individual stays at baseline.
|
|
429
|
+
// Assigns account_type deterministically per user via djb2 hash
|
|
430
|
+
// (independent of engine RNG). Places each "application approved"
|
|
431
|
+
// event at a fixed offset from the first "application started".
|
|
432
|
+
{
|
|
433
|
+
// djb2 hash of userId → deterministic cohort
|
|
434
|
+
let h = 5381;
|
|
435
|
+
for (let i = 0; i < userId.length; i++) {
|
|
436
|
+
h = ((h << 5) + h + userId.charCodeAt(i)) | 0;
|
|
437
|
+
}
|
|
438
|
+
const acctTypes = ["individual", "family", "business"];
|
|
439
|
+
const userAccountType = acctTypes[((h % 3) + 3) % 3];
|
|
440
|
+
// Stamp account created events with the deterministic type
|
|
441
|
+
for (const evt of userEvents) {
|
|
442
|
+
if (evt.event === "account created") {
|
|
443
|
+
evt.account_type = userAccountType;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
userEvents.sort((a, b) => new Date(a.time) - new Date(b.time));
|
|
447
|
+
// Collect all "application started" times
|
|
448
|
+
const startedTimes = [];
|
|
449
|
+
for (const evt of userEvents) {
|
|
450
|
+
if (evt.event === "application started") {
|
|
451
|
+
startedTimes.push(dayjs(evt.time).valueOf());
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (startedTimes.length > 0) {
|
|
455
|
+
const firstStartTime = startedTimes[0];
|
|
456
|
+
// Target hours: biz=36, indiv=48, family=63
|
|
457
|
+
// Ratios: biz/indiv≈0.75, family/indiv≈1.31
|
|
458
|
+
const targetHours = (
|
|
459
|
+
userAccountType === "business" ? TTC_BUSINESS_HOURS :
|
|
460
|
+
userAccountType === "family" ? TTC_FAMILY_HOURS :
|
|
461
|
+
TTC_INDIVIDUAL_HOURS
|
|
462
|
+
);
|
|
463
|
+
const targetMs = targetHours * 3600000;
|
|
464
|
+
for (const evt of userEvents) {
|
|
465
|
+
if (evt.event !== "application approved") continue;
|
|
466
|
+
const evtTime = dayjs(evt.time).valueOf();
|
|
467
|
+
const origGap = evtTime - firstStartTime;
|
|
468
|
+
// Small jitter from original gap (mod 4h) for variance
|
|
469
|
+
const jitter = origGap > 0 ? (origGap % (4 * 3600000)) : 0;
|
|
470
|
+
evt.time = dayjs(firstStartTime + targetMs + jitter).toISOString();
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ─── Hook #2: SUPPORT TICKET VOLUME ───
|
|
476
|
+
// PRE-V2.13: Inject 2-3 extra support tickets with bug-related categories
|
|
477
|
+
const preV213Tickets = userEvents.filter(
|
|
478
|
+
(e) =>
|
|
479
|
+
e.event === "support ticket created" &&
|
|
480
|
+
dayjs(e.time).isBefore(V213_DATE)
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
if (preV213Tickets.length > 0) {
|
|
484
|
+
const extraCount = chance.integer({ min: TICKET_INJECT_MIN, max: TICKET_INJECT_MAX });
|
|
485
|
+
const bugCategories = [
|
|
486
|
+
"form_crash",
|
|
487
|
+
"login_error",
|
|
488
|
+
"page_timeout",
|
|
489
|
+
"payment_failure",
|
|
490
|
+
];
|
|
491
|
+
|
|
492
|
+
for (let i = 0; i < extraCount; i++) {
|
|
493
|
+
// Pick a random pre-v2.13 ticket to base timing on
|
|
494
|
+
const sourceTicket = chance.pickone(preV213Tickets);
|
|
495
|
+
const sourceTime = dayjs(sourceTicket.time);
|
|
496
|
+
// Offset by a few hours to days
|
|
497
|
+
const offsetHours = chance.integer({ min: 1, max: 72 });
|
|
498
|
+
let newTime = sourceTime.add(offsetHours, "hours");
|
|
499
|
+
// Ensure it stays before v2.13
|
|
500
|
+
if (newTime.isAfter(V213_DATE)) {
|
|
501
|
+
newTime = V213_DATE.subtract(
|
|
502
|
+
chance.integer({ min: 1, max: 48 }),
|
|
503
|
+
"hours"
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Compute app_version for injected event
|
|
508
|
+
let injectedVersion;
|
|
509
|
+
if (newTime.isBefore(V211_DATE)) {
|
|
510
|
+
injectedVersion = "2.10";
|
|
511
|
+
} else if (newTime.isBefore(V212_DATE)) {
|
|
512
|
+
injectedVersion = "2.11";
|
|
513
|
+
} else {
|
|
514
|
+
injectedVersion = "2.12"; // always pre-v2.13
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
userEvents.push({
|
|
518
|
+
...sourceTicket,
|
|
519
|
+
time: newTime.toISOString(),
|
|
520
|
+
user_id: userId,
|
|
521
|
+
app_version: injectedVersion,
|
|
522
|
+
issue_category: chance.pickone(bugCategories),
|
|
523
|
+
priority: chance.pickone(["medium", "high", "high"]),
|
|
524
|
+
channel: chance.pickone([
|
|
525
|
+
"chat",
|
|
526
|
+
"phone",
|
|
527
|
+
"email",
|
|
528
|
+
"web_form",
|
|
529
|
+
]),
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// POST-V2.13: Remove support tickets with increasing probability
|
|
535
|
+
// Day 1 after release: ~30% removal
|
|
536
|
+
// Day 5: ~60% removal
|
|
537
|
+
// Day 10: ~85% removal
|
|
538
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
539
|
+
const evt = userEvents[i];
|
|
540
|
+
if (evt.event === "support ticket created") {
|
|
541
|
+
const evtTime = dayjs(evt.time);
|
|
542
|
+
if (evtTime.isAfter(V213_DATE)) {
|
|
543
|
+
const daysSinceRelease = evtTime.diff(
|
|
544
|
+
V213_DATE,
|
|
545
|
+
"days",
|
|
546
|
+
true
|
|
547
|
+
);
|
|
548
|
+
// Linear ramp: 30% base + 5.5% per day → ~85% at day 10
|
|
549
|
+
const removalLikelihood = Math.min(
|
|
550
|
+
TICKET_REMOVAL_CAP_PCT,
|
|
551
|
+
TICKET_REMOVAL_BASE_PCT + daysSinceRelease * TICKET_REMOVAL_PER_DAY_PCT
|
|
552
|
+
);
|
|
553
|
+
if (chance.bool({ likelihood: removalLikelihood })) {
|
|
554
|
+
userEvents.splice(i, 1);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ─── Hook #3: APPLICATION CONVERSION BOOST ───
|
|
561
|
+
// PRE-V2.13: Remove ALL application approved + policy activated events
|
|
562
|
+
// for ~95% of users (per-user gating, not per-event). This produces
|
|
563
|
+
// visible funnel completion gap pre-v2.13 vs post-v2.13.
|
|
564
|
+
if (chance.bool({ likelihood: PRE_V213_APPROVAL_DROP_LIKELIHOOD })) {
|
|
565
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
566
|
+
const evt = userEvents[i];
|
|
567
|
+
if (
|
|
568
|
+
(evt.event === "application approved" ||
|
|
569
|
+
evt.event === "policy activated") &&
|
|
570
|
+
dayjs(evt.time).isBefore(V213_DATE)
|
|
571
|
+
) {
|
|
572
|
+
userEvents.splice(i, 1);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Hook 4: APPLICATION-STEP MAGIC NUMBER (no flags)
|
|
578
|
+
// Sweet 8-14 application step completed → +35% on approved_premium
|
|
579
|
+
// for application approved events. Over 15+ → drop 40% of
|
|
580
|
+
// application approved events (fraud filter triggers).
|
|
581
|
+
const stepCount = userEvents.filter(e => e.event === "application step completed").length;
|
|
582
|
+
if (stepCount >= STEP_SWEET_MIN && stepCount <= STEP_SWEET_MAX) {
|
|
583
|
+
userEvents.forEach(e => {
|
|
584
|
+
if (e.event === "application approved" && typeof e.approved_premium === "number") {
|
|
585
|
+
e.approved_premium = Math.round(e.approved_premium * STEP_PREMIUM_BOOST);
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
} else if (stepCount >= STEP_OVER_THRESHOLD) {
|
|
589
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
590
|
+
if (userEvents[i].event === "application approved" && chance.bool({ likelihood: STEP_OVER_DROP_LIKELIHOOD })) {
|
|
591
|
+
userEvents.splice(i, 1);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// ─── Hook #8: DOCUMENT UPLOAD RETENTION ───
|
|
597
|
+
// Users with 3+ "document uploaded" in first 14 days retain;
|
|
598
|
+
// others lose 75% of events post-day-30.
|
|
599
|
+
if (meta.userIsBornInDataset) {
|
|
600
|
+
const firstT = userEvents[0]?.time;
|
|
601
|
+
if (firstT) {
|
|
602
|
+
const window14 = dayjs(firstT).add(DOC_RETENTION_WINDOW_DAYS, "days").toISOString();
|
|
603
|
+
const docUploads = userEvents.filter(
|
|
604
|
+
(e) => e.event === "document uploaded" && e.time <= window14
|
|
605
|
+
).length;
|
|
606
|
+
if (docUploads < DOC_RETENTION_MIN) {
|
|
607
|
+
const cutoff = dayjs(firstT).add(DOC_RETENTION_CUTOFF_DAYS, "days");
|
|
608
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
609
|
+
if (
|
|
610
|
+
dayjs(userEvents[i].time).isAfter(cutoff) &&
|
|
611
|
+
chance.bool({ likelihood: DOC_RETENTION_DROP_LIKELIHOOD })
|
|
612
|
+
) {
|
|
613
|
+
userEvents.splice(i, 1);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ─── Hook #9: END-OF-QUARTER RENEWAL SPIKE ───
|
|
621
|
+
// Days 85-95 get 3x renewal clones + 2x coverage reviewed clones
|
|
622
|
+
{
|
|
623
|
+
const spikeStart = datasetStart.add(RENEWAL_SPIKE_START_DAY, "days");
|
|
624
|
+
const spikeEnd = datasetStart.add(RENEWAL_SPIKE_END_DAY, "days");
|
|
625
|
+
const clones = [];
|
|
626
|
+
userEvents.forEach((e) => {
|
|
627
|
+
const t = dayjs(e.time);
|
|
628
|
+
if (t.isAfter(spikeStart) && t.isBefore(spikeEnd)) {
|
|
629
|
+
if (e.event === "renewal completed") {
|
|
630
|
+
for (let c = 0; c < RENEWAL_CLONE_COUNT; c++) {
|
|
631
|
+
clones.push({
|
|
632
|
+
...e,
|
|
633
|
+
time: t
|
|
634
|
+
.add(
|
|
635
|
+
chance.integer({ min: 5, max: 240 }),
|
|
636
|
+
"minutes"
|
|
637
|
+
)
|
|
638
|
+
.toISOString(),
|
|
639
|
+
insert_id: chance.guid(),
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
if (e.event === "coverage reviewed") {
|
|
644
|
+
for (let c = 0; c < COVERAGE_CLONE_COUNT; c++) {
|
|
645
|
+
clones.push({
|
|
646
|
+
...e,
|
|
647
|
+
time: t
|
|
648
|
+
.add(
|
|
649
|
+
chance.integer({ min: 5, max: 120 }),
|
|
650
|
+
"minutes"
|
|
651
|
+
)
|
|
652
|
+
.toISOString(),
|
|
653
|
+
insert_id: chance.guid(),
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
if (clones.length) userEvents.push(...clones);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// ─── Hook #1: VERSION STAMPING ───
|
|
663
|
+
// Runs LAST: stamps app_version based on FINAL timestamps
|
|
664
|
+
// (after Hook #5 TTC scaling has adjusted event times).
|
|
665
|
+
// v2.10 (days 0-30) → v2.11 (30-60) → v2.12 (60-90) → v2.13 (last 10 days)
|
|
666
|
+
for (const evt of userEvents) {
|
|
667
|
+
const eventTime = dayjs(evt.time);
|
|
668
|
+
if (eventTime.isBefore(V211_DATE)) {
|
|
669
|
+
evt.app_version = "2.10";
|
|
670
|
+
} else if (eventTime.isBefore(V212_DATE)) {
|
|
671
|
+
evt.app_version = "2.11";
|
|
672
|
+
} else if (eventTime.isBefore(V213_DATE)) {
|
|
673
|
+
evt.app_version = "2.12";
|
|
674
|
+
} else {
|
|
675
|
+
evt.app_version = "2.13";
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Sort events by time after injection/removal/shifting
|
|
680
|
+
userEvents.sort(
|
|
681
|
+
(a, b) => new Date(a.time) - new Date(b.time)
|
|
682
|
+
);
|
|
683
|
+
|
|
684
|
+
return record;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// ── CONFIG ──
|
|
345
688
|
/** @type {Config} */
|
|
346
689
|
const config = {
|
|
347
690
|
version: 2,
|
|
348
|
-
token,
|
|
349
691
|
seed: SEED,
|
|
350
|
-
datasetStart:
|
|
351
|
-
datasetEnd:
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
numUsers: num_users,
|
|
355
|
-
hasAnonIds: true,
|
|
356
|
-
avgDevicePerUser: 2,
|
|
357
|
-
hasSessionIds: true,
|
|
692
|
+
datasetStart: DATASET_START,
|
|
693
|
+
datasetEnd: DATASET_END,
|
|
694
|
+
avgEventsPerUserPerDay: EVENTS_PER_DAY,
|
|
695
|
+
numUsers: NUM_USERS,
|
|
358
696
|
format: "json",
|
|
359
697
|
gzip: true,
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
698
|
+
credentials: {
|
|
699
|
+
token,
|
|
700
|
+
},
|
|
701
|
+
switches: {
|
|
702
|
+
hasSessionIds: true,
|
|
703
|
+
alsoInferFunnels: false,
|
|
704
|
+
hasLocation: true,
|
|
705
|
+
hasAndroidDevices: true,
|
|
706
|
+
hasIOSDevices: true,
|
|
707
|
+
hasDesktopDevices: true,
|
|
708
|
+
hasBrowser: false,
|
|
709
|
+
hasCampaigns: false,
|
|
710
|
+
isAnonymous: false,
|
|
711
|
+
hasAdSpend: false,
|
|
712
|
+
hasAvatar: true,
|
|
713
|
+
},
|
|
714
|
+
identity: {
|
|
715
|
+
avgDevicePerUser: 2,
|
|
716
|
+
},
|
|
370
717
|
concurrency: 1,
|
|
371
718
|
writeToDisk: false,
|
|
372
719
|
|
|
@@ -381,7 +728,6 @@ const config = {
|
|
|
381
728
|
mirrorProps: {},
|
|
382
729
|
lookupTables: [],
|
|
383
730
|
|
|
384
|
-
// ── Events ──
|
|
385
731
|
events: [
|
|
386
732
|
{
|
|
387
733
|
event: "account created",
|
|
@@ -604,7 +950,6 @@ const config = {
|
|
|
604
950
|
},
|
|
605
951
|
],
|
|
606
952
|
|
|
607
|
-
// ── Funnels ──
|
|
608
953
|
funnels: [
|
|
609
954
|
{
|
|
610
955
|
name: "Onboarding",
|
|
@@ -666,14 +1011,12 @@ const config = {
|
|
|
666
1011
|
},
|
|
667
1012
|
],
|
|
668
1013
|
|
|
669
|
-
// ── Super Props (on every event) ──
|
|
670
1014
|
superProps: {
|
|
671
1015
|
Platform: ["web", "ios", "android"],
|
|
672
1016
|
insurance_type: ["auto", "home", "life", "health", "renters"],
|
|
673
1017
|
app_version: ["2.10"],
|
|
674
1018
|
},
|
|
675
1019
|
|
|
676
|
-
// ── User Props (set once per user) ──
|
|
677
1020
|
userProps: {
|
|
678
1021
|
Platform: ["web", "ios", "android"],
|
|
679
1022
|
insurance_type: ["auto", "home", "life", "health", "renters"],
|
|
@@ -685,335 +1028,10 @@ const config = {
|
|
|
685
1028
|
preferred_contact: ["email", "phone", "app_notification"],
|
|
686
1029
|
},
|
|
687
1030
|
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
* This hook function creates 10 deliberate patterns in the data:
|
|
693
|
-
*
|
|
694
|
-
* 1. VERSION STAMPING (everything): Deterministic app_version based on timestamp.
|
|
695
|
-
* 2. SUPPORT TICKET VOLUME (everything): Inflated pre-v2.13, progressive drop post.
|
|
696
|
-
* 3. APPLICATION CONVERSION BOOST (everything): Pre-v2.13 approval drop.
|
|
697
|
-
* 4. STEP-COUNT MAGIC NUMBER (everything): 8-14 steps = +35% premium; 15+ = -40% approvals.
|
|
698
|
-
* 5. APPLICATION TTC (everything): Business 0.74x faster, family 1.3x slower.
|
|
699
|
-
* 6. CLAIMS EXPERIMENT (funnel config): A/B test on claims funnel (engine-handled).
|
|
700
|
-
* 7. RISK PROFILE APPROVAL (funnel-pre): Low-risk 1.8x, high-risk 0.3x conversion.
|
|
701
|
-
* 8. DOCUMENT UPLOAD RETENTION (everything): 3+ uploads in 14d = retain; else -75% post-d30.
|
|
702
|
-
* 9. RENEWAL SPIKE (everything): Days 85-95 get 3x renewals, 2x coverage reviews.
|
|
703
|
-
* 10. CLAIM-TO-PREMIUM (event): Premium 2.0x on first payment after claim filed.
|
|
704
|
-
*/
|
|
705
|
-
hook: function (record, type, meta) {
|
|
706
|
-
// =============================================================
|
|
707
|
-
// H7: RISK PROFILE AFFECTS APPROVAL (funnel-pre)
|
|
708
|
-
// =============================================================
|
|
709
|
-
if (type === "funnel-pre") {
|
|
710
|
-
const isApprovalFunnel = meta.funnel?.sequence?.includes("application approved");
|
|
711
|
-
if (isApprovalFunnel) {
|
|
712
|
-
const risk = meta.profile?.risk_profile;
|
|
713
|
-
if (risk === "low") record.conversionRate = Math.min(95, Math.round(record.conversionRate * 1.8));
|
|
714
|
-
else if (risk === "high") record.conversionRate = Math.round(record.conversionRate * 0.3);
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
// =============================================================
|
|
719
|
-
// H10: CLAIM-TO-PREMIUM INCREASE (event — closure Map)
|
|
720
|
-
// =============================================================
|
|
721
|
-
if (type === "event") {
|
|
722
|
-
if (record.event === "claim filed") {
|
|
723
|
-
claimFilers.set(record.user_id, true);
|
|
724
|
-
}
|
|
725
|
-
if (record.event === "payment made" && claimFilers.has(record.user_id)) {
|
|
726
|
-
record.premium_amount = Math.round((record.premium_amount || 500) * 2.0);
|
|
727
|
-
claimFilers.delete(record.user_id);
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
// =============================================================
|
|
732
|
-
// Everything hooks: H5 first (timestamps), then H2-H4, then
|
|
733
|
-
// H8-H9, then H1 (version stamping LAST).
|
|
734
|
-
// =============================================================
|
|
735
|
-
if (type === "everything") {
|
|
736
|
-
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
737
|
-
const datasetEnd = dayjs.unix(meta.datasetEnd);
|
|
738
|
-
const V211_DATE = datasetStart.add(30, "days");
|
|
739
|
-
const V212_DATE = datasetStart.add(60, "days");
|
|
740
|
-
const V213_DATE = datasetEnd.subtract(10, "days");
|
|
741
|
-
const userEvents = record;
|
|
742
|
-
if (userEvents.length === 0) return record;
|
|
743
|
-
|
|
744
|
-
// Stamp superProps from profile for consistency
|
|
745
|
-
const profile = meta.profile;
|
|
746
|
-
userEvents.forEach(e => {
|
|
747
|
-
e.Platform = profile.Platform;
|
|
748
|
-
e.insurance_type = profile.insurance_type;
|
|
749
|
-
e.app_version = profile.app_version;
|
|
750
|
-
});
|
|
751
|
-
|
|
752
|
-
// Find a user_id from any existing event
|
|
753
|
-
const userId =
|
|
754
|
-
userEvents.find((e) => e.user_id)?.user_id ||
|
|
755
|
-
userEvents[0]?.device_id;
|
|
756
|
-
|
|
757
|
-
// ─── Hook #5: APPLICATION COMPLETION TIME-TO-CONVERT ───
|
|
758
|
-
// Runs FIRST: adjusts timestamps before version stamping.
|
|
759
|
-
// Business accounts complete the application funnel faster (0.74x),
|
|
760
|
-
// family accounts slower (1.3x). Individual stays at baseline.
|
|
761
|
-
// Assigns account_type deterministically per user via djb2 hash
|
|
762
|
-
// (independent of engine RNG). Places each "application approved"
|
|
763
|
-
// event at a fixed offset from the first "application started".
|
|
764
|
-
{
|
|
765
|
-
// djb2 hash of userId → deterministic cohort
|
|
766
|
-
let h = 5381;
|
|
767
|
-
for (let i = 0; i < userId.length; i++) {
|
|
768
|
-
h = ((h << 5) + h + userId.charCodeAt(i)) | 0;
|
|
769
|
-
}
|
|
770
|
-
const acctTypes = ["individual", "family", "business"];
|
|
771
|
-
const userAccountType = acctTypes[((h % 3) + 3) % 3];
|
|
772
|
-
// Stamp account created events with the deterministic type
|
|
773
|
-
for (const evt of userEvents) {
|
|
774
|
-
if (evt.event === "account created") {
|
|
775
|
-
evt.account_type = userAccountType;
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
userEvents.sort((a, b) => new Date(a.time) - new Date(b.time));
|
|
779
|
-
// Collect all "application started" times
|
|
780
|
-
const startedTimes = [];
|
|
781
|
-
for (const evt of userEvents) {
|
|
782
|
-
if (evt.event === "application started") {
|
|
783
|
-
startedTimes.push(dayjs(evt.time).valueOf());
|
|
784
|
-
}
|
|
785
|
-
}
|
|
786
|
-
if (startedTimes.length > 0) {
|
|
787
|
-
const firstStartTime = startedTimes[0];
|
|
788
|
-
// Target hours: biz=36, indiv=48, family=63
|
|
789
|
-
// Ratios: biz/indiv≈0.75, family/indiv≈1.31
|
|
790
|
-
const targetHours = (
|
|
791
|
-
userAccountType === "business" ? 36 :
|
|
792
|
-
userAccountType === "family" ? 63 :
|
|
793
|
-
48
|
|
794
|
-
);
|
|
795
|
-
const targetMs = targetHours * 3600000;
|
|
796
|
-
for (const evt of userEvents) {
|
|
797
|
-
if (evt.event !== "application approved") continue;
|
|
798
|
-
const evtTime = dayjs(evt.time).valueOf();
|
|
799
|
-
const origGap = evtTime - firstStartTime;
|
|
800
|
-
// Small jitter from original gap (mod 4h) for variance
|
|
801
|
-
const jitter = origGap > 0 ? (origGap % (4 * 3600000)) : 0;
|
|
802
|
-
evt.time = dayjs(firstStartTime + targetMs + jitter).toISOString();
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
// ─── Hook #2: SUPPORT TICKET VOLUME ───
|
|
808
|
-
// PRE-V2.13: Inject 2-3 extra support tickets with bug-related categories
|
|
809
|
-
const preV213Tickets = userEvents.filter(
|
|
810
|
-
(e) =>
|
|
811
|
-
e.event === "support ticket created" &&
|
|
812
|
-
dayjs(e.time).isBefore(V213_DATE)
|
|
813
|
-
);
|
|
814
|
-
|
|
815
|
-
if (preV213Tickets.length > 0) {
|
|
816
|
-
const extraCount = chance.integer({ min: 2, max: 3 });
|
|
817
|
-
const bugCategories = [
|
|
818
|
-
"form_crash",
|
|
819
|
-
"login_error",
|
|
820
|
-
"page_timeout",
|
|
821
|
-
"payment_failure",
|
|
822
|
-
];
|
|
823
|
-
|
|
824
|
-
for (let i = 0; i < extraCount; i++) {
|
|
825
|
-
// Pick a random pre-v2.13 ticket to base timing on
|
|
826
|
-
const sourceTicket = chance.pickone(preV213Tickets);
|
|
827
|
-
const sourceTime = dayjs(sourceTicket.time);
|
|
828
|
-
// Offset by a few hours to days
|
|
829
|
-
const offsetHours = chance.integer({ min: 1, max: 72 });
|
|
830
|
-
let newTime = sourceTime.add(offsetHours, "hours");
|
|
831
|
-
// Ensure it stays before v2.13
|
|
832
|
-
if (newTime.isAfter(V213_DATE)) {
|
|
833
|
-
newTime = V213_DATE.subtract(
|
|
834
|
-
chance.integer({ min: 1, max: 48 }),
|
|
835
|
-
"hours"
|
|
836
|
-
);
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
// Compute app_version for injected event
|
|
840
|
-
let injectedVersion;
|
|
841
|
-
if (newTime.isBefore(V211_DATE)) {
|
|
842
|
-
injectedVersion = "2.10";
|
|
843
|
-
} else if (newTime.isBefore(V212_DATE)) {
|
|
844
|
-
injectedVersion = "2.11";
|
|
845
|
-
} else {
|
|
846
|
-
injectedVersion = "2.12"; // always pre-v2.13
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
userEvents.push({
|
|
850
|
-
...sourceTicket,
|
|
851
|
-
time: newTime.toISOString(),
|
|
852
|
-
user_id: userId,
|
|
853
|
-
app_version: injectedVersion,
|
|
854
|
-
issue_category: chance.pickone(bugCategories),
|
|
855
|
-
priority: chance.pickone(["medium", "high", "high"]),
|
|
856
|
-
channel: chance.pickone([
|
|
857
|
-
"chat",
|
|
858
|
-
"phone",
|
|
859
|
-
"email",
|
|
860
|
-
"web_form",
|
|
861
|
-
]),
|
|
862
|
-
});
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
// POST-V2.13: Remove support tickets with increasing probability
|
|
867
|
-
// Day 1 after release: ~30% removal
|
|
868
|
-
// Day 5: ~60% removal
|
|
869
|
-
// Day 10: ~85% removal
|
|
870
|
-
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
871
|
-
const evt = userEvents[i];
|
|
872
|
-
if (evt.event === "support ticket created") {
|
|
873
|
-
const evtTime = dayjs(evt.time);
|
|
874
|
-
if (evtTime.isAfter(V213_DATE)) {
|
|
875
|
-
const daysSinceRelease = evtTime.diff(
|
|
876
|
-
V213_DATE,
|
|
877
|
-
"days",
|
|
878
|
-
true
|
|
879
|
-
);
|
|
880
|
-
// Linear ramp: 30% base + 5.5% per day → ~85% at day 10
|
|
881
|
-
const removalLikelihood = Math.min(
|
|
882
|
-
85,
|
|
883
|
-
30 + daysSinceRelease * 5.5
|
|
884
|
-
);
|
|
885
|
-
if (chance.bool({ likelihood: removalLikelihood })) {
|
|
886
|
-
userEvents.splice(i, 1);
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
// ─── Hook #3: APPLICATION CONVERSION BOOST ───
|
|
893
|
-
// PRE-V2.13: Remove ALL application approved + policy activated events
|
|
894
|
-
// for ~80% of users (per-user gating, not per-event). This produces
|
|
895
|
-
// visible funnel completion gap pre-v2.13 vs post-v2.13.
|
|
896
|
-
if (chance.bool({ likelihood: 95 })) {
|
|
897
|
-
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
898
|
-
const evt = userEvents[i];
|
|
899
|
-
if (
|
|
900
|
-
(evt.event === "application approved" ||
|
|
901
|
-
evt.event === "policy activated") &&
|
|
902
|
-
dayjs(evt.time).isBefore(V213_DATE)
|
|
903
|
-
) {
|
|
904
|
-
userEvents.splice(i, 1);
|
|
905
|
-
}
|
|
906
|
-
}
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
// Hook 4: APPLICATION-STEP MAGIC NUMBER (no flags)
|
|
910
|
-
// Sweet 8-14 application step completed → +35% on approved_premium
|
|
911
|
-
// for application approved events. Over 15+ → drop 40% of
|
|
912
|
-
// application approved events (fraud filter triggers).
|
|
913
|
-
const stepCount = userEvents.filter(e => e.event === "application step completed").length;
|
|
914
|
-
if (stepCount >= 8 && stepCount <= 14) {
|
|
915
|
-
userEvents.forEach(e => {
|
|
916
|
-
if (e.event === "application approved" && typeof e.approved_premium === "number") {
|
|
917
|
-
e.approved_premium = Math.round(e.approved_premium * 1.35);
|
|
918
|
-
}
|
|
919
|
-
});
|
|
920
|
-
} else if (stepCount >= 15) {
|
|
921
|
-
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
922
|
-
if (userEvents[i].event === "application approved" && chance.bool({ likelihood: 40 })) {
|
|
923
|
-
userEvents.splice(i, 1);
|
|
924
|
-
}
|
|
925
|
-
}
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
// ─── Hook #8: DOCUMENT UPLOAD RETENTION ───
|
|
929
|
-
// Users with 3+ "document uploaded" in first 14 days retain;
|
|
930
|
-
// others lose 75% of events post-day-30.
|
|
931
|
-
if (meta.userIsBornInDataset) {
|
|
932
|
-
const firstT = userEvents[0]?.time;
|
|
933
|
-
if (firstT) {
|
|
934
|
-
const window14 = dayjs(firstT).add(14, "days").toISOString();
|
|
935
|
-
const docUploads = userEvents.filter(
|
|
936
|
-
(e) => e.event === "document uploaded" && e.time <= window14
|
|
937
|
-
).length;
|
|
938
|
-
if (docUploads < 3) {
|
|
939
|
-
const cutoff = dayjs(firstT).add(30, "days");
|
|
940
|
-
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
941
|
-
if (
|
|
942
|
-
dayjs(userEvents[i].time).isAfter(cutoff) &&
|
|
943
|
-
chance.bool({ likelihood: 75 })
|
|
944
|
-
) {
|
|
945
|
-
userEvents.splice(i, 1);
|
|
946
|
-
}
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
// ─── Hook #9: END-OF-QUARTER RENEWAL SPIKE ───
|
|
953
|
-
// Days 85-95 get 3x renewal clones + 2x coverage reviewed clones
|
|
954
|
-
{
|
|
955
|
-
const spikeStart = datasetStart.add(85, "days");
|
|
956
|
-
const spikeEnd = datasetStart.add(95, "days");
|
|
957
|
-
const clones = [];
|
|
958
|
-
userEvents.forEach((e) => {
|
|
959
|
-
const t = dayjs(e.time);
|
|
960
|
-
if (t.isAfter(spikeStart) && t.isBefore(spikeEnd)) {
|
|
961
|
-
if (e.event === "renewal completed") {
|
|
962
|
-
for (let c = 0; c < 2; c++) {
|
|
963
|
-
clones.push({
|
|
964
|
-
...e,
|
|
965
|
-
time: t
|
|
966
|
-
.add(
|
|
967
|
-
chance.integer({ min: 5, max: 240 }),
|
|
968
|
-
"minutes"
|
|
969
|
-
)
|
|
970
|
-
.toISOString(),
|
|
971
|
-
insert_id: chance.guid(),
|
|
972
|
-
});
|
|
973
|
-
}
|
|
974
|
-
}
|
|
975
|
-
if (e.event === "coverage reviewed") {
|
|
976
|
-
clones.push({
|
|
977
|
-
...e,
|
|
978
|
-
time: t
|
|
979
|
-
.add(
|
|
980
|
-
chance.integer({ min: 5, max: 120 }),
|
|
981
|
-
"minutes"
|
|
982
|
-
)
|
|
983
|
-
.toISOString(),
|
|
984
|
-
insert_id: chance.guid(),
|
|
985
|
-
});
|
|
986
|
-
}
|
|
987
|
-
}
|
|
988
|
-
});
|
|
989
|
-
if (clones.length) userEvents.push(...clones);
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
// ─── Hook #1: VERSION STAMPING ───
|
|
993
|
-
// Runs LAST: stamps app_version based on FINAL timestamps
|
|
994
|
-
// (after Hook #5 TTC scaling has adjusted event times).
|
|
995
|
-
// v2.10 (days 0-30) → v2.11 (30-60) → v2.12 (60-90) → v2.13 (last 10 days)
|
|
996
|
-
for (const evt of userEvents) {
|
|
997
|
-
const eventTime = dayjs(evt.time);
|
|
998
|
-
if (eventTime.isBefore(V211_DATE)) {
|
|
999
|
-
evt.app_version = "2.10";
|
|
1000
|
-
} else if (eventTime.isBefore(V212_DATE)) {
|
|
1001
|
-
evt.app_version = "2.11";
|
|
1002
|
-
} else if (eventTime.isBefore(V213_DATE)) {
|
|
1003
|
-
evt.app_version = "2.12";
|
|
1004
|
-
} else {
|
|
1005
|
-
evt.app_version = "2.13";
|
|
1006
|
-
}
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
// Sort events by time after injection/removal/shifting
|
|
1010
|
-
userEvents.sort(
|
|
1011
|
-
(a, b) => new Date(a.time) - new Date(b.time)
|
|
1012
|
-
);
|
|
1013
|
-
|
|
1014
|
-
return record;
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1031
|
+
hook(record, type, meta) {
|
|
1032
|
+
if (type === "funnel-pre") return handleFunnelPreHooks(record, meta);
|
|
1033
|
+
if (type === "event") return handleEventHooks(record);
|
|
1034
|
+
if (type === "everything") return handleEverythingHooks(record, meta);
|
|
1017
1035
|
return record;
|
|
1018
1036
|
},
|
|
1019
1037
|
};
|