@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/CHANGELOG.md CHANGED
@@ -2,6 +2,64 @@
2
2
 
3
3
  All notable changes to `@ak--47/dungeon-master`.
4
4
 
5
+ ## 1.4.0 — 2026-05-03
6
+
7
+ The "identity model + hook verification" release. Users get multi-device identity, declarative experiments, and a complete hook authoring pipeline with verification. All 20 vertical dungeons upgraded and verified.
8
+
9
+ ### Added
10
+
11
+ - **Identity model.** Three additive knobs for realistic user/device identity:
12
+ - `avgDevicePerUser: N` — per-user device pool sized by normal distribution; sessions are sticky to a device drawn from the pool.
13
+ - `EventConfig.isAuthEvent: true` — marks the sign-up/login event as the identity stitch point. Pre-auth funnel steps get `device_id` only; the stitch event gets both; post-auth gets `user_id` only.
14
+ - `Funnel.attempts: { min, max, conversionRate? }` — failed-prior-attempt retries before final conversion. Failed attempts truncate before `isAuthEvent`.
15
+ - `EventConfig.isAttributionEvent: true` — opt-in UTM stamping on specific events (replaces blanket 25% of all events).
16
+ - **Declarative experiments.** `Funnel.experiment: { name, variants, startDaysBeforeEnd }` on any funnel. Engine handles variant assignment (deterministic hash), `$experiment_started` events, and conversion/TTC multipliers. Hooks read `meta.experiment` for variant-specific downstream effects.
17
+ - **Hook helpers** (`@ak--47/dungeon-master/hook-helpers`). 14 composable atoms across 5 modules: cohort binning, event mutation, timing manipulation, event injection, and identity partitioning. Full JSDoc on each atom.
18
+ - **Hook patterns** (`@ak--47/dungeon-master/hook-patterns`). 5 high-level recipes mapping 1:1 to Mixpanel analysis types: frequency-by-frequency, funnel-frequency breakdown, aggregate-by-bin, TTC-by-segment, attributed-by-source.
19
+ - **Verification pipeline** (`@ak--47/dungeon-master/verify`). `emulateBreakdown` re-derives Mixpanel breakdown tables from raw events. `verifyDungeon` runs CI-style assertions with NAILED/STRONG/WEAK/NONE/INVERSE scoring.
20
+ - **`HOOKS.md` encyclopedia.** 23 production-proven hook recipes with code, Mixpanel report instructions, and adaptation notes. 14 core principles. Phase 3/4 atom and pattern reference tables.
21
+ - **`EmulateOptions` type.** Previously referenced but undefined in `types.d.ts`. Now fully typed with per-analysis-type field documentation.
22
+ - **Skills pipeline.** `/create-dungeon` (schema only) → `/write-hooks` (engineer patterns) → `/verify-hooks` (DuckDB verification with 5-tier scoring). Each skill is self-contained with reference examples.
23
+
24
+ ### Changed
25
+
26
+ - **Legacy 42% per-event `user_id` dice removed.** Every event now gets `user_id` by default (unless in a pre-auth funnel step). More correct for Mixpanel identity.
27
+ - **`isStrictEvent` events excluded from standalone generation.** They appear only in explicitly-defined funnels — cleaner data for events like "application approved" that shouldn't exist outside funnel context.
28
+ - **`insert_id` uses `crypto.randomUUID()`.** Eliminates hash collisions. Non-deterministic but irrelevant for analytics.
29
+ - **Funnel-pre hooks have final authority.** Persona and world-event modifiers apply before the hook — hooks can override everything.
30
+ - **Experiment variant assignment is deterministic per user** (hash-based, not random per funnel run).
31
+ - **All 20 vertical dungeons upgraded to version 2.** Identity model adopted, dates standardized (120-day window), temporal hooks migrated to `everything`, deprecated features replaced in hooks, all hooks verified STRONG or NAILED.
32
+ - **`types.d.ts` expanded.** JSDoc warnings on `HookMetaEvent` (temporal unreliability), hook ordering note on `HookMetaEverything`, `numDays` 3-mode resolution docs, `isStrictEvent` clarification.
33
+
34
+ ### Removed (silently ignored)
35
+
36
+ - `subscription`, `attribution`, `geo`, `features`, `anomalies` config keys. Engine strips them with one deprecation warning per dungeon and continues. Recreate these patterns via hooks — see `HOOKS.md` recipes 3.22 (deprecated feature replacement) and the `write-hooks` skill.
37
+
38
+ ### Key Learnings from 20-Dungeon Eval
39
+
40
+ These patterns are documented in `HOOKS.md` principles 7-14 and the skill REV 10 sections:
41
+
42
+ 1. **Temporal hooks belong in `everything`, not `event`.** The event hook's `meta.datasetStart`/`meta.datasetEnd` are in a different time frame than `record.time`. Move any day-in-dataset check to `everything`.
43
+ 2. **Temporal mutations run AFTER all cloning.** If Hook A clones events into a time window and Hook B mutates events in that window, B must run after A or clones miss the mutation.
44
+ 3. **Cohort detection must survive downstream filtering.** If a churn hook removes the marker events used to detect a cohort, require 3+ markers instead of 1+ so survivors still identify the group.
45
+ 4. **Deprecated feature replacement.** Add equivalent property assignments in `user`/`everything` hooks and `superProps`/`userProps`. See recipe 3.22.
46
+ 5. **Unseeded Chance breaks determinism.** Replace `new Chance()` with `initChance(SEED)`.
47
+
48
+ ### Documentation
49
+
50
+ - **`research/FINALIZE-REPORT.md`** — full eval report: 19 dungeons, 191 hooks, all STRONG/NAILED.
51
+ - **`research/1.4.0-upgrade-guide.md`** — migration checklist and hook-writing guide.
52
+ - **`HOOKS.md`** — 23 recipes, 14 principles, atom/pattern reference.
53
+ - **`.claude/skills/verify-hooks/SKILL.md`** — REV 10: clone dilution, cohort filtering, deprecated features, dynamic date window derivation.
54
+ - **`.claude/skills/write-hooks/SKILL.md`** — REV 10: hook ordering within `everything`, deprecated feature replacement, cohort sizing guidelines.
55
+
56
+ ### Backward Compatibility
57
+
58
+ - **No breaking changes to the public API.** `DUNGEON_MASTER(config)` signature unchanged. All named exports unchanged.
59
+ - Existing dungeons run without modification. New features are opt-in.
60
+ - Dungeons that relied on the legacy 42% `user_id` dice will see more consistent identity (every event gets `user_id`). This is more correct for Mixpanel.
61
+ - `numDays`-only configs continue to work (window anchors to today). Pin `datasetStart`+`datasetEnd` for deterministic runs.
62
+
5
63
  ## 1.3.0 — 2026-04-28
