@ak--47/dungeon-master 1.3.0 → 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/array-of-object-lookup.js +0 -2
- 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/technical/simple.js +3 -4
- package/dungeons/technical/simplest-schema.json +5 -0
- package/dungeons/technical/text-generation.js +1 -1
- package/dungeons/vertical/ai-platform.js +216 -164
- package/dungeons/vertical/community.js +74 -41
- package/dungeons/vertical/crypto.js +343 -233
- package/dungeons/vertical/dating.js +246 -292
- package/dungeons/vertical/devtools.js +179 -70
- package/dungeons/vertical/ecommerce.js +275 -122
- package/dungeons/vertical/education.js +345 -312
- package/dungeons/vertical/fintech.js +445 -314
- package/dungeons/vertical/fitness.js +199 -165
- package/dungeons/vertical/food-delivery.js +309 -330
- package/dungeons/vertical/gaming.js +387 -241
- package/dungeons/vertical/healthcare.js +149 -66
- package/dungeons/vertical/insurance-application.js +173 -75
- package/dungeons/vertical/logistics.js +107 -12
- package/dungeons/vertical/marketplace.js +171 -76
- package/dungeons/vertical/media.js +259 -402
- package/dungeons/vertical/real-estate.js +336 -318
- package/dungeons/vertical/sass.js +294 -288
- package/dungeons/vertical/social.js +289 -234
- package/dungeons/vertical/travel.js +66 -20
- package/index.js +17 -17
- package/lib/core/config-validator.js +226 -175
- package/lib/core/context.js +10 -24
- package/lib/core/storage.js +6 -1
- package/lib/generators/events.js +57 -104
- package/lib/generators/funnels.js +212 -93
- package/lib/generators/mirror.js +3 -2
- package/lib/generators/product-names.js +1 -1
- package/lib/generators/scd.js +2 -1
- package/lib/generators/text.js +1 -1
- 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 +143 -293
- package/lib/utils/utils.js +97 -52
- 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/scripts/smoke-test-all.mjs +162 -0
- package/scripts/verify-runner.mjs +72 -24
- package/types.d.ts +434 -225
- package/dungeons/technical/ad-spend-schema.json +0 -128
- package/dungeons/technical/anonymous-users-schema.json +0 -92
- package/dungeons/technical/array-of-object-lookup-schema.json +0 -191
- package/dungeons/technical/experiments-schema.json +0 -203
- package/dungeons/technical/foobar-schema.json +0 -362
- package/dungeons/technical/group-analytics-schema.json +0 -241
- package/dungeons/technical/mirror-strategies-schema.json +0 -84
- package/dungeons/technical/nested-objects-schema.json +0 -145
- package/dungeons/technical/retention-cadence-schema.json +0 -37
- package/dungeons/technical/sanity-schema.json +0 -185
- package/dungeons/technical/scale-test-schema.json +0 -70
- package/dungeons/technical/scd-schema.json +0 -467
- package/dungeons/technical/simple-schema.json +0 -362
- package/dungeons/technical/text-generation-schema.json +0 -1062
- package/dungeons/vertical/ai-platform-schema.json +0 -617
- package/dungeons/vertical/community-schema.json +0 -579
- package/dungeons/vertical/crypto-schema.json +0 -546
- package/dungeons/vertical/dating-schema.json +0 -401
- package/dungeons/vertical/devtools-schema.json +0 -601
- package/dungeons/vertical/ecommerce-schema.json +0 -604
- package/dungeons/vertical/education-schema.json +0 -5686
- package/dungeons/vertical/fintech-schema.json +0 -630
- package/dungeons/vertical/fitness-schema.json +0 -530
- package/dungeons/vertical/food-delivery-schema.json +0 -36728
- package/dungeons/vertical/gaming-schema.json +0 -2703
- package/dungeons/vertical/healthcare-schema.json +0 -549
- package/dungeons/vertical/insurance-application-schema.json +0 -485
- package/dungeons/vertical/logistics-schema.json +0 -574
- package/dungeons/vertical/marketplace-schema.json +0 -533
- package/dungeons/vertical/media-schema.json +0 -4749
- package/dungeons/vertical/real-estate-schema.json +0 -527
- package/dungeons/vertical/sass-schema.json +0 -3128
- package/dungeons/vertical/social-schema.json +0 -620
- package/dungeons/vertical/travel-schema.json +0 -580
|
@@ -14,8 +14,80 @@ import * as u from "../utils/utils.js";
|
|
|
14
14
|
import { resolveSoup } from "../templates/soup-presets.js";
|
|
15
15
|
import { resolveMacro } from "../templates/macro-presets.js";
|
|
16
16
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Resolve dataset window from config. Returns { datasetStartUnix, datasetEndUnix, numDays }.
|
|
19
|
+
* Three modes:
|
|
20
|
+
* 1. Both `datasetStart` AND `datasetEnd` provided → pin window. Recompute numDays from the
|
|
21
|
+
* window (ignore any user-supplied numDays — warn on conflict). Bit-exact deterministic.
|
|
22
|
+
* 2. Neither provided → fall back to (today_start - numDays, today_start). Deterministic
|
|
23
|
+
* within a single calendar day; slides across days. Warn once.
|
|
24
|
+
* 3. Exactly one provided → throw.
|
|
25
|
+
*
|
|
26
|
+
* Accepts ISO strings, unix seconds, or anything dayjs() can parse.
|
|
27
|
+
*
|
|
28
|
+
* @param {*} datasetStart
|
|
29
|
+
* @param {*} datasetEnd
|
|
30
|
+
* @param {number} [userNumDays]
|
|
31
|
+
* @returns {{ datasetStartUnix: number, datasetEndUnix: number, numDays: number }}
|
|
32
|
+
*/
|
|
33
|
+
function resolveDatasetWindow(datasetStart, datasetEnd, userNumDays) {
|
|
34
|
+
const hasStart = datasetStart !== undefined && datasetStart !== null;
|
|
35
|
+
const hasEnd = datasetEnd !== undefined && datasetEnd !== null;
|
|
36
|
+
|
|
37
|
+
if (hasStart !== hasEnd) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`datasetStart and datasetEnd must be specified together (got datasetStart=${datasetStart}, datasetEnd=${datasetEnd}). ` +
|
|
40
|
+
`Provide both to pin the window, or neither to fall back to numDays.`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (hasStart && hasEnd) {
|
|
45
|
+
const startUnix = parseToUnix(datasetStart, 'datasetStart');
|
|
46
|
+
const endUnix = parseToUnix(datasetEnd, 'datasetEnd');
|
|
47
|
+
if (endUnix <= startUnix) {
|
|
48
|
+
throw new Error(`datasetEnd (${datasetEnd}) must be after datasetStart (${datasetStart}).`);
|
|
49
|
+
}
|
|
50
|
+
const derivedNumDays = Math.max(1, Math.round((endUnix - startUnix) / 86400));
|
|
51
|
+
if (userNumDays !== undefined && userNumDays !== null && userNumDays !== derivedNumDays) {
|
|
52
|
+
console.warn(
|
|
53
|
+
`⚠️ datasetStart/datasetEnd take precedence; user-supplied numDays=${userNumDays} ignored, derived numDays=${derivedNumDays}.`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return { datasetStartUnix: startUnix, datasetEndUnix: endUnix, numDays: derivedNumDays };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Fallback: anchor to today's start-of-day, walk back numDays
|
|
60
|
+
const fallbackNumDays = (typeof userNumDays === 'number' && userNumDays > 0) ? userNumDays : 30;
|
|
61
|
+
const todayStart = dayjs().startOf('day').unix();
|
|
62
|
+
const fallbackStart = todayStart - fallbackNumDays * 86400;
|
|
63
|
+
console.warn(
|
|
64
|
+
`⚠️ No 'datasetStart'/'datasetEnd' set — dataset window anchored to today's date and will shift across runs. Pin both for full determinism.`
|
|
65
|
+
);
|
|
66
|
+
return { datasetStartUnix: fallbackStart, datasetEndUnix: todayStart, numDays: fallbackNumDays };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Parse a value (ISO string, unix seconds, dayjs-parseable) into unix seconds.
|
|
71
|
+
* Throws if the value can't be parsed into a valid date.
|
|
72
|
+
* @param {*} value
|
|
73
|
+
* @param {string} fieldName
|
|
74
|
+
* @returns {number}
|
|
75
|
+
*/
|
|
76
|
+
function parseToUnix(value, fieldName) {
|
|
77
|
+
// Treat numbers as unix seconds (or unix milliseconds if too large)
|
|
78
|
+
if (typeof value === 'number') {
|
|
79
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
80
|
+
throw new Error(`${fieldName} must be a positive finite number (got ${value}).`);
|
|
81
|
+
}
|
|
82
|
+
// Heuristic: > 10^12 means milliseconds, otherwise seconds
|
|
83
|
+
return value > 1e12 ? Math.floor(value / 1000) : Math.floor(value);
|
|
84
|
+
}
|
|
85
|
+
const parsed = dayjs(value);
|
|
86
|
+
if (!parsed.isValid()) {
|
|
87
|
+
throw new Error(`${fieldName} could not be parsed as a date (got ${JSON.stringify(value)}).`);
|
|
88
|
+
}
|
|
89
|
+
return parsed.unix();
|
|
90
|
+
}
|
|
19
91
|
|
|
20
92
|
/**
|
|
21
93
|
* Infers funnels from the provided events
|
|
@@ -73,6 +145,106 @@ function inferFunnels(events) {
|
|
|
73
145
|
return createdFunnels;
|
|
74
146
|
}
|
|
75
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
|
+
|
|
76
248
|
/**
|
|
77
249
|
* Validates and enriches a dungeon configuration object
|
|
78
250
|
* @param {Partial<Dungeon>} config - Raw configuration object
|
|
@@ -81,6 +253,9 @@ function inferFunnels(events) {
|
|
|
81
253
|
export function validateDungeonConfig(config) {
|
|
82
254
|
const chance = u.getChance();
|
|
83
255
|
|
|
256
|
+
// Phase 1 — strip killed config keys before anything else reads them.
|
|
257
|
+
stripKilledConfigKeys(config);
|
|
258
|
+
|
|
84
259
|
// Transform SCD props to regular props if credentials are missing
|
|
85
260
|
// This MUST happen BEFORE we extract values from the config
|
|
86
261
|
transformSCDPropsWithoutCredentials(config);
|
|
@@ -168,18 +343,18 @@ export function validateDungeonConfig(config) {
|
|
|
168
343
|
throw new Error(`numDays must be a positive number (got ${config.numDays})`);
|
|
169
344
|
}
|
|
170
345
|
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
346
|
+
// ── Resolve dataset window ──
|
|
347
|
+
// Preferred path: explicit datasetStart + datasetEnd → pinned, deterministic window.
|
|
348
|
+
// Fallback: numDays only → today_start - numDays back (sliding, warn-emitted).
|
|
349
|
+
const windowResolution = resolveDatasetWindow(config.datasetStart, config.datasetEnd, config.numDays);
|
|
350
|
+
const datasetStartUnix = windowResolution.datasetStartUnix;
|
|
351
|
+
const datasetEndUnix = windowResolution.datasetEndUnix;
|
|
352
|
+
numDays = windowResolution.numDays;
|
|
178
353
|
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
354
|
+
// Mirror window into legacy epoch* surface (still consumed by inferFunnels callers
|
|
355
|
+
// and any external code that read these from validated config).
|
|
356
|
+
epochStart = datasetStartUnix;
|
|
357
|
+
epochEnd = datasetEndUnix;
|
|
183
358
|
|
|
184
359
|
// Resolve event-rate primitive: avgEventsPerUserPerDay is the canonical knob.
|
|
185
360
|
// numEvents is supported as a fallback (legacy + total-volume target). Whichever
|
|
@@ -349,7 +524,7 @@ export function validateDungeonConfig(config) {
|
|
|
349
524
|
// Feature 2: World Events
|
|
350
525
|
let worldEvents = config.worldEvents || null;
|
|
351
526
|
if (worldEvents) {
|
|
352
|
-
worldEvents = resolveWorldEvents(worldEvents,
|
|
527
|
+
worldEvents = resolveWorldEvents(worldEvents, datasetStartUnix);
|
|
353
528
|
}
|
|
354
529
|
|
|
355
530
|
// Feature 3: Engagement Decay
|
|
@@ -364,35 +539,31 @@ export function validateDungeonConfig(config) {
|
|
|
364
539
|
dataQuality = validateDataQuality(dataQuality);
|
|
365
540
|
}
|
|
366
541
|
|
|
367
|
-
//
|
|
368
|
-
|
|
369
|
-
if (subscription) {
|
|
370
|
-
subscription = validateSubscription(subscription);
|
|
371
|
-
}
|
|
542
|
+
// Phase 1: validate Funnel.attempts on every funnel (additive — most have none).
|
|
543
|
+
validateAttempts(funnels);
|
|
372
544
|
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
if (attribution) {
|
|
376
|
-
attribution = validateAttribution(attribution, numDays);
|
|
377
|
-
}
|
|
545
|
+
// Normalize experiment configs: true → default 3-variant, object → validated.
|
|
546
|
+
normalizeExperiments(funnels, datasetEndUnix);
|
|
378
547
|
|
|
379
|
-
//
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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;
|
|
384
555
|
|
|
385
|
-
//
|
|
386
|
-
|
|
387
|
-
if (
|
|
388
|
-
|
|
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
|
+
);
|
|
389
563
|
}
|
|
390
564
|
|
|
391
|
-
//
|
|
392
|
-
|
|
393
|
-
if (anomalies) {
|
|
394
|
-
anomalies = resolveAnomalies(anomalies, numDays);
|
|
395
|
-
}
|
|
565
|
+
// Precompute whether any event has isAttributionEvent for UTM stamping logic.
|
|
566
|
+
const hasAttributionFlags = validatedEvents.some(e => e.isAttributionEvent);
|
|
396
567
|
|
|
397
568
|
// Build final config object
|
|
398
569
|
const validatedConfig = {
|
|
@@ -407,6 +578,8 @@ export function validateDungeonConfig(config) {
|
|
|
407
578
|
avgEventsPerUserPerDay,
|
|
408
579
|
epochStart,
|
|
409
580
|
epochEnd,
|
|
581
|
+
datasetStart: datasetStartUnix,
|
|
582
|
+
datasetEnd: datasetEndUnix,
|
|
410
583
|
events: validatedEvents,
|
|
411
584
|
superProps,
|
|
412
585
|
userProps,
|
|
@@ -415,7 +588,8 @@ export function validateDungeonConfig(config) {
|
|
|
415
588
|
groupKeys,
|
|
416
589
|
groupProps,
|
|
417
590
|
lookupTables,
|
|
418
|
-
hasAnonIds,
|
|
591
|
+
hasAnonIds: hasAnonIdsResolved,
|
|
592
|
+
avgDevicePerUser,
|
|
419
593
|
hasSessionIds,
|
|
420
594
|
sessionTimeout: (typeof sessionTimeout === 'number' && sessionTimeout > 0) ? sessionTimeout : 30,
|
|
421
595
|
format,
|
|
@@ -427,6 +601,7 @@ export function validateDungeonConfig(config) {
|
|
|
427
601
|
hook,
|
|
428
602
|
hasAdSpend,
|
|
429
603
|
hasCampaigns,
|
|
604
|
+
hasAttributionFlags,
|
|
430
605
|
hasLocation,
|
|
431
606
|
hasAvatar,
|
|
432
607
|
isAnonymous,
|
|
@@ -441,16 +616,17 @@ export function validateDungeonConfig(config) {
|
|
|
441
616
|
bornRecentBias,
|
|
442
617
|
percentUsersBornInDataset,
|
|
443
618
|
preExistingSpread,
|
|
444
|
-
// Advanced features
|
|
619
|
+
// Advanced features (kept after 1.4)
|
|
445
620
|
personas,
|
|
446
621
|
worldEvents,
|
|
447
622
|
engagementDecay,
|
|
448
623
|
dataQuality,
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
|
454
630
|
};
|
|
455
631
|
|
|
456
632
|
return validatedConfig;
|
|
@@ -551,12 +727,11 @@ function validatePersonas(personas) {
|
|
|
551
727
|
/**
|
|
552
728
|
* Resolves world events to absolute timestamps
|
|
553
729
|
* @param {import('../../types').WorldEvent[]} worldEvents
|
|
554
|
-
* @param {number}
|
|
730
|
+
* @param {number} beginUnix - Dataset start (unix seconds)
|
|
555
731
|
* @returns {import('../../types').ResolvedWorldEvent[]}
|
|
556
732
|
*/
|
|
557
|
-
function resolveWorldEvents(worldEvents,
|
|
733
|
+
function resolveWorldEvents(worldEvents, beginUnix) {
|
|
558
734
|
if (!Array.isArray(worldEvents) || worldEvents.length === 0) return null;
|
|
559
|
-
const beginUnix = dayjs.unix(FIXED_NOW).subtract(numDays, 'day').unix();
|
|
560
735
|
|
|
561
736
|
return worldEvents.map(we => {
|
|
562
737
|
const startUnix = beginUnix + (we.startDay * 86400);
|
|
@@ -616,132 +791,8 @@ function validateDataQuality(dq) {
|
|
|
616
791
|
return dq;
|
|
617
792
|
}
|
|
618
793
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
* @returns {import('../../types').Subscription}
|
|
623
|
-
*/
|
|
624
|
-
function validateSubscription(sub) {
|
|
625
|
-
if (!sub.plans || !Array.isArray(sub.plans) || sub.plans.length === 0) {
|
|
626
|
-
throw new Error('subscription.plans must be a non-empty array');
|
|
627
|
-
}
|
|
628
|
-
const hasDefault = sub.plans.some(p => p.default);
|
|
629
|
-
if (!hasDefault) sub.plans[0].default = true;
|
|
630
|
-
if (!sub.lifecycle) sub.lifecycle = {};
|
|
631
|
-
const lc = sub.lifecycle;
|
|
632
|
-
if (lc.trialToPayRate === undefined) lc.trialToPayRate = 0.3;
|
|
633
|
-
if (lc.upgradeRate === undefined) lc.upgradeRate = 0.1;
|
|
634
|
-
if (lc.downgradeRate === undefined) lc.downgradeRate = 0.03;
|
|
635
|
-
if (lc.churnRate === undefined) lc.churnRate = 0.05;
|
|
636
|
-
if (lc.winBackRate === undefined) lc.winBackRate = 0.1;
|
|
637
|
-
if (lc.winBackDelay === undefined) lc.winBackDelay = 30;
|
|
638
|
-
if (lc.paymentFailureRate === undefined) lc.paymentFailureRate = 0.02;
|
|
639
|
-
if (!sub.events) sub.events = {};
|
|
640
|
-
const ev = sub.events;
|
|
641
|
-
if (!ev.trialStarted) ev.trialStarted = "trial started";
|
|
642
|
-
if (!ev.subscribed) ev.subscribed = "subscription started";
|
|
643
|
-
if (!ev.upgraded) ev.upgraded = "plan upgraded";
|
|
644
|
-
if (!ev.downgraded) ev.downgraded = "plan downgraded";
|
|
645
|
-
if (!ev.renewed) ev.renewed = "subscription renewed";
|
|
646
|
-
if (!ev.cancelled) ev.cancelled = "subscription cancelled";
|
|
647
|
-
if (!ev.paymentFailed) ev.paymentFailed = "payment failed";
|
|
648
|
-
if (!ev.wonBack) ev.wonBack = "subscription reactivated";
|
|
649
|
-
return sub;
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
/**
|
|
653
|
-
* Validates attribution config
|
|
654
|
-
* @param {import('../../types').Attribution} attr
|
|
655
|
-
* @param {number} numDays
|
|
656
|
-
* @returns {import('../../types').Attribution}
|
|
657
|
-
*/
|
|
658
|
-
function validateAttribution(attr, numDays) {
|
|
659
|
-
if (!attr.campaigns || !Array.isArray(attr.campaigns)) {
|
|
660
|
-
throw new Error('attribution.campaigns must be an array');
|
|
661
|
-
}
|
|
662
|
-
if (attr.model === undefined) attr.model = "last_touch";
|
|
663
|
-
if (attr.window === undefined) attr.window = 7;
|
|
664
|
-
if (attr.organicRate === undefined) attr.organicRate = 0.4;
|
|
665
|
-
for (const c of attr.campaigns) {
|
|
666
|
-
if (!c.name) throw new Error('Each attribution campaign must have a name');
|
|
667
|
-
if (!c.source) throw new Error(`Attribution campaign "${c.name}" must have a source`);
|
|
668
|
-
if (!c.activeDays) throw new Error(`Attribution campaign "${c.name}" must have activeDays [start, end]`);
|
|
669
|
-
if (!c.dailyBudget) c.dailyBudget = [50, 200];
|
|
670
|
-
if (c.acquisitionRate === undefined) c.acquisitionRate = 0.02;
|
|
671
|
-
}
|
|
672
|
-
return attr;
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
/**
|
|
676
|
-
* Validates geo config
|
|
677
|
-
* @param {import('../../types').GeoConfig} geo
|
|
678
|
-
* @returns {import('../../types').GeoConfig}
|
|
679
|
-
*/
|
|
680
|
-
function validateGeo(geo) {
|
|
681
|
-
if (geo.sticky === undefined) geo.sticky = false;
|
|
682
|
-
if (geo.regions && !Array.isArray(geo.regions)) throw new Error('geo.regions must be an array');
|
|
683
|
-
if (geo.regionalLaunches && !Array.isArray(geo.regionalLaunches)) throw new Error('geo.regionalLaunches must be an array');
|
|
684
|
-
return geo;
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
/**
|
|
688
|
-
* Resolves feature configs with logistic curve parameters
|
|
689
|
-
* @param {import('../../types').FeatureConfig[]} features
|
|
690
|
-
* @param {number} numDays
|
|
691
|
-
* @returns {import('../../types').FeatureConfig[]}
|
|
692
|
-
*/
|
|
693
|
-
function resolveFeatures(features, numDays) {
|
|
694
|
-
if (!Array.isArray(features) || features.length === 0) return null;
|
|
695
|
-
const curvePresets = {
|
|
696
|
-
fast: { k: 0.3, midpoint: 7 },
|
|
697
|
-
slow: { k: 0.08, midpoint: 30 },
|
|
698
|
-
instant: { k: 10, midpoint: 0 }
|
|
699
|
-
};
|
|
700
|
-
return features.map(f => {
|
|
701
|
-
if (!f.name) throw new Error('Each feature must have a name');
|
|
702
|
-
if (f.launchDay === undefined) throw new Error(`Feature "${f.name}" must have a launchDay`);
|
|
703
|
-
if (!f.property) throw new Error(`Feature "${f.name}" must have a property`);
|
|
704
|
-
if (!f.values || !Array.isArray(f.values) || f.values.length === 0) {
|
|
705
|
-
throw new Error(`Feature "${f.name}" must have a non-empty values array`);
|
|
706
|
-
}
|
|
707
|
-
if (!f.affectsEvents) f.affectsEvents = "*";
|
|
708
|
-
if (!f.adoptionCurve) f.adoptionCurve = "slow";
|
|
709
|
-
if (typeof f.adoptionCurve === 'string') {
|
|
710
|
-
f._resolvedCurve = curvePresets[f.adoptionCurve] || curvePresets.slow;
|
|
711
|
-
} else {
|
|
712
|
-
f._resolvedCurve = f.adoptionCurve;
|
|
713
|
-
}
|
|
714
|
-
// Pre-compute adopted values to avoid array allocation in hot loop
|
|
715
|
-
f._adoptedValues = f.values.length > 1 ? f.values.slice(1) : f.values;
|
|
716
|
-
return f;
|
|
717
|
-
});
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
/**
|
|
721
|
-
* Resolves anomaly configs with absolute timestamps
|
|
722
|
-
* @param {import('../../types').AnomalyConfig[]} anomalies
|
|
723
|
-
* @param {number} numDays
|
|
724
|
-
* @returns {import('../../types').AnomalyConfig[]}
|
|
725
|
-
*/
|
|
726
|
-
function resolveAnomalies(anomalies, numDays) {
|
|
727
|
-
if (!Array.isArray(anomalies) || anomalies.length === 0) return null;
|
|
728
|
-
const beginUnix = dayjs.unix(FIXED_NOW).subtract(numDays, 'day').unix();
|
|
729
|
-
return anomalies.map(a => {
|
|
730
|
-
if (!a.type) throw new Error('Each anomaly must have a type');
|
|
731
|
-
if (!a.event) throw new Error('Each anomaly must have an event name');
|
|
732
|
-
const resolved = { ...a };
|
|
733
|
-
if (a.day !== undefined) {
|
|
734
|
-
resolved._startUnix = beginUnix + (a.day * 86400);
|
|
735
|
-
if (a.duration) {
|
|
736
|
-
resolved._endUnix = resolved._startUnix + (a.duration * 86400);
|
|
737
|
-
} else if (a.window) {
|
|
738
|
-
resolved._endUnix = resolved._startUnix + (a.window * 86400);
|
|
739
|
-
} else {
|
|
740
|
-
resolved._endUnix = resolved._startUnix + 86400; // default 1 day
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
return resolved;
|
|
744
|
-
});
|
|
745
|
-
}
|
|
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.
|
|
746
797
|
|
|
747
798
|
export { inferFunnels, transformSCDPropsWithoutCredentials };
|
package/lib/core/context.js
CHANGED
|
@@ -156,30 +156,16 @@ export function createContext(config, storage = null, timeConstants = {}) {
|
|
|
156
156
|
return runtime.isBatchMode;
|
|
157
157
|
},
|
|
158
158
|
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
},
|
|
169
|
-
|
|
170
|
-
// Time constants (passed explicitly from index.js; global fallback only for direct test usage)
|
|
171
|
-
FIXED_NOW: timeConstants.fixedNow || global.FIXED_NOW,
|
|
172
|
-
FIXED_BEGIN: timeConstants.fixedBegin || global.FIXED_BEGIN,
|
|
173
|
-
|
|
174
|
-
// PERFORMANCE: Pre-calculated time shift (instead of calculating per-event)
|
|
175
|
-
TIME_SHIFT_SECONDS: (() => {
|
|
176
|
-
const fixedNow = timeConstants.fixedNow || global.FIXED_NOW;
|
|
177
|
-
const actualNow = dayjs().subtract(1, "hour");
|
|
178
|
-
return actualNow.diff(dayjs.unix(fixedNow), "seconds");
|
|
179
|
-
})(),
|
|
180
|
-
|
|
181
|
-
// Max timestamp (unix seconds) — clamp here to prevent future events
|
|
182
|
-
MAX_TIME: dayjs().unix(),
|
|
159
|
+
// Dataset window anchors (resolved by config-validator). FIXED_BEGIN and
|
|
160
|
+
// FIXED_NOW are the authoritative window — events are generated directly
|
|
161
|
+
// inside this range, no time-shift step. DATASET_*_SECONDS are aliases
|
|
162
|
+
// kept for the existing `meta.datasetStart`/`meta.datasetEnd` hook surface.
|
|
163
|
+
// Resolution: explicit timeConstants override, otherwise pull from validated config.
|
|
164
|
+
// validateDungeonConfig always normalizes datasetStart/datasetEnd to unix seconds.
|
|
165
|
+
FIXED_NOW: /** @type {number} */ (timeConstants.fixedNow || config.datasetEnd),
|
|
166
|
+
FIXED_BEGIN: /** @type {number} */ (timeConstants.fixedBegin || config.datasetStart),
|
|
167
|
+
get DATASET_START_SECONDS() { return this.FIXED_BEGIN; },
|
|
168
|
+
get DATASET_END_SECONDS() { return this.FIXED_NOW; },
|
|
183
169
|
};
|
|
184
170
|
|
|
185
171
|
return context;
|
package/lib/core/storage.js
CHANGED
|
@@ -111,7 +111,12 @@ export async function createHookArray(arr = [], opts) {
|
|
|
111
111
|
}
|
|
112
112
|
} else {
|
|
113
113
|
// Slow path for actual transformation hooks
|
|
114
|
-
const allMetaData = {
|
|
114
|
+
const allMetaData = {
|
|
115
|
+
...rest,
|
|
116
|
+
...meta,
|
|
117
|
+
datasetStart: context?.DATASET_START_SECONDS,
|
|
118
|
+
datasetEnd: context?.DATASET_END_SECONDS
|
|
119
|
+
};
|
|
115
120
|
|
|
116
121
|
// Helper to validate events have required properties
|
|
117
122
|
// Note: event-type hooks are handled in the fast path (alreadyHooked),
|