@ak--47/dungeon-master 1.4.4 → 1.5.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.
Files changed (67) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +158 -0
  2. package/.claude/skills/create-dungeon/SKILL.md +464 -0
  3. package/.claude/skills/verify-dungeon/SKILL.md +157 -0
  4. package/.claude/skills/verify-dungeon/references/counting-semantics.md +161 -0
  5. package/.claude/skills/verify-dungeon/references/report-format.md +216 -0
  6. package/.claude/skills/verify-dungeon/references/sql-recipes.md +857 -0
  7. package/.claude/skills/write-hooks/SKILL.md +468 -0
  8. package/CHANGELOG.md +147 -0
  9. package/HOOKS.md +1243 -597
  10. package/README.md +140 -5
  11. package/dungeons/technical/ad-spend.js +1 -1
  12. package/dungeons/technical/anonymous-users.js +1 -1
  13. package/dungeons/technical/array-of-object-lookup.js +1 -1
  14. package/dungeons/technical/datagen-v15-verify.js +74 -0
  15. package/dungeons/technical/experiments.js +1 -1
  16. package/dungeons/technical/foobar.js +1 -1
  17. package/dungeons/technical/group-analytics.js +1 -1
  18. package/dungeons/technical/mirror-strategies.js +1 -1
  19. package/dungeons/technical/nested-objects.js +1 -1
  20. package/dungeons/technical/retention-cadence.js +1 -1
  21. package/dungeons/technical/sanity.js +1 -1
  22. package/dungeons/technical/scale-test.js +1 -1
  23. package/dungeons/technical/scd.js +1 -1
  24. package/dungeons/technical/simple.js +1 -1
  25. package/dungeons/technical/simplest.js +74 -20
  26. package/dungeons/technical/text-generation.js +1 -1
  27. package/dungeons/vertical/ai-platform.js +4 -0
  28. package/dungeons/vertical/community.js +9 -3
  29. package/dungeons/vertical/crypto.js +5 -0
  30. package/dungeons/vertical/dating.js +23 -10
  31. package/dungeons/vertical/devtools.js +10 -0
  32. package/dungeons/vertical/ecommerce.js +6 -0
  33. package/dungeons/vertical/education.js +11 -0
  34. package/dungeons/vertical/fintech.js +13 -0
  35. package/dungeons/vertical/fitness.js +10 -0
  36. package/dungeons/vertical/food-delivery.js +9 -0
  37. package/dungeons/vertical/gaming.js +10 -0
  38. package/dungeons/vertical/healthcare.js +5 -0
  39. package/dungeons/vertical/insurance-application.js +10 -0
  40. package/dungeons/vertical/logistics.js +8 -1
  41. package/dungeons/vertical/marketplace.js +7 -0
  42. package/dungeons/vertical/media.js +8 -0
  43. package/dungeons/vertical/real-estate.js +7 -1
  44. package/dungeons/vertical/sass.js +12 -0
  45. package/dungeons/vertical/social.js +9 -0
  46. package/dungeons/vertical/travel.js +5 -0
  47. package/index.js +45 -7
  48. package/lib/core/config-validator.js +270 -7
  49. package/lib/core/context.js +58 -0
  50. package/lib/core/dungeon-loader.js +2 -5
  51. package/lib/generators/events.js +12 -13
  52. package/lib/generators/funnels.js +72 -1
  53. package/lib/hook-helpers/index.js +1 -0
  54. package/lib/hook-helpers/inject.js +95 -0
  55. package/lib/orchestrators/mixpanel-sender.js +27 -1
  56. package/lib/orchestrators/user-loop.js +488 -29
  57. package/lib/templates/macro-presets.js +39 -9
  58. package/lib/utils/utils.js +16 -79
  59. package/lib/verify/counting.js +320 -0
  60. package/lib/verify/emulate-breakdown.js +512 -108
  61. package/lib/verify/funnel-engine.js +539 -0
  62. package/lib/verify/identity.js +78 -0
  63. package/lib/verify/index.js +19 -0
  64. package/lib/verify/verify-dungeon.js +58 -0
  65. package/package.json +4 -2
  66. package/types.d.ts +314 -4
  67. package/scripts/smoke-test-all.mjs +0 -162
@@ -168,6 +168,85 @@ function stripKilledConfigKeys(config) {
168
168
  }
169
169
  }
170
170
 
