@ak--47/dungeon-master 1.5.0 → 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/CHANGELOG.md +43 -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
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,49 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@ak--47/dungeon-master`.
|
|
4
4
|
|
|
5
|
+
## 1.5.1 — 2026-05-20
|
|
6
|
+
|
|
7
|
+
Quality + ergonomics release. No new analytical capabilities — fixes accumulated rough edges around concurrency, accuracy, profiles, and config ergonomics that surfaced after 1.5.0 shipped. Adds a generator-side retention shaper, exposes a config sub-object API for cleaner dungeon files, and restructures all 48 shipped dungeons to a canonical layout. Top-level keys keep working for back-compat.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`credentials` / `switches` / `identity` config sub-objects.** New ergonomic shape for grouping related dungeon keys: `credentials: { token, region, serviceAccount, serviceSecret, projectId }`, `switches: { hasLocation, hasCampaigns, hasSessionIds, hasAvatar, isAnonymous, ... }`, `identity: { avgDevicePerUser, sessionTimeout }`. `mergeConfigSubObjects` hoists sub-object values into top-level keys at validation time; top-level still wins when both set (with a verbose warn). Old flat top-level keys keep working — back-compat suite in `tests/unit/config-restructure.test.js`.
|
|
12
|
+
- **`retentionCurve` config knob.** Generator-side retention shaper. Accepts an array of `{day, retention}` waypoints; the engine interpolates log-linearly to drop late events per user based on first-event age. Independent of `engagementDecay`. Enables analytical-style retention shapes (D1 80% → D7 50% → D30 20%) at dungeon-config level instead of via hooks.
|
|
13
|
+
- **Per-macro `avgActiveDaysPerUser` defaults.** When `avgActiveDaysPerUser` is unset, defaults derived per macro: steady=15, growth=10, viral=20, decline=5, flat=20 (numDays/4 cap). Removes the need to hand-tune for every macro.
|
|
14
|
+
- **`COUNT_DISTINCT` aggregation in `emulateBreakdown`** (`type: 'distinctCount'`). Mirrors Mixpanel's count-distinct measure for cohort sizing / unique-user breakdowns.
|
|
15
|
+
- **`userSeed` config knob.** Separate distinct_id seed from the main `seed`. Lets you regenerate a dataset with a different event distribution while keeping the user pool stable across runs — useful for incremental data layering.
|
|
16
|
+
- **`result.profilesPushed`** count exposed on the `Result` object. Reports how many profiles actually got pushed to `/engage` after `_drop` filtering.
|
|
17
|
+
- **`runWithDataset(begin, now, fn)`** API for explicit dataset-window scoping (rare — most callers don't need this; in-process `generate()` calls now auto-scope via AsyncLocalStorage).
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **Anonymous non-converters get `_drop: true` stamped on their profile.** Real-world Mixpanel `$identify` semantics — profiles only exist for users who actually identified. Born-in-dataset users who never reach an `isAuthEvent` step in their first funnel are anonymous: events still flow (tied to `device_id`), but no profile is pushed to `/engage`. `userProfilesData` still contains every profile object; `mixpanel-sender` filters `_drop:true` before push. Pre-existing users are considered already-identified and never get `_drop`. The `everything` hook can rescue a profile by `delete meta.profile._drop`.
|
|
22
|
+
- **`numEvents` more accurate.** Removed dice rolls + `0.714` magic dampening from per-user budget computation; replaced with a clean `chance.normal(mean=budget, dev=budget/3)`. Old behavior overshot the target by 1.6-2x; new behavior matches the configured rate within ±3% across all 5 macros at 50K target. **If you previously tuned `avgEventsPerUserPerDay` around the old overshoot, expect ~40-60% fewer events at the same rate.** Recompute targets.
|
|
23
|
+
- **Default `Platform` device property removed.** 59 entries commented out in `lib/templates/defaults.js` (15 iOS + 15 Android + 29 Desktop). `os` already carries the platform signal. If a dungeon reads `Platform` in hooks or downstream, you'll see undefined — define `Platform` explicitly in your event properties to opt back in.
|
|
24
|
+
- **`hasAnonIds` deprecated.** Use `identity.avgDevicePerUser: 1` instead. The deprecated alias still works through 1.5.x: when `hasAnonIds: true` is set without an explicit `avgDevicePerUser`, the validator promotes to `avgDevicePerUser: 1`.
|
|
25
|
+
- **`DATASET_NOW` / `DATASET_BEGIN` scoped via AsyncLocalStorage.** No more module-level mutable globals. In-process concurrent `generate()` calls with different `datasetStart`/`datasetEnd` windows now produce isolated, in-window output. Legacy `setDatasetNow` / `setDatasetBegin` setters remain as back-compat shims.
|
|
26
|
+
- **UTC bare-date parsing** for `datasetStart` / `datasetEnd`. `"2026-01-01"` parses as `2026-01-01T00:00:00Z`, not as local-midnight (which shifted the window by UTC offset).
|
|
27
|
+
- **Quiet by default.** `verbose: false` (default) now gates all warnings, info logs, and dataset-context messages. Set `verbose: true` to opt back into the chatty output.
|
|
28
|
+
- **GCS upload retry hardening.** 10 retries with exponential backoff, 10-minute total budget. Handles transient cloud upload failures without giving up.
|
|
29
|
+
- **>64K user runs no longer crash V8.** Internal data structure swap (`Array` for sparse-keyed maps) eliminates V8 limit hit at high user counts.
|
|
30
|
+
- **All 48 shipped dungeons restructured to canonical layout.** Sections in fixed order: IMPORTS → OVERVIEW → HOOK STORIES → SCALE → KNOBS → DATA ARRAYS → HOOK STATE → HELPER FUNCTIONS → CONFIG. Hook stories preserve full per-hook Mixpanel report docs; `config.hook` becomes a thin dispatcher delegating to per-type helpers (`handleEventHooks`, `handleEverythingHooks`, etc.). Zero behavioral changes — every dungeon's seed-pinned output is unchanged.
|
|
31
|
+
- **All 49 dungeons + ~18 test fixtures migrated to the new sub-object API.** Cosmetic adoption only — `mergeConfigSubObjects` already supported both shapes since Phase 1.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- **Standalone events now stamp `config.superProps`.** Was silently `{}` before — masked by the validator's auto-funnel pre-fix in 1.5.0. Surfaced by the `numEvents` overshoot fix when the `useFunnel` gate started routing more users to the standalone path.
|
|
36
|
+
- **Born-late funnel auth events past `FIXED_NOW` no longer set `userAuthTimeMs`.** Engine drops the event at storage time (`funnels.js:640`) but previously still recorded the auth-time, marking the user as authed without a real sign_up event. Fix gates `authTimeMs` on `!_drop` (`funnels.js:328`). Affected 1-2% of users in test runs.
|
|
37
|
+
- **Pre-existing user events strict-clamp at `FIXED_BEGIN`.** Born-outside-window users no longer leak events into the pre-dataset window via TimeSoup's sub-window distribution.
|
|
38
|
+
|
|
39
|
+
### Docs
|
|
40
|
+
|
|
41
|
+
- **HOOKS.md targeted edits.** Recipe 4.25 (First-Touch Attribution Bias) gains a v1.5 note pointing to Recipe 4.26's OVERWRITE pattern when `hasCampaigns: true`. Atom catalog gains a footnote that `injectBetween` / `injectBurst` / `injectAfterEvent` / `injectOnNewDays` no longer require trailing `record.sort(...)` calls — covered by `autoSortAfterEverything: true` default since 1.5.0.
|
|
42
|
+
- **`docs/guides/1.5.1-upgrade-guide.md`** — TL;DR + per-change action items for existing dungeon authors.
|
|
43
|
+
|
|
44
|
+
### Infra
|
|
45
|
+
|
|
46
|
+
- 95+ commits across the branch; 1269 vitest tests pass; engine canary 10/10; engine-shape full sweep 194/194; smoke test 20/20 verticals; 5-vertical hook verifier matches Sprint 1 baseline (ecommerce 10/10, fitness 12/12, sass 10/10, social 11/11, dating 11/13 pre-existing small-mode artifacts).
|
|
47
|
+
|
|
5
48
|
## 1.5.0 — 2026-05-08
|
|
6
49
|
|
|
7
50
|
The "count and verify like Mixpanel does" release. Aligns BOTH the data generation engine AND the verifier with Mixpanel's actual counting semantics — greedy single-pass funnels, distinct-period frequency counting, null-aware aggregation, touchpoint-capped attribution, identity merge, retention, sessions, time-bucketed trends. Removes `bunchIntoSessions`, the root cause of funnel ordering corruption since 1.0.
|
package/HOOKS.md
CHANGED
|
@@ -1183,6 +1183,13 @@ first-touch result as stamping 10 — Mixpanel's attribution module
|
|
|
1183
1183
|
(`attributed_value_reader.cpp`) only considers `TOUCHPOINTS_LIMIT = 10`. Aim
|
|
1184
1184
|
for sparse, deterministic touches.
|
|
1185
1185
|
|
|
1186
|
+
**v1.5 with `hasCampaigns: true`:** when the engine has already stamped UTMs
|
|
1187
|
+
on up to `maxTouchpointsPerUser` events per user (default 10), DO NOT stamp
|
|
1188
|
+
fresh touches in your hook — they'd push the user past the cap and fall
|
|
1189
|
+
outside Mixpanel's last-10 window. Use [Recipe 4.26](#426-bias-engine-stamped-touches-v15)
|
|
1190
|
+
to OVERWRITE the engine's `utm_source` on the existing stamped events
|
|
1191
|
+
instead. See [§2.4](#24-attribution-caps-at-10-touchpoints).
|
|
1192
|
+
|
|
1186
1193
|
---
|
|
1187
1194
|
|
|
1188
1195
|
#### 4.26 Bias Engine-Stamped Touches (v1.5)
|
|
@@ -1326,6 +1333,12 @@ Import from `@ak--47/dungeon-master/hook-helpers`:
|
|
|
1326
1333
|
| `isPreAuthEvent` | identity | `(event, authTime) -> boolean` | Check if before user's stitch |
|
|
1327
1334
|
| `splitByAuth` | identity | `(events, authTime) -> { preAuth, postAuth, stitch }` | Partition by auth boundary |
|
|
1328
1335
|
|
|
1336
|
+
**Inject atoms + v1.5:** the engine auto-sorts events by time after the
|
|
1337
|
+
`everything` hook (`autoSortAfterEverything: true` default — see Principle
|
|
1338
|
+
26). Hooks using `injectBetween` / `injectBurst` / `injectAfterEvent` /
|
|
1339
|
+
`injectOnNewDays` no longer need a trailing `record.sort(...)` to keep the
|
|
1340
|
+
greedy funnel engine happy.
|
|
1341
|
+
|
|
1329
1342
|
Full JSDoc in [`lib/hook-helpers/*.js`](lib/hook-helpers/).
|
|
1330
1343
|
|
|
1331
1344
|
---
|
|
@@ -1,59 +1,51 @@
|
|
|
1
|
-
// ──
|
|
2
|
-
|
|
3
|
-
const num_days = 90;
|
|
4
|
-
const num_users = 1_000;
|
|
5
|
-
const avg_events_per_user_per_day = 0.56;
|
|
6
|
-
let token = "";
|
|
7
|
-
|
|
8
|
-
// ── env overrides ──
|
|
9
|
-
if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
|
|
10
|
-
|
|
11
|
-
import Chance from 'chance';
|
|
12
|
-
let chance = new Chance();
|
|
13
|
-
import dayjs from "dayjs";
|
|
14
|
-
import utc from "dayjs/plugin/utc.js";
|
|
15
|
-
dayjs.extend(utc);
|
|
16
|
-
import { uid, comma } from 'ak-tools';
|
|
17
|
-
import { weighNumRange, date, integer, weighChoices } from "../../lib/utils/utils.js";
|
|
18
|
-
|
|
1
|
+
// ── IMPORTS ──
|
|
2
|
+
import { weighNumRange } from "../../lib/utils/utils.js";
|
|
19
3
|
/** @typedef {import("../../types").Dungeon} Config */
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* - hasCampaigns: true — adds UTM parameters to events
|
|
29
|
-
* - hasBrowser: true — required for campaign tracking
|
|
30
|
-
* - 6 events: signup, page view, purchase, ad click, search, share
|
|
31
|
-
*
|
|
32
|
-
* No hooks. Focus is on verifying ad spend event structure
|
|
33
|
-
* and campaign attribution properties flow through correctly.
|
|
4
|
+
|
|
5
|
+
// ── OVERVIEW ──
|
|
6
|
+
/*
|
|
7
|
+
* NAME: ad-spend
|
|
8
|
+
* PURPOSE: exercises hasAdSpend + hasCampaigns — generates cost/CPC/CTR/impressions/clicks + UTM attribution
|
|
9
|
+
* SCALE: 1,000 users, ~50K events, 90 days
|
|
10
|
+
* EVENTS (6): sign up, page view, purchase, ad click, search, share
|
|
11
|
+
* FUNNELS (0): none
|
|
34
12
|
*/
|
|
35
13
|
|
|
36
|
-
|
|
14
|
+
// ── SCALE ──
|
|
15
|
+
const SEED = "ad spend test";
|
|
16
|
+
const NUM_DAYS = 90;
|
|
17
|
+
const NUM_USERS = 1_000;
|
|
18
|
+
const EVENTS_PER_DAY = 0.56;
|
|
19
|
+
const token = process.env.MP_TOKEN || "";
|
|
20
|
+
|
|
21
|
+
// ── CONFIG ──
|
|
22
|
+
/** @type {Config} */
|
|
37
23
|
const config = {
|
|
38
|
-
token,
|
|
39
24
|
seed: SEED,
|
|
40
25
|
name: "ad-spend",
|
|
41
|
-
numDays:
|
|
42
|
-
avgEventsPerUserPerDay:
|
|
43
|
-
numUsers:
|
|
26
|
+
numDays: NUM_DAYS,
|
|
27
|
+
avgEventsPerUserPerDay: EVENTS_PER_DAY,
|
|
28
|
+
numUsers: NUM_USERS,
|
|
44
29
|
format: 'json',
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
30
|
+
credentials: {
|
|
31
|
+
token,
|
|
32
|
+
region: "US",
|
|
33
|
+
},
|
|
34
|
+
switches: {
|
|
35
|
+
hasSessionIds: true,
|
|
36
|
+
hasAdSpend: true,
|
|
37
|
+
hasLocation: false,
|
|
38
|
+
hasAndroidDevices: false,
|
|
39
|
+
hasIOSDevices: false,
|
|
40
|
+
hasDesktopDevices: true,
|
|
41
|
+
hasBrowser: true,
|
|
42
|
+
hasCampaigns: true,
|
|
43
|
+
isAnonymous: false,
|
|
44
|
+
alsoInferFunnels: false,
|
|
45
|
+
},
|
|
46
|
+
identity: {
|
|
47
|
+
avgDevicePerUser: 1,
|
|
48
|
+
},
|
|
57
49
|
concurrency: 1,
|
|
58
50
|
writeToDisk: false,
|
|
59
51
|
|
|
@@ -1,50 +1,52 @@
|
|
|
1
|
-
// ──
|
|
1
|
+
// ── IMPORTS ──
|
|
2
|
+
import Chance from 'chance';
|
|
3
|
+
let chance = new Chance();
|
|
4
|
+
import { weighNumRange, weighChoices } from "../../lib/utils/utils.js";
|
|
5
|
+
/** @typedef {import("../../types").Dungeon} Config */
|
|
6
|
+
|
|
7
|
+
// ── OVERVIEW ──
|
|
8
|
+
/*
|
|
9
|
+
* NAME: anonymous-users
|
|
10
|
+
* PURPOSE: Anonymous-user mode fixture — exercises isAnonymous + hasAnonIds + anon-to-identified flow
|
|
11
|
+
* SCALE: 1,000 users, ~50K events, 180 days
|
|
12
|
+
* EVENTS (5): page view, signup, feature used, purchase, button click
|
|
13
|
+
* FUNNELS (0): none
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ── SCALE ──
|
|
2
17
|
const SEED = "anonymous-users";
|
|
3
18
|
const num_days = 180;
|
|
4
19
|
const num_users = 1_000;
|
|
5
20
|
const avg_events_per_user_per_day = 0.28;
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
// ── env overrides ──
|
|
9
|
-
if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Anonymous Users — tests anonymous user mode, anonIds, and identity merge.
|
|
13
|
-
*
|
|
14
|
-
* Exercises: isAnonymous mode, hasAnonIds for anonymous-to-identified
|
|
15
|
-
* user flows, long 180-day timespan for tail behavior.
|
|
16
|
-
*
|
|
17
|
-
* - 1000 users, 50K events, 180 days
|
|
18
|
-
* - isAnonymous: true, hasAnonIds: true
|
|
19
|
-
* - Simple events with signup as isFirstEvent (identity resolution point)
|
|
20
|
-
* - No hooks, minimal config
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import Chance from 'chance';
|
|
24
|
-
let chance = new Chance();
|
|
25
|
-
import { weighNumRange, weighChoices } from "../../lib/utils/utils.js";
|
|
21
|
+
const token = process.env.MP_TOKEN || "";
|
|
26
22
|
|
|
27
|
-
|
|
28
|
-
/** @type {
|
|
23
|
+
// ── CONFIG ──
|
|
24
|
+
/** @type {Config} */
|
|
29
25
|
const config = {
|
|
30
|
-
token,
|
|
31
26
|
seed: SEED,
|
|
32
27
|
numDays: num_days,
|
|
33
28
|
avgEventsPerUserPerDay: avg_events_per_user_per_day,
|
|
34
29
|
numUsers: num_users,
|
|
35
30
|
format: "json",
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
31
|
+
credentials: {
|
|
32
|
+
token,
|
|
33
|
+
region: "US",
|
|
34
|
+
},
|
|
35
|
+
switches: {
|
|
36
|
+
isAnonymous: true,
|
|
37
|
+
hasSessionIds: true,
|
|
38
|
+
hasAdSpend: false,
|
|
39
|
+
hasLocation: false,
|
|
40
|
+
hasAndroidDevices: true,
|
|
41
|
+
hasIOSDevices: true,
|
|
42
|
+
hasDesktopDevices: true,
|
|
43
|
+
hasBrowser: true,
|
|
44
|
+
hasCampaigns: false,
|
|
45
|
+
alsoInferFunnels: false,
|
|
46
|
+
},
|
|
47
|
+
identity: {
|
|
48
|
+
avgDevicePerUser: 1,
|
|
49
|
+
},
|
|
48
50
|
concurrency: 1,
|
|
49
51
|
writeToDisk: false,
|
|
50
52
|
|
|
@@ -1,40 +1,24 @@
|
|
|
1
|
-
// ──
|
|
2
|
-
|
|
3
|
-
const num_days = 60;
|
|
4
|
-
const num_users = 1_000;
|
|
5
|
-
const avg_events_per_user_per_day = 1.67;
|
|
6
|
-
let token = "";
|
|
7
|
-
|
|
8
|
-
// ── env overrides ──
|
|
9
|
-
if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
|
|
10
|
-
|
|
11
|
-
import Chance from 'chance';
|
|
12
|
-
let chance = new Chance();
|
|
1
|
+
// ── IMPORTS ──
|
|
2
|
+
import Chance from "chance";
|
|
13
3
|
import dayjs from "dayjs";
|
|
14
4
|
import utc from "dayjs/plugin/utc.js";
|
|
15
5
|
dayjs.extend(utc);
|
|
16
|
-
import {
|
|
17
|
-
import { weighNumRange, date, integer, weighChoices } from "../../lib/utils/utils.js";
|
|
18
|
-
|
|
6
|
+
import { weighNumRange, integer, weighChoices } from "../../lib/utils/utils.js";
|
|
19
7
|
/** @typedef {import("../../types").Dungeon} Config */
|
|
20
|
-
const videoCategories = ["funny", "educational", "inspirational", "music", "news", "sports", "cooking", "DIY", "travel", "gaming"];
|
|
21
|
-
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"];
|
|
22
8
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
* ═══════════════════════════════════════════════════════════════
|
|
37
|
-
*
|
|
9
|
+
// ── OVERVIEW ──
|
|
10
|
+
/*
|
|
11
|
+
* NAME: array-of-object-lookup
|
|
12
|
+
* PURPOSE: Exercises nested product arrays in events plus a lookup table
|
|
13
|
+
* keyed by product_id with rich attributes.
|
|
14
|
+
* SCALE: 1,000 users, ~100K events, 60 days
|
|
15
|
+
* EVENTS (4): checkout (cart array), add to cart, view item, save item (single item)
|
|
16
|
+
* FUNNELS (0): none
|
|
17
|
+
* LOOKUPS (1): product_id (1,000 entries; amount, quantity, featured, category, descriptor)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// ── HOOK STORIES ──
|
|
21
|
+
/*
|
|
38
22
|
* 1. COUPON DISCOUNT TAGGING (event hook)
|
|
39
23
|
* Checkout events with coupons get discount_applied: true and
|
|
40
24
|
* discount_percent extracted from the coupon string.
|
|
@@ -48,27 +32,124 @@ const spiritAnimals = ["duck", "dog", "otter", "penguin", "cat", "elephant", "li
|
|
|
48
32
|
* events tagged user_segment: "window_shopper".
|
|
49
33
|
*/
|
|
50
34
|
|
|
51
|
-
|
|
35
|
+
// ── SCALE ──
|
|
36
|
+
const SEED = "dm4-array-of-object-lookup";
|
|
37
|
+
const NUM_DAYS = 60;
|
|
38
|
+
const NUM_USERS = 1_000;
|
|
39
|
+
const EVENTS_PER_DAY = 1.67;
|
|
40
|
+
const token = process.env.MP_TOKEN || "";
|
|
41
|
+
|
|
42
|
+
const chance = new Chance();
|
|
43
|
+
|
|
44
|
+
// ── DATA ARRAYS ──
|
|
45
|
+
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"];
|
|
46
|
+
|
|
47
|
+
// ── HELPER FUNCTIONS ──
|
|
48
|
+
function makeProducts(maxItems = 5) {
|
|
49
|
+
return function () {
|
|
50
|
+
const categories = ["electronics", "books", "clothing", "home", "garden", "toys", "sports", "automotive", "beauty", "health", "grocery", "jewelry", "shoes", "tools", "office supplies"];
|
|
51
|
+
const descriptors = ["brand new", "open box", "refurbished", "used", "like new", "vintage", "antique", "collectible"];
|
|
52
|
+
const suffix = ["item", "product", "good", "merchandise", "thing", "object", "widget", "gadget", "device", "apparatus", "contraption", "instrument", "tool", "implement", "utensil", "appliance", "machine", "equipment", "gear", "kit", "set", "package"];
|
|
53
|
+
const assetPreview = ['.png', '.jpg', '.jpeg', '.heic', '.mp4', '.mov', '.avi'];
|
|
54
|
+
const data = [];
|
|
55
|
+
const numOfItems = integer(1, maxItems);
|
|
56
|
+
|
|
57
|
+
for (var i = 0; i < numOfItems; i++) {
|
|
58
|
+
const category = chance.pickone(categories);
|
|
59
|
+
const descriptor = chance.pickone(descriptors);
|
|
60
|
+
const suffixWord = chance.pickone(suffix);
|
|
61
|
+
const slug = `${descriptor.replace(/\s+/g, '-').toLowerCase()}-${suffixWord.replace(/\s+/g, '-').toLowerCase()}`;
|
|
62
|
+
const asset = chance.pickone(assetPreview);
|
|
63
|
+
|
|
64
|
+
const price = integer(1, 100);
|
|
65
|
+
const quantity = integer(1, 5);
|
|
66
|
+
const product_id = integer(1, 1_000);
|
|
67
|
+
|
|
68
|
+
const item = {
|
|
69
|
+
product_id: product_id,
|
|
70
|
+
product_url: `https://example.com/assets/${product_id}`,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
data.push(item);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return () => [data];
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function flip(likelihood = 50) {
|
|
81
|
+
return chance.bool({ likelihood });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function handleEventHooks(record) {
|
|
85
|
+
// Pattern 1: Checkouts with coupons get a discount_applied flag and adjusted total
|
|
86
|
+
if (record.event === "checkout" && record.coupon && record.coupon !== "none") {
|
|
87
|
+
record.discount_applied = true;
|
|
88
|
+
const pctMatch = record.coupon.match(/(\d+)%/);
|
|
89
|
+
if (pctMatch) {
|
|
90
|
+
record.discount_percent = parseInt(pctMatch[1]);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Pattern 2: "save item" events on weekends are tagged as wishlist behavior
|
|
95
|
+
if (record.event === "save item") {
|
|
96
|
+
const dow = dayjs(record.time).day();
|
|
97
|
+
if (dow === 0 || dow === 6) {
|
|
98
|
+
record.save_context = "weekend_browse";
|
|
99
|
+
} else {
|
|
100
|
+
record.save_context = "weekday_intent";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return record;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function handleEverythingHooks(record, meta) {
|
|
108
|
+
const profile = meta.profile;
|
|
109
|
+
record.forEach(e => {
|
|
110
|
+
e.theme = profile.theme;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Pattern 3: Users who view 5+ items but never checkout are tagged as window shoppers
|
|
114
|
+
const views = record.filter(e => e.event === "view item").length;
|
|
115
|
+
const checkouts = record.filter(e => e.event === "checkout").length;
|
|
116
|
+
if (views >= 5 && checkouts === 0) {
|
|
117
|
+
for (const e of record) {
|
|
118
|
+
e.user_segment = "window_shopper";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return record;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── CONFIG ──
|
|
126
|
+
/** @type {Config} */
|
|
52
127
|
const config = {
|
|
53
|
-
token,
|
|
54
128
|
seed: SEED,
|
|
55
129
|
name: "array-of-object-lookup",
|
|
56
|
-
numDays:
|
|
57
|
-
avgEventsPerUserPerDay:
|
|
58
|
-
numUsers:
|
|
59
|
-
format: 'json',
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
130
|
+
numDays: NUM_DAYS,
|
|
131
|
+
avgEventsPerUserPerDay: EVENTS_PER_DAY,
|
|
132
|
+
numUsers: NUM_USERS,
|
|
133
|
+
format: 'json',
|
|
134
|
+
credentials: {
|
|
135
|
+
token,
|
|
136
|
+
region: "US",
|
|
137
|
+
},
|
|
138
|
+
switches: {
|
|
139
|
+
hasSessionIds: true,
|
|
140
|
+
hasAdSpend: false,
|
|
141
|
+
hasLocation: true,
|
|
142
|
+
hasAndroidDevices: false,
|
|
143
|
+
hasIOSDevices: false,
|
|
144
|
+
hasDesktopDevices: true,
|
|
145
|
+
hasBrowser: true,
|
|
146
|
+
hasCampaigns: false,
|
|
147
|
+
isAnonymous: false,
|
|
148
|
+
alsoInferFunnels: true,
|
|
149
|
+
},
|
|
150
|
+
identity: {
|
|
151
|
+
avgDevicePerUser: 1,
|
|
152
|
+
},
|
|
72
153
|
concurrency: 1,
|
|
73
154
|
writeToDisk: false,
|
|
74
155
|
events: [
|
|
@@ -108,28 +189,18 @@ const config = {
|
|
|
108
189
|
save_context: ["weekday_intent"],
|
|
109
190
|
user_segment: ["regular"],
|
|
110
191
|
}
|
|
111
|
-
}
|
|
192
|
+
}
|
|
112
193
|
],
|
|
113
|
-
funnels: [
|
|
194
|
+
funnels: [],
|
|
114
195
|
superProps: {
|
|
115
196
|
theme: ["light", "dark", "custom", "light", "dark"],
|
|
116
197
|
},
|
|
117
|
-
/*
|
|
118
|
-
user properties work the same as event properties
|
|
119
|
-
each key should be an array or function reference
|
|
120
|
-
*/
|
|
121
198
|
userProps: {
|
|
122
199
|
theme: ["light", "dark", "custom", "light", "dark"],
|
|
123
|
-
// title: chance.profession.bind(chance),
|
|
124
|
-
// luckyNumber: weighNumRange(1, 500, .3),
|
|
125
200
|
spiritAnimal: spiritAnimals
|
|
126
201
|
},
|
|
127
202
|
scdProps: {},
|
|
128
203
|
mirrorProps: {},
|
|
129
|
-
/*
|
|
130
|
-
for group analytics keys, we need an array of arrays [[],[],[]]
|
|
131
|
-
each pair represents a group_key and the number of profiles for that key
|
|
132
|
-
*/
|
|
133
204
|
groupKeys: [],
|
|
134
205
|
groupProps: {},
|
|
135
206
|
lookupTables: [{
|
|
@@ -141,101 +212,13 @@ const config = {
|
|
|
141
212
|
featured: flip,
|
|
142
213
|
category: ["electronics", "books", "clothing", "home", "garden", "toys", "sports", "automotive", "beauty", "health", "grocery", "jewelry", "shoes", "tools", "office supplies"],
|
|
143
214
|
descriptor: ["brand new", "open box", "refurbished", "used", "like new", "vintage", "antique", "collectible"]
|
|
144
|
-
|
|
145
215
|
}
|
|
146
|
-
|
|
147
216
|
}],
|
|
148
217
|
hook: function (record, type, meta) {
|
|
149
|
-
|
|
150
|
-
if (type === "
|
|
151
|
-
// Pattern 1: Checkouts with coupons get a discount_applied flag and adjusted total
|
|
152
|
-
if (record.event === "checkout" && record.coupon && record.coupon !== "none") {
|
|
153
|
-
record.discount_applied = true;
|
|
154
|
-
const pctMatch = record.coupon.match(/(\d+)%/);
|
|
155
|
-
if (pctMatch) {
|
|
156
|
-
record.discount_percent = parseInt(pctMatch[1]);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Pattern 2: "save item" events on weekends are tagged as wishlist behavior
|
|
161
|
-
if (record.event === "save item") {
|
|
162
|
-
const dow = dayjs(record.time).day();
|
|
163
|
-
if (dow === 0 || dow === 6) {
|
|
164
|
-
record.save_context = "weekend_browse";
|
|
165
|
-
} else {
|
|
166
|
-
record.save_context = "weekday_intent";
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
if (type === "everything") {
|
|
172
|
-
const profile = meta.profile;
|
|
173
|
-
record.forEach(e => {
|
|
174
|
-
e.theme = profile.theme;
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
// Pattern 3: Users who view 5+ items but never checkout are tagged as window shoppers
|
|
178
|
-
const views = record.filter(e => e.event === "view item").length;
|
|
179
|
-
const checkouts = record.filter(e => e.event === "checkout").length;
|
|
180
|
-
if (views >= 5 && checkouts === 0) {
|
|
181
|
-
for (const e of record) {
|
|
182
|
-
e.user_segment = "window_shopper";
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
218
|
+
if (type === "event") return handleEventHooks(record);
|
|
219
|
+
if (type === "everything") return handleEverythingHooks(record, meta);
|
|
187
220
|
return record;
|
|
188
221
|
}
|
|
189
222
|
};
|
|
190
223
|
|
|
191
|
-
|
|
192
|
-
return function () {
|
|
193
|
-
const categories = ["electronics", "books", "clothing", "home", "garden", "toys", "sports", "automotive", "beauty", "health", "grocery", "jewelry", "shoes", "tools", "office supplies"];
|
|
194
|
-
const descriptors = ["brand new", "open box", "refurbished", "used", "like new", "vintage", "antique", "collectible"];
|
|
195
|
-
const suffix = ["item", "product", "good", "merchandise", "thing", "object", "widget", "gadget", "device", "apparatus", "contraption", "instrument", "tool", "implement", "utensil", "appliance", "machine", "equipment", "gear", "kit", "set", "package"];
|
|
196
|
-
const assetPreview = ['.png', '.jpg', '.jpeg', '.heic', '.mp4', '.mov', '.avi'];
|
|
197
|
-
const data = [];
|
|
198
|
-
const numOfItems = integer(1, maxItems);
|
|
199
|
-
|
|
200
|
-
for (var i = 0; i < numOfItems; i++) {
|
|
201
|
-
const category = chance.pickone(categories);
|
|
202
|
-
const descriptor = chance.pickone(descriptors);
|
|
203
|
-
const suffixWord = chance.pickone(suffix);
|
|
204
|
-
const slug = `${descriptor.replace(/\s+/g, '-').toLowerCase()}-${suffixWord.replace(/\s+/g, '-').toLowerCase()}`;
|
|
205
|
-
const asset = chance.pickone(assetPreview);
|
|
206
|
-
|
|
207
|
-
// const product_id = chance.guid();
|
|
208
|
-
const price = integer(1, 100);
|
|
209
|
-
const quantity = integer(1, 5);
|
|
210
|
-
const product_id = integer(1, 1_000);
|
|
211
|
-
|
|
212
|
-
const item = {
|
|
213
|
-
product_id: product_id,
|
|
214
|
-
product_url: `https://example.com/assets/${product_id}`,
|
|
215
|
-
// sku: integer(11111, 99999),
|
|
216
|
-
// amount: price,
|
|
217
|
-
// quantity: quantity,
|
|
218
|
-
// total_value: price * quantity,
|
|
219
|
-
// featured: chance.pickone([true, false, false]),
|
|
220
|
-
// category: category,
|
|
221
|
-
// descriptor: descriptor,
|
|
222
|
-
// slug: slug,
|
|
223
|
-
|
|
224
|
-
// assetType: asset
|
|
225
|
-
|
|
226
|
-
};
|
|
227
|
-
|
|
228
|
-
data.push(item);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
return () => [data];
|
|
232
|
-
};
|
|
233
|
-
};
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
function flip(likelihood = 50) {
|
|
237
|
-
return chance.bool({ likelihood });
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
export default config;
|
|
224
|
+
export default config;
|