@ak--47/dungeon-master 1.3.1 → 1.4.0
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 +58 -0
- package/dungeons/technical/hook-helpers-verify.js +89 -0
- package/dungeons/technical/identity-model-verify.js +47 -0
- package/dungeons/technical/pattern-aggregate-by-bin.js +41 -0
- package/dungeons/technical/pattern-attributed-by-source.js +42 -0
- package/dungeons/technical/pattern-frequency-by-frequency.js +40 -0
- package/dungeons/technical/pattern-funnel-frequency.js +54 -0
- package/dungeons/technical/pattern-ttc-by-segment.js +45 -0
- package/dungeons/vertical/ai-platform.js +45 -52
- package/dungeons/vertical/community.js +11 -8
- package/dungeons/vertical/crypto.js +25 -24
- package/dungeons/vertical/dating.js +56 -48
- package/dungeons/vertical/devtools.js +25 -18
- package/dungeons/vertical/ecommerce.js +42 -38
- package/dungeons/vertical/education.js +24 -9
- package/dungeons/vertical/fintech.js +13 -8
- package/dungeons/vertical/fitness.js +73 -122
- package/dungeons/vertical/food-delivery.js +18 -19
- package/dungeons/vertical/gaming.js +19 -20
- package/dungeons/vertical/healthcare.js +11 -8
- package/dungeons/vertical/insurance-application.js +6 -3
- package/dungeons/vertical/logistics.js +15 -9
- package/dungeons/vertical/marketplace.js +36 -27
- package/dungeons/vertical/media.js +27 -25
- package/dungeons/vertical/real-estate.js +18 -7
- package/dungeons/vertical/sass.js +84 -68
- package/dungeons/vertical/social.js +46 -47
- package/dungeons/vertical/travel.js +8 -5
- package/lib/core/config-validator.js +136 -157
- package/lib/generators/events.js +49 -93
- package/lib/generators/funnels.js +202 -91
- package/lib/hook-helpers/_internal.js +23 -0
- package/lib/hook-helpers/cohort.js +124 -0
- package/lib/hook-helpers/identity.js +56 -0
- package/lib/hook-helpers/index.js +44 -0
- package/lib/hook-helpers/inject.js +99 -0
- package/lib/hook-helpers/mutate.js +151 -0
- package/lib/hook-helpers/timing.js +99 -0
- package/lib/hook-patterns/aggregate-per-user-by-bin.js +38 -0
- package/lib/hook-patterns/attributed-by-source.js +72 -0
- package/lib/hook-patterns/frequency-by-frequency.js +46 -0
- package/lib/hook-patterns/funnel-frequency-breakdown.js +73 -0
- package/lib/hook-patterns/index.js +14 -0
- package/lib/hook-patterns/time-to-convert-by-segment.js +41 -0
- package/lib/orchestrators/user-loop.js +119 -269
- package/lib/utils/utils.js +29 -16
- package/lib/verify/emulate-breakdown.js +281 -0
- package/lib/verify/index.js +12 -0
- package/lib/verify/verify-dungeon.js +61 -0
- package/package.json +6 -4
- package/types.d.ts +397 -211
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// ── TWEAK THESE ──
|
|
2
2
|
const SEED = "harness-social";
|
|
3
|
-
const num_days =
|
|
4
|
-
const num_users =
|
|
3
|
+
const num_days = 120;
|
|
4
|
+
const num_users = 10_000;
|
|
5
5
|
const avg_events_per_user_per_day = 1.2;
|
|
6
6
|
let token = "your-mixpanel-token";
|
|
7
7
|
|
|
@@ -107,7 +107,7 @@ const chance = u.initChance(SEED);
|
|
|
107
107
|
* receive follow-backs and post more frequently.
|
|
108
108
|
*
|
|
109
109
|
* -------------------------------------------------------------------------------------
|
|
110
|
-
* 3. ALGORITHM CHANGE (
|
|
110
|
+
* 3. ALGORITHM CHANGE (everything)
|
|
111
111
|
* -------------------------------------------------------------------------------------
|
|
112
112
|
*
|
|
113
113
|
* PATTERN: On day 45, the dominant `source` for "post viewed" flips from
|
|
@@ -146,7 +146,7 @@ const chance = u.initChance(SEED);
|
|
|
146
146
|
* quality is awful, dragging down avg watch time.
|
|
147
147
|
*
|
|
148
148
|
* -------------------------------------------------------------------------------------
|
|
149
|
-
* 5. NOTIFICATION RE-ENGAGEMENT (
|
|
149
|
+
* 5. NOTIFICATION RE-ENGAGEMENT (everything)
|
|
150
150
|
* -------------------------------------------------------------------------------------
|
|
151
151
|
*
|
|
152
152
|
* PATTERN: After day 30, 30% of "post viewed" events have source flipped
|
|
@@ -190,14 +190,14 @@ const chance = u.initChance(SEED);
|
|
|
190
190
|
* 7. TOXICITY CHURN (everything)
|
|
191
191
|
* -------------------------------------------------------------------------------------
|
|
192
192
|
*
|
|
193
|
-
* PATTERN: Users with
|
|
193
|
+
* PATTERN: Users with 2+ "report submitted" events lose 60% of activity
|
|
194
194
|
* after day 30. No flag — discover via retention or per-user activity drop.
|
|
195
195
|
*
|
|
196
196
|
* HOW TO FIND IT IN MIXPANEL:
|
|
197
197
|
*
|
|
198
198
|
* Report 1: Retention by Toxicity
|
|
199
199
|
* - Report type: Retention
|
|
200
|
-
* - Cohort A: users with >=
|
|
200
|
+
* - Cohort A: users with >= 2 "report submitted"
|
|
201
201
|
* - Cohort B: rest
|
|
202
202
|
* - Expected: A ~ 40% retention vs B ~ 80%
|
|
203
203
|
*
|
|
@@ -276,15 +276,17 @@ const postIds = v.range(1, 1001).map(n => `post_${v.uid(8)}`);
|
|
|
276
276
|
|
|
277
277
|
/** @type {Config} */
|
|
278
278
|
const config = {
|
|
279
|
+
version: 2,
|
|
279
280
|
token,
|
|
280
281
|
seed: SEED,
|
|
281
282
|
datasetStart: "2026-01-01T00:00:00Z",
|
|
282
|
-
datasetEnd: "2026-
|
|
283
|
+
datasetEnd: "2026-05-01T23:59:59Z",
|
|
283
284
|
soup: { dayOfWeekWeights: [1.0, 1.0, 1.0, 1.0, 1.0, 1.2, 1.2] },
|
|
284
285
|
// numDays: num_days,
|
|
285
286
|
avgEventsPerUserPerDay: avg_events_per_user_per_day,
|
|
286
287
|
numUsers: num_users,
|
|
287
|
-
hasAnonIds:
|
|
288
|
+
hasAnonIds: true,
|
|
289
|
+
avgDevicePerUser: 2,
|
|
288
290
|
hasSessionIds: true,
|
|
289
291
|
format: "json",
|
|
290
292
|
gzip: true,
|
|
@@ -379,6 +381,7 @@ const config = {
|
|
|
379
381
|
event: "account created",
|
|
380
382
|
weight: 1,
|
|
381
383
|
isFirstEvent: true,
|
|
384
|
+
isAuthEvent: true,
|
|
382
385
|
properties: {
|
|
383
386
|
"signup_method": ["email", "google", "apple", "sso"],
|
|
384
387
|
"referred_by": ["organic", "friend", "ad", "influencer"],
|
|
@@ -576,43 +579,6 @@ const config = {
|
|
|
576
579
|
}
|
|
577
580
|
|
|
578
581
|
|
|
579
|
-
// ─── EVENT-LEVEL HOOKS ───────────────────────────────────────────
|
|
580
|
-
|
|
581
|
-
if (type === "event") {
|
|
582
|
-
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
583
|
-
const ALGORITHM_CHANGE_DAY = datasetStart.add(45, 'days');
|
|
584
|
-
const REENGAGEMENT_START = datasetStart.add(30, 'days');
|
|
585
|
-
const EVENT_TIME = dayjs(record.time);
|
|
586
|
-
|
|
587
|
-
// Hook #3: ALGORITHM CHANGE - Day 45 flips feed -> explore.
|
|
588
|
-
// Mutates the existing config-defined `source` prop.
|
|
589
|
-
if (record.event === "post viewed") {
|
|
590
|
-
if (EVENT_TIME.isAfter(ALGORITHM_CHANGE_DAY)) {
|
|
591
|
-
if (chance.bool({ likelihood: 70 })) {
|
|
592
|
-
record.source = "explore";
|
|
593
|
-
}
|
|
594
|
-
} else {
|
|
595
|
-
if (chance.bool({ likelihood: 70 })) {
|
|
596
|
-
record.source = "feed";
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
// Hook #4: ENGAGEMENT BAIT - 20% of post views get crushed view duration.
|
|
602
|
-
// No flag — analyst sees bimodal duration distribution + low-tail share.
|
|
603
|
-
if (record.event === "post viewed") {
|
|
604
|
-
if (chance.bool({ likelihood: 20 })) {
|
|
605
|
-
record.view_duration_sec = chance.integer({ min: 1, max: 5 });
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
// Hook #5: NOTIFICATION RE-ENGAGEMENT — after day 30, 30% of views
|
|
609
|
-
// flip source to "notification". Mutates existing source prop.
|
|
610
|
-
if (EVENT_TIME.isAfter(REENGAGEMENT_START) && chance.bool({ likelihood: 30 })) {
|
|
611
|
-
record.source = "notification";
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
|
|
616
582
|
// ─── EVERYTHING-LEVEL HOOKS ──────────────────────────────────────
|
|
617
583
|
|
|
618
584
|
if (type === "everything") {
|
|
@@ -782,9 +748,42 @@ const config = {
|
|
|
782
748
|
}
|
|
783
749
|
}
|
|
784
750
|
|
|
751
|
+
// Hook #3: ALGORITHM CHANGE — day 45 flips feed → explore on post viewed.
|
|
752
|
+
// Hook #4: ENGAGEMENT BAIT — 20% of post-viewed events get crushed duration.
|
|
753
|
+
// Hook #5: NOTIFICATION RE-ENGAGEMENT — after day 30, 30% of views → notification.
|
|
754
|
+
// All three run AFTER injection passes so they apply to cloned events too.
|
|
755
|
+
const ALGORITHM_CHANGE_DAY = datasetStart.add(45, 'days');
|
|
756
|
+
const REENGAGEMENT_START = datasetStart.add(30, 'days');
|
|
757
|
+
userEvents.forEach(e => {
|
|
758
|
+
if (e.event === "post viewed") {
|
|
759
|
+
const eventTime = dayjs(e.time);
|
|
760
|
+
|
|
761
|
+
// Hook #3: Algorithm Change
|
|
762
|
+
if (eventTime.isAfter(ALGORITHM_CHANGE_DAY)) {
|
|
763
|
+
if (chance.bool({ likelihood: 70 })) {
|
|
764
|
+
e.source = "explore";
|
|
765
|
+
}
|
|
766
|
+
} else {
|
|
767
|
+
if (chance.bool({ likelihood: 70 })) {
|
|
768
|
+
e.source = "feed";
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Hook #4: Engagement Bait — 20% crushed view duration
|
|
773
|
+
if (chance.bool({ likelihood: 20 })) {
|
|
774
|
+
e.view_duration_sec = chance.integer({ min: 1, max: 5 });
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Hook #5: Notification Re-engagement (runs after #3 so can override)
|
|
778
|
+
if (eventTime.isAfter(REENGAGEMENT_START) && chance.bool({ likelihood: 30 })) {
|
|
779
|
+
e.source = "notification";
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
|
|
785
784
|
// Hook #7: TOXICITY CHURN — drop 60% of activity after day 30 for high reporters.
|
|
786
|
-
// Discovery: cohort users with >=
|
|
787
|
-
if (reportSubmittedCount >=
|
|
785
|
+
// Discovery: cohort users with >=2 report-submitted events, observe retention drop.
|
|
786
|
+
if (reportSubmittedCount >= 2) {
|
|
788
787
|
const churnCutoff = datasetStart.add(30, 'days');
|
|
789
788
|
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
790
789
|
const evt = userEvents[i];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// ── TWEAK THESE ──
|
|
2
2
|
const SEED = "dm4-travel";
|
|
3
|
-
const num_days =
|
|
4
|
-
const num_users =
|
|
3
|
+
const num_days = 120;
|
|
4
|
+
const num_users = 10_000;
|
|
5
5
|
const avg_events_per_user_per_day = 1.2;
|
|
6
6
|
let token = "your-mixpanel-token";
|
|
7
7
|
|
|
@@ -29,7 +29,7 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
|
|
|
29
29
|
* StayQuest — a hotel booking platform for business and leisure travelers.
|
|
30
30
|
* Users search destinations, compare hotels, book rooms, and leave reviews.
|
|
31
31
|
*
|
|
32
|
-
* - 5,000 users over
|
|
32
|
+
* - 5,000 users over 120 days, ~600K events
|
|
33
33
|
* - Segments: business travelers (weekday), leisure families, luxury, budget
|
|
34
34
|
* - Core loop: search → view hotel → compare → book → stay → review
|
|
35
35
|
* - Revenue: commission per booking + premium loyalty membership
|
|
@@ -210,14 +210,16 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
|
|
|
210
210
|
|
|
211
211
|
/** @type {Config} */
|
|
212
212
|
const config = {
|
|
213
|
+
version: 2,
|
|
213
214
|
token,
|
|
214
215
|
seed: SEED,
|
|
215
216
|
datasetStart: "2026-01-01T00:00:00Z",
|
|
216
|
-
datasetEnd: "2026-
|
|
217
|
+
datasetEnd: "2026-05-01T23:59:59Z",
|
|
217
218
|
// numDays: num_days,
|
|
218
219
|
avgEventsPerUserPerDay: avg_events_per_user_per_day,
|
|
219
220
|
numUsers: num_users,
|
|
220
|
-
hasAnonIds:
|
|
221
|
+
hasAnonIds: true,
|
|
222
|
+
avgDevicePerUser: 2,
|
|
221
223
|
hasSessionIds: true,
|
|
222
224
|
format: "json",
|
|
223
225
|
gzip: true,
|
|
@@ -250,6 +252,7 @@ const config = {
|
|
|
250
252
|
event: "account created",
|
|
251
253
|
weight: 1,
|
|
252
254
|
isFirstEvent: true,
|
|
255
|
+
isAuthEvent: true,
|
|
253
256
|
properties: {
|
|
254
257
|
signup_source: ["organic", "google", "instagram", "tripadvisor", "referral", "email_campaign"],
|
|
255
258
|
},
|
|
@@ -145,6 +145,106 @@ function inferFunnels(events) {
|
|
|
145
145
|
return createdFunnels;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Config keys removed from the engine in 1.4. Silently stripped by
|
|
150
|
+
* `validateDungeonConfig` with a single warning per dungeon. To recreate any of these
|
|
151
|
+
* patterns, use hooks (see `lib/hook-patterns/*`).
|
|
152
|
+
*/
|
|
153
|
+
const KILLED_CONFIG_KEYS = ['subscription', 'attribution', 'geo', 'features', 'anomalies'];
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Strip killed config keys in place, log one deprecation warning per dungeon.
|
|
157
|
+
* @param {Partial<Dungeon>} config
|
|
158
|
+
*/
|
|
159
|
+
function stripKilledConfigKeys(config) {
|
|
160
|
+
const found = KILLED_CONFIG_KEYS.filter(k => config[k] !== undefined && config[k] !== null);
|
|
161
|
+
if (!found.length) return;
|
|
162
|
+
for (const k of found) delete config[k];
|
|
163
|
+
if (config.verbose !== false) {
|
|
164
|
+
console.warn(
|
|
165
|
+
`⚠️ dungeon-master 1.4 removed engine support for: ${found.join(', ')}. ` +
|
|
166
|
+
`These config keys are silently ignored. Recreate via hooks (see lib/hook-patterns/* once Phase 4 lands).`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Validate `Funnel.attempts` config in place. Coerces missing/invalid bounds so callers
|
|
173
|
+
* downstream don't have to re-defend. Throws on logically invalid configs (max < min).
|
|
174
|
+
* @param {import('../../types.js').Funnel[]} funnels
|
|
175
|
+
*/
|
|
176
|
+
function validateAttempts(funnels) {
|
|
177
|
+
for (const f of funnels) {
|
|
178
|
+
if (!f || !f.attempts) continue;
|
|
179
|
+
const a = f.attempts;
|
|
180
|
+
const min = Number.isFinite(a.min) ? Math.max(0, Math.floor(a.min)) : 0;
|
|
181
|
+
const max = Number.isFinite(a.max) ? Math.max(0, Math.floor(a.max)) : min;
|
|
182
|
+
if (max < min) {
|
|
183
|
+
throw new Error(`Funnel "${f.name || f.sequence?.join(' > ')}" attempts.max (${a.max}) must be >= attempts.min (${a.min})`);
|
|
184
|
+
}
|
|
185
|
+
a.min = min;
|
|
186
|
+
a.max = max;
|
|
187
|
+
if (a.conversionRate !== undefined) {
|
|
188
|
+
if (!Number.isFinite(a.conversionRate)) {
|
|
189
|
+
throw new Error(`Funnel "${f.name || f.sequence?.join(' > ')}" attempts.conversionRate must be a finite number 0-100`);
|
|
190
|
+
}
|
|
191
|
+
a.conversionRate = Math.max(0, Math.min(100, a.conversionRate));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Normalize `Funnel.experiment` in place. `true` → default 3-variant config.
|
|
198
|
+
* Object → validated and resolved. Stores `funnel._experiment` for downstream.
|
|
199
|
+
* @param {import('../../types.js').Funnel[]} funnels
|
|
200
|
+
* @param {number} datasetEndUnix
|
|
201
|
+
*/
|
|
202
|
+
function normalizeExperiments(funnels, datasetEndUnix) {
|
|
203
|
+
const DEFAULT_VARIANTS = [
|
|
204
|
+
{ name: 'Variant A', conversionMultiplier: 0.7, ttcMultiplier: 1.5, weight: 1 },
|
|
205
|
+
{ name: 'Variant B', conversionMultiplier: 1.3, ttcMultiplier: 0.7, weight: 1 },
|
|
206
|
+
{ name: 'Control', conversionMultiplier: 1.0, ttcMultiplier: 1.0, weight: 1 },
|
|
207
|
+
];
|
|
208
|
+
for (const f of funnels) {
|
|
209
|
+
if (!f || !f.experiment) continue;
|
|
210
|
+
const raw = f.experiment === true ? {} : f.experiment;
|
|
211
|
+
const name = raw.name || (f.name ? f.name + ' Experiment' : 'Unnamed Experiment');
|
|
212
|
+
const variants = (raw.variants && raw.variants.length)
|
|
213
|
+
? raw.variants.map(v => ({
|
|
214
|
+
name: v.name || 'Unnamed Variant',
|
|
215
|
+
conversionMultiplier: Number.isFinite(v.conversionMultiplier) ? Math.max(0.01, v.conversionMultiplier) : 1.0,
|
|
216
|
+
ttcMultiplier: Number.isFinite(v.ttcMultiplier) ? Math.max(0.01, v.ttcMultiplier) : 1.0,
|
|
217
|
+
weight: Number.isFinite(v.weight) && v.weight > 0 ? v.weight : 1,
|
|
218
|
+
}))
|
|
219
|
+
: DEFAULT_VARIANTS;
|
|
220
|
+
const startDays = Number.isFinite(raw.startDaysBeforeEnd) && raw.startDaysBeforeEnd > 0
|
|
221
|
+
? raw.startDaysBeforeEnd : 0;
|
|
222
|
+
const startUnix = startDays > 0 ? datasetEndUnix - startDays * 86400 : null;
|
|
223
|
+
f._experiment = { name, variants, startUnix };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Resolve `avgDevicePerUser` per Section 3.3 rules. Returns the integer device count
|
|
229
|
+
* the engine should use (0 = no device_id stamping at all, 1+ = pool size).
|
|
230
|
+
*
|
|
231
|
+
* @param {Partial<Dungeon>} config
|
|
232
|
+
* @returns {number}
|
|
233
|
+
*/
|
|
234
|
+
function resolveDevicesPerUser(config) {
|
|
235
|
+
const raw = config.avgDevicePerUser;
|
|
236
|
+
const hasAnon = config.hasAnonIds === true;
|
|
237
|
+
if (raw === undefined || raw === null) {
|
|
238
|
+
return hasAnon ? 1 : 0;
|
|
239
|
+
}
|
|
240
|
+
if (!Number.isFinite(raw)) {
|
|
241
|
+
return hasAnon ? 1 : 0;
|
|
242
|
+
}
|
|
243
|
+
const n = Math.round(raw);
|
|
244
|
+
if (n <= 0) return hasAnon ? 1 : 0;
|
|
245
|
+
return n;
|
|
246
|
+
}
|
|
247
|
+
|
|
148
248
|
/**
|
|
149
249
|
* Validates and enriches a dungeon configuration object
|
|
150
250
|
* @param {Partial<Dungeon>} config - Raw configuration object
|
|
@@ -153,6 +253,9 @@ function inferFunnels(events) {
|
|
|
153
253
|
export function validateDungeonConfig(config) {
|
|
154
254
|
const chance = u.getChance();
|
|
155
255
|
|
|
256
|
+
// Phase 1 — strip killed config keys before anything else reads them.
|
|
257
|
+
stripKilledConfigKeys(config);
|
|
258
|
+
|
|
156
259
|
// Transform SCD props to regular props if credentials are missing
|
|
157
260
|
// This MUST happen BEFORE we extract values from the config
|
|
158
261
|
transformSCDPropsWithoutCredentials(config);
|
|
@@ -436,35 +539,31 @@ export function validateDungeonConfig(config) {
|
|
|
436
539
|
dataQuality = validateDataQuality(dataQuality);
|
|
437
540
|
}
|
|
438
541
|
|
|
439
|
-
//
|
|
440
|
-
|
|
441
|
-
if (subscription) {
|
|
442
|
-
subscription = validateSubscription(subscription);
|
|
443
|
-
}
|
|
542
|
+
// Phase 1: validate Funnel.attempts on every funnel (additive — most have none).
|
|
543
|
+
validateAttempts(funnels);
|
|
444
544
|
|
|
445
|
-
//
|
|
446
|
-
|
|
447
|
-
if (attribution) {
|
|
448
|
-
attribution = validateAttribution(attribution, numDays);
|
|
449
|
-
}
|
|
545
|
+
// Normalize experiment configs: true → default 3-variant, object → validated.
|
|
546
|
+
normalizeExperiments(funnels, datasetEndUnix);
|
|
450
547
|
|
|
451
|
-
//
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
548
|
+
// Phase 1: resolve multi-device config. `avgDevicePerUser` is the canonical knob;
|
|
549
|
+
// `hasAnonIds: true` aliases to 1. Default 0 = no device_id stamping (legacy).
|
|
550
|
+
const avgDevicePerUser = resolveDevicesPerUser(config);
|
|
551
|
+
// Keep hasAnonIds in sync — downstream code (utils.generateUser) still reads it.
|
|
552
|
+
// Setting it true here when avgDevicePerUser >= 1 lets the legacy device-pool
|
|
553
|
+
// generation in `person()` continue to allocate `anonymousIds[]` for the user.
|
|
554
|
+
const hasAnonIdsResolved = avgDevicePerUser >= 1;
|
|
456
555
|
|
|
457
|
-
//
|
|
458
|
-
|
|
459
|
-
if (
|
|
460
|
-
|
|
556
|
+
// Warn if isAuthEvent is set but avgDevicePerUser=0 — pre-auth device_only
|
|
557
|
+
// stamping degrades to user_id via the floor guard, defeating the identity model.
|
|
558
|
+
if (avgDevicePerUser === 0 && validatedEvents.some(e => e.isAuthEvent)) {
|
|
559
|
+
console.warn(
|
|
560
|
+
`⚠️ isAuthEvent requires avgDevicePerUser >= 1 to produce pre-auth anonymous events. ` +
|
|
561
|
+
`Set avgDevicePerUser or hasAnonIds: true.`
|
|
562
|
+
);
|
|
461
563
|
}
|
|
462
564
|
|
|
463
|
-
//
|
|
464
|
-
|
|
465
|
-
if (anomalies) {
|
|
466
|
-
anomalies = resolveAnomalies(anomalies, datasetStartUnix);
|
|
467
|
-
}
|
|
565
|
+
// Precompute whether any event has isAttributionEvent for UTM stamping logic.
|
|
566
|
+
const hasAttributionFlags = validatedEvents.some(e => e.isAttributionEvent);
|
|
468
567
|
|
|
469
568
|
// Build final config object
|
|
470
569
|
const validatedConfig = {
|
|
@@ -489,7 +588,8 @@ export function validateDungeonConfig(config) {
|
|
|
489
588
|
groupKeys,
|
|
490
589
|
groupProps,
|
|
491
590
|
lookupTables,
|
|
492
|
-
hasAnonIds,
|
|
591
|
+
hasAnonIds: hasAnonIdsResolved,
|
|
592
|
+
avgDevicePerUser,
|
|
493
593
|
hasSessionIds,
|
|
494
594
|
sessionTimeout: (typeof sessionTimeout === 'number' && sessionTimeout > 0) ? sessionTimeout : 30,
|
|
495
595
|
format,
|
|
@@ -501,6 +601,7 @@ export function validateDungeonConfig(config) {
|
|
|
501
601
|
hook,
|
|
502
602
|
hasAdSpend,
|
|
503
603
|
hasCampaigns,
|
|
604
|
+
hasAttributionFlags,
|
|
504
605
|
hasLocation,
|
|
505
606
|
hasAvatar,
|
|
506
607
|
isAnonymous,
|
|
@@ -515,16 +616,17 @@ export function validateDungeonConfig(config) {
|
|
|
515
616
|
bornRecentBias,
|
|
516
617
|
percentUsersBornInDataset,
|
|
517
618
|
preExistingSpread,
|
|
518
|
-
// Advanced features
|
|
619
|
+
// Advanced features (kept after 1.4)
|
|
519
620
|
personas,
|
|
520
621
|
worldEvents,
|
|
521
622
|
engagementDecay,
|
|
522
623
|
dataQuality,
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
624
|
+
// Killed in 1.4 — set to null so hooks/external code that reads these get a falsy value.
|
|
625
|
+
subscription: null,
|
|
626
|
+
attribution: null,
|
|
627
|
+
geo: null,
|
|
628
|
+
features: null,
|
|
629
|
+
anomalies: null
|
|
528
630
|
};
|
|
529
631
|
|
|
530
632
|
return validatedConfig;
|
|
@@ -689,131 +791,8 @@ function validateDataQuality(dq) {
|
|
|
689
791
|
return dq;
|
|
690
792
|
}
|
|
691
793
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
* @returns {import('../../types').Subscription}
|
|
696
|
-
*/
|
|
697
|
-
function validateSubscription(sub) {
|
|
698
|
-
if (!sub.plans || !Array.isArray(sub.plans) || sub.plans.length === 0) {
|
|
699
|
-
throw new Error('subscription.plans must be a non-empty array');
|
|
700
|
-
}
|
|
701
|
-
const hasDefault = sub.plans.some(p => p.default);
|
|
702
|
-
if (!hasDefault) sub.plans[0].default = true;
|
|
703
|
-
if (!sub.lifecycle) sub.lifecycle = {};
|
|
704
|
-
const lc = sub.lifecycle;
|
|
705
|
-
if (lc.trialToPayRate === undefined) lc.trialToPayRate = 0.3;
|
|
706
|
-
if (lc.upgradeRate === undefined) lc.upgradeRate = 0.1;
|
|
707
|
-
if (lc.downgradeRate === undefined) lc.downgradeRate = 0.03;
|
|
708
|
-
if (lc.churnRate === undefined) lc.churnRate = 0.05;
|
|
709
|
-
if (lc.winBackRate === undefined) lc.winBackRate = 0.1;
|
|
710
|
-
if (lc.winBackDelay === undefined) lc.winBackDelay = 30;
|
|
711
|
-
if (lc.paymentFailureRate === undefined) lc.paymentFailureRate = 0.02;
|
|
712
|
-
if (!sub.events) sub.events = {};
|
|
713
|
-
const ev = sub.events;
|
|
714
|
-
if (!ev.trialStarted) ev.trialStarted = "trial started";
|
|
715
|
-
if (!ev.subscribed) ev.subscribed = "subscription started";
|
|
716
|
-
if (!ev.upgraded) ev.upgraded = "plan upgraded";
|
|
717
|
-
if (!ev.downgraded) ev.downgraded = "plan downgraded";
|
|
718
|
-
if (!ev.renewed) ev.renewed = "subscription renewed";
|
|
719
|
-
if (!ev.cancelled) ev.cancelled = "subscription cancelled";
|
|
720
|
-
if (!ev.paymentFailed) ev.paymentFailed = "payment failed";
|
|
721
|
-
if (!ev.wonBack) ev.wonBack = "subscription reactivated";
|
|
722
|
-
return sub;
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
/**
|
|
726
|
-
* Validates attribution config
|
|
727
|
-
* @param {import('../../types').Attribution} attr
|
|
728
|
-
* @param {number} numDays
|
|
729
|
-
* @returns {import('../../types').Attribution}
|
|
730
|
-
*/
|
|
731
|
-
function validateAttribution(attr, numDays) {
|
|
732
|
-
if (!attr.campaigns || !Array.isArray(attr.campaigns)) {
|
|
733
|
-
throw new Error('attribution.campaigns must be an array');
|
|
734
|
-
}
|
|
735
|
-
if (attr.model === undefined) attr.model = "last_touch";
|
|
736
|
-
if (attr.window === undefined) attr.window = 7;
|
|
737
|
-
if (attr.organicRate === undefined) attr.organicRate = 0.4;
|
|
738
|
-
for (const c of attr.campaigns) {
|
|
739
|
-
if (!c.name) throw new Error('Each attribution campaign must have a name');
|
|
740
|
-
if (!c.source) throw new Error(`Attribution campaign "${c.name}" must have a source`);
|
|
741
|
-
if (!c.activeDays) throw new Error(`Attribution campaign "${c.name}" must have activeDays [start, end]`);
|
|
742
|
-
if (!c.dailyBudget) c.dailyBudget = [50, 200];
|
|
743
|
-
if (c.acquisitionRate === undefined) c.acquisitionRate = 0.02;
|
|
744
|
-
}
|
|
745
|
-
return attr;
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
/**
|
|
749
|
-
* Validates geo config
|
|
750
|
-
* @param {import('../../types').GeoConfig} geo
|
|
751
|
-
* @returns {import('../../types').GeoConfig}
|
|
752
|
-
*/
|
|
753
|
-
function validateGeo(geo) {
|
|
754
|
-
if (geo.sticky === undefined) geo.sticky = false;
|
|
755
|
-
if (geo.regions && !Array.isArray(geo.regions)) throw new Error('geo.regions must be an array');
|
|
756
|
-
if (geo.regionalLaunches && !Array.isArray(geo.regionalLaunches)) throw new Error('geo.regionalLaunches must be an array');
|
|
757
|
-
return geo;
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
/**
|
|
761
|
-
* Resolves feature configs with logistic curve parameters
|
|
762
|
-
* @param {import('../../types').FeatureConfig[]} features
|
|
763
|
-
* @param {number} numDays
|
|
764
|
-
* @returns {import('../../types').FeatureConfig[]}
|
|
765
|
-
*/
|
|
766
|
-
function resolveFeatures(features, numDays) {
|
|
767
|
-
if (!Array.isArray(features) || features.length === 0) return null;
|
|
768
|
-
const curvePresets = {
|
|
769
|
-
fast: { k: 0.3, midpoint: 7 },
|
|
770
|
-
slow: { k: 0.08, midpoint: 30 },
|
|
771
|
-
instant: { k: 10, midpoint: 0 }
|
|
772
|
-
};
|
|
773
|
-
return features.map(f => {
|
|
774
|
-
if (!f.name) throw new Error('Each feature must have a name');
|
|
775
|
-
if (f.launchDay === undefined) throw new Error(`Feature "${f.name}" must have a launchDay`);
|
|
776
|
-
if (!f.property) throw new Error(`Feature "${f.name}" must have a property`);
|
|
777
|
-
if (!f.values || !Array.isArray(f.values) || f.values.length === 0) {
|
|
778
|
-
throw new Error(`Feature "${f.name}" must have a non-empty values array`);
|
|
779
|
-
}
|
|
780
|
-
if (!f.affectsEvents) f.affectsEvents = "*";
|
|
781
|
-
if (!f.adoptionCurve) f.adoptionCurve = "slow";
|
|
782
|
-
if (typeof f.adoptionCurve === 'string') {
|
|
783
|
-
f._resolvedCurve = curvePresets[f.adoptionCurve] || curvePresets.slow;
|
|
784
|
-
} else {
|
|
785
|
-
f._resolvedCurve = f.adoptionCurve;
|
|
786
|
-
}
|
|
787
|
-
// Pre-compute adopted values to avoid array allocation in hot loop
|
|
788
|
-
f._adoptedValues = f.values.length > 1 ? f.values.slice(1) : f.values;
|
|
789
|
-
return f;
|
|
790
|
-
});
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
/**
|
|
794
|
-
* Resolves anomaly configs with absolute timestamps
|
|
795
|
-
* @param {import('../../types').AnomalyConfig[]} anomalies
|
|
796
|
-
* @param {number} beginUnix - Dataset start (unix seconds)
|
|
797
|
-
* @returns {import('../../types').AnomalyConfig[]}
|
|
798
|
-
*/
|
|
799
|
-
function resolveAnomalies(anomalies, beginUnix) {
|
|
800
|
-
if (!Array.isArray(anomalies) || anomalies.length === 0) return null;
|
|
801
|
-
return anomalies.map(a => {
|
|
802
|
-
if (!a.type) throw new Error('Each anomaly must have a type');
|
|
803
|
-
if (!a.event) throw new Error('Each anomaly must have an event name');
|
|
804
|
-
const resolved = { ...a };
|
|
805
|
-
if (a.day !== undefined) {
|
|
806
|
-
resolved._startUnix = beginUnix + (a.day * 86400);
|
|
807
|
-
if (a.duration) {
|
|
808
|
-
resolved._endUnix = resolved._startUnix + (a.duration * 86400);
|
|
809
|
-
} else if (a.window) {
|
|
810
|
-
resolved._endUnix = resolved._startUnix + (a.window * 86400);
|
|
811
|
-
} else {
|
|
812
|
-
resolved._endUnix = resolved._startUnix + 86400; // default 1 day
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
return resolved;
|
|
816
|
-
});
|
|
817
|
-
}
|
|
794
|
+
// validateSubscription / validateAttribution / validateGeo / resolveFeatures /
|
|
795
|
+
// resolveAnomalies were removed in 1.4 along with their respective config keys.
|
|
796
|
+
// `stripKilledConfigKeys` deletes the inputs before they reach the validator body.
|
|
818
797
|
|
|
819
798
|
export { inferFunnels, transformSCDPropsWithoutCredentials };
|