6
64
 
7
65
  The "no more end-of-dataset blowup" release. Rewrote how per-user event budgets and big-picture trends are decided so the default chart looks flat with a weekly cycle, instead of meteoric ramp + cliff in the final ~14 days.
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Phase 3 hook-helpers verification fixture.
3
+ *
4
+ * Exercises three atoms inside one `everything` hook:
5
+ * 1. `binUsersByEventCount` — classify users into "casual" / "engaged" / "power"
6
+ * based on their count of `Browse` events.
7
+ * 2. `scalePropertyValue` — engaged users get 2x `amount` on Purchase events;
8
+ * power users get 3x.
9
+ * 3. `injectBurst` — power users also get a burst of 5 cloned `Browse` events
10
+ * around the midpoint of their stream.
11
+ *
12
+ * Verified by DuckDB queries against the events output (see
13
+ * scripts/verify-runner.mjs + the Phase 3 verification gate notes in the plan).
14
+ */
15
+
16
+ import dayjs from 'dayjs';
17
+ import {
18
+ binUsersByEventCount,
19
+ scalePropertyValue,
20
+ injectBurst,
21
+ cloneEvent,
22
+ } from '../../lib/hook-helpers/index.js';
23
+
24
+ const FIXED_NOW = dayjs('2024-02-02').unix();
25
+
26
+ const BINS = {
27
+ casual: [0, 5],
28
+ engaged: [5, 15],
29
+ power: [15, Infinity],
30
+ };
31
+
32
+ const SCALE_BY_BIN = { casual: 1, engaged: 2, power: 3 };
33
+ const BURST_COUNT_BY_BIN = { casual: 0, engaged: 0, power: 5 };
34
+
35
+ export default {
36
+ name: 'hook-helpers-verify',
37
+ seed: 'phase3-helpers-verify',
38
+ datasetStart: FIXED_NOW - 30 * 86400,
39
+ datasetEnd: FIXED_NOW,
40
+ numUsers: 1_000,
41
+ avgEventsPerUserPerDay: 4,
42
+ percentUsersBornInDataset: 30,
43
+ hasAnonIds: false,
44
+ format: 'json',
45
+ concurrency: 1,
46
+ writeToDisk: true,
47
+ verbose: false,
48
+ events: [
49
+ { event: 'Browse', weight: 8, properties: { surface: ['home', 'category', 'search'] } },
50
+ { event: 'Purchase', weight: 2, properties: { amount: [10, 20, 30, 40, 50] } },
51
+ ],
52
+ hook: function (record, type, meta) {
53
+ if (type !== 'everything' || !Array.isArray(record)) return record;
54
+
55
+ // 1. Classify the user via cohort atom
56
+ const bin = binUsersByEventCount(record, 'Browse', BINS);
57
+ if (!bin) return record;
58
+
59
+ // Stamp the bin onto each Purchase so verification queries can group by it
60
+ // without re-deriving. Non-flag-stamping: this property is config-defined when
61
+ // dungeons add `cohort` to Purchase.properties; here we accept the small bend
62
+ // for the verification fixture so DuckDB queries are clean.
63
+ for (const ev of record) {
64
+ if (ev.event === 'Purchase') ev.cohort = bin;
65
+ }
66
+
67
+ // 2. Scale Purchase amounts via mutate atom (engaged 2x, power 3x)
68
+ const factor = SCALE_BY_BIN[bin] || 1;
69
+ if (factor !== 1) {
70
+ scalePropertyValue(record, e => e.event === 'Purchase', 'amount', factor);
71
+ }
72
+
73
+ // 3. Power users get a Browse burst clustered at the dataset midpoint
74
+ const burstCount = BURST_COUNT_BY_BIN[bin] || 0;
75
+ if (burstCount > 0 && record.length) {
76
+ const midUnixMs = (meta.datasetStart + (meta.datasetEnd - meta.datasetStart) / 2) * 1000;
77
+ // Use the user's first Browse as a clone template so we honor the schema.
78
+ const template = record.find(e => e.event === 'Browse');
79
+ if (template) {
80
+ const tpl = cloneEvent(template, { cohort: bin });
81
+ injectBurst(record, tpl, burstCount, midUnixMs, 60 * 60 * 1000); // ±1h spread
82
+ }
83
+ }
84
+
85
+ // Re-sort by time after burst injection so downstream pipeline sees ordered events.
86
+ record.sort((a, b) => (a.time < b.time ? -1 : a.time > b.time ? 1 : 0));
87
+ return record;
88
+ },
89
+ };
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Phase 2 identity model — verification fixture (intentionally simple).
3
+ *
4
+ * 1K born-in-dataset users with isAuthEvent on `Sign Up` (the stitch step) and a
5
+ * 3-step first funnel. Dataset window pinned for determinism. avgDevicePerUser:2
6
+ * exercises the multi-device sticky-per-session model. Used by phase 2 verification
7
+ * gate (DuckDB queries against the events output).
8
+ */
9
+
10
+ import dayjs from 'dayjs';
11
+
12
+ const FIXED_NOW = dayjs('2024-02-02').unix();
13
+
14
+ export default {
15
+ name: 'identity-model-verify',
16
+ seed: 'phase2-identity-verify',
17
+ datasetStart: FIXED_NOW - 30 * 86400,
18
+ datasetEnd: FIXED_NOW,
19
+ numUsers: 1_000,
20
+ avgEventsPerUserPerDay: 3,
21
+ percentUsersBornInDataset: 100,
22
+ hasAnonIds: true,
23
+ avgDevicePerUser: 2,
24
+ hasSessionIds: true,
25
+ format: 'json',
26
+ concurrency: 1,
27
+ writeToDisk: true,
28
+ verbose: false,
29
+ events: [
30
+ { event: 'Land', isFirstEvent: true, isStrictEvent: true },
31
+ { event: 'View Pricing', isStrictEvent: true },
32
+ { event: 'Sign Up', isAuthEvent: true, isStrictEvent: true },
33
+ { event: 'Onboarding', isStrictEvent: true },
34
+ { event: 'Browse', weight: 5 },
35
+ { event: 'Save Item', weight: 2 },
36
+ ],
37
+ funnels: [
38
+ {
39
+ name: 'Acquisition',
40
+ sequence: ['Land', 'View Pricing', 'Sign Up', 'Onboarding'],
41
+ conversionRate: 80,
42
+ isFirstFunnel: true,
43
+ timeToConvert: 2,
44
+ attempts: { min: 0, max: 2 },
45
+ },
46
+ ],
47
+ };
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Phase 4 reference dungeon — aggregate per user, by bin.
3
+ *
4
+ * Users in the high-Browse cohort have 4× higher Purchase amounts on average;
5
+ * mid cohort 2×; low cohort 1×. Verified via the aggregatePerUser emulator.
6
+ */
7
+
8
+ import dayjs from 'dayjs';
9
+ import { applyAggregateByBin } from '../../lib/hook-patterns/index.js';
10
+
11
+ const FIXED_NOW = dayjs('2024-02-02').unix();
12
+
13
+ export default {
14
+ name: 'pattern-agg-by-bin',
15
+ seed: 'phase4-aggbybin',
16
+ datasetStart: FIXED_NOW - 30 * 86400,
17
+ datasetEnd: FIXED_NOW,
18
+ numUsers: 1_000,
19
+ avgEventsPerUserPerDay: 6,
20
+ percentUsersBornInDataset: 30,
21
+ hasAnonIds: false,
22
+ format: 'json',
23
+ concurrency: 1,
24
+ writeToDisk: true,
25
+ verbose: false,
26
+ events: [
27
+ { event: 'Browse', weight: 6 },
28
+ { event: 'Purchase', weight: 2, properties: { amount: [10, 20, 30] } },
29
+ ],
30
+ hook: function (record, type) {
31
+ if (type !== 'everything' || !Array.isArray(record)) return record;
32
+ applyAggregateByBin(record, null, {
33
+ cohortEvent: 'Browse',
34
+ bins: { low: [0, 5], mid: [5, 15], high: [15, Infinity] },
35
+ event: 'Purchase',
36
+ propertyName: 'amount',
37
+ deltas: { low: 1, mid: 2, high: 4 },
38
+ });
39
+ return record;
40
+ },
41
+ };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Phase 4 reference dungeon — conversions attributed by source.
3
+ *
4
+ * Touch events carry one of three random sources; conversions are stamped with
5
+ * the user's first-touch source weighted google:facebook:twitter = 10:5:1.
6
+ * Verified via the attributedBy emulator.
7
+ */
8
+
9
+ import dayjs from 'dayjs';
10
+ import { applyAttributedBySource } from '../../lib/hook-patterns/index.js';
11
+
12
+ const FIXED_NOW = dayjs('2024-02-02').unix();
13
+
14
+ export default {
15
+ name: 'pattern-attributed-by-source',
16
+ seed: 'phase4-attrib',
17
+ datasetStart: FIXED_NOW - 30 * 86400,
18
+ datasetEnd: FIXED_NOW,
19
+ numUsers: 1_000,
20
+ avgEventsPerUserPerDay: 4,
21
+ percentUsersBornInDataset: 50,
22
+ hasAnonIds: false,
23
+ format: 'json',
24
+ concurrency: 1,
25
+ writeToDisk: true,
26
+ verbose: false,
27
+ events: [
28
+ { event: 'Touch', weight: 5, properties: { source: ['google', 'facebook', 'twitter'] } },
29
+ { event: 'Convert', weight: 2, properties: { source: ['unknown'] } },
30
+ ],
31
+ hook: function (record, type) {
32
+ if (type !== 'everything' || !Array.isArray(record)) return record;
33
+ applyAttributedBySource(record, null, {
34
+ sourceEvent: 'Touch',
35
+ sourceProperty: 'source',
36
+ downstreamEvent: 'Convert',
37
+ weights: { google: 10, facebook: 5, twitter: 1 },
38
+ model: 'firstTouch',
39
+ });
40
+ return record;
41
+ },
42
+ };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Phase 4 reference dungeon — frequency × frequency.
3
+ *
4
+ * Engages users into low / mid / high "Browse" cohorts; high-cohort users get 3×
5
+ * Purchase events. Verified by the Phase 4 patterns test + verify-runner.
6
+ */
7
+
8
+ import dayjs from 'dayjs';
9
+ import { applyFrequencyByFrequency } from '../../lib/hook-patterns/index.js';
10
+
11
+ const FIXED_NOW = dayjs('2024-02-02').unix();
12
+
13
+ export default {
14
+ name: 'pattern-freq-by-freq',
15
+ seed: 'phase4-freqxfreq',
16
+ datasetStart: FIXED_NOW - 30 * 86400,
17
+ datasetEnd: FIXED_NOW,
18
+ numUsers: 1_000,
19
+ avgEventsPerUserPerDay: 6,
20
+ percentUsersBornInDataset: 30,
21
+ hasAnonIds: false,
22
+ format: 'json',
23
+ concurrency: 1,
24
+ writeToDisk: true,
25
+ verbose: false,
26
+ events: [
27
+ { event: 'Browse', weight: 6 },
28
+ { event: 'Purchase', weight: 2, properties: { amount: [10, 20, 30, 40, 50] } },
29
+ ],
30
+ hook: function (record, type) {
31
+ if (type !== 'everything' || !Array.isArray(record)) return record;
32
+ applyFrequencyByFrequency(record, null, {
33
+ cohortEvent: 'Browse',
34
+ bins: { low: [0, 5], mid: [5, 15], high: [15, Infinity] },
35
+ targetEvent: 'Purchase',
36
+ multipliers: { low: 1, mid: 2, high: 3 },
37
+ });
38
+ return record;
39
+ },
40
+ };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Phase 4 reference dungeon — funnel frequency breakdown.
3
+ *
4
+ * Engaged-cohort users (high count of `Browse`) are 1.5× more likely to complete
5
+ * the activation funnel's last step than low-cohort users. Verified via the
6
+ * funnelFrequency emulator.
7
+ *
8
+ * Note: this pattern is funnel-post; it edits the funnel's own events. Combined
9
+ * with the per-user funnel attempts, the breakdown should show a stronger
10
+ * conversion-per-step lift in the high cohort.
11
+ */
12
+
13
+ import dayjs from 'dayjs';
14
+ import { applyFunnelFrequencyBreakdown } from '../../lib/hook-patterns/index.js';
15
+
16
+ const FIXED_NOW = dayjs('2024-02-02').unix();
17
+
18
+ export default {
19
+ name: 'pattern-funnel-freq',
20
+ seed: 'phase4-funnel-freq',
21
+ datasetStart: FIXED_NOW - 30 * 86400,
22
+ datasetEnd: FIXED_NOW,
23
+ numUsers: 1_000,
24
+ avgEventsPerUserPerDay: 4,
25
+ percentUsersBornInDataset: 100,
26
+ hasAnonIds: false,
27
+ format: 'json',
28
+ concurrency: 1,
29
+ writeToDisk: true,
30
+ verbose: false,
31
+ events: [
32
+ { event: 'Land', isFirstEvent: true, isStrictEvent: true },
33
+ { event: 'Sign Up', isAuthEvent: true, isStrictEvent: true },
34
+ { event: 'Activate', isStrictEvent: true },
35
+ { event: 'Browse', weight: 5 },
36
+ ],
37
+ funnels: [{
38
+ sequence: ['Land', 'Sign Up', 'Activate'],
39
+ conversionRate: 70, isFirstFunnel: true, timeToConvert: 4,
40
+ }],
41
+ hook: function (record, type, meta) {
42
+ if (type !== 'funnel-post' || !Array.isArray(record)) return;
43
+ if (!meta.isFirstFunnel) return;
44
+ // In funnel-post we don't have the user's full event stream — pattern accepts
45
+ // null for `allUserEvents` and falls back to counting cohortEvent inside the
46
+ // funnel-only events. Real dungeons usually wire this via `everything` instead.
47
+ applyFunnelFrequencyBreakdown(null, meta.profile || {}, record, {
48
+ cohortEvent: 'Browse',
49
+ bins: { low: [0, 3], high: [3, Infinity] },
50
+ dropMultipliers: { low: 0.5, high: 0.95 },
51
+ finalStep: 'Activate',
52
+ });
53
+ },
54
+ };
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Phase 4 reference dungeon — time to convert by segment.
3
+ *
4
+ * Trial users take 4× longer to complete the activation funnel than enterprise
5
+ * users (0.5× faster). Verified via the timeToConvert emulator.
6
+ */
7
+
8
+ import dayjs from 'dayjs';
9
+ import { applyTTCBySegment } from '../../lib/hook-patterns/index.js';
10
+
11
+ const FIXED_NOW = dayjs('2024-02-02').unix();
12
+
13
+ export default {
14
+ name: 'pattern-ttc-by-segment',
15
+ seed: 'phase4-ttc-seg',
16
+ datasetStart: FIXED_NOW - 30 * 86400,
17
+ datasetEnd: FIXED_NOW,
18
+ numUsers: 1_000,
19
+ avgEventsPerUserPerDay: 4,
20
+ percentUsersBornInDataset: 100,
21
+ hasAnonIds: false,
22
+ format: 'json',
23
+ concurrency: 1,
24
+ writeToDisk: true,
25
+ verbose: false,
26
+ userProps: { tier: ['trial', 'trial', 'enterprise'] },
27
+ events: [
28
+ { event: 'Land', isFirstEvent: true, isStrictEvent: true },
29
+ { event: 'Sign Up', isAuthEvent: true, isStrictEvent: true },
30
+ { event: 'Activate', isStrictEvent: true },
31
+ { event: 'Browse', weight: 5 },
32
+ ],
33
+ funnels: [{
34
+ sequence: ['Land', 'Sign Up', 'Activate'],
35
+ conversionRate: 100, isFirstFunnel: true, timeToConvert: 4,
36
+ }],
37
+ hook: function (record, type, meta) {
38
+ if (type !== 'funnel-post' || !Array.isArray(record)) return;
39
+ if (!meta.isFirstFunnel) return;
40
+ applyTTCBySegment(record, meta.profile || {}, {
41
+ segmentKey: 'tier',
42
+ factors: { trial: 4, enterprise: 0.5 },
43
+ });
44
+ },
45
+ };
@@ -1,7 +1,7 @@
1
1
  // ── TWEAK THESE ──
