@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,67 +1,49 @@
|
|
|
1
|
-
// ──
|
|
2
|
-
const SEED = "dm4-marketplace";
|
|
3
|
-
const num_days = 120;
|
|
4
|
-
const num_users = 10_000;
|
|
5
|
-
const avg_events_per_user_per_day = 1.2;
|
|
6
|
-
let token = "your-mixpanel-token";
|
|
7
|
-
|
|
8
|
-
// ── env overrides ──
|
|
9
|
-
if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
|
|
10
|
-
|
|
1
|
+
// ── IMPORTS ──
|
|
11
2
|
import dayjs from "dayjs";
|
|
12
3
|
import utc from "dayjs/plugin/utc.js";
|
|
4
|
+
dayjs.extend(utc);
|
|
13
5
|
import "dotenv/config";
|
|
14
6
|
import * as u from "../../lib/utils/utils.js";
|
|
15
7
|
import * as v from "ak-tools";
|
|
16
|
-
|
|
17
|
-
dayjs.extend(utc);
|
|
18
|
-
const chance = u.initChance(SEED);
|
|
19
8
|
/** @typedef {import("../../types").Dungeon} Config */
|
|
20
9
|
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* - user_type: buyer vs seller role
|
|
10
|
+
// ── OVERVIEW ──
|
|
11
|
+
/*
|
|
12
|
+
* NAME: TradeNest
|
|
13
|
+
* APP: Two-sided marketplace connecting sellers who list products with
|
|
14
|
+
* buyers who search, purchase, and review. Sellers list items;
|
|
15
|
+
* buyers browse, message, negotiate, and buy. Revenue from
|
|
16
|
+
* marketplace fees and listing fees.
|
|
17
|
+
* SCALE: 10,000 users, ~1.4M events, 121 days (2026-01-01 → 2026-05-01)
|
|
18
|
+
* CORE LOOP: search → view → add to cart → purchase → review
|
|
19
|
+
* (seller side: create listing → receive offers → accept → ship)
|
|
20
|
+
*
|
|
21
|
+
* EVENTS (18):
|
|
22
|
+
* item searched (8) > app session (8) > item viewed (7) > notification received (6)
|
|
23
|
+
* > add to cart (5) > message sent (5) > listing created (4) > purchase completed (3)
|
|
24
|
+
* > listing updated (3) > offer received (3) > shipping updated (3) > offer accepted (2)
|
|
25
|
+
* > review submitted (2) > seller rated (2) > profile updated (2) > account created (1)
|
|
26
|
+
* > refund requested (1) > account deactivated (1)
|
|
27
|
+
*
|
|
28
|
+
* FUNNELS (5):
|
|
29
|
+
* - Buyer Onboarding: account created → item searched → item viewed → add to cart (40%)
|
|
30
|
+
* - Browse to Purchase: item searched → item viewed → add to cart → purchase completed (30%)
|
|
31
|
+
* - Seller Listing Flow: listing created → listing updated → offer received → offer accepted (25%)
|
|
32
|
+
* - Offer Negotiation: offer received → message sent → offer accepted (35%)
|
|
33
|
+
* - Review After Purchase: purchase completed → shipping updated → review submitted (20%)
|
|
34
|
+
*
|
|
35
|
+
* USER PROPS: user_type, segment, seller_rating, total_transactions, response_time_hours,
|
|
36
|
+
* store_name, Platform, category
|
|
37
|
+
* SUPER PROPS: Platform, category
|
|
38
|
+
* SCD PROPS: seller_tier (new/verified/power/featured, monthly fuzzy, max 8)
|
|
39
|
+
* GROUPS: none
|
|
52
40
|
*/
|
|
53
41
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
* ANALYTICS HOOKS (10 hooks)
|
|
57
|
-
*
|
|
42
|
+
// ── HOOK STORIES ──
|
|
43
|
+
/*
|
|
58
44
|
* NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable via
|
|
59
45
|
* raw-prop breakdowns (segment, day, category) or behavioral cohorts.
|
|
60
46
|
*
|
|
61
|
-
* Adds 9. BROWSE TO PURCHASE TIME-TO-CONVERT and 10. MESSAGE-COUNT MAGIC
|
|
62
|
-
* NUMBER on top of original 8.
|
|
63
|
-
* ═══════════════════════════════════════════════════════════════
|
|
64
|
-
*
|
|
65
47
|
* ───────────────────────────────────────────────────────────────
|
|
66
48
|
* 1. FEE CHANGE IMPACT (everything hook)
|
|
67
49
|
* ───────────────────────────────────────────────────────────────
|
|
@@ -238,6 +220,7 @@ const listingIds = v.range(1, 500).map(() => `LST_${v.uid(8)}`);
|
|
|
238
220
|
*
|
|
239
221
|
* ───────────────────────────────────────────────────────────────
|
|
240
222
|
* 9. BROWSE TO PURCHASE TIME-TO-CONVERT (funnel-post)
|
|
223
|
+
* ───────────────────────────────────────────────────────────────
|
|
241
224
|
*
|
|
242
225
|
* PATTERN: Power_seller and frequent_buyer users complete the
|
|
243
226
|
* Browse to Purchase funnel 1.4x faster (factor 0.71); window_shopper
|
|
@@ -258,6 +241,7 @@ const listingIds = v.range(1, 500).map(() => `LST_${v.uid(8)}`);
|
|
|
258
241
|
*
|
|
259
242
|
* ───────────────────────────────────────────────────────────────
|
|
260
243
|
* 10. MESSAGE-COUNT MAGIC NUMBER (in-funnel, everything)
|
|
244
|
+
* ───────────────────────────────────────────────────────────────
|
|
261
245
|
*
|
|
262
246
|
* PATTERN: Sweet 2-5 message-sent events between item-viewed and
|
|
263
247
|
* offer-received → +35% on offer_amount of offer-received events.
|
|
@@ -302,31 +286,305 @@ const listingIds = v.range(1, 500).map(() => `LST_${v.uid(8)}`);
|
|
|
302
286
|
* Message Magic Number | over purchases/user | 1x | 0.75x | -25%
|
|
303
287
|
*/
|
|
304
288
|
|
|
289
|
+
// ── SCALE ──
|
|
290
|
+
const SEED = "dm4-marketplace";
|
|
291
|
+
const NUM_USERS = 10_000;
|
|
292
|
+
const DATASET_START = "2026-01-01T00:00:00Z";
|
|
293
|
+
const DATASET_END = "2026-05-01T23:59:59Z";
|
|
294
|
+
const EVENTS_PER_DAY = 1.2;
|
|
295
|
+
const token = process.env.MP_TOKEN || "your-mixpanel-token";
|
|
296
|
+
|
|
297
|
+
const chance = u.initChance(SEED);
|
|
298
|
+
|
|
299
|
+
// ── KNOBS (tweak these to reshape stories) ──
|
|
300
|
+
const FEE_CHANGE_DAY = 45;
|
|
301
|
+
const FEE_CHANGE_MULT = 1.3;
|
|
302
|
+
|
|
303
|
+
const WEEKEND_SPEND_MULT = 1.2;
|
|
304
|
+
|
|
305
|
+
const POWER_SELLER_CLONE_LIKELIHOOD = 65;
|
|
306
|
+
|
|
307
|
+
const ELECTRONICS_CLONE_LIKELIHOOD = 40;
|
|
308
|
+
const ELECTRONICS_MAX_CLONES = 3;
|
|
309
|
+
|
|
310
|
+
const FAST_RESPONDER_HASH_MOD = 5;
|
|
311
|
+
const FAST_RESPONDER_HASH_THRESHOLD = 2;
|
|
312
|
+
const FAST_OFFER_CLONE_COUNT = 2;
|
|
313
|
+
const FAST_OFFER_FROM_RECEIVED_LIKELIHOOD = 60;
|
|
314
|
+
const SLOW_OFFER_DROP_LIKELIHOOD = 60;
|
|
315
|
+
|
|
316
|
+
const NEW_SELLER_CUTOFF_DAYS = 14;
|
|
317
|
+
const NEW_SELLER_DROP_LIKELIHOOD = 50;
|
|
318
|
+
|
|
319
|
+
const FREQUENT_BUYER_DROP_LIKELIHOOD = 25;
|
|
320
|
+
|
|
321
|
+
const TTC_FAST_FACTOR = 0.71;
|
|
322
|
+
const TTC_SLOW_FACTOR = 1.4;
|
|
323
|
+
|
|
324
|
+
const MSG_SWEET_MIN = 2;
|
|
325
|
+
const MSG_SWEET_MAX = 5;
|
|
326
|
+
const MSG_OVER_THRESHOLD = 6;
|
|
327
|
+
const MSG_OFFER_BOOST = 1.35;
|
|
328
|
+
const MSG_PURCHASE_DROP_LIKELIHOOD = 25;
|
|
329
|
+
|
|
330
|
+
// ── DATA ARRAYS ──
|
|
331
|
+
// Generate consistent seller store and listing IDs at module level
|
|
332
|
+
const storeIds = v.range(1, 150).map(() => `STORE_${v.uid(6)}`);
|
|
333
|
+
const listingIds = v.range(1, 500).map(() => `LST_${v.uid(8)}`);
|
|
334
|
+
|
|
335
|
+
// ── HELPER FUNCTIONS ──
|
|
336
|
+
function handleUserHooks(record) {
|
|
337
|
+
// H7: Power seller profiles — enrich segment-tagged profiles
|
|
338
|
+
if (record.segment === "power_seller") {
|
|
339
|
+
record.total_transactions = chance.integer({ min: 100, max: 500 });
|
|
340
|
+
record.seller_rating = chance.floating({ min: 4.5, max: 5.0, fixed: 1 });
|
|
341
|
+
record.store_name = chance.pickone([
|
|
342
|
+
"TechVault Pro", "StyleHaven", "GearFactory", "PrimeFinds",
|
|
343
|
+
"TopShelf Goods", "EliteDeals", "QualityFirst Store", "BestBuy Resale",
|
|
344
|
+
]);
|
|
345
|
+
} else if (record.segment === "casual_seller") {
|
|
346
|
+
record.total_transactions = chance.integer({ min: 5, max: 50 });
|
|
347
|
+
record.seller_rating = chance.floating({ min: 3.0, max: 4.5, fixed: 1 });
|
|
348
|
+
record.store_name = chance.pickone([
|
|
349
|
+
"My Garage Sale", "Closet Cleanout", "Random Finds", "Weekend Seller",
|
|
350
|
+
]);
|
|
351
|
+
} else if (record.segment === "new_seller") {
|
|
352
|
+
record.total_transactions = chance.integer({ min: 0, max: 3 });
|
|
353
|
+
record.seller_rating = 0;
|
|
354
|
+
record.store_name = "New Store";
|
|
355
|
+
}
|
|
356
|
+
return record;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function handleFunnelPostHooks(record, meta) {
|
|
360
|
+
// H9: Browse-to-purchase TTC scaled by segment
|
|
361
|
+
const segment = meta?.profile?.segment;
|
|
362
|
+
if (Array.isArray(record) && record.length > 1) {
|
|
363
|
+
const factor = (
|
|
364
|
+
segment === "power_seller" || segment === "frequent_buyer" ? TTC_FAST_FACTOR :
|
|
365
|
+
segment === "window_shopper" ? TTC_SLOW_FACTOR :
|
|
366
|
+
1.0
|
|
367
|
+
);
|
|
368
|
+
if (factor !== 1.0) {
|
|
369
|
+
for (let i = 1; i < record.length; i++) {
|
|
370
|
+
const prev = dayjs(record[i - 1].time);
|
|
371
|
+
const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
|
|
372
|
+
record[i].time = prev.add(newGap, "milliseconds").toISOString();
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return record;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function handleEverythingHooks(record, meta) {
|
|
380
|
+
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
381
|
+
const FEE_CHANGE_CUTOFF = datasetStart.add(FEE_CHANGE_DAY, "days");
|
|
382
|
+
let events = record;
|
|
383
|
+
if (!events.length) return record;
|
|
384
|
+
|
|
385
|
+
const profile = meta.profile;
|
|
386
|
+
|
|
387
|
+
// Stamp superProps from profile so they are consistent per user.
|
|
388
|
+
if (profile) {
|
|
389
|
+
events.forEach(e => {
|
|
390
|
+
if (profile.Platform) e.Platform = profile.Platform;
|
|
391
|
+
if (profile.category) e.category = profile.category;
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// H1: Fee change impact — listings after d45 get listing_fee 1.3x.
|
|
396
|
+
// In everything hook so timestamp comparison sees post-bunchIntoSessions times.
|
|
397
|
+
events.forEach(e => {
|
|
398
|
+
if (e.event === "listing created" && dayjs(e.time).isAfter(FEE_CHANGE_CUTOFF)) {
|
|
399
|
+
e.listing_fee = Math.floor((e.listing_fee || 15) * FEE_CHANGE_MULT);
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// H8: Frequent buyer conversion filter — non-frequent-buyer users
|
|
404
|
+
// drop ~25% of "purchase completed" events.
|
|
405
|
+
if (profile && profile.segment !== "frequent_buyer") {
|
|
406
|
+
record = record.filter(e => {
|
|
407
|
+
if (e.event === "purchase completed" && chance.bool({ likelihood: FREQUENT_BUYER_DROP_LIKELIHOOD })) return false;
|
|
408
|
+
return true;
|
|
409
|
+
});
|
|
410
|
+
events = record;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// H3: Seller success → buyer trust — power sellers get 2x purchase events cloned.
|
|
414
|
+
if (profile && profile.segment === "power_seller") {
|
|
415
|
+
const purchases = events.filter(e => e.event === "purchase completed");
|
|
416
|
+
const templatePurchase = purchases[0];
|
|
417
|
+
if (templatePurchase && purchases.length > 0) {
|
|
418
|
+
purchases.forEach(p => {
|
|
419
|
+
if (chance.bool({ likelihood: POWER_SELLER_CLONE_LIKELIHOOD })) {
|
|
420
|
+
events.push({
|
|
421
|
+
...templatePurchase,
|
|
422
|
+
time: dayjs(p.time).add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
|
|
423
|
+
user_id: p.user_id,
|
|
424
|
+
total_amount: chance.integer({ min: 15, max: 400 }),
|
|
425
|
+
item_count: chance.integer({ min: 1, max: 4 }),
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// H4: Search-to-purchase by category — electronics gets cloned purchase events.
|
|
433
|
+
const hasElectronicsSearch = events.some(e =>
|
|
434
|
+
e.event === "item searched" && e.category === "electronics"
|
|
435
|
+
);
|
|
436
|
+
if (hasElectronicsSearch) {
|
|
437
|
+
const templatePurchase = events.find(e => e.event === "purchase completed");
|
|
438
|
+
if (templatePurchase) {
|
|
439
|
+
const electronicsSearches = events.filter(e =>
|
|
440
|
+
e.event === "item searched" && e.category === "electronics"
|
|
441
|
+
);
|
|
442
|
+
electronicsSearches.slice(0, ELECTRONICS_MAX_CLONES).forEach(search => {
|
|
443
|
+
if (chance.bool({ likelihood: ELECTRONICS_CLONE_LIKELIHOOD })) {
|
|
444
|
+
events.push({
|
|
445
|
+
...templatePurchase,
|
|
446
|
+
time: dayjs(search.time).add(chance.integer({ min: 1, max: 12 }), "hours").toISOString(),
|
|
447
|
+
user_id: search.user_id,
|
|
448
|
+
total_amount: chance.integer({ min: 50, max: 300 }),
|
|
449
|
+
category: "electronics",
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// H5: Response time → conversion — deterministic fast/slow cohorts via user hash.
|
|
457
|
+
// Fast (~40%): set response_time_hours 1-4, clone offer_accepted.
|
|
458
|
+
// Slow (~60%): set 8-36, drop 60% of offer_accepted events.
|
|
459
|
+
const msgEvents = events.filter(e => e.event === "message sent");
|
|
460
|
+
if (msgEvents.length > 0) {
|
|
461
|
+
const uid = msgEvents[0].user_id || "";
|
|
462
|
+
const isFast = (uid.charCodeAt(0) + uid.charCodeAt(uid.length - 1)) % FAST_RESPONDER_HASH_MOD < FAST_RESPONDER_HASH_THRESHOLD;
|
|
463
|
+
msgEvents.forEach(m => {
|
|
464
|
+
m.response_time_hours = isFast
|
|
465
|
+
? chance.floating({ min: 0.5, max: 4, fixed: 1 })
|
|
466
|
+
: chance.floating({ min: 8, max: 36, fixed: 1 });
|
|
467
|
+
});
|
|
468
|
+
const templateOffer = events.find(e => e.event === "offer accepted");
|
|
469
|
+
if (isFast && templateOffer) {
|
|
470
|
+
// Fast responders: clone existing offer_accepted 2x each
|
|
471
|
+
const existingAccepts = events.filter(e => e.event === "offer accepted");
|
|
472
|
+
existingAccepts.forEach(accept => {
|
|
473
|
+
for (let c = 0; c < FAST_OFFER_CLONE_COUNT; c++) {
|
|
474
|
+
events.push({
|
|
475
|
+
...accept,
|
|
476
|
+
time: dayjs(accept.time).add(chance.integer({ min: 1, max: 8 }), "hours").toISOString(),
|
|
477
|
+
user_id: accept.user_id,
|
|
478
|
+
response_time_hours: chance.floating({ min: 0.1, max: 2, fixed: 1 }),
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
// Also clone from offer_received → offer_accepted
|
|
483
|
+
const offers = events.filter(e => e.event === "offer received");
|
|
484
|
+
offers.forEach(offer => {
|
|
485
|
+
if (chance.bool({ likelihood: FAST_OFFER_FROM_RECEIVED_LIKELIHOOD })) {
|
|
486
|
+
events.push({
|
|
487
|
+
...templateOffer,
|
|
488
|
+
time: dayjs(offer.time).add(chance.integer({ min: 1, max: 6 }), "hours").toISOString(),
|
|
489
|
+
user_id: offer.user_id,
|
|
490
|
+
response_time_hours: chance.floating({ min: 0.1, max: 3, fixed: 1 }),
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
} else if (!isFast) {
|
|
495
|
+
// Slow responders: drop 60% of offer_accepted events
|
|
496
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
497
|
+
if (events[i].event === "offer accepted" && chance.bool({ likelihood: SLOW_OFFER_DROP_LIKELIHOOD })) {
|
|
498
|
+
events.splice(i, 1);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// H6: New seller churn — drop 50% of events after user's first 14 days.
|
|
505
|
+
if (profile && profile.segment === "new_seller") {
|
|
506
|
+
const firstEventTime = events.length > 0 ? dayjs(events[0].time) : null;
|
|
507
|
+
if (firstEventTime) {
|
|
508
|
+
const userCutoff = firstEventTime.add(NEW_SELLER_CUTOFF_DAYS, "days");
|
|
509
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
510
|
+
if (dayjs(events[i].time).isAfter(userCutoff) && chance.bool({ likelihood: NEW_SELLER_DROP_LIKELIHOOD })) {
|
|
511
|
+
events.splice(i, 1);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// H2: Weekend shopping surge — Sat/Sun purchases get total_amount 1.2x.
|
|
518
|
+
events.forEach(e => {
|
|
519
|
+
if (e.event === "purchase completed") {
|
|
520
|
+
const dow = new Date(e.time).getUTCDay();
|
|
521
|
+
if (dow === 0 || dow === 6) {
|
|
522
|
+
e.total_amount = Math.floor((e.total_amount || 60) * WEEKEND_SPEND_MULT);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// H10: Message-count magic number (in-funnel, no flags)
|
|
528
|
+
// Sweet 2-5 messages between item-viewed and offer-received →
|
|
529
|
+
// +35% on offer-received offer_amount. Over 6+ → drop 25% of purchases.
|
|
530
|
+
const itemViewed = events.find(e => e.event === "item viewed");
|
|
531
|
+
const offerReceived = events.find(e => e.event === "offer received");
|
|
532
|
+
if (itemViewed && offerReceived) {
|
|
533
|
+
const aTime = dayjs(itemViewed.time);
|
|
534
|
+
const bTime = dayjs(offerReceived.time);
|
|
535
|
+
const msgBetween = events.filter(e =>
|
|
536
|
+
e.event === "message sent" &&
|
|
537
|
+
dayjs(e.time).isAfter(aTime) &&
|
|
538
|
+
dayjs(e.time).isBefore(bTime)
|
|
539
|
+
).length;
|
|
540
|
+
if (msgBetween >= MSG_SWEET_MIN && msgBetween <= MSG_SWEET_MAX) {
|
|
541
|
+
events.forEach(e => {
|
|
542
|
+
if (e.event === "offer received" && typeof e.offer_amount === "number") {
|
|
543
|
+
e.offer_amount = Math.round(e.offer_amount * MSG_OFFER_BOOST);
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
} else if (msgBetween >= MSG_OVER_THRESHOLD) {
|
|
547
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
548
|
+
if (events[i].event === "purchase completed" && chance.bool({ likelihood: MSG_PURCHASE_DROP_LIKELIHOOD })) {
|
|
549
|
+
events.splice(i, 1);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return record;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ── CONFIG ──
|
|
305
559
|
/** @type {Config} */
|
|
306
560
|
const config = {
|
|
307
561
|
version: 2,
|
|
308
|
-
token,
|
|
309
562
|
seed: SEED,
|
|
310
|
-
datasetStart:
|
|
311
|
-
datasetEnd:
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
numUsers: num_users,
|
|
315
|
-
hasAnonIds: true,
|
|
316
|
-
avgDevicePerUser: 2,
|
|
317
|
-
hasSessionIds: true,
|
|
563
|
+
datasetStart: DATASET_START,
|
|
564
|
+
datasetEnd: DATASET_END,
|
|
565
|
+
avgEventsPerUserPerDay: EVENTS_PER_DAY,
|
|
566
|
+
numUsers: NUM_USERS,
|
|
318
567
|
format: "json",
|
|
319
568
|
gzip: true,
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
569
|
+
credentials: {
|
|
570
|
+
token,
|
|
571
|
+
},
|
|
572
|
+
switches: {
|
|
573
|
+
hasSessionIds: true,
|
|
574
|
+
alsoInferFunnels: false,
|
|
575
|
+
hasLocation: true,
|
|
576
|
+
hasAndroidDevices: true,
|
|
577
|
+
hasIOSDevices: true,
|
|
578
|
+
hasDesktopDevices: true,
|
|
579
|
+
hasBrowser: false,
|
|
580
|
+
hasCampaigns: false,
|
|
581
|
+
isAnonymous: false,
|
|
582
|
+
hasAdSpend: false,
|
|
583
|
+
hasAvatar: true,
|
|
584
|
+
},
|
|
585
|
+
identity: {
|
|
586
|
+
avgDevicePerUser: 2,
|
|
587
|
+
},
|
|
330
588
|
concurrency: 1,
|
|
331
589
|
writeToDisk: false,
|
|
332
590
|
scdProps: {
|
|
@@ -340,7 +598,6 @@ const config = {
|
|
|
340
598
|
mirrorProps: {},
|
|
341
599
|
lookupTables: [],
|
|
342
600
|
|
|
343
|
-
// ── Events (18) ──────────────────────────────────────────
|
|
344
601
|
events: [
|
|
345
602
|
{
|
|
346
603
|
event: "account created",
|
|
@@ -354,6 +611,7 @@ const config = {
|
|
|
354
611
|
{
|
|
355
612
|
event: "item searched",
|
|
356
613
|
weight: 8,
|
|
614
|
+
isStrictEvent: false,
|
|
357
615
|
properties: {
|
|
358
616
|
search_query: ["laptop", "vintage jacket", "sneakers", "headphones", "phone case", "gaming console", "watch", "sunglasses", "backpack", "camera"],
|
|
359
617
|
results_count: u.weighNumRange(0, 50, 0.5),
|
|
@@ -363,6 +621,7 @@ const config = {
|
|
|
363
621
|
{
|
|
364
622
|
event: "item viewed",
|
|
365
623
|
weight: 7,
|
|
624
|
+
isStrictEvent: false,
|
|
366
625
|
properties: {
|
|
367
626
|
listing_id: chance.pickone.bind(chance, listingIds),
|
|
368
627
|
item_price: u.weighNumRange(5, 500, 0.3, 45),
|
|
@@ -383,6 +642,7 @@ const config = {
|
|
|
383
642
|
{
|
|
384
643
|
event: "purchase completed",
|
|
385
644
|
weight: 3,
|
|
645
|
+
isStrictEvent: false,
|
|
386
646
|
properties: {
|
|
387
647
|
listing_id: chance.pickone.bind(chance, listingIds),
|
|
388
648
|
total_amount: u.weighNumRange(10, 500, 0.3, 60),
|
|
@@ -394,6 +654,7 @@ const config = {
|
|
|
394
654
|
{
|
|
395
655
|
event: "listing created",
|
|
396
656
|
weight: 4,
|
|
657
|
+
isStrictEvent: false,
|
|
397
658
|
properties: {
|
|
398
659
|
listing_id: chance.pickone.bind(chance, listingIds),
|
|
399
660
|
store_id: chance.pickone.bind(chance, storeIds),
|
|
@@ -414,6 +675,7 @@ const config = {
|
|
|
414
675
|
{
|
|
415
676
|
event: "offer received",
|
|
416
677
|
weight: 3,
|
|
678
|
+
isStrictEvent: false,
|
|
417
679
|
properties: {
|
|
418
680
|
listing_id: chance.pickone.bind(chance, listingIds),
|
|
419
681
|
offer_amount: u.weighNumRange(5, 400, 0.3, 40),
|
|
@@ -423,6 +685,7 @@ const config = {
|
|
|
423
685
|
{
|
|
424
686
|
event: "offer accepted",
|
|
425
687
|
weight: 2,
|
|
688
|
+
isStrictEvent: false,
|
|
426
689
|
properties: {
|
|
427
690
|
listing_id: chance.pickone.bind(chance, listingIds),
|
|
428
691
|
final_price: u.weighNumRange(10, 500, 0.3, 55),
|
|
@@ -452,6 +715,7 @@ const config = {
|
|
|
452
715
|
{
|
|
453
716
|
event: "message sent",
|
|
454
717
|
weight: 5,
|
|
718
|
+
isStrictEvent: false,
|
|
455
719
|
properties: {
|
|
456
720
|
message_type: ["inquiry", "inquiry", "negotiation", "shipping_question", "complaint", "thank_you"],
|
|
457
721
|
recipient_type: ["seller", "seller", "buyer", "support"],
|
|
@@ -512,7 +776,6 @@ const config = {
|
|
|
512
776
|
},
|
|
513
777
|
],
|
|
514
778
|
|
|
515
|
-
// ── Funnels (5) ──────────────────────────────────────────
|
|
516
779
|
funnels: [
|
|
517
780
|
{
|
|
518
781
|
name: "Buyer Onboarding",
|
|
@@ -557,13 +820,11 @@ const config = {
|
|
|
557
820
|
},
|
|
558
821
|
],
|
|
559
822
|
|
|
560
|
-
// ── SuperProps ──────────────────────────────────────────
|
|
561
823
|
superProps: {
|
|
562
824
|
Platform: ["ios", "android", "web"],
|
|
563
825
|
category: ["electronics", "clothing", "home_garden", "collectibles", "sports", "toys", "automotive"],
|
|
564
826
|
},
|
|
565
827
|
|
|
566
|
-
// ── UserProps ──────────────────────────────────────────
|
|
567
828
|
userProps: {
|
|
568
829
|
user_type: ["buyer"],
|
|
569
830
|
segment: ["window_shopper"],
|
|
@@ -575,7 +836,6 @@ const config = {
|
|
|
575
836
|
category: ["electronics", "clothing", "home_garden", "collectibles", "sports", "toys", "automotive"],
|
|
576
837
|
},
|
|
577
838
|
|
|
578
|
-
// ── Personas ──────────────────────────────────
|
|
579
839
|
personas: [
|
|
580
840
|
{
|
|
581
841
|
name: "power_seller",
|
|
@@ -635,7 +895,6 @@ const config = {
|
|
|
635
895
|
},
|
|
636
896
|
],
|
|
637
897
|
|
|
638
|
-
// ── World Events ──────────────────────────────
|
|
639
898
|
worldEvents: [
|
|
640
899
|
{
|
|
641
900
|
name: "marketplace_fee_change",
|
|
@@ -655,14 +914,12 @@ const config = {
|
|
|
655
914
|
},
|
|
656
915
|
],
|
|
657
916
|
|
|
658
|
-
// ── Engagement Decay ──────────────────────────
|
|
659
917
|
engagementDecay: {
|
|
660
918
|
model: "exponential",
|
|
661
919
|
halfLife: 75,
|
|
662
920
|
floor: 0.1,
|
|
663
921
|
},
|
|
664
922
|
|
|
665
|
-
// ── Attribution ──────────────────────────────
|
|
666
923
|
attribution: {
|
|
667
924
|
model: "last_touch",
|
|
668
925
|
window: 7,
|
|
@@ -698,7 +955,6 @@ const config = {
|
|
|
698
955
|
organicRate: 0.30,
|
|
699
956
|
},
|
|
700
957
|
|
|
701
|
-
// ── Geo ──────────────────────────────────────
|
|
702
958
|
geo: {
|
|
703
959
|
sticky: true,
|
|
704
960
|
regions: [
|
|
@@ -733,7 +989,6 @@ const config = {
|
|
|
733
989
|
],
|
|
734
990
|
},
|
|
735
991
|
|
|
736
|
-
// ── Anomalies ──────────────────────────────────
|
|
737
992
|
anomalies: [
|
|
738
993
|
{
|
|
739
994
|
type: "extreme_value",
|
|
@@ -753,248 +1008,10 @@ const config = {
|
|
|
753
1008
|
},
|
|
754
1009
|
],
|
|
755
1010
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
// and a realistic store name. Casual sellers get mid-range stats.
|
|
761
|
-
if (type === "user") {
|
|
762
|
-
if (record.segment === "power_seller") {
|
|
763
|
-
record.total_transactions = chance.integer({ min: 100, max: 500 });
|
|
764
|
-
record.seller_rating = chance.floating({ min: 4.5, max: 5.0, fixed: 1 });
|
|
765
|
-
record.store_name = chance.pickone([
|
|
766
|
-
"TechVault Pro", "StyleHaven", "GearFactory", "PrimeFinds",
|
|
767
|
-
"TopShelf Goods", "EliteDeals", "QualityFirst Store", "BestBuy Resale",
|
|
768
|
-
]);
|
|
769
|
-
} else if (record.segment === "casual_seller") {
|
|
770
|
-
record.total_transactions = chance.integer({ min: 5, max: 50 });
|
|
771
|
-
record.seller_rating = chance.floating({ min: 3.0, max: 4.5, fixed: 1 });
|
|
772
|
-
record.store_name = chance.pickone([
|
|
773
|
-
"My Garage Sale", "Closet Cleanout", "Random Finds", "Weekend Seller",
|
|
774
|
-
]);
|
|
775
|
-
} else if (record.segment === "new_seller") {
|
|
776
|
-
record.total_transactions = chance.integer({ min: 0, max: 3 });
|
|
777
|
-
record.seller_rating = 0;
|
|
778
|
-
record.store_name = "New Store";
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
// HOOK 9 (T2C): BROWSE TO PURCHASE TIME-TO-CONVERT (funnel-post)
|
|
783
|
-
// Power_seller users complete Browse to Purchase funnel 1.4x faster
|
|
784
|
-
// (factor 0.71); window_shopper 1.4x slower (factor 1.4).
|
|
785
|
-
if (type === "funnel-post") {
|
|
786
|
-
const segment = meta?.profile?.segment;
|
|
787
|
-
if (Array.isArray(record) && record.length > 1) {
|
|
788
|
-
const factor = (
|
|
789
|
-
segment === "power_seller" || segment === "frequent_buyer" ? 0.71 :
|
|
790
|
-
segment === "window_shopper" ? 1.4 :
|
|
791
|
-
1.0
|
|
792
|
-
);
|
|
793
|
-
if (factor !== 1.0) {
|
|
794
|
-
for (let i = 1; i < record.length; i++) {
|
|
795
|
-
const prev = dayjs(record[i - 1].time);
|
|
796
|
-
const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
|
|
797
|
-
record[i].time = prev.add(newGap, "milliseconds").toISOString();
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
// HOOK 1: FEE CHANGE IMPACT — moved to everything hook below.
|
|
804
|
-
// (event hook fires before bunchIntoSessions reshuffles timestamps)
|
|
805
|
-
|
|
806
|
-
// ── EVERYTHING HOOKS ─────────────────────────────────
|
|
807
|
-
if (type === "everything") {
|
|
808
|
-
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
809
|
-
const FEE_CHANGE_DAY = datasetStart.add(45, "days");
|
|
810
|
-
let events = record;
|
|
811
|
-
if (!events.length) return record;
|
|
812
|
-
|
|
813
|
-
const profile = meta.profile;
|
|
814
|
-
|
|
815
|
-
// ── SUPERPROP STAMPING ──────────────────────────
|
|
816
|
-
// Stamp superProps from profile so they are consistent per user.
|
|
817
|
-
if (profile) {
|
|
818
|
-
events.forEach(e => {
|
|
819
|
-
if (profile.Platform) e.Platform = profile.Platform;
|
|
820
|
-
if (profile.category) e.category = profile.category;
|
|
821
|
-
});
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
// HOOK 1: FEE CHANGE IMPACT — listings after d45 get listing_fee 1.3x.
|
|
825
|
-
// In everything hook so timestamp comparison sees post-bunchIntoSessions times.
|
|
826
|
-
events.forEach(e => {
|
|
827
|
-
if (e.event === "listing created" && dayjs(e.time).isAfter(FEE_CHANGE_DAY)) {
|
|
828
|
-
e.listing_fee = Math.floor((e.listing_fee || 15) * 1.3);
|
|
829
|
-
}
|
|
830
|
-
});
|
|
831
|
-
|
|
832
|
-
// ── HOOK 8: FREQUENT BUYER CONVERSION FILTER ────
|
|
833
|
-
// Non-frequent-buyer users drop ~25% of "purchase completed"
|
|
834
|
-
// (last step of Browse to Purchase funnel) to simulate lower conversion.
|
|
835
|
-
if (profile && profile.segment !== "frequent_buyer") {
|
|
836
|
-
record = record.filter(e => {
|
|
837
|
-
if (e.event === "purchase completed" && chance.bool({ likelihood: 25 })) return false;
|
|
838
|
-
return true;
|
|
839
|
-
});
|
|
840
|
-
events = record;
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
// ── HOOK 3: SELLER SUCCESS → BUYER TRUST ─────────
|
|
844
|
-
// Power sellers get 2x purchase events (cloned from existing).
|
|
845
|
-
if (profile && profile.segment === "power_seller") {
|
|
846
|
-
const purchases = events.filter(e => e.event === "purchase completed");
|
|
847
|
-
const templatePurchase = purchases[0];
|
|
848
|
-
if (templatePurchase && purchases.length > 0) {
|
|
849
|
-
purchases.forEach(p => {
|
|
850
|
-
if (chance.bool({ likelihood: 65 })) {
|
|
851
|
-
events.push({
|
|
852
|
-
...templatePurchase,
|
|
853
|
-
time: dayjs(p.time).add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
|
|
854
|
-
user_id: p.user_id,
|
|
855
|
-
total_amount: chance.integer({ min: 15, max: 400 }),
|
|
856
|
-
item_count: chance.integer({ min: 1, max: 4 }),
|
|
857
|
-
});
|
|
858
|
-
}
|
|
859
|
-
});
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
// ── HOOK 4: SEARCH-TO-PURCHASE BY CATEGORY ───────
|
|
864
|
-
// Electronics category gets cloned purchase events (higher conversion).
|
|
865
|
-
const hasElectronicsSearch = events.some(e =>
|
|
866
|
-
e.event === "item searched" && e.category === "electronics"
|
|
867
|
-
);
|
|
868
|
-
if (hasElectronicsSearch) {
|
|
869
|
-
const templatePurchase = events.find(e => e.event === "purchase completed");
|
|
870
|
-
if (templatePurchase) {
|
|
871
|
-
const electronicsSearches = events.filter(e =>
|
|
872
|
-
e.event === "item searched" && e.category === "electronics"
|
|
873
|
-
);
|
|
874
|
-
electronicsSearches.slice(0, 3).forEach(search => {
|
|
875
|
-
if (chance.bool({ likelihood: 40 })) {
|
|
876
|
-
events.push({
|
|
877
|
-
...templatePurchase,
|
|
878
|
-
time: dayjs(search.time).add(chance.integer({ min: 1, max: 12 }), "hours").toISOString(),
|
|
879
|
-
user_id: search.user_id,
|
|
880
|
-
total_amount: chance.integer({ min: 50, max: 300 }),
|
|
881
|
-
category: "electronics",
|
|
882
|
-
});
|
|
883
|
-
}
|
|
884
|
-
});
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
// ── HOOK 5: RESPONSE TIME → CONVERSION ───────────
|
|
889
|
-
// Deterministic fast/slow cohorts using user hash.
|
|
890
|
-
// Fast responders (~40%): response_time_hours set to 1-4,
|
|
891
|
-
// extra offer_accepted clones. Slow (~60%): set to 8-36,
|
|
892
|
-
// lose 60% of offer_accepted events.
|
|
893
|
-
const msgEvents = events.filter(e => e.event === "message sent");
|
|
894
|
-
if (msgEvents.length > 0) {
|
|
895
|
-
const uid = msgEvents[0].user_id || "";
|
|
896
|
-
const isFast = (uid.charCodeAt(0) + uid.charCodeAt(uid.length - 1)) % 5 < 2; // ~40%
|
|
897
|
-
// Stamp response_time_hours to create the measurable correlation
|
|
898
|
-
msgEvents.forEach(m => {
|
|
899
|
-
m.response_time_hours = isFast
|
|
900
|
-
? chance.floating({ min: 0.5, max: 4, fixed: 1 })
|
|
901
|
-
: chance.floating({ min: 8, max: 36, fixed: 1 });
|
|
902
|
-
});
|
|
903
|
-
const templateOffer = events.find(e => e.event === "offer accepted");
|
|
904
|
-
if (isFast && templateOffer) {
|
|
905
|
-
// Fast responders: clone existing offer_accepted 2x each
|
|
906
|
-
const existingAccepts = events.filter(e => e.event === "offer accepted");
|
|
907
|
-
existingAccepts.forEach(accept => {
|
|
908
|
-
for (let c = 0; c < 2; c++) {
|
|
909
|
-
events.push({
|
|
910
|
-
...accept,
|
|
911
|
-
time: dayjs(accept.time).add(chance.integer({ min: 1, max: 8 }), "hours").toISOString(),
|
|
912
|
-
user_id: accept.user_id,
|
|
913
|
-
response_time_hours: chance.floating({ min: 0.1, max: 2, fixed: 1 }),
|
|
914
|
-
});
|
|
915
|
-
}
|
|
916
|
-
});
|
|
917
|
-
// Also clone from offer_received → offer_accepted
|
|
918
|
-
const offers = events.filter(e => e.event === "offer received");
|
|
919
|
-
offers.forEach(offer => {
|
|
920
|
-
if (chance.bool({ likelihood: 60 })) {
|
|
921
|
-
events.push({
|
|
922
|
-
...templateOffer,
|
|
923
|
-
time: dayjs(offer.time).add(chance.integer({ min: 1, max: 6 }), "hours").toISOString(),
|
|
924
|
-
user_id: offer.user_id,
|
|
925
|
-
response_time_hours: chance.floating({ min: 0.1, max: 3, fixed: 1 }),
|
|
926
|
-
});
|
|
927
|
-
}
|
|
928
|
-
});
|
|
929
|
-
} else if (!isFast) {
|
|
930
|
-
// Slow responders: drop 60% of offer_accepted events
|
|
931
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
932
|
-
if (events[i].event === "offer accepted" && chance.bool({ likelihood: 60 })) {
|
|
933
|
-
events.splice(i, 1);
|
|
934
|
-
}
|
|
935
|
-
}
|
|
936
|
-
}
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
// ── HOOK 6: NEW SELLER CHURN ─────────────────────
|
|
940
|
-
// New sellers lose 50% of events after their first 14 days.
|
|
941
|
-
// Uses user's first event as anchor (not dataset start).
|
|
942
|
-
if (profile && profile.segment === "new_seller") {
|
|
943
|
-
const firstEventTime = events.length > 0 ? dayjs(events[0].time) : null;
|
|
944
|
-
if (firstEventTime) {
|
|
945
|
-
const userDay14 = firstEventTime.add(14, "days");
|
|
946
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
947
|
-
if (dayjs(events[i].time).isAfter(userDay14) && chance.bool({ likelihood: 50 })) {
|
|
948
|
-
events.splice(i, 1);
|
|
949
|
-
}
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
// HOOK 2: WEEKEND SHOPPING SURGE — Sat/Sun purchases get
|
|
955
|
-
// total_amount 1.2x. Mutates raw prop. No flag.
|
|
956
|
-
events.forEach(e => {
|
|
957
|
-
if (e.event === "purchase completed") {
|
|
958
|
-
const dow = new Date(e.time).getUTCDay();
|
|
959
|
-
if (dow === 0 || dow === 6) {
|
|
960
|
-
e.total_amount = Math.floor((e.total_amount || 60) * 1.2);
|
|
961
|
-
}
|
|
962
|
-
}
|
|
963
|
-
});
|
|
964
|
-
|
|
965
|
-
// HOOK 10: MESSAGE-COUNT MAGIC NUMBER (in-funnel, no flags)
|
|
966
|
-
// Sweet 2-5 message-sent events between item-viewed and
|
|
967
|
-
// offer-received → +35% on offer-received offer_amount/asking_price.
|
|
968
|
-
// Over 6+ → drop 25% of purchase-completed events (over-message
|
|
969
|
-
// signals haggling deadlock).
|
|
970
|
-
const itemViewed = events.find(e => e.event === "item viewed");
|
|
971
|
-
const offerReceived = events.find(e => e.event === "offer received");
|
|
972
|
-
if (itemViewed && offerReceived) {
|
|
973
|
-
const aTime = dayjs(itemViewed.time);
|
|
974
|
-
const bTime = dayjs(offerReceived.time);
|
|
975
|
-
const msgBetween = events.filter(e =>
|
|
976
|
-
e.event === "message sent" &&
|
|
977
|
-
dayjs(e.time).isAfter(aTime) &&
|
|
978
|
-
dayjs(e.time).isBefore(bTime)
|
|
979
|
-
).length;
|
|
980
|
-
if (msgBetween >= 2 && msgBetween <= 5) {
|
|
981
|
-
events.forEach(e => {
|
|
982
|
-
if (e.event === "offer received" && typeof e.offer_amount === "number") {
|
|
983
|
-
e.offer_amount = Math.round(e.offer_amount * 1.35);
|
|
984
|
-
}
|
|
985
|
-
});
|
|
986
|
-
} else if (msgBetween >= 6) {
|
|
987
|
-
for (let i = events.length - 1; i >= 0; i--) {
|
|
988
|
-
if (events[i].event === "purchase completed" && chance.bool({ likelihood: 25 })) {
|
|
989
|
-
events.splice(i, 1);
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
return record;
|
|
996
|
-
}
|
|
997
|
-
|
|
1011
|
+
hook(record, type, meta) {
|
|
1012
|
+
if (type === "user") return handleUserHooks(record);
|
|
1013
|
+
if (type === "funnel-post") return handleFunnelPostHooks(record, meta);
|
|
1014
|
+
if (type === "everything") return handleEverythingHooks(record, meta);
|
|
998
1015
|
return record;
|
|
999
1016
|
},
|
|
1000
1017
|
};
|