@ak--47/dungeon-master 1.5.0 → 1.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/create-dungeon/SKILL.md +139 -46
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +31 -6
- package/.claude/skills/verify-dungeon/references/sql-recipes.md +44 -25
- package/.claude/skills/write-hooks/SKILL.md +31 -3
- package/CHANGELOG.md +85 -0
- package/HOOKS.md +13 -0
- package/dungeons/technical/ad-spend.js +41 -49
- package/dungeons/technical/anonymous-users.js +38 -36
- package/dungeons/technical/array-of-object-lookup.js +136 -153
- package/dungeons/technical/datagen-v15-verify.js +24 -11
- package/dungeons/technical/experiments.js +42 -40
- package/dungeons/technical/foobar.js +114 -118
- package/dungeons/technical/group-analytics.js +42 -40
- package/dungeons/technical/hook-helpers-verify.js +69 -50
- package/dungeons/technical/identity-model-verify.js +22 -12
- package/dungeons/technical/mirror-strategies.js +37 -39
- package/dungeons/technical/nested-objects.js +119 -118
- package/dungeons/technical/pattern-aggregate-by-bin.js +21 -8
- package/dungeons/technical/pattern-attributed-by-source.js +23 -9
- package/dungeons/technical/pattern-frequency-by-frequency.js +21 -8
- package/dungeons/technical/pattern-funnel-frequency.js +30 -15
- package/dungeons/technical/pattern-ttc-by-segment.js +21 -8
- package/dungeons/technical/retention-cadence.js +115 -112
- package/dungeons/technical/sanity.js +86 -80
- package/dungeons/technical/scale-test.js +34 -38
- package/dungeons/technical/scd.js +111 -128
- package/dungeons/technical/simple.js +134 -141
- package/dungeons/technical/simplest.js +54 -62
- package/dungeons/technical/text-generation.js +110 -146
- package/dungeons/vertical/ai-platform.js +296 -333
- package/dungeons/vertical/community.js +284 -255
- package/dungeons/vertical/crypto.js +395 -391
- package/dungeons/vertical/dating.js +411 -378
- package/dungeons/vertical/devtools.js +336 -298
- package/dungeons/vertical/ecommerce.js +316 -394
- package/dungeons/vertical/education.js +369 -325
- package/dungeons/vertical/fintech.js +358 -325
- package/dungeons/vertical/fitness.js +335 -291
- package/dungeons/vertical/food-delivery.js +343 -307
- package/dungeons/vertical/gaming.js +480 -444
- package/dungeons/vertical/healthcare.js +306 -262
- package/dungeons/vertical/insurance-application.js +427 -409
- package/dungeons/vertical/logistics.js +271 -252
- package/dungeons/vertical/marketplace.js +333 -323
- package/dungeons/vertical/media.js +382 -335
- package/dungeons/vertical/real-estate.js +395 -346
- package/dungeons/vertical/sass.js +319 -333
- package/dungeons/vertical/social.js +368 -316
- package/dungeons/vertical/travel.js +297 -295
- package/index.js +46 -4
- package/lib/core/config-validator.js +126 -28
- package/lib/generators/funnels.js +4 -1
- package/lib/orchestrators/mixpanel-sender.js +7 -0
- package/lib/orchestrators/user-loop.js +132 -31
- package/lib/templates/defaults.js +59 -59
- package/lib/templates/macro-presets.js +14 -2
- package/lib/utils/dataset-context.js +103 -0
- package/lib/utils/retention-curve.js +140 -0
- package/lib/utils/utils.js +149 -38
- package/lib/verify/counting.js +40 -0
- package/lib/verify/emulate-breakdown.js +20 -1
- package/lib/verify/index.js +1 -0
- package/lib/verify/schema-validator.js +3 -1
- package/package.json +11 -2
- package/scripts/run-dungeon.mjs +12 -1
- package/types.d.ts +117 -1
package/index.js
CHANGED
|
@@ -25,7 +25,8 @@ import { makeMirror } from './lib/generators/mirror.js';
|
|
|
25
25
|
import { makeGroupProfile, makeProfile } from './lib/generators/profiles.js';
|
|
26
26
|
|
|
27
27
|
// Utilities
|
|
28
|
-
import { initChance, setDatasetNow, setDatasetBegin, deleteFile } from './lib/utils/utils.js';
|
|
28
|
+
import { initChance, initUserChance, resetUserChance, setDatasetNow, setDatasetBegin, deleteFile } from './lib/utils/utils.js';
|
|
29
|
+
import { runWithDataset } from './lib/utils/dataset-context.js';
|
|
29
30
|
|
|
30
31
|
// External dependencies
|
|
31
32
|
import dayjs from "dayjs";
|
|
@@ -130,6 +131,15 @@ async function runDungeon(config) {
|
|
|
130
131
|
if (config.seed) {
|
|
131
132
|
initChance(config.seed);
|
|
132
133
|
}
|
|
134
|
+
// v1.5.1: optional separate user-id RNG. Same userSeed across runs
|
|
135
|
+
// produces the same user pool, regardless of `seed`. When unset, reset
|
|
136
|
+
// any leftover state from a prior in-process run so getUserChance()
|
|
137
|
+
// falls back to the event chance (= pre-v1.5.1 behavior).
|
|
138
|
+
if (config.userSeed) {
|
|
139
|
+
initUserChance(config.userSeed);
|
|
140
|
+
} else {
|
|
141
|
+
resetUserChance();
|
|
142
|
+
}
|
|
133
143
|
|
|
134
144
|
// Step 1: Validate and enrich configuration (resolves dataset window)
|
|
135
145
|
validatedConfig = validateDungeonConfig(config);
|
|
@@ -140,11 +150,20 @@ async function runDungeon(config) {
|
|
|
140
150
|
const fixedBegin = /** @type {number} */ (validatedConfig.datasetStart);
|
|
141
151
|
|
|
142
152
|
// Anchor the wall-clock-free reference used by date()/day() helpers in
|
|
143
|
-
// dungeon configs.
|
|
144
|
-
//
|
|
153
|
+
// dungeon configs. v1.5.1: ALS-scoped via `runWithDataset` below; the
|
|
154
|
+
// legacy `setDatasetNow` / `setDatasetBegin` setters still fire as a
|
|
155
|
+
// back-compat fallback for tests that haven't migrated to the new API.
|
|
145
156
|
setDatasetNow(fixedNow);
|
|
146
157
|
setDatasetBegin(fixedBegin);
|
|
147
158
|
|
|
159
|
+
// v1.5.1: wrap the entire pipeline in an ALS scope so factory thunks
|
|
160
|
+
// (`date`, `day`, `dateRange`, `TimeSoup`, `validTime`) inside dungeon
|
|
161
|
+
// configs resolve the dataset window per-`generate()`-call instead of
|
|
162
|
+
// reading clobberable module state. Concurrent in-process `generate()`
|
|
163
|
+
// calls now run safely with distinct windows. See
|
|
164
|
+
// `lib/utils/dataset-context.js`.
|
|
165
|
+
return await runWithDataset(fixedBegin, fixedNow, async () => {
|
|
166
|
+
|
|
148
167
|
// Step 2: Create context with validated config (pass time constants explicitly)
|
|
149
168
|
const context = createContext(validatedConfig, null, { fixedNow, fixedBegin });
|
|
150
169
|
|
|
@@ -234,7 +253,7 @@ async function runDungeon(config) {
|
|
|
234
253
|
const _t12 = Date.now();
|
|
235
254
|
importResults = await sendToMixpanel(context);
|
|
236
255
|
context.reportProgress({ phase: "step", step: "import", status: "complete", duration: Date.now() - _t12 });
|
|
237
|
-
} else {
|
|
256
|
+
} else if (validatedConfig.verbose) {
|
|
238
257
|
console.warn(
|
|
239
258
|
`⚠️ Skipping Mixpanel import: token "${validatedConfig.token}" does not look like a real Mixpanel project token ` +
|
|
240
259
|
`(expected 32-char hex). Set a real token or pass empty string to skip. ` +
|
|
@@ -251,6 +270,12 @@ async function runDungeon(config) {
|
|
|
251
270
|
|
|
252
271
|
const progressSummary = context.getProgressSummary();
|
|
253
272
|
|
|
273
|
+
// v1.5.1: count of profiles that would be pushed to Mixpanel (non-`_drop`).
|
|
274
|
+
// Anonymous non-converters get `_drop: true` stamped in user-loop.js so
|
|
275
|
+
// mixpanel-sender skips them. `userProfilesData` still holds the full
|
|
276
|
+
// population for downstream tools.
|
|
277
|
+
const profilesPushed = countProfilesPushed(storage.userProfilesData);
|
|
278
|
+
|
|
254
279
|
return {
|
|
255
280
|
...extractedData,
|
|
256
281
|
importResults,
|
|
@@ -259,9 +284,12 @@ async function runDungeon(config) {
|
|
|
259
284
|
operations: context.getOperations(),
|
|
260
285
|
eventCount: context.getStoredEventCount(),
|
|
261
286
|
userCount: context.getUserCount(),
|
|
287
|
+
profilesPushed,
|
|
262
288
|
...(progressSummary.updates > 0 || progressSummary.errors > 0 ? { progress: progressSummary } : {})
|
|
263
289
|
};
|
|
264
290
|
|
|
291
|
+
}); // end runWithDataset
|
|
292
|
+
|
|
265
293
|
} catch (error) {
|
|
266
294
|
logger.error({ err: error }, `Error: ${error.message}`);
|
|
267
295
|
throw error;
|
|
@@ -535,6 +563,20 @@ function extractFileInfo(storage) {
|
|
|
535
563
|
return collectWrittenFiles(storage);
|
|
536
564
|
}
|
|
537
565
|
|
|
566
|
+
/**
|
|
567
|
+
* Count profiles that would be pushed to Mixpanel (anonymous non-converters carry
|
|
568
|
+
* `_drop: true` and are skipped by mixpanel-sender). v1.5.1.
|
|
569
|
+
* @param {any} profilesContainer
|
|
570
|
+
* @returns {number}
|
|
571
|
+
*/
|
|
572
|
+
function countProfilesPushed(profilesContainer) {
|
|
573
|
+
if (!profilesContainer) return 0;
|
|
574
|
+
const arr = Array.isArray(profilesContainer) ? profilesContainer : Array.from(profilesContainer);
|
|
575
|
+
let n = 0;
|
|
576
|
+
for (const p of arr) if (p && !p._drop) n++;
|
|
577
|
+
return n;
|
|
578
|
+
}
|
|
579
|
+
|
|
538
580
|
/**
|
|
539
581
|
* Extract data from storage containers, preserving array structure for groups/lookups/SCDs
|
|
540
582
|
* @param {import('./types').Storage} storage - Storage object
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
/** @typedef {import('../../types.js').Funnel} Funnel */
|
|
10
10
|
|
|
11
11
|
import dayjs from "dayjs";
|
|
12
|
+
import utc from "dayjs/plugin/utc.js";
|
|
13
|
+
dayjs.extend(utc);
|
|
12
14
|
import { makeName } from "ak-tools";
|
|
13
15
|
import * as u from "../utils/utils.js";
|
|
14
16
|
import { resolveSoup } from "../templates/soup-presets.js";
|
|
@@ -42,8 +44,8 @@ function resolveDatasetWindow(datasetStart, datasetEnd, userNumDays, verbose = f
|
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
if (hasStart && hasEnd) {
|
|
45
|
-
const startUnix = parseToUnix(datasetStart, 'datasetStart');
|
|
46
|
-
const endUnix = parseToUnix(datasetEnd, 'datasetEnd');
|
|
47
|
+
const startUnix = parseToUnix(datasetStart, 'datasetStart', false);
|
|
48
|
+
const endUnix = parseToUnix(datasetEnd, 'datasetEnd', true);
|
|
47
49
|
if (endUnix <= startUnix) {
|
|
48
50
|
throw new Error(`datasetEnd (${datasetEnd}) must be after datasetStart (${datasetStart}).`);
|
|
49
51
|
}
|
|
@@ -69,11 +71,22 @@ function resolveDatasetWindow(datasetStart, datasetEnd, userNumDays, verbose = f
|
|
|
69
71
|
/**
|
|
70
72
|
* Parse a value (ISO string, unix seconds, dayjs-parseable) into unix seconds.
|
|
71
73
|
* Throws if the value can't be parsed into a valid date.
|
|
74
|
+
*
|
|
75
|
+
* Bare-date convention (`YYYY-MM-DD` with no time component): pinned to UTC and
|
|
76
|
+
* interpreted as the user's intuitive intent — start of UTC day for the start
|
|
77
|
+
* boundary, end of UTC day for the end boundary. Without this rule, `dayjs()`
|
|
78
|
+
* would parse bare dates in the local timezone (cross-machine non-determinism)
|
|
79
|
+
* and treat both as start-of-day (datasetEnd would truncate ~16 hours).
|
|
80
|
+
*
|
|
81
|
+
* Full ISO strings with explicit time (`2026-05-10T23:59:59Z`,
|
|
82
|
+
* `2026-05-10T15:30:00-08:00`, etc.) are trusted as-is and parsed in UTC.
|
|
83
|
+
*
|
|
72
84
|
* @param {*} value
|
|
73
85
|
* @param {string} fieldName
|
|
86
|
+
* @param {boolean} [isEnd=false] - true treats bare-date strings as end-of-UTC-day
|
|
74
87
|
* @returns {number}
|
|
75
88
|
*/
|
|
76
|
-
function parseToUnix(value, fieldName) {
|
|
89
|
+
function parseToUnix(value, fieldName, isEnd = false) {
|
|
77
90
|
// Treat numbers as unix seconds (or unix milliseconds if too large)
|
|
78
91
|
if (typeof value === 'number') {
|
|
79
92
|
if (!Number.isFinite(value) || value <= 0) {
|
|
@@ -82,7 +95,16 @@ function parseToUnix(value, fieldName) {
|
|
|
82
95
|
// Heuristic: > 10^12 means milliseconds, otherwise seconds
|
|
83
96
|
return value > 1e12 ? Math.floor(value / 1000) : Math.floor(value);
|
|
84
97
|
}
|
|
85
|
-
|
|
98
|
+
// Bare YYYY-MM-DD → UTC start/end of day (intuitive convention)
|
|
99
|
+
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
100
|
+
const d = dayjs.utc(value);
|
|
101
|
+
if (!d.isValid()) {
|
|
102
|
+
throw new Error(`${fieldName} could not be parsed as a date (got ${JSON.stringify(value)}).`);
|
|
103
|
+
}
|
|
104
|
+
return (isEnd ? d.endOf('day') : d.startOf('day')).unix();
|
|
105
|
+
}
|
|
106
|
+
// Full ISO string (with time component) or other dayjs-parseable input — UTC parse
|
|
107
|
+
const parsed = dayjs.utc(value);
|
|
86
108
|
if (!parsed.isValid()) {
|
|
87
109
|
throw new Error(`${fieldName} could not be parsed as a date (got ${JSON.stringify(value)}).`);
|
|
88
110
|
}
|
|
@@ -152,6 +174,63 @@ function inferFunnels(events) {
|
|
|
152
174
|
*/
|
|
153
175
|
const KILLED_CONFIG_KEYS = ['subscription', 'attribution', 'geo', 'features', 'anomalies'];
|
|
154
176
|
|
|
177
|
+
// v1.5.1 (TODO #8): config restructure. Map sub-object → list of keys that can
|
|
178
|
+
// live in that sub-object. Sub-object value wins ONLY when the top-level key
|
|
179
|
+
// isn't set; if both are set, the top-level value wins and a verbose warning
|
|
180
|
+
// fires (one per offending key). Old top-level keys remain functional through
|
|
181
|
+
// v1.5.1 — migration is gradual.
|
|
182
|
+
const CONFIG_SUBOBJECTS = {
|
|
183
|
+
credentials: ['token', 'region', 'serviceAccount', 'serviceSecret', 'projectId'],
|
|
184
|
+
switches: ['hasLocation', 'hasCampaigns', 'hasAdSpend', 'hasSessionIds', 'hasAvatar', 'hasIOSDevices', 'hasAndroidDevices', 'hasDesktopDevices', 'hasBrowser', 'isAnonymous', 'alsoInferFunnels', 'hasAttributionFlags'],
|
|
185
|
+
identity: ['avgDevicePerUser', 'sessionTimeout'],
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* v1.5.1 (TODO #8): merge `credentials` / `switches` / `identity` sub-objects
|
|
190
|
+
* into top-level keys. Returns a NEW config object — does not mutate input.
|
|
191
|
+
*
|
|
192
|
+
* Resolution semantics:
|
|
193
|
+
* - sub-object key set, top-level NOT set → use sub-object value
|
|
194
|
+
* - sub-object key set, top-level set → top-level wins + warn (verbose)
|
|
195
|
+
* - sub-object missing, top-level set → use top-level (back-compat)
|
|
196
|
+
*
|
|
197
|
+
* Special case: `identity.hasAnonIds` → deprecated. If present, maps to
|
|
198
|
+
* `avgDevicePerUser: 1` and emits a verbose warning. Top-level `hasAnonIds`
|
|
199
|
+
* preserved (legacy alias) and untouched here.
|
|
200
|
+
*
|
|
201
|
+
* @param {Partial<Dungeon>} config
|
|
202
|
+
* @returns {Partial<Dungeon>}
|
|
203
|
+
*/
|
|
204
|
+
function mergeConfigSubObjects(config) {
|
|
205
|
+
if (!config || typeof config !== 'object') return config;
|
|
206
|
+
const verbose = config.verbose === true;
|
|
207
|
+
const out = { ...config };
|
|
208
|
+
for (const [subKey, fields] of Object.entries(CONFIG_SUBOBJECTS)) {
|
|
209
|
+
const sub = out[subKey];
|
|
210
|
+
if (!sub || typeof sub !== 'object') continue;
|
|
211
|
+
for (const field of fields) {
|
|
212
|
+
if (sub[field] === undefined) continue;
|
|
213
|
+
if (out[field] === undefined) {
|
|
214
|
+
out[field] = sub[field];
|
|
215
|
+
} else if (verbose) {
|
|
216
|
+
console.warn(
|
|
217
|
+
`⚠️ config.${field} and config.${subKey}.${field} both set; ` +
|
|
218
|
+
`top-level wins. Drop config.${subKey}.${field} or remove the top-level value.`
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// identity.hasAnonIds: deprecated alias.
|
|
224
|
+
if (out.identity && typeof out.identity === 'object' && out.identity.hasAnonIds !== undefined) {
|
|
225
|
+
if (verbose) console.warn(
|
|
226
|
+
`⚠️ identity.hasAnonIds is deprecated. Use identity.avgDevicePerUser: 1 instead. ` +
|
|
227
|
+
`Falling back to avgDevicePerUser=1 for v1.5.1.`
|
|
228
|
+
);
|
|
229
|
+
if (out.avgDevicePerUser === undefined) out.avgDevicePerUser = 1;
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
|
|
155
234
|
/**
|
|
156
235
|
* Strip killed config keys in place, log one deprecation warning per dungeon.
|
|
157
236
|
* @param {Partial<Dungeon>} config
|
|
@@ -179,7 +258,7 @@ function stripKilledConfigKeys(config) {
|
|
|
179
258
|
*
|
|
180
259
|
* @param {import('../../types.js').Funnel[]} funnels
|
|
181
260
|
*/
|
|
182
|
-
function validateConversionWindow(funnels) {
|
|
261
|
+
function validateConversionWindow(funnels, verbose = false) {
|
|
183
262
|
const DEFAULT_WINDOW_DAYS = 30;
|
|
184
263
|
const MAX_WINDOW_DAYS = 180;
|
|
185
264
|
for (const f of funnels) {
|
|
@@ -189,7 +268,7 @@ function validateConversionWindow(funnels) {
|
|
|
189
268
|
if (f.conversionWindowDays === undefined || f.conversionWindowDays === null) {
|
|
190
269
|
if (ttcDays >= DEFAULT_WINDOW_DAYS) {
|
|
191
270
|
f.conversionWindowDays = Math.min(MAX_WINDOW_DAYS, Math.ceil(ttcDays * 1.5));
|
|
192
|
-
console.warn(
|
|
271
|
+
if (verbose) console.warn(
|
|
193
272
|
`⚠️ Funnel "${f.name || (f.sequence && f.sequence.join(' > '))}": ` +
|
|
194
273
|
`timeToConvert (${ttcDays.toFixed(1)}d) exceeds default 30d conversion window. ` +
|
|
195
274
|
`Auto-set conversionWindowDays=${f.conversionWindowDays}. Set explicitly to silence.`
|
|
@@ -224,7 +303,7 @@ function validateConversionWindow(funnels) {
|
|
|
224
303
|
* @param {import('../../types.js').Funnel[]} funnels
|
|
225
304
|
* @param {Array<{event?: string}>} events
|
|
226
305
|
*/
|
|
227
|
-
function validateExclusionEvents(funnels, events) {
|
|
306
|
+
function validateExclusionEvents(funnels, events, verbose = false) {
|
|
228
307
|
if (!Array.isArray(funnels) || !Array.isArray(events)) return;
|
|
229
308
|
const eventNames = new Set(events.map(e => e && e.event).filter(Boolean));
|
|
230
309
|
for (const f of funnels) {
|
|
@@ -237,7 +316,7 @@ function validateExclusionEvents(funnels, events) {
|
|
|
237
316
|
`Add it as an event (schema-first) before referencing it as an exclusion.`
|
|
238
317
|
);
|
|
239
318
|
}
|
|
240
|
-
if (Array.isArray(f.sequence) && f.sequence.includes(name)) {
|
|
319
|
+
if (verbose && Array.isArray(f.sequence) && f.sequence.includes(name)) {
|
|
241
320
|
console.warn(
|
|
242
321
|
`⚠️ Funnel "${f.name || (f.sequence && f.sequence.join(' > '))}": ` +
|
|
243
322
|
`exclusion event "${name}" is also a funnel step — semantics are ambiguous.`
|
|
@@ -332,6 +411,13 @@ function resolveDevicesPerUser(config) {
|
|
|
332
411
|
export function validateDungeonConfig(config) {
|
|
333
412
|
const chance = u.getChance();
|
|
334
413
|
|
|
414
|
+
// v1.5.1 (TODO #8): merge `switches` / `identity` / `credentials`
|
|
415
|
+
// sub-objects into top-level keys. New shape is preferred; old top-level
|
|
416
|
+
// keys still honored for back-compat with a verbose-gated warning. The
|
|
417
|
+
// rest of the validator reads from flat keys, so this is a transparent
|
|
418
|
+
// normalization step.
|
|
419
|
+
config = mergeConfigSubObjects(config);
|
|
420
|
+
|
|
335
421
|
// Phase 1 — strip killed config keys before anything else reads them.
|
|
336
422
|
stripKilledConfigKeys(config);
|
|
337
423
|
|
|
@@ -456,13 +542,18 @@ export function validateDungeonConfig(config) {
|
|
|
456
542
|
// `avgActiveDaysPerUser` is a CONCENTRATOR — total event count is preserved
|
|
457
543
|
// (`avgEventsPerUserPerDay × numDays`), but events cluster onto fewer days.
|
|
458
544
|
// The implied per-active-day rate inflates: warn when it exceeds 50.
|
|
459
|
-
|
|
545
|
+
// v1.5.1: each macro preset (except `flat`) ships a sensible default —
|
|
546
|
+
// applied only when the dungeon doesn't set the field explicitly.
|
|
547
|
+
const macroResolvedEarly = resolveMacro(config.macro);
|
|
548
|
+
const avgActiveDaysPerUser = (config.avgActiveDaysPerUser !== undefined && config.avgActiveDaysPerUser !== null)
|
|
549
|
+
? config.avgActiveDaysPerUser
|
|
550
|
+
: macroResolvedEarly.avgActiveDaysPerUser;
|
|
460
551
|
if (avgActiveDaysPerUser !== undefined && avgActiveDaysPerUser !== null) {
|
|
461
552
|
if (!Number.isFinite(avgActiveDaysPerUser) || avgActiveDaysPerUser <= 0) {
|
|
462
553
|
throw new Error(`avgActiveDaysPerUser must be a positive finite number (got ${avgActiveDaysPerUser})`);
|
|
463
554
|
}
|
|
464
555
|
const impliedRatePerActiveDay = (avgEventsPerUserPerDay * numDays) / avgActiveDaysPerUser;
|
|
465
|
-
if (impliedRatePerActiveDay > 50) {
|
|
556
|
+
if (verbose && impliedRatePerActiveDay > 50) {
|
|
466
557
|
console.warn(
|
|
467
558
|
`⚠️ avgActiveDaysPerUser=${avgActiveDaysPerUser} concentrates ` +
|
|
468
559
|
`${Math.round(avgEventsPerUserPerDay * numDays).toLocaleString()} events into ${avgActiveDaysPerUser} day(s) ` +
|
|
@@ -489,8 +580,9 @@ export function validateDungeonConfig(config) {
|
|
|
489
580
|
// bornRecentBias / percentUsersBornInDataset / preExistingSpread on the
|
|
490
581
|
// dungeon config win over the macro preset's values, so existing dungeons
|
|
491
582
|
// that set these explicitly continue to render the same way.
|
|
492
|
-
// Resolve into local vars (do NOT mutate input config).
|
|
493
|
-
|
|
583
|
+
// Resolve into local vars (do NOT mutate input config). v1.5.1: reuse the
|
|
584
|
+
// early-resolved macro from the avgActiveDaysPerUser merge above.
|
|
585
|
+
const macroResolved = macroResolvedEarly;
|
|
494
586
|
let bornRecentBias = config.bornRecentBias !== undefined ? config.bornRecentBias : macroResolved.bornRecentBias;
|
|
495
587
|
let percentUsersBornInDataset = config.percentUsersBornInDataset !== undefined ? config.percentUsersBornInDataset : macroResolved.percentUsersBornInDataset;
|
|
496
588
|
let preExistingSpread = config.preExistingSpread !== undefined ? config.preExistingSpread : macroResolved.preExistingSpread;
|
|
@@ -526,11 +618,11 @@ export function validateDungeonConfig(config) {
|
|
|
526
618
|
percentUsersBornInDataset = 0;
|
|
527
619
|
}
|
|
528
620
|
if (percentUsersBornInDataset > 100) {
|
|
529
|
-
console.warn(`⚠️ percentUsersBornInDataset=${percentUsersBornInDataset} clamped to 100. Values above 100 are not meaningful.`);
|
|
621
|
+
if (verbose) console.warn(`⚠️ percentUsersBornInDataset=${percentUsersBornInDataset} clamped to 100. Values above 100 are not meaningful.`);
|
|
530
622
|
percentUsersBornInDataset = 100;
|
|
531
623
|
}
|
|
532
624
|
if (percentUsersBornInDataset < 0) {
|
|
533
|
-
console.warn(`⚠️ percentUsersBornInDataset=${percentUsersBornInDataset} clamped to 0. Negative values are not meaningful.`);
|
|
625
|
+
if (verbose) console.warn(`⚠️ percentUsersBornInDataset=${percentUsersBornInDataset} clamped to 0. Negative values are not meaningful.`);
|
|
534
626
|
percentUsersBornInDataset = 0;
|
|
535
627
|
}
|
|
536
628
|
|
|
@@ -552,7 +644,7 @@ export function validateDungeonConfig(config) {
|
|
|
552
644
|
if (userBornExplicit && macroExplicit && MACRO_BORN_CAP[macroKey] !== undefined) {
|
|
553
645
|
const cap = MACRO_BORN_CAP[macroKey];
|
|
554
646
|
if (percentUsersBornInDataset > cap) {
|
|
555
|
-
console.warn(
|
|
647
|
+
if (verbose) console.warn(
|
|
556
648
|
`⚠️ macro="${macroKey}" + percentUsersBornInDataset=${percentUsersBornInDataset} ` +
|
|
557
649
|
`clamped to ${cap}. High born% with macro="${macroKey}" produces cumulative-acquisition ` +
|
|
558
650
|
`right-edge explosion. Use macro="growth" or "viral" for genuinely high-born configs. ` +
|
|
@@ -566,11 +658,11 @@ export function validateDungeonConfig(config) {
|
|
|
566
658
|
// on user-set values — viral preset (0.6) is allowed by design.
|
|
567
659
|
if (userBiasExplicit) {
|
|
568
660
|
if (bornRecentBias > 0.5) {
|
|
569
|
-
console.warn(`⚠️ bornRecentBias=${bornRecentBias} clamped to 0.5. Above 0.5 produces unusable right-skew. To suppress, fix the config.`);
|
|
661
|
+
if (verbose) console.warn(`⚠️ bornRecentBias=${bornRecentBias} clamped to 0.5. Above 0.5 produces unusable right-skew. To suppress, fix the config.`);
|
|
570
662
|
bornRecentBias = 0.5;
|
|
571
663
|
}
|
|
572
664
|
if (bornRecentBias < -0.5) {
|
|
573
|
-
console.warn(`⚠️ bornRecentBias=${bornRecentBias} clamped to -0.5. Below -0.5 produces unusable left-skew. To suppress, fix the config.`);
|
|
665
|
+
if (verbose) console.warn(`⚠️ bornRecentBias=${bornRecentBias} clamped to -0.5. Below -0.5 produces unusable left-skew. To suppress, fix the config.`);
|
|
574
666
|
bornRecentBias = -0.5;
|
|
575
667
|
}
|
|
576
668
|
}
|
|
@@ -578,7 +670,7 @@ export function validateDungeonConfig(config) {
|
|
|
578
670
|
// Clamp 4: bias × born compound check (only on explicit user values).
|
|
579
671
|
// Plan PROMPT.md: "born > 80 + bias > 0.4 → clamp bias to 0.3".
|
|
580
672
|
if ((userBornExplicit || userBiasExplicit) && percentUsersBornInDataset > 60 && bornRecentBias > 0.4) {
|
|
581
|
-
console.warn(
|
|
673
|
+
if (verbose) console.warn(
|
|
582
674
|
`⚠️ percentUsersBornInDataset=${percentUsersBornInDataset} + bornRecentBias=${bornRecentBias} ` +
|
|
583
675
|
`compounds to right-edge explosion. Clamping bornRecentBias to 0.3. To suppress, fix the config.`
|
|
584
676
|
);
|
|
@@ -591,7 +683,7 @@ export function validateDungeonConfig(config) {
|
|
|
591
683
|
// Clamp 5: avgEventsPerUserPerDay safe range. Above 50 produces unrealistic
|
|
592
684
|
// load + memory cost. Plan PROMPT.md: clamp to 50.
|
|
593
685
|
if (Number.isFinite(avgEventsPerUserPerDay) && avgEventsPerUserPerDay > 50) {
|
|
594
|
-
console.warn(`⚠️ avgEventsPerUserPerDay=${avgEventsPerUserPerDay} clamped to 50. Above 50 is unrealistic load + memory cost. To suppress, fix the config.`);
|
|
686
|
+
if (verbose) console.warn(`⚠️ avgEventsPerUserPerDay=${avgEventsPerUserPerDay} clamped to 50. Above 50 is unrealistic load + memory cost. To suppress, fix the config.`);
|
|
595
687
|
avgEventsPerUserPerDay = 50;
|
|
596
688
|
numEvents = Math.round(avgEventsPerUserPerDay * numUsers * numDays);
|
|
597
689
|
}
|
|
@@ -601,7 +693,7 @@ export function validateDungeonConfig(config) {
|
|
|
601
693
|
let avgActiveDaysClamped = avgActiveDaysPerUser;
|
|
602
694
|
if (avgActiveDaysPerUser !== undefined && avgActiveDaysPerUser !== null && avgActiveDaysPerUser > numDays * 0.5) {
|
|
603
695
|
const cap = Math.max(1, Math.floor(numDays * 0.5));
|
|
604
|
-
console.warn(`⚠️ avgActiveDaysPerUser=${avgActiveDaysPerUser} > numDays/2 (${numDays}/2); clamped to ${cap}. Above 50% defeats the concentrator purpose. To suppress, fix the config.`);
|
|
696
|
+
if (verbose) console.warn(`⚠️ avgActiveDaysPerUser=${avgActiveDaysPerUser} > numDays/2 (${numDays}/2); clamped to ${cap}. Above 50% defeats the concentrator purpose. To suppress, fix the config.`);
|
|
605
697
|
avgActiveDaysClamped = cap;
|
|
606
698
|
}
|
|
607
699
|
|
|
@@ -610,7 +702,7 @@ export function validateDungeonConfig(config) {
|
|
|
610
702
|
// already been resolved upstream — clamping numDays alone would desync the
|
|
611
703
|
// engine. Pre-validator numDays bound is preferred (validator throws on
|
|
612
704
|
// numDays <= 0 already at line ~422). Just warn for visibility.
|
|
613
|
-
if (numDays < 14) {
|
|
705
|
+
if (verbose && numDays < 14) {
|
|
614
706
|
console.warn(`⚠️ numDays=${numDays} < 14. Strict-bar engine-validation metrics use 14-day windows; results are noisy below 14 days. Consider increasing numDays.`);
|
|
615
707
|
}
|
|
616
708
|
// ──────────────────────────────────────────────────────────────────────
|
|
@@ -686,7 +778,7 @@ export function validateDungeonConfig(config) {
|
|
|
686
778
|
if (ev.isStrictEvent === false) continue; // explicit opt-out
|
|
687
779
|
if (ev.isStrictEvent === true) continue; // already set
|
|
688
780
|
ev.isStrictEvent = true;
|
|
689
|
-
console.warn(
|
|
781
|
+
if (verbose) console.warn(
|
|
690
782
|
`⚠️ Auto-promoted "${ev.event}" to isStrictEvent: true (appears as a funnel step). ` +
|
|
691
783
|
`Set isStrictEvent: false to opt out and allow standalone instances.`
|
|
692
784
|
);
|
|
@@ -782,10 +874,10 @@ export function validateDungeonConfig(config) {
|
|
|
782
874
|
validateAttempts(funnels);
|
|
783
875
|
|
|
784
876
|
// v1.5: default + auto-bump Funnel.conversionWindowDays.
|
|
785
|
-
validateConversionWindow(funnels);
|
|
877
|
+
validateConversionWindow(funnels, verbose);
|
|
786
878
|
|
|
787
879
|
// v1.5.0: validate Funnel.exclusionEvents — entries must exist in events[].
|
|
788
|
-
validateExclusionEvents(funnels, validatedEvents);
|
|
880
|
+
validateExclusionEvents(funnels, validatedEvents, verbose);
|
|
789
881
|
|
|
790
882
|
// Normalize experiment configs: true → default 3-variant, object → validated.
|
|
791
883
|
normalizeExperiments(funnels, datasetEndUnix);
|
|
@@ -875,6 +967,12 @@ export function validateDungeonConfig(config) {
|
|
|
875
967
|
avgActiveDaysPerUser: avgActiveDaysClamped !== undefined && avgActiveDaysClamped !== null
|
|
876
968
|
? avgActiveDaysClamped
|
|
877
969
|
: undefined,
|
|
970
|
+
// v1.5.1 retention curve (`day1`/`day7`/`day30` etc. anchor weights). When
|
|
971
|
+
// set, `buildActiveDayPlan` biases day selection by the curve and the
|
|
972
|
+
// effective `avgActiveDaysPerUser` is derived from the curve's sum.
|
|
973
|
+
retentionCurve: (typeof config.retentionCurve === 'object' && config.retentionCurve !== null)
|
|
974
|
+
? config.retentionCurve
|
|
975
|
+
: undefined,
|
|
878
976
|
// v1.5 attribution touchpoint cap (Mixpanel TOUCHPOINTS_LIMIT = 10).
|
|
879
977
|
maxTouchpointsPerUser,
|
|
880
978
|
// v1.5 auto-sort after everything hook. Default true. Opt out with explicit `false`.
|
|
@@ -932,7 +1030,7 @@ function transformSCDPropsWithoutCredentials(config) {
|
|
|
932
1030
|
}
|
|
933
1031
|
|
|
934
1032
|
// UI job without credentials - convert SCD props to regular props
|
|
935
|
-
if (config.verbose
|
|
1033
|
+
if (config.verbose === true) console.log('\u26a0\ufe0f Service account credentials missing - converting SCD properties to static properties');
|
|
936
1034
|
|
|
937
1035
|
// Ensure userProps and groupProps exist
|
|
938
1036
|
if (!config.userProps) config.userProps = {};
|
|
@@ -951,20 +1049,20 @@ function transformSCDPropsWithoutCredentials(config) {
|
|
|
951
1049
|
if (type === "user") {
|
|
952
1050
|
// Add to userProps
|
|
953
1051
|
config.userProps[propKey] = values;
|
|
954
|
-
if (config.verbose
|
|
1052
|
+
if (config.verbose === true) console.log(` \u2713 Converted user SCD property: ${propKey}`);
|
|
955
1053
|
} else {
|
|
956
1054
|
// Add to groupProps for the specific group type
|
|
957
1055
|
if (!config.groupProps[type]) {
|
|
958
1056
|
config.groupProps[type] = {};
|
|
959
1057
|
}
|
|
960
1058
|
config.groupProps[type][propKey] = values;
|
|
961
|
-
if (config.verbose
|
|
1059
|
+
if (config.verbose === true) console.log(` \u2713 Converted group SCD property: ${propKey} (${type})`);
|
|
962
1060
|
}
|
|
963
1061
|
}
|
|
964
1062
|
|
|
965
1063
|
// Clear out scdProps since we've converted everything
|
|
966
1064
|
config.scdProps = {};
|
|
967
|
-
if (config.verbose
|
|
1065
|
+
if (config.verbose === true) console.log('\u2713 SCD properties converted to static properties\n');
|
|
968
1066
|
}
|
|
969
1067
|
|
|
970
1068
|
// ── Advanced Feature Validation Functions ──
|
|
@@ -325,7 +325,10 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
|
|
|
325
325
|
);
|
|
326
326
|
|
|
327
327
|
// Compute the auth-time of the actual stitch event in execution order, if any.
|
|
328
|
-
|
|
328
|
+
// Skip when the stitch event itself is _drop'd (e.g. born-late users whose auth
|
|
329
|
+
// event lands past FIXED_NOW and gets filtered out): the user has no real auth
|
|
330
|
+
// event, so userAuthTimeMs must stay null to keep downstream stamping consistent.
|
|
331
|
+
const authTimeMs = runAuthExecIdx >= 0 && finalEvents[runAuthExecIdx] && !finalEvents[runAuthExecIdx]._drop
|
|
329
332
|
? Date.parse(finalEvents[runAuthExecIdx].time) || null
|
|
330
333
|
: null;
|
|
331
334
|
|
|
@@ -110,10 +110,17 @@ export async function sendToMixpanel(context) {
|
|
|
110
110
|
const files = userProfilesData.getWrittenFiles();
|
|
111
111
|
if (files.length > 0) userProfilesToImport = files;
|
|
112
112
|
}
|
|
113
|
+
// v1.5.1: skip `_drop` profiles (anonymous non-converters — see user-loop.js).
|
|
114
|
+
// In-memory path: filter the array. Disk path: `transformFunc` filters
|
|
115
|
+
// per-record as mixpanel-import streams the files.
|
|
116
|
+
if (Array.isArray(userProfilesToImport)) {
|
|
117
|
+
userProfilesToImport = userProfilesToImport.filter(p => !p || !p._drop);
|
|
118
|
+
}
|
|
113
119
|
const userTotal = Array.isArray(userProfilesToImport) ? userProfilesToImport.length : 0;
|
|
114
120
|
const imported = await mp(creds, userProfilesToImport, {
|
|
115
121
|
recordType: "user",
|
|
116
122
|
...commonOpts,
|
|
123
|
+
transformFunc: (record) => (record && record._drop) ? {} : record,
|
|
117
124
|
progressCallback: makeProgressCallback(userTotal),
|
|
118
125
|
});
|
|
119
126
|
log(` -> ${comma(imported.success)} user profiles sent\n`);
|