@ak--47/dungeon-master 1.3.0 → 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 (74) hide show
  1. package/dungeons/technical/array-of-object-lookup.js +0 -2
  2. package/dungeons/technical/simple.js +3 -4
  3. package/dungeons/technical/simplest-schema.json +5 -0
  4. package/dungeons/technical/text-generation.js +1 -1
  5. package/dungeons/vertical/ai-platform.js +192 -133
  6. package/dungeons/vertical/community.js +64 -34
  7. package/dungeons/vertical/crypto.js +333 -224
  8. package/dungeons/vertical/dating.js +228 -282
  9. package/dungeons/vertical/devtools.js +164 -62
  10. package/dungeons/vertical/ecommerce.js +240 -91
  11. package/dungeons/vertical/education.js +328 -310
  12. package/dungeons/vertical/fintech.js +437 -311
  13. package/dungeons/vertical/fitness.js +141 -58
  14. package/dungeons/vertical/food-delivery.js +298 -318
  15. package/dungeons/vertical/gaming.js +378 -231
  16. package/dungeons/vertical/healthcare.js +140 -60
  17. package/dungeons/vertical/insurance-application.js +168 -73
  18. package/dungeons/vertical/logistics.js +95 -6
  19. package/dungeons/vertical/marketplace.js +137 -51
  20. package/dungeons/vertical/media.js +241 -386
  21. package/dungeons/vertical/real-estate.js +322 -315
  22. package/dungeons/vertical/sass.js +253 -263
  23. package/dungeons/vertical/social.js +262 -206
  24. package/dungeons/vertical/travel.js +59 -16
  25. package/index.js +17 -17
  26. package/lib/core/config-validator.js +93 -21
  27. package/lib/core/context.js +10 -24
  28. package/lib/core/storage.js +6 -1
  29. package/lib/generators/events.js +11 -14
  30. package/lib/generators/funnels.js +12 -4
  31. package/lib/generators/mirror.js +3 -2
  32. package/lib/generators/product-names.js +1 -1
  33. package/lib/generators/scd.js +2 -1
  34. package/lib/generators/text.js +1 -1
  35. package/lib/orchestrators/user-loop.js +47 -47
  36. package/lib/utils/utils.js +71 -39
  37. package/package.json +1 -1
  38. package/scripts/smoke-test-all.mjs +162 -0
  39. package/scripts/verify-runner.mjs +72 -24
  40. package/types.d.ts +38 -15
  41. package/dungeons/technical/ad-spend-schema.json +0 -128
  42. package/dungeons/technical/anonymous-users-schema.json +0 -92
  43. package/dungeons/technical/array-of-object-lookup-schema.json +0 -191
  44. package/dungeons/technical/experiments-schema.json +0 -203
  45. package/dungeons/technical/foobar-schema.json +0 -362
  46. package/dungeons/technical/group-analytics-schema.json +0 -241
  47. package/dungeons/technical/mirror-strategies-schema.json +0 -84
  48. package/dungeons/technical/nested-objects-schema.json +0 -145
  49. package/dungeons/technical/retention-cadence-schema.json +0 -37
  50. package/dungeons/technical/sanity-schema.json +0 -185
  51. package/dungeons/technical/scale-test-schema.json +0 -70
  52. package/dungeons/technical/scd-schema.json +0 -467
  53. package/dungeons/technical/simple-schema.json +0 -362
  54. package/dungeons/technical/text-generation-schema.json +0 -1062
  55. package/dungeons/vertical/ai-platform-schema.json +0 -617
  56. package/dungeons/vertical/community-schema.json +0 -579
  57. package/dungeons/vertical/crypto-schema.json +0 -546
  58. package/dungeons/vertical/dating-schema.json +0 -401
  59. package/dungeons/vertical/devtools-schema.json +0 -601
  60. package/dungeons/vertical/ecommerce-schema.json +0 -604
  61. package/dungeons/vertical/education-schema.json +0 -5686
  62. package/dungeons/vertical/fintech-schema.json +0 -630
  63. package/dungeons/vertical/fitness-schema.json +0 -530
  64. package/dungeons/vertical/food-delivery-schema.json +0 -36728
  65. package/dungeons/vertical/gaming-schema.json +0 -2703
  66. package/dungeons/vertical/healthcare-schema.json +0 -549
  67. package/dungeons/vertical/insurance-application-schema.json +0 -485
  68. package/dungeons/vertical/logistics-schema.json +0 -574
  69. package/dungeons/vertical/marketplace-schema.json +0 -533
  70. package/dungeons/vertical/media-schema.json +0 -4749
  71. package/dungeons/vertical/real-estate-schema.json +0 -527
  72. package/dungeons/vertical/sass-schema.json +0 -3128
  73. package/dungeons/vertical/social-schema.json +0 -620
  74. package/dungeons/vertical/travel-schema.json +0 -580
