@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.
- package/CHANGELOG.md +58 -0
- package/dungeons/technical/hook-helpers-verify.js +89 -0
- package/dungeons/technical/identity-model-verify.js +47 -0
- package/dungeons/technical/pattern-aggregate-by-bin.js +41 -0
- package/dungeons/technical/pattern-attributed-by-source.js +42 -0
- package/dungeons/technical/pattern-frequency-by-frequency.js +40 -0
- package/dungeons/technical/pattern-funnel-frequency.js +54 -0
- package/dungeons/technical/pattern-ttc-by-segment.js +45 -0
- package/dungeons/vertical/ai-platform.js +45 -52
- package/dungeons/vertical/community.js +11 -8
- package/dungeons/vertical/crypto.js +25 -24
- package/dungeons/vertical/dating.js +56 -48
- package/dungeons/vertical/devtools.js +25 -18
- package/dungeons/vertical/ecommerce.js +42 -38
- package/dungeons/vertical/education.js +24 -9
- package/dungeons/vertical/fintech.js +13 -8
- package/dungeons/vertical/fitness.js +73 -122
- package/dungeons/vertical/food-delivery.js +18 -19
- package/dungeons/vertical/gaming.js +19 -20
- package/dungeons/vertical/healthcare.js +11 -8
- package/dungeons/vertical/insurance-application.js +6 -3
- package/dungeons/vertical/logistics.js +15 -9
- package/dungeons/vertical/marketplace.js +36 -27
- package/dungeons/vertical/media.js +27 -25
- package/dungeons/vertical/real-estate.js +18 -7
- package/dungeons/vertical/sass.js +84 -68
- package/dungeons/vertical/social.js +46 -47
- package/dungeons/vertical/travel.js +8 -5
- package/index.js +17 -71
- package/lib/core/config-validator.js +143 -164
- package/lib/core/storage.js +5 -1
- package/lib/generators/events.js +49 -93
- package/lib/generators/funnels.js +202 -91
- package/lib/hook-helpers/_internal.js +23 -0
- package/lib/hook-helpers/cohort.js +124 -0
- package/lib/hook-helpers/identity.js +56 -0
- package/lib/hook-helpers/index.js +44 -0
- package/lib/hook-helpers/inject.js +99 -0
- package/lib/hook-helpers/mutate.js +151 -0
- package/lib/hook-helpers/timing.js +99 -0
- package/lib/hook-patterns/aggregate-per-user-by-bin.js +38 -0
- package/lib/hook-patterns/attributed-by-source.js +72 -0
- package/lib/hook-patterns/frequency-by-frequency.js +46 -0
- package/lib/hook-patterns/funnel-frequency-breakdown.js +73 -0
- package/lib/hook-patterns/index.js +14 -0
- package/lib/hook-patterns/time-to-convert-by-segment.js +41 -0
- package/lib/orchestrators/mixpanel-sender.js +46 -51
- package/lib/orchestrators/user-loop.js +119 -269
- package/lib/utils/utils.js +39 -16
- package/lib/verify/emulate-breakdown.js +281 -0
- package/lib/verify/index.js +12 -0
- package/lib/verify/verify-dungeon.js +61 -0
- package/package.json +6 -4
- 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
|
+
}
|