@ak--47/dungeon-master 1.3.0 → 1.4.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 (98) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dungeons/technical/array-of-object-lookup.js +0 -2
  3. package/dungeons/technical/hook-helpers-verify.js +89 -0
  4. package/dungeons/technical/identity-model-verify.js +47 -0
  5. package/dungeons/technical/pattern-aggregate-by-bin.js +41 -0
  6. package/dungeons/technical/pattern-attributed-by-source.js +42 -0
  7. package/dungeons/technical/pattern-frequency-by-frequency.js +40 -0
  8. package/dungeons/technical/pattern-funnel-frequency.js +54 -0
  9. package/dungeons/technical/pattern-ttc-by-segment.js +45 -0
  10. package/dungeons/technical/simple.js +3 -4
  11. package/dungeons/technical/simplest-schema.json +5 -0
  12. package/dungeons/technical/text-generation.js +1 -1
  13. package/dungeons/vertical/ai-platform.js +216 -164
  14. package/dungeons/vertical/community.js +74 -41
  15. package/dungeons/vertical/crypto.js +343 -233
  16. package/dungeons/vertical/dating.js +246 -292
  17. package/dungeons/vertical/devtools.js +179 -70
  18. package/dungeons/vertical/ecommerce.js +275 -122
  19. package/dungeons/vertical/education.js +345 -312
  20. package/dungeons/vertical/fintech.js +445 -314
  21. package/dungeons/vertical/fitness.js +199 -165
  22. package/dungeons/vertical/food-delivery.js +309 -330
  23. package/dungeons/vertical/gaming.js +387 -241
  24. package/dungeons/vertical/healthcare.js +149 -66
  25. package/dungeons/vertical/insurance-application.js +173 -75
  26. package/dungeons/vertical/logistics.js +107 -12
  27. package/dungeons/vertical/marketplace.js +171 -76
  28. package/dungeons/vertical/media.js +259 -402
  29. package/dungeons/vertical/real-estate.js +336 -318
  30. package/dungeons/vertical/sass.js +294 -288
  31. package/dungeons/vertical/social.js +289 -234
  32. package/dungeons/vertical/travel.js +66 -20
  33. package/index.js +17 -17
  34. package/lib/core/config-validator.js +226 -175
  35. package/lib/core/context.js +10 -24
  36. package/lib/core/storage.js +6 -1
  37. package/lib/generators/events.js +57 -104
  38. package/lib/generators/funnels.js +212 -93
  39. package/lib/generators/mirror.js +3 -2
  40. package/lib/generators/product-names.js +1 -1
  41. package/lib/generators/scd.js +2 -1
  42. package/lib/generators/text.js +1 -1
  43. package/lib/hook-helpers/_internal.js +23 -0
  44. package/lib/hook-helpers/cohort.js +124 -0
  45. package/lib/hook-helpers/identity.js +56 -0
  46. package/lib/hook-helpers/index.js +44 -0
  47. package/lib/hook-helpers/inject.js +99 -0
  48. package/lib/hook-helpers/mutate.js +151 -0
  49. package/lib/hook-helpers/timing.js +99 -0
  50. package/lib/hook-patterns/aggregate-per-user-by-bin.js +38 -0
  51. package/lib/hook-patterns/attributed-by-source.js +72 -0
  52. package/lib/hook-patterns/frequency-by-frequency.js +46 -0
  53. package/lib/hook-patterns/funnel-frequency-breakdown.js +73 -0
  54. package/lib/hook-patterns/index.js +14 -0
  55. package/lib/hook-patterns/time-to-convert-by-segment.js +41 -0
  56. package/lib/orchestrators/user-loop.js +143 -293
  57. package/lib/utils/utils.js +97 -52
  58. package/lib/verify/emulate-breakdown.js +281 -0
  59. package/lib/verify/index.js +12 -0
  60. package/lib/verify/verify-dungeon.js +61 -0
  61. package/package.json +6 -4
  62. package/scripts/smoke-test-all.mjs +162 -0
  63. package/scripts/verify-runner.mjs +72 -24
  64. package/types.d.ts +434 -225
  65. package/dungeons/technical/ad-spend-schema.json +0 -128
  66. package/dungeons/technical/anonymous-users-schema.json +0 -92
  67. package/dungeons/technical/array-of-object-lookup-schema.json +0 -191
  68. package/dungeons/technical/experiments-schema.json +0 -203
  69. package/dungeons/technical/foobar-schema.json +0 -362
  70. package/dungeons/technical/group-analytics-schema.json +0 -241
  71. package/dungeons/technical/mirror-strategies-schema.json +0 -84
  72. package/dungeons/technical/nested-objects-schema.json +0 -145
  73. package/dungeons/technical/retention-cadence-schema.json +0 -37
  74. package/dungeons/technical/sanity-schema.json +0 -185
  75. package/dungeons/technical/scale-test-schema.json +0 -70
  76. package/dungeons/technical/scd-schema.json +0 -467
  77. package/dungeons/technical/simple-schema.json +0 -362
  78. package/dungeons/technical/text-generation-schema.json +0 -1062
  79. package/dungeons/vertical/ai-platform-schema.json +0 -617
  80. package/dungeons/vertical/community-schema.json +0 -579
  81. package/dungeons/vertical/crypto-schema.json +0 -546
  82. package/dungeons/vertical/dating-schema.json +0 -401
  83. package/dungeons/vertical/devtools-schema.json +0 -601
  84. package/dungeons/vertical/ecommerce-schema.json +0 -604
  85. package/dungeons/vertical/education-schema.json +0 -5686
  86. package/dungeons/vertical/fintech-schema.json +0 -630
  87. package/dungeons/vertical/fitness-schema.json +0 -530
  88. package/dungeons/vertical/food-delivery-schema.json +0 -36728
  89. package/dungeons/vertical/gaming-schema.json +0 -2703
  90. package/dungeons/vertical/healthcare-schema.json +0 -549
  91. package/dungeons/vertical/insurance-application-schema.json +0 -485
  92. package/dungeons/vertical/logistics-schema.json +0 -574
  93. package/dungeons/vertical/marketplace-schema.json +0 -533
  94. package/dungeons/vertical/media-schema.json +0 -4749
  95. package/dungeons/vertical/real-estate-schema.json +0 -527
  96. package/dungeons/vertical/sass-schema.json +0 -3128
  97. package/dungeons/vertical/social-schema.json +0 -620
  98. package/dungeons/vertical/travel-schema.json +0 -580
