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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) 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/index.js +17 -71
  30. package/lib/core/config-validator.js +143 -164
  31. package/lib/core/storage.js +5 -1
  32. package/lib/generators/events.js +49 -93
  33. package/lib/generators/funnels.js +202 -91
  34. package/lib/hook-helpers/_internal.js +23 -0
  35. package/lib/hook-helpers/cohort.js +124 -0
  36. package/lib/hook-helpers/identity.js +56 -0
  37. package/lib/hook-helpers/index.js +44 -0
  38. package/lib/hook-helpers/inject.js +99 -0
  39. package/lib/hook-helpers/mutate.js +151 -0
  40. package/lib/hook-helpers/timing.js +99 -0
  41. package/lib/hook-patterns/aggregate-per-user-by-bin.js +38 -0
  42. package/lib/hook-patterns/attributed-by-source.js +72 -0
  43. package/lib/hook-patterns/frequency-by-frequency.js +46 -0
  44. package/lib/hook-patterns/funnel-frequency-breakdown.js +73 -0
  45. package/lib/hook-patterns/index.js +14 -0
  46. package/lib/hook-patterns/time-to-convert-by-segment.js +41 -0
  47. package/lib/orchestrators/mixpanel-sender.js +46 -51
  48. package/lib/orchestrators/user-loop.js +119 -269
  49. package/lib/utils/utils.js +39 -16
  50. package/lib/verify/emulate-breakdown.js +281 -0
  51. package/lib/verify/index.js +12 -0
  52. package/lib/verify/verify-dungeon.js +61 -0
  53. package/package.json +6 -4
  54. package/types.d.ts +404 -212
package/types.d.ts CHANGED
@@ -17,11 +17,27 @@ 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;
22
+ /** Optional app or dataset name. Not used by the engine — available for logging and metadata when set. */
23
+ appName?: string;
20
24
  /** Mixpanel project token. If provided, data will be imported to Mixpanel after generation. */
21
25
  token?: string;
22
26
  /** RNG seed for reproducible output. Same seed + concurrency=1 = identical data. */
23
27
  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). */
28
+ /**
29
+ * Number of days the dataset spans. Default: 30.
30
+ *
31
+ * Three resolution modes:
32
+ * 1. **`numDays` alone (no datasetStart/End):** Window = `[today - numDays, today]`.
33
+ * Simplest API for ad-hoc dungeons. NOT deterministic across runs (today changes).
34
+ * 2. **`datasetStart` + `datasetEnd` (no numDays):** Window pinned exactly. `numDays`
35
+ * derived automatically. Fully deterministic — use for vertical/production dungeons.
36
+ * 3. **All three set:** `datasetStart`/`datasetEnd` win. `numDays` is recomputed from
37
+ * the window; user-supplied value is ignored (with a warning).
38
+ *
39
+ * Setting only one of `datasetStart`/`datasetEnd` throws.
40
+ */
25
41
  numDays?: number;
26
42
  /**
27
43
  * Explicit start of the dataset window. Pin BOTH `datasetStart` and `datasetEnd` for
@@ -69,7 +85,21 @@ export interface Dungeon {
69
85
  hasAvatar?: boolean;
70
86
  /** If true, events include geo properties (city, region, country, lat/lng). */
71
87
  hasLocation?: boolean;
72
- /** If true, events include UTM campaign properties. */
88
+ /**
89
+ * If true, events include UTM campaign properties (utm_source / utm_campaign / utm_medium /
90
+ * utm_content / utm_term).
91
+ *
92
+ * Default: false.
93
+ *
94
+ * Behavior:
95
+ * - false: no UTM stamping anywhere.
96
+ * - true + at least one event in `events[]` has `isAttributionEvent: true`: only the flagged
97
+ * events are eligible. Within those, ~25% are stamped with a randomly-picked campaign.
98
+ * - true + no event flagged: backwards-compat fallback — ~25% of ALL events are stamped
99
+ * with a randomly-picked campaign (legacy behavior).
100
+ *
101
+ * @see EventConfig.isAttributionEvent
102
+ */
73
103
  hasCampaigns?: boolean;
74
104
  /** If true, generates ad spend data (impressions, clicks, cost). */
75
105
  hasAdSpend?: boolean;
@@ -81,14 +111,55 @@ export interface Dungeon {
81
111
  hasDesktopDevices?: boolean;
82
112
  /** If true, events include browser properties. */
83
113
  hasBrowser?: boolean;
84
- /** If true (default), writes output files to ./data/. Can also be a directory path string. */
114
+ /** If true (default), writes output files to ./data/. Can also be a directory path string or gs:// URI. */
85
115
  writeToDisk?: boolean | string;
116
+ /** If true, deletes all written files (local and GCS) at end of run regardless of import success/failure. Default: false. */
117
+ cleanup?: boolean;
86
118
  /** If true, gzip-compresses output files. */
87
119
  gzip?: boolean;
88
120
  /** If true, prints progress to stdout during generation. */
89
121
  verbose?: boolean;
90
- /** If true, users get anonymous device IDs in addition to distinct_id. */
122
+ /**
123
+ * @deprecated Prefer `avgDevicePerUser`. `true` is now an alias for `avgDevicePerUser: 1`
124
+ * (single sticky device per user, every event stamped with that `device_id`). `false`
125
+ * (default) leaves the engine in legacy "no device_id stamping" mode unless
126
+ * `avgDevicePerUser` is set.
127
+ *
128
+ * @see Dungeon.avgDevicePerUser
129
+ */
91
130
  hasAnonIds?: boolean;
131
+ /**
132
+ * Number of distinct devices each user owns. Whole number ≥ 0. Default: 0 (legacy —
133
+ * no `device_id` stamping anywhere). `≤0` is coerced to `1` if `hasAnonIds: true` is
134
+ * also set; otherwise `0` keeps the engine in legacy mode for backwards compat.
135
+ *
136
+ * Behavior:
137
+ * - `0` (default): no `device_id` stamping. Every event gets `user_id` only. Same as
138
+ * pre-1.4 behavior when `hasAnonIds` is not set.
139
+ * - `1`: one device per user. All of that user's events that need a device share a
140
+ * single sticky `device_id`. `hasAnonIds: true` is an alias for this.
141
+ * - `>1`: per-user device pool sized via a normal distribution centered on this value
142
+ * (sd ≈ value/2, clamped ≥ 1, integer-rounded). Sessions are sticky to a single
143
+ * device drawn from the user's pool — every event in that session shares the same
144
+ * `device_id`. Cross-session events for the same user may differ.
145
+ *
146
+ * Identity stamping interactions (multi-device + auth + first funnel):
147
+ * - Pre-existing users (born before dataset window): every event gets both `user_id`
148
+ * and a per-session `device_id`.
149
+ * - Born-in-dataset users running their `isFirstFunnel`:
150
+ * * Pre-auth steps (steps before the first `isAuthEvent` in the funnel sequence):
151
+ * `device_id` only — no `user_id` yet.
152
+ * * The stitch step (the first `isAuthEvent`): both `user_id` AND `device_id`.
153
+ * * Post-auth steps in the same funnel: `user_id` only.
154
+ * * All later (non-firstFunnel) events: `user_id` + per-session sticky `device_id`.
155
+ * - Born-in-dataset users on a `Funnel.attempts` retry that does not reach `isAuthEvent`:
156
+ * every event in that failed attempt is `device_id` only (pre-auth, never stitched).
157
+ *
158
+ * @see Dungeon.hasAnonIds (deprecated alias when `true`)
159
+ * @see EventConfig.isAuthEvent
160
+ * @see Funnel.attempts
161
+ */
162
+ avgDevicePerUser?: number;
92
163
  /** If true, users get session IDs attached to events based on temporal clustering. */
93
164
  hasSessionIds?: boolean;
94
165
  /** Session timeout in minutes. Events with gaps exceeding this start a new session. Default: 30. Only used when hasSessionIds is true. */
@@ -139,16 +210,13 @@ export interface Dungeon {
139
210
  engagementDecay?: EngagementDecay;
140
211
  /** Data quality imperfections to inject (nulls, duplicates, bots, late-arriving events). */
141
212
  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[];
213
+
214
+ // ── Removed in 1.4 (silently ignored, one deprecation warning per dungeon) ──
215
+ // The following config keys were removed from the engine in 1.4. Existing dungeon
216
+ // files that still set them will load and run — `validateDungeonConfig` strips them
217
+ // with a single deprecation warning per dungeon. Recreate these patterns as hooks
218
+ // (see `lib/hook-patterns/*` once Phase 4 lands).
219
+ // subscription, attribution, geo, features, anomalies
152
220
 
153
221
  /** Allow arbitrary additional properties on the config. */
154
222
  [key: string]: any;
@@ -302,7 +370,19 @@ export interface HookMetaTimeAnchors {
302
370
  datasetEnd: number;
303
371
  }
304
372
 
305
- /** Meta passed to the "event" hook. */
373
+ /**
374
+ * Meta passed to the "event" hook.
375
+ *
376
+ * **Temporal-check warning:** `datasetStart`/`datasetEnd` are unix seconds in the
377
+ * shifted time frame, but `record.time` during the event hook is in the pre-shift
378
+ * fixed window. Comparing them directly (e.g., `dayjs(record.time).diff(dayjs.unix(meta.datasetStart))`)
379
+ * produces unreliable day-in-dataset values. Move any temporal check to the
380
+ * `everything` hook where both timestamps are in the same frame.
381
+ *
382
+ * Safe uses of the event hook: closure-based state (module-level Maps), event
383
+ * replacement (return a different object), simple property mutations not gated
384
+ * on time.
385
+ */
306
386
  export interface HookMetaEvent extends HookMetaTimeAnchors {
307
387
  /** The user this event belongs to (only `distinct_id` is guaranteed). */
308
388
  user: { distinct_id: string };
@@ -341,8 +421,29 @@ export interface HookMetaFunnelPre extends HookMetaTimeAnchors {
341
421
  scd: Record<string, SCDSchema[]>;
342
422
  funnel: Funnel;
343
423
  config: Dungeon;
344
- /** Unix seconds — earliest possible event time for this funnel's first step. */
424
+ /**
425
+ * Unix seconds — temporal anchor for this funnel run. For usage funnels, advances
426
+ * after each run so successive funnels spread across the user's active window.
427
+ * For first-funnel attempts, matches the attempt cursor. Use this to implement
428
+ * temporal conversion trends (e.g., "conversion increases after day 30").
429
+ */
345
430
  firstEventTime: number;
431
+ /** True if this funnel is the user's `isFirstFunnel`. */
432
+ isFirstFunnel: boolean;
433
+ /** True if the user's account creation falls inside the dataset window. */
434
+ isBorn: boolean;
435
+ /** Resolved attempts config for this funnel run, or null if attempts is not configured. */
436
+ attemptsConfig: AttemptsConfig | null;
437
+ /** 1-indexed attempt number for this run (1..totalAttempts). */
438
+ attemptNumber: number;
439
+ /** Total number of attempts (failed priors + 1 final). When attempts is omitted, this is 1. */
440
+ totalAttempts: number;
441
+ /** True if this is the final attempt (attemptNumber === totalAttempts). */
442
+ isFinalAttempt: boolean;
443
+ /** The user's assigned persona (if `personas` is configured), or null. */
444
+ persona: Persona | null;
445
+ /** Experiment context for this funnel run, or null if no experiment / pre-start-date. */
446
+ experiment: HookMetaExperiment | null;
346
447
  }
347
448
 
348
449
  /** Meta passed to the "funnel-post" hook (mutate generated funnel events in place). */
@@ -352,11 +453,39 @@ export interface HookMetaFunnelPost extends HookMetaTimeAnchors {
352
453
  scd: Record<string, SCDSchema[]>;
353
454
  funnel: Funnel;
354
455
  config: Dungeon;
456
+ /** Unix seconds — temporal anchor for this funnel run (see HookMetaFunnelPre.firstEventTime). */
457
+ firstEventTime: number;
458
+ /** True if this funnel is the user's `isFirstFunnel`. */
459
+ isFirstFunnel: boolean;
460
+ /** True if the user's account creation falls inside the dataset window. */
461
+ isBorn: boolean;
462
+ /** Resolved attempts config for this funnel run, or null if attempts is not configured. */
463
+ attemptsConfig: AttemptsConfig | null;
464
+ /** 1-indexed attempt number for this run (1..totalAttempts). */
465
+ attemptNumber: number;
466
+ /** Total number of attempts. */
467
+ totalAttempts: number;
468
+ /** True if this is the final attempt. */
469
+ isFinalAttempt: boolean;
470
+ /** The user's assigned persona (if `personas` is configured), or null. */
471
+ persona: Persona | null;
472
+ /** Experiment context for this funnel run, or null if no experiment / pre-start-date. */
473
+ experiment: HookMetaExperiment | null;
355
474
  }
356
475
 
357
- /** Meta passed to the "everything" hook — most powerful hook (sees all events for one user). */
476
+ /**
477
+ * Meta passed to the "everything" hook — most powerful hook (sees all events for one user).
478
+ *
479
+ * **Ordering within the hook matters.** When multiple effects coexist:
480
+ * 1. SuperProp stamping (profile values → events)
481
+ * 2. Non-temporal mutations and event cloning/injection
482
+ * 3. Event filtering (churn, retention, rate-limit drops)
483
+ * 4. Temporal value mutations (price spikes, error windows) — run LAST
484
+ * so cloned events that land in the window also get the mutation
485
+ * 5. Sort by time
486
+ */
358
487
  export interface HookMetaEverything extends HookMetaTimeAnchors {
359
- /** The user's profile, including merged persona/region/attribution properties. */
488
+ /** The user's profile, including any merged persona properties. */
360
489
  profile: UserProfile;
361
490
  /** All SCD entries for this user, keyed by prop name. */
362
491
  scd: Record<string, SCDSchema[]>;
@@ -364,6 +493,22 @@ export interface HookMetaEverything extends HookMetaTimeAnchors {
364
493
  config: Dungeon;
365
494
  /** True if the user's account creation falls inside the dataset window. */
366
495
  userIsBornInDataset: boolean;
496
+ /**
497
+ * Unix milliseconds of the stitch event (the first `isAuthEvent` in the user's stream).
498
+ * `null` if this user never authed (pre-existing users have no stitch event in the
499
+ * dataset window — they're already authed; born-in-dataset users who never converted
500
+ * remain pre-auth forever).
501
+ */
502
+ authTime: number | null;
503
+ /**
504
+ * Predicate bound to this user's `authTime`. Returns true if the event happened before
505
+ * the stitch (i.e. the user was anonymous at that point). Returns false for pre-existing
506
+ * users (they're considered authed throughout). For born-in-dataset users that never
507
+ * authed, returns true for every event.
508
+ */
509
+ isPreAuth: (event: EventSchema) => boolean;
510
+ /** The user's assigned persona (if `personas` is configured), or null. */
511
+ persona: Persona | null;
367
512
  }
368
513
 
369
514
  export interface hookArrayOptions<T> {
@@ -400,6 +545,8 @@ export interface HookedArray<T> extends Array<T> {
400
545
  getWriteDir: () => string;
401
546
  /** Absolute path (with extension) of the next batch file. */
402
547
  getWritePath: () => string;
548
+ /** Returns all file paths written by this container during the current run. */
549
+ getWrittenFiles: () => string[];
403
550
  /** SCD prop name this array carries (only set on SCD HookedArrays). */
404
551
  scdKey?: string;
405
552
  /** Entity type for SCDs ("user" or a group key). */
@@ -518,7 +665,7 @@ export interface Context {
518
665
  export interface EventConfig {
519
666
  /** The event name (e.g., "page viewed", "purchase completed"). */
520
667
  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 */
668
+ /** 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
669
  weight?: number;
523
670
  /** Properties to attach to this event type. Values can be arrays (random pick), functions, or primitives. */
524
671
  properties?: Record<string, ValueValid>;
@@ -532,8 +679,49 @@ export interface EventConfig {
532
679
  isSessionStartEvent?: boolean;
533
680
  /** Internal: timing offset in milliseconds (set by funnel system, not user-configured). */
534
681
  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. */
682
+ /** 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
683
  isStrictEvent?: boolean;
684
+ /**
685
+ * If true, this event marks the moment a user transitions from anonymous (pre-auth)
686
+ * to identified (post-auth) — typically the "Sign Up" or "Login" event. Multiple events
687
+ * in a dungeon may carry this flag; the engine looks at the first occurrence in a user's
688
+ * stream to determine the identity stitch moment.
689
+ *
690
+ * Default: false.
691
+ *
692
+ * Behavior, when in a funnel marked `isFirstFunnel: true`:
693
+ * - All steps before the first `isAuthEvent` step in the funnel sequence are stamped
694
+ * with `device_id` only (pre-auth).
695
+ * - The `isAuthEvent` step itself is the stitch — it carries BOTH `user_id` AND
696
+ * `device_id`. Exactly one such record per converted born-in-dataset user.
697
+ * - Steps after the stitch in that funnel get `user_id` only.
698
+ *
699
+ * Behavior outside `isFirstFunnel`: the flag has no extra effect — those events follow
700
+ * the usual identity rules for that user (per `avgDevicePerUser`).
701
+ *
702
+ * Behavior on born-in-dataset users whose `Funnel.attempts` retries fail to reach the
703
+ * `isAuthEvent`: every event in those failed attempts is `device_id` only (pre-auth,
704
+ * never stitched). If their final attempt also fails, they remain pre-auth forever.
705
+ *
706
+ * @see Dungeon.avgDevicePerUser
707
+ * @see Funnel.isFirstFunnel
708
+ * @see Funnel.attempts
709
+ */
710
+ isAuthEvent?: boolean;
711
+ /**
712
+ * If true, this event is eligible to carry UTM campaign properties when
713
+ * `Dungeon.hasCampaigns: true`. ~25% of flagged events get a randomly-picked campaign
714
+ * stamped (utm_source / utm_campaign / utm_medium / utm_content / utm_term).
715
+ *
716
+ * Default: false.
717
+ *
718
+ * Backwards compat: if `Dungeon.hasCampaigns: true` but no event carries this flag,
719
+ * ~25% of ALL events are stamped (legacy behavior, preserved). Opt-in by flagging at
720
+ * least one event.
721
+ *
722
+ * @see Dungeon.hasCampaigns
723
+ */
724
+ isAttributionEvent?: boolean;
537
725
  }
538
726
 
539
727
  export interface GroupEventConfig extends EventConfig {
@@ -618,14 +806,137 @@ export interface Funnel {
618
806
  */
619
807
  conditions?: Record<string, ValueValid>;
620
808
  /**
621
- * If true, the funnel will be part of an experiment where we generate 3 variants of the funnel with different conversion rates
809
+ * Experiment configuration for this funnel.
810
+ *
811
+ * - `true` — backward-compatible shorthand: 3 variants (Variant A = worse, Variant B = better, Control),
812
+ * active for the entire dataset.
813
+ * - `ExperimentConfig` object — custom variant names, conversion/TTC multipliers, temporal gating,
814
+ * and distribution weights.
815
+ *
816
+ * Variant assignment is **deterministic per user** (hash of user_id + experiment name), so the same
817
+ * user is in the same variant across all funnel runs. `$experiment_started` is prepended to the
818
+ * sequence for every post-start-date funnel run.
819
+ *
820
+ * Hook meta (`meta.experiment`) exposes the resolved variant in `funnel-pre` and `funnel-post`
821
+ * hooks, enabling variant-specific story injection.
622
822
  *
823
+ * @see ExperimentConfig
623
824
  */
624
- experiment?: boolean;
825
+ experiment?: boolean | ExperimentConfig;
625
826
  /**
626
827
  * optional: if set, in sequential funnels, this will determine WHEN the property is bound to the rest of the events in the funnel
627
828
  */
628
829
  bindPropsIndex?: number;
830
+ /**
831
+ * Multi-attempt iteration for this funnel. Models real users who land, abandon, come
832
+ * back, and try again. Additive — omit for legacy single-attempt behavior.
833
+ *
834
+ * @see AttemptsConfig
835
+ */
836
+ attempts?: AttemptsConfig;
837
+ /** @internal Resolved experiment config set by config-validator. */
838
+ _experiment?: { name: string; variants: Array<{ name: string; conversionMultiplier: number; ttcMultiplier: number; weight: number }>; startUnix: number | null };
839
+ /** @internal Set by funnels.js during experiment handling. */
840
+ _experimentName?: string;
841
+ /** @internal Set by funnels.js during experiment handling. */
842
+ _experimentVariant?: string;
843
+ }
844
+
845
+ /**
846
+ * Per-funnel multi-attempt config. `attempts.min`/`attempts.max` describe the count of
847
+ * **failed prior attempts** (NOT total attempts). The engine picks an integer
848
+ * `failedPriors = chance.integer({min, max})` then runs `failedPriors + 1` total
849
+ * passes through the funnel. The last pass is the "final attempt" — it converts per
850
+ * `attempts.conversionRate ?? funnel.conversionRate`. Each prior attempt is a truncated
851
+ * pre-auth pass that drops out at a random step before reaching any `isAuthEvent`.
852
+ *
853
+ * Identity interaction (when the funnel is `isFirstFunnel`):
854
+ * - Failed prior attempts: every event stamped with `device_id` only — never reach the
855
+ * stitch step, so `user_id` is never assigned.
856
+ * - Final attempt: follows the standard pre-auth → stitch → post-auth identity model.
857
+ * If the final attempt also fails, the user remains pre-auth forever.
858
+ *
859
+ * For non-`isFirstFunnel` funnels, each attempt is treated as an independent usage
860
+ * session (e.g. abandon-cart). Identity stamping uses the user's normal post-auth model.
861
+ *
862
+ * @example single attempt (default behavior)
863
+ * { attempts: { min: 0, max: 0 } } // exactly one pass — equivalent to omitting attempts
864
+ *
865
+ * @example up to 3 failed retries before a 60% final conversion
866
+ * { conversionRate: 60, attempts: { min: 0, max: 3 } }
867
+ *
868
+ * @example heavy churn before final attempt with overridden conversion rate
869
+ * { conversionRate: 80, attempts: { min: 1, max: 5, conversionRate: 30 } }
870
+ */
871
+ export interface AttemptsConfig {
872
+ /** Lower bound on the number of FAILED PRIOR attempts. 0 = a single attempt is possible. Whole number, ≥ 0. Default: 0. */
873
+ min?: number;
874
+ /** Upper bound on the number of FAILED PRIOR attempts (inclusive). Whole number, ≥ min. Default: 0. */
875
+ max?: number;
876
+ /**
877
+ * Conversion rate (0–100) applied to the FINAL attempt only — overrides
878
+ * `funnel.conversionRate` if set. Omit to inherit `funnel.conversionRate`.
879
+ * Matches the existing `Funnel.conversionRate` scale (0–100, NOT 0–1).
880
+ */
881
+ conversionRate?: number;
882
+ }
883
+
884
+ /**
885
+ * Experiment configuration for a funnel. Controls variant assignment, naming,
886
+ * conversion/TTC modifiers, and temporal gating.
887
+ *
888
+ * @example A/B test starting 30 days before dataset end
889
+ * {
890
+ * name: "Checkout Redesign",
891
+ * startDaysBeforeEnd: 30,
892
+ * variants: [
893
+ * { name: "Control" },
894
+ * { name: "New Checkout", conversionMultiplier: 1.25, ttcMultiplier: 0.8 },
895
+ * ]
896
+ * }
897
+ */
898
+ export interface ExperimentConfig {
899
+ /** Human-readable experiment name. Default: `funnel.name + " Experiment"`. */
900
+ name?: string;
901
+ /**
902
+ * Variant definitions. Each variant gets a deterministic share of users.
903
+ * Default (when omitted): 3 variants — Variant A (worse), Variant B (better), Control.
904
+ */
905
+ variants?: ExperimentVariant[];
906
+ /**
907
+ * Days before dataset end that the experiment starts. Funnel runs before
908
+ * the start date skip experiment logic entirely (no variant, no $experiment_started).
909
+ * Default: 0 (entire dataset).
910
+ */
911
+ startDaysBeforeEnd?: number;
912
+ }
913
+
914
+ /** A single variant in an experiment. */
915
+ export interface ExperimentVariant {
916
+ /** Display name — appears in the "Variant name" property on $experiment_started. */
917
+ name: string;
918
+ /** Multiplier applied to funnel.conversionRate. 1.0 = unchanged. Default: 1.0. */
919
+ conversionMultiplier?: number;
920
+ /** Multiplier applied to funnel.timeToConvert. 1.0 = unchanged. Default: 1.0. */
921
+ ttcMultiplier?: number;
922
+ /** Distribution weight. Default: 1 (equal split across variants). */
923
+ weight?: number;
924
+ }
925
+
926
+ /** Experiment context exposed in funnel-pre and funnel-post hook meta. */
927
+ export interface HookMetaExperiment {
928
+ /** Experiment name. */
929
+ name: string;
930
+ /** Name of the assigned variant. */
931
+ variantName: string;
932
+ /** 0-based index of the assigned variant. */
933
+ variantIndex: number;
934
+ /** Conversion multiplier applied for this variant. */
935
+ conversionMultiplier: number;
936
+ /** TTC multiplier applied for this variant. */
937
+ ttcMultiplier: number;
938
+ /** Unix seconds of experiment start, or null if active for entire dataset. */
939
+ startDate: number | null;
629
940
  }
630
941
 
631
942
  /**
@@ -845,196 +1156,12 @@ export interface DataQuality {
845
1156
  emptyEvents?: number;
846
1157
  }
847
1158
 
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
- }
1159
+ // ── Removed types in 1.4 ──
1160
+ // Subscription, SubscriptionPlan, SubscriptionLifecycle, Attribution, AttributionCampaign,
1161
+ // GeoConfig, GeoRegion, RegionalLaunch, FeatureConfig, AnomalyConfig were removed from
1162
+ // the engine in 1.4. Recreate these patterns via hooks (see lib/hook-patterns/* and the
1163
+ // `write-hooks` skill once Phase 4/5 land). The killed config keys are silently stripped
1164
+ // by `validateDungeonConfig` with a single deprecation warning per dungeon.
1038
1165
 
1039
1166
  /**
1040
1167
  * dungeon-master: generate realistic Mixpanel data at scale
@@ -1380,3 +1507,68 @@ export interface TestContext {
1380
1507
  runtime: RuntimeState;
1381
1508
  [key: string]: unknown;
1382
1509
  }
1510
+
1511
+ // ── Subpath module declarations ──
1512
+
1513
+ declare module '@ak--47/dungeon-master/hook-helpers' {
1514
+ export function binUsersByEventCount(events: EventSchema[], eventName: string, bins: Record<string, [number, number]>): string;
1515
+ export function binUsersByEventInRange(events: EventSchema[], eventName: string, startTime: number | string, endTime: number | string, bins: Record<string, [number, number]>): string;
1516
+ export function countEventsBetween(events: EventSchema[], eventA: string, eventB: string): number;
1517
+ export function userInProfileSegment(profile: Record<string, unknown>, segmentKey: string, segmentValues: unknown[]): boolean;
1518
+ export function cloneEvent(template: EventSchema, overrides?: Partial<EventSchema>): EventSchema;
1519
+ export function dropEventsWhere(events: EventSchema[], predicate: (event: EventSchema) => boolean): number;
1520
+ export function scaleEventCount(events: EventSchema[], eventName: string, factor: number): void;
1521
+ export function scalePropertyValue(events: EventSchema[], predicate: (event: EventSchema) => boolean, propertyName: string, factor: number): void;
1522
+ export function shiftEventTime(event: EventSchema, deltaMs: number): EventSchema;
1523
+ export function scaleTimingBetween(events: EventSchema[], eventA: string, eventB: string, factor: number): void;
1524
+ export function scaleFunnelTTC(funnelEvents: EventSchema[], factor: number): void;
1525
+ export function findFirstSequence(events: EventSchema[], eventNames: string[], maxGapMin?: number): EventSchema[] | null;
1526
+ export function injectAfterEvent(events: EventSchema[], sourceEvent: EventSchema, templateEvent: EventSchema, gapMs: number, overrides?: Partial<EventSchema>): void;
1527
+ export function injectBetween(events: EventSchema[], eventA: EventSchema, eventB: EventSchema, templateEvent: EventSchema, overrides?: Partial<EventSchema>): void;
1528
+ export function injectBurst(events: EventSchema[], templateEvent: EventSchema, count: number, anchorTime: number | string, spreadMs: number): void;
1529
+ export function isPreAuthEvent(event: EventSchema, authTime: number | null): boolean;
1530
+ export function splitByAuth(events: EventSchema[], authTime: number | null): { preAuth: EventSchema[]; postAuth: EventSchema[]; stitch: EventSchema | null };
1531
+ }
1532
+
1533
+ declare module '@ak--47/dungeon-master/hook-patterns' {
1534
+ 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;
1535
+ 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;
1536
+ 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;
1537
+ export function applyTTCBySegment(funnelEvents: EventSchema[], profile: Record<string, unknown>, opts: { segmentKey: string; factors: Record<string, number> }): void;
1538
+ 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;
1539
+ }
1540
+
1541
+ /**
1542
+ * Options for `emulateBreakdown`. Each `type` uses a different subset of fields.
1543
+ *
1544
+ * | type | Required fields | Optional |
1545
+ * |---|---|---|
1546
+ * | `frequencyByFrequency` | `metricEvent`, `breakdownByFrequencyOf` | `perUser` |
1547
+ * | `funnelFrequency` | `steps`, `breakdownByFrequencyOf` | — |
1548
+ * | `aggregatePerUser` | `event`, `property`, `breakdownByFrequencyOf` | `agg` (default: `'avg'`) |
1549
+ * | `timeToConvert` | `fromEvent`, `toEvent`, `breakdownByUserProperty`, `profiles` | — |
1550
+ * | `attributedBy` | `conversionEvent`, `attributionEvent`, `attributionProperty` | `model` (default: `'lastTouch'`) |
1551
+ */
1552
+ export interface EmulateOptions {
1553
+ type: 'frequencyByFrequency' | 'funnelFrequency' | 'aggregatePerUser' | 'timeToConvert' | 'attributedBy';
1554
+ metricEvent?: string;
1555
+ breakdownByFrequencyOf?: string;
1556
+ perUser?: boolean;
1557
+ steps?: string[];
1558
+ event?: string;
1559
+ property?: string;
1560
+ agg?: 'avg' | 'sum' | 'count' | 'max' | 'min';
1561
+ fromEvent?: string;
1562
+ toEvent?: string;
1563
+ breakdownByUserProperty?: string;
1564
+ profiles?: UserProfile[];
1565
+ conversionEvent?: string;
1566
+ attributionEvent?: string;
1567
+ attributionProperty?: string;
1568
+ model?: 'firstTouch' | 'lastTouch';
1569
+ }
1570
+
1571
+ declare module '@ak--47/dungeon-master/verify' {
1572
+ export function emulateBreakdown(events: EventSchema[], config: EmulateOptions): Array<Record<string, unknown>>;
1573
+ 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>> }> }>;
1574
+ }