@@ -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,7 +212,9 @@ const destinationCities = ["New York", "London", "Paris", "Tokyo", "Barcelona",
206
212
  const config = {
207
213
  token,
208
214
  seed: SEED,
209
- numDays: num_days,
215
+ datasetStart: "2026-01-01T00:00:00Z",
216
+ datasetEnd: "2026-04-28T23:59:59Z",
217
+ // numDays: num_days,
210
218
  avgEventsPerUserPerDay: avg_events_per_user_per_day,
211
219
  numUsers: num_users,
212
220
  hasAnonIds: false,
@@ -639,11 +647,25 @@ const config = {
639
647
  }
640
648
  }
641
649
 
642
- // ── HOOK 8: REPEAT DESTINATION CLUSTERING (funnel-pre)
643
- // conversionRate modifications moved to everything hook (event filtering)
644
- // to avoid dilution by organic events
645
- if (type === "funnel-pre") {
646
- // prop-setting only; no conversionRate changes
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
+ }
667
+ }
668
+ }
647
669
  }
648
670
 
649
671
  // ── HOOK 1: WEEKEND LEISURE SURGE ────────────
@@ -686,10 +708,11 @@ const config = {
686
708
  });
687
709
 
688
710
  // ── HOOK 2: ADVANCE BOOKING DISCOUNT ─────
