@ak--47/dungeon-master 1.4.5 → 1.5.1
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 +158 -0
- package/.claude/skills/create-dungeon/SKILL.md +464 -0
- package/.claude/skills/verify-dungeon/SKILL.md +157 -0
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +161 -0
- package/.claude/skills/verify-dungeon/references/report-format.md +216 -0
- package/.claude/skills/verify-dungeon/references/sql-recipes.md +857 -0
- package/.claude/skills/write-hooks/SKILL.md +468 -0
- package/CHANGELOG.md +182 -0
- package/HOOKS.md +1256 -597
- package/README.md +140 -5
- 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 +87 -0
- 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 +111 -65
- package/dungeons/technical/text-generation.js +110 -146
- package/dungeons/vertical/ai-platform.js +300 -333
- package/dungeons/vertical/community.js +290 -255
- package/dungeons/vertical/crypto.js +400 -391
- package/dungeons/vertical/dating.js +421 -375
- package/dungeons/vertical/devtools.js +346 -298
- package/dungeons/vertical/ecommerce.js +322 -394
- package/dungeons/vertical/education.js +380 -325
- package/dungeons/vertical/fintech.js +371 -325
- package/dungeons/vertical/fitness.js +345 -291
- package/dungeons/vertical/food-delivery.js +352 -307
- package/dungeons/vertical/gaming.js +490 -444
- package/dungeons/vertical/healthcare.js +311 -262
- package/dungeons/vertical/insurance-application.js +437 -409
- package/dungeons/vertical/logistics.js +278 -252
- package/dungeons/vertical/marketplace.js +340 -323
- package/dungeons/vertical/media.js +390 -335
- package/dungeons/vertical/real-estate.js +402 -347
- package/dungeons/vertical/sass.js +331 -333
- package/dungeons/vertical/social.js +377 -316
- package/dungeons/vertical/travel.js +302 -295
- package/index.js +64 -7
- package/lib/core/config-validator.js +378 -17
- package/lib/core/dungeon-loader.js +2 -5
- package/lib/generators/events.js +12 -13
- package/lib/generators/funnels.js +76 -2
- package/lib/hook-helpers/index.js +1 -0
- package/lib/hook-helpers/inject.js +95 -0
- package/lib/orchestrators/mixpanel-sender.js +7 -0
- package/lib/orchestrators/user-loop.js +598 -48
- package/lib/templates/defaults.js +59 -59
- package/lib/templates/macro-presets.js +53 -11
- package/lib/utils/dataset-context.js +103 -0
- package/lib/utils/retention-curve.js +140 -0
- package/lib/utils/utils.js +157 -109
- package/lib/verify/counting.js +360 -0
- package/lib/verify/emulate-breakdown.js +531 -108
- package/lib/verify/funnel-engine.js +539 -0
- package/lib/verify/identity.js +78 -0
- package/lib/verify/index.js +20 -0
- package/lib/verify/schema-validator.js +3 -1
- package/lib/verify/verify-dungeon.js +58 -0
- package/package.json +14 -3
- package/scripts/run-dungeon.mjs +12 -1
- package/types.d.ts +353 -4
- package/scripts/smoke-test-all.mjs +0 -162
|
@@ -1,78 +1,40 @@
|
|
|
1
|
-
// ──
|
|
2
|
-
const SEED = "simple is best";
|
|
3
|
-
const num_days = 120;
|
|
4
|
-
const num_users = 42_000;
|
|
5
|
-
const avg_events_per_user_per_day = 0.37;
|
|
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";
|
|
13
4
|
dayjs.extend(utc);
|
|
14
|
-
import { uid
|
|
15
|
-
import { weighNumRange,
|
|
5
|
+
import { uid } from "ak-tools";
|
|
6
|
+
import { weighNumRange, integer, weighChoices, decimal, initChance } from "../../lib/utils/utils.js";
|
|
16
7
|
import { scaleFunnelTTC } from "../../lib/hook-helpers/timing.js";
|
|
17
|
-
const chance = initChance(SEED);
|
|
18
|
-
|
|
19
8
|
/** @typedef {import("../../types").Dungeon} Config */
|
|
20
|
-
const itemCategories = ["Books", "Movies", "Music", "Games", "Electronics", "Computers", "Smart Home", "Home", "Garden", "Pet", "Beauty", "Health", "Toys", "Kids", "Baby", "Handmade", "Sports", "Outdoors", "Automotive", "Industrial", "Entertainment", "Art", "Food", "Appliances", "Office", "Wedding", "Software"];
|
|
21
|
-
const videoCategories = ["funny", "educational", "inspirational", "music", "news", "sports", "cooking", "DIY", "travel", "gaming"];
|
|
22
|
-
const spiritAnimals = ["duck", "dog", "otter", "penguin", "cat", "elephant", "lion", "cheetah", "giraffe", "zebra", "rhino", "hippo", "whale", "dolphin", "shark", "octopus", "squid", "jellyfish", "starfish", "seahorse", "crab", "lobster", "shrimp", "clam", "snail", "slug", "butterfly", "moth", "bee", "wasp", "ant", "beetle", "ladybug", "caterpillar", "centipede", "millipede", "scorpion", "spider", "tarantula", "tick", "mite", "mosquito", "fly", "dragonfly", "damselfly", "grasshopper", "cricket", "locust", "mantis", "cockroach", "termite", "praying mantis", "walking stick", "stick bug", "leaf insect", "lacewing", "aphid", "cicada", "thrips", "psyllid", "scale insect", "whitefly", "mealybug", "planthopper", "leafhopper", "treehopper", "flea", "louse", "bedbug", "flea beetle", "weevil", "longhorn beetle", "leaf beetle", "tiger beetle", "ground beetle", "lady beetle", "firefly", "click beetle", "rove beetle", "scarab beetle", "dung beetle", "stag beetle", "rhinoceros beetle", "hercules beetle", "goliath beetle", "jewel beetle", "tortoise beetle"];
|
|
23
9
|
|
|
10
|
+
// ── OVERVIEW ──
|
|
24
11
|
/*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* (
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* -
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* Super props: theme (light/dark/custom)
|
|
48
|
-
* ============================================================================
|
|
12
|
+
* NAME: ShopStream
|
|
13
|
+
* APP: eCommerce marketplace with integrated video content. Users browse
|
|
14
|
+
* products, watch videos, build carts, and check out. A signup
|
|
15
|
+
* funnel converts anonymous browsers into registered users; video
|
|
16
|
+
* engagement (like/dislike) runs alongside the shopping flow.
|
|
17
|
+
* SCALE: 42,000 users, ~2M events, 121 days (2026-01-01 → 2026-05-01)
|
|
18
|
+
* CORE LOOP: page view → view item → save/add to cart → checkout (+ video like/dislike side loop)
|
|
19
|
+
*
|
|
20
|
+
* EVENTS (9):
|
|
21
|
+
* page view (10) > view item (8) > watch video (8) > like video (6) > save item (5)
|
|
22
|
+
* > add to cart (4) > dislike video (4) > checkout (2) > sign up (1)
|
|
23
|
+
*
|
|
24
|
+
* FUNNELS (4):
|
|
25
|
+
* - Signup Flow: page view → view item → save item → page view → sign up (50%)
|
|
26
|
+
* - Video Likes: watch video → like video → watch video → like video (60%)
|
|
27
|
+
* - Video Dislikes: watch video → dislike video → watch video → dislike video (20%)
|
|
28
|
+
* - eCommerce Purchase: view item → view item → add to cart → view item → add to cart → checkout (15%, A/B/C experiment)
|
|
29
|
+
*
|
|
30
|
+
* USER PROPS: title, luckyNumber, spiritAnimal, theme
|
|
31
|
+
* SUPER PROPS: theme
|
|
32
|
+
* SCD PROPS: loyalty_tier (bronze/silver/gold/platinum, monthly fuzzy, max 8)
|
|
33
|
+
* GROUPS: none
|
|
49
34
|
*/
|
|
50
35
|
|
|
36
|
+
// ── HOOK STORIES ──
|
|
51
37
|
/*
|
|
52
|
-
* ============================================================================
|
|
53
|
-
* ANALYTICS HOOKS (10 hooks)
|
|
54
|
-
*
|
|
55
|
-
* Adds 7. SIGNUP FLOW TIME-TO-CONVERT: gold/platinum loyalty 0.67x faster,
|
|
56
|
-
* bronze 1.33x slower (everything hook via scaleFunnelTTC on the page-view-
|
|
57
|
-
* to-sign-up window). Loyalty tier is determined from SCD data
|
|
58
|
-
* (meta.scd.loyalty_tier) with deterministic hash fallback. Discover via
|
|
59
|
-
* bound-sequence funnel TTC breakdown by loyalty tier.
|
|
60
|
-
*
|
|
61
|
-
* Adds 8. CHECKOUT FLOW EXPERIMENT: A/B/C experiment on the eCommerce
|
|
62
|
-
* Purchase funnel — "Control", "Express Checkout" (1.25x conversion),
|
|
63
|
-
* "Social Proof" (1.15x conversion, 0.9x TTC). Engine-managed via funnel
|
|
64
|
-
* experiment config; starts 30 days before dataset end.
|
|
65
|
-
*
|
|
66
|
-
* Adds 9. DARK THEME POWER USERS: funnel-pre hook that boosts purchase
|
|
67
|
-
* funnel conversion 1.3x for dark-theme users and penalizes light-theme
|
|
68
|
-
* users to 0.85x. Discoverable by breakdown of funnel conversion by theme.
|
|
69
|
-
*
|
|
70
|
-
* Adds 10. SAVE-ITEM RETENTION: born-in-dataset users who save 2+ items
|
|
71
|
-
* in their first 10 days retain long-term; those below threshold lose ~70%
|
|
72
|
-
* of post-day-25 events. Discoverable by retention or frequency analysis
|
|
73
|
-
* segmented by early save-item count.
|
|
74
|
-
* ============================================================================
|
|
75
|
-
*
|
|
76
38
|
* ----------------------------------------------------------------------------
|
|
77
39
|
* Hook 1: Signup Flow Improvement (event + everything)
|
|
78
40
|
* ----------------------------------------------------------------------------
|
|
@@ -386,30 +348,69 @@ const spiritAnimals = ["duck", "dog", "otter", "penguin", "cat", "elephant", "li
|
|
|
386
348
|
* ============================================================================
|
|
387
349
|
*/
|
|
388
350
|
|
|
351
|
+
// ── SCALE ──
|
|
352
|
+
const SEED = "simple is best";
|
|
353
|
+
const NUM_USERS = 42_000;
|
|
354
|
+
const DATASET_START = "2026-01-01T00:00:00Z";
|
|
355
|
+
const DATASET_END = "2026-05-01T23:59:59Z";
|
|
356
|
+
const EVENTS_PER_DAY = 0.37;
|
|
357
|
+
const token = process.env.MP_TOKEN || "your-mixpanel-token";
|
|
358
|
+
|
|
359
|
+
const chance = initChance(SEED);
|
|
360
|
+
|
|
361
|
+
// ── KNOBS (tweak these to reshape stories) ──
|
|
362
|
+
const SIGNUP_FIX_DAYS_AGO = 7;
|
|
363
|
+
const WATCH_INFLECTION_DAYS_AGO = 30;
|
|
364
|
+
const WATCH_FACTOR_MIN = 0.25;
|
|
365
|
+
const WATCH_FACTOR_MAX = 0.79;
|
|
366
|
+
const VIEW_SWEET_MIN = 3;
|
|
367
|
+
const VIEW_SWEET_MAX = 8;
|
|
368
|
+
const VIEW_OVER_THRESHOLD = 9;
|
|
369
|
+
const SWEET_CART_BOOST = 1.25;
|
|
370
|
+
const SWEET_CLONE_LIKELIHOOD = 45;
|
|
371
|
+
const OVER_CHECKOUT_DROP_LIKELIHOOD = 30;
|
|
372
|
+
const LOYALTY_TTC_FAST = 0.67;
|
|
373
|
+
const LOYALTY_TTC_SLOW = 1.33;
|
|
374
|
+
const LOYALTY_LOOKBACK_DAYS = 2;
|
|
375
|
+
const SAVE_RETENTION_MIN = 2;
|
|
376
|
+
const SAVE_RETENTION_WINDOW_DAYS = 10;
|
|
377
|
+
const SAVE_RETENTION_CUTOFF_DAYS = 25;
|
|
378
|
+
const SAVE_RETENTION_DROP_LIKELIHOOD = 70;
|
|
379
|
+
const QUALITY_FACTORS = {
|
|
380
|
+
"2160p": 1.5,
|
|
381
|
+
"1440p": 1.4,
|
|
382
|
+
"1080p": 1.3,
|
|
383
|
+
"720p": 1.15,
|
|
384
|
+
"480p": 1.0,
|
|
385
|
+
"360p": 0.85,
|
|
386
|
+
"240p": 0.7,
|
|
387
|
+
};
|
|
389
388
|
|
|
389
|
+
// ── DATA ARRAYS ──
|
|
390
|
+
const videoCategories = ["funny", "educational", "inspirational", "music", "news", "sports", "cooking", "DIY", "travel", "gaming"];
|
|
391
|
+
const spiritAnimals = ["duck", "dog", "otter", "penguin", "cat", "elephant", "lion", "cheetah", "giraffe", "zebra", "rhino", "hippo", "whale", "dolphin", "shark", "octopus", "squid", "jellyfish", "starfish", "seahorse", "crab", "lobster", "shrimp", "clam", "snail", "slug", "butterfly", "moth", "bee", "wasp", "ant", "beetle", "ladybug", "caterpillar", "centipede", "millipede", "scorpion", "spider", "tarantula", "tick", "mite", "mosquito", "fly", "dragonfly", "damselfly", "grasshopper", "cricket", "locust", "mantis", "cockroach", "termite", "praying mantis", "walking stick", "stick bug", "leaf insect", "lacewing", "aphid", "cicada", "thrips", "psyllid", "scale insect", "whitefly", "mealybug", "planthopper", "leafhopper", "treehopper", "flea", "louse", "bedbug", "flea beetle", "weevil", "longhorn beetle", "leaf beetle", "tiger beetle", "ground beetle", "lady beetle", "firefly", "click beetle", "rove beetle", "scarab beetle", "dung beetle", "stag beetle", "rhinoceros beetle", "hercules beetle", "goliath beetle", "jewel beetle", "tortoise beetle"];
|
|
392
|
+
const productCategories = ["electronics", "books", "clothing", "home", "garden", "toys", "sports", "automotive", "beauty", "health", "grocery", "jewelry", "shoes", "tools", "office supplies"];
|
|
393
|
+
const productDescriptors = ["brand new", "open box", "refurbished", "used", "like new", "vintage", "antique", "collectible"];
|
|
394
|
+
const productSuffixes = ["item", "product", "good", "merchandise", "thing", "object", "widget", "gadget", "device", "apparatus", "contraption", "instrument", "tool", "implement", "utensil", "appliance", "machine", "equipment", "gear", "kit", "set", "package"];
|
|
395
|
+
const assetPreview = [".png", ".jpg", ".jpeg", ".heic", ".mp4", ".mov", ".avi"];
|
|
396
|
+
|
|
397
|
+
// ── HELPER FUNCTIONS ──
|
|
390
398
|
function makeProducts(maxItems = 5) {
|
|
391
399
|
return function () {
|
|
392
|
-
const categories = ["electronics", "books", "clothing", "home", "garden", "toys", "sports", "automotive", "beauty", "health", "grocery", "jewelry", "shoes", "tools", "office supplies"];
|
|
393
|
-
const descriptors = ["brand new", "open box", "refurbished", "used", "like new", "vintage", "antique", "collectible"];
|
|
394
|
-
const suffix = ["item", "product", "good", "merchandise", "thing", "object", "widget", "gadget", "device", "apparatus", "contraption", "instrument", "tool", "implement", "utensil", "appliance", "machine", "equipment", "gear", "kit", "set", "package"];
|
|
395
|
-
const assetPreview = ['.png', '.jpg', '.jpeg', '.heic', '.mp4', '.mov', '.avi'];
|
|
396
400
|
const data = [];
|
|
397
401
|
const numOfItems = integer(1, maxItems);
|
|
398
402
|
|
|
399
|
-
for (
|
|
400
|
-
const category = chance.pickone(
|
|
401
|
-
const descriptor = chance.pickone(
|
|
402
|
-
const suffixWord = chance.pickone(
|
|
403
|
-
const slug = `${descriptor.replace(/\s+/g,
|
|
403
|
+
for (let i = 0; i < numOfItems; i++) {
|
|
404
|
+
const category = chance.pickone(productCategories);
|
|
405
|
+
const descriptor = chance.pickone(productDescriptors);
|
|
406
|
+
const suffixWord = chance.pickone(productSuffixes);
|
|
407
|
+
const slug = `${descriptor.replace(/\s+/g, "-").toLowerCase()}-${suffixWord.replace(/\s+/g, "-").toLowerCase()}`;
|
|
404
408
|
const asset = chance.pickone(assetPreview);
|
|
405
409
|
|
|
406
|
-
// const product_id = chance.guid();
|
|
407
410
|
const price = integer(1, 100);
|
|
408
411
|
const quantity = integer(1, 5);
|
|
409
412
|
|
|
410
|
-
|
|
411
|
-
// product_id: product_id,
|
|
412
|
-
// sku: integer(11111, 99999),
|
|
413
|
+
data.push({
|
|
413
414
|
amount: price,
|
|
414
415
|
quantity: quantity,
|
|
415
416
|
total_value: price * quantity,
|
|
@@ -418,132 +419,303 @@ function makeProducts(maxItems = 5) {
|
|
|
418
419
|
descriptor: descriptor,
|
|
419
420
|
slug: slug,
|
|
420
421
|
assetPreview: `https://example.com/assets/${slug}${asset}`,
|
|
421
|
-
assetType: asset
|
|
422
|
-
|
|
423
|
-
};
|
|
424
|
-
|
|
425
|
-
data.push(item);
|
|
422
|
+
assetType: asset,
|
|
423
|
+
});
|
|
426
424
|
}
|
|
427
425
|
|
|
428
426
|
return () => [data];
|
|
429
427
|
};
|
|
430
|
-
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function handleFunnelPreHooks(record, meta) {
|
|
431
|
+
// H9: Dark Theme Power Users — dark +30%, light -15%, custom unchanged
|
|
432
|
+
const isPurchaseFunnel = meta.funnel?.sequence?.includes("checkout");
|
|
433
|
+
if (isPurchaseFunnel) {
|
|
434
|
+
const theme = meta.profile?.theme;
|
|
435
|
+
if (theme === "dark") record.conversionRate = Math.min(95, Math.round(record.conversionRate * 1.3));
|
|
436
|
+
else if (theme === "light") record.conversionRate = Math.round(record.conversionRate * 0.85);
|
|
437
|
+
}
|
|
438
|
+
return record;
|
|
439
|
+
}
|
|
431
440
|
|
|
441
|
+
function handleEventHooks(record) {
|
|
442
|
+
// H5: Item flattening — promote first item's fields to top level
|
|
443
|
+
if (record.item && Array.isArray(record.item)) {
|
|
444
|
+
record = { ...record, ...record.item[0] };
|
|
445
|
+
delete record.item;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// H3: Toys + Shoes cart correlation
|
|
449
|
+
if (record.event === "checkout" && Array.isArray(record.cart)) {
|
|
450
|
+
const hasToys = record.cart.some(item => item.category === "toys");
|
|
451
|
+
const hasShoes = record.cart.some(item => item.category === "shoes");
|
|
452
|
+
if (hasToys && !hasShoes) {
|
|
453
|
+
const bigCart = makeProducts(20)()()[0];
|
|
454
|
+
const shoeItems = bigCart.filter(item => item.category === "shoes");
|
|
455
|
+
if (shoeItems.length > 0) record.cart.push(shoeItems[0]);
|
|
456
|
+
}
|
|
457
|
+
if (hasShoes && !hasToys) {
|
|
458
|
+
const bigCart = makeProducts(20)()()[0];
|
|
459
|
+
const toyItems = bigCart.filter(item => item.category === "toys");
|
|
460
|
+
if (toyItems.length > 0) record.cart.push(toyItems[0]);
|
|
461
|
+
}
|
|
462
|
+
if (!hasToys && !hasShoes) {
|
|
463
|
+
const cheapFactor = decimal(0.75, 0.9);
|
|
464
|
+
record.cart = record.cart.map(item => ({
|
|
465
|
+
...item,
|
|
466
|
+
amount: Math.round(item.amount * cheapFactor),
|
|
467
|
+
total_value: Math.round(item.total_value * cheapFactor),
|
|
468
|
+
}));
|
|
469
|
+
}
|
|
470
|
+
}
|
|
432
471
|
|
|
433
|
-
|
|
472
|
+
// H4: Quality → watch time correlation
|
|
473
|
+
if (record.event === "watch video") {
|
|
474
|
+
const quality = record.quality || "480p";
|
|
475
|
+
const factor = QUALITY_FACTORS[quality] ?? 1.0;
|
|
476
|
+
record.watchTimeSec = Math.round(record.watchTimeSec * factor);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return record;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function handleEverythingHooks(record, meta) {
|
|
483
|
+
const profile = meta.profile;
|
|
484
|
+
record.forEach(e => {
|
|
485
|
+
e.theme = profile.theme;
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
const datasetEnd = dayjs.unix(meta.datasetEnd);
|
|
489
|
+
const DAY_SIGNUPS_IMPROVED = datasetEnd.subtract(SIGNUP_FIX_DAYS_AGO, "day");
|
|
490
|
+
const DAY_WATCH_TIME_WENT_UP = datasetEnd.subtract(WATCH_INFLECTION_DAYS_AGO, "day");
|
|
491
|
+
|
|
492
|
+
// H1: Signup flow v1 → v2; pre-fix v1 signups lose 50% to _drop
|
|
493
|
+
record.forEach(e => {
|
|
494
|
+
if (e.event !== "sign up") return;
|
|
495
|
+
const eventTime = dayjs(e.time);
|
|
496
|
+
e.signup_flow = "v1";
|
|
497
|
+
if (eventTime.isBefore(DAY_SIGNUPS_IMPROVED)) {
|
|
498
|
+
if (chance.bool({ likelihood: 50 })) e._drop = true;
|
|
499
|
+
}
|
|
500
|
+
if (eventTime.isAfter(DAY_SIGNUPS_IMPROVED)) {
|
|
501
|
+
e.signup_flow = "v2";
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
// H2: Watch time inflection — before: shrink, after: grow
|
|
506
|
+
record.forEach(e => {
|
|
507
|
+
if (e.event !== "watch video") return;
|
|
508
|
+
const eventTime = dayjs(e.time);
|
|
509
|
+
const factor = decimal(WATCH_FACTOR_MIN, WATCH_FACTOR_MAX);
|
|
510
|
+
if (eventTime.isBefore(DAY_WATCH_TIME_WENT_UP)) {
|
|
511
|
+
e.watchTimeSec = Math.round(e.watchTimeSec * (1 - factor));
|
|
512
|
+
}
|
|
513
|
+
if (eventTime.isAfter(DAY_WATCH_TIME_WENT_UP)) {
|
|
514
|
+
e.watchTimeSec = Math.round(e.watchTimeSec * (1 + factor));
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
// H6: View-item magic number — sweet spot (3-8) boost; over (9+) checkout drop
|
|
519
|
+
const viewItemCount = record.filter(e => e.event === "view item").length;
|
|
520
|
+
if (viewItemCount >= VIEW_SWEET_MIN && viewItemCount <= VIEW_SWEET_MAX) {
|
|
521
|
+
record.forEach(e => {
|
|
522
|
+
if (e.event === "checkout" && Array.isArray(e.cart)) {
|
|
523
|
+
e.cart = e.cart.map(it => ({
|
|
524
|
+
...it,
|
|
525
|
+
amount: typeof it.amount === "number" ? Math.round(it.amount * SWEET_CART_BOOST) : it.amount,
|
|
526
|
+
total_value: typeof it.total_value === "number" ? Math.round(it.total_value * SWEET_CART_BOOST) : it.total_value,
|
|
527
|
+
}));
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
const addToCartEvents = record.filter(e => e.event === "add to cart");
|
|
531
|
+
const clones = [];
|
|
532
|
+
addToCartEvents.forEach(e => {
|
|
533
|
+
if (chance.bool({ likelihood: SWEET_CLONE_LIKELIHOOD })) {
|
|
534
|
+
const offsetMs = integer(60_000, 600_000);
|
|
535
|
+
clones.push({
|
|
536
|
+
...e,
|
|
537
|
+
insert_id: uid(),
|
|
538
|
+
time: dayjs(e.time).add(offsetMs, "milliseconds").toISOString(),
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
if (clones.length > 0) record.push(...clones);
|
|
543
|
+
} else if (viewItemCount >= VIEW_OVER_THRESHOLD) {
|
|
544
|
+
for (let i = record.length - 1; i >= 0; i--) {
|
|
545
|
+
if (record[i].event === "checkout" && chance.bool({ likelihood: OVER_CHECKOUT_DROP_LIKELIHOOD })) {
|
|
546
|
+
record.splice(i, 1);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// H7: Signup-flow TTC scaled by loyalty tier (gold/plat fast, bronze slow)
|
|
552
|
+
{
|
|
553
|
+
let loyaltyTier = "silver";
|
|
554
|
+
const scdEntries = meta?.scd?.loyalty_tier;
|
|
555
|
+
if (Array.isArray(scdEntries) && scdEntries.length > 0) {
|
|
556
|
+
const latest = scdEntries.reduce((a, b) => (a.time > b.time ? a : b));
|
|
557
|
+
loyaltyTier = latest.loyalty_tier || "silver";
|
|
558
|
+
} else {
|
|
559
|
+
const uidStr = record[0]?.user_id || "";
|
|
560
|
+
const hash = uidStr.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0);
|
|
561
|
+
const bucket = hash % 100;
|
|
562
|
+
loyaltyTier = bucket < 20 ? "gold" : bucket < 50 ? "silver" : "bronze";
|
|
563
|
+
}
|
|
564
|
+
const ttcFactor = (
|
|
565
|
+
loyaltyTier === "gold" || loyaltyTier === "platinum" ? LOYALTY_TTC_FAST :
|
|
566
|
+
loyaltyTier === "bronze" ? LOYALTY_TTC_SLOW :
|
|
567
|
+
1.0
|
|
568
|
+
);
|
|
569
|
+
if (ttcFactor !== 1.0) {
|
|
570
|
+
const funnelSteps = new Set(["page view", "view item", "save item", "sign up"]);
|
|
571
|
+
const sorted = record.slice().sort((a, b) => new Date(a.time) - new Date(b.time));
|
|
572
|
+
const signupEvent = sorted.find(e => e.event === "sign up");
|
|
573
|
+
if (signupEvent) {
|
|
574
|
+
const signupMs = new Date(signupEvent.time).getTime();
|
|
575
|
+
const windowMs = LOYALTY_LOOKBACK_DAYS * 24 * 60 * 60 * 1000;
|
|
576
|
+
const funnelEvents = sorted.filter(e =>
|
|
577
|
+
funnelSteps.has(e.event) &&
|
|
578
|
+
new Date(e.time).getTime() >= signupMs - windowMs &&
|
|
579
|
+
new Date(e.time).getTime() <= signupMs
|
|
580
|
+
);
|
|
581
|
+
if (funnelEvents.length >= 2) scaleFunnelTTC(funnelEvents, ttcFactor);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// H10: Save-item retention — born-in users below threshold churn after day 25
|
|
587
|
+
if (meta.userIsBornInDataset) {
|
|
588
|
+
const firstT = record[0]?.time;
|
|
589
|
+
if (firstT) {
|
|
590
|
+
const window10 = dayjs(firstT).add(SAVE_RETENTION_WINDOW_DAYS, "days").toISOString();
|
|
591
|
+
const saves = record.filter(e => e.event === "save item" && e.time <= window10).length;
|
|
592
|
+
if (saves < SAVE_RETENTION_MIN) {
|
|
593
|
+
const cutoff = dayjs(firstT).add(SAVE_RETENTION_CUTOFF_DAYS, "days");
|
|
594
|
+
for (let i = record.length - 1; i >= 0; i--) {
|
|
595
|
+
if (dayjs(record[i].time).isAfter(cutoff) && chance.bool({ likelihood: SAVE_RETENTION_DROP_LIKELIHOOD })) {
|
|
596
|
+
record.splice(i, 1);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return record.filter(e => !e._drop);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ── CONFIG ──
|
|
607
|
+
/** @type {Config} */
|
|
434
608
|
const config = {
|
|
435
609
|
version: 2,
|
|
436
|
-
token,
|
|
437
610
|
seed: SEED,
|
|
438
|
-
datasetStart:
|
|
439
|
-
datasetEnd:
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
611
|
+
datasetStart: DATASET_START,
|
|
612
|
+
datasetEnd: DATASET_END,
|
|
613
|
+
avgEventsPerUserPerDay: EVENTS_PER_DAY,
|
|
614
|
+
numUsers: NUM_USERS,
|
|
615
|
+
format: "json",
|
|
616
|
+
credentials: {
|
|
617
|
+
token,
|
|
618
|
+
region: "US",
|
|
619
|
+
},
|
|
620
|
+
switches: {
|
|
621
|
+
hasSessionIds: true,
|
|
622
|
+
hasAdSpend: false,
|
|
623
|
+
hasLocation: true,
|
|
624
|
+
hasAndroidDevices: true,
|
|
625
|
+
hasIOSDevices: true,
|
|
626
|
+
hasDesktopDevices: true,
|
|
627
|
+
hasBrowser: true,
|
|
628
|
+
hasCampaigns: false,
|
|
629
|
+
isAnonymous: false,
|
|
630
|
+
alsoInferFunnels: false,
|
|
631
|
+
},
|
|
632
|
+
identity: {
|
|
633
|
+
avgDevicePerUser: 2,
|
|
634
|
+
},
|
|
457
635
|
concurrency: 1,
|
|
458
636
|
events: [
|
|
459
637
|
{
|
|
460
638
|
event: "checkout",
|
|
461
639
|
weight: 2,
|
|
640
|
+
isStrictEvent: false,
|
|
462
641
|
properties: {
|
|
463
642
|
currency: ["USD", "CAD", "EUR", "BTC", "ETH", "JPY"],
|
|
464
643
|
coupon: weighChoices(["none", "none", "none", "none", "10%OFF", "20%OFF", "10%OFF", "20%OFF", "30%OFF", "40%OFF", "50%OFF"]),
|
|
465
644
|
cart: makeProducts(),
|
|
466
|
-
}
|
|
645
|
+
},
|
|
467
646
|
},
|
|
468
647
|
{
|
|
469
648
|
event: "add to cart",
|
|
470
649
|
weight: 4,
|
|
650
|
+
isStrictEvent: false,
|
|
471
651
|
properties: {
|
|
472
652
|
item: makeProducts(1),
|
|
473
653
|
amount: weighNumRange(1, 100, 0.3),
|
|
474
654
|
quantity: weighNumRange(1, 5, 0.3),
|
|
475
655
|
total_value: weighNumRange(1, 500, 0.3),
|
|
476
656
|
featured: [true, false, false],
|
|
477
|
-
category:
|
|
478
|
-
descriptor:
|
|
657
|
+
category: productCategories,
|
|
658
|
+
descriptor: productDescriptors,
|
|
479
659
|
slug: ["item"],
|
|
480
660
|
assetPreview: ["https://example.com/assets/item.png"],
|
|
481
|
-
assetType:
|
|
482
|
-
}
|
|
661
|
+
assetType: assetPreview,
|
|
662
|
+
},
|
|
483
663
|
},
|
|
484
664
|
{
|
|
485
665
|
event: "view item",
|
|
486
666
|
weight: 8,
|
|
667
|
+
isStrictEvent: false,
|
|
487
668
|
properties: {
|
|
488
669
|
item: makeProducts(1),
|
|
489
670
|
amount: weighNumRange(1, 100, 0.3),
|
|
490
671
|
quantity: weighNumRange(1, 5, 0.3),
|
|
491
672
|
total_value: weighNumRange(1, 500, 0.3),
|
|
492
673
|
featured: [true, false, false],
|
|
493
|
-
category:
|
|
494
|
-
descriptor:
|
|
674
|
+
category: productCategories,
|
|
675
|
+
descriptor: productDescriptors,
|
|
495
676
|
slug: ["item"],
|
|
496
677
|
assetPreview: ["https://example.com/assets/item.png"],
|
|
497
|
-
assetType:
|
|
498
|
-
}
|
|
678
|
+
assetType: assetPreview,
|
|
679
|
+
},
|
|
499
680
|
},
|
|
500
681
|
{
|
|
501
682
|
event: "save item",
|
|
502
683
|
weight: 5,
|
|
684
|
+
isStrictEvent: false,
|
|
503
685
|
properties: {
|
|
504
686
|
item: makeProducts(1),
|
|
505
687
|
amount: weighNumRange(1, 100, 0.3),
|
|
506
688
|
quantity: weighNumRange(1, 5, 0.3),
|
|
507
689
|
total_value: weighNumRange(1, 500, 0.3),
|
|
508
690
|
featured: [true, false, false],
|
|
509
|
-
category:
|
|
510
|
-
descriptor:
|
|
691
|
+
category: productCategories,
|
|
692
|
+
descriptor: productDescriptors,
|
|
511
693
|
slug: ["item"],
|
|
512
694
|
assetPreview: ["https://example.com/assets/item.png"],
|
|
513
|
-
assetType:
|
|
514
|
-
}
|
|
695
|
+
assetType: assetPreview,
|
|
696
|
+
},
|
|
515
697
|
},
|
|
516
698
|
{
|
|
517
699
|
event: "page view",
|
|
518
700
|
weight: 10,
|
|
701
|
+
isStrictEvent: false,
|
|
519
702
|
properties: {
|
|
520
703
|
page: ["/", "/help", "/account", "/watch", "/listen", "/product", "/people", "/peace"],
|
|
521
|
-
}
|
|
704
|
+
},
|
|
522
705
|
},
|
|
523
706
|
{
|
|
524
707
|
event: "watch video",
|
|
525
708
|
weight: 8,
|
|
709
|
+
isStrictEvent: false,
|
|
526
710
|
properties: {
|
|
527
|
-
watchTimeSec: weighNumRange(10, 600, .25),
|
|
711
|
+
watchTimeSec: weighNumRange(10, 600, 0.25),
|
|
528
712
|
quality: ["2160p", "1440p", "1080p", "720p", "480p", "360p", "240p"],
|
|
529
713
|
format: ["mp4", "avi", "mov", "mpg"],
|
|
530
714
|
uploader_id: chance.guid.bind(chance),
|
|
531
|
-
}
|
|
532
|
-
},
|
|
533
|
-
{
|
|
534
|
-
event: "like video",
|
|
535
|
-
weight: 6,
|
|
536
|
-
properties: {
|
|
537
|
-
|
|
538
|
-
}
|
|
539
|
-
},
|
|
540
|
-
{
|
|
541
|
-
event: "dislike video",
|
|
542
|
-
weight: 4,
|
|
543
|
-
properties: {
|
|
544
|
-
|
|
545
|
-
}
|
|
715
|
+
},
|
|
546
716
|
},
|
|
717
|
+
{ event: "like video", weight: 6, properties: {} },
|
|
718
|
+
{ event: "dislike video", weight: 4, properties: {} },
|
|
547
719
|
{
|
|
548
720
|
event: "sign up",
|
|
549
721
|
weight: 1,
|
|
@@ -553,9 +725,8 @@ const config = {
|
|
|
553
725
|
signupMethod: ["email", "google", "facebook", "twitter", "linkedin", "github"],
|
|
554
726
|
referral: weighChoices(["none", "none", "none", "friend", "ad", "ad", "ad", "friend", "friend", "friend", "friend"]),
|
|
555
727
|
signup_flow: ["v1"],
|
|
556
|
-
}
|
|
728
|
+
},
|
|
557
729
|
},
|
|
558
|
-
|
|
559
730
|
],
|
|
560
731
|
funnels: [
|
|
561
732
|
{
|
|
@@ -566,8 +737,7 @@ const config = {
|
|
|
566
737
|
isFirstFunnel: true,
|
|
567
738
|
timeToConvert: 2,
|
|
568
739
|
experiment: false,
|
|
569
|
-
name: "Signup Flow"
|
|
570
|
-
|
|
740
|
+
name: "Signup Flow",
|
|
571
741
|
},
|
|
572
742
|
{
|
|
573
743
|
sequence: ["watch video", "like video", "watch video", "like video"],
|
|
@@ -577,8 +747,8 @@ const config = {
|
|
|
577
747
|
videoCategory: videoCategories,
|
|
578
748
|
quality: ["2160p", "1440p", "1080p", "720p", "480p", "360p", "240p"],
|
|
579
749
|
format: ["mp4", "avi", "mov", "mpg"],
|
|
580
|
-
uploader_id: chance.guid.bind(chance)
|
|
581
|
-
}
|
|
750
|
+
uploader_id: chance.guid.bind(chance),
|
|
751
|
+
},
|
|
582
752
|
},
|
|
583
753
|
{
|
|
584
754
|
name: "Video Dislikes",
|
|
@@ -588,8 +758,8 @@ const config = {
|
|
|
588
758
|
videoCategory: videoCategories,
|
|
589
759
|
quality: ["2160p", "1440p", "1080p", "720p", "480p", "360p", "240p"],
|
|
590
760
|
format: ["mp4", "avi", "mov", "mpg"],
|
|
591
|
-
uploader_id: chance.guid.bind(chance)
|
|
592
|
-
}
|
|
761
|
+
uploader_id: chance.guid.bind(chance),
|
|
762
|
+
},
|
|
593
763
|
},
|
|
594
764
|
{
|
|
595
765
|
name: "eCommerce Purchase",
|
|
@@ -607,19 +777,14 @@ const config = {
|
|
|
607
777
|
],
|
|
608
778
|
startDaysBeforeEnd: 30,
|
|
609
779
|
},
|
|
610
|
-
}
|
|
611
|
-
|
|
780
|
+
},
|
|
612
781
|
],
|
|
613
782
|
superProps: {
|
|
614
783
|
theme: ["light", "dark", "custom", "light", "dark"],
|
|
615
784
|
},
|
|
616
|
-
/*
|
|
617
|
-
user properties work the same as event properties
|
|
618
|
-
each key should be an array or function reference
|
|
619
|
-
*/
|
|
620
785
|
userProps: {
|
|
621
786
|
title: chance.profession.bind(chance),
|
|
622
|
-
luckyNumber: weighNumRange(42, 420, .3),
|
|
787
|
+
luckyNumber: weighNumRange(42, 420, 0.3),
|
|
623
788
|
spiritAnimal: spiritAnimals,
|
|
624
789
|
theme: ["light", "dark", "custom", "light", "dark"],
|
|
625
790
|
},
|
|
@@ -628,256 +793,19 @@ const config = {
|
|
|
628
793
|
values: ["bronze", "silver", "gold", "platinum"],
|
|
629
794
|
frequency: "month",
|
|
630
795
|
timing: "fuzzy",
|
|
631
|
-
max: 8
|
|
632
|
-
}
|
|
796
|
+
max: 8,
|
|
797
|
+
},
|
|
633
798
|
},
|
|
634
799
|
mirrorProps: {},
|
|
635
|
-
|
|
636
|
-
/*
|
|
637
|
-
for group analytics keys, we need an array of arrays [[],[],[]]
|
|
638
|
-
each pair represents a group_key and the number of profiles for that key
|
|
639
|
-
*/
|
|
640
800
|
groupKeys: [],
|
|
641
801
|
groupProps: {},
|
|
642
802
|
lookupTables: [],
|
|
643
|
-
hook
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
// light-theme users convert 0.85x. Custom-theme is unchanged.
|
|
648
|
-
if (type === "funnel-pre") {
|
|
649
|
-
const isPurchaseFunnel = meta.funnel?.sequence?.includes("checkout");
|
|
650
|
-
if (isPurchaseFunnel) {
|
|
651
|
-
const theme = meta.profile?.theme;
|
|
652
|
-
if (theme === "dark") record.conversionRate = Math.min(95, Math.round(record.conversionRate * 1.3));
|
|
653
|
-
else if (theme === "light") record.conversionRate = Math.round(record.conversionRate * 0.85);
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
if (type === "event") {
|
|
658
|
-
// unflattering 'items'
|
|
659
|
-
if (record.item && Array.isArray(record.item)) {
|
|
660
|
-
record = { ...record, ...record.item[0] };
|
|
661
|
-
delete record.item;
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
// toys + shoes frequently purchases together (and are higher cart values)
|
|
665
|
-
if (record.event === 'checkout' && Array.isArray(record.cart)) {
|
|
666
|
-
const hasToys = record.cart.some(item => item.category === 'toys');
|
|
667
|
-
const hasShoes = record.cart.some(item => item.category === 'shoes');
|
|
668
|
-
if (hasToys && !hasShoes) {
|
|
669
|
-
const bigCart = makeProducts(20)()()[0];
|
|
670
|
-
const shoeItems = bigCart.filter(item => item.category === 'shoes');
|
|
671
|
-
if (shoeItems.length > 0) {
|
|
672
|
-
record.cart.push(shoeItems[0]);
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
if (hasShoes && !hasToys) {
|
|
677
|
-
const bigCart = makeProducts(20)()()[0];
|
|
678
|
-
const toyItems = bigCart.filter(item => item.category === 'toys');
|
|
679
|
-
if (toyItems.length > 0) {
|
|
680
|
-
record.cart.push(toyItems[0]);
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
if (!hasToys && !hasShoes) {
|
|
685
|
-
const cheapFactor = decimal(.75, 0.9);
|
|
686
|
-
// make every item a bit cheaper
|
|
687
|
-
record.cart = record.cart.map(item => {
|
|
688
|
-
return {
|
|
689
|
-
...item,
|
|
690
|
-
amount: Math.round(item.amount * cheapFactor),
|
|
691
|
-
total_value: Math.round(item.total_value * cheapFactor)
|
|
692
|
-
};
|
|
693
|
-
});
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
// high quality video means longer watch times (lower quality shorter watch times)
|
|
697
|
-
if (record.event === 'watch video') {
|
|
698
|
-
const qualityFactors = {
|
|
699
|
-
"2160p": 1.5,
|
|
700
|
-
"1440p": 1.4,
|
|
701
|
-
"1080p": 1.3,
|
|
702
|
-
"720p": 1.15,
|
|
703
|
-
"480p": 1.0,
|
|
704
|
-
"360p": 0.85,
|
|
705
|
-
"240p": 0.7
|
|
706
|
-
};
|
|
707
|
-
const quality = record.quality || "480p";
|
|
708
|
-
const factor = qualityFactors[quality] || 1.0;
|
|
709
|
-
record.watchTimeSec = Math.round(record.watchTimeSec * factor);
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
if (type === "everything") {
|
|
719
|
-
// Stamp superProps from profile for consistency
|
|
720
|
-
const profile = meta.profile;
|
|
721
|
-
record.forEach(e => {
|
|
722
|
-
e.theme = profile.theme;
|
|
723
|
-
});
|
|
724
|
-
|
|
725
|
-
const datasetEnd = dayjs.unix(meta.datasetEnd);
|
|
726
|
-
const DAY_SIGNUPS_IMPROVED = datasetEnd.subtract(7, 'day');
|
|
727
|
-
const DAY_WATCH_TIME_WENT_UP = datasetEnd.subtract(30, 'day');
|
|
728
|
-
|
|
729
|
-
// Hook 1: Signup flow improvement — pre-fix signups (v1) get 50% dropped
|
|
730
|
-
record.forEach(e => {
|
|
731
|
-
if (e.event !== 'sign up') return;
|
|
732
|
-
const eventTime = dayjs(e.time);
|
|
733
|
-
e.signup_flow = "v1";
|
|
734
|
-
if (eventTime.isBefore(DAY_SIGNUPS_IMPROVED)) {
|
|
735
|
-
if (chance.bool({ likelihood: 50 })) {
|
|
736
|
-
e._drop = true;
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
if (eventTime.isAfter(DAY_SIGNUPS_IMPROVED)) {
|
|
740
|
-
e.signup_flow = "v2";
|
|
741
|
-
}
|
|
742
|
-
});
|
|
743
|
-
|
|
744
|
-
// Hook 2: Watch time inflection — before 30 days ago: reduce, after: increase
|
|
745
|
-
record.forEach(e => {
|
|
746
|
-
if (e.event !== 'watch video') return;
|
|
747
|
-
const eventTime = dayjs(e.time);
|
|
748
|
-
const factor = decimal(0.25, 0.79);
|
|
749
|
-
if (eventTime.isBefore(DAY_WATCH_TIME_WENT_UP)) {
|
|
750
|
-
e.watchTimeSec = Math.round(e.watchTimeSec * (1 - factor));
|
|
751
|
-
}
|
|
752
|
-
if (eventTime.isAfter(DAY_WATCH_TIME_WENT_UP)) {
|
|
753
|
-
e.watchTimeSec = Math.round(e.watchTimeSec * (1 + factor));
|
|
754
|
-
}
|
|
755
|
-
});
|
|
756
|
-
|
|
757
|
-
// Hook 6: View-Item Magic Number (behavioral, no flags)
|
|
758
|
-
// Users in 3-8 view-item sweet spot show ~25% higher cart amounts
|
|
759
|
-
// AND elevated add-to-cart rate (~45% boost via cloned events).
|
|
760
|
-
// Users >=9 are over-engaged window-shoppers; ~30% of their checkouts drop.
|
|
761
|
-
const viewItemCount = record.filter(e => e.event === 'view item').length;
|
|
762
|
-
if (viewItemCount >= 3 && viewItemCount <= 8) {
|
|
763
|
-
record.forEach(e => {
|
|
764
|
-
if (e.event === 'checkout' && Array.isArray(e.cart)) {
|
|
765
|
-
e.cart = e.cart.map(it => ({
|
|
766
|
-
...it,
|
|
767
|
-
amount: typeof it.amount === 'number' ? Math.round(it.amount * 1.25) : it.amount,
|
|
768
|
-
total_value: typeof it.total_value === 'number' ? Math.round(it.total_value * 1.25) : it.total_value
|
|
769
|
-
}));
|
|
770
|
-
}
|
|
771
|
-
});
|
|
772
|
-
// Clone ~45% of add-to-cart events to boost cart add rate
|
|
773
|
-
const addToCartEvents = record.filter(e => e.event === 'add to cart');
|
|
774
|
-
const clones = [];
|
|
775
|
-
addToCartEvents.forEach(e => {
|
|
776
|
-
if (chance.bool({ likelihood: 45 })) {
|
|
777
|
-
const offsetMs = integer(60_000, 600_000); // 1-10 min offset
|
|
778
|
-
clones.push({
|
|
779
|
-
...e,
|
|
780
|
-
insert_id: uid(),
|
|
781
|
-
time: dayjs(e.time).add(offsetMs, 'milliseconds').toISOString()
|
|
782
|
-
});
|
|
783
|
-
}
|
|
784
|
-
});
|
|
785
|
-
if (clones.length > 0) record.push(...clones);
|
|
786
|
-
} else if (viewItemCount >= 9) {
|
|
787
|
-
for (let i = record.length - 1; i >= 0; i--) {
|
|
788
|
-
if (record[i].event === 'checkout' && chance.bool({ likelihood: 30 })) {
|
|
789
|
-
record.splice(i, 1);
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
// Hook 7 (TTC): SIGNUP FLOW TIME-TO-CONVERT (everything)
|
|
795
|
-
// Gold/platinum loyalty users complete the Signup Flow funnel
|
|
796
|
-
// 0.67x faster; bronze users 1.33x slower. Loyalty tier is read
|
|
797
|
-
// from SCD data (meta.scd.loyalty_tier); falls back to a
|
|
798
|
-
// deterministic hash of user_id for consistent assignment.
|
|
799
|
-
//
|
|
800
|
-
// The funnel uses order:"first-and-last-fixed" so middle steps
|
|
801
|
-
// can reorder. We anchor on the first "sign up" event, look back
|
|
802
|
-
// up to 2 days for all funnel-step events in that window, and
|
|
803
|
-
// scale the whole cluster via scaleFunnelTTC.
|
|
804
|
-
{
|
|
805
|
-
// Determine loyalty tier from SCD (latest entry) or hash fallback
|
|
806
|
-
let loyaltyTier = "silver"; // default baseline
|
|
807
|
-
const scdEntries = meta?.scd?.loyalty_tier;
|
|
808
|
-
if (Array.isArray(scdEntries) && scdEntries.length > 0) {
|
|
809
|
-
// Pick the latest SCD entry
|
|
810
|
-
const latest = scdEntries.reduce((a, b) =>
|
|
811
|
-
(a.time > b.time ? a : b));
|
|
812
|
-
loyaltyTier = latest.loyalty_tier || "silver";
|
|
813
|
-
} else {
|
|
814
|
-
// Deterministic hash fallback: gold ~20%, silver ~30%, bronze ~50%
|
|
815
|
-
const uid = record[0]?.user_id || "";
|
|
816
|
-
const hash = uid.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0);
|
|
817
|
-
const bucket = hash % 100;
|
|
818
|
-
loyaltyTier = bucket < 20 ? "gold" : bucket < 50 ? "silver" : "bronze";
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
const ttcFactor = (
|
|
822
|
-
loyaltyTier === "gold" || loyaltyTier === "platinum" ? 0.67 :
|
|
823
|
-
loyaltyTier === "bronze" ? 1.33 :
|
|
824
|
-
1.0
|
|
825
|
-
);
|
|
826
|
-
if (ttcFactor !== 1.0) {
|
|
827
|
-
// Find signup funnel: anchor on first "sign up", look back 2 days
|
|
828
|
-
// for the cluster of funnel-step events
|
|
829
|
-
const funnelSteps = new Set(["page view", "view item", "save item", "sign up"]);
|
|
830
|
-
const sorted = record.slice().sort((a, b) =>
|
|
831
|
-
new Date(a.time) - new Date(b.time));
|
|
832
|
-
const signupEvent = sorted.find(e => e.event === "sign up");
|
|
833
|
-
if (signupEvent) {
|
|
834
|
-
const signupMs = new Date(signupEvent.time).getTime();
|
|
835
|
-
const windowMs = 2 * 24 * 60 * 60 * 1000; // 2-day lookback
|
|
836
|
-
const funnelEvents = sorted.filter(e =>
|
|
837
|
-
funnelSteps.has(e.event) &&
|
|
838
|
-
new Date(e.time).getTime() >= signupMs - windowMs &&
|
|
839
|
-
new Date(e.time).getTime() <= signupMs
|
|
840
|
-
);
|
|
841
|
-
if (funnelEvents.length >= 2) {
|
|
842
|
-
scaleFunnelTTC(funnelEvents, ttcFactor);
|
|
843
|
-
}
|
|
844
|
-
}
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
// H10: Save-Item Retention (everything — retention magic number)
|
|
851
|
-
// Born-in-dataset users with 2+ "save item" in their first 10 days
|
|
852
|
-
// retain; users below the threshold lose ~70% of events after day 25.
|
|
853
|
-
if (type === "everything" && meta.userIsBornInDataset) {
|
|
854
|
-
const firstT = record[0]?.time;
|
|
855
|
-
if (firstT) {
|
|
856
|
-
const window10 = dayjs(firstT).add(10, 'days').toISOString();
|
|
857
|
-
const saves = record.filter(e => e.event === 'save item' && e.time <= window10).length;
|
|
858
|
-
if (saves < 2) {
|
|
859
|
-
const cutoff = dayjs(firstT).add(25, 'days');
|
|
860
|
-
for (let i = record.length - 1; i >= 0; i--) {
|
|
861
|
-
if (dayjs(record[i].time).isAfter(cutoff) && chance.bool({ likelihood: 70 })) {
|
|
862
|
-
record.splice(i, 1);
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
// Filter out events tagged for removal in the event hook
|
|
870
|
-
if (type === "everything") {
|
|
871
|
-
record = record.filter(e => !e._drop);
|
|
872
|
-
}
|
|
873
|
-
|
|
803
|
+
hook(record, type, meta) {
|
|
804
|
+
if (type === "funnel-pre") return handleFunnelPreHooks(record, meta);
|
|
805
|
+
if (type === "event") return handleEventHooks(record);
|
|
806
|
+
if (type === "everything") return handleEverythingHooks(record, meta);
|
|
874
807
|
return record;
|
|
875
|
-
}
|
|
808
|
+
},
|
|
876
809
|
};
|
|
877
810
|
|
|
878
|
-
function flip(likelihood = 50) {
|
|
879
|
-
return chance.bool({ likelihood });
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
|
|
883
811
|
export default config;
|