@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,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,42 +419,219 @@ 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
|
{
|
|
@@ -464,7 +642,7 @@ const config = {
|
|
|
464
642
|
currency: ["USD", "CAD", "EUR", "BTC", "ETH", "JPY"],
|
|
465
643
|
coupon: weighChoices(["none", "none", "none", "none", "10%OFF", "20%OFF", "10%OFF", "20%OFF", "30%OFF", "40%OFF", "50%OFF"]),
|
|
466
644
|
cart: makeProducts(),
|
|
467
|
-
}
|
|
645
|
+
},
|
|
468
646
|
},
|
|
469
647
|
{
|
|
470
648
|
event: "add to cart",
|
|
@@ -476,12 +654,12 @@ const config = {
|
|
|
476
654
|
quantity: weighNumRange(1, 5, 0.3),
|
|
477
655
|
total_value: weighNumRange(1, 500, 0.3),
|
|
478
656
|
featured: [true, false, false],
|
|
479
|
-
category:
|
|
480
|
-
descriptor:
|
|
657
|
+
category: productCategories,
|
|
658
|
+
descriptor: productDescriptors,
|
|
481
659
|
slug: ["item"],
|
|
482
660
|
assetPreview: ["https://example.com/assets/item.png"],
|
|
483
|
-
assetType:
|
|
484
|
-
}
|
|
661
|
+
assetType: assetPreview,
|
|
662
|
+
},
|
|
485
663
|
},
|
|
486
664
|
{
|
|
487
665
|
event: "view item",
|
|
@@ -493,12 +671,12 @@ const config = {
|
|
|
493
671
|
quantity: weighNumRange(1, 5, 0.3),
|
|
494
672
|
total_value: weighNumRange(1, 500, 0.3),
|
|
495
673
|
featured: [true, false, false],
|
|
496
|
-
category:
|
|
497
|
-
descriptor:
|
|
674
|
+
category: productCategories,
|
|
675
|
+
descriptor: productDescriptors,
|
|
498
676
|
slug: ["item"],
|
|
499
677
|
assetPreview: ["https://example.com/assets/item.png"],
|
|
500
|
-
assetType:
|
|
501
|
-
}
|
|
678
|
+
assetType: assetPreview,
|
|
679
|
+
},
|
|
502
680
|
},
|
|
503
681
|
{
|
|
504
682
|
event: "save item",
|
|
@@ -510,12 +688,12 @@ const config = {
|
|
|
510
688
|
quantity: weighNumRange(1, 5, 0.3),
|
|
511
689
|
total_value: weighNumRange(1, 500, 0.3),
|
|
512
690
|
featured: [true, false, false],
|
|
513
|
-
category:
|
|
514
|
-
descriptor:
|
|
691
|
+
category: productCategories,
|
|
692
|
+
descriptor: productDescriptors,
|
|
515
693
|
slug: ["item"],
|
|
516
694
|
assetPreview: ["https://example.com/assets/item.png"],
|
|
517
|
-
assetType:
|
|
518
|
-
}
|
|
695
|
+
assetType: assetPreview,
|
|
696
|
+
},
|
|
519
697
|
},
|
|
520
698
|
{
|
|
521
699
|
event: "page view",
|
|
@@ -523,33 +701,21 @@ const config = {
|
|
|
523
701
|
isStrictEvent: false,
|
|
524
702
|
properties: {
|
|
525
703
|
page: ["/", "/help", "/account", "/watch", "/listen", "/product", "/people", "/peace"],
|
|
526
|
-
}
|
|
704
|
+
},
|
|
527
705
|
},
|
|
528
706
|
{
|
|
529
707
|
event: "watch video",
|
|
530
708
|
weight: 8,
|
|
531
709
|
isStrictEvent: false,
|
|
532
710
|
properties: {
|
|
533
|
-
watchTimeSec: weighNumRange(10, 600, .25),
|
|
711
|
+
watchTimeSec: weighNumRange(10, 600, 0.25),
|
|
534
712
|
quality: ["2160p", "1440p", "1080p", "720p", "480p", "360p", "240p"],
|
|
535
713
|
format: ["mp4", "avi", "mov", "mpg"],
|
|
536
714
|
uploader_id: chance.guid.bind(chance),
|
|
537
|
-
}
|
|
538
|
-
},
|
|
539
|
-
{
|
|
540
|
-
event: "like video",
|
|
541
|
-
weight: 6,
|
|
542
|
-
properties: {
|
|
543
|
-
|
|
544
|
-
}
|
|
545
|
-
},
|
|
546
|
-
{
|
|
547
|
-
event: "dislike video",
|
|
548
|
-
weight: 4,
|
|
549
|
-
properties: {
|
|
550
|
-
|
|
551
|
-
}
|
|
715
|
+
},
|
|
552
716
|
},
|
|
717
|
+
{ event: "like video", weight: 6, properties: {} },
|
|
718
|
+
{ event: "dislike video", weight: 4, properties: {} },
|
|
553
719
|
{
|
|
554
720
|
event: "sign up",
|
|
555
721
|
weight: 1,
|
|
@@ -559,9 +725,8 @@ const config = {
|
|
|
559
725
|
signupMethod: ["email", "google", "facebook", "twitter", "linkedin", "github"],
|
|
560
726
|
referral: weighChoices(["none", "none", "none", "friend", "ad", "ad", "ad", "friend", "friend", "friend", "friend"]),
|
|
561
727
|
signup_flow: ["v1"],
|
|
562
|
-
}
|
|
728
|
+
},
|
|
563
729
|
},
|
|
564
|
-
|
|
565
730
|
],
|
|
566
731
|
funnels: [
|
|
567
732
|
{
|
|
@@ -572,8 +737,7 @@ const config = {
|
|
|
572
737
|
isFirstFunnel: true,
|
|
573
738
|
timeToConvert: 2,
|
|
574
739
|
experiment: false,
|
|
575
|
-
name: "Signup Flow"
|
|
576
|
-
|
|
740
|
+
name: "Signup Flow",
|
|
577
741
|
},
|
|
578
742
|
{
|
|
579
743
|
sequence: ["watch video", "like video", "watch video", "like video"],
|
|
@@ -583,8 +747,8 @@ const config = {
|
|
|
583
747
|
videoCategory: videoCategories,
|
|
584
748
|
quality: ["2160p", "1440p", "1080p", "720p", "480p", "360p", "240p"],
|
|
585
749
|
format: ["mp4", "avi", "mov", "mpg"],
|
|
586
|
-
uploader_id: chance.guid.bind(chance)
|
|
587
|
-
}
|
|
750
|
+
uploader_id: chance.guid.bind(chance),
|
|
751
|
+
},
|
|
588
752
|
},
|
|
589
753
|
{
|
|
590
754
|
name: "Video Dislikes",
|
|
@@ -594,8 +758,8 @@ const config = {
|
|
|
594
758
|
videoCategory: videoCategories,
|
|
595
759
|
quality: ["2160p", "1440p", "1080p", "720p", "480p", "360p", "240p"],
|
|
596
760
|
format: ["mp4", "avi", "mov", "mpg"],
|
|
597
|
-
uploader_id: chance.guid.bind(chance)
|
|
598
|
-
}
|
|
761
|
+
uploader_id: chance.guid.bind(chance),
|
|
762
|
+
},
|
|
599
763
|
},
|
|
600
764
|
{
|
|
601
765
|
name: "eCommerce Purchase",
|
|
@@ -613,19 +777,14 @@ const config = {
|
|
|
613
777
|
],
|
|
614
778
|
startDaysBeforeEnd: 30,
|
|
615
779
|
},
|
|
616
|
-
}
|
|
617
|
-
|
|
780
|
+
},
|
|
618
781
|
],
|
|
619
782
|
superProps: {
|
|
620
783
|
theme: ["light", "dark", "custom", "light", "dark"],
|
|
621
784
|
},
|
|
622
|
-
/*
|
|
623
|
-
user properties work the same as event properties
|
|
624
|
-
each key should be an array or function reference
|
|
625
|
-
*/
|
|
626
785
|
userProps: {
|
|
627
786
|
title: chance.profession.bind(chance),
|
|
628
|
-
luckyNumber: weighNumRange(42, 420, .3),
|
|
787
|
+
luckyNumber: weighNumRange(42, 420, 0.3),
|
|
629
788
|
spiritAnimal: spiritAnimals,
|
|
630
789
|
theme: ["light", "dark", "custom", "light", "dark"],
|
|
631
790
|
},
|
|
@@ -634,256 +793,19 @@ const config = {
|
|
|
634
793
|
values: ["bronze", "silver", "gold", "platinum"],
|
|
635
794
|
frequency: "month",
|
|
636
795
|
timing: "fuzzy",
|
|
637
|
-
max: 8
|
|
638
|
-
}
|
|
796
|
+
max: 8,
|
|
797
|
+
},
|
|
639
798
|
},
|
|
640
799
|
mirrorProps: {},
|
|
641
|
-
|
|
642
|
-
/*
|
|
643
|
-
for group analytics keys, we need an array of arrays [[],[],[]]
|
|
644
|
-
each pair represents a group_key and the number of profiles for that key
|
|
645
|
-
*/
|
|
646
800
|
groupKeys: [],
|
|
647
801
|
groupProps: {},
|
|
648
802
|
lookupTables: [],
|
|
649
|
-
hook
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
// light-theme users convert 0.85x. Custom-theme is unchanged.
|
|
654
|
-
if (type === "funnel-pre") {
|
|
655
|
-
const isPurchaseFunnel = meta.funnel?.sequence?.includes("checkout");
|
|
656
|
-
if (isPurchaseFunnel) {
|
|
657
|
-
const theme = meta.profile?.theme;
|
|
658
|
-
if (theme === "dark") record.conversionRate = Math.min(95, Math.round(record.conversionRate * 1.3));
|
|
659
|
-
else if (theme === "light") record.conversionRate = Math.round(record.conversionRate * 0.85);
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
if (type === "event") {
|
|
664
|
-
// unflattering 'items'
|
|
665
|
-
if (record.item && Array.isArray(record.item)) {
|
|
666
|
-
record = { ...record, ...record.item[0] };
|
|
667
|
-
delete record.item;
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
// toys + shoes frequently purchases together (and are higher cart values)
|
|
671
|
-
if (record.event === 'checkout' && Array.isArray(record.cart)) {
|
|
672
|
-
const hasToys = record.cart.some(item => item.category === 'toys');
|
|
673
|
-
const hasShoes = record.cart.some(item => item.category === 'shoes');
|
|
674
|
-
if (hasToys && !hasShoes) {
|
|
675
|
-
const bigCart = makeProducts(20)()()[0];
|
|
676
|
-
const shoeItems = bigCart.filter(item => item.category === 'shoes');
|
|
677
|
-
if (shoeItems.length > 0) {
|
|
678
|
-
record.cart.push(shoeItems[0]);
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
if (hasShoes && !hasToys) {
|
|
683
|
-
const bigCart = makeProducts(20)()()[0];
|
|
684
|
-
const toyItems = bigCart.filter(item => item.category === 'toys');
|
|
685
|
-
if (toyItems.length > 0) {
|
|
686
|
-
record.cart.push(toyItems[0]);
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
if (!hasToys && !hasShoes) {
|
|
691
|
-
const cheapFactor = decimal(.75, 0.9);
|
|
692
|
-
// make every item a bit cheaper
|
|
693
|
-
record.cart = record.cart.map(item => {
|
|
694
|
-
return {
|
|
695
|
-
...item,
|
|
696
|
-
amount: Math.round(item.amount * cheapFactor),
|
|
697
|
-
total_value: Math.round(item.total_value * cheapFactor)
|
|
698
|
-
};
|
|
699
|
-
});
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
// high quality video means longer watch times (lower quality shorter watch times)
|
|
703
|
-
if (record.event === 'watch video') {
|
|
704
|
-
const qualityFactors = {
|
|
705
|
-
"2160p": 1.5,
|
|
706
|
-
"1440p": 1.4,
|
|
707
|
-
"1080p": 1.3,
|
|
708
|
-
"720p": 1.15,
|
|
709
|
-
"480p": 1.0,
|
|
710
|
-
"360p": 0.85,
|
|
711
|
-
"240p": 0.7
|
|
712
|
-
};
|
|
713
|
-
const quality = record.quality || "480p";
|
|
714
|
-
const factor = qualityFactors[quality] || 1.0;
|
|
715
|
-
record.watchTimeSec = Math.round(record.watchTimeSec * factor);
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
if (type === "everything") {
|
|
725
|
-
// Stamp superProps from profile for consistency
|
|
726
|
-
const profile = meta.profile;
|
|
727
|
-
record.forEach(e => {
|
|
728
|
-
e.theme = profile.theme;
|
|
729
|
-
});
|
|
730
|
-
|
|
731
|
-
const datasetEnd = dayjs.unix(meta.datasetEnd);
|
|
732
|
-
const DAY_SIGNUPS_IMPROVED = datasetEnd.subtract(7, 'day');
|
|
733
|
-
const DAY_WATCH_TIME_WENT_UP = datasetEnd.subtract(30, 'day');
|
|
734
|
-
|
|
735
|
-
// Hook 1: Signup flow improvement — pre-fix signups (v1) get 50% dropped
|
|
736
|
-
record.forEach(e => {
|
|
737
|
-
if (e.event !== 'sign up') return;
|
|
738
|
-
const eventTime = dayjs(e.time);
|
|
739
|
-
e.signup_flow = "v1";
|
|
740
|
-
if (eventTime.isBefore(DAY_SIGNUPS_IMPROVED)) {
|
|
741
|
-
if (chance.bool({ likelihood: 50 })) {
|
|
742
|
-
e._drop = true;
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
if (eventTime.isAfter(DAY_SIGNUPS_IMPROVED)) {
|
|
746
|
-
e.signup_flow = "v2";
|
|
747
|
-
}
|
|
748
|
-
});
|
|
749
|
-
|
|
750
|
-
// Hook 2: Watch time inflection — before 30 days ago: reduce, after: increase
|
|
751
|
-
record.forEach(e => {
|
|
752
|
-
if (e.event !== 'watch video') return;
|
|
753
|
-
const eventTime = dayjs(e.time);
|
|
754
|
-
const factor = decimal(0.25, 0.79);
|
|
755
|
-
if (eventTime.isBefore(DAY_WATCH_TIME_WENT_UP)) {
|
|
756
|
-
e.watchTimeSec = Math.round(e.watchTimeSec * (1 - factor));
|
|
757
|
-
}
|
|
758
|
-
if (eventTime.isAfter(DAY_WATCH_TIME_WENT_UP)) {
|
|
759
|
-
e.watchTimeSec = Math.round(e.watchTimeSec * (1 + factor));
|
|
760
|
-
}
|
|
761
|
-
});
|
|
762
|
-
|
|
763
|
-
// Hook 6: View-Item Magic Number (behavioral, no flags)
|
|
764
|
-
// Users in 3-8 view-item sweet spot show ~25% higher cart amounts
|
|
765
|
-
// AND elevated add-to-cart rate (~45% boost via cloned events).
|
|
766
|
-
// Users >=9 are over-engaged window-shoppers; ~30% of their checkouts drop.
|
|
767
|
-
const viewItemCount = record.filter(e => e.event === 'view item').length;
|
|
768
|
-
if (viewItemCount >= 3 && viewItemCount <= 8) {
|
|
769
|
-
record.forEach(e => {
|
|
770
|
-
if (e.event === 'checkout' && Array.isArray(e.cart)) {
|
|
771
|
-
e.cart = e.cart.map(it => ({
|
|
772
|
-
...it,
|
|
773
|
-
amount: typeof it.amount === 'number' ? Math.round(it.amount * 1.25) : it.amount,
|
|
774
|
-
total_value: typeof it.total_value === 'number' ? Math.round(it.total_value * 1.25) : it.total_value
|
|
775
|
-
}));
|
|
776
|
-
}
|
|
777
|
-
});
|
|
778
|
-
// Clone ~45% of add-to-cart events to boost cart add rate
|
|
779
|
-
const addToCartEvents = record.filter(e => e.event === 'add to cart');
|
|
780
|
-
const clones = [];
|
|
781
|
-
addToCartEvents.forEach(e => {
|
|
782
|
-
if (chance.bool({ likelihood: 45 })) {
|
|
783
|
-
const offsetMs = integer(60_000, 600_000); // 1-10 min offset
|
|
784
|
-
clones.push({
|
|
785
|
-
...e,
|
|
786
|
-
insert_id: uid(),
|
|
787
|
-
time: dayjs(e.time).add(offsetMs, 'milliseconds').toISOString()
|
|
788
|
-
});
|
|
789
|
-
}
|
|
790
|
-
});
|
|
791
|
-
if (clones.length > 0) record.push(...clones);
|
|
792
|
-
} else if (viewItemCount >= 9) {
|
|
793
|
-
for (let i = record.length - 1; i >= 0; i--) {
|
|
794
|
-
if (record[i].event === 'checkout' && chance.bool({ likelihood: 30 })) {
|
|
795
|
-
record.splice(i, 1);
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
// Hook 7 (TTC): SIGNUP FLOW TIME-TO-CONVERT (everything)
|
|
801
|
-
// Gold/platinum loyalty users complete the Signup Flow funnel
|
|
802
|
-
// 0.67x faster; bronze users 1.33x slower. Loyalty tier is read
|
|
803
|
-
// from SCD data (meta.scd.loyalty_tier); falls back to a
|
|
804
|
-
// deterministic hash of user_id for consistent assignment.
|
|
805
|
-
//
|
|
806
|
-
// The funnel uses order:"first-and-last-fixed" so middle steps
|
|
807
|
-
// can reorder. We anchor on the first "sign up" event, look back
|
|
808
|
-
// up to 2 days for all funnel-step events in that window, and
|
|
809
|
-
// scale the whole cluster via scaleFunnelTTC.
|
|
810
|
-
{
|
|
811
|
-
// Determine loyalty tier from SCD (latest entry) or hash fallback
|
|
812
|
-
let loyaltyTier = "silver"; // default baseline
|
|
813
|
-
const scdEntries = meta?.scd?.loyalty_tier;
|
|
814
|
-
if (Array.isArray(scdEntries) && scdEntries.length > 0) {
|
|
815
|
-
// Pick the latest SCD entry
|
|
816
|
-
const latest = scdEntries.reduce((a, b) =>
|
|
817
|
-
(a.time > b.time ? a : b));
|
|
818
|
-
loyaltyTier = latest.loyalty_tier || "silver";
|
|
819
|
-
} else {
|
|
820
|
-
// Deterministic hash fallback: gold ~20%, silver ~30%, bronze ~50%
|
|
821
|
-
const uid = record[0]?.user_id || "";
|
|
822
|
-
const hash = uid.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0);
|
|
823
|
-
const bucket = hash % 100;
|
|
824
|
-
loyaltyTier = bucket < 20 ? "gold" : bucket < 50 ? "silver" : "bronze";
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
const ttcFactor = (
|
|
828
|
-
loyaltyTier === "gold" || loyaltyTier === "platinum" ? 0.67 :
|
|
829
|
-
loyaltyTier === "bronze" ? 1.33 :
|
|
830
|
-
1.0
|
|
831
|
-
);
|
|
832
|
-
if (ttcFactor !== 1.0) {
|
|
833
|
-
// Find signup funnel: anchor on first "sign up", look back 2 days
|
|
834
|
-
// for the cluster of funnel-step events
|
|
835
|
-
const funnelSteps = new Set(["page view", "view item", "save item", "sign up"]);
|
|
836
|
-
const sorted = record.slice().sort((a, b) =>
|
|
837
|
-
new Date(a.time) - new Date(b.time));
|
|
838
|
-
const signupEvent = sorted.find(e => e.event === "sign up");
|
|
839
|
-
if (signupEvent) {
|
|
840
|
-
const signupMs = new Date(signupEvent.time).getTime();
|
|
841
|
-
const windowMs = 2 * 24 * 60 * 60 * 1000; // 2-day lookback
|
|
842
|
-
const funnelEvents = sorted.filter(e =>
|
|
843
|
-
funnelSteps.has(e.event) &&
|
|
844
|
-
new Date(e.time).getTime() >= signupMs - windowMs &&
|
|
845
|
-
new Date(e.time).getTime() <= signupMs
|
|
846
|
-
);
|
|
847
|
-
if (funnelEvents.length >= 2) {
|
|
848
|
-
scaleFunnelTTC(funnelEvents, ttcFactor);
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
// H10: Save-Item Retention (everything — retention magic number)
|
|
857
|
-
// Born-in-dataset users with 2+ "save item" in their first 10 days
|
|
858
|
-
// retain; users below the threshold lose ~70% of events after day 25.
|
|
859
|
-
if (type === "everything" && meta.userIsBornInDataset) {
|
|
860
|
-
const firstT = record[0]?.time;
|
|
861
|
-
if (firstT) {
|
|
862
|
-
const window10 = dayjs(firstT).add(10, 'days').toISOString();
|
|
863
|
-
const saves = record.filter(e => e.event === 'save item' && e.time <= window10).length;
|
|
864
|
-
if (saves < 2) {
|
|
865
|
-
const cutoff = dayjs(firstT).add(25, 'days');
|
|
866
|
-
for (let i = record.length - 1; i >= 0; i--) {
|
|
867
|
-
if (dayjs(record[i].time).isAfter(cutoff) && chance.bool({ likelihood: 70 })) {
|
|
868
|
-
record.splice(i, 1);
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
// Filter out events tagged for removal in the event hook
|
|
876
|
-
if (type === "everything") {
|
|
877
|
-
record = record.filter(e => !e._drop);
|
|
878
|
-
}
|
|
879
|
-
|
|
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);
|
|
880
807
|
return record;
|
|
881
|
-
}
|
|
808
|
+
},
|
|
882
809
|
};
|
|
883
810
|
|
|
884
|
-
function flip(likelihood = 50) {
|
|
885
|
-
return chance.bool({ likelihood });
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
|
|
889
811
|
export default config;
|