2
2
  const SEED = "promptforge";
3
3
  const num_days = 120;
4
- const num_users = 8_000;
4
+ const num_users = 10_000;
5
5
  const avg_events_per_user_per_day = 0.83;
6
6
  let token = "your-mixpanel-token";
7
7
 
@@ -112,7 +112,7 @@ const chance = u.initChance(SEED);
112
112
  * 3. AGENTIC LOOP POWER USERS (everything)
113
113
  * ---------------------------------------------------------------
114
114
  *
115
- * PATTERN: Users with both "tool use call" AND any api-call event with
115
+ * PATTERN: Users with 3+ "tool use call" AND 3+ api-call events with
116
116
  * multi_turn=true get 8x tokens_used on api calls plus 2 extra cloned
117
117
  * api-call events per existing (3x rate). Cloned events with unique
118
118
  * offset timestamps. No flag — discover via cohort builder.
@@ -121,7 +121,7 @@ const chance = u.initChance(SEED);
121
121
  *
122
122
  * Report 1: Tokens per User — Agentic Cohort
123
123
  * - Report type: Insights (with cohort)
124
- * - Cohort A: users with both >= 1 "tool use call" AND >= 1 api-call with multi_turn=true
124
+ * - Cohort A: users with >= 3 "tool use call" AND >= 3 api-call with multi_turn=true
125
125
  * - Cohort B: rest
