@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.
Files changed (66) hide show
  1. package/.claude/skills/create-dungeon/SKILL.md +139 -46
  2. package/.claude/skills/verify-dungeon/references/counting-semantics.md +31 -6
  3. package/.claude/skills/verify-dungeon/references/sql-recipes.md +44 -25
  4. package/.claude/skills/write-hooks/SKILL.md +31 -3
  5. package/CHANGELOG.md +85 -0
  6. package/HOOKS.md +13 -0
  7. package/dungeons/technical/ad-spend.js +41 -49
  8. package/dungeons/technical/anonymous-users.js +38 -36
  9. package/dungeons/technical/array-of-object-lookup.js +136 -153
  10. package/dungeons/technical/datagen-v15-verify.js +24 -11
  11. package/dungeons/technical/experiments.js +42 -40
  12. package/dungeons/technical/foobar.js +114 -118
  13. package/dungeons/technical/group-analytics.js +42 -40
  14. package/dungeons/technical/hook-helpers-verify.js +69 -50
  15. package/dungeons/technical/identity-model-verify.js +22 -12
  16. package/dungeons/technical/mirror-strategies.js +37 -39
  17. package/dungeons/technical/nested-objects.js +119 -118
  18. package/dungeons/technical/pattern-aggregate-by-bin.js +21 -8
  19. package/dungeons/technical/pattern-attributed-by-source.js +23 -9
  20. package/dungeons/technical/pattern-frequency-by-frequency.js +21 -8
  21. package/dungeons/technical/pattern-funnel-frequency.js +30 -15
  22. package/dungeons/technical/pattern-ttc-by-segment.js +21 -8
  23. package/dungeons/technical/retention-cadence.js +115 -112
  24. package/dungeons/technical/sanity.js +86 -80
  25. package/dungeons/technical/scale-test.js +34 -38
  26. package/dungeons/technical/scd.js +111 -128
  27. package/dungeons/technical/simple.js +134 -141
  28. package/dungeons/technical/simplest.js +54 -62
  29. package/dungeons/technical/text-generation.js +110 -146
  30. package/dungeons/vertical/ai-platform.js +296 -333
  31. package/dungeons/vertical/community.js +284 -255
  32. package/dungeons/vertical/crypto.js +395 -391
  33. package/dungeons/vertical/dating.js +411 -378
  34. package/dungeons/vertical/devtools.js +336 -298
  35. package/dungeons/vertical/ecommerce.js +316 -394
  36. package/dungeons/vertical/education.js +369 -325
  37. package/dungeons/vertical/fintech.js +358 -325
  38. package/dungeons/vertical/fitness.js +335 -291
  39. package/dungeons/vertical/food-delivery.js +343 -307
  40. package/dungeons/vertical/gaming.js +480 -444
  41. package/dungeons/vertical/healthcare.js +306 -262
  42. package/dungeons/vertical/insurance-application.js +427 -409
  43. package/dungeons/vertical/logistics.js +271 -252
  44. package/dungeons/vertical/marketplace.js +333 -323
  45. package/dungeons/vertical/media.js +382 -335
  46. package/dungeons/vertical/real-estate.js +395 -346
  47. package/dungeons/vertical/sass.js +319 -333
  48. package/dungeons/vertical/social.js +368 -316
  49. package/dungeons/vertical/travel.js +297 -295
  50. package/index.js +46 -4
  51. package/lib/core/config-validator.js +126 -28
  52. package/lib/generators/funnels.js +4 -1
  53. package/lib/orchestrators/mixpanel-sender.js +7 -0
  54. package/lib/orchestrators/user-loop.js +132 -31
  55. package/lib/templates/defaults.js +59 -59
  56. package/lib/templates/macro-presets.js +14 -2
  57. package/lib/utils/dataset-context.js +103 -0
  58. package/lib/utils/retention-curve.js +140 -0
  59. package/lib/utils/utils.js +149 -38
  60. package/lib/verify/counting.js +40 -0
  61. package/lib/verify/emulate-breakdown.js +20 -1
  62. package/lib/verify/index.js +1 -0
  63. package/lib/verify/schema-validator.js +3 -1
  64. package/package.json +11 -2
  65. package/scripts/run-dungeon.mjs +12 -1
  66. 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. Without this, those factories produce values relative
144
- // to process-start, which leaks wall-clock time into the output.
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
- const parsed = dayjs(value);
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
- const avgActiveDaysPerUser = config.avgActiveDaysPerUser;
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
- const macroResolved = resolveMacro(config.macro);
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 !== false) console.log('\u26a0\ufe0f Service account credentials missing - converting SCD properties to static properties');
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 !== false) console.log(` \u2713 Converted user SCD property: ${propKey}`);
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 !== false) console.log(` \u2713 Converted group SCD property: ${propKey} (${type})`);
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 !== false) console.log('\u2713 SCD properties converted to static properties\n');
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
- const authTimeMs = runAuthExecIdx >= 0 && finalEvents[runAuthExecIdx]
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`);