@ak--47/dungeon-master 1.3.1 → 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 (51) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dungeons/technical/hook-helpers-verify.js +89 -0
  3. package/dungeons/technical/identity-model-verify.js +47 -0
  4. package/dungeons/technical/pattern-aggregate-by-bin.js +41 -0
  5. package/dungeons/technical/pattern-attributed-by-source.js +42 -0
  6. package/dungeons/technical/pattern-frequency-by-frequency.js +40 -0
  7. package/dungeons/technical/pattern-funnel-frequency.js +54 -0
  8. package/dungeons/technical/pattern-ttc-by-segment.js +45 -0
  9. package/dungeons/vertical/ai-platform.js +45 -52
  10. package/dungeons/vertical/community.js +11 -8
  11. package/dungeons/vertical/crypto.js +25 -24
  12. package/dungeons/vertical/dating.js +56 -48
  13. package/dungeons/vertical/devtools.js +25 -18
  14. package/dungeons/vertical/ecommerce.js +42 -38
  15. package/dungeons/vertical/education.js +24 -9
  16. package/dungeons/vertical/fintech.js +13 -8
  17. package/dungeons/vertical/fitness.js +73 -122
  18. package/dungeons/vertical/food-delivery.js +18 -19
  19. package/dungeons/vertical/gaming.js +19 -20
  20. package/dungeons/vertical/healthcare.js +11 -8
  21. package/dungeons/vertical/insurance-application.js +6 -3
  22. package/dungeons/vertical/logistics.js +15 -9
  23. package/dungeons/vertical/marketplace.js +36 -27
  24. package/dungeons/vertical/media.js +27 -25
  25. package/dungeons/vertical/real-estate.js +18 -7
  26. package/dungeons/vertical/sass.js +84 -68
  27. package/dungeons/vertical/social.js +46 -47
  28. package/dungeons/vertical/travel.js +8 -5
  29. package/lib/core/config-validator.js +136 -157
  30. package/lib/generators/events.js +49 -93
  31. package/lib/generators/funnels.js +202 -91
  32. package/lib/hook-helpers/_internal.js +23 -0
  33. package/lib/hook-helpers/cohort.js +124 -0
  34. package/lib/hook-helpers/identity.js +56 -0
  35. package/lib/hook-helpers/index.js +44 -0
  36. package/lib/hook-helpers/inject.js +99 -0
  37. package/lib/hook-helpers/mutate.js +151 -0
  38. package/lib/hook-helpers/timing.js +99 -0
  39. package/lib/hook-patterns/aggregate-per-user-by-bin.js +38 -0
  40. package/lib/hook-patterns/attributed-by-source.js +72 -0
  41. package/lib/hook-patterns/frequency-by-frequency.js +46 -0
  42. package/lib/hook-patterns/funnel-frequency-breakdown.js +73 -0
  43. package/lib/hook-patterns/index.js +14 -0
  44. package/lib/hook-patterns/time-to-convert-by-segment.js +41 -0
  45. package/lib/orchestrators/user-loop.js +119 -269
  46. package/lib/utils/utils.js +29 -16
  47. package/lib/verify/emulate-breakdown.js +281 -0
  48. package/lib/verify/index.js +12 -0
  49. package/lib/verify/verify-dungeon.js +61 -0
  50. package/package.json +6 -4
  51. package/types.d.ts +397 -211
package/types.d.ts CHANGED
@@ -17,11 +17,25 @@ 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. Used as fallback when datasetStart/datasetEnd are NOT both set — window becomes (today_start - numDays, today_start). Default: 30. When datasetStart/datasetEnd ARE both set, numDays is recomputed from the window and any user-supplied value is ignored (with a warning). */
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
40
  /**
27
41
  * Explicit start of the dataset window. Pin BOTH `datasetStart` and `datasetEnd` for
@@ -69,7 +83,21 @@ export interface Dungeon {
69
83
  hasAvatar?: boolean;
70
84
  /** If true, events include geo properties (city, region, country, lat/lng). */
71
85
  hasLocation?: boolean;
72
- /** 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
+ */
73
101
  hasCampaigns?: boolean;
74
102
  /** If true, generates ad spend data (impressions, clicks, cost). */
