@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
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Hook helpers — injection atoms.
3
+ *
4
+ * Splice cloned events into a user's event stream. CRITICAL: per Phase 1 schema-first
5
+ * rules, `templateEvent` should be an existing event from the stream (not a
6
+ * fabricated one), so the injected event carries the dungeon's defined schema. Use
7
+ * `cloneEvent` from `mutate.js` if you want the override semantics explicit.
8
+ *
9
+ * Randomness uses the seeded `chance` instance from utils so dungeons stay reproducible.
10
+ */
11
+
12
+ import { getChance } from '../utils/utils.js';
13
+ import { toMs, writeTime } from './_internal.js';
14
+
15
+ /**
16
+ * Splice a cloned event into `events` immediately after `sourceEvent`. Time is
17
+ * `sourceEvent.time + gapMs`; `overrides` are shallow-merged on top of `templateEvent`.
18
+ * If `sourceEvent` is not in `events` (caller passed a stale ref), the new event is
19
+ * pushed to the end instead. Returns the newly created event.
20
+ *
21
+ * @param {Array<Object>} events
22
+ * @param {{time: string|number}} sourceEvent
23
+ * @param {Object} templateEvent
24
+ * @param {number} gapMs
25
+ * @param {Object} [overrides]
26
+ * @returns {Object|null}
27
+ */
28
+ export function injectAfterEvent(events, sourceEvent, templateEvent, gapMs, overrides = {}) {
29
+ if (!events || !sourceEvent || !templateEvent) return null;
30
+ const baseT = toMs(sourceEvent.time);
31
+ if (!Number.isFinite(baseT)) return null;
32
+ const newEv = { ...templateEvent, ...overrides };
33
+ writeTime(newEv, baseT + gapMs);
34
+ const idx = events.indexOf(sourceEvent);
35
+ if (idx >= 0) events.splice(idx + 1, 0, newEv);
36
+ else events.push(newEv);
37
+ return newEv;
38
+ }
39
+
40
+ /**
41
+ * Splice a cloned event between the first `eventA` and the first `eventB` after
42
+ * it (in time order), at the midpoint of the gap. Returns the new event, or null
43
+ * if either anchor is missing.
44
+ *
45
+ * @param {Array<{event:string,time:string|number}>} events
46
+ * @param {string} eventA
47
+ * @param {string} eventB
48
+ * @param {Object} templateEvent
49
+ * @param {Object} [overrides]
50
+ * @returns {Object|null}
51
+ */
52
+ export function injectBetween(events, eventA, eventB, templateEvent, overrides = {}) {
53
+ if (!events || !eventA || !eventB || !templateEvent) return null;
54
+ const sorted = events.slice().sort((x, y) => toMs(x && x.time) - toMs(y && y.time));
55
+ const aIdx = sorted.findIndex(e => e && e.event === eventA);
56
+ if (aIdx < 0) return null;
57
+ const a = sorted[aIdx];
58
+ const b = sorted.slice(aIdx + 1).find(e => e && e.event === eventB);
59
+ if (!b) return null;
60
+ const aT = toMs(a.time);
61
+ const bT = toMs(b.time);
62
+ if (!Number.isFinite(aT) || !Number.isFinite(bT)) return null;
63
+ const newEv = { ...templateEvent, ...overrides };
64
+ writeTime(newEv, (aT + bT) / 2);
65
+ const bIdxOrig = events.indexOf(b);
66
+ if (bIdxOrig >= 0) events.splice(bIdxOrig, 0, newEv);
67
+ else events.push(newEv);
68
+ return newEv;
69
+ }
70
+
71
+ /**
72
+ * Inject `count` clones of `templateEvent` into `events`, distributed uniformly at
73
+ * random within `[anchorTime - spreadMs, anchorTime + spreadMs]`. Uses the seeded
74
+ * RNG. Returns the array of newly created events.
75
+ *
76
+ * @param {Array<Object>} events
77
+ * @param {Object} templateEvent
78
+ * @param {number} count
79
+ * @param {string|number} anchorTime
80
+ * @param {number} spreadMs
81
+ * @param {Object} [overrides]
82
+ * @returns {Object[]}
83
+ */
84
+ export function injectBurst(events, templateEvent, count, anchorTime, spreadMs, overrides = {}) {
85
+ if (!events || !templateEvent || count <= 0 || typeof spreadMs !== 'number') return [];
86
+ const anchorMs = toMs(anchorTime);
87
+ if (!Number.isFinite(anchorMs)) return [];
88
+ const chance = getChance();
89
+ const created = [];
90
+ for (let i = 0; i < count; i++) {
91
+ const offset = chance.floating({ min: -spreadMs, max: spreadMs });
92
+ const newEv = { ...templateEvent, ...overrides };
93
+ writeTime(newEv, anchorMs + offset);
94
+ events.push(newEv);
95
+ created.push(newEv);
96
+ }
97
+ return created;
98
+ }
99
+
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Hook helpers — mutation atoms.
3
+ *
4
+ * In-place mutation primitives that hooks call to scale, drop, or modify events.
5
+ * These are deliberately tiny and composable so patterns built on top stay readable.
6
+ *
7
+ * Randomness uses the seeded `chance` instance from `lib/utils/utils.js` — never
8
+ * `Math.random()` — so dungeon runs stay reproducible.
9
+ */
10
+
11
+ import { toMs } from './_internal.js';
12
+
13
+ import { getChance } from '../utils/utils.js';
14
+
15
+ /**
16
+ * Returns a new event object built from `template` with `overrides` shallow-merged
17
+ * on top. Mandatory replacement fields (time, user_id, etc.) belong in `overrides`.
18
+ * The schema-first hook rule still applies: only properties that already exist in
19
+ * the dungeon's event config should be set in `overrides`.
20
+ *
21
+ * @template {Record<string, any>} T
22
+ * @param {T} template
23
+ * @param {Partial<T>} [overrides]
24
+ * @returns {T}
25
+ */
26
+ export function cloneEvent(template, overrides = {}) {
27
+ if (!template) throw new Error('cloneEvent: template is required');
28
+ return /** @type {any} */ ({ ...template, ...overrides });
29
+ }
30
+
31
+ /**
32
+ * Drop events where `predicate(event, index)` is truthy. Mutates `events` in place.
33
+ *
34
+ * @param {Array<Object>} events
35
+ * @param {(event: Object, index: number) => boolean} predicate
36
+ * @returns {number} Number of events dropped.
37
+ */
38
+ export function dropEventsWhere(events, predicate) {
39
+ if (!events || typeof predicate !== 'function') return 0;
40
+ let dropped = 0;
41
+ for (let i = events.length - 1; i >= 0; i--) {
42
+ if (predicate(events[i], i)) {
43
+ events.splice(i, 1);
44
+ dropped++;
45
+ }
46
+ }
47
+ return dropped;
48
+ }
49
+
50
+ /**
51
+ * Scale the count of events with name `eventName` in `events` by `factor`. Mutates
52
+ * `events` in place.
53
+ *
54
+ * - factor > 1: clones existing matches with small monotonic time offsets (1s steps)
55
+ * so duplicates land just after their source. Returns positive integer = clones added.
56
+ * - factor < 1: drops matches at random using the seeded RNG. Returns negative
57
+ * integer = -dropped.
58
+ * - factor === 1 or no matches: no-op, returns 0.
59
+ *
60
+ * Note: the `insert_id` of cloned events is removed so a downstream pass can
61
+ * regenerate it (otherwise Mixpanel will dedupe on import).
62
+ *
63
+ * @param {Array<{event:string,time:string|number,insert_id?:string}>} events
64
+ * @param {string} eventName
65
+ * @param {number} factor
66
+ * @returns {number}
67
+ */
68
+ export function scaleEventCount(events, eventName, factor) {
69
+ if (!events || !eventName || typeof factor !== 'number' || factor === 1) return 0;
70
+ if (factor > 1) {
71
+ const matches = events.filter(e => e && e.event === eventName);
72
+ if (!matches.length) return 0;
73
+ const additionalNeeded = Math.round(matches.length * (factor - 1));
74
+ let added = 0;
75
+ for (let i = 0; i < additionalNeeded; i++) {
76
+ const src = matches[i % matches.length];
77
+ const baseMs = toMs(src.time);
78
+ const newTime = Number.isFinite(baseMs)
79
+ ? new Date(baseMs + (i + 1) * 1000).toISOString()
80
+ : src.time;
81
+ const clone = { ...src, time: newTime };
82
+ delete clone.insert_id;
83
+ events.push(clone);
84
+ added++;
85
+ }
86
+ return added;
87
+ }
88
+ // factor < 1 → drop at random
89
+ const chance = getChance();
90
+ const dropProb = Math.max(0, Math.min(1, 1 - factor));
91
+ let dropped = 0;
92
+ for (let i = events.length - 1; i >= 0; i--) {
93
+ const ev = events[i];
94
+ if (ev && ev.event === eventName && chance.bool({ likelihood: dropProb * 100 })) {
95
+ events.splice(i, 1);
96
+ dropped++;
97
+ }
98
+ }
99
+ return -dropped;
100
+ }
101
+
102
+ /**
103
+ * For each event in `events` matching `predicate`, multiply the numeric value at
104
+ * `propertyName` by `factor`. Skips events where the property is missing or non-numeric.
105
+ *
106
+ * @param {Array<Object>} events
107
+ * @param {(event: Object) => boolean} predicate
108
+ * @param {string} propertyName
109
+ * @param {number} factor
110
+ * @returns {number} Number of events whose property was scaled.
111
+ */
112
+ export function scalePropertyValue(events, predicate, propertyName, factor) {
113
+ if (!events || typeof predicate !== 'function' || !propertyName || typeof factor !== 'number') return 0;
114
+ let count = 0;
115
+ for (const ev of events) {
116
+ if (!ev || !predicate(ev)) continue;
117
+ const v = ev[propertyName];
118
+ if (typeof v === 'number') {
119
+ ev[propertyName] = v * factor;
120
+ count++;
121
+ }
122
+ }
123
+ return count;
124
+ }
125
+
126
+ /**
127
+ * Shift a single event's `time` by `deltaMs` milliseconds. Mutates the event in place.
128
+ * Accepts ISO string or numeric (unix ms / unix seconds) inputs and writes back in
129
+ * the original format.
130
+ *
131
+ * @param {{time: string|number}} event
132
+ * @param {number} deltaMs
133
+ * @returns {Object} The mutated event.
134
+ */
135
+ export function shiftEventTime(event, deltaMs) {
136
+ if (!event || event.time === undefined || event.time === null) return event;
137
+ if (typeof event.time === 'string') {
138
+ const ms = Date.parse(event.time);
139
+ if (Number.isFinite(ms)) {
140
+ event.time = new Date(ms + deltaMs).toISOString();
141
+ }
142
+ } else if (typeof event.time === 'number') {
143
+ // Preserve scale (seconds vs ms).
144
+ if (event.time > 1e12) {
145
+ event.time = event.time + deltaMs;
146
+ } else {
147
+ event.time = event.time + deltaMs / 1000;
148
+ }
149
+ }
150
+ return event;
151
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Hook helpers — timing atoms.
3
+ *
4
+ * Adjust gaps between specific events (or the whole funnel TTC) and detect ordered
5
+ * sequences within a maximum gap. All times are normalized to unix milliseconds
6
+ * internally; ISO strings are written back when the source was a string.
7
+ */
8
+
9
+ import { toMs, writeTime } from './_internal.js';
10
+
11
+ /**
12
+ * Find the FIRST `eventA` in time order, then the FIRST `eventB` after it, then
13
+ * scale the time gap between them by `factor`. The B event is mutated to the new
14
+ * timestamp (the A event is unchanged). Returns true on success, false if either
15
+ * anchor is missing.
16
+ *
17
+ * @param {Array<{event:string,time:string|number}>} events
18
+ * @param {string} eventA
19
+ * @param {string} eventB
20
+ * @param {number} factor - 0.5 halves the gap, 2.0 doubles it.
21
+ * @returns {boolean}
22
+ */
23
+ export function scaleTimingBetween(events, eventA, eventB, factor) {
24
+ if (!events || !eventA || !eventB || typeof factor !== 'number') return false;
25
+ const sorted = events.slice().sort((x, y) => toMs(x && x.time) - toMs(y && y.time));
26
+ const aIdx = sorted.findIndex(e => e && e.event === eventA);
27
+ if (aIdx < 0) return false;
28
+ const tail = sorted.slice(aIdx + 1);
29
+ const b = tail.find(e => e && e.event === eventB);
30
+ if (!b) return false;
31
+ const a = sorted[aIdx];
32
+ const aT = toMs(a.time);
33
+ const bT = toMs(b.time);
34
+ if (!Number.isFinite(aT) || !Number.isFinite(bT)) return false;
35
+ const newBT = aT + (bT - aT) * factor;
36
+ writeTime(b, newBT);
37
+ return true;
38
+ }
39
+
40
+ /**
41
+ * Scale the time-to-convert (TTC) of an entire funnel. Each event's offset from
42
+ * the funnel's first event is multiplied by `factor`. Mutates events in place.
43
+ *
44
+ * @param {Array<{event:string,time:string|number}>} funnelEvents
45
+ * @param {number} factor
46
+ * @returns {number} Count of events shifted (excludes the anchor).
47
+ */
48
+ export function scaleFunnelTTC(funnelEvents, factor) {
49
+ if (!funnelEvents || !funnelEvents.length || typeof factor !== 'number') return 0;
50
+ const sorted = funnelEvents.slice().sort((x, y) => toMs(x && x.time) - toMs(y && y.time));
51
+ const baseT = toMs(sorted[0].time);
52
+ if (!Number.isFinite(baseT)) return 0;
53
+ let n = 0;
54
+ for (const ev of funnelEvents) {
55
+ if (ev === sorted[0]) continue;
56
+ const t = toMs(ev.time);
57
+ if (!Number.isFinite(t)) continue;
58
+ writeTime(ev, baseT + (t - baseT) * factor);
59
+ n++;
60
+ }
61
+ return n;
62
+ }
63
+
64
+ /**
65
+ * Detect the FIRST occurrence of an ordered sequence of event names within a
66
+ * maximum gap. Returns the matching events (in order) or `null` if no run satisfies
67
+ * the constraint. The gap is checked between *consecutive matched* events, not
68
+ * between any two events in the stream.
69
+ *
70
+ * @param {Array<{event:string,time:string|number}>} events
71
+ * @param {string[]} eventNames - Ordered sequence of event names to match.
72
+ * @param {number} maxGapMin - Maximum allowable gap between consecutive matched events, in minutes.
73
+ * @returns {Array<Object>|null}
74
+ */
75
+ export function findFirstSequence(events, eventNames, maxGapMin) {
76
+ if (!events || !eventNames || !eventNames.length || typeof maxGapMin !== 'number') return null;
77
+ const sorted = events.slice().sort((x, y) => toMs(x && x.time) - toMs(y && y.time));
78
+ const maxGapMs = maxGapMin * 60 * 1000;
79
+ for (let i = 0; i < sorted.length; i++) {
80
+ const head = sorted[i];
81
+ if (!head || head.event !== eventNames[0]) continue;
82
+ const matched = [head];
83
+ let lastT = toMs(head.time);
84
+ let stepIdx = 1;
85
+ for (let j = i + 1; j < sorted.length && stepIdx < eventNames.length; j++) {
86
+ const cur = sorted[j];
87
+ if (!cur || cur.time === undefined) continue;
88
+ const t = toMs(cur.time);
89
+ if (!Number.isFinite(t) || t - lastT > maxGapMs) break;
90
+ if (cur.event === eventNames[stepIdx]) {
91
+ matched.push(cur);
92
+ lastT = t;
93
+ stepIdx++;
94
+ }
95
+ }
96
+ if (matched.length === eventNames.length) return matched;
97
+ }
98
+ return null;
99
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Pattern: Aggregate per User, by cohort bin.
3
+ *
4
+ * Adjusts the average value of a numeric event property based on the user's
5
+ * cohort bin (derived from `count(cohortEvent)`). Used for "Avg Order Value by
6
+ * per-user count of Sessions" Insights views — engaged users skew avg up.
7
+ *
8
+ * Mechanism: classify, then `scalePropertyValue` with `deltas[bin]` as the
9
+ * multiplier. Property must already be defined on the event in the dungeon
10
+ * schema and carry numeric values.
11
+ */
12
+
13
+ import { binUsersByEventCount } from '../hook-helpers/cohort.js';
14
+ import { scalePropertyValue } from '../hook-helpers/mutate.js';
15
+
16
+ /**
17
+ * @param {Array<Object>} events - User's event stream (mutated in place).
18
+ * @param {Object} _profile
19
+ * @param {Object} opts
20
+ * @param {string} opts.cohortEvent
21
+ * @param {Record<string, [number, number]>} opts.bins
22
+ * @param {string} opts.event - Event whose property is scaled.
23
+ * @param {string} opts.propertyName
24
+ * @param {Record<string, number>} opts.deltas - Bin name → multiplier (1 = no-op,
25
+ * 1.5 = 50% lift, 0.7 = 30% drop).
26
+ * @returns {{ bin: string|null, scaled: number }}
27
+ */
28
+ export function applyAggregateByBin(events, _profile, { cohortEvent, bins, event, propertyName, deltas }) {
29
+ if (!events || !cohortEvent || !bins || !event || !propertyName || !deltas) {
30
+ return { bin: null, scaled: 0 };
31
+ }
32
+ const bin = binUsersByEventCount(events, cohortEvent, bins);
33
+ if (!bin) return { bin: null, scaled: 0 };
34
+ const factor = deltas[bin];
35
+ if (typeof factor !== 'number' || factor === 1) return { bin, scaled: 0 };
36
+ const scaled = scalePropertyValue(events, e => e && e.event === event, propertyName, factor);
37
+ return { bin, scaled };
38
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Pattern: Attribute conversions by source.
3
+ *
4
+ * For each user, copy a property value from a "touch" event onto a downstream
5
+ * "conversion" event so Mixpanel's "Conversions by Source" attribution analysis
6
+ * shows the configured weighted distribution. The pattern doesn't invent the
7
+ * source distribution — it preserves whatever the touch events already carry —
8
+ * BUT it lets you bias the conversion completion rate per source via `weights`
9
+ * (probability of stamping = weight ÷ max(weight) ).
10
+ *
11
+ * Mechanism: walk the user's event stream; when a `downstreamEvent` event fires,
12
+ * look back at the most-recent (or first) `sourceEvent` and copy
13
+ * `sourceEvent[sourceProperty]` onto the downstream event. Skip stamping
14
+ * probabilistically per `weights[sourceValue]`.
15
+ *
16
+ * Identity & schema: the destination property must already exist on
17
+ * `downstreamEvent` in the dungeon schema (we OVERWRITE the value, not invent it).
18
+ */
19
+
20
+ /**
21
+ * @param {Array<Object>} events - User's event stream (mutated in place).
22
+ * @param {Object} _profile
23
+ * @param {Object} opts
24
+ * @param {string} opts.sourceEvent - Event whose property we copy from.
25
+ * @param {string} opts.sourceProperty - Property on `sourceEvent` to copy.
26
+ * @param {string} opts.downstreamEvent - Event whose property we overwrite.
27
+ * @param {string} [opts.downstreamProperty] - Defaults to `sourceProperty`.
28
+ * @param {Record<string, number>} opts.weights - Source value → relative weight
29
+ * (probability of stamping = weight / maxWeight; missing entries = 0).
30
+ * @param {'firstTouch'|'lastTouch'} [opts.model] - Default 'firstTouch'.
31
+ * @returns {{ stamped: number, skipped: number }}
32
+ */
33
+ export function applyAttributedBySource(events, _profile, opts) {
34
+ const { sourceEvent, sourceProperty, downstreamEvent, downstreamProperty, weights, model = 'firstTouch' } = opts || {};
35
+ if (!events || !sourceEvent || !sourceProperty || !downstreamEvent || !weights) {
36
+ return { stamped: 0, skipped: 0 };
37
+ }
38
+ const destProp = downstreamProperty || sourceProperty;
39
+ const sorted = events.slice().sort((a, b) => Date.parse(a.time) - Date.parse(b.time));
40
+ const maxWeight = Math.max(...Object.values(weights), 0);
41
+ if (maxWeight <= 0) return { stamped: 0, skipped: 0 };
42
+
43
+ let stamped = 0;
44
+ let skipped = 0;
45
+ const touches = []; // accumulated source events in time order
46
+ for (const ev of sorted) {
47
+ if (!ev) continue;
48
+ if (ev.event === sourceEvent && ev[sourceProperty] !== undefined) {
49
+ touches.push(ev);
50
+ continue;
51
+ }
52
+ if (ev.event === downstreamEvent && touches.length) {
53
+ const touch = model === 'lastTouch' ? touches[touches.length - 1] : touches[0];
54
+ const val = touch[sourceProperty];
55
+ const w = weights[val] || 0;
56
+ const prob = w / maxWeight; // 0..1
57
+ // Deterministic per-user pseudo-RNG keyed on the downstream event's
58
+ // insert_id (or time fallback) — keeps verification reproducible.
59
+ const seed = ev.insert_id || ev.time || '';
60
+ const r = simpleHashFloat(String(seed));
61
+ if (r < prob) {
62
+ ev[destProp] = val;
63
+ stamped++;
64
+ } else {
65
+ skipped++;
66
+ }
67
+ }
68
+ }
69
+ return { stamped, skipped };
70
+ }
71
+
72
+ import { simpleHashFloat } from '../hook-helpers/_internal.js';
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Pattern: Frequency × Frequency.
3
+ *
4
+ * Engineers the joint distribution of `count(metricEvent)` × `count(cohortEvent)`
5
+ * per user, so Mixpanel's "Frequency Distribution of A by per-user count of B"
6
+ * Insights view shows a deliberate shape (e.g., users with 5–20 cohort events
7
+ * have 2x the metric event count of users with <5).
8
+ *
9
+ * Mechanism: classify the user into a bin based on their `cohortEvent` count,
10
+ * then `scaleEventCount(events, targetEvent, multipliers[bin])` to scale that
11
+ * user's count of the target event up or down.
12
+ *
13
+ * Identity & schema constraints:
14
+ * - Operates on the user's full event stream — call from the `everything` hook.
15
+ * - Does NOT add new properties; uses existing event names defined in the dungeon
16
+ * schema.
17
+ * - Cloned events have their `insert_id` stripped (mutate.scaleEventCount handles
18
+ * that), so the engine's batch writer can re-stamp them downstream.
19
+ */
20
+
21
+ import { binUsersByEventCount } from '../hook-helpers/cohort.js';
22
+ import { scaleEventCount } from '../hook-helpers/mutate.js';
23
+
24
+ /**
25
+ * @param {Array<Object>} events - User's event stream (mutated in place).
26
+ * @param {Object} _profile - User profile (unused, kept for API symmetry).
27
+ * @param {Object} opts
28
+ * @param {string} opts.cohortEvent - Event whose per-user count classifies the user.
29
+ * @param {Record<string, [number, number]>} opts.bins - Bin name → [lo, hi).
30
+ * @param {string} opts.targetEvent - Event whose count is scaled per bin.
31
+ * @param {Record<string, number>} opts.multipliers - Bin name → multiplier (1 = no-op,
32
+ * 2 = double, 0.5 = halve). Bins absent from this map use multiplier 1.
33
+ * @returns {{ bin: string|null, delta: number }} Bin assigned + signed delta from
34
+ * scaleEventCount (positive = clones added; negative = events dropped).
35
+ */
36
+ export function applyFrequencyByFrequency(events, _profile, { cohortEvent, bins, targetEvent, multipliers }) {
37
+ if (!events || !cohortEvent || !bins || !targetEvent || !multipliers) {
38
+ return { bin: null, delta: 0 };
39
+ }
40
+ const bin = binUsersByEventCount(events, cohortEvent, bins);
41
+ if (!bin) return { bin: null, delta: 0 };
42
+ const factor = multipliers[bin];
43
+ if (typeof factor !== 'number' || factor === 1) return { bin, delta: 0 };
44
+ const delta = scaleEventCount(events, targetEvent, factor);
45
+ return { bin, delta };
46
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Pattern: Funnel Frequency Breakdown.
3
+ *
4
+ * Inside a `funnel-post` hook, vary the user's completion of the funnel by their
5
+ * count of `cohortEvent` (anywhere in the dataset, not just the funnel). Used
6
+ * when you want Mixpanel's funnel report — broken down by per-user count of an
7
+ * activity event — to show e.g. "users who did 5+ X are 1.4x as likely to
8
+ * complete this funnel."
9
+ *
10
+ * Mechanism: for users in a "drop-prone" bin, drop the funnel's final step
11
+ * event(s) per the `dropMultipliers` config (1 = drop none, 0 = drop all).
12
+ *
13
+ * Schema-first: does not add new event properties or invent events. Operates on
14
+ * the funnelEvents array passed by the funnel-post hook.
15
+ */
16
+
17
+ import { binUsersByEventCount } from '../hook-helpers/cohort.js';
18
+ import { dropEventsWhere } from '../hook-helpers/mutate.js';
19
+
20
+ /**
21
+ * @param {Array<Object>} allUserEvents - Full per-user event history (read-only;
22
+ * used to count `cohortEvent`). When called inside `funnel-post`, derive this
23
+ * from `meta.profile` or pass the user's accumulated events from a closure.
24
+ * When `null`, falls back to counting cohortEvent inside `funnelEvents`.
25
+ * @param {Object} _profile
26
+ * @param {Array<Object>} funnelEvents - Funnel events produced by `makeFunnel`
27
+ * (mutated in place).
28
+ * @param {Object} opts
29
+ * @param {string} opts.cohortEvent
30
+ * @param {Record<string, [number, number]>} opts.bins
31
+ * @param {Record<string, number>} opts.dropMultipliers - Bin name → keep-rate (0..1)
32
+ * for the FINAL step event. 1 = always keep, 0 = always drop.
33
+ * @param {string} [opts.finalStep] - Event name of the final step. Defaults to the
34
+ * last event in `funnelEvents` (in time order).
35
+ * @returns {{ bin: string|null, droppedFinal: boolean }}
36
+ */
37
+ export function applyFunnelFrequencyBreakdown(allUserEvents, _profile, funnelEvents, opts) {
38
+ const { cohortEvent, bins, dropMultipliers, finalStep } = opts || {};
39
+ if (!funnelEvents || !cohortEvent || !bins || !dropMultipliers) {
40
+ return { bin: null, droppedFinal: false };
41
+ }
42
+ const sourceForBin = allUserEvents || funnelEvents;
43
+ const bin = binUsersByEventCount(sourceForBin, cohortEvent, bins);
44
+ if (!bin) return { bin: null, droppedFinal: false };
45
+ const keepRate = dropMultipliers[bin];
46
+ if (typeof keepRate !== 'number' || keepRate >= 1) return { bin, droppedFinal: false };
47
+
48
+ // Identify the final step. If finalStep is named, use it; otherwise pick the
49
+ // latest-in-time event in the funnel as the final step.
50
+ let stepName = finalStep;
51
+ if (!stepName) {
52
+ const sorted = funnelEvents.slice().sort((a, b) => Date.parse(a.time) - Date.parse(b.time));
53
+ stepName = sorted.length ? sorted[sorted.length - 1].event : null;
54
+ }
55
+ if (!stepName) return { bin, droppedFinal: false };
56
+
57
+ // Coin-flip drop using a deterministic-ish heuristic — this is called from a
58
+ // non-RNG context (funnel-post), so use Math.random would break determinism.
59
+ // Instead, use a hash on the funnel's first event's insert_id (deterministic
60
+ // per-call) modulo 1000 / 1000 vs. (1 - keepRate). For simplicity we use
61
+ // chance from utils when available.
62
+ const dropProb = 1 - keepRate;
63
+ const seed = funnelEvents[0] && (funnelEvents[0].insert_id || funnelEvents[0].time) || '';
64
+ const det = simpleHashFloat(String(seed));
65
+ if (det < dropProb) {
66
+ const before = funnelEvents.length;
67
+ dropEventsWhere(funnelEvents, e => e && e.event === stepName);
68
+ return { bin, droppedFinal: funnelEvents.length < before };
69
+ }
70
+ return { bin, droppedFinal: false };
71
+ }
72
+
73
+ import { simpleHashFloat } from '../hook-helpers/_internal.js';
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @ak--47/dungeon-master/hook-patterns — Phase 4 pattern barrel.
3
+ *
4
+ * Patterns are higher-level recipes built on Phase 3 atoms. Each one engineers
5
+ * the kind of distribution / table shape Mixpanel surfaces in a specific report.
6
+ * Pair with `verifyDungeon` + `emulateBreakdown` from `../verify` to assert the
7
+ * pattern is producing what you expect.
8
+ */
9
+
10
+ export { applyFrequencyByFrequency } from './frequency-by-frequency.js';
11
+ export { applyFunnelFrequencyBreakdown } from './funnel-frequency-breakdown.js';
12
+ export { applyAggregateByBin } from './aggregate-per-user-by-bin.js';
13
+ export { applyTTCBySegment } from './time-to-convert-by-segment.js';
14
+ export { applyAttributedBySource } from './attributed-by-source.js';
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Pattern: Time to Convert, broken down by user segment.
3
+ *
4
+ * Inside a `funnel-post` hook, scale the funnel's time-to-convert by a factor
5
+ * keyed off of a user-profile property value (e.g., trial users convert 3x
6
+ * slower than enterprise). Lets Mixpanel's TTC funnel report — broken down by
7
+ * a profile property — show a deliberate spread.
8
+ *
9
+ * Mechanism: read `profile[segmentKey]`, look up the multiplier in `factors`,
10
+ * and `scaleFunnelTTC(funnelEvents, factor)`. The first event's time is the
11
+ * anchor (unchanged); subsequent steps' offsets from it are scaled.
12
+ *
13
+ * Caveat: Mixpanel's "Time to Convert" funnel report uses the time between the
14
+ * FIRST event of step A and the FIRST event of step B per user, not the actual
15
+ * gap inside any one funnel run. The scaled funnel run will reflect in TTC only
16
+ * when this funnel is the user's first occurrence of those steps — which it is
17
+ * for an `isFirstFunnel`. For usage funnels, document this caveat to authors.
18
+ */
19
+
20
+ import { scaleFunnelTTC } from '../hook-helpers/timing.js';
21
+
22
+ /**
23
+ * @param {Array<Object>} funnelEvents - Mutated in place.
24
+ * @param {Object} profile - The user's profile (must contain `segmentKey`).
25
+ * @param {Object} opts
26
+ * @param {string} opts.segmentKey - Profile property name to look up.
27
+ * @param {Record<string, number>} opts.factors - Profile-value → TTC factor.
28
+ * @returns {{ segmentValue: any, factor: number, shifted: number }}
29
+ */
30
+ export function applyTTCBySegment(funnelEvents, profile, { segmentKey, factors }) {
31
+ if (!funnelEvents || !funnelEvents.length || !profile || !segmentKey || !factors) {
32
+ return { segmentValue: null, factor: 1, shifted: 0 };
33
+ }
34
+ const segmentValue = profile[segmentKey];
35
+ const factor = factors[segmentValue];
36
+ if (typeof factor !== 'number' || factor === 1) {
37
+ return { segmentValue, factor: 1, shifted: 0 };
38
+ }
39
+ const shifted = scaleFunnelTTC(funnelEvents, factor);
40
+ return { segmentValue, factor, shifted };
41
+ }