@ak--47/dungeon-master 1.2.3 → 1.3.1

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 (89) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +51 -13
  3. package/dungeons/technical/ad-spend.js +2 -2
  4. package/dungeons/technical/anonymous-users.js +2 -2
  5. package/dungeons/technical/array-of-object-lookup.js +2 -4
  6. package/dungeons/technical/experiments.js +2 -2
  7. package/dungeons/technical/foobar.js +2 -2
  8. package/dungeons/technical/group-analytics.js +2 -2
  9. package/dungeons/technical/mirror-strategies.js +2 -2
  10. package/dungeons/technical/nested-objects.js +2 -2
  11. package/dungeons/technical/retention-cadence.js +2 -3
  12. package/dungeons/technical/sanity.js +2 -2
  13. package/dungeons/technical/scale-test.js +2 -2
  14. package/dungeons/technical/scd.js +2 -2
  15. package/dungeons/technical/simple.js +5 -6
  16. package/dungeons/technical/simplest-schema.json +5 -0
  17. package/dungeons/technical/simplest.js +2 -2
  18. package/dungeons/technical/text-generation.js +3 -3
  19. package/dungeons/vertical/ai-platform.js +858 -0
  20. package/dungeons/vertical/community.js +84 -40
  21. package/dungeons/vertical/crypto.js +830 -0
  22. package/dungeons/vertical/dating.js +744 -0
  23. package/dungeons/vertical/devtools.js +175 -69
  24. package/dungeons/vertical/ecommerce.js +242 -94
  25. package/dungeons/vertical/education.js +330 -313
  26. package/dungeons/vertical/fintech.js +442 -313
  27. package/dungeons/vertical/fitness.js +143 -61
  28. package/dungeons/vertical/food-delivery.js +327 -353
  29. package/dungeons/vertical/gaming.js +912 -382
  30. package/dungeons/vertical/healthcare.js +142 -63
  31. package/dungeons/vertical/insurance-application.js +170 -76
  32. package/dungeons/vertical/logistics.js +115 -20
  33. package/dungeons/vertical/marketplace.js +152 -58
  34. package/dungeons/vertical/media.js +248 -384
  35. package/dungeons/vertical/real-estate.js +781 -0
  36. package/dungeons/vertical/sass.js +255 -266
  37. package/dungeons/vertical/social.js +264 -206
  38. package/dungeons/vertical/travel.js +117 -41
  39. package/index.js +17 -17
  40. package/lib/core/config-validator.js +159 -31
  41. package/lib/core/context.js +10 -24
  42. package/lib/core/storage.js +6 -1
  43. package/lib/generators/events.js +11 -14
  44. package/lib/generators/funnels.js +12 -4
  45. package/lib/generators/mirror.js +3 -2
  46. package/lib/generators/product-names.js +1 -1
  47. package/lib/generators/scd.js +2 -1
  48. package/lib/generators/text.js +1 -1
  49. package/lib/orchestrators/user-loop.js +81 -47
  50. package/lib/templates/macro-presets.js +111 -0
  51. package/lib/templates/soup-presets.js +19 -36
  52. package/lib/utils/utils.js +71 -39
  53. package/package.json +8 -2
  54. package/scripts/smoke-test-all.mjs +162 -0
  55. package/scripts/verify-runner.mjs +72 -24
  56. package/types.d.ts +251 -51
  57. package/dungeons/technical/ad-spend-schema.json +0 -128
  58. package/dungeons/technical/anonymous-users-schema.json +0 -92
  59. package/dungeons/technical/array-of-object-lookup-schema.json +0 -191
  60. package/dungeons/technical/experiments-schema.json +0 -203
  61. package/dungeons/technical/foobar-schema.json +0 -362
  62. package/dungeons/technical/group-analytics-schema.json +0 -241
  63. package/dungeons/technical/mirror-strategies-schema.json +0 -84
  64. package/dungeons/technical/nested-objects-schema.json +0 -145
  65. package/dungeons/technical/retention-cadence-schema.json +0 -37
  66. package/dungeons/technical/sanity-schema.json +0 -185
  67. package/dungeons/technical/scale-test-schema.json +0 -70
  68. package/dungeons/technical/scd-schema.json +0 -467
  69. package/dungeons/technical/simple-schema.json +0 -362
  70. package/dungeons/technical/text-generation-schema.json +0 -1062
  71. package/dungeons/user/.gitkeep +0 -0
  72. package/dungeons/vertical/community-schema.json +0 -579
  73. package/dungeons/vertical/devtools-schema.json +0 -601
  74. package/dungeons/vertical/ecommerce-schema.json +0 -604
  75. package/dungeons/vertical/education-schema.json +0 -5686
  76. package/dungeons/vertical/fintech-schema.json +0 -630
  77. package/dungeons/vertical/fitness-schema.json +0 -530
  78. package/dungeons/vertical/food-delivery-schema.json +0 -36728
  79. package/dungeons/vertical/gaming-schema.json +0 -438
  80. package/dungeons/vertical/healthcare-schema.json +0 -549
  81. package/dungeons/vertical/insurance-application-schema.json +0 -485
  82. package/dungeons/vertical/logistics-schema.json +0 -574
  83. package/dungeons/vertical/marketplace-schema.json +0 -533
  84. package/dungeons/vertical/media-schema.json +0 -4749
  85. package/dungeons/vertical/rpg-schema.json +0 -2491
  86. package/dungeons/vertical/rpg.js +0 -976
  87. package/dungeons/vertical/sass-schema.json +0 -3128
  88. package/dungeons/vertical/social-schema.json +0 -620
  89. package/dungeons/vertical/travel-schema.json +0 -580