75
103
  hasAdSpend?: boolean;
@@ -87,8 +115,47 @@ export interface Dungeon {
87
115
  gzip?: boolean;
88
116
  /** If true, prints progress to stdout during generation. */
89
117
  verbose?: boolean;
90
- /** 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
+ */
91
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;
92
159
  /** If true, users get session IDs attached to events based on temporal clustering. */
93
160
  hasSessionIds?: boolean;
94
161
  /** Session timeout in minutes. Events with gaps exceeding this start a new session. Default: 30. Only used when hasSessionIds is true. */
@@ -139,16 +206,13 @@ export interface Dungeon {
139
206
  engagementDecay?: EngagementDecay;
140
207
  /** Data quality imperfections to inject (nulls, duplicates, bots, late-arriving events). */
141
208
  dataQuality?: DataQuality;
142
- /** Subscription/revenue lifecycle configuration. */
143
- subscription?: Subscription;
144
- /** Connected attribution configuration linking campaigns to user acquisition. */
145
- attribution?: Attribution;
146
- /** Geographic intelligence: sticky locations, timezone-aware activity, regional launches. */
147
- geo?: GeoConfig;
148
- /** Progressive feature adoption: features that launch mid-dataset with S-curve adoption. */
149
- features?: FeatureConfig[];
150
- /** Anomaly/outlier injection: extreme values, bursts, coordinated spikes. */
151
- 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
152
216
 
153
217
  /** Allow arbitrary additional properties on the config. */
154
218
  [key: string]: any;
@@ -302,7 +366,19 @@ export interface HookMetaTimeAnchors {
302
366
  datasetEnd: number;
303
367
  }
304
368
 
305
- /** Meta passed to the "event" hook. */
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
+ */
306
382
  export interface HookMetaEvent extends HookMetaTimeAnchors {
307
383
  /** The user this event belongs to (only `distinct_id` is guaranteed). */
308
384
  user: { distinct_id: string };
@@ -341,8 +417,29 @@ export interface HookMetaFunnelPre extends HookMetaTimeAnchors {
341
417
  scd: Record<string, SCDSchema[]>;
342
418
  funnel: Funnel;
343
419
  config: Dungeon;
344
- /** 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
+ */
345
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;
346
443
  }
347
444
 
348
445
  /** Meta passed to the "funnel-post" hook (mutate generated funnel events in place). */
@@ -352,11 +449,39 @@ export interface HookMetaFunnelPost extends HookMetaTimeAnchors {
352
449
  scd: Record<string, SCDSchema[]>;
353
450
  funnel: Funnel;
354
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;
355
470
  }
356
471
 
357
- /** Meta passed to the "everything" hook — most powerful hook (sees all events for one user). */
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
+ */
358
483
  export interface HookMetaEverything extends HookMetaTimeAnchors {
359
- /** The user's profile, including merged persona/region/attribution properties. */
484
+ /** The user's profile, including any merged persona properties. */
360
485
  profile: UserProfile;
361
486
  /** All SCD entries for this user, keyed by prop name. */
362
487
  scd: Record<string, SCDSchema[]>;
@@ -364,6 +489,22 @@ export interface HookMetaEverything extends HookMetaTimeAnchors {
364
489
  config: Dungeon;
365
490
  /** True if the user's account creation falls inside the dataset window. */
366
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;
367
508
  }
368
509
 
369
510
  export interface hookArrayOptions<T> {
@@ -518,7 +659,7 @@ export interface Context {
518
659
  export interface EventConfig {
519
660
  /** The event name (e.g., "page viewed", "purchase completed"). */
520
661
  event?: string;
521
- /** 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 */
522
663
  weight?: number;
523
664
  /** Properties to attach to this event type. Values can be arrays (random pick), functions, or primitives. */
524
665
  properties?: Record<string, ValueValid>;
@@ -532,8 +673,49 @@ export interface EventConfig {
532
673
  isSessionStartEvent?: boolean;
533
674
  /** Internal: timing offset in milliseconds (set by funnel system, not user-configured). */
534
675
  relativeTimeMs?: number;
535
- /** 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. */
536
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;
537
719
  }
538
720
 
539
721
  export interface GroupEventConfig extends EventConfig {
@@ -618,14 +800,137 @@ export interface Funnel {
618
800
  */
619
801
  conditions?: Record<string, ValueValid>;
620
802
  /**
621
- * 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.
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.
622
816
  *
817
+ * @see ExperimentConfig
623
818
  */
624
- experiment?: boolean;
819
+ experiment?: boolean | ExperimentConfig;
625
820
  /**
626
821
  * optional: if set, in sequential funnels, this will determine WHEN the property is bound to the rest of the events in the funnel
627
822
  */
628
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;
629
934
  }
630
935
 
631
936
  /**
@@ -845,196 +1150,12 @@ export interface DataQuality {
845
1150
  emptyEvents?: number;
846
1151
  }
847
1152
 
848
- /**
849
- * Subscription plan definition.
850
- */
851
- export interface SubscriptionPlan {
852
- /** Plan name (e.g., "free", "starter", "pro"). */
853
- name: string;
854
- /** Monthly price. 0 for free tier. */
855
- price: number;
856
- /** If true, users start on this plan. */
857
- default?: boolean;
858
- /** Trial period in days before requiring payment. */
859
- trialDays?: number;
860
- }
861
-
862
- /**
863
- * Subscription lifecycle rates.
864
- */
865
- export interface SubscriptionLifecycle {
866
- /** Rate of trial-to-paid conversion (0-1). */
867
- trialToPayRate?: number;
868
- /** Monthly upgrade rate (0-1). */
869
- upgradeRate?: number;
870
- /** Monthly downgrade rate (0-1). */
871
- downgradeRate?: number;
872
- /** Monthly churn/cancellation rate (0-1). */
873
- churnRate?: number;
874
- /** Rate of churned users who come back (0-1). */
875
- winBackRate?: number;
876
- /** Days before win-back attempt. */
877
- winBackDelay?: number;
878
- /** Rate of payment failures (0-1). */
879
- paymentFailureRate?: number;
880
- }
881
-
882
- /**
883
- * Subscription configuration.
884
- */
885
- export interface Subscription {
886
- /** Available plans, ordered from lowest to highest tier. */
887
- plans: SubscriptionPlan[];
888
- /** Lifecycle transition rates. */
889
- lifecycle?: SubscriptionLifecycle;
890
- /** Event names for subscription lifecycle events. */
891
- events?: {
892
- trialStarted?: string;
893
- subscribed?: string;
894
- upgraded?: string;
895
- downgraded?: string;
896
- renewed?: string;
897
- cancelled?: string;
898
- paymentFailed?: string;
899
- wonBack?: string;
900
- };
901
- }
902
-
903
- /**
904
- * Attribution campaign definition.
905
- */
906
- export interface AttributionCampaign {
907
- /** Campaign name. */
908
- name: string;
909
- /** UTM source (e.g., "google", "facebook"). */
910
- source: string;
911
- /** UTM medium (e.g., "search_ad", "social"). */
912
- medium?: string;
913
- /** UTM content (e.g., "variant_a", "hero_image"). */
914
- utm_content?: string;
915
- /** UTM term (e.g., "running+shoes", "best+deals"). */
916
- utm_term?: string;
917
- /** Active days range [startDay, endDay] relative to dataset start. */
918
- activeDays: [number, number];
919
- /** Daily budget range [min, max]. */
920
- dailyBudget?: [number, number];
921
- /** Fraction of impressions that become users (0-1). */
922
- acquisitionRate?: number;
923
- /** Persona weight biases for users acquired by this campaign. */
924
- userPersonaBias?: Record<string, number>;
925
- }
926
-
927
- /**
928
- * Connected attribution configuration.
929
- */
930
- export interface Attribution {
931
- /** Attribution model type. */
932
- model?: "last_touch" | "first_touch" | "linear" | "time_decay";
933
- /** Attribution window in days. */
934
- window?: number;
935
- /** Campaign definitions. */
936
- campaigns: AttributionCampaign[];
937
- /** Fraction of users who arrive organically (no campaign) (0-1). */
938
- organicRate?: number;
939
- }
940
-
941
- /**
942
- * Geographic region definition.
943
- */
944
- export interface GeoRegion {
945
- /** Region name (e.g., "north_america"). */
946
- name: string;
947
- /** Country codes in this region. */
948
- countries: string[];
949
- /** Weight for user assignment (higher = more users). */
950
- weight: number;
951
- /** UTC timezone offset for this region (e.g., -5 for EST). */
952
- timezoneOffset: number;
953
- /** Properties injected for users in this region. */
954
- properties?: Record<string, ValueValid>;
955
- }
956
-
957
- /**
958
- * Regional feature launch definition.
959
- */
960
- export interface RegionalLaunch {
961
- /** Region name to match. */
962
- region: string;
963
- /** Feature name. */
964
- featureName: string;
965
- /** Day the feature launches in this region. */
966
- startDay: number;
967
- }
968
-
969
- /**
970
- * Geographic intelligence configuration.
971
- */
972
- export interface GeoConfig {
973
- /** If true, users keep their location across all events (default: false for backwards compat). */
974
- sticky?: boolean;
975
- /** Region definitions with timezone offsets and properties. */
976
- regions?: GeoRegion[];
977
- /** Regional feature launches. */
978
- regionalLaunches?: RegionalLaunch[];
979
- }
980
-
981
- /**
982
- * Progressive feature adoption configuration.
983
- */
984
- export interface FeatureConfig {
985
- /** Feature name (e.g., "dark_mode", "ai_recommendations"). */
986
- name: string;
987
- /** Day the feature launches (relative to dataset start). */
988
- launchDay: number;
989
- /** Adoption curve speed or custom logistic params. */
990
- adoptionCurve?: "fast" | "slow" | "instant" | { k: number; midpoint: number };
991
- /** Property name to inject on events. */
992
- property: string;
993
- /** Possible values for the property. First value is the "before" default if defaultBefore not set. */
994
- values: ValueValid[];
995
- /** Default value before the feature launches. If not set, property doesn't exist before launch. */
996
- defaultBefore?: ValueValid;
997
- /** Which events are affected ("*" for all, or array of event names). */
998
- affectsEvents?: string[] | "*";
999
- /** Conversion rate lift for users who adopted the feature. */
1000
- conversionLift?: number;
1001
- /** Resolved logistic curve params (set by config-validator). */
1002
- _resolvedCurve?: { k: number; midpoint: number };
1003
- /** Pre-computed adopted values (set by config-validator). */
1004
- _adoptedValues?: ValueValid[];
1005
- }
1006
-
1007
- /**
1008
- * Anomaly/outlier configuration.
1009
- */
1010
- export interface AnomalyConfig {
1011
- /** Type of anomaly. */
1012
- type: "extreme_value" | "burst" | "coordinated";
1013
- /** Event name this anomaly applies to. */
1014
- event: string;
1015
- /** For extreme_value: property to modify. */
1016
- property?: string;
1017
- /** For extreme_value: fraction of events affected (0-1). */
1018
- frequency?: number;
1019
- /** For extreme_value: multiplier applied to the property value. */
1020
- multiplier?: number;
1021
- /** Tag property added to anomalous events. */
1022
- tag?: string;
1023
- /** For burst/coordinated: day when the anomaly occurs. */
1024
- day?: number;
1025
- /** For burst: duration in days (0.083 = ~2 hours). */
1026
- duration?: number;
1027
- /** For burst/coordinated: time window in days (0.01 = ~15 minutes). */
1028
- window?: number;
1029
- /** For burst/coordinated: number of events to inject. */
1030
- count?: number;
1031
- /** Properties injected on anomalous events. */
1032
- properties?: Record<string, ValueValid>;
1033
- /** Resolved absolute start time in unix seconds (set by config-validator). */
1034
- _startUnix?: number;
1035
- /** Resolved absolute end time in unix seconds (set by config-validator). */
1036
- _endUnix?: number;
1037
- }
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.
1038
1159
 
1039
1160
  /**
1040
1161
  * dungeon-master: generate realistic Mixpanel data at scale
@@ -1380,3 +1501,68 @@ export interface TestContext {
1380
1501
  runtime: RuntimeState;
1381
1502
  [key: string]: unknown;
1382
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
+ }