@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
package/types.d.ts CHANGED
@@ -17,15 +17,40 @@ export type ValueValid = Primitives | ValueValid[] | (() => ValueValid);
17
17
  */
18
18
  export interface Dungeon {
19
19
  // ── Core Parameters ──
20
+ /** Optional dungeon version. Not used by the engine — serves as metadata for tracking revisions when configs are saved/shared. */
21
+ version?: string | number;
20
22
  /** Mixpanel project token. If provided, data will be imported to Mixpanel after generation. */
21
23
  token?: string;
22
24
  /** RNG seed for reproducible output. Same seed + concurrency=1 = identical data. */
23
25
  seed?: string;
24
- /** Number of days the dataset spans (from "now" looking backward). Default: 30 */
26
+ /**
27
+ * Number of days the dataset spans. Default: 30.
28
+ *
29
+ * Three resolution modes:
30
+ * 1. **`numDays` alone (no datasetStart/End):** Window = `[today - numDays, today]`.
31
+ * Simplest API for ad-hoc dungeons. NOT deterministic across runs (today changes).
32
+ * 2. **`datasetStart` + `datasetEnd` (no numDays):** Window pinned exactly. `numDays`
33
+ * derived automatically. Fully deterministic — use for vertical/production dungeons.
34
+ * 3. **All three set:** `datasetStart`/`datasetEnd` win. `numDays` is recomputed from
35
+ * the window; user-supplied value is ignored (with a warning).
36
+ *
37
+ * Setting only one of `datasetStart`/`datasetEnd` throws.
38
+ */
25
39
  numDays?: number;
26
- /** Explicit start of dataset window (unix seconds). Alternative to numDays. */
40
+ /**
41
+ * Explicit start of the dataset window. Pin BOTH `datasetStart` and `datasetEnd` for
42
+ * bit-exact deterministic runs. Accepts ISO string ("2026-01-01T00:00:00Z"), unix
43
+ * seconds (1735689600), unix milliseconds (1735689600000), or anything `dayjs()`
44
+ * can parse. Setting only one of datasetStart/datasetEnd throws.
45
+ */
46
+ datasetStart?: string | number;
47
+ /**
48
+ * Explicit end of the dataset window. See `datasetStart` — both must be set together.
49
+ */
50
+ datasetEnd?: string | number;
51
+ /** @deprecated Legacy alias internally aliased to datasetStart on validated config. Prefer `datasetStart`. */
27
52
  epochStart?: number;
28
- /** Explicit end of dataset window (unix seconds). Defaults to FIXED_NOW. */
53
+ /** @deprecated Legacy alias internally aliased to datasetEnd on validated config. Prefer `datasetEnd`. */
29
54
  epochEnd?: number;
30
55
  /** Target total number of events to generate across all users. Fallback when avgEventsPerUserPerDay is not set; otherwise derived from rate × numUsers × numDays. */
31
56
  numEvents?: number;
@@ -58,7 +83,21 @@ export interface Dungeon {
58
83
  hasAvatar?: boolean;
59
84
  /** If true, events include geo properties (city, region, country, lat/lng). */
60
85
  hasLocation?: boolean;
61
- /** If true, events include UTM campaign properties. */
86
+ /**
87
+ * If true, events include UTM campaign properties (utm_source / utm_campaign / utm_medium /
88
+ * utm_content / utm_term).
89
+ *
90
+ * Default: false.
91
+ *
92
+ * Behavior:
93
+ * - false: no UTM stamping anywhere.
94
+ * - true + at least one event in `events[]` has `isAttributionEvent: true`: only the flagged
95
+ * events are eligible. Within those, ~25% are stamped with a randomly-picked campaign.
96
+ * - true + no event flagged: backwards-compat fallback — ~25% of ALL events are stamped
97
+ * with a randomly-picked campaign (legacy behavior).
98
+ *
99
+ * @see EventConfig.isAttributionEvent
100
+ */
62
101
  hasCampaigns?: boolean;
63
102
  /** If true, generates ad spend data (impressions, clicks, cost). */
64
103
  hasAdSpend?: boolean;
@@ -76,8 +115,47 @@ export interface Dungeon {
76
115
  gzip?: boolean;
77
116
  /** If true, prints progress to stdout during generation. */
78
117
  verbose?: boolean;
79
- /** If true, users get anonymous device IDs in addition to distinct_id. */
118
+ /**
119
+ * @deprecated Prefer `avgDevicePerUser`. `true` is now an alias for `avgDevicePerUser: 1`
120
+ * (single sticky device per user, every event stamped with that `device_id`). `false`
121
+ * (default) leaves the engine in legacy "no device_id stamping" mode unless
122
+ * `avgDevicePerUser` is set.
123
+ *
124
+ * @see Dungeon.avgDevicePerUser
125
+ */
80
126
  hasAnonIds?: boolean;
127
+ /**
128
+ * Number of distinct devices each user owns. Whole number ≥ 0. Default: 0 (legacy —
129
+ * no `device_id` stamping anywhere). `≤0` is coerced to `1` if `hasAnonIds: true` is
130
+ * also set; otherwise `0` keeps the engine in legacy mode for backwards compat.
131
+ *
132
+ * Behavior:
133
+ * - `0` (default): no `device_id` stamping. Every event gets `user_id` only. Same as
134
+ * pre-1.4 behavior when `hasAnonIds` is not set.
135
+ * - `1`: one device per user. All of that user's events that need a device share a
136
+ * single sticky `device_id`. `hasAnonIds: true` is an alias for this.
137
+ * - `>1`: per-user device pool sized via a normal distribution centered on this value
138
+ * (sd ≈ value/2, clamped ≥ 1, integer-rounded). Sessions are sticky to a single
139
+ * device drawn from the user's pool — every event in that session shares the same
140
+ * `device_id`. Cross-session events for the same user may differ.
141
+ *
142
+ * Identity stamping interactions (multi-device + auth + first funnel):
143
+ * - Pre-existing users (born before dataset window): every event gets both `user_id`
144
+ * and a per-session `device_id`.
145
+ * - Born-in-dataset users running their `isFirstFunnel`:
146
+ * * Pre-auth steps (steps before the first `isAuthEvent` in the funnel sequence):
147
+ * `device_id` only — no `user_id` yet.
148
+ * * The stitch step (the first `isAuthEvent`): both `user_id` AND `device_id`.
149
+ * * Post-auth steps in the same funnel: `user_id` only.
150
+ * * All later (non-firstFunnel) events: `user_id` + per-session sticky `device_id`.
151
+ * - Born-in-dataset users on a `Funnel.attempts` retry that does not reach `isAuthEvent`:
152
+ * every event in that failed attempt is `device_id` only (pre-auth, never stitched).
153
+ *
154
+ * @see Dungeon.hasAnonIds (deprecated alias when `true`)
155
+ * @see EventConfig.isAuthEvent
156
+ * @see Funnel.attempts
157
+ */
158
+ avgDevicePerUser?: number;
81
159
  /** If true, users get session IDs attached to events based on temporal clustering. */
82
160
  hasSessionIds?: boolean;
83
161
  /** Session timeout in minutes. Events with gaps exceeding this start a new session. Default: 30. Only used when hasSessionIds is true. */
@@ -128,16 +206,13 @@ export interface Dungeon {
128
206
  engagementDecay?: EngagementDecay;
129
207
  /** Data quality imperfections to inject (nulls, duplicates, bots, late-arriving events). */
130
208
  dataQuality?: DataQuality;
131
- /** Subscription/revenue lifecycle configuration. */
132
- subscription?: Subscription;
133
- /** Connected attribution configuration linking campaigns to user acquisition. */
134
- attribution?: Attribution;
135
- /** Geographic intelligence: sticky locations, timezone-aware activity, regional launches. */
136
- geo?: GeoConfig;
137
- /** Progressive feature adoption: features that launch mid-dataset with S-curve adoption. */
138
- features?: FeatureConfig[];
139
- /** Anomaly/outlier injection: extreme values, bursts, coordinated spikes. */
140
- anomalies?: AnomalyConfig[];
209
+
210
+ // ── Removed in 1.4 (silently ignored, one deprecation warning per dungeon) ──
211
+ // The following config keys were removed from the engine in 1.4. Existing dungeon
212
+ // files that still set them will load and run — `validateDungeonConfig` strips them
213
+ // with a single deprecation warning per dungeon. Recreate these patterns as hooks
214
+ // (see `lib/hook-patterns/*` once Phase 4 lands).
215
+ // subscription, attribution, geo, features, anomalies
141
216
 
142
217
  /** Allow arbitrary additional properties on the config. */
143
218
  [key: string]: any;
@@ -279,8 +354,32 @@ export type hookTypes =
279
354
  */
280
355
  export type Hook<T> = (record: any, type: hookTypes, meta: any) => T;
281
356
 
282
- /** Meta passed to the "event" hook. */
283
- export interface HookMetaEvent {
357
+ /**
358
+ * Time-window anchors present on every hook's `meta`. Use these to derive relative
359
+ * dates inside hooks (e.g. `dayjs.unix(meta.datasetStart).add(45, 'days')`). NEVER
360
+ * read wall-clock `dayjs()` inside a hook — it makes hooks non-deterministic.
361
+ */
362
+ export interface HookMetaTimeAnchors {
363
+ /** Start of the dataset window (unix seconds). Same value the engine uses to bound event generation. */
364
+ datasetStart: number;
365
+ /** End of the dataset window (unix seconds). Same value the engine uses to bound event generation. */
366
+ datasetEnd: number;
367
+ }
368
+
369
+ /**
370
+ * Meta passed to the "event" hook.
371
+ *
372
+ * **Temporal-check warning:** `datasetStart`/`datasetEnd` are unix seconds in the
373
+ * shifted time frame, but `record.time` during the event hook is in the pre-shift
374
+ * fixed window. Comparing them directly (e.g., `dayjs(record.time).diff(dayjs.unix(meta.datasetStart))`)
375
+ * produces unreliable day-in-dataset values. Move any temporal check to the
376
+ * `everything` hook where both timestamps are in the same frame.
377
+ *
378
+ * Safe uses of the event hook: closure-based state (module-level Maps), event
379
+ * replacement (return a different object), simple property mutations not gated
380
+ * on time.
381
+ */
382
+ export interface HookMetaEvent extends HookMetaTimeAnchors {
284
383
  /** The user this event belongs to (only `distinct_id` is guaranteed). */
285
384
  user: { distinct_id: string };
286
385
  /** The fully-resolved dungeon config. */
@@ -288,7 +387,7 @@ export interface HookMetaEvent {
288
387
  }
289
388
 
290
389
  /** Meta passed to the "user" hook (fires when a user profile is created). */
291
- export interface HookMetaUser {
390
+ export interface HookMetaUser extends HookMetaTimeAnchors {
292
391
  /** The user object being constructed (mutate in place). */
293
392
  user: UserProfile;
294
393
  /** The fully-resolved dungeon config. */
@@ -298,7 +397,7 @@ export interface HookMetaUser {
298
397
  }
299
398
 
300
399
  /** Meta passed to the "scd-pre" hook (fires per SCD prop, before insertion). */
301
- export interface HookMetaScdPre {
400
+ export interface HookMetaScdPre extends HookMetaTimeAnchors {
302
401
  /** The user profile that owns these SCD entries. */
303
402
  profile: UserProfile;
304
403
  /** The SCD prop key being generated (e.g. "plan", "tier"). */
@@ -312,28 +411,77 @@ export interface HookMetaScdPre {
312
411
  }
313
412
 
314
413
  /** Meta passed to the "funnel-pre" hook (mutate funnel before generating events). */
315
- export interface HookMetaFunnelPre {
414
+ export interface HookMetaFunnelPre extends HookMetaTimeAnchors {
316
415
  user: { distinct_id: string };
317
416
  profile: UserProfile;
318
417
  scd: Record<string, SCDSchema[]>;
319
418
  funnel: Funnel;
320
419
  config: Dungeon;
321
- /** Unix seconds — earliest possible event time for this funnel's first step. */
420
+ /**
421
+ * Unix seconds — temporal anchor for this funnel run. For usage funnels, advances
422
+ * after each run so successive funnels spread across the user's active window.
423
+ * For first-funnel attempts, matches the attempt cursor. Use this to implement
424
+ * temporal conversion trends (e.g., "conversion increases after day 30").
425
+ */
322
426
  firstEventTime: number;
427
+ /** True if this funnel is the user's `isFirstFunnel`. */
428
+ isFirstFunnel: boolean;
429
+ /** True if the user's account creation falls inside the dataset window. */
430
+ isBorn: boolean;
431
+ /** Resolved attempts config for this funnel run, or null if attempts is not configured. */
432
+ attemptsConfig: AttemptsConfig | null;
433
+ /** 1-indexed attempt number for this run (1..totalAttempts). */
434
+ attemptNumber: number;
435
+ /** Total number of attempts (failed priors + 1 final). When attempts is omitted, this is 1. */
436
+ totalAttempts: number;
437
+ /** True if this is the final attempt (attemptNumber === totalAttempts). */
438
+ isFinalAttempt: boolean;
439
+ /** The user's assigned persona (if `personas` is configured), or null. */
440
+ persona: Persona | null;
441
+ /** Experiment context for this funnel run, or null if no experiment / pre-start-date. */
442
+ experiment: HookMetaExperiment | null;
323
443
  }
324
444
 
325
445
  /** Meta passed to the "funnel-post" hook (mutate generated funnel events in place). */
326
- export interface HookMetaFunnelPost {
446
+ export interface HookMetaFunnelPost extends HookMetaTimeAnchors {
327
447
  user: { distinct_id: string };
328
448
  profile: UserProfile;
329
449
  scd: Record<string, SCDSchema[]>;
330
450
  funnel: Funnel;
331
451
  config: Dungeon;
452
+ /** Unix seconds — temporal anchor for this funnel run (see HookMetaFunnelPre.firstEventTime). */
453
+ firstEventTime: number;
454
+ /** True if this funnel is the user's `isFirstFunnel`. */
455
+ isFirstFunnel: boolean;
456
+ /** True if the user's account creation falls inside the dataset window. */
457
+ isBorn: boolean;
458
+ /** Resolved attempts config for this funnel run, or null if attempts is not configured. */
459
+ attemptsConfig: AttemptsConfig | null;
460
+ /** 1-indexed attempt number for this run (1..totalAttempts). */
461
+ attemptNumber: number;
462
+ /** Total number of attempts. */
463
+ totalAttempts: number;
464
+ /** True if this is the final attempt. */
465
+ isFinalAttempt: boolean;
466
+ /** The user's assigned persona (if `personas` is configured), or null. */
467
+ persona: Persona | null;
468
+ /** Experiment context for this funnel run, or null if no experiment / pre-start-date. */
469
+ experiment: HookMetaExperiment | null;
332
470
  }
333
471
 
334
- /** Meta passed to the "everything" hook — most powerful hook (sees all events for one user). */
335
- export interface HookMetaEverything {
336
- /** The user's profile, including merged persona/region/attribution properties. */
472
+ /**
473
+ * Meta passed to the "everything" hook — most powerful hook (sees all events for one user).
474
+ *
475
+ * **Ordering within the hook matters.** When multiple effects coexist:
476
+ * 1. SuperProp stamping (profile values → events)
477
+ * 2. Non-temporal mutations and event cloning/injection
478
+ * 3. Event filtering (churn, retention, rate-limit drops)
479
+ * 4. Temporal value mutations (price spikes, error windows) — run LAST
480
+ * so cloned events that land in the window also get the mutation
481
+ * 5. Sort by time
482
+ */
483
+ export interface HookMetaEverything extends HookMetaTimeAnchors {
484
+ /** The user's profile, including any merged persona properties. */
337
485
  profile: UserProfile;
338
486
  /** All SCD entries for this user, keyed by prop name. */
339
487
  scd: Record<string, SCDSchema[]>;
@@ -341,6 +489,22 @@ export interface HookMetaEverything {
341
489
  config: Dungeon;
342
490
  /** True if the user's account creation falls inside the dataset window. */
343
491
  userIsBornInDataset: boolean;
492
+ /**
493
+ * Unix milliseconds of the stitch event (the first `isAuthEvent` in the user's stream).
494
+ * `null` if this user never authed (pre-existing users have no stitch event in the
495
+ * dataset window — they're already authed; born-in-dataset users who never converted
496
+ * remain pre-auth forever).
497
+ */
498
+ authTime: number | null;
499
+ /**
500
+ * Predicate bound to this user's `authTime`. Returns true if the event happened before
501
+ * the stitch (i.e. the user was anonymous at that point). Returns false for pre-existing
502
+ * users (they're considered authed throughout). For born-in-dataset users that never
503
+ * authed, returns true for every event.
504
+ */
505
+ isPreAuth: (event: EventSchema) => boolean;
506
+ /** The user's assigned persona (if `personas` is configured), or null. */
507
+ persona: Persona | null;
344
508
  }
345
509
 
346
510
  export interface hookArrayOptions<T> {
@@ -463,10 +627,14 @@ export interface Context {
463
627
  /** Pre-built UTM campaign pool (used when `hasCampaigns: true`). */
464
628
  campaigns: Record<string, ValueValid>[];
465
629
  runtime: RuntimeState;
630
+ /** End of the resolved dataset window (unix seconds). Equal to the user-supplied `datasetEnd`, or fallback `today_start`. */
466
631
  FIXED_NOW: number;
632
+ /** Start of the resolved dataset window (unix seconds). Equal to the user-supplied `datasetStart`, or fallback `today_start - numDays`. */
467
633
  FIXED_BEGIN?: number;
468
- TIME_SHIFT_SECONDS: number;
469
- MAX_TIME: number;
634
+ /** Alias of `FIXED_BEGIN` — surfaced on hook `meta.datasetStart`. */
635
+ DATASET_START_SECONDS: number;
636
+ /** Alias of `FIXED_NOW` — surfaced on hook `meta.datasetEnd`. */
637
+ DATASET_END_SECONDS: number;
470
638
 
471
639
  // State update methods
472
640
  incrementOperations(): void;
@@ -483,10 +651,6 @@ export interface Context {
483
651
  incrementUserCount(): void;
484
652
  incrementEventCount(): void;
485
653
  isBatchMode(): boolean;
486
-
487
- // Time helper methods
488
- getTimeShift(): number;
489
- getDaysShift(): number;
490
654
  }
491
655
 
492
656
  /**
@@ -495,7 +659,7 @@ export interface Context {
495
659
  export interface EventConfig {
496
660
  /** The event name (e.g., "page viewed", "purchase completed"). */
497
661
  event?: string;
498
- /** Relative frequency weight (1-10). Higher = more likely to be selected. Used for both standalone event selection and funnel sequence building. Default: 1 */
662
+ /** Relative frequency weight (1-10, clamped by validator). Higher = more likely to be selected for standalone event generation. Does NOT control funnel event frequency — funnels generate their own events. 0 is clamped to 1. Default: 1 */
499
663
  weight?: number;
500
664
  /** Properties to attach to this event type. Values can be arrays (random pick), functions, or primitives. */
501
665
  properties?: Record<string, ValueValid>;
@@ -509,8 +673,49 @@ export interface EventConfig {
509
673
  isSessionStartEvent?: boolean;
510
674
  /** Internal: timing offset in milliseconds (set by funnel system, not user-configured). */
511
675
  relativeTimeMs?: number;
512
- /** If true, this event is excluded from auto-generated funnels (inferFunnels and catch-all). Use for system events that shouldn't appear in conversion sequences. */
676
+ /** If true, this event appears ONLY in explicitly-defined funnels that reference it — excluded from standalone event generation and auto-generated funnels. Use for events that should only occur in funnel context (e.g., "application approved" only after "application submitted"). Also useful to suppress standalone generation of events with weight > 0 that you only want from funnels. */
513
677
  isStrictEvent?: boolean;
678
+ /**
679
+ * If true, this event marks the moment a user transitions from anonymous (pre-auth)
680
+ * to identified (post-auth) — typically the "Sign Up" or "Login" event. Multiple events
681
+ * in a dungeon may carry this flag; the engine looks at the first occurrence in a user's
682
+ * stream to determine the identity stitch moment.
683
+ *
684
+ * Default: false.
685
+ *
686
+ * Behavior, when in a funnel marked `isFirstFunnel: true`:
687
+ * - All steps before the first `isAuthEvent` step in the funnel sequence are stamped
688
+ * with `device_id` only (pre-auth).
689
+ * - The `isAuthEvent` step itself is the stitch — it carries BOTH `user_id` AND
690
+ * `device_id`. Exactly one such record per converted born-in-dataset user.
691
+ * - Steps after the stitch in that funnel get `user_id` only.
692
+ *
693
+ * Behavior outside `isFirstFunnel`: the flag has no extra effect — those events follow
694
+ * the usual identity rules for that user (per `avgDevicePerUser`).
695
+ *
696
+ * Behavior on born-in-dataset users whose `Funnel.attempts` retries fail to reach the
697
+ * `isAuthEvent`: every event in those failed attempts is `device_id` only (pre-auth,
698
+ * never stitched). If their final attempt also fails, they remain pre-auth forever.
699
+ *
700
+ * @see Dungeon.avgDevicePerUser
701
+ * @see Funnel.isFirstFunnel
702
+ * @see Funnel.attempts
703
+ */
704
+ isAuthEvent?: boolean;
705
+ /**
706
+ * If true, this event is eligible to carry UTM campaign properties when
707
+ * `Dungeon.hasCampaigns: true`. ~25% of flagged events get a randomly-picked campaign
708
+ * stamped (utm_source / utm_campaign / utm_medium / utm_content / utm_term).
709
+ *
710
+ * Default: false.
711
+ *
712
+ * Backwards compat: if `Dungeon.hasCampaigns: true` but no event carries this flag,
713
+ * ~25% of ALL events are stamped (legacy behavior, preserved). Opt-in by flagging at
714
+ * least one event.
715
+ *
716
+ * @see Dungeon.hasCampaigns
717
+ */
718
+ isAttributionEvent?: boolean;
514
719
  }
515
720
 
516
721
  export interface GroupEventConfig extends EventConfig {
@@ -595,14 +800,137 @@ export interface Funnel {
595
800
  */
596
801
  conditions?: Record<string, ValueValid>;
597
802
  /**
598
- * If true, the funnel will be part of an experiment where we generate 3 variants of the funnel with different conversion rates
803
+ * Experiment configuration for this funnel.
804
+ *
805
+ * - `true` — backward-compatible shorthand: 3 variants (Variant A = worse, Variant B = better, Control),
806
+ * active for the entire dataset.
807
+ * - `ExperimentConfig` object — custom variant names, conversion/TTC multipliers, temporal gating,
808
+ * and distribution weights.
599
809
  *
810
+ * Variant assignment is **deterministic per user** (hash of user_id + experiment name), so the same
811
+ * user is in the same variant across all funnel runs. `$experiment_started` is prepended to the
812
+ * sequence for every post-start-date funnel run.
813
+ *
814
+ * Hook meta (`meta.experiment`) exposes the resolved variant in `funnel-pre` and `funnel-post`
815
+ * hooks, enabling variant-specific story injection.
816
+ *
817
+ * @see ExperimentConfig
600
818
  */
601
- experiment?: boolean;
819
+ experiment?: boolean | ExperimentConfig;
602
820
  /**
603
821
  * optional: if set, in sequential funnels, this will determine WHEN the property is bound to the rest of the events in the funnel
604
822
  */
605
823
  bindPropsIndex?: number;
824
+ /**
825
+ * Multi-attempt iteration for this funnel. Models real users who land, abandon, come
826
+ * back, and try again. Additive — omit for legacy single-attempt behavior.
827
+ *
828
+ * @see AttemptsConfig
829
+ */
830
+ attempts?: AttemptsConfig;
831
+ /** @internal Resolved experiment config set by config-validator. */
832
+ _experiment?: { name: string; variants: Array<{ name: string; conversionMultiplier: number; ttcMultiplier: number; weight: number }>; startUnix: number | null };
833
+ /** @internal Set by funnels.js during experiment handling. */
834
+ _experimentName?: string;
835
+ /** @internal Set by funnels.js during experiment handling. */
836
+ _experimentVariant?: string;
837
+ }
838
+
839
+ /**
840
+ * Per-funnel multi-attempt config. `attempts.min`/`attempts.max` describe the count of
841
+ * **failed prior attempts** (NOT total attempts). The engine picks an integer
842
+ * `failedPriors = chance.integer({min, max})` then runs `failedPriors + 1` total
843
+ * passes through the funnel. The last pass is the "final attempt" — it converts per
844
+ * `attempts.conversionRate ?? funnel.conversionRate`. Each prior attempt is a truncated
845
+ * pre-auth pass that drops out at a random step before reaching any `isAuthEvent`.
846
+ *
847
+ * Identity interaction (when the funnel is `isFirstFunnel`):
848
+ * - Failed prior attempts: every event stamped with `device_id` only — never reach the
849
+ * stitch step, so `user_id` is never assigned.
850
+ * - Final attempt: follows the standard pre-auth → stitch → post-auth identity model.
851
+ * If the final attempt also fails, the user remains pre-auth forever.
852
+ *
853
+ * For non-`isFirstFunnel` funnels, each attempt is treated as an independent usage
854
+ * session (e.g. abandon-cart). Identity stamping uses the user's normal post-auth model.
855
+ *
856
+ * @example single attempt (default behavior)
857
+ * { attempts: { min: 0, max: 0 } } // exactly one pass — equivalent to omitting attempts
858
+ *
859
+ * @example up to 3 failed retries before a 60% final conversion
860
+ * { conversionRate: 60, attempts: { min: 0, max: 3 } }
861
+ *
862
+ * @example heavy churn before final attempt with overridden conversion rate
863
+ * { conversionRate: 80, attempts: { min: 1, max: 5, conversionRate: 30 } }
864
+ */
865
+ export interface AttemptsConfig {
866
+ /** Lower bound on the number of FAILED PRIOR attempts. 0 = a single attempt is possible. Whole number, ≥ 0. Default: 0. */
867
+ min?: number;
868
+ /** Upper bound on the number of FAILED PRIOR attempts (inclusive). Whole number, ≥ min. Default: 0. */
869
+ max?: number;
870
+ /**
871
+ * Conversion rate (0–100) applied to the FINAL attempt only — overrides
872
+ * `funnel.conversionRate` if set. Omit to inherit `funnel.conversionRate`.
873
+ * Matches the existing `Funnel.conversionRate` scale (0–100, NOT 0–1).
874
+ */
875
+ conversionRate?: number;
876
+ }
877
+
878
+ /**
879
+ * Experiment configuration for a funnel. Controls variant assignment, naming,
880
+ * conversion/TTC modifiers, and temporal gating.
881
+ *
882
+ * @example A/B test starting 30 days before dataset end
883
+ * {
884
+ * name: "Checkout Redesign",
885
+ * startDaysBeforeEnd: 30,
886
+ * variants: [
887
+ * { name: "Control" },
888
+ * { name: "New Checkout", conversionMultiplier: 1.25, ttcMultiplier: 0.8 },
889
+ * ]
890
+ * }
891
+ */
892
+ export interface ExperimentConfig {
893
+ /** Human-readable experiment name. Default: `funnel.name + " Experiment"`. */
894
+ name?: string;
895
+ /**
896
+ * Variant definitions. Each variant gets a deterministic share of users.
897
+ * Default (when omitted): 3 variants — Variant A (worse), Variant B (better), Control.
898
+ */
899
+ variants?: ExperimentVariant[];
900
+ /**
901
+ * Days before dataset end that the experiment starts. Funnel runs before
902
+ * the start date skip experiment logic entirely (no variant, no $experiment_started).
903
+ * Default: 0 (entire dataset).
904
+ */
905
+ startDaysBeforeEnd?: number;
906
+ }
907
+
908
+ /** A single variant in an experiment. */
909
+ export interface ExperimentVariant {
910
+ /** Display name — appears in the "Variant name" property on $experiment_started. */
911
+ name: string;
912
+ /** Multiplier applied to funnel.conversionRate. 1.0 = unchanged. Default: 1.0. */
913
+ conversionMultiplier?: number;
914
+ /** Multiplier applied to funnel.timeToConvert. 1.0 = unchanged. Default: 1.0. */
915
+ ttcMultiplier?: number;
916
+ /** Distribution weight. Default: 1 (equal split across variants). */
917
+ weight?: number;
918
+ }
919
+
920
+ /** Experiment context exposed in funnel-pre and funnel-post hook meta. */
921
+ export interface HookMetaExperiment {
922
+ /** Experiment name. */
923
+ name: string;
924
+ /** Name of the assigned variant. */
925
+ variantName: string;
926
+ /** 0-based index of the assigned variant. */
927
+ variantIndex: number;
928
+ /** Conversion multiplier applied for this variant. */
929
+ conversionMultiplier: number;
930
+ /** TTC multiplier applied for this variant. */
931
+ ttcMultiplier: number;
932
+ /** Unix seconds of experiment start, or null if active for entire dataset. */
933
+ startDate: number | null;
606
934
  }
607
935
 
608
936
  /**
@@ -822,196 +1150,12 @@ export interface DataQuality {
822
1150
  emptyEvents?: number;
823
1151
  }
824
1152
 
825
- /**
826
- * Subscription plan definition.
827
- */
828
- export interface SubscriptionPlan {
829
- /** Plan name (e.g., "free", "starter", "pro"). */
830
- name: string;
831
- /** Monthly price. 0 for free tier. */
832
- price: number;
833
- /** If true, users start on this plan. */
834
- default?: boolean;
835
- /** Trial period in days before requiring payment. */
836
- trialDays?: number;
837
- }
838
-
839
- /**
840
- * Subscription lifecycle rates.
841
- */
842
- export interface SubscriptionLifecycle {
843
- /** Rate of trial-to-paid conversion (0-1). */
844
- trialToPayRate?: number;
845
- /** Monthly upgrade rate (0-1). */
846
- upgradeRate?: number;
847
- /** Monthly downgrade rate (0-1). */
848
- downgradeRate?: number;
849
- /** Monthly churn/cancellation rate (0-1). */
850
- churnRate?: number;
851
- /** Rate of churned users who come back (0-1). */
852
- winBackRate?: number;
853
- /** Days before win-back attempt. */
854
- winBackDelay?: number;
855
- /** Rate of payment failures (0-1). */
856
- paymentFailureRate?: number;
857
- }
858
-
859
- /**
860
- * Subscription configuration.
861
- */
862
- export interface Subscription {
863
- /** Available plans, ordered from lowest to highest tier. */
864
- plans: SubscriptionPlan[];
865
- /** Lifecycle transition rates. */
866
- lifecycle?: SubscriptionLifecycle;
867
- /** Event names for subscription lifecycle events. */
868
- events?: {
869
- trialStarted?: string;
870
- subscribed?: string;
871
- upgraded?: string;
872
- downgraded?: string;
873
- renewed?: string;
874
- cancelled?: string;
875
- paymentFailed?: string;
876
- wonBack?: string;
877
- };
878
- }
879
-
880
- /**
881
- * Attribution campaign definition.
882
- */
883
- export interface AttributionCampaign {
884
- /** Campaign name. */
885
- name: string;
886
- /** UTM source (e.g., "google", "facebook"). */
887
- source: string;
888
- /** UTM medium (e.g., "search_ad", "social"). */
889
- medium?: string;
890
- /** UTM content (e.g., "variant_a", "hero_image"). */
891
- utm_content?: string;
892
- /** UTM term (e.g., "running+shoes", "best+deals"). */
893
- utm_term?: string;
894
- /** Active days range [startDay, endDay] relative to dataset start. */
895
- activeDays: [number, number];
896
- /** Daily budget range [min, max]. */
897
- dailyBudget?: [number, number];
898
- /** Fraction of impressions that become users (0-1). */
899
- acquisitionRate?: number;
900
- /** Persona weight biases for users acquired by this campaign. */
901
- userPersonaBias?: Record<string, number>;
902
- }
903
-
904
- /**
905
- * Connected attribution configuration.
906
- */
907
- export interface Attribution {
908
- /** Attribution model type. */
909
- model?: "last_touch" | "first_touch" | "linear" | "time_decay";
910
- /** Attribution window in days. */
911
- window?: number;
912
- /** Campaign definitions. */
913
- campaigns: AttributionCampaign[];
914
- /** Fraction of users who arrive organically (no campaign) (0-1). */
915
- organicRate?: number;
916
- }
917
-
918
- /**
919
- * Geographic region definition.
920
- */
921
- export interface GeoRegion {
922
- /** Region name (e.g., "north_america"). */
923
- name: string;
924
- /** Country codes in this region. */
925
- countries: string[];
926
- /** Weight for user assignment (higher = more users). */
927
- weight: number;
928
- /** UTC timezone offset for this region (e.g., -5 for EST). */
929
- timezoneOffset: number;
930
- /** Properties injected for users in this region. */
931
- properties?: Record<string, ValueValid>;
932
- }
933
-
934
- /**
935
- * Regional feature launch definition.
936
- */
937
- export interface RegionalLaunch {
938
- /** Region name to match. */
939
- region: string;
940
- /** Feature name. */
941
- featureName: string;
942
- /** Day the feature launches in this region. */
943
- startDay: number;
944
- }
945
-
946
- /**
947
- * Geographic intelligence configuration.
948
- */
949
- export interface GeoConfig {
950
- /** If true, users keep their location across all events (default: false for backwards compat). */
951
- sticky?: boolean;
952
- /** Region definitions with timezone offsets and properties. */
953
- regions?: GeoRegion[];
954
- /** Regional feature launches. */
955
- regionalLaunches?: RegionalLaunch[];
956
- }
957
-
958
- /**
959
- * Progressive feature adoption configuration.
960
- */
961
- export interface FeatureConfig {
962
- /** Feature name (e.g., "dark_mode", "ai_recommendations"). */
963
- name: string;
964
- /** Day the feature launches (relative to dataset start). */
965
- launchDay: number;
966
- /** Adoption curve speed or custom logistic params. */
967
- adoptionCurve?: "fast" | "slow" | "instant" | { k: number; midpoint: number };
968
- /** Property name to inject on events. */
969
- property: string;
970
- /** Possible values for the property. First value is the "before" default if defaultBefore not set. */
971
- values: ValueValid[];
972
- /** Default value before the feature launches. If not set, property doesn't exist before launch. */
973
- defaultBefore?: ValueValid;
974
- /** Which events are affected ("*" for all, or array of event names). */
975
- affectsEvents?: string[] | "*";
976
- /** Conversion rate lift for users who adopted the feature. */
977
- conversionLift?: number;
978
- /** Resolved logistic curve params (set by config-validator). */
979
- _resolvedCurve?: { k: number; midpoint: number };
980
- /** Pre-computed adopted values (set by config-validator). */
981
- _adoptedValues?: ValueValid[];
982
- }
983
-
984
- /**
985
- * Anomaly/outlier configuration.
986
- */
987
- export interface AnomalyConfig {
988
- /** Type of anomaly. */
989
- type: "extreme_value" | "burst" | "coordinated";
990
- /** Event name this anomaly applies to. */
991
- event: string;
992
- /** For extreme_value: property to modify. */
993
- property?: string;
994
- /** For extreme_value: fraction of events affected (0-1). */
995
- frequency?: number;
996
- /** For extreme_value: multiplier applied to the property value. */
997
- multiplier?: number;
998
- /** Tag property added to anomalous events. */
999
- tag?: string;
1000
- /** For burst/coordinated: day when the anomaly occurs. */
1001
- day?: number;
1002
- /** For burst: duration in days (0.083 = ~2 hours). */
1003
- duration?: number;
1004
- /** For burst/coordinated: time window in days (0.01 = ~15 minutes). */
1005
- window?: number;
1006
- /** For burst/coordinated: number of events to inject. */
1007
- count?: number;
1008
- /** Properties injected on anomalous events. */
1009
- properties?: Record<string, ValueValid>;
1010
- /** Resolved absolute start time in unix seconds (set by config-validator). */
1011
- _startUnix?: number;
1012
- /** Resolved absolute end time in unix seconds (set by config-validator). */
1013
- _endUnix?: number;
1014
- }
1153
+ // ── Removed types in 1.4 ──
1154
+ // Subscription, SubscriptionPlan, SubscriptionLifecycle, Attribution, AttributionCampaign,
1155
+ // GeoConfig, GeoRegion, RegionalLaunch, FeatureConfig, AnomalyConfig were removed from
1156
+ // the engine in 1.4. Recreate these patterns via hooks (see lib/hook-patterns/* and the
1157
+ // `write-hooks` skill once Phase 4/5 land). The killed config keys are silently stripped
1158
+ // by `validateDungeonConfig` with a single deprecation warning per dungeon.
1015
1159
 
1016
1160
  /**
1017
1161
  * dungeon-master: generate realistic Mixpanel data at scale
@@ -1357,3 +1501,68 @@ export interface TestContext {
1357
1501
  runtime: RuntimeState;
1358
1502
  [key: string]: unknown;
1359
1503
  }
1504
+
1505
+ // ── Subpath module declarations ──
1506
+
1507
+ declare module '@ak--47/dungeon-master/hook-helpers' {
1508
+ export function binUsersByEventCount(events: EventSchema[], eventName: string, bins: Record<string, [number, number]>): string;
1509
+ export function binUsersByEventInRange(events: EventSchema[], eventName: string, startTime: number | string, endTime: number | string, bins: Record<string, [number, number]>): string;
1510
+ export function countEventsBetween(events: EventSchema[], eventA: string, eventB: string): number;
1511
+ export function userInProfileSegment(profile: Record<string, unknown>, segmentKey: string, segmentValues: unknown[]): boolean;
1512
+ export function cloneEvent(template: EventSchema, overrides?: Partial<EventSchema>): EventSchema;
1513
+ export function dropEventsWhere(events: EventSchema[], predicate: (event: EventSchema) => boolean): number;
1514
+ export function scaleEventCount(events: EventSchema[], eventName: string, factor: number): void;
1515
+ export function scalePropertyValue(events: EventSchema[], predicate: (event: EventSchema) => boolean, propertyName: string, factor: number): void;
1516
+ export function shiftEventTime(event: EventSchema, deltaMs: number): EventSchema;
1517
+ export function scaleTimingBetween(events: EventSchema[], eventA: string, eventB: string, factor: number): void;
1518
+ export function scaleFunnelTTC(funnelEvents: EventSchema[], factor: number): void;
1519
+ export function findFirstSequence(events: EventSchema[], eventNames: string[], maxGapMin?: number): EventSchema[] | null;
1520
+ export function injectAfterEvent(events: EventSchema[], sourceEvent: EventSchema, templateEvent: EventSchema, gapMs: number, overrides?: Partial<EventSchema>): void;
1521
+ export function injectBetween(events: EventSchema[], eventA: EventSchema, eventB: EventSchema, templateEvent: EventSchema, overrides?: Partial<EventSchema>): void;
1522
+ export function injectBurst(events: EventSchema[], templateEvent: EventSchema, count: number, anchorTime: number | string, spreadMs: number): void;
1523
+ export function isPreAuthEvent(event: EventSchema, authTime: number | null): boolean;
1524
+ export function splitByAuth(events: EventSchema[], authTime: number | null): { preAuth: EventSchema[]; postAuth: EventSchema[]; stitch: EventSchema | null };
1525
+ }
1526
+
1527
+ declare module '@ak--47/dungeon-master/hook-patterns' {
1528
+ export function applyFrequencyByFrequency(events: EventSchema[], profile: Record<string, unknown> | null, opts: { cohortEvent: string; bins: Record<string, [number, number]>; targetEvent: string; multipliers: Record<string, number> }): void;
1529
+ export function applyFunnelFrequencyBreakdown(allUserEvents: EventSchema[], profile: Record<string, unknown> | null, funnelEvents: EventSchema[], opts: { cohortEvent: string; bins: Record<string, [number, number]>; dropMultipliers: Record<string, number> }): void;
1530
+ export function applyAggregateByBin(events: EventSchema[], profile: Record<string, unknown> | null, opts: { cohortEvent: string; bins: Record<string, [number, number]>; event: string; propertyName: string; deltas: Record<string, number> }): void;
1531
+ export function applyTTCBySegment(funnelEvents: EventSchema[], profile: Record<string, unknown>, opts: { segmentKey: string; factors: Record<string, number> }): void;
1532
+ export function applyAttributedBySource(events: EventSchema[], profile: Record<string, unknown> | null, opts: { sourceEvent: string; sourceProperty: string; downstreamEvent: string; weights: Record<string, number>; model?: 'firstTouch' | 'lastTouch' }): void;
1533
+ }
1534
+
1535
+ /**
1536
+ * Options for `emulateBreakdown`. Each `type` uses a different subset of fields.
1537
+ *
1538
+ * | type | Required fields | Optional |
1539
+ * |---|---|---|
1540
+ * | `frequencyByFrequency` | `metricEvent`, `breakdownByFrequencyOf` | `perUser` |
1541
+ * | `funnelFrequency` | `steps`, `breakdownByFrequencyOf` | — |
1542
+ * | `aggregatePerUser` | `event`, `property`, `breakdownByFrequencyOf` | `agg` (default: `'avg'`) |
1543
+ * | `timeToConvert` | `fromEvent`, `toEvent`, `breakdownByUserProperty`, `profiles` | — |
1544
+ * | `attributedBy` | `conversionEvent`, `attributionEvent`, `attributionProperty` | `model` (default: `'lastTouch'`) |
1545
+ */
1546
+ export interface EmulateOptions {
1547
+ type: 'frequencyByFrequency' | 'funnelFrequency' | 'aggregatePerUser' | 'timeToConvert' | 'attributedBy';
1548
+ metricEvent?: string;
1549
+ breakdownByFrequencyOf?: string;
1550
+ perUser?: boolean;
1551
+ steps?: string[];
1552
+ event?: string;
1553
+ property?: string;
1554
+ agg?: 'avg' | 'sum' | 'count' | 'max' | 'min';
1555
+ fromEvent?: string;
1556
+ toEvent?: string;
1557
+ breakdownByUserProperty?: string;
1558
+ profiles?: UserProfile[];
1559
+ conversionEvent?: string;
1560
+ attributionEvent?: string;
1561
+ attributionProperty?: string;
1562
+ model?: 'firstTouch' | 'lastTouch';
1563
+ }
1564
+
1565
+ declare module '@ak--47/dungeon-master/verify' {
1566
+ export function emulateBreakdown(events: EventSchema[], config: EmulateOptions): Array<Record<string, unknown>>;
1567
+ export function verifyDungeon(config: Dungeon, checks: Array<{ name: string; breakdown: EmulateOptions; assert: (rows: Array<Record<string, unknown>>, ctx: { events: EventSchema[]; profiles: UserProfile[] }) => { pass: boolean; detail?: string } }>): Promise<{ pass: boolean; results: Array<{ name: string; pass: boolean; detail?: string; rows?: Array<Record<string, unknown>> }> }>;
1568
+ }