711
+ const datasetEndForBooking = dayjs.unix(meta.datasetEnd);
689
712
  events.forEach(e => {
690
713
  if (e.event === "booking completed") {
691
714
  const eventTime = dayjs(e.time);
692
- const daysUntilEnd = NOW.diff(eventTime, "days");
715
+ const daysUntilEnd = datasetEndForBooking.diff(eventTime, "days");
693
716
  if (daysUntilEnd > 21) {
694
717
  e.booking_window = "advance";
695
718
  e.nightly_rate = Math.floor((e.nightly_rate || 150) * 0.8);
@@ -705,12 +728,11 @@ const config = {
705
728
  // business_traveler or luxury_seeker to simulate their lower funnel
706
729
  // conversion (was conversionRate * 1.3 / 1.15 in funnel-pre)
707
730
  const segment = profile && profile.customer_segment;
708
- if (segment !== "business_traveler" && segment !== "luxury_seeker") {
731
+ if (segment !== "business_traveler" && segment !== "luxury_seeker"
732
+ && chance.bool({ likelihood: 25 })) {
709
733
  for (let i = events.length - 1; i >= 0; i--) {
710
734
  if (events[i].event === "booking completed") {
711
- if (chance.bool({ likelihood: 25 })) {
712
- events.splice(i, 1);
713
- }
735
+ events.splice(i, 1);
714
736
  }
715
737
  }
716
738
  }
@@ -776,7 +798,8 @@ const config = {
776
798
  }
777
799
  }
778
800
 
779
- // ── 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.
780
803
  let totalRating = 0;
781
804
  let ratingCount = 0;
782
805
  events.forEach(e => {
@@ -796,6 +819,26 @@ const config = {
796
819
  }
797
820
  });
798
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
+
799
842
  return record;
800
843
  }
801
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) {
@@ -14,8 +14,80 @@ import * as u from "../utils/utils.js";
14
14
  import { resolveSoup } from "../templates/soup-presets.js";
15
15
  import { resolveMacro } from "../templates/macro-presets.js";
16
16
 
17
- /** Fixed reference point for time calculations (2024-02-02) */
18
- 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
+ }
19
91
 
20
92
  /**
21
93
  * Infers funnels from the provided events
@@ -168,18 +240,18 @@ export function validateDungeonConfig(config) {
168
240
  throw new Error(`numDays must be a positive number (got ${config.numDays})`);
169
241
  }
170
242
 
171
- // Setting up "TIME"
172
- if (epochStart && !numDays) numDays = dayjs.unix(epochEnd).diff(dayjs.unix(epochStart), "day");
173
- if (!epochStart && numDays) epochStart = dayjs.unix(epochEnd).subtract(numDays, "day").unix();
174
- if (epochStart && numDays) { } // noop
175
- if (!epochStart && !numDays) {
176
- throw new Error("Either epochStart or numDays must be provided");
177
- }
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;
178
250
 
179
- // Final sanity check (e.g. epochStart/epochEnd produced a non-positive numDays).
180
- if (!Number.isFinite(numDays) || numDays <= 0) {
181
- throw new Error(`numDays must be a positive number (got ${numDays})`);
182
- }
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;
183
255
 
184
256
  // Resolve event-rate primitive: avgEventsPerUserPerDay is the canonical knob.
185
257
  // numEvents is supported as a fallback (legacy + total-volume target). Whichever
@@ -349,7 +421,7 @@ export function validateDungeonConfig(config) {
349
421
  // Feature 2: World Events
350
422
  let worldEvents = config.worldEvents || null;
351
423
  if (worldEvents) {
352
- worldEvents = resolveWorldEvents(worldEvents, numDays);
424
+ worldEvents = resolveWorldEvents(worldEvents, datasetStartUnix);
353
425
  }
354
426
 
355
427
  // Feature 3: Engagement Decay
@@ -391,7 +463,7 @@ export function validateDungeonConfig(config) {
391
463
  // Feature 9: Anomalies
392
464
  let anomalies = config.anomalies || null;
393
465
  if (anomalies) {
394
- anomalies = resolveAnomalies(anomalies, numDays);
466
+ anomalies = resolveAnomalies(anomalies, datasetStartUnix);
395
467
  }
396
468
 
397
469
  // Build final config object
@@ -407,6 +479,8 @@ export function validateDungeonConfig(config) {
407
479
  avgEventsPerUserPerDay,
408
480
  epochStart,
409
481
  epochEnd,
482
+ datasetStart: datasetStartUnix,
483
+ datasetEnd: datasetEndUnix,
410
484
  events: validatedEvents,
411
485
  superProps,
412
486
  userProps,
@@ -551,12 +625,11 @@ function validatePersonas(personas) {
551
625
  /**
552
626
  * Resolves world events to absolute timestamps
553
627
  * @param {import('../../types').WorldEvent[]} worldEvents
554
- * @param {number} numDays
628
+ * @param {number} beginUnix - Dataset start (unix seconds)
555
629
  * @returns {import('../../types').ResolvedWorldEvent[]}
556
630
  */
557
- function resolveWorldEvents(worldEvents, numDays) {
631
+ function resolveWorldEvents(worldEvents, beginUnix) {
558
632
  if (!Array.isArray(worldEvents) || worldEvents.length === 0) return null;
559
- const beginUnix = dayjs.unix(FIXED_NOW).subtract(numDays, 'day').unix();
560
633
 
561
634
  return worldEvents.map(we => {
562
635
  const startUnix = beginUnix + (we.startDay * 86400);
@@ -720,12 +793,11 @@ function resolveFeatures(features, numDays) {
720
793
  /**
721
794
  * Resolves anomaly configs with absolute timestamps
722
795
  * @param {import('../../types').AnomalyConfig[]} anomalies
723
- * @param {number} numDays
796
+ * @param {number} beginUnix - Dataset start (unix seconds)
724
797
  * @returns {import('../../types').AnomalyConfig[]}
725
798
  */
726
- function resolveAnomalies(anomalies, numDays) {
799
+ function resolveAnomalies(anomalies, beginUnix) {
727
800
  if (!Array.isArray(anomalies) || anomalies.length === 0) return null;
728
- const beginUnix = dayjs.unix(FIXED_NOW).subtract(numDays, 'day').unix();
729
801
  return anomalies.map(a => {
730
802
  if (!a.type) throw new Error('Each anomaly must have a type');
731
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),
@@ -101,19 +101,13 @@ export async function makeEvent(
101
101
 
102
102
  // Set event time using TimeSoup for realistic distribution
103
103
  if (earliestTime) {
104
- let shiftedTimestamp;
104
+ let unixTime;
105
105
  if (isFirstEvent) {
106
- shiftedTimestamp = earliestTime + context.TIME_SHIFT_SECONDS;
106
+ unixTime = earliestTime;
107
107
  } else {
108
- // TimeSoup returns unix seconds; shift and convert to ISO once
109
- const soupTimestamp = u.TimeSoup(earliestTime, context.FIXED_NOW, peaks, deviation, mean, dayOfWeekWeights, hourOfDayWeights, context.TIME_SHIFT_SECONDS);
110
- shiftedTimestamp = soupTimestamp + context.TIME_SHIFT_SECONDS;
108
+ unixTime = u.TimeSoup(earliestTime, context.FIXED_NOW, peaks, deviation, mean, dayOfWeekWeights, hourOfDayWeights);
111
109
  }
112
- // Drop events that would land in the future (Mixpanel rewrites these to "now", causing pile-ups)
113
- if (shiftedTimestamp > context.MAX_TIME) {
114
- eventTemplate._drop = true;
115
- }
116
- eventTemplate.time = dayjs.unix(Math.min(shiftedTimestamp, context.MAX_TIME)).toISOString();
110
+ eventTemplate.time = dayjs.unix(unixTime).toISOString();
117
111
  }
118
112
 
119
113
  // Add anonymous identifiers
@@ -196,11 +190,12 @@ export async function makeEvent(
196
190
  }
197
191
  }
198
192
 
199
- // Perf 1: Compute eventUnix once for all time-based checks
200
- // Subtracts TIME_SHIFT to convert real time back to FIXED time (world events/features use FIXED time)
193
+ // Perf 1: Compute eventUnix once for all time-based checks. World events &
194
+ // features were resolved against the dataset window (no shift), and event
195
+ // times now also live in that same window — direct unix conversion works.
201
196
  let eventUnix = null;
202
197
  if ((worldEventsTimeline || resolvedFeatures) && eventTemplate.time) {
203
- eventUnix = dayjs(eventTemplate.time).subtract(context.TIME_SHIFT_SECONDS, 'seconds').unix();
198
+ eventUnix = dayjs(eventTemplate.time).unix();
204
199
  }
205
200
 
206
201
  // Feature 2: World events — inject props for active events at this timestamp
@@ -303,7 +298,9 @@ export async function makeEvent(
303
298
  const hookedEvent = await hook(eventTemplate, "event", {
304
299
  user: { distinct_id },
305
300
  config,
306
- persona: featureCtx.persona || null
301
+ persona: featureCtx.persona || null,
302
+ datasetStart: context.DATASET_START_SECONDS,
303
+ datasetEnd: context.DATASET_END_SECONDS
307
304
  });
308
305
  // If hook returns a modified event, use it; otherwise use original
309
306
  if (hookedEvent && typeof hookedEvent === 'object') {
@@ -68,7 +68,11 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
68
68
  }
69
69
 
70
70
  // Call pre-funnel hook
71
- await hook(funnel, "funnel-pre", { user, profile, scd, funnel, config, firstEventTime });
71
+ await hook(funnel, "funnel-pre", {
72
+ user, profile, scd, funnel, config, firstEventTime,
73
+ datasetStart: context.DATASET_START_SECONDS,
74
+ datasetEnd: context.DATASET_END_SECONDS
75
+ });
72
76
 
73
77
  // Extract funnel configuration
74
78
  let {
@@ -206,7 +210,11 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
206
210
  );
207
211
 
208
212
  // Call post-funnel hook
209
- await hook(finalEvents, "funnel-post", { user, profile, scd, funnel, config });
213
+ await hook(finalEvents, "funnel-post", {
214
+ user, profile, scd, funnel, config,
215
+ datasetStart: context.DATASET_START_SECONDS,
216
+ datasetEnd: context.DATASET_END_SECONDS
217
+ });
210
218
 
211
219
  return [finalEvents, doesUserConvert];
212
220
  }
@@ -446,8 +454,8 @@ async function generateFunnelEvents(
446
454
 
447
455
  try {
448
456
  let computedTime = dayjs(funnelStartTime).add(event.relativeTimeMs, "milliseconds");
449
- // Drop events that would land in the future
450
- if (context.MAX_TIME && computedTime.unix() > context.MAX_TIME) {
457
+ // Drop events that would land past the dataset window
458
+ if (context.FIXED_NOW && computedTime.unix() > context.FIXED_NOW) {
451
459
  newEvent._drop = true;
452
460
  }
453
461
  if (computedTime.isValid()) {
@@ -22,8 +22,9 @@ export async function makeMirror(context) {
22
22
  if (!mirrorProps || Object.keys(mirrorProps).length === 0) {
23
23
  return; // No mirror properties configured
24
24
  }
25
-
26
- const now = dayjs();
25
+
26
+ // Anchor to the resolved dataset end (not wall-clock) so mirror behavior is deterministic.
27
+ const now = context.FIXED_NOW ? dayjs.unix(context.FIXED_NOW) : dayjs();
27
28
 
28
29
  for (const oldEvent of eventData) {
29
30
  let newEvent = null;
@@ -119,7 +119,7 @@ export const productComparisonGen = createTextGenerator({
119
119
  authenticityLevel: 0.7,
120
120
  specificityLevel: 0.8,
121
121
  min: 100,
122
- max: 300,
122
+ max: 254, // capped at Mixpanel string property limit
123
123
  includeMetadata: false
124
124
  });
125
125
 
@@ -48,7 +48,8 @@ export async function makeSCD(context, scdProp, scdKey, distinct_id, mutations,
48
48
 
49
49
  const scdEntries = [];
50
50
  let lastInserted = dayjs(created);
51
- const now = dayjs();
51
+ // Anchor to the resolved dataset end (not wall-clock) so SCD count is deterministic.
52
+ const now = context.FIXED_NOW ? dayjs.unix(context.FIXED_NOW) : dayjs();
52
53
  const deltaDays = now.diff(lastInserted, "day");
53
54
  const uuidKeyName = type === 'user' ? 'distinct_id' : type;
54
55
  let lastStartTime = null; // Track for monotonic ordering
@@ -524,7 +524,7 @@ class OrganicTextGenerator {
524
524
  intensity: 'medium',
525
525
  formality: 'casual',
526
526
  min: 100,
527
- max: 500,
527
+ max: 254, // default capped at Mixpanel string property limit; override per-generator if needed
528
528
  seed: null,
529
529
  keywords: null,
530
530
  keywordDensity: 0.15,