126
126
  * - Event: "api call"
127
127
  * - Measure: Average of "tokens_used"
@@ -272,23 +272,23 @@ const chance = u.initChance(SEED);
272
272
  * ---------------------------------------------------------------
273
273
  *
274
274
  * PATTERN: Count "docs searched" events between organization-created and
275
- * first billing-payment. Sweet 5-8 → +35% on amount_usd of billing
276
- * payment events. Over 9+ → drop 30% of billing payment events. No flag.
275
+ * first billing-payment. Sweet 2-4 → +35% on amount_usd of billing
276
+ * payment events. Over 5+ → drop 30% of billing payment events. No flag.
277
277
  *
278
278
  * HOW TO FIND IT IN MIXPANEL:
279
279
  *
280
280
  * Report 1: Avg Billing Amount by Docs-Searched Bucket
281
281
  * - Report type: Insights (with cohort)
282
- * - Cohort A: users with 5-8 "docs searched" between sign-up and first billing
283
- * - Cohort B: users with 0-4
282
+ * - Cohort A: users with 2-4 "docs searched" between sign-up and first billing
283
+ * - Cohort B: users with 0-1
284
284
  * - Event: "billing payment"
285
285
  * - Measure: Average of "amount_usd"
286
286
  * - Expected: A ~ 1.35x B
