@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.
Files changed (51) 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/lib/core/config-validator.js +136 -157
  30. package/lib/generators/events.js +49 -93
  31. package/lib/generators/funnels.js +202 -91
  32. package/lib/hook-helpers/_internal.js +23 -0
  33. package/lib/hook-helpers/cohort.js +124 -0
  34. package/lib/hook-helpers/identity.js +56 -0
  35. package/lib/hook-helpers/index.js +44 -0
  36. package/lib/hook-helpers/inject.js +99 -0
  37. package/lib/hook-helpers/mutate.js +151 -0
  38. package/lib/hook-helpers/timing.js +99 -0
  39. package/lib/hook-patterns/aggregate-per-user-by-bin.js +38 -0
  40. package/lib/hook-patterns/attributed-by-source.js +72 -0
  41. package/lib/hook-patterns/frequency-by-frequency.js +46 -0
  42. package/lib/hook-patterns/funnel-frequency-breakdown.js +73 -0
  43. package/lib/hook-patterns/index.js +14 -0
  44. package/lib/hook-patterns/time-to-convert-by-segment.js +41 -0
  45. package/lib/orchestrators/user-loop.js +119 -269
  46. package/lib/utils/utils.js +29 -16
  47. package/lib/verify/emulate-breakdown.js +281 -0
  48. package/lib/verify/index.js +12 -0
  49. package/lib/verify/verify-dungeon.js +61 -0
  50. package/package.json +6 -4
  51. 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';