@@ -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
@@ -73,6 +145,106 @@ function inferFunnels(events) {
73
145
  return createdFunnels;
74
146
  }
75
147
 
148
+ /**
149
+ * Config keys removed from the engine in 1.4. Silently stripped by
150
+ * `validateDungeonConfig` with a single warning per dungeon. To recreate any of these
151
+ * patterns, use hooks (see `lib/hook-patterns/*`).
152
+ */
153
+ const KILLED_CONFIG_KEYS = ['subscription', 'attribution', 'geo', 'features', 'anomalies'];
154
+
155
+ /**
156
+ * Strip killed config keys in place, log one deprecation warning per dungeon.
157
+ * @param {Partial<Dungeon>} config
158
+ */
159
+ function stripKilledConfigKeys(config) {
160
+ const found = KILLED_CONFIG_KEYS.filter(k => config[k] !== undefined && config[k] !== null);
161
+ if (!found.length) return;
162
+ for (const k of found) delete config[k];
163
+ if (config.verbose !== false) {
164
+ console.warn(
165
+ `⚠️ dungeon-master 1.4 removed engine support for: ${found.join(', ')}. ` +
166
+ `These config keys are silently ignored. Recreate via hooks (see lib/hook-patterns/* once Phase 4 lands).`
167
+ );
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Validate `Funnel.attempts` config in place. Coerces missing/invalid bounds so callers
173
+ * downstream don't have to re-defend. Throws on logically invalid configs (max < min).
174
+ * @param {import('../../types.js').Funnel[]} funnels
175
+ */
176
+ function validateAttempts(funnels) {
177
+ for (const f of funnels) {
178
+ if (!f || !f.attempts) continue;
179
+ const a = f.attempts;
180
+ const min = Number.isFinite(a.min) ? Math.max(0, Math.floor(a.min)) : 0;
181
+ const max = Number.isFinite(a.max) ? Math.max(0, Math.floor(a.max)) : min;
182
+ if (max < min) {
183
+ throw new Error(`Funnel "${f.name || f.sequence?.join(' > ')}" attempts.max (${a.max}) must be >= attempts.min (${a.min})`);
184
+ }
185
+ a.min = min;
186
+ a.max = max;
187
+ if (a.conversionRate !== undefined) {
188
+ if (!Number.isFinite(a.conversionRate)) {
189
+ throw new Error(`Funnel "${f.name || f.sequence?.join(' > ')}" attempts.conversionRate must be a finite number 0-100`);
190
+ }
191
+ a.conversionRate = Math.max(0, Math.min(100, a.conversionRate));
192
+ }
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Normalize `Funnel.experiment` in place. `true` → default 3-variant config.
198
+ * Object → validated and resolved. Stores `funnel._experiment` for downstream.
199
+ * @param {import('../../types.js').Funnel[]} funnels
200
+ * @param {number} datasetEndUnix
201
+ */
202
+ function normalizeExperiments(funnels, datasetEndUnix) {
203
+ const DEFAULT_VARIANTS = [
204
+ { name: 'Variant A', conversionMultiplier: 0.7, ttcMultiplier: 1.5, weight: 1 },
205
+ { name: 'Variant B', conversionMultiplier: 1.3, ttcMultiplier: 0.7, weight: 1 },
206
+ { name: 'Control', conversionMultiplier: 1.0, ttcMultiplier: 1.0, weight: 1 },
207
+ ];
208
+ for (const f of funnels) {
209
+ if (!f || !f.experiment) continue;
210
+ const raw = f.experiment === true ? {} : f.experiment;
211
+ const name = raw.name || (f.name ? f.name + ' Experiment' : 'Unnamed Experiment');
212
+ const variants = (raw.variants && raw.variants.length)
213
+ ? raw.variants.map(v => ({
214
+ name: v.name || 'Unnamed Variant',
215
+ conversionMultiplier: Number.isFinite(v.conversionMultiplier) ? Math.max(0.01, v.conversionMultiplier) : 1.0,
216
+ ttcMultiplier: Number.isFinite(v.ttcMultiplier) ? Math.max(0.01, v.ttcMultiplier) : 1.0,
217
+ weight: Number.isFinite(v.weight) && v.weight > 0 ? v.weight : 1,
218
+ }))
219
+ : DEFAULT_VARIANTS;
220
+ const startDays = Number.isFinite(raw.startDaysBeforeEnd) && raw.startDaysBeforeEnd > 0
221
+ ? raw.startDaysBeforeEnd : 0;
222
+ const startUnix = startDays > 0 ? datasetEndUnix - startDays * 86400 : null;
223
+ f._experiment = { name, variants, startUnix };
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Resolve `avgDevicePerUser` per Section 3.3 rules. Returns the integer device count
229
+ * the engine should use (0 = no device_id stamping at all, 1+ = pool size).
230
+ *
231
+ * @param {Partial<Dungeon>} config
232
+ * @returns {number}
233
+ */
234
+ function resolveDevicesPerUser(config) {
235
+ const raw = config.avgDevicePerUser;
236
+ const hasAnon = config.hasAnonIds === true;
237
+ if (raw === undefined || raw === null) {
238
+ return hasAnon ? 1 : 0;
239
+ }
240
+ if (!Number.isFinite(raw)) {
241
+ return hasAnon ? 1 : 0;
242
+ }
243
+ const n = Math.round(raw);
244
+ if (n <= 0) return hasAnon ? 1 : 0;
245
+ return n;
246
+ }
247
+
76
248
  /**
77
249
  * Validates and enriches a dungeon configuration object
78
250
  * @param {Partial<Dungeon>} config - Raw configuration object
@@ -81,6 +253,9 @@ function inferFunnels(events) {
81
253
  export function validateDungeonConfig(config) {
82
254
  const chance = u.getChance();
83
255
 
256
+ // Phase 1 — strip killed config keys before anything else reads them.
257
+ stripKilledConfigKeys(config);
258
+
84
259
  // Transform SCD props to regular props if credentials are missing
85
260
  // This MUST happen BEFORE we extract values from the config
86
261
  transformSCDPropsWithoutCredentials(config);
@@ -168,18 +343,18 @@ export function validateDungeonConfig(config) {
168
343
  throw new Error(`numDays must be a positive number (got ${config.numDays})`);
169
344
  }
170
345
 
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
- }
346
+ // ── Resolve dataset window ──
347
+ // Preferred path: explicit datasetStart + datasetEnd → pinned, deterministic window.
348
+ // Fallback: numDays only → today_start - numDays back (sliding, warn-emitted).
349
+ const windowResolution = resolveDatasetWindow(config.datasetStart, config.datasetEnd, config.numDays);
350
+ const datasetStartUnix = windowResolution.datasetStartUnix;
351
+ const datasetEndUnix = windowResolution.datasetEndUnix;
352
+ numDays = windowResolution.numDays;
178
353
 
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
- }
354
+ // Mirror window into legacy epoch* surface (still consumed by inferFunnels callers
355
+ // and any external code that read these from validated config).
356
+ epochStart = datasetStartUnix;
357
+ epochEnd = datasetEndUnix;
183
358
 
184
359
  // Resolve event-rate primitive: avgEventsPerUserPerDay is the canonical knob.
185
360
  // numEvents is supported as a fallback (legacy + total-volume target). Whichever
@@ -349,7 +524,7 @@ export function validateDungeonConfig(config) {
349
524
  // Feature 2: World Events
350
525
  let worldEvents = config.worldEvents || null;
351
526
  if (worldEvents) {
352
- worldEvents = resolveWorldEvents(worldEvents, numDays);
527
+ worldEvents = resolveWorldEvents(worldEvents, datasetStartUnix);
353
528
  }
354
529
 
355
530
  // Feature 3: Engagement Decay
@@ -364,35 +539,31 @@ export function validateDungeonConfig(config) {
364
539
  dataQuality = validateDataQuality(dataQuality);
365
540
  }
366
541
 
367
- // Feature 5: Subscription
368
- let subscription = config.subscription || null;
369
- if (subscription) {
370
- subscription = validateSubscription(subscription);
371
- }
542
+ // Phase 1: validate Funnel.attempts on every funnel (additive — most have none).
543
+ validateAttempts(funnels);
372
544
 
373
- // Feature 6: Attribution
374
- let attribution = config.attribution || null;
375
- if (attribution) {
376
- attribution = validateAttribution(attribution, numDays);
377
- }
545
+ // Normalize experiment configs: true → default 3-variant, object → validated.
546
+ normalizeExperiments(funnels, datasetEndUnix);
378
547
 
379
- // Feature 7: Geo
380
- let geo = config.geo || null;
381
- if (geo) {
382
- geo = validateGeo(geo);
383
- }
548
+ // Phase 1: resolve multi-device config. `avgDevicePerUser` is the canonical knob;
549
+ // `hasAnonIds: true` aliases to 1. Default 0 = no device_id stamping (legacy).
550
+ const avgDevicePerUser = resolveDevicesPerUser(config);
551
+ // Keep hasAnonIds in sync — downstream code (utils.generateUser) still reads it.
552
+ // Setting it true here when avgDevicePerUser >= 1 lets the legacy device-pool
553
+ // generation in `person()` continue to allocate `anonymousIds[]` for the user.
554
+ const hasAnonIdsResolved = avgDevicePerUser >= 1;
384
555
 
385
- // Feature 8: Features (progressive adoption)
386
- let features = config.features || null;
387
- if (features) {
388
- features = resolveFeatures(features, numDays);
556
+ // Warn if isAuthEvent is set but avgDevicePerUser=0 — pre-auth device_only
557
+ // stamping degrades to user_id via the floor guard, defeating the identity model.
558
+ if (avgDevicePerUser === 0 && validatedEvents.some(e => e.isAuthEvent)) {
559
+ console.warn(
560
+ `⚠️ isAuthEvent requires avgDevicePerUser >= 1 to produce pre-auth anonymous events. ` +
561
+ `Set avgDevicePerUser or hasAnonIds: true.`
562
+ );
389
563
  }
390
564
 
391
- // Feature 9: Anomalies
392
- let anomalies = config.anomalies || null;
393
- if (anomalies) {
394
- anomalies = resolveAnomalies(anomalies, numDays);
395
- }
565
+ // Precompute whether any event has isAttributionEvent for UTM stamping logic.
566
+ const hasAttributionFlags = validatedEvents.some(e => e.isAttributionEvent);
396
567
 
397
568
  // Build final config object
398
569
  const validatedConfig = {
@@ -407,6 +578,8 @@ export function validateDungeonConfig(config) {
407
578
  avgEventsPerUserPerDay,
408
579
  epochStart,
409
580
  epochEnd,
581
+ datasetStart: datasetStartUnix,
582
+ datasetEnd: datasetEndUnix,
410
583
  events: validatedEvents,
411
584
  superProps,
412
585
  userProps,
@@ -415,7 +588,8 @@ export function validateDungeonConfig(config) {
415
588
  groupKeys,
416
589
  groupProps,
417
590
  lookupTables,
418
- hasAnonIds,
591
+ hasAnonIds: hasAnonIdsResolved,
592
+ avgDevicePerUser,
419
593
  hasSessionIds,
420
594
  sessionTimeout: (typeof sessionTimeout === 'number' && sessionTimeout > 0) ? sessionTimeout : 30,
421
595
  format,
@@ -427,6 +601,7 @@ export function validateDungeonConfig(config) {
427
601
  hook,
428
602
  hasAdSpend,
429
603
  hasCampaigns,
604
+ hasAttributionFlags,
430
605
  hasLocation,
431
606
  hasAvatar,
432
607
  isAnonymous,
@@ -441,16 +616,17 @@ export function validateDungeonConfig(config) {
441
616
  bornRecentBias,
442
617
  percentUsersBornInDataset,
443
618
  preExistingSpread,
444
- // Advanced features
619
+ // Advanced features (kept after 1.4)
445
620
  personas,
446
621
  worldEvents,
447
622
  engagementDecay,
448
623
  dataQuality,
449
- subscription,
450
- attribution,
451
- geo,
452
- features,
453
- anomalies
624
+ // Killed in 1.4 — set to null so hooks/external code that reads these get a falsy value.
625
+ subscription: null,
626
+ attribution: null,
627
+ geo: null,
628
+ features: null,
629
+ anomalies: null
454
630
  };
455
631
 
456
632
  return validatedConfig;
@@ -551,12 +727,11 @@ function validatePersonas(personas) {
551
727
  /**
552
728
  * Resolves world events to absolute timestamps
553
729
  * @param {import('../../types').WorldEvent[]} worldEvents
554
- * @param {number} numDays
730
+ * @param {number} beginUnix - Dataset start (unix seconds)
555
731
  * @returns {import('../../types').ResolvedWorldEvent[]}
556
732
  */
557
- function resolveWorldEvents(worldEvents, numDays) {
733
+ function resolveWorldEvents(worldEvents, beginUnix) {
558
734
  if (!Array.isArray(worldEvents) || worldEvents.length === 0) return null;
559
- const beginUnix = dayjs.unix(FIXED_NOW).subtract(numDays, 'day').unix();
560
735
 
561
736
  return worldEvents.map(we => {
562
737
  const startUnix = beginUnix + (we.startDay * 86400);
@@ -616,132 +791,8 @@ function validateDataQuality(dq) {
616
791
  return dq;
617
792
  }
618
793
 
619
- /**
620
- * Validates subscription config
621
- * @param {import('../../types').Subscription} sub
622
- * @returns {import('../../types').Subscription}
623
- */
624
- function validateSubscription(sub) {
625
- if (!sub.plans || !Array.isArray(sub.plans) || sub.plans.length === 0) {
626
- throw new Error('subscription.plans must be a non-empty array');
627
- }
628
- const hasDefault = sub.plans.some(p => p.default);
629
- if (!hasDefault) sub.plans[0].default = true;
630
- if (!sub.lifecycle) sub.lifecycle = {};
631
- const lc = sub.lifecycle;
632
- if (lc.trialToPayRate === undefined) lc.trialToPayRate = 0.3;
633
- if (lc.upgradeRate === undefined) lc.upgradeRate = 0.1;
634
- if (lc.downgradeRate === undefined) lc.downgradeRate = 0.03;
635
- if (lc.churnRate === undefined) lc.churnRate = 0.05;
636
- if (lc.winBackRate === undefined) lc.winBackRate = 0.1;
637
- if (lc.winBackDelay === undefined) lc.winBackDelay = 30;
638
- if (lc.paymentFailureRate === undefined) lc.paymentFailureRate = 0.02;
639
- if (!sub.events) sub.events = {};
640
- const ev = sub.events;
641
- if (!ev.trialStarted) ev.trialStarted = "trial started";
642
- if (!ev.subscribed) ev.subscribed = "subscription started";
643
- if (!ev.upgraded) ev.upgraded = "plan upgraded";
644
- if (!ev.downgraded) ev.downgraded = "plan downgraded";
645
- if (!ev.renewed) ev.renewed = "subscription renewed";
646
- if (!ev.cancelled) ev.cancelled = "subscription cancelled";
647
- if (!ev.paymentFailed) ev.paymentFailed = "payment failed";
648
- if (!ev.wonBack) ev.wonBack = "subscription reactivated";
649
- return sub;
650
- }
651
-
652
- /**
653
- * Validates attribution config
654
- * @param {import('../../types').Attribution} attr
655
- * @param {number} numDays
656
- * @returns {import('../../types').Attribution}
657
- */
658
- function validateAttribution(attr, numDays) {
659
- if (!attr.campaigns || !Array.isArray(attr.campaigns)) {
660
- throw new Error('attribution.campaigns must be an array');
661
- }
662
- if (attr.model === undefined) attr.model = "last_touch";
663
- if (attr.window === undefined) attr.window = 7;
664
- if (attr.organicRate === undefined) attr.organicRate = 0.4;
665
- for (const c of attr.campaigns) {
666
- if (!c.name) throw new Error('Each attribution campaign must have a name');
667
- if (!c.source) throw new Error(`Attribution campaign "${c.name}" must have a source`);
668
- if (!c.activeDays) throw new Error(`Attribution campaign "${c.name}" must have activeDays [start, end]`);
669
- if (!c.dailyBudget) c.dailyBudget = [50, 200];
670
- if (c.acquisitionRate === undefined) c.acquisitionRate = 0.02;
671
- }
672
- return attr;
673
- }
674
-
675
- /**
676
- * Validates geo config
677
- * @param {import('../../types').GeoConfig} geo
678
- * @returns {import('../../types').GeoConfig}
679
- */
680
- function validateGeo(geo) {
681
- if (geo.sticky === undefined) geo.sticky = false;
682
- if (geo.regions && !Array.isArray(geo.regions)) throw new Error('geo.regions must be an array');
683
- if (geo.regionalLaunches && !Array.isArray(geo.regionalLaunches)) throw new Error('geo.regionalLaunches must be an array');
684
- return geo;
685
- }
686
-
687
- /**
688
- * Resolves feature configs with logistic curve parameters
689
- * @param {import('../../types').FeatureConfig[]} features
690
- * @param {number} numDays
691
- * @returns {import('../../types').FeatureConfig[]}
692
- */
693
- function resolveFeatures(features, numDays) {
694
- if (!Array.isArray(features) || features.length === 0) return null;
695
- const curvePresets = {
696
- fast: { k: 0.3, midpoint: 7 },
697
- slow: { k: 0.08, midpoint: 30 },
698
- instant: { k: 10, midpoint: 0 }
699
- };
700
- return features.map(f => {
701
- if (!f.name) throw new Error('Each feature must have a name');
702
- if (f.launchDay === undefined) throw new Error(`Feature "${f.name}" must have a launchDay`);
703
- if (!f.property) throw new Error(`Feature "${f.name}" must have a property`);
704
- if (!f.values || !Array.isArray(f.values) || f.values.length === 0) {
705
- throw new Error(`Feature "${f.name}" must have a non-empty values array`);
706
- }
707
- if (!f.affectsEvents) f.affectsEvents = "*";
708
- if (!f.adoptionCurve) f.adoptionCurve = "slow";
709
- if (typeof f.adoptionCurve === 'string') {
710
- f._resolvedCurve = curvePresets[f.adoptionCurve] || curvePresets.slow;
711
- } else {
712
- f._resolvedCurve = f.adoptionCurve;
713
- }
714
- // Pre-compute adopted values to avoid array allocation in hot loop
715
- f._adoptedValues = f.values.length > 1 ? f.values.slice(1) : f.values;
716
- return f;
717
- });
718
- }
719
-
720
- /**
721
- * Resolves anomaly configs with absolute timestamps
722
- * @param {import('../../types').AnomalyConfig[]} anomalies
723
- * @param {number} numDays
724
- * @returns {import('../../types').AnomalyConfig[]}
725
- */
726
- function resolveAnomalies(anomalies, numDays) {
727
- if (!Array.isArray(anomalies) || anomalies.length === 0) return null;
728
- const beginUnix = dayjs.unix(FIXED_NOW).subtract(numDays, 'day').unix();
729
- return anomalies.map(a => {
730
- if (!a.type) throw new Error('Each anomaly must have a type');
731
- if (!a.event) throw new Error('Each anomaly must have an event name');
732
- const resolved = { ...a };
733
- if (a.day !== undefined) {
734
- resolved._startUnix = beginUnix + (a.day * 86400);
735
- if (a.duration) {
736
- resolved._endUnix = resolved._startUnix + (a.duration * 86400);
737
- } else if (a.window) {
738
- resolved._endUnix = resolved._startUnix + (a.window * 86400);
739
- } else {
740
- resolved._endUnix = resolved._startUnix + 86400; // default 1 day
741
- }
742
- }
743
- return resolved;
744
- });
745
- }
794
+ // validateSubscription / validateAttribution / validateGeo / resolveFeatures /
795
+ // resolveAnomalies were removed in 1.4 along with their respective config keys.
796
+ // `stripKilledConfigKeys` deletes the inputs before they reach the validator body.
746
797
 
747
798
  export { inferFunnels, transformSCDPropsWithoutCredentials };
@@ -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),