@@ -2,7 +2,7 @@
2
2
  const SEED = "dm4-travel";
3
3
  const num_days = 100;
4
4
  const num_users = 5_000;
5
- const avg_events_per_user = 120;
5
+ const avg_events_per_user_per_day = 1.2;
6
6
  let token = "your-mixpanel-token";
7
7
 
8
8
  // ── env overrides ──
@@ -16,9 +16,6 @@ import * as v from "ak-tools";
16
16
 
17
17
  dayjs.extend(utc);
18
18
  const chance = u.initChance(SEED);
19
- const NOW = dayjs();
20
- const DATASET_START = NOW.subtract(num_days, "days");
21
-
22
19
  /** @typedef {import("../../types").Dungeon} Config */
23
20
 
24
21
  const hotelIds = v.range(1, 200).map(() => `HTL_${v.uid(6)}`);
@@ -48,7 +45,16 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
48
45
 
49
46
  /**
50
47
  * ═══════════════════════════════════════════════════════════════
51
- * ANALYTICS HOOKS (8 hooks)
48
+ * ANALYTICS HOOKS (10 hooks)
49
+ *
50
+ * NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable via
51
+ * raw-prop breakdowns (booking_window, day, segment) or behavioral cohorts.
52
+ *
53
+ * Adds 9. BOOKING TIME-TO-CONVERT (Business 1.35x faster, Budget 1.25x slower)
54
+ * [funnel-post: visible only in Mixpanel funnel median TTC; cross-event
55
+ * MIN→MIN SQL queries do NOT show this]
56
+ * and 10. HOTEL-VIEWED MAGIC NUMBER (sweet 5-10 → +30% nightly_rate;
57
+ * over 11+ → drop 35% of bookings).
52
58
  * ═══════════════════════════════════════════════════════════════
53
59
  *
54
60
  * ───────────────────────────────────────────────────────────────
@@ -206,8 +212,10 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
206
212
  const config = {
207
213
  token,
208
214
  seed: SEED,
209
- numDays: num_days,
210
- numEvents: num_users * avg_events_per_user,
215
+ datasetStart: "2026-01-01T00:00:00Z",
216
+ datasetEnd: "2026-04-28T23:59:59Z",
217
+ // numDays: num_days,
218
+ avgEventsPerUserPerDay: avg_events_per_user_per_day,
211
219
  numUsers: num_users,
212
220
  hasAnonIds: false,
213
221
  hasSessionIds: true,
@@ -222,7 +230,6 @@ const config = {
222
230
  hasCampaigns: false,
223
231
  isAnonymous: false,
224
232
  hasAdSpend: false,
225
- percentUsersBornInDataset: 35,
226
233
  hasAvatar: true,
227
234
  concurrency: 1,
228
235
  writeToDisk: false,
@@ -640,36 +647,34 @@ const config = {
640
647
  }
641
648
  }
642
649
 
643
- // ── HOOK 8: REPEAT DESTINATION CLUSTERING (funnel-pre)
644
- // conversionRate modifications moved to everything hook (event filtering)
645
- // to avoid dilution by organic events
646
- if (type === "funnel-pre") {
647
- // prop-setting only; no conversionRate changes
648
- }
649
-
650
- // ── HOOK 1: WEEKEND LEISURE SURGE (event) ────────────
651
- if (type === "event") {
652
- if (record.event === "booking completed") {
653
- const dayOfWeek = dayjs(record.time).day();
654
- // Friday=5, Saturday=6, Sunday=0
655
- if (dayOfWeek === 0 || dayOfWeek === 5 || dayOfWeek === 6) {
656
- record.nightly_rate = Math.floor((record.nightly_rate || 150) * 1.3);
657
- record.total_cost = Math.floor((record.total_cost || 450) * 1.3);
650
+ // HOOK 9 (T2C): BOOKING TIME-TO-CONVERT (funnel-post)
651
+ // Business travelers complete the Search-to-Book funnel 1.35x faster
652
+ // (factor 0.74); leisure_family/budget customers 1.25x slower (1.25).
653
+ if (type === "funnel-post") {
654
+ const segment = meta?.profile?.customer_segment;
655
+ if (Array.isArray(record) && record.length > 1) {
656
+ const factor = (
657
+ segment === "business_traveler" ? 0.74 :
658
+ segment === "budget_hunter" || segment === "leisure_family" ? 1.25 :
659
+ 1.0
660
+ );
661
+ if (factor !== 1.0) {
662
+ for (let i = 1; i < record.length; i++) {
663
+ const prev = dayjs(record[i - 1].time);
664
+ const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
665
+ record[i].time = prev.add(newGap, "milliseconds").toISOString();
666
+ }
658
667
  }
659
668
  }
669
+ }
660
670
 
661
- // ── HOOK 2: ADVANCE BOOKING DISCOUNT (event) ─────
662
- if (record.event === "booking completed") {
663
- const eventTime = dayjs(record.time);
664
- const daysUntilEnd = NOW.diff(eventTime, "days");
665
- if (daysUntilEnd > 21) {
666
- record.booking_window = "advance";
667
- record.nightly_rate = Math.floor((record.nightly_rate || 150) * 0.8);
668
- } else if (daysUntilEnd < 3) {
669
- record.booking_window = "last_minute";
670
- record.nightly_rate = Math.floor((record.nightly_rate || 150) * 1.4);
671
- }
672
- }
671
+ // ── HOOK 1: WEEKEND LEISURE SURGE ────────────
672
+ // Moved to everything hook (sessionization reassigns times after event hook)
673
+
674
+ // ── HOOK 2: ADVANCE BOOKING DISCOUNT ─────
675
+ // Moved to everything hook (sessionization reassigns times after event hook)
676
+ if (type === "event") {
677
+ // no-op: DOW-dependent hooks moved to everything hook
673
678
  }
674
679
 
675
680
  // ── EVERYTHING HOOKS ─────────────────────────────────
@@ -689,17 +694,45 @@ const config = {
689
694
  });
690
695
  }
691
696
 
697
+ // ── HOOK 1: WEEKEND LEISURE SURGE ────────────
698
+ // Apply AFTER sessionization has finalized times
699
+ events.forEach(e => {
700
+ if (e.event === "booking completed") {
701
+ const dayOfWeek = new Date(e.time).getUTCDay();
702
+ // Friday=5, Saturday=6, Sunday=0
703
+ if (dayOfWeek === 0 || dayOfWeek === 5 || dayOfWeek === 6) {
704
+ e.nightly_rate = Math.floor((e.nightly_rate || 150) * 1.3);
705
+ e.total_cost = Math.floor((e.total_cost || 450) * 1.3);
706
+ }
707
+ }
708
+ });
709
+
710
+ // ── HOOK 2: ADVANCE BOOKING DISCOUNT ─────
711
+ const datasetEndForBooking = dayjs.unix(meta.datasetEnd);
712
+ events.forEach(e => {
713
+ if (e.event === "booking completed") {
714
+ const eventTime = dayjs(e.time);
715
+ const daysUntilEnd = datasetEndForBooking.diff(eventTime, "days");
716
+ if (daysUntilEnd > 21) {
717
+ e.booking_window = "advance";
718
+ e.nightly_rate = Math.floor((e.nightly_rate || 150) * 0.8);
719
+ } else if (daysUntilEnd < 3) {
720
+ e.booking_window = "last_minute";
721
+ e.nightly_rate = Math.floor((e.nightly_rate || 150) * 1.4);
722
+ }
723
+ }
724
+ });
725
+
692
726
  // ─── Bug 2 fix: Repeat destination clustering conversion filtering ───
693
727
  // Drop ~25% of "booking completed" events for users who are NOT
694
728
  // business_traveler or luxury_seeker to simulate their lower funnel
695
729
  // conversion (was conversionRate * 1.3 / 1.15 in funnel-pre)
696
730
  const segment = profile && profile.customer_segment;
697
- if (segment !== "business_traveler" && segment !== "luxury_seeker") {
731
+ if (segment !== "business_traveler" && segment !== "luxury_seeker"
732
+ && chance.bool({ likelihood: 25 })) {
698
733
  for (let i = events.length - 1; i >= 0; i--) {
699
734
  if (events[i].event === "booking completed") {
700
- if (chance.bool({ likelihood: 25 })) {
701
- events.splice(i, 1);
702
- }
735
+ events.splice(i, 1);
703
736
  }
704
737
  }
705
738
  }
@@ -716,7 +749,29 @@ const config = {
716
749
  }
717
750
 
718
751
  // ── HOOK 4: CANCELLATION BY BOOKING WINDOW ───────
719
- // Last-minute bookers rarely cancel.
752
+ // Match each cancellation to its nearest preceding booking
753
+ // and copy the booking's booking_window. This replaces the
754
+ // random booking_window that the event config assigns to
755
+ // cancellation events independently.
756
+ const bookingsByTime = events
757
+ .filter(e => e.event === "booking completed")
758
+ .sort((a, b) => new Date(a.time) - new Date(b.time));
759
+ events.forEach(e => {
760
+ if (e.event === "booking cancelled" && bookingsByTime.length > 0) {
761
+ const cancelTime = new Date(e.time).getTime();
762
+ // Find the nearest preceding booking (or the first one)
763
+ let matched = bookingsByTime[0];
764
+ for (let b = bookingsByTime.length - 1; b >= 0; b--) {
765
+ if (new Date(bookingsByTime[b].time).getTime() <= cancelTime) {
766
+ matched = bookingsByTime[b];
767
+ break;
768
+ }
769
+ }
770
+ e.booking_window = matched.booking_window;
771
+ }
772
+ });
773
+
774
+ // Last-minute bookers rarely cancel — drop 40%.
720
775
  for (let i = events.length - 1; i >= 0; i--) {
721
776
  if (events[i].event === "booking cancelled" && events[i].booking_window === "last_minute") {
722
777
  if (chance.bool({ likelihood: 40 })) {
@@ -743,7 +798,8 @@ const config = {
743
798
  }
744
799
  }
745
800
 
746
- // ── HOOK 6: REVIEW QUALITY BY STAY RATING ────────
801
+ // HOOK 6: REVIEW QUALITY BY STAY RATING — high avg ratings get
802
+ // review_length 1.5x; low avg ratings get 0.5x. Mutates raw prop.
747
803
  let totalRating = 0;
748
804
  let ratingCount = 0;
749
805
  events.forEach(e => {
@@ -763,6 +819,26 @@ const config = {
763
819
  }
764
820
  });
765
821
 
822
+ // HOOK 10: HOTEL-VIEWED MAGIC NUMBER (in-funnel, no flags)
823
+ // Sweet 5-10 hotel-viewed events → +30% on booking nightly_rate
824
+ // (decisive comparison shoppers book higher-tier rooms).
825
+ // Over 11+ → drop 35% of booking-completed events (analysis
826
+ // paralysis blocks conversion).
827
+ const hotelViews = events.filter(e => e.event === "hotel viewed").length;
828
+ if (hotelViews >= 5 && hotelViews <= 10) {
829
+ events.forEach(e => {
830
+ if (e.event === "booking completed" && typeof e.nightly_rate === "number") {
831
+ e.nightly_rate = Math.round(e.nightly_rate * 1.3);
832
+ }
833
+ });
834
+ } else if (hotelViews >= 11) {
835
+ for (let i = events.length - 1; i >= 0; i--) {
836
+ if (events[i].event === "booking completed" && chance.bool({ likelihood: 35 })) {
837
+ events.splice(i, 1);
838
+ }
839
+ }
840
+ }
841
+
766
842
  return record;
767
843
  }
768
844
 
package/index.js CHANGED
@@ -25,7 +25,7 @@ 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 } from './lib/utils/utils.js';
28
+ import { initChance, setDatasetNow } from './lib/utils/utils.js';
29
29
 
30
30
  // External dependencies
31
31
  import dayjs from "dayjs";
@@ -33,12 +33,8 @@ import utc from "dayjs/plugin/utc.js";
33
33
  import { timer } from 'ak-tools';
34
34
  import { dataLogger as logger } from './lib/utils/logger.js';
35
35
 
36
- // Initialize dayjs and time constants
36
+ // Initialize dayjs (window anchors are now resolved per-config in config-validator)
37
37
  dayjs.extend(utc);
38
- const FIXED_NOW = dayjs('2024-02-02').unix();
39
- global.FIXED_NOW = FIXED_NOW;
40
- let FIXED_BEGIN = dayjs.unix(FIXED_NOW).subtract(90, 'd').unix();
41
- global.FIXED_BEGIN = FIXED_BEGIN;
42
38
 
43
39
 
44
40
  /**
@@ -134,18 +130,21 @@ async function runDungeon(config) {
134
130
  initChance(config.seed);
135
131
  }
136
132
 
137
- // Step 1: Validate and enrich configuration
133
+ // Step 1: Validate and enrich configuration (resolves dataset window)
138
134
  validatedConfig = validateDungeonConfig(config);
139
135
 
140
- // Compute FIXED_BEGIN from validated numDays
141
- const configNumDays = validatedConfig.numDays || 30;
142
- const fixedBegin = dayjs.unix(FIXED_NOW).subtract(configNumDays, 'd').unix();
136
+ // validateDungeonConfig always resolves these to unix seconds, but the
137
+ // public Dungeon type accepts string | number on input. Narrow here.
138
+ const fixedNow = /** @type {number} */ (validatedConfig.datasetEnd);
139
+ const fixedBegin = /** @type {number} */ (validatedConfig.datasetStart);
143
140
 
