@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/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
- switches: ['hasLocation', 'hasCampaigns', 'hasAdSpend', 'hasSessionIds', 'hasAvatar', 'hasIOSDevices', 'hasAndroidDevices', 'hasDesktopDevices', 'hasBrowser', 'isAnonymous', 'alsoInferFunnels'],
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
- f._experiment = { name, variants, startUnix, sticky };
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
- : (config.macro && config.macro.preset) ? config.macro.preset : 'flat';
687
- if (userBornExplicit && macroExplicit && MACRO_BORN_CAP[macroKey] !== undefined) {
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
- `To suppress, fix the config.`
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 (verbose && numDays < 14) {
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
- if (p.churnRate === undefined) p.churnRate = 0;
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
  }
@@ -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
- try { fn(); } catch { return null; }
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 {
@@ -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 = u.pickRandom(defaults.locationsEvents());
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
- eventTemplate.time = dayjs.unix(unixTime).toISOString();
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;