171
+ /**
172
+ * v1.5: validate / default `Funnel.conversionWindowDays` in place.
173
+ *
174
+ * - Missing field + `timeToConvert/24 < 30` → set to 30 (Mixpanel UI default)
175
+ * - Missing field + `timeToConvert/24 >= 30` → auto-bump to `min(180, ceil(ttc * 1.5))` + warn
176
+ * - Field set > 180 → throw (Mixpanel hard cap)
177
+ *
178
+ * Reference: `backend/arb/reader/funnels/conversion_window.cpp`.
179
+ *
180
+ * @param {import('../../types.js').Funnel[]} funnels
181
+ */
182
+ function validateConversionWindow(funnels) {
183
+ const DEFAULT_WINDOW_DAYS = 30;
184
+ const MAX_WINDOW_DAYS = 180;
185
+ for (const f of funnels) {
186
+ if (!f) continue;
187
+ const ttcHours = Number.isFinite(f.timeToConvert) ? f.timeToConvert : 24;
188
+ const ttcDays = ttcHours / 24;
189
+ if (f.conversionWindowDays === undefined || f.conversionWindowDays === null) {
190
+ if (ttcDays >= DEFAULT_WINDOW_DAYS) {
191
+ f.conversionWindowDays = Math.min(MAX_WINDOW_DAYS, Math.ceil(ttcDays * 1.5));
192
+ console.warn(
193
+ `⚠️ Funnel "${f.name || (f.sequence && f.sequence.join(' > '))}": ` +
194
+ `timeToConvert (${ttcDays.toFixed(1)}d) exceeds default 30d conversion window. ` +
195
+ `Auto-set conversionWindowDays=${f.conversionWindowDays}. Set explicitly to silence.`
196
+ );
197
+ } else {
198
+ f.conversionWindowDays = DEFAULT_WINDOW_DAYS;
199
+ }
200
+ } else {
201
+ if (!Number.isFinite(f.conversionWindowDays) || f.conversionWindowDays <= 0) {
202
+ throw new Error(
203
+ `Funnel "${f.name || (f.sequence && f.sequence.join(' > '))}": ` +
204
+ `conversionWindowDays must be a positive finite number (got ${f.conversionWindowDays})`
205
+ );
206
+ }
207
+ if (f.conversionWindowDays > MAX_WINDOW_DAYS) {
208
+ throw new Error(
209
+ `Funnel "${f.name || (f.sequence && f.sequence.join(' > '))}": ` +
210
+ `conversionWindowDays cannot exceed ${MAX_WINDOW_DAYS} (Mixpanel hard cap)`
211
+ );
212
+ }
213
+ }
214
+ }
215
+ }
216
+
217
+ /**
218
+ * v1.5.0: validate `Funnel.exclusionEvents` in place.
219
+ * - Each entry must reference an event in `events[]` (schema-first guarantee).
220
+ * - Warn (don't throw) when an exclusion event is also a step in the funnel — the
221
+ * verifier will treat its presence as a terminator, but the same name appearing as
222
+ * a step is ambiguous.
223
+ *
224
+ * @param {import('../../types.js').Funnel[]} funnels
225
+ * @param {Array<{event?: string}>} events
226
+ */
227
+ function validateExclusionEvents(funnels, events) {
228
+ if (!Array.isArray(funnels) || !Array.isArray(events)) return;
229
+ const eventNames = new Set(events.map(e => e && e.event).filter(Boolean));
230
+ for (const f of funnels) {
231
+ if (!f || !Array.isArray(f.exclusionEvents) || !f.exclusionEvents.length) continue;
232
+ for (const name of f.exclusionEvents) {
233
+ if (!eventNames.has(name)) {
234
+ throw new Error(
235
+ `Funnel "${f.name || (f.sequence && f.sequence.join(' > '))}": ` +
236
+ `exclusionEvents entry "${name}" is not declared in events[]. ` +
237
+ `Add it as an event (schema-first) before referencing it as an exclusion.`
238
+ );
239
+ }
240
+ if (Array.isArray(f.sequence) && f.sequence.includes(name)) {
241
+ console.warn(
242
+ `⚠️ Funnel "${f.name || (f.sequence && f.sequence.join(' > '))}": ` +
243
+ `exclusion event "${name}" is also a funnel step — semantics are ambiguous.`
244
+ );
245
+ }
246
+ }
247
+ }
248
+ }
249
+
171
250
  /**
172
251
  * Validate `Funnel.attempts` config in place. Coerces missing/invalid bounds so callers
173
252
  * downstream don't have to re-defend. Throws on logically invalid configs (max < min).
@@ -373,6 +452,26 @@ export function validateDungeonConfig(config) {
373
452
  avgEventsPerUserPerDay = numEvents / numUsers / numDays;
374
453
  }
375
454
 
455
+ // ── v1.5 Active-day primitive validation ──
456
+ // `avgActiveDaysPerUser` is a CONCENTRATOR — total event count is preserved
457
+ // (`avgEventsPerUserPerDay × numDays`), but events cluster onto fewer days.
458
+ // The implied per-active-day rate inflates: warn when it exceeds 50.
459
+ const avgActiveDaysPerUser = config.avgActiveDaysPerUser;
460
+ if (avgActiveDaysPerUser !== undefined && avgActiveDaysPerUser !== null) {
461
+ if (!Number.isFinite(avgActiveDaysPerUser) || avgActiveDaysPerUser <= 0) {
462
+ throw new Error(`avgActiveDaysPerUser must be a positive finite number (got ${avgActiveDaysPerUser})`);
463
+ }
464
+ const impliedRatePerActiveDay = (avgEventsPerUserPerDay * numDays) / avgActiveDaysPerUser;
465
+ if (impliedRatePerActiveDay > 50) {
466
+ console.warn(
467
+ `⚠️ avgActiveDaysPerUser=${avgActiveDaysPerUser} concentrates ` +
468
+ `${Math.round(avgEventsPerUserPerDay * numDays).toLocaleString()} events into ${avgActiveDaysPerUser} day(s) ` +
469
+ `→ ${impliedRatePerActiveDay.toFixed(0)} events per active day. ` +
470
+ `If you want fewer total events, lower avgEventsPerUserPerDay.`
471
+ );
472
+ }
473
+ }
474
+
376
475
  // Auto-enable batch mode for large datasets to prevent OOM.
377
476
  // MUST run after rate→numEvents resolution above, otherwise dungeons that set
378
477
  // only avgEventsPerUserPerDay would never trigger auto-batch.
@@ -396,14 +495,126 @@ export function validateDungeonConfig(config) {
396
495
  let percentUsersBornInDataset = config.percentUsersBornInDataset !== undefined ? config.percentUsersBornInDataset : macroResolved.percentUsersBornInDataset;
397
496
  let preExistingSpread = config.preExistingSpread !== undefined ? config.preExistingSpread : macroResolved.preExistingSpread;
398
497
 
399
- // Clamp bornRecentBias to [-1, 1] values outside this range produce
400
- // nonsensical exponents (e.g. Math.pow(0, -0.4) = Infinity in user-loop.js).
401
- if (typeof bornRecentBias === 'number' && Number.isFinite(bornRecentBias)) {
402
- bornRecentBias = Math.max(-1, Math.min(1, bornRecentBias));
403
- } else {
498
+ // ── v1.5 Engine-validation strict clamps ──────────────────────────────
499
+ // Pathological knob combinations produce nosedive / right-edge explosion
500
+ // patterns that no engine fix can rescue. We clamp them at validation time
501
+ // with a clear warning so dungeon authors fix the config rather than ship
502
+ // a broken-looking dataset. See `plans/ENGINE-VALIDATION/FIX.md` for the
503
+ // sweep evidence behind each rule.
504
+ // User-explicit detection. Fires when the value comes from EITHER top-level
505
+ // dungeon config OR macro-object override (e.g., `macro: { preset: 'growth',
506
+ // bornRecentBias: 0.5 }`). Both paths represent user intent to override; only
507
+ // raw preset names (e.g., `macro: 'growth'`) are exempt — their preset values
508
+ // are designed to be safe.
509
+ const macroAsObj = (config.macro && typeof config.macro === 'object' && !Array.isArray(config.macro))
510
+ ? /** @type {{preset?: string, percentUsersBornInDataset?: number, bornRecentBias?: number}} */ (config.macro)
511
+ : null;
512
+ const userBornExplicit =
513
+ (config.percentUsersBornInDataset !== undefined && config.percentUsersBornInDataset !== null)
514
+ || (macroAsObj !== null && macroAsObj.percentUsersBornInDataset !== undefined && macroAsObj.percentUsersBornInDataset !== null);
515
+ const userBiasExplicit =
516
+ (config.bornRecentBias !== undefined && config.bornRecentBias !== null)
517
+ || (macroAsObj !== null && macroAsObj.bornRecentBias !== undefined && macroAsObj.bornRecentBias !== null);
518
+
519
+ // Coerce non-finite to 0 first (sanity)
520
+ if (typeof bornRecentBias !== 'number' || !Number.isFinite(bornRecentBias)) {
404
521
  bornRecentBias = 0;
405
522
  }