144
- // Keep globals for backwards compatibility with tests/dungeons that read them
145
- global.FIXED_BEGIN = fixedBegin;
141
+ // Anchor the wall-clock-free reference used by date()/day() helpers in
142
+ // dungeon configs. Without this, those factories produce values relative
143
+ // to process-start, which leaks wall-clock time into the output.
144
+ setDatasetNow(fixedNow);
146
145
 
147
146
  // Step 2: Create context with validated config (pass time constants explicitly)
148
- const context = createContext(validatedConfig, null, { fixedNow: FIXED_NOW, fixedBegin });
147
+ const context = createContext(validatedConfig, null, { fixedNow, fixedBegin });
149
148
 
150
149
  // Step 3: Initialize storage containers
151
150
  const storageManager = new StorageManager(context);
@@ -237,11 +236,12 @@ async function generateAdSpendData(context) {
237
236
  const { config, storage } = context;
238
237
  const { numDays } = config;
239
238
 
240
- const timeShift = context.TIME_SHIFT_SECONDS;
241
239
  for (let day = 0; day < numDays; day++) {
242
- const fixedDay = dayjs.unix(context.FIXED_BEGIN).add(day, 'day').unix();
243
- const shiftedDay = Math.min(fixedDay + timeShift, context.MAX_TIME);
244
- const targetDay = dayjs.unix(shiftedDay).toISOString();
240
+ const dayUnix = Math.min(
241
+ dayjs.unix(context.FIXED_BEGIN).add(day, 'day').unix(),
242
+ context.FIXED_NOW
243
+ );
244
+ const targetDay = dayjs.unix(dayUnix).toISOString();
245
245
  const adSpendEvents = await makeAdSpend(context, targetDay);
246
246
 
247
247
  if (adSpendEvents.length > 0) {
@@ -12,9 +12,82 @@ import dayjs from "dayjs";
12
12
  import { makeName } from "ak-tools";
13
13
  import * as u from "../utils/utils.js";
14
14
  import { resolveSoup } from "../templates/soup-presets.js";
15
+ import { resolveMacro } from "../templates/macro-presets.js";
15
16
 
16
- /** Fixed reference point for time calculations (2024-02-02) */
17
- const FIXED_NOW = dayjs('2024-02-02').unix();
17
+ /**
18
+ * Resolve dataset window from config. Returns { datasetStartUnix, datasetEndUnix, numDays }.
19
+ * Three modes:
20
+ * 1. Both `datasetStart` AND `datasetEnd` provided → pin window. Recompute numDays from the
21
+ * window (ignore any user-supplied numDays — warn on conflict). Bit-exact deterministic.
22
+ * 2. Neither provided → fall back to (today_start - numDays, today_start). Deterministic
23
+ * within a single calendar day; slides across days. Warn once.
24
+ * 3. Exactly one provided → throw.
25
+ *
26
+ * Accepts ISO strings, unix seconds, or anything dayjs() can parse.
27
+ *
28
+ * @param {*} datasetStart
29
+ * @param {*} datasetEnd
30
+ * @param {number} [userNumDays]
31
+ * @returns {{ datasetStartUnix: number, datasetEndUnix: number, numDays: number }}
32
+ */
33
+ function resolveDatasetWindow(datasetStart, datasetEnd, userNumDays) {
34
+ const hasStart = datasetStart !== undefined && datasetStart !== null;
35
+ const hasEnd = datasetEnd !== undefined && datasetEnd !== null;
36
+
37
+ if (hasStart !== hasEnd) {
38
+ throw new Error(
39
+ `datasetStart and datasetEnd must be specified together (got datasetStart=${datasetStart}, datasetEnd=${datasetEnd}). ` +
40
+ `Provide both to pin the window, or neither to fall back to numDays.`
41
+ );
42
+ }
43
+
44
+ if (hasStart && hasEnd) {
45
+ const startUnix = parseToUnix(datasetStart, 'datasetStart');
46
+ const endUnix = parseToUnix(datasetEnd, 'datasetEnd');
47
+ if (endUnix <= startUnix) {
48
+ throw new Error(`datasetEnd (${datasetEnd}) must be after datasetStart (${datasetStart}).`);
49
+ }
50
+ const derivedNumDays = Math.max(1, Math.round((endUnix - startUnix) / 86400));
51
+ if (userNumDays !== undefined && userNumDays !== null && userNumDays !== derivedNumDays) {
52
+ console.warn(
53
+ `⚠️ datasetStart/datasetEnd take precedence; user-supplied numDays=${userNumDays} ignored, derived numDays=${derivedNumDays}.`
54
+ );
55
+ }
56
+ return { datasetStartUnix: startUnix, datasetEndUnix: endUnix, numDays: derivedNumDays };
57
+ }
58
+
59
+ // Fallback: anchor to today's start-of-day, walk back numDays
60
+ const fallbackNumDays = (typeof userNumDays === 'number' && userNumDays > 0) ? userNumDays : 30;
61
+ const todayStart = dayjs().startOf('day').unix();
62
+ const fallbackStart = todayStart - fallbackNumDays * 86400;
63
+ console.warn(
64
+ `⚠️ No 'datasetStart'/'datasetEnd' set — dataset window anchored to today's date and will shift across runs. Pin both for full determinism.`
65
+ );
66
+ return { datasetStartUnix: fallbackStart, datasetEndUnix: todayStart, numDays: fallbackNumDays };
67
+ }
68
+
69
+ /**
70
+ * Parse a value (ISO string, unix seconds, dayjs-parseable) into unix seconds.
71
+ * Throws if the value can't be parsed into a valid date.
72
+ * @param {*} value
73
+ * @param {string} fieldName
74
+ * @returns {number}
75
+ */
76
+ function parseToUnix(value, fieldName) {
77
+ // Treat numbers as unix seconds (or unix milliseconds if too large)
78
+ if (typeof value === 'number') {
79
+ if (!Number.isFinite(value) || value <= 0) {
80
+ throw new Error(`${fieldName} must be a positive finite number (got ${value}).`);
81
+ }
82
+ // Heuristic: > 10^12 means milliseconds, otherwise seconds
83
+ return value > 1e12 ? Math.floor(value / 1000) : Math.floor(value);
84
+ }
85
+ const parsed = dayjs(value);
86
+ if (!parsed.isValid()) {
87
+ throw new Error(`${fieldName} could not be parsed as a date (got ${JSON.stringify(value)}).`);
88
+ }
89
+ return parsed.unix();
90
+ }
18
91
 
19
92
  /**
20
93
  * Infers funnels from the provided events
@@ -87,9 +160,10 @@ export function validateDungeonConfig(config) {
87
160
  // Extract configuration with defaults
88
161
  let {
89
162
  seed,
90
- numEvents = 100_000,
163
+ numEvents,
91
164
  numUsers = 1000,
92
165
  numDays = 30,
166
+ avgEventsPerUserPerDay,
93
167
  epochStart = 0,
94
168
  epochEnd = dayjs().unix(),
95
169
  events = [{ event: "foo" }, { event: "bar" }, { event: "baz" }],
@@ -148,35 +222,83 @@ export function validateDungeonConfig(config) {
148
222
  );
149
223
  }
150
224
 
151
- // Auto-enable batch mode for large datasets to prevent OOM
152
- if (numEvents >= 2_000_000 && config.batchSize === undefined) {
153
- batchSize = 1_000_000;
154
- console.warn(`⚠️ Auto-enabling batch mode: numEvents (${numEvents.toLocaleString()}) >= 2M. Using batchSize of ${batchSize.toLocaleString()}.`);
155
- }
156
-
157
225
  // Ensure defaults for deep objects
158
226
  if (!config.superProps) config.superProps = superProps;
159
227
  if (!config.userProps || Object.keys(config.userProps).length === 0) {
160
228
  userProps = { spiritAnimal: chance.animal.bind(chance) };
161
229
  }
162
230
 
163
- // Setting up "TIME"
164
- if (epochStart && !numDays) numDays = dayjs.unix(epochEnd).diff(dayjs.unix(epochStart), "day");
165
- if (!epochStart && numDays) epochStart = dayjs.unix(epochEnd).subtract(numDays, "day").unix();
166
- if (epochStart && numDays) { } // noop
167
- if (!epochStart && !numDays) {
168
- throw new Error("Either epochStart or numDays must be provided");
231
+ // Guard against zero/negative numUsers up front (would produce NaN budgets).
232
+ if (!Number.isFinite(numUsers) || numUsers <= 0) {
233
+ throw new Error(`numUsers must be a positive number (got ${numUsers})`);
234
+ }
235
+
236
+ // Reject explicit zero/negative numDays from the caller before falling through
237
+ // to the epoch-derivation branches below (which would otherwise replace 0
238
+ // with a derived value or throw a less-specific message).
239
+ if (config.numDays !== undefined && (!Number.isFinite(config.numDays) || config.numDays <= 0)) {
240
+ throw new Error(`numDays must be a positive number (got ${config.numDays})`);
241
+ }
242
+
243
+ // ── Resolve dataset window ──
244
+ // Preferred path: explicit datasetStart + datasetEnd → pinned, deterministic window.
245
+ // Fallback: numDays only → today_start - numDays back (sliding, warn-emitted).
246
+ const windowResolution = resolveDatasetWindow(config.datasetStart, config.datasetEnd, config.numDays);
247
+ const datasetStartUnix = windowResolution.datasetStartUnix;
248
+ const datasetEndUnix = windowResolution.datasetEndUnix;
249
+ numDays = windowResolution.numDays;
250
+
251
+ // Mirror window into legacy epoch* surface (still consumed by inferFunnels callers
252
+ // and any external code that read these from validated config).
253
+ epochStart = datasetStartUnix;
254
+ epochEnd = datasetEndUnix;
255
+
256
+ // Resolve event-rate primitive: avgEventsPerUserPerDay is the canonical knob.
257
+ // numEvents is supported as a fallback (legacy + total-volume target). Whichever
258
+ // is set, derive the other so downstream code (batching, progress, mixpanel-sender)
259
+ // can use either.
260
+ if (avgEventsPerUserPerDay !== undefined && numEvents !== undefined) {
261
+ // Both provided: avgEventsPerUserPerDay wins. Recompute numEvents from rate.
262
+ numEvents = Math.round(avgEventsPerUserPerDay * numUsers * numDays);
263
+ } else if (avgEventsPerUserPerDay !== undefined) {
264
+ numEvents = Math.round(avgEventsPerUserPerDay * numUsers * numDays);
265
+ } else if (numEvents !== undefined) {
266
+ avgEventsPerUserPerDay = numEvents / numUsers / numDays;
267
+ } else {
268
+ // Neither set — fall back to legacy default (100K total).
269
+ numEvents = 100_000;
270
+ avgEventsPerUserPerDay = numEvents / numUsers / numDays;
271
+ }
272
+
273
+ // Auto-enable batch mode for large datasets to prevent OOM.
274
+ // MUST run after rate→numEvents resolution above, otherwise dungeons that set
275
+ // only avgEventsPerUserPerDay would never trigger auto-batch.
276
+ if (numEvents >= 2_000_000 && config.batchSize === undefined) {
277
+ batchSize = 1_000_000;
278
+ console.warn(`⚠️ Auto-enabling batch mode: numEvents (${numEvents.toLocaleString()}) >= 2M. Using batchSize of ${batchSize.toLocaleString()}.`);
169
279
  }
170
280
 
171
- // Resolve soup presets (must happen after numDays is computed)
281
+ // Resolve soup presets (intra-week / intra-day shape — must happen after numDays is computed)
172
282
  const resolved = resolveSoup(soup, numDays);
173
283
  soup = resolved.soup;
174
- // Apply suggested birth distribution params if not explicitly set by the dungeon
175
- if (resolved.suggestedBornRecentBias !== undefined && config.bornRecentBias === undefined) {
176
- config.bornRecentBias = resolved.suggestedBornRecentBias;
177
- }
178
- if (resolved.suggestedPercentUsersBornInDataset !== undefined && config.percentUsersBornInDataset === undefined) {
179
- config.percentUsersBornInDataset = resolved.suggestedPercentUsersBornInDataset;
284
+
285
+ // Resolve macro preset (big-picture trend shape across the window).
286
+ // Default is "flat" — see lib/templates/macro-presets.js. Top-level
287
+ // bornRecentBias / percentUsersBornInDataset / preExistingSpread on the
288
+ // dungeon config win over the macro preset's values, so existing dungeons
289
+ // that set these explicitly continue to render the same way.
290
+ // Resolve into local vars (do NOT mutate input config).
291
+ const macroResolved = resolveMacro(config.macro);
292
+ let bornRecentBias = config.bornRecentBias !== undefined ? config.bornRecentBias : macroResolved.bornRecentBias;
293
+ let percentUsersBornInDataset = config.percentUsersBornInDataset !== undefined ? config.percentUsersBornInDataset : macroResolved.percentUsersBornInDataset;
294
+ let preExistingSpread = config.preExistingSpread !== undefined ? config.preExistingSpread : macroResolved.preExistingSpread;
295
+
296
+ // Clamp bornRecentBias to [-1, 1] — values outside this range produce
297
+ // nonsensical exponents (e.g. Math.pow(0, -0.4) = Infinity in user-loop.js).
298
+ if (typeof bornRecentBias === 'number' && Number.isFinite(bornRecentBias)) {
299
+ bornRecentBias = Math.max(-1, Math.min(1, bornRecentBias));
300
+ } else {
301
+ bornRecentBias = 0;
180
302
  }
181
303
 
182
304
  // Use provided name if non-empty string, otherwise generate one
@@ -220,7 +342,7 @@ export function validateDungeonConfig(config) {
220
342
  }
221
343
 
222
344
  // Validate: if every user is born in dataset, we need either isFirstEvent or isFirstFunnel
223
- const percentBorn = config.percentUsersBornInDataset ?? 15;
345
+ const percentBorn = percentUsersBornInDataset;
224
346
  const hasFirstEvent = events.some(e => e.isFirstEvent);
225
347
  const hasFirstFunnel = funnels.some(f => f.isFirstFunnel);
226
348
  if (percentBorn >= 100 && !hasFirstEvent && !hasFirstFunnel) {
@@ -299,7 +421,7 @@ export function validateDungeonConfig(config) {
299
421
  // Feature 2: World Events
300
422
  let worldEvents = config.worldEvents || null;
301
423
  if (worldEvents) {
302
- worldEvents = resolveWorldEvents(worldEvents, numDays);
424
+ worldEvents = resolveWorldEvents(worldEvents, datasetStartUnix);
303
425
  }
304
426
 
305
427
  // Feature 3: Engagement Decay
@@ -341,7 +463,7 @@ export function validateDungeonConfig(config) {
341
463
  // Feature 9: Anomalies
342
464
  let anomalies = config.anomalies || null;
343
465
  if (anomalies) {
344
- anomalies = resolveAnomalies(anomalies, numDays);
466
+ anomalies = resolveAnomalies(anomalies, datasetStartUnix);
345
467
  }
346
468
 
347
469
  // Build final config object
@@ -354,8 +476,11 @@ export function validateDungeonConfig(config) {
354
476
  numEvents,
355
477
  numUsers,
356
478
  numDays,
479
+ avgEventsPerUserPerDay,
357
480
  epochStart,
358
481
  epochEnd,
482
+ datasetStart: datasetStartUnix,
483
+ datasetEnd: datasetEndUnix,
359
484
  events: validatedEvents,
360
485
  superProps,
361
486
  userProps,
@@ -385,6 +510,11 @@ export function validateDungeonConfig(config) {
385
510
  hasIOSDevices,
386
511
  name,
387
512
  strictEventCount,
513
+ // Macro trend (resolved from preset + per-dungeon overrides; clamped)
514
+ macro: config.macro,
515
+ bornRecentBias,
516
+ percentUsersBornInDataset,
517
+ preExistingSpread,
388
518
  // Advanced features
389
519
  personas,
390
520
  worldEvents,
@@ -495,12 +625,11 @@ function validatePersonas(personas) {
495
625
  /**
496
626
  * Resolves world events to absolute timestamps
497
627
  * @param {import('../../types').WorldEvent[]} worldEvents
498
- * @param {number} numDays
628
+ * @param {number} beginUnix - Dataset start (unix seconds)
499
629
  * @returns {import('../../types').ResolvedWorldEvent[]}
500
630
  */
501
- function resolveWorldEvents(worldEvents, numDays) {
631
+ function resolveWorldEvents(worldEvents, beginUnix) {
502
632
  if (!Array.isArray(worldEvents) || worldEvents.length === 0) return null;
503
- const beginUnix = dayjs.unix(FIXED_NOW).subtract(numDays, 'day').unix();
504
633
 
505
634
  return worldEvents.map(we => {
506
635
  const startUnix = beginUnix + (we.startDay * 86400);
@@ -664,12 +793,11 @@ function resolveFeatures(features, numDays) {
664
793
  /**
665
794
  * Resolves anomaly configs with absolute timestamps
666
795
  * @param {import('../../types').AnomalyConfig[]} anomalies
667
- * @param {number} numDays
796
+ * @param {number} beginUnix - Dataset start (unix seconds)
668
797
  * @returns {import('../../types').AnomalyConfig[]}
669
798
  */
670
- function resolveAnomalies(anomalies, numDays) {
799
+ function resolveAnomalies(anomalies, beginUnix) {
671
800
  if (!Array.isArray(anomalies) || anomalies.length === 0) return null;
672
- const beginUnix = dayjs.unix(FIXED_NOW).subtract(numDays, 'day').unix();
673
801
  return anomalies.map(a => {
674
802
  if (!a.type) throw new Error('Each anomaly must have a type');
675
803
  if (!a.event) throw new Error('Each anomaly must have an event name');
@@ -156,30 +156,16 @@ export function createContext(config, storage = null, timeConstants = {}) {
156
156
  return runtime.isBatchMode;
157
157
  },
158
158
 
159
- // Time helper methods
160
- getTimeShift() {
161
- const actualNow = dayjs().subtract(1, "hour");
162
- return actualNow.diff(dayjs.unix(this.FIXED_NOW), "seconds");
163
- },
164
-
165
- getDaysShift() {
166
- const actualNow = dayjs().subtract(1, "hour");
167
- return actualNow.diff(dayjs.unix(this.FIXED_NOW), "days");
168
- },
169
-
170
- // Time constants (passed explicitly from index.js; global fallback only for direct test usage)
171
- FIXED_NOW: timeConstants.fixedNow || global.FIXED_NOW,
172
- FIXED_BEGIN: timeConstants.fixedBegin || global.FIXED_BEGIN,
173
-
174
- // PERFORMANCE: Pre-calculated time shift (instead of calculating per-event)
175
- TIME_SHIFT_SECONDS: (() => {
176
- const fixedNow = timeConstants.fixedNow || global.FIXED_NOW;
177
- const actualNow = dayjs().subtract(1, "hour");
178
- return actualNow.diff(dayjs.unix(fixedNow), "seconds");
179
- })(),
180
-
181
- // Max timestamp (unix seconds) — clamp here to prevent future events
182
- MAX_TIME: dayjs().unix(),
159
+ // Dataset window anchors (resolved by config-validator). FIXED_BEGIN and
160
+ // FIXED_NOW are the authoritative window — events are generated directly
161
+ // inside this range, no time-shift step. DATASET_*_SECONDS are aliases
162
+ // kept for the existing `meta.datasetStart`/`meta.datasetEnd` hook surface.
163
+ // Resolution: explicit timeConstants override, otherwise pull from validated config.
164
+ // validateDungeonConfig always normalizes datasetStart/datasetEnd to unix seconds.
165
+ FIXED_NOW: /** @type {number} */ (timeConstants.fixedNow || config.datasetEnd),
166
+ FIXED_BEGIN: /** @type {number} */ (timeConstants.fixedBegin || config.datasetStart),
167
+ get DATASET_START_SECONDS() { return this.FIXED_BEGIN; },
168
+ get DATASET_END_SECONDS() { return this.FIXED_NOW; },
183
169
  };
184
170
 
185
171
  return context;
@@ -111,7 +111,12 @@ export async function createHookArray(arr = [], opts) {
111
111
  }
112
112
  } else {
113
113
  // Slow path for actual transformation hooks
114
- const allMetaData = { ...rest, ...meta };
114
+ const allMetaData = {
115
+ ...rest,
116
+ ...meta,
117
+ datasetStart: context?.DATASET_START_SECONDS,
118
+ datasetEnd: context?.DATASET_END_SECONDS
119
+ };
115
120
 
116
121
  // Helper to validate events have required properties
117
122
  // Note: event-type hooks are handled in the fast path (alreadyHooked),