@ak--47/dungeon-master 1.6.4 → 1.7.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 +201 -0
- package/HOOKS.md +49 -0
- package/README.md +129 -8
- package/docs/guides/1.7.0-upgrade-guide.md +154 -0
- package/index.js +16 -1
- package/lib/core/config-validator.js +243 -13
- package/lib/core/context.js +39 -0
- package/lib/core/dungeon-loader.js +4 -1
- package/lib/generators/events.js +53 -7
- package/lib/generators/funnels.js +85 -11
- package/lib/generators/profiles.js +9 -4
- package/lib/orchestrators/mixpanel-sender.js +13 -1
- package/lib/orchestrators/user-loop.js +239 -9
- package/lib/utils/conditions.js +62 -0
- package/lib/utils/json-evaluator.js +12 -2
- package/lib/utils/utils.js +78 -7
- package/lib/verify/schema-validator.js +8 -0
- package/package.json +2 -10
- package/types.d.ts +303 -34
package/index.js
CHANGED
|
@@ -27,7 +27,7 @@ import { makeMirror } from './lib/generators/mirror.js';
|
|
|
27
27
|
import { makeGroupProfile, makeProfile } from './lib/generators/profiles.js';
|
|
28
28
|
|
|
29
29
|
// Utilities
|
|
30
|
-
import { initChance, initUserChance, resetUserChance, resetValueCaches, setDatasetNow, setDatasetBegin, deleteFile } from './lib/utils/utils.js';
|
|
30
|
+
import { initChance, initUserChance, resetUserChance, resetValueCaches, setAutoPowerLaw, setDatasetNow, setDatasetBegin, deleteFile } from './lib/utils/utils.js';
|
|
31
31
|
import { runWithDataset } from './lib/utils/dataset-context.js';
|
|
32
32
|
|
|
33
33
|
// External dependencies
|
|
@@ -150,6 +150,12 @@ async function runDungeon(config) {
|
|
|
150
150
|
// Step 1: Validate and enrich configuration (resolves dataset window)
|
|
151
151
|
validatedConfig = validateDungeonConfig(config);
|
|
152
152
|
|
|
153
|
+
// v1.7.0 (P2-1): `autoPowerLaw: false` turns off the implicit 45/25/15 draw on
|
|
154
|
+
// 3–19-item string arrays for this run. Module-level flag, like the seeded
|
|
155
|
+
// chance instance — `choose()` has no config access. resetValueCaches()
|
|
156
|
+
// above already restored the default (true) for this run.
|
|
157
|
+
setAutoPowerLaw(validatedConfig.autoPowerLaw !== false);
|
|
158
|
+
|
|
153
159
|
// validateDungeonConfig always resolves these to unix seconds, but the
|
|
154
160
|
// public Dungeon type accepts string | number on input. Narrow here.
|
|
155
161
|
const fixedNow = /** @type {number} */ (validatedConfig.datasetEnd);
|
|
@@ -282,9 +288,18 @@ async function runDungeon(config) {
|
|
|
282
288
|
// population for downstream tools.
|
|
283
289
|
const profilesPushed = countProfilesPushed(storage.userProfilesData);
|
|
284
290
|
|
|
291
|
+
// v1.7.0 (P2-2): every value the engine changed or flagged, validator
|
|
292
|
+
// clamps first, then aggregated runtime warnings (conversionRate saturation,
|
|
293
|
+
// users matching no funnel, …). Always present, even when empty.
|
|
294
|
+
const warnings = [
|
|
295
|
+
...(Array.isArray(validatedConfig._warnings) ? validatedConfig._warnings : []),
|
|
296
|
+
...context.getWarnings(),
|
|
297
|
+
];
|
|
298
|
+
|
|
285
299
|
return {
|
|
286
300
|
...extractedData,
|
|
287
301
|
importResults,
|
|
302
|
+
warnings,
|
|
288
303
|
files: extractFileInfo(storage),
|
|
289
304
|
time: { start, end, delta, human },
|
|
290
305
|
operations: context.getOperations(),
|
|
@@ -15,6 +15,45 @@ import { makeName } from "ak-tools";
|
|
|
15
15
|
import * as u from "../utils/utils.js";
|
|
16
16
|
import { resolveSoup } from "../templates/soup-presets.js";
|
|
17
17
|
import { resolveMacro } from "../templates/macro-presets.js";
|
|
18
|
+
import { locations as LOCATION_TEMPLATE } from "../templates/defaults.js";
|
|
19
|
+
import { CONDITION_OPERATORS } from "../utils/conditions.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* v1.7.0 (P2-2): one entry per value the validator changed or flagged. Collected
|
|
23
|
+
* on `validatedConfig._warnings` and surfaced as `result.warnings` regardless of
|
|
24
|
+
* `verbose`. Console output stays `verbose`-gated; a config UI needs the data,
|
|
25
|
+
* not the log line.
|
|
26
|
+
*
|
|
27
|
+
* @typedef {import('../../types.js').EngineWarning} EngineWarning
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve `singleCountry` to the canonical country name used by the location
|
|
32
|
+
* template. Accepts the full name (`"United States"`) or the ISO code (`"US"`),
|
|
33
|
+
* case-insensitive. Throws when nothing matches — an empty location pool
|
|
34
|
+
* silently deleted every geo property from events and profiles before 1.7.0.
|
|
35
|
+
*
|
|
36
|
+
* @param {unknown} singleCountry
|
|
37
|
+
* @returns {string | undefined} canonical `country` value, or undefined when unset
|
|
38
|
+
*/
|
|
39
|
+
export function resolveSingleCountry(singleCountry) {
|
|
40
|
+
if (singleCountry === undefined || singleCountry === null || singleCountry === '') return undefined;
|
|
41
|
+
if (typeof singleCountry !== 'string') {
|
|
42
|
+
throw new Error(`singleCountry must be a country name or ISO code string (got ${typeof singleCountry})`);
|
|
43
|
+
}
|
|
44
|
+
const needle = singleCountry.trim().toLowerCase();
|
|
45
|
+
const hit = LOCATION_TEMPLATE.find(l =>
|
|
46
|
+
String(l.country).toLowerCase() === needle || String(l.country_code).toLowerCase() === needle
|
|
47
|
+
);
|
|
48
|
+
if (!hit) {
|
|
49
|
+
const valid = [...new Map(LOCATION_TEMPLATE.map(l => [l.country_code, `${l.country_code} (${l.country})`])).values()];
|
|
50
|
+
throw new Error(
|
|
51
|
+
`singleCountry "${singleCountry}" matches no country in the location template, so the location pool would be empty ` +
|
|
52
|
+
`and every geo property would vanish. Valid values (name or code): ${valid.join(', ')}`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return String(hit.country);
|
|
56
|
+
}
|
|
18
57
|
|
|
19
58
|
/**
|
|
20
59
|
* Resolve dataset window from config. Returns { datasetStartUnix, datasetEndUnix, numDays }.
|
|
@@ -185,7 +224,9 @@ const CONFIG_SUBOBJECTS = {
|
|
|
185
224
|
// settable — line ~942 unconditionally overwrites it with
|
|
186
225
|
// `validatedEvents.some(e => e.isAttributionEvent)`. Listing it here presented
|
|
187
226
|
// a knob that never did anything.
|
|
188
|
-
|
|
227
|
+
// v1.7.0: `singleCountry`, `campaignPerUser` and `stickyEventProps` are data-shape
|
|
228
|
+
// knobs, so they hoist from `switches` like the booleans do.
|
|
229
|
+
switches: ['hasLocation', 'hasCampaigns', 'hasAdSpend', 'hasSessionIds', 'hasAvatar', 'hasIOSDevices', 'hasAndroidDevices', 'hasDesktopDevices', 'hasBrowser', 'isAnonymous', 'alsoInferFunnels', 'singleCountry', 'campaignPerUser', 'stickyEventProps'],
|
|
189
230
|
identity: ['avgDevicePerUser', 'sessionTimeout'],
|
|
190
231
|
};
|
|
191
232
|
|
|
@@ -262,7 +303,7 @@ function stripKilledConfigKeys(config) {
|
|
|
262
303
|
*
|
|
263
304
|
* @param {import('../../types.js').Funnel[]} funnels
|
|
264
305
|
*/
|
|
265
|
-
function validateConversionWindow(funnels, verbose = false) {
|
|
306
|
+
function validateConversionWindow(funnels, verbose = false, warnings = []) {
|
|
266
307
|
const DEFAULT_WINDOW_DAYS = 30;
|
|
267
308
|
const MAX_WINDOW_DAYS = 180;
|
|
268
309
|
for (const f of funnels) {
|
|
@@ -272,6 +313,13 @@ function validateConversionWindow(funnels, verbose = false) {
|
|
|
272
313
|
if (f.conversionWindowDays === undefined || f.conversionWindowDays === null) {
|
|
273
314
|
if (ttcDays >= DEFAULT_WINDOW_DAYS) {
|
|
274
315
|
f.conversionWindowDays = Math.min(MAX_WINDOW_DAYS, Math.ceil(ttcDays * 1.5));
|
|
316
|
+
warnings.push({
|
|
317
|
+
key: `funnels[${f.name || (f.sequence && f.sequence.join(' > '))}].conversionWindowDays`,
|
|
318
|
+
requested: undefined,
|
|
319
|
+
applied: f.conversionWindowDays,
|
|
320
|
+
reason: `timeToConvert (${ttcDays.toFixed(1)}d) exceeds the default 30d conversion window; auto-set`,
|
|
321
|
+
severity: 'warn',
|
|
322
|
+
});
|
|
275
323
|
if (verbose) console.warn(
|
|
276
324
|
`⚠️ Funnel "${f.name || (f.sequence && f.sequence.join(' > '))}": ` +
|
|
277
325
|
`timeToConvert (${ttcDays.toFixed(1)}d) exceeds default 30d conversion window. ` +
|
|
@@ -385,8 +433,112 @@ function normalizeExperiments(funnels, datasetEndUnix) {
|
|
|
385
433
|
// Sticky bucketing defaults true — the per-user hash was the only pre-1.6
|
|
386
434
|
// behavior, so existing dungeons stay byte-identical.
|
|
387
435
|
const sticky = raw.sticky === undefined ? true : !!raw.sticky;
|
|
388
|
-
|
|
436
|
+
// v1.7.0 (P0-2): stamp the assigned variant on the user profile as
|
|
437
|
+
// `Experiment: <name>`. Default true; `stampProfile: false` opts out.
|
|
438
|
+
// Only meaningful when sticky — a re-rolled variant has no single value.
|
|
439
|
+
const stampProfile = raw.stampProfile === undefined ? true : !!raw.stampProfile;
|
|
440
|
+
f._experiment = { name, variants, startUnix, sticky, stampProfile };
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* v1.7.0 (P0-1): validate `Funnel.conditions` shapes at config time.
|
|
446
|
+
*
|
|
447
|
+
* Throws on shapes that can never match: function values, bare arrays (use
|
|
448
|
+
* `{ in: [...] }`), unknown operators, `in`/`nin` without an array, ordering
|
|
449
|
+
* operators without a number/string. Warns (into `warnings`) when a condition
|
|
450
|
+
* key is declared nowhere the profile is built from — `userProps`, any persona's
|
|
451
|
+
* `properties`, or `superProps` — because only a `user` hook could then supply it.
|
|
452
|
+
*
|
|
453
|
+
* @param {import('../../types.js').Funnel[]} funnels
|
|
454
|
+
* @param {Partial<Dungeon>} config
|
|
455
|
+
* @param {EngineWarning[]} warnings
|
|
456
|
+
*/
|
|
457
|
+
function validateFunnelConditions(funnels, config, warnings) {
|
|
458
|
+
if (!Array.isArray(funnels)) return;
|
|
459
|
+
const declared = new Set([
|
|
460
|
+
...Object.keys(config.userProps || {}),
|
|
461
|
+
...Object.keys(config.superProps || {}),
|
|
462
|
+
...(Array.isArray(config.personas) ? config.personas.flatMap(p => Object.keys((p && p.properties) || {})) : []),
|
|
463
|
+
]);
|
|
464
|
+
const undeclared = new Set();
|
|
465
|
+
for (const f of funnels) {
|
|
466
|
+
if (!f || !f.conditions) continue;
|
|
467
|
+
const label = `funnels[${f.name || (Array.isArray(f.sequence) ? f.sequence.join(' > ') : '?')}].conditions`;
|
|
468
|
+
if (typeof f.conditions !== 'object' || Array.isArray(f.conditions)) {
|
|
469
|
+
throw new Error(`${label} must be an object mapping profile keys to a scalar or an operator map`);
|
|
470
|
+
}
|
|
471
|
+
for (const [key, cond] of Object.entries(f.conditions)) {
|
|
472
|
+
if (typeof cond === 'function') {
|
|
473
|
+
throw new Error(`${label}.${key} is a function. Conditions compare against the resolved profile value; pass a scalar or an operator map such as { in: [...] }.`);
|
|
474
|
+
}
|
|
475
|
+
if (Array.isArray(cond)) {
|
|
476
|
+
throw new Error(`${label}.${key} is a bare array, which never matches (strict equality). Use { in: [${cond.map(v => JSON.stringify(v)).join(', ')}] }.`);
|
|
477
|
+
}
|
|
478
|
+
if (cond !== null && typeof cond === 'object' && !(cond instanceof Date)) {
|
|
479
|
+
const ops = Object.keys(cond);
|
|
480
|
+
if (!ops.length) throw new Error(`${label}.${key} is an empty operator map`);
|
|
481
|
+
for (const op of ops) {
|
|
482
|
+
if (!CONDITION_OPERATORS.includes(op)) {
|
|
483
|
+
throw new Error(`${label}.${key} uses unknown operator "${op}". Valid operators: ${CONDITION_OPERATORS.join(', ')}`);
|
|
484
|
+
}
|
|
485
|
+
const expected = cond[op];
|
|
486
|
+
if ((op === 'in' || op === 'nin') && !Array.isArray(expected)) {
|
|
487
|
+
throw new Error(`${label}.${key}.${op} requires an array (got ${typeof expected})`);
|
|
488
|
+
}
|
|
489
|
+
if ((op === 'gt' || op === 'gte' || op === 'lt' || op === 'lte') && typeof expected !== 'number' && typeof expected !== 'string') {
|
|
490
|
+
throw new Error(`${label}.${key}.${op} requires a number or string (got ${typeof expected})`);
|
|
491
|
+
}
|
|
492
|
+
if ((op === 'eq' || op === 'neq') && (typeof expected === 'function' || Array.isArray(expected))) {
|
|
493
|
+
throw new Error(`${label}.${key}.${op} requires a scalar`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
if (!declared.has(key)) undeclared.add(`${label}.${key}`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
for (const path of undeclared) {
|
|
501
|
+
warnings.push({
|
|
502
|
+
key: path,
|
|
503
|
+
requested: undefined,
|
|
504
|
+
applied: undefined,
|
|
505
|
+
reason: 'condition key is not declared in userProps, superProps, or any persona.properties — it can only match if a `user` hook sets it',
|
|
506
|
+
severity: 'warn',
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* v1.7.0 (P1-2): validate `stickyEventProps`. Every key must be declared in
|
|
513
|
+
* `userProps`, a persona's `properties`, or `superProps` (schema-first). Returns
|
|
514
|
+
* the normalized list plus the subset that lives only in `superProps` (those are
|
|
515
|
+
* resolved once per user rather than copied from the profile).
|
|
516
|
+
*
|
|
517
|
+
* @param {unknown} stickyEventProps
|
|
518
|
+
* @param {Partial<Dungeon>} config
|
|
519
|
+
* @returns {{ keys: string[], superOnly: Set<string> }}
|
|
520
|
+
*/
|
|
521
|
+
function validateStickyEventProps(stickyEventProps, config) {
|
|
522
|
+
if (stickyEventProps === undefined || stickyEventProps === null) return { keys: [], superOnly: new Set() };
|
|
523
|
+
if (!Array.isArray(stickyEventProps) || stickyEventProps.some(k => typeof k !== 'string' || !k)) {
|
|
524
|
+
throw new Error(`stickyEventProps must be an array of property-name strings (got ${JSON.stringify(stickyEventProps)})`);
|
|
525
|
+
}
|
|
526
|
+
const profileKeys = new Set([
|
|
527
|
+
...Object.keys(config.userProps || {}),
|
|
528
|
+
...(Array.isArray(config.personas) ? config.personas.flatMap(p => Object.keys((p && p.properties) || {})) : []),
|
|
529
|
+
]);
|
|
530
|
+
const superKeys = new Set(Object.keys(config.superProps || {}));
|
|
531
|
+
const superOnly = new Set();
|
|
532
|
+
const keys = [...new Set(stickyEventProps)];
|
|
533
|
+
for (const key of keys) {
|
|
534
|
+
if (profileKeys.has(key)) continue;
|
|
535
|
+
if (superKeys.has(key)) { superOnly.add(key); continue; }
|
|
536
|
+
throw new Error(
|
|
537
|
+
`stickyEventProps entry "${key}" is not declared in userProps, superProps, or any persona.properties. ` +
|
|
538
|
+
`Declare it first (schema-first) — hooks may not add new properties.`
|
|
539
|
+
);
|
|
389
540
|
}
|
|
541
|
+
return { keys, superOnly };
|
|
390
542
|
}
|
|
391
543
|
|
|
392
544
|
/**
|
|
@@ -493,9 +645,35 @@ export function validateDungeonConfig(config) {
|
|
|
493
645
|
name = "",
|
|
494
646
|
batchSize = 2_500_000,
|
|
495
647
|
concurrency = 1,
|
|
496
|
-
strictEventCount = false
|
|
648
|
+
strictEventCount = false,
|
|
649
|
+
autoPowerLaw = true,
|
|
650
|
+
campaignPerUser = false,
|
|
497
651
|
} = config;
|
|
498
652
|
|
|
653
|
+
/** @type {EngineWarning[]} */
|
|
654
|
+
const warnings = [];
|
|
655
|
+
|
|
656
|
+
// v1.7.0 (R2-2): resolve singleCountry to the template's canonical name (accepts
|
|
657
|
+
// ISO code or full name, case-insensitive) or throw. Before 1.7.0 a miss
|
|
658
|
+
// filtered the location pool to empty and silently deleted every geo property.
|
|
659
|
+
const singleCountry = resolveSingleCountry(config.singleCountry);
|
|
660
|
+
|
|
661
|
+
if (typeof autoPowerLaw !== 'boolean') {
|
|
662
|
+
throw new Error(`autoPowerLaw must be a boolean (got ${typeof autoPowerLaw})`);
|
|
663
|
+
}
|
|
664
|
+
if (typeof campaignPerUser !== 'boolean') {
|
|
665
|
+
throw new Error(`campaignPerUser must be a boolean (got ${typeof campaignPerUser})`);
|
|
666
|
+
}
|
|
667
|
+
if (campaignPerUser && !hasCampaigns) {
|
|
668
|
+
warnings.push({
|
|
669
|
+
key: 'campaignPerUser',
|
|
670
|
+
requested: true,
|
|
671
|
+
applied: false,
|
|
672
|
+
reason: 'campaignPerUser has no effect without hasCampaigns: true (no UTMs are stamped)',
|
|
673
|
+
severity: 'warn',
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
|
|
499
677
|
// Allow concurrency override from config (default is now 1)
|
|
500
678
|
if (config.concurrency === undefined || config.concurrency === null) {
|
|
501
679
|
concurrency = 1;
|
|
@@ -662,10 +840,12 @@ export function validateDungeonConfig(config) {
|
|
|
662
840
|
}
|
|
663
841
|
if (percentUsersBornInDataset > 100) {
|
|
664
842
|
if (verbose) console.warn(`⚠️ percentUsersBornInDataset=${percentUsersBornInDataset} clamped to 100. Values above 100 are not meaningful.`);
|
|
843
|
+
warnings.push({ key: 'percentUsersBornInDataset', requested: percentUsersBornInDataset, applied: 100, reason: 'values above 100 are not meaningful', severity: 'clamp' });
|
|
665
844
|
percentUsersBornInDataset = 100;
|
|
666
845
|
}
|
|
667
846
|
if (percentUsersBornInDataset < 0) {
|
|
668
847
|
if (verbose) console.warn(`⚠️ percentUsersBornInDataset=${percentUsersBornInDataset} clamped to 0. Negative values are not meaningful.`);
|
|
848
|
+
warnings.push({ key: 'percentUsersBornInDataset', requested: percentUsersBornInDataset, applied: 0, reason: 'negative values are not meaningful', severity: 'clamp' });
|
|
669
849
|
percentUsersBornInDataset = 0;
|
|
670
850
|
}
|
|
671
851
|
|
|
@@ -679,20 +859,32 @@ export function validateDungeonConfig(config) {
|
|
|
679
859
|
// dungeons that set percentUsersBornInDataset directly without picking a
|
|
680
860
|
// macro keep their existing behavior. Tuned empirically against the
|
|
681
861
|
// engine-validation sweep matrix (research/engine-sweep-pass*.json).
|
|
862
|
+
//
|
|
863
|
+
// v1.7.0 (R2-1): the cap is keyed on the NAMED preset only. A macro object
|
|
864
|
+
// without `preset` (`macro: { bornRecentBias: 0.3, percentUsersBornInDataset: 50 }`)
|
|
865
|
+
// is a custom macro — the author owns the shape, so no preset contract applies
|
|
866
|
+
// and the overrides are honored as written. Before 1.7.0 the preset-less object
|
|
867
|
+
// fell back to the `flat` cap (12) and silently ignored its own born%.
|
|
682
868
|
const MACRO_BORN_CAP = { flat: 12, steady: 12, growth: 30, viral: 55, decline: 5 };
|
|
683
|
-
const macroExplicit = config.macro !== undefined && config.macro !== null;
|
|
684
869
|
const macroKey = (typeof config.macro === 'string')
|
|
685
870
|
? config.macro
|
|
686
|
-
: (
|
|
687
|
-
if (userBornExplicit &&
|
|
871
|
+
: (macroAsObj !== null && typeof macroAsObj.preset === 'string') ? macroAsObj.preset : null;
|
|
872
|
+
if (userBornExplicit && macroKey !== null && MACRO_BORN_CAP[macroKey] !== undefined) {
|
|
688
873
|
const cap = MACRO_BORN_CAP[macroKey];
|
|
689
874
|
if (percentUsersBornInDataset > cap) {
|
|
690
875
|
if (verbose) console.warn(
|
|
691
876
|
`⚠️ macro="${macroKey}" + percentUsersBornInDataset=${percentUsersBornInDataset} ` +
|
|
692
877
|
`clamped to ${cap}. High born% with macro="${macroKey}" produces cumulative-acquisition ` +
|
|
693
|
-
`right-edge explosion. Use macro="growth" or "viral" for genuinely high-born configs
|
|
694
|
-
`
|
|
878
|
+
`right-edge explosion. Use macro="growth" or "viral" for genuinely high-born configs, ` +
|
|
879
|
+
`or drop \`preset\` for a custom macro with no cap.`
|
|
695
880
|
);
|
|
881
|
+
warnings.push({
|
|
882
|
+
key: 'percentUsersBornInDataset',
|
|
883
|
+
requested: percentUsersBornInDataset,
|
|
884
|
+
applied: cap,
|
|
885
|
+
reason: `macro preset "${macroKey}" caps percentUsersBornInDataset at ${cap} to keep its shape; use a higher-born preset or a custom macro object without \`preset\``,
|
|
886
|
+
severity: 'clamp',
|
|
887
|
+
});
|
|
696
888
|
percentUsersBornInDataset = cap;
|
|
697
889
|
}
|
|
698
890
|
}
|
|
@@ -702,10 +894,12 @@ export function validateDungeonConfig(config) {
|
|
|
702
894
|
if (userBiasExplicit) {
|
|
703
895
|
if (bornRecentBias > 0.5) {
|
|
704
896
|
if (verbose) console.warn(`⚠️ bornRecentBias=${bornRecentBias} clamped to 0.5. Above 0.5 produces unusable right-skew. To suppress, fix the config.`);
|
|
897
|
+
warnings.push({ key: 'bornRecentBias', requested: bornRecentBias, applied: 0.5, reason: 'above 0.5 produces unusable right-skew', severity: 'clamp' });
|
|
705
898
|
bornRecentBias = 0.5;
|
|
706
899
|
}
|
|
707
900
|
if (bornRecentBias < -0.5) {
|
|
708
901
|
if (verbose) console.warn(`⚠️ bornRecentBias=${bornRecentBias} clamped to -0.5. Below -0.5 produces unusable left-skew. To suppress, fix the config.`);
|
|
902
|
+
warnings.push({ key: 'bornRecentBias', requested: bornRecentBias, applied: -0.5, reason: 'below -0.5 produces unusable left-skew', severity: 'clamp' });
|
|
709
903
|
bornRecentBias = -0.5;
|
|
710
904
|
}
|
|
711
905
|
}
|
|
@@ -717,6 +911,7 @@ export function validateDungeonConfig(config) {
|
|
|
717
911
|
`⚠️ percentUsersBornInDataset=${percentUsersBornInDataset} + bornRecentBias=${bornRecentBias} ` +
|
|
718
912
|
`compounds to right-edge explosion. Clamping bornRecentBias to 0.3. To suppress, fix the config.`
|
|
719
913
|
);
|
|
914
|
+
warnings.push({ key: 'bornRecentBias', requested: bornRecentBias, applied: 0.3, reason: `percentUsersBornInDataset=${percentUsersBornInDataset} with bornRecentBias > 0.4 compounds to a right-edge explosion`, severity: 'clamp' });
|
|
720
915
|
bornRecentBias = 0.3;
|
|
721
916
|
}
|
|
722
917
|
|
|
@@ -727,6 +922,7 @@ export function validateDungeonConfig(config) {
|
|
|
727
922
|
// load + memory cost. Plan PROMPT.md: clamp to 50.
|
|
728
923
|
if (Number.isFinite(avgEventsPerUserPerDay) && avgEventsPerUserPerDay > 50) {
|
|
729
924
|
if (verbose) console.warn(`⚠️ avgEventsPerUserPerDay=${avgEventsPerUserPerDay} clamped to 50. Above 50 is unrealistic load + memory cost. To suppress, fix the config.`);
|
|
925
|
+
warnings.push({ key: 'avgEventsPerUserPerDay', requested: avgEventsPerUserPerDay, applied: 50, reason: 'above 50 is unrealistic load and memory cost; numEvents recomputed from the clamped rate', severity: 'clamp' });
|
|
730
926
|
avgEventsPerUserPerDay = 50;
|
|
731
927
|
numEvents = Math.round(avgEventsPerUserPerDay * numUsers * numDays);
|
|
732
928
|
}
|
|
@@ -737,6 +933,7 @@ export function validateDungeonConfig(config) {
|
|
|
737
933
|
if (avgActiveDaysPerUser !== undefined && avgActiveDaysPerUser !== null && avgActiveDaysPerUser > numDays * 0.5) {
|
|
738
934
|
const cap = Math.max(1, Math.floor(numDays * 0.5));
|
|
739
935
|
if (verbose) console.warn(`⚠️ avgActiveDaysPerUser=${avgActiveDaysPerUser} > numDays/2 (${numDays}/2); clamped to ${cap}. Above 50% defeats the concentrator purpose. To suppress, fix the config.`);
|
|
936
|
+
warnings.push({ key: 'avgActiveDaysPerUser', requested: avgActiveDaysPerUser, applied: cap, reason: `above numDays/2 (${numDays}/2) defeats the concentrator purpose`, severity: 'clamp' });
|
|
740
937
|
avgActiveDaysClamped = cap;
|
|
741
938
|
}
|
|
742
939
|
|
|
@@ -745,8 +942,9 @@ export function validateDungeonConfig(config) {
|
|
|
745
942
|
// already been resolved upstream — clamping numDays alone would desync the
|
|
746
943
|
// engine. Pre-validator numDays bound is preferred (validator throws on
|
|
747
944
|
// numDays <= 0 already at line ~422). Just warn for visibility.
|
|
748
|
-
if (
|
|
749
|
-
console.warn(`⚠️ numDays=${numDays} < 14. Strict-bar engine-validation metrics use 14-day windows; results are noisy below 14 days. Consider increasing numDays.`);
|
|
945
|
+
if (numDays < 14) {
|
|
946
|
+
if (verbose) console.warn(`⚠️ numDays=${numDays} < 14. Strict-bar engine-validation metrics use 14-day windows; results are noisy below 14 days. Consider increasing numDays.`);
|
|
947
|
+
warnings.push({ key: 'numDays', requested: numDays, applied: numDays, reason: 'below the 14-day safe range; trend shape is noisy and not clamped', severity: 'warn' });
|
|
750
948
|
}
|
|
751
949
|
|
|
752
950
|
// v1.6.4 (P3-9): warn when `avgActiveDaysPerUser` and `engagementDecay` are both
|
|
@@ -764,6 +962,7 @@ export function validateDungeonConfig(config) {
|
|
|
764
962
|
`Decay erodes the effective active-day count, so the realized value will be at or below ` +
|
|
765
963
|
`${avgActiveDaysPerUser}, never above. Prefer one knob or the other. See HOOKS.md §2.5.`
|
|
766
964
|
);
|
|
965
|
+
warnings.push({ key: 'avgActiveDaysPerUser', requested: avgActiveDaysPerUser, applied: avgActiveDaysPerUser, reason: 'set together with engagementDecay; decay erodes the realized active-day count below this value', severity: 'warn' });
|
|
767
966
|
}
|
|
768
967
|
// ──────────────────────────────────────────────────────────────────────
|
|
769
968
|
|
|
@@ -887,6 +1086,9 @@ export function validateDungeonConfig(config) {
|
|
|
887
1086
|
// Explicit user-defined funnels keep their declared `timeToConvert`.
|
|
888
1087
|
timeToConvert: 24,
|
|
889
1088
|
requireRepeats: false,
|
|
1089
|
+
// v1.7.0: marks the engine-synthesized catch-all so the "user matched no
|
|
1090
|
+
// funnel" report (P0-1 item 5) counts only author-declared funnels.
|
|
1091
|
+
_catchAll: true,
|
|
890
1092
|
});
|
|
891
1093
|
}
|
|
892
1094
|
|
|
@@ -918,6 +1120,12 @@ export function validateDungeonConfig(config) {
|
|
|
918
1120
|
worldEvents = resolveWorldEvents(worldEvents, datasetStartUnix);
|
|
919
1121
|
}
|
|
920
1122
|
|
|
1123
|
+
// v1.7.0 (P0-1): reject condition shapes that can never match; warn on undeclared keys.
|
|
1124
|
+
validateFunnelConditions(funnels, config, warnings);
|
|
1125
|
+
|
|
1126
|
+
// v1.7.0 (P1-2): schema-first check on stickyEventProps.
|
|
1127
|
+
const sticky = validateStickyEventProps(config.stickyEventProps, config);
|
|
1128
|
+
|
|
921
1129
|
// Feature 3: Engagement Decay
|
|
922
1130
|
let engagementDecay = config.engagementDecay || null;
|
|
923
1131
|
if (engagementDecay) {
|
|
@@ -937,7 +1145,7 @@ export function validateDungeonConfig(config) {
|
|
|
937
1145
|
validateAttempts(funnels);
|
|
938
1146
|
|
|
939
1147
|
// v1.5: default + auto-bump Funnel.conversionWindowDays.
|
|
940
|
-
validateConversionWindow(funnels, verbose);
|
|
1148
|
+
validateConversionWindow(funnels, verbose, warnings);
|
|
941
1149
|
|
|
942
1150
|
// v1.5.0: validate Funnel.exclusionEvents — entries must exist in events[].
|
|
943
1151
|
validateExclusionEvents(funnels, validatedEvents, verbose);
|
|
@@ -1021,6 +1229,15 @@ export function validateDungeonConfig(config) {
|
|
|
1021
1229
|
hasIOSDevices,
|
|
1022
1230
|
name,
|
|
1023
1231
|
strictEventCount,
|
|
1232
|
+
// v1.7.0 knobs
|
|
1233
|
+
singleCountry,
|
|
1234
|
+
autoPowerLaw,
|
|
1235
|
+
campaignPerUser,
|
|
1236
|
+
stickyEventProps: sticky.keys,
|
|
1237
|
+
/** @internal keys in stickyEventProps that live only in superProps (resolved once per user) */
|
|
1238
|
+
_stickySuperOnly: [...sticky.superOnly],
|
|
1239
|
+
/** @internal v1.7.0 (P2-2): validator clamps + warnings; surfaced as `result.warnings` */
|
|
1240
|
+
_warnings: warnings,
|
|
1024
1241
|
// Macro trend (resolved from preset + per-dungeon overrides; clamped)
|
|
1025
1242
|
macro: config.macro,
|
|
1026
1243
|
bornRecentBias,
|
|
@@ -1189,7 +1406,11 @@ function validatePersonas(personas) {
|
|
|
1189
1406
|
}
|
|
1190
1407
|
if (p.eventMultiplier === undefined) p.eventMultiplier = 1.0;
|
|
1191
1408
|
if (p.conversionModifier === undefined) p.conversionModifier = 1.0;
|
|
1192
|
-
|
|
1409
|
+
// v1.7.0 (P1-3): time-to-convert multiplier, symmetrical with conversionModifier.
|
|
1410
|
+
if (p.ttcModifier === undefined) p.ttcModifier = 1.0;
|
|
1411
|
+
if (!Number.isFinite(p.ttcModifier) || p.ttcModifier <= 0) {
|
|
1412
|
+
throw new Error(`Persona "${p.name}" ttcModifier must be a positive finite number (got ${p.ttcModifier})`);
|
|
1413
|
+
}
|
|
1193
1414
|
if (p.properties === undefined) p.properties = {};
|
|
1194
1415
|
}
|
|
1195
1416
|
if (deadFieldsSet.size > 0 && !warnedDeadPersonaFields) {
|
|
@@ -1223,6 +1444,15 @@ function resolveWorldEvents(worldEvents, beginUnix) {
|
|
|
1223
1444
|
if (!we.affectsEvents) resolved.affectsEvents = "*";
|
|
1224
1445
|
if (!we.volumeMultiplier) resolved.volumeMultiplier = 1.0;
|
|
1225
1446
|
if (!we.conversionModifier) resolved.conversionModifier = 1.0;
|
|
1447
|
+
// v1.7.0 (P0-3): amplification is now implemented, so the field must be a
|
|
1448
|
+
// usable number. Above-1 values clone affected events across the window.
|
|
1449
|
+
if (!Number.isFinite(resolved.volumeMultiplier) || resolved.volumeMultiplier < 0) {
|
|
1450
|
+
throw new Error(`worldEvents["${we.name}"].volumeMultiplier must be a non-negative finite number (got ${we.volumeMultiplier})`);
|
|
1451
|
+
}
|
|
1452
|
+
if (resolved.aftermath && resolved.aftermath.volumeMultiplier !== undefined &&
|
|
1453
|
+
(!Number.isFinite(resolved.aftermath.volumeMultiplier) || resolved.aftermath.volumeMultiplier < 0)) {
|
|
1454
|
+
throw new Error(`worldEvents["${we.name}"].aftermath.volumeMultiplier must be a non-negative finite number (got ${resolved.aftermath.volumeMultiplier})`);
|
|
1455
|
+
}
|
|
1226
1456
|
return resolved;
|
|
1227
1457
|
}).sort((a, b) => a.startUnix - b.startUnix || (a.name || '').localeCompare(b.name || ''));
|
|
1228
1458
|
}
|
package/lib/core/context.js
CHANGED
|
@@ -75,6 +75,10 @@ function createRuntimeState() {
|
|
|
75
75
|
eventCount: 0,
|
|
76
76
|
storedEventCount: 0,
|
|
77
77
|
userCount: 0,
|
|
78
|
+
// v1.7.0 (R2-5): profile receipt counters, incremented at push time so
|
|
79
|
+
// they are correct in batch mode too (the in-memory array is flushed).
|
|
80
|
+
profilesGenerated: 0,
|
|
81
|
+
profilesDropped: 0,
|
|
78
82
|
isBatchMode: false,
|
|
79
83
|
verbose: false
|
|
80
84
|
};
|
|
@@ -157,6 +161,12 @@ export function createContext(config, storage = null, timeConstants = {}) {
|
|
|
157
161
|
|
|
158
162
|
const { reportProgress, getProgressSummary } = createProgressReporter(config);
|
|
159
163
|
|
|
164
|
+
// v1.7.0 (P2-2 / P2-4): runtime warning sink. Generators aggregate by `key`
|
|
165
|
+
// (one entry per funnel / knob, with a `count`) — never one entry per user,
|
|
166
|
+
// because the callers sit in the per-user hot loop.
|
|
167
|
+
/** @type {Map<string, import('../../types.js').EngineWarning>} */
|
|
168
|
+
const runtimeWarnings = new Map();
|
|
169
|
+
|
|
160
170
|
const context = {
|
|
161
171
|
config,
|
|
162
172
|
storage,
|
|
@@ -166,6 +176,35 @@ export function createContext(config, storage = null, timeConstants = {}) {
|
|
|
166
176
|
reportProgress,
|
|
167
177
|
getProgressSummary,
|
|
168
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Record (or bump) an aggregated runtime warning.
|
|
181
|
+
* @param {import('../../types.js').EngineWarning} entry
|
|
182
|
+
*/
|
|
183
|
+
addWarning(entry) {
|
|
184
|
+
const existing = runtimeWarnings.get(entry.key);
|
|
185
|
+
if (existing) {
|
|
186
|
+
existing.count = (existing.count || 1) + 1;
|
|
187
|
+
if (typeof entry.requested === 'number' && typeof existing.requested === 'number') {
|
|
188
|
+
existing.requested = Math.max(existing.requested, entry.requested);
|
|
189
|
+
}
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
runtimeWarnings.set(entry.key, { ...entry, count: entry.count || 1 });
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
/** @returns {import('../../types.js').EngineWarning[]} */
|
|
196
|
+
getWarnings() {
|
|
197
|
+
return [...runtimeWarnings.values()];
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
incrementProfilesGenerated() {
|
|
201
|
+
runtime.profilesGenerated++;
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
incrementProfilesDropped() {
|
|
205
|
+
runtime.profilesDropped++;
|
|
206
|
+
},
|
|
207
|
+
|
|
169
208
|
// Helper methods for updating state
|
|
170
209
|
incrementOperations() {
|
|
171
210
|
runtime.operations++;
|
|
@@ -202,7 +202,10 @@ function reviveFunctionObject(obj) {
|
|
|
202
202
|
if (typeof fn === 'function') {
|
|
203
203
|
// Smoke-test: call it once to verify it doesn't reference other missing variables.
|
|
204
204
|
// If it throws (e.g., referencing `items` from a lost closure), discard it.
|
|
205
|
-
|
|
205
|
+
// v1.7.0 (P1-1): context-aware bodies (`(ctx) => ctx.profile.plan`) get an
|
|
206
|
+
// empty ValueContext so the probe does not throw on `ctx` itself.
|
|
207
|
+
const probeCtx = { profile: {}, event: {}, time: 0, config: {} };
|
|
208
|
+
try { fn.length >= 1 ? fn(probeCtx) : fn(); } catch { return null; }
|
|
206
209
|
return fn;
|
|
207
210
|
}
|
|
208
211
|
} catch {
|
package/lib/generators/events.js
CHANGED
|
@@ -83,9 +83,15 @@ export async function makeEvent(
|
|
|
83
83
|
|
|
84
84
|
let defaultProps = {};
|
|
85
85
|
|
|
86
|
-
// Add default properties based on configuration
|
|
86
|
+
// Add default properties based on configuration.
|
|
87
|
+
// v1.7.0 (P1-2 / B2): a user's events share the user's location. Before 1.7.0
|
|
88
|
+
// `featureCtx.userLocation` was computed per user and never read here, so every
|
|
89
|
+
// event drew a fresh random city — 0.8% of events matched their profile's city.
|
|
90
|
+
// The per-event draw remains only for callers that pass no user location.
|
|
87
91
|
if (hasLocation) {
|
|
88
|
-
defaultProps.location =
|
|
92
|
+
defaultProps.location = (featureCtx && featureCtx.userLocation)
|
|
93
|
+
? featureCtx.userLocation
|
|
94
|
+
: u.pickRandom(defaults.locationsEvents());
|
|
89
95
|
}
|
|
90
96
|
|
|
91
97
|
if (hasBrowser) {
|
|
@@ -114,9 +120,25 @@ export async function makeEvent(
|
|
|
114
120
|
const latestTime = (featureCtx && Number.isFinite(featureCtx.latestTime))
|
|
115
121
|
? featureCtx.latestTime
|
|
116
122
|
: context.FIXED_NOW;
|
|
123
|
+
// TimeSoup ALWAYS runs (even when the caller pins the time below) so the
|
|
124
|
+
// seeded RNG stream is consumed identically to pre-1.7 — byte-identical
|
|
125
|
+
// output for existing dungeons.
|
|
117
126
|
unixTime = u.TimeSoup(earliestTime, latestTime, peaks, deviation, mean, dayOfWeekWeights, hourOfDayWeights);
|
|
118
127
|
}
|
|
119
|
-
|
|
128
|
+
// v1.7.0: funnel steps after the first know their final time up front
|
|
129
|
+
// (`fixedTimeMs` = step-0 time + timing offset), so property thunks see the
|
|
130
|
+
// real `ctx.time` / `ctx.event` and world-event windows test the real time.
|
|
131
|
+
// Before 1.7.0 the step's time was overwritten after properties resolved.
|
|
132
|
+
if (featureCtx && Number.isFinite(featureCtx.fixedTimeMs)) {
|
|
133
|
+
eventTemplate.time = new Date(featureCtx.fixedTimeMs).toISOString();
|
|
134
|
+
} else {
|
|
135
|
+
eventTemplate.time = dayjs.unix(unixTime).toISOString();
|
|
136
|
+
}
|
|
137
|
+
// Synchronous side channel: lets the funnel generator learn step 0's time
|
|
138
|
+
// before later steps start resolving (see generateFunnelEvents).
|
|
139
|
+
if (featureCtx && typeof featureCtx.onTimeResolved === 'function') {
|
|
140
|
+
featureCtx.onTimeResolved(Date.parse(eventTemplate.time));
|
|
141
|
+
}
|
|
120
142
|
}
|
|
121
143
|
|
|
122
144
|
// ── Phase 2 identity stamping ──
|
|
@@ -149,6 +171,17 @@ export async function makeEvent(
|
|
|
149
171
|
eventTemplate.user_id = distinct_id;
|
|
150
172
|
}
|
|
151
173
|
|
|
174
|
+
// v1.7.0 (P1-1): value context handed to every property thunk. `event` is the
|
|
175
|
+
// partially-built record (identity + time set; properties resolve in declaration
|
|
176
|
+
// order, so a later key can read an earlier one). `profile` is the user's
|
|
177
|
+
// resolved profile when the caller supplied it (user-loop / funnels do).
|
|
178
|
+
const valueCtx = {
|
|
179
|
+
profile: (featureCtx && featureCtx.profile) || undefined,
|
|
180
|
+
event: eventTemplate,
|
|
181
|
+
time: eventTemplate.time ? Date.parse(eventTemplate.time) : undefined,
|
|
182
|
+
config,
|
|
183
|
+
};
|
|
184
|
+
|
|
152
185
|
// PERFORMANCE: Process properties directly without creating intermediate object
|
|
153
186
|
// Add custom properties from event configuration
|
|
154
187
|
if (chosenEvent.properties) {
|
|
@@ -156,21 +189,21 @@ export async function makeEvent(
|
|
|
156
189
|
for (let i = 0; i < eventKeys.length; i++) {
|
|
157
190
|
const key = eventKeys[i];
|
|
158
191
|
try {
|
|
159
|
-
eventTemplate[key] = u.choose(chosenEvent.properties[key]);
|
|
192
|
+
eventTemplate[key] = u.choose(chosenEvent.properties[key], valueCtx);
|
|
160
193
|
} catch (e) {
|
|
161
194
|
logger.error({ err: e, key, event: chosenEvent.event }, `Error processing property ${key} in ${chosenEvent.event} event`);
|
|
162
195
|
// Continue processing other properties
|
|
163
196
|
}
|
|
164
197
|
}
|
|
165
198
|
}
|
|
166
|
-
|
|
199
|
+
|
|
167
200
|
// Add super properties (override event properties if needed)
|
|
168
201
|
if (superProps) {
|
|
169
202
|
const superKeys = Object.keys(superProps);
|
|
170
203
|
for (let i = 0; i < superKeys.length; i++) {
|
|
171
204
|
const key = superKeys[i];
|
|
172
205
|
try {
|
|
173
|
-
eventTemplate[key] = u.choose(superProps[key]);
|
|
206
|
+
eventTemplate[key] = u.choose(superProps[key], valueCtx);
|
|
174
207
|
} catch (e) {
|
|
175
208
|
logger.error({ err: e, key }, `Error processing super property ${key}`);
|
|
176
209
|
// Continue processing other properties
|
|
@@ -178,6 +211,16 @@ export async function makeEvent(
|
|
|
178
211
|
}
|
|
179
212
|
}
|
|
180
213
|
|
|
214
|
+
// v1.7.0 (P1-2): user-sticky event properties. Resolved once per user in the
|
|
215
|
+
// user loop (after the `user` hook, so hook overrides are honored) and copied
|
|
216
|
+
// onto every event AFTER superProps — a sticky key beats a per-event re-roll of
|
|
217
|
+
// the same name — and BEFORE the `event` hook, which stays the final authority.
|
|
218
|
+
if (featureCtx && featureCtx.stickyValues) {
|
|
219
|
+
for (const key in featureCtx.stickyValues) {
|
|
220
|
+
eventTemplate[key] = featureCtx.stickyValues[key];
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
181
224
|
// Add default properties if not skipped
|
|
182
225
|
if (!skipDefaults) {
|
|
183
226
|
addDefaultProperties(eventTemplate, defaultProps);
|
|
@@ -211,7 +254,10 @@ export async function makeEvent(
|
|
|
211
254
|
eventTemplate[k] = v;
|
|
212
255
|
}
|
|
213
256
|
}
|
|
214
|
-
// Volume modulation via accept/reject: if volumeMultiplier < 1, randomly drop
|
|
257
|
+
// Volume modulation via accept/reject: if volumeMultiplier < 1, randomly drop.
|
|
258
|
+
// v1.7.0 (P0-3): volumeMultiplier > 1 is handled per user in
|
|
259
|
+
// user-loop.js `amplifyWorldEvents` — affected events are cloned
|
|
260
|
+
// (fresh insert_id) and spread across the window.
|
|
215
261
|
const volMult = inAftermath ? (we.aftermath?.volumeMultiplier || 1.0) : we.volumeMultiplier;
|
|
216
262
|
if (volMult < 1.0 && !chance.bool({ likelihood: volMult * 100 })) {
|
|
217
263
|
eventTemplate._drop = true;
|