406
523
 
524
+ // Clamp 1: hard absolute bounds on born% (data sanity)
525
+ if (typeof percentUsersBornInDataset !== 'number' || !Number.isFinite(percentUsersBornInDataset)) {
526
+ percentUsersBornInDataset = 0;
527
+ }
528
+ if (percentUsersBornInDataset > 100) {
529
+ console.warn(`⚠️ percentUsersBornInDataset=${percentUsersBornInDataset} clamped to 100. Values above 100 are not meaningful.`);
530
+ percentUsersBornInDataset = 100;
531
+ }
532
+ if (percentUsersBornInDataset < 0) {
533
+ console.warn(`⚠️ percentUsersBornInDataset=${percentUsersBornInDataset} clamped to 0. Negative values are not meaningful.`);
534
+ percentUsersBornInDataset = 0;
535
+ }
536
+
537
+ // Clamp 2: per-macro born compatibility — fires when the user explicitly opts
538
+ // into a named macro AND explicitly sets born%. Macro = contract: "produce
539
+ // the shape this preset describes". Born% over the cap breaks that contract
540
+ // (cumulative-acquisition right-edge explosion). Caps match each preset's
541
+ // default `percentUsersBornInDataset` to preserve the macro's characteristic
542
+ // shape. Users who need higher born% should switch macros (flat→growth,
543
+ // growth→viral). When no macro is set, the clamp does NOT fire — legacy
544
+ // dungeons that set percentUsersBornInDataset directly without picking a
545
+ // macro keep their existing behavior. Tuned empirically against the
546
+ // engine-validation sweep matrix (research/engine-sweep-pass*.json).
547
+ const MACRO_BORN_CAP = { flat: 12, steady: 12, growth: 30, viral: 55, decline: 5 };
548
+ const macroExplicit = config.macro !== undefined && config.macro !== null;
549
+ const macroKey = (typeof config.macro === 'string')
550
+ ? config.macro
551
+ : (config.macro && config.macro.preset) ? config.macro.preset : 'flat';
552
+ if (userBornExplicit && macroExplicit && MACRO_BORN_CAP[macroKey] !== undefined) {
553
+ const cap = MACRO_BORN_CAP[macroKey];
554
+ if (percentUsersBornInDataset > cap) {
555
+ console.warn(
556
+ `⚠️ macro="${macroKey}" + percentUsersBornInDataset=${percentUsersBornInDataset} ` +
557
+ `clamped to ${cap}. High born% with macro="${macroKey}" produces cumulative-acquisition ` +
558
+ `right-edge explosion. Use macro="growth" or "viral" for genuinely high-born configs. ` +
559
+ `To suppress, fix the config.`
560
+ );
561
+ percentUsersBornInDataset = cap;
562
+ }
563
+ }
564
+
565
+ // Clamp 3: bornRecentBias safe range. Plan PROMPT.md "[-0.5, 0.5]". Only fires
566
+ // on user-set values — viral preset (0.6) is allowed by design.
567
+ if (userBiasExplicit) {
568
+ 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.`);
570
+ bornRecentBias = 0.5;
571
+ }
572
+ 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.`);
574
+ bornRecentBias = -0.5;
575
+ }
576
+ }
577
+
578
+ // Clamp 4: bias × born compound check (only on explicit user values).
579
+ // Plan PROMPT.md: "born > 80 + bias > 0.4 → clamp bias to 0.3".
580
+ if ((userBornExplicit || userBiasExplicit) && percentUsersBornInDataset > 60 && bornRecentBias > 0.4) {
581
+ console.warn(
582
+ `⚠️ percentUsersBornInDataset=${percentUsersBornInDataset} + bornRecentBias=${bornRecentBias} ` +
583
+ `compounds to right-edge explosion. Clamping bornRecentBias to 0.3. To suppress, fix the config.`
584
+ );
585
+ bornRecentBias = 0.3;
586
+ }
587
+
588
+ // Final sanity: bias must be in [-1, 1] (Math.pow guards)
589
+ bornRecentBias = Math.max(-1, Math.min(1, bornRecentBias));
590
+
591
+ // Clamp 5: avgEventsPerUserPerDay safe range. Above 50 produces unrealistic
592
+ // load + memory cost. Plan PROMPT.md: clamp to 50.
593
+ 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.`);
595
+ avgEventsPerUserPerDay = 50;
596
+ numEvents = Math.round(avgEventsPerUserPerDay * numUsers * numDays);
597
+ }
598
+
599
+ // Clamp 6: avgActiveDaysPerUser cap at numDays/2. Above defeats the
600
+ // concentrator purpose. Reassign config so the user-loop sees the clamped value.
601
+ let avgActiveDaysClamped = avgActiveDaysPerUser;
602
+ if (avgActiveDaysPerUser !== undefined && avgActiveDaysPerUser !== null && avgActiveDaysPerUser > numDays * 0.5) {
603
+ 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.`);
605
+ avgActiveDaysClamped = cap;
606
+ }
607
+
608
+ // Clamp 7: numDays minimum. Below 14 makes the strict-bar 14-day window
609
+ // meaningless. We WARN but do NOT clamp here because the dataset window has
610
+ // already been resolved upstream — clamping numDays alone would desync the
611
+ // engine. Pre-validator numDays bound is preferred (validator throws on
612
+ // numDays <= 0 already at line ~422). Just warn for visibility.
613
+ if (numDays < 14) {
614
+ console.warn(`⚠️ numDays=${numDays} < 14. Strict-bar engine-validation metrics use 14-day windows; results are noisy below 14 days. Consider increasing numDays.`);
615
+ }
616
+ // ──────────────────────────────────────────────────────────────────────
617
+
407
618
  // Use provided name if non-empty string, otherwise generate one
408
619
  if (!name || name === "") {
409
620
  name = makeName();
@@ -461,6 +672,26 @@ export function validateDungeonConfig(config) {
461
672
  funnels = [...funnels, ...inferredFunnels];
462
673
  }
463
674
 
675
+ // v1.5: auto-promote funnel-step events to `isStrictEvent: true`. Run BEFORE the
676
+ // catch-all funnel below so the catch-all only sweeps non-strict events. Without
677
+ // this, the greedy single-pass funnel engine consumes standalone instances as
678
+ // funnel step matches — corrupting both the standalone count AND the funnel TTC.
679
+ // Explicit `isStrictEvent: false` opts out (advanced; preserves mixed semantics).
680
+ // Skip `$experiment_started` since it's prepended by experiments, not user-declared.
681
+ const userDeclaredFunnelSteps = new Set(funnels.flatMap(f => Array.isArray(f.sequence) ? f.sequence : []));
682
+ userDeclaredFunnelSteps.delete('$experiment_started');
683
+ for (const ev of events) {
684
+ if (!ev || typeof ev.event !== 'string') continue;
685
+ if (!userDeclaredFunnelSteps.has(ev.event)) continue;
686
+ if (ev.isStrictEvent === false) continue; // explicit opt-out
687
+ if (ev.isStrictEvent === true) continue; // already set
688
+ ev.isStrictEvent = true;
689
+ console.warn(
690
+ `⚠️ Auto-promoted "${ev.event}" to isStrictEvent: true (appears as a funnel step). ` +
691
+ `Set isStrictEvent: false to opt out and allow standalone instances.`
692
+ );
693
+ }
694
+
464
695
  // Create funnel for events not in other funnels
465
696
  const eventContainedInFunnels = Array.from(funnels.reduce((acc, f) => {
466
697
  const events = f.sequence;
@@ -494,7 +725,15 @@ export function validateDungeonConfig(config) {
494
725
  sequence,
495
726
  conversionRate: 50,
496
727
  order: 'random',
497
- timeToConvert: 24 * 14,
728
+ // v1.5 engine bunchiness fix: shortened catch-all ttc from 14d → 1d.
729
+ // The user-loop fix constrains funnel step1's TimeSoup `latestTime` to
730
+ // `FIXED_NOW - ttc` to prevent spillover-and-_drop. With ttc=14d, this
731
+ // created a 14-day "no event zone" at the right edge of every dataset
732
+ // — flattening growth/viral macros into near-baseline shapes. ttc=1d
733
+ // gives the catch-all a 1-day right-edge zone, restoring magnitude
734
+ // distinction across macro presets while keeping spillover near zero.
735
+ // Explicit user-defined funnels keep their declared `timeToConvert`.
736
+ timeToConvert: 24,
498
737
  requireRepeats: false,
499
738
  });
500
739
  }
@@ -510,7 +749,7 @@ export function validateDungeonConfig(config) {
510
749
 
511
750
 
512
751
 
513
- // Event validation
752
+ // Event validation
514
753
  const validatedEvents = u.validateEventConfig(events);
515
754
 
516
755
  // ── Validate and resolve advanced features ──
@@ -542,6 +781,12 @@ export function validateDungeonConfig(config) {
542
781
  // Phase 1: validate Funnel.attempts on every funnel (additive — most have none).
543
782
  validateAttempts(funnels);
544
783
 
784
+ // v1.5: default + auto-bump Funnel.conversionWindowDays.
785
+ validateConversionWindow(funnels);
786
+
787
+ // v1.5.0: validate Funnel.exclusionEvents — entries must exist in events[].
788
+ validateExclusionEvents(funnels, validatedEvents);
789
+
545
790
  // Normalize experiment configs: true → default 3-variant, object → validated.
546
791
  normalizeExperiments(funnels, datasetEndUnix);
547
792
 
@@ -565,6 +810,16 @@ export function validateDungeonConfig(config) {
565
810
  // Precompute whether any event has isAttributionEvent for UTM stamping logic.
566
811
  const hasAttributionFlags = validatedEvents.some(e => e.isAttributionEvent);
567
812
 
813
+ // v1.5: Touchpoint cap. Default 10 (Mixpanel TOUCHPOINTS_LIMIT). Setting Infinity
814
+ // disables the cap (every eligible event gets stamped). Negative or zero disables
815
+ // stamping entirely (treat as "don't apply touchpoint cap pass").
816
+ let maxTouchpointsPerUser = config.maxTouchpointsPerUser;
817
+ if (maxTouchpointsPerUser === undefined || maxTouchpointsPerUser === null) {
818
+ maxTouchpointsPerUser = 10;
819
+ } else if (maxTouchpointsPerUser !== Infinity && (!Number.isFinite(maxTouchpointsPerUser) || maxTouchpointsPerUser < 0)) {
820
+ throw new Error(`maxTouchpointsPerUser must be a non-negative finite number or Infinity (got ${maxTouchpointsPerUser})`);
821
+ }
822
+
568
823
  // Build final config object
569
824
  const validatedConfig = {
570
825
  ...config,
@@ -616,6 +871,14 @@ export function validateDungeonConfig(config) {
616
871
  bornRecentBias,
617
872
  percentUsersBornInDataset,
618
873
  preExistingSpread,
874
+ // v1.5 distinct-day primitive (concentrator). undefined = legacy behavior.
875
+ avgActiveDaysPerUser: avgActiveDaysClamped !== undefined && avgActiveDaysClamped !== null
876
+ ? avgActiveDaysClamped
877
+ : undefined,
878
+ // v1.5 attribution touchpoint cap (Mixpanel TOUCHPOINTS_LIMIT = 10).
879
+ maxTouchpointsPerUser,
880
+ // v1.5 auto-sort after everything hook. Default true. Opt out with explicit `false`.
881
+ autoSortAfterEverything: config.autoSortAfterEverything !== false,
619
882
  // Advanced features (kept after 1.4)
620
883
  personas,
621
884
  worldEvents,
@@ -8,6 +8,8 @@
8
8
  /** @typedef {import('../../types.js').Context} Context */
9
9
  /** @typedef {import('../../types.js').RuntimeState} RuntimeState */
10
10
  /** @typedef {import('../../types.js').Defaults} Defaults */
11
+ /** @typedef {import('../../types.js').ProgressUpdate} ProgressUpdate */
12
+ /** @typedef {import('../../types.js').ProgressSummary} ProgressSummary */
11
13
 
12
14
  import dayjs from "dayjs";
13
15
  import { campaigns, devices, locations } from '../templates/defaults.js';
@@ -78,6 +80,58 @@ function createRuntimeState() {
78
80
  };
79
81
  }
80
82
 
83
+ /**
84
+ * @param {Dungeon} config
85
+ * @returns {{ reportProgress: (update: ProgressUpdate) => void, getProgressSummary: () => ProgressSummary }}
86
+ */
87
+ function createProgressReporter(config) {
88
+ const interval = config.progressInterval ?? 500;
89
+ const verbose = config.verbose || false;
90
+ let callback = config.onProgress ?? null;
91
+ let lastFireTime = 0;
92
+ let errorCount = 0;
93
+ let totalUpdates = 0;
94
+ let disabled = false;
95
+
96
+ if (callback !== null && typeof callback !== 'function') {
97
+ if (verbose) console.warn(`[dungeon-master] onProgress is not a function (got ${typeof callback}), ignoring`);
98
+ callback = null;
99
+ }
100
+
101
+ function reportProgress(/** @type {ProgressUpdate} */ update) {
102
+ if (!callback || disabled) return;
103
+
104
+ const isThrottled = update.phase === 'generation' || update.phase === 'import';
105
+ if (isThrottled) {
106
+ const now = Date.now();
107
+ if (now - lastFireTime < interval) return;
108
+ lastFireTime = now;
109
+ }
110
+
111
+ try {
112
+ const result = /** @type {any} */ (callback(update));
113
+ totalUpdates++;
114
+ if (result && typeof result.then === 'function') {
115
+ result.then(undefined, (/** @type {any} */ err) => {
116
+ errorCount++;
117
+ if (verbose) console.warn(`[dungeon-master] onProgress async error (${errorCount}/3): ${err?.message || err}`);
118
+ if (errorCount >= 3) disabled = true;
119
+ });
120
+ }
121
+ } catch (err) {
122
+ errorCount++;
123
+ if (verbose) console.warn(`[dungeon-master] onProgress error (${errorCount}/3): ${err?.message || err}`);
124
+ if (errorCount >= 3) disabled = true;
125
+ }
126
+ }
127
+
128
+ function getProgressSummary() {
129
+ return { updates: totalUpdates, errors: errorCount, disabled };
130
+ }
131
+
132
+ return { reportProgress, getProgressSummary };
133
+ }
134
+
81
135
  /**
82
136
  * Context factory that creates a complete context object for data generation
83
137
  * @param {Dungeon} config - Validated configuration object
@@ -99,12 +153,16 @@ export function createContext(config, storage = null, timeConstants = {}) {
99
153
  runtime.verbose = config.verbose || false;
100
154
  runtime.isBatchMode = config.batchSize && config.batchSize < config.numEvents;
101
155
 
156
+ const { reportProgress, getProgressSummary } = createProgressReporter(config);
157
+
102
158
  const context = {
103
159
  config,
104
160
  storage,
105
161
  defaults,
106
162
  campaigns: campaignData,
107
163
  runtime,
164
+ reportProgress,
165
+ getProgressSummary,
108
166
 
109
167
  // Helper methods for updating state
110
168
  incrementOperations() {
@@ -4,14 +4,11 @@
4
4
  */
5
5
 
6
6
  import path from 'path';
7
+ import os from 'os';
7
8
  import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync } from 'fs';
8
- import { fileURLToPath } from 'url';
9
9
  import { randomBytes } from 'crypto';
10
10
  import Chance from 'chance';
11
11
 
12
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
- const PACKAGE_ROOT = path.resolve(__dirname, '..', '..');
14
-
15
12
  /**
16
13
  * Detect what kind of input was passed and normalize it
17
14
  * @param {any} input - The raw input to DUNGEON_MASTER
@@ -221,7 +218,7 @@ function reviveFunctionObject(obj) {
221
218
  * @returns {Promise<import('../../types').Dungeon>}
222
219
  */
223
220
  export async function loadFromText(code) {
224
- const tmpDir = path.join(PACKAGE_ROOT, '.dungeon-tmp');
221
+ const tmpDir = path.join(os.tmpdir(), 'dungeon-master');
225
222
  const tmpId = randomBytes(8).toString('hex');
226
223
  const tmpFile = path.join(tmpDir, `dungeon-${tmpId}.mjs`);
227
224
 
@@ -92,30 +92,29 @@ export async function makeEvent(
92
92
  defaultProps.browser = u.choose(defaults.browsers());
93
93
  }
94
94
 
95
- // Add campaigns with attribution likelihood.
96
- // When any event has isAttributionEvent, only stamp UTMs on those events (25% chance).
97
- // Otherwise, backwards-compat: ~25% of all events get UTMs.
98
- if (hasCampaigns) {
99
- const shouldStamp = config.hasAttributionFlags
100
- ? (chosenEvent.isAttributionEvent && chance.bool({ likelihood: 25 }))
101
- : chance.bool({ likelihood: 25 });
102
- if (shouldStamp) {
103
- defaultProps.campaigns = u.pickRandom(defaults.campaigns());
104
- }
105
- }
95
+ // v1.5: UTM stamping moved to per-user post-generation pass in user-loop.js
96
+ // (`applyTouchpointCap`). The pass identifies eligible events (per
97
+ // `isAttributionEvent` flag presence), samples up to `maxTouchpointsPerUser`
98
+ // (default 10) across the user's lifetime, and stamps UTMs on the sample.
99
+ // Stamping here per-event would defeat the lifetime-distributed sampling.
106
100
 
107
101
  // PERFORMANCE: Use pre-computed device pool instead of rebuilding every time
108
102
  if (defaults.allDevices.length) {
109
103
  defaultProps.device = u.pickRandom(defaults.allDevices);
110
104
  }
111
105
 
112
- // Set event time using TimeSoup for realistic distribution
106
+ // Set event time using TimeSoup for realistic distribution.
107
+ // v1.5: active-day mode passes `featureCtx.latestTime` to constrain TimeSoup
108
+ // to a specific UTC day. When unset, defaults to FIXED_NOW (legacy behavior).
113
109
  if (earliestTime) {
114
110
  let unixTime;
115
111
  if (isFirstEvent) {
116
112
  unixTime = earliestTime;
117
113
  } else {
118
- unixTime = u.TimeSoup(earliestTime, context.FIXED_NOW, peaks, deviation, mean, dayOfWeekWeights, hourOfDayWeights);
114
+ const latestTime = (featureCtx && Number.isFinite(featureCtx.latestTime))
115
+ ? featureCtx.latestTime
116
+ : context.FIXED_NOW;
117
+ unixTime = u.TimeSoup(earliestTime, latestTime, peaks, deviation, mean, dayOfWeekWeights, hourOfDayWeights);
119
118
  }
120
119
  eventTemplate.time = dayjs.unix(unixTime).toISOString();
121
120
  }
@@ -249,19 +249,42 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
249
249
  numStepsUserWillTake
250
250
  );
251
251
 
252
+ // v1.5: cap the funnel's total span at conversionWindowDays * 86400000 - 1 ms
253
+ // (1ms slack to clear Mixpanel's strict-`<` boundary in conversion_window.cpp).
254
+ // When a funnel's `timeToConvert` would push the last step past the window, scale
255
+ // all relative offsets proportionally to fit. Validator already auto-bumped
256
+ // `conversionWindowDays` for long-TTC funnels, so this rarely fires — but it
257
+ // makes the contract explicit at generation time.
258
+ const conversionWindowDays = funnel.conversionWindowDays;
259
+ if (Number.isFinite(conversionWindowDays) && conversionWindowDays > 0 && funnelEventsWithTiming.length > 1) {
260
+ const maxSpanMs = conversionWindowDays * 86400000 - 1;
261
+ const lastEvent = funnelEventsWithTiming[funnelEventsWithTiming.length - 1];
262
+ if (Number.isFinite(lastEvent.relativeTimeMs) && lastEvent.relativeTimeMs > maxSpanMs) {
263
+ const scale = maxSpanMs / lastEvent.relativeTimeMs;
264
+ for (let i = 1; i < funnelEventsWithTiming.length; i++) {
265
+ if (Number.isFinite(funnelEventsWithTiming[i].relativeTimeMs)) {
266
+ funnelEventsWithTiming[i].relativeTimeMs = Math.floor(funnelEventsWithTiming[i].relativeTimeMs * scale);
267
+ }
268
+ }
269
+ }
270
+ }
271
+
252
272
  // Add session start event if configured (clone to avoid mutating shared config)
253
273
  if (sessionStartEvents.length) {
254
274
  const sessionStartEvent = { ...chance.pickone(sessionStartEvents), relativeTimeMs: -15000 };
255
275
  funnelEventsWithTiming.push(sessionStartEvent);
256
276
  }
257
277
 
258
- // Build complete feature context: merge passed-in featureCtx with config fallbacks
278
+ // Build complete feature context: merge passed-in featureCtx with config fallbacks.
279
+ // v1.5: preserve `latestTime` if active-day mode is in effect, so makeEvent's
280
+ // TimeSoup constrains the funnel's first event to the picked day.
259
281
  const funnelFeatureCtx = {
260
282
  persona: featureCtx.persona || persona || null,
261
283
  userCampaign: featureCtx.userCampaign || null,
262
284
  userLocation: featureCtx.userLocation || null,
263
285
  worldEventsTimeline: featureCtx.worldEventsTimeline || context.config.worldEvents || null,
264
286
  dataQuality: featureCtx.dataQuality || context.config.dataQuality || null,
287
+ latestTime: Number.isFinite(featureCtx.latestTime) ? featureCtx.latestTime : undefined,
265
288
  };
266
289
 
267
290
  // Pre-compute per-step stamping modes for execution order. For isFirstFunnel + isBorn
@@ -306,6 +329,54 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
306
329
  ? Date.parse(finalEvents[runAuthExecIdx].time) || null
307
330
  : null;
308
331
 
332
+ // v1.5.0: inject exclusion events for non-converters. When `funnel.exclusionEvents`
333
+ // is set and the user dropped off mid-funnel (≥1 step completed but < sequence.length),
334
+ // stamp 1-2 cloned events bearing one of the listed exclusion event names between the
335
+ // last completed step and where the next step would have been. The verifier reads
336
+ // `funnel.exclusionEvents` and applies them as exclusionSteps to terminate the attempt.
337
+ //
338
+ // Schema-first: copy ONLY identity + super props + group keys from the source event,
339
+ // plus props declared on the exclusion event's own config. Source-event-specific
340
+ // props (e.g. `cart_value` on `Add to Cart`) MUST NOT bleed onto a different event
341
+ // type or the schema validator will flag undeclared columns.
342
+ if (Array.isArray(funnel.exclusionEvents) && funnel.exclusionEvents.length
343
+ && !doesUserConvert && finalEvents.length > 0 && finalEvents.length < sequence.length) {
344
+ const lastEvent = finalEvents[finalEvents.length - 1];
345
+ const lastTimeMs = Date.parse(lastEvent.time);
346
+ if (Number.isFinite(lastTimeMs)) {
347
+ const IDENTITY_KEYS = ['user_id', 'device_id', 'distinct_id', 'session_id', 'insert_id'];
348
+ const superPropKeys = Object.keys(superProps || {});
349
+ const groupKeyNames = (groupKeys || []).map(gk => Array.isArray(gk) ? gk[0] : gk).filter(Boolean);
350
+ const numToInject = chance.integer({ min: 1, max: 2 });
351
+ for (let i = 0; i < numToInject; i++) {
352
+ const excName = chance.pickone(funnel.exclusionEvents);
353
+ const excConfig = (config.events || []).find(e => e.event === excName);
354
+ const offsetMs = (i + 1) * chance.integer({ min: 30_000, max: 300_000 });
355
+ const cloned = {
356
+ event: excName,
357
+ time: new Date(lastTimeMs + offsetMs).toISOString(),
358
+ };
359
+ // Identity from source event (correct user/device/session attribution).
360
+ for (const k of IDENTITY_KEYS) {
361
+ if (k in lastEvent) cloned[k] = lastEvent[k];
362
+ }
363
+ // Super props + group keys carry over (user-stable values).
364
+ for (const k of superPropKeys) if (k in lastEvent) cloned[k] = lastEvent[k];
365
+ for (const k of groupKeyNames) if (k in lastEvent) cloned[k] = lastEvent[k];
366
+ // Resolve declared props on the exclusion event's config.
367
+ if (excConfig && excConfig.properties) {
368
+ for (const k of Object.keys(excConfig.properties)) {
369
+ try { cloned[k] = u.choose(excConfig.properties[k]); }
370
+ catch (e) { cloned[k] = null; }
371
+ }
372
+ }
373
+ // Fresh insert_id so the new event isn't a duplicate of the source.
374
+ cloned.insert_id = `${excName}-${cloned.time}-${chance.string({ length: 10, alpha: true })}`;
375
+ finalEvents.push(cloned);
376
+ }
377
+ }
378
+ }
379
+
309
380
  // Call post-funnel hook
310
381
  await hook(finalEvents, "funnel-post", {
311
382
  user, profile, scd, funnel, config,
@@ -36,6 +36,7 @@ export {
36
36
  injectAfterEvent,
37
37
  injectBetween,
38
38
  injectBurst,
39
+ injectOnNewDays,
39
40
  } from './inject.js';
40
41
 
41
42
  export {