287
287
  *
288
288
  * Report 2: Billing Payments per User on Heavy Searchers
289
289
  * - Report type: Insights (with cohort)
290
- * - Cohort C: users with >= 9 "docs searched" between sign-up and billing
291
- * - Cohort A: users with 5-8
290
+ * - Cohort C: users with >= 5 "docs searched" between sign-up and billing
291
+ * - Cohort A: users with 2-4
292
292
  * - Event: "billing payment"
293
293
  * - Measure: Total per user
294
294
  * - Expected: C ~ 30% fewer billing payments per user
@@ -317,15 +317,17 @@ const chance = u.initChance(SEED);
317
317
 
318
318
  /** @type {Config} */
319
319
  const config = {
320
+ version: 2,
320
321
  token,
321
322
  seed: SEED,
322
323
  datasetStart: "2026-01-01T00:00:00Z",
323
- datasetEnd: "2026-04-28T23:59:59Z",
324
+ datasetEnd: "2026-05-01T23:59:59Z",
324
325
  // numDays: num_days,
325
326
  avgEventsPerUserPerDay: avg_events_per_user_per_day,
326
327
  numUsers: num_users,
327
- hasAnonIds: false,
328
- hasSessionIds: false,
328
+ hasAnonIds: true,
329
+ avgDevicePerUser: 2,
330
+ hasSessionIds: true,
329
331
  format: "json",
330
332
  gzip: true,
331
333
  alsoInferFunnels: false,
@@ -364,6 +366,7 @@ const config = {
364
366
  event: "organization created",
365
367
  weight: 1,
366
368
  isFirstEvent: true,
369
+ isAuthEvent: true,
367
370
  properties: {
368
371
  org_size: ["solo", "startup", "growth", "enterprise"],
369
372
  referral_source: ["docs", "blog", "github", "word_of_mouth", "search", "conference"],
@@ -588,38 +591,6 @@ const config = {
588
591
 
589
592
  // -- Hook Function ----------------------------------------
590
593
  hook: function (record, type, meta) {
591
- // ─────────────────────────────────────────────────────────
592
- // Hook #6: OUTAGE DAY (event)
593
- // Days 40-41: 40% of api calls get is_error=true with
594
- // service error types
595
- // ─────────────────────────────────────────────────────────
596
- if (type === "event") {
597
- const datasetStart = dayjs.unix(meta.datasetStart);
598
- if (record.event === "api call") {
599
- const eventTime = dayjs(record.time);
600
- const dayInDataset = eventTime.diff(datasetStart, "days", true);
601
-
602
- // Hook #6: Outage day errors
603
- if (dayInDataset >= 40 && dayInDataset < 42) {
604
- if (chance.bool({ likelihood: 40 })) {
605
- record.is_error = true;
606
- record.error_type = chance.pickone([
607
- "service_overloaded",
608
- "internal_server_error",
609
- "gateway_timeout",
610
- ]);
611
- record.latency_ms = Math.floor((record.latency_ms || 1500) * 3);
612
- }
613
- }
614
-
615
- // Hook #2 (model migration wave) MOVED to everything hook —
616
- // at event-hook time `record.api_tier` is the random per-event
617
- // value, not the user's profile tier. Stamping happens later in
618
- // the everything hook.
619
- }
620
-
621
- return record;
622
- }
623
594
 
624
595
  // ─────────────────────────────────────────────────────────
625
596
  // Hook 9 (T2C): API-TO-EVAL TIME-TO-CONVERT (funnel-post)
@@ -661,6 +632,26 @@ const config = {
661
632
  if (profile.sdk_language) e.sdk_language = profile.sdk_language;
662
633
  });
663
634
 
635
+ // ─────────────────────────────────────────────────────
636
+ // Hook #6: OUTAGE DAY (days 40-41)
637
+ // 40% of api calls get is_error=true with service errors
638
+ // ─────────────────────────────────────────────────────
639
+ events.forEach(e => {
640
+ if (e.event !== "api call") return;
641
+ const dayInDataset = dayjs(e.time).diff(datasetStart, "days", true);
642
+ if (dayInDataset >= 40 && dayInDataset < 42) {
643
+ if (chance.bool({ likelihood: 40 })) {
644
+ e.is_error = true;
645
+ e.error_type = chance.pickone([
646
+ "service_overloaded",
647
+ "internal_server_error",
648
+ "gateway_timeout",
649
+ ]);
650
+ e.latency_ms = Math.floor((e.latency_ms || 1500) * 3);
651
+ }
652
+ }
653
+ });
654
+
664
655
  // Determine first event time for relative day calculations
665
656
  const sortedByTime = [...events].sort((a, b) => dayjs(a.time).valueOf() - dayjs(b.time).valueOf());
666
657
  const firstEventTime = sortedByTime.length > 0 ? dayjs(sortedByTime[0].time) : datasetStart;
@@ -727,11 +718,13 @@ const config = {
727
718
 
728
719
  // ─────────────────────────────────────────────────────
729
720
  // Hook #3: AGENTIC LOOP POWER USERS (BEHAVIORS TOGETHER)
730
- // Users with tool use + multi_turn get 8x tokens, 3x events
721
+ // Users with 3+ tool use calls + 3+ multi_turn api calls
722
+ // get 8x tokens, 3x events. Threshold ensures a meaningful
723
+ // agentic cohort (~20-30% of users).
731
724
  // ─────────────────────────────────────────────────────
732
- const hasToolUse = events.some(e => e.event === "tool use call");
733
- const hasMultiTurn = events.some(e => e.event === "api call" && e.multi_turn === true);
734
- const isAgenticUser = hasToolUse && hasMultiTurn;
725
+ const toolUseCount = events.filter(e => e.event === "tool use call").length;
726
+ const multiTurnCount = events.filter(e => e.event === "api call" && e.multi_turn === true).length;
727
+ const isAgenticUser = toolUseCount >= 3 && multiTurnCount >= 3;
735
728
 
736
729
  if (isAgenticUser) {
737
730
  events.forEach(e => {
@@ -819,8 +812,8 @@ const config = {
819
812
  // ─────────────────────────────────────────────────────
820
813
  // Hook 10: DOCS-SEARCHED MAGIC NUMBER (in-funnel, no flags)
821
814
  // Count "docs searched" events between first "organization
822
- // created" (sign-up) and any "billing payment". Sweet 5-8 → +35%
823
- // on amount_usd of billing-payment events. Over 9+ → drop 30%
815
+ // created" (sign-up) and any "billing payment". Sweet 2-4 → +35%
816
+ // on amount_usd of billing-payment events. Over 5+ → drop 30%
824
817
  // of billing-payment events.
825
818
  // ─────────────────────────────────────────────────────
826
819
  const orgEvent = events.find(e => e.event === "organization created");
@@ -833,13 +826,13 @@ const config = {
833
826
  dayjs(e.time).isAfter(aTime) &&
834
827
  dayjs(e.time).isBefore(bTime)
835
828
  ).length;
836
- if (docsBetween >= 5 && docsBetween <= 8) {
829
+ if (docsBetween >= 2 && docsBetween <= 4) {
837
830
  events.forEach(e => {
838
831
  if (e.event === "billing payment" && typeof e.amount_usd === "number") {
839
832
  e.amount_usd = Math.round(e.amount_usd * 1.35);
840
833
  }
841
834
  });
842
- } else if (docsBetween >= 9) {
835
+ } else if (docsBetween >= 5) {
843
836
  for (let i = events.length - 1; i >= 0; i--) {
844
837
  if (events[i].event === "billing payment" && chance.bool({ likelihood: 30 })) {
845
838
  events.splice(i, 1);