@ak--47/dungeon-master 1.3.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/lib/core/config-validator.js +136 -157
- 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/user-loop.js +119 -269
- package/lib/utils/utils.js +29 -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 +397 -211
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook helpers — cohort atoms.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions used inside `everything` / `event` hooks to classify users into
|
|
5
|
+
* behavioral cohorts. None of these mutate the input. They derive a label from a
|
|
6
|
+
* user's events or profile and return it; the caller decides what to do with the
|
|
7
|
+
* label (typically: feed into a `mutate` or `inject` atom).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { toMs } from './_internal.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Classify a user into a named bin based on the count of a specific event in their stream.
|
|
14
|
+
* Bin definitions use inclusive lower bound, exclusive upper bound (`[lo, hi)`).
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* const tier = binUsersByEventCount(events, 'Complete Action Item', {
|
|
18
|
+
* low: [0, 5],
|
|
19
|
+
* sweet: [5, 20],
|
|
20
|
+
* over: [20, Infinity],
|
|
21
|
+
* });
|
|
22
|
+
*
|
|
23
|
+
* @param {Array<{event:string,time?:string|number}>} events - User's event stream.
|
|
24
|
+
* @param {string} eventName - Event to count.
|
|
25
|
+
* @param {Record<string, [number, number]>} bins - Map of bin name → [lo, hi).
|
|
26
|
+
* @returns {string|null} Matching bin name, or null if no bin matches.
|
|
27
|
+
*/
|
|
28
|
+
export function binUsersByEventCount(events, eventName, bins) {
|
|
29
|
+
if (!events || !eventName || !bins) return null;
|
|
30
|
+
let count = 0;
|
|
31
|
+
for (const ev of events) {
|
|
32
|
+
if (ev && ev.event === eventName) count++;
|
|
33
|
+
}
|
|
34
|
+
for (const [name, range] of Object.entries(bins)) {
|
|
35
|
+
if (!Array.isArray(range) || range.length !== 2) continue;
|
|
36
|
+
const [lo, hi] = range;
|
|
37
|
+
if (count >= lo && count < hi) return name;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Like `binUsersByEventCount` but only counts events whose timestamp falls inside
|
|
44
|
+
* `[startTime, endTime]` (inclusive). Times can be unix milliseconds, unix seconds,
|
|
45
|
+
* ISO strings, or anything `Date.parse` accepts.
|
|
46
|
+
*
|
|
47
|
+
* @param {Array<{event:string,time:string|number}>} events
|
|
48
|
+
* @param {string} eventName
|
|
49
|
+
* @param {string|number} startTime
|
|
50
|
+
* @param {string|number} endTime
|
|
51
|
+
* @param {Record<string, [number, number]>} bins
|
|
52
|
+
* @returns {string|null}
|
|
53
|
+
*/
|
|
54
|
+
export function binUsersByEventInRange(events, eventName, startTime, endTime, bins) {
|
|
55
|
+
if (!events || !eventName || !bins) return null;
|
|
56
|
+
const startMs = toMs(startTime);
|
|
57
|
+
const endMs = toMs(endTime);
|
|
58
|
+
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null;
|
|
59
|
+
let count = 0;
|
|
60
|
+
for (const ev of events) {
|
|
61
|
+
if (!ev || ev.event !== eventName || ev.time === undefined) continue;
|
|
62
|
+
const t = toMs(ev.time);
|
|
63
|
+
if (Number.isFinite(t) && t >= startMs && t <= endMs) count++;
|
|
64
|
+
}
|
|
65
|
+
for (const [name, range] of Object.entries(bins)) {
|
|
66
|
+
if (!Array.isArray(range) || range.length !== 2) continue;
|
|
67
|
+
const [lo, hi] = range;
|
|
68
|
+
if (count >= lo && count < hi) return name;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Count events that occur strictly between the FIRST `eventA` and the FIRST `eventB`
|
|
75
|
+
* after it in the stream. Useful for "how many ${X} did the user do between landing
|
|
76
|
+
* and converting" measurements that hooks then condition on.
|
|
77
|
+
*
|
|
78
|
+
* @param {Array<{event:string,time:string|number}>} events
|
|
79
|
+
* @param {string} eventA
|
|
80
|
+
* @param {string} eventB
|
|
81
|
+
* @returns {number} Count, or 0 if either anchor is missing.
|
|
82
|
+
*/
|
|
83
|
+
export function countEventsBetween(events, eventA, eventB) {
|
|
84
|
+
if (!events || !eventA || !eventB) return 0;
|
|
85
|
+
const sorted = sortByTime(events);
|
|
86
|
+
const a = sorted.find(e => e && e.event === eventA);
|
|
87
|
+
if (!a) return 0;
|
|
88
|
+
const aIdx = sorted.indexOf(a);
|
|
89
|
+
const b = sorted.slice(aIdx + 1).find(e => e && e.event === eventB);
|
|
90
|
+
if (!b) return 0;
|
|
91
|
+
const aT = toMs(a.time);
|
|
92
|
+
const bT = toMs(b.time);
|
|
93
|
+
let n = 0;
|
|
94
|
+
for (const ev of sorted) {
|
|
95
|
+
if (!ev || ev.time === undefined) continue;
|
|
96
|
+
const t = toMs(ev.time);
|
|
97
|
+
if (Number.isFinite(t) && t > aT && t < bT) n++;
|
|
98
|
+
}
|
|
99
|
+
return n;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Profile-based cohort check. Returns true if `profile[segmentKey]` matches one of
|
|
104
|
+
* `segmentValues` (array) or equals the single value passed.
|
|
105
|
+
*
|
|
106
|
+
* @param {Object} profile
|
|
107
|
+
* @param {string} segmentKey
|
|
108
|
+
* @param {*|Array<*>} segmentValues
|
|
109
|
+
* @returns {boolean}
|
|
110
|
+
*/
|
|
111
|
+
export function userInProfileSegment(profile, segmentKey, segmentValues) {
|
|
112
|
+
if (!profile || !segmentKey) return false;
|
|
113
|
+
const v = profile[segmentKey];
|
|
114
|
+
if (Array.isArray(segmentValues)) return segmentValues.includes(v);
|
|
115
|
+
return v === segmentValues;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── internal helpers ──
|
|
119
|
+
|
|
120
|
+
function sortByTime(events) {
|
|
121
|
+
const copy = events.slice();
|
|
122
|
+
copy.sort((a, b) => toMs(a && a.time) - toMs(b && b.time));
|
|
123
|
+
return copy;
|
|
124
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook helpers — identity atoms.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the Phase 2 identity primitives so hook authors can re-derive pre-auth /
|
|
5
|
+
* post-auth / stitch info without having to grovel inside `meta`. The `everything`
|
|
6
|
+
* hook already exposes `meta.authTime` and `meta.isPreAuth(event)`; these helpers
|
|
7
|
+
* cover callers that operate on stored events outside that hook.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Returns true if the event happened strictly before the user's stitch event.
|
|
12
|
+
* - `authTime === null | undefined` is interpreted as "user never authed" → every
|
|
13
|
+
* event is pre-auth (matches the `everything` hook's behavior for born-in-dataset
|
|
14
|
+
* users that never converted).
|
|
15
|
+
* - Pre-existing users (already authed before the dataset window) won't have
|
|
16
|
+
* `authTime` populated by the engine; callers wanting "always false" semantics
|
|
17
|
+
* should pass `0` or `-Infinity`.
|
|
18
|
+
*
|
|
19
|
+
* @param {{time: string|number}} event
|
|
20
|
+
* @param {number|null|undefined} authTime - Unix milliseconds.
|
|
21
|
+
* @returns {boolean}
|
|
22
|
+
*/
|
|
23
|
+
export function isPreAuthEvent(event, authTime) {
|
|
24
|
+
if (!event || event.time === undefined || event.time === null) return false;
|
|
25
|
+
if (authTime === null || authTime === undefined) return true;
|
|
26
|
+
const t = typeof event.time === 'number'
|
|
27
|
+
? (event.time > 1e12 ? event.time : event.time * 1000)
|
|
28
|
+
: Date.parse(event.time);
|
|
29
|
+
return Number.isFinite(t) ? t < authTime : false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Partition `events` into pre-auth / post-auth / stitch buckets relative to
|
|
34
|
+
* `authTime`. The stitch is the first post-auth event whose record carries BOTH
|
|
35
|
+
* `user_id` and `device_id` (the engine stamps this exactly once per converted
|
|
36
|
+
* born-in-dataset user). When no such event exists, `stitch` is `null`.
|
|
37
|
+
*
|
|
38
|
+
* @param {Array<{event:string,time:string|number,user_id?:string,device_id?:string}>} events
|
|
39
|
+
* @param {number|null|undefined} authTime - Unix milliseconds.
|
|
40
|
+
* @returns {{ preAuth: Object[], postAuth: Object[], stitch: Object|null }}
|
|
41
|
+
*/
|
|
42
|
+
export function splitByAuth(events, authTime) {
|
|
43
|
+
const result = { preAuth: [], postAuth: [], stitch: null };
|
|
44
|
+
if (!events) return result;
|
|
45
|
+
for (const ev of events) {
|
|
46
|
+
if (isPreAuthEvent(ev, authTime)) {
|
|
47
|
+
result.preAuth.push(ev);
|
|
48
|
+
} else {
|
|
49
|
+
result.postAuth.push(ev);
|
|
50
|
+
if (!result.stitch && ev && ev.user_id && ev.device_id) {
|
|
51
|
+
result.stitch = ev;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @ak--47/dungeon-master/hook-helpers — Phase 3 atom barrel export.
|
|
3
|
+
*
|
|
4
|
+
* Atoms are pure-ish primitives that hooks compose to build trends. The five
|
|
5
|
+
* sub-modules (cohort, mutate, timing, inject, identity) cover the moves Mixpanel
|
|
6
|
+
* analyses need: classify users into bins, scale event counts and property values,
|
|
7
|
+
* adjust timings, splice in cloned events, and reason about pre-auth state.
|
|
8
|
+
*
|
|
9
|
+
* Each atom carries full JSDoc on its definition; see the individual files for
|
|
10
|
+
* the contract details. Patterns (Phase 4, lib/hook-patterns) are higher-level
|
|
11
|
+
* recipes built on top of these atoms.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
binUsersByEventCount,
|
|
16
|
+
binUsersByEventInRange,
|
|
17
|
+
countEventsBetween,
|
|
18
|
+
userInProfileSegment,
|
|
19
|
+
} from './cohort.js';
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
cloneEvent,
|
|
23
|
+
dropEventsWhere,
|
|
24
|
+
scaleEventCount,
|
|
25
|
+
scalePropertyValue,
|
|
26
|
+
shiftEventTime,
|
|
27
|
+
} from './mutate.js';
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
scaleTimingBetween,
|
|
31
|
+
scaleFunnelTTC,
|
|
32
|
+
findFirstSequence,
|
|
33
|
+
} from './timing.js';
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
injectAfterEvent,
|
|
37
|
+
injectBetween,
|
|
38
|
+
injectBurst,
|
|
39
|
+
} from './inject.js';
|
|
40
|
+
|
|
41
|
+
export {
|
|
42
|
+
isPreAuthEvent,
|
|
43
|
+
splitByAuth,
|
|
44
|
+
} from './identity.js';
|
|
@@ -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';
|