@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
package/lib/utils/utils.js
CHANGED
|
@@ -1156,21 +1156,14 @@ CORE
|
|
|
1156
1156
|
*/
|
|
1157
1157
|
|
|
1158
1158
|
//the function which generates $distinct_id + $anonymous_ids, $session_ids, and created, skewing towards the present
|
|
1159
|
-
function generateUser(user_id, opts
|
|
1159
|
+
function generateUser(user_id, opts) {
|
|
1160
1160
|
const chance = getChance();
|
|
1161
|
-
const { numDays, isAnonymous, hasAvatar, hasAnonIds, hasSessionIds, datasetEndUnix } = opts;
|
|
1162
|
-
//
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
// Sine function for a smoother curve
|
|
1166
|
-
const sineValue = (Math.sin(u * Math.PI * frequency - Math.PI / 2) * amplitude + 1) / 2;
|
|
1167
|
-
|
|
1168
|
-
// Scale the sineValue to the range of days
|
|
1169
|
-
let daysAgoBorn = Math.round(sineValue * (numDays - 1)) + 1;
|
|
1170
|
-
|
|
1171
|
-
// Clamp values to ensure they are within the desired range
|
|
1161
|
+
const { numDays, isAnonymous, hasAvatar, hasAnonIds, hasSessionIds, datasetEndUnix, avgDevicePerUser } = opts;
|
|
1162
|
+
// Birth date placement is overridden by bornRecentBias in user-loop.js;
|
|
1163
|
+
// use a simple uniform distribution as the seed value.
|
|
1164
|
+
let daysAgoBorn = Math.round(chance.random() * (numDays - 1)) + 1;
|
|
1172
1165
|
daysAgoBorn = Math.min(daysAgoBorn, numDays);
|
|
1173
|
-
const props = person(user_id, daysAgoBorn, isAnonymous, hasAvatar, hasAnonIds, hasSessionIds, datasetEndUnix);
|
|
1166
|
+
const props = person(user_id, daysAgoBorn, isAnonymous, hasAvatar, hasAnonIds, hasSessionIds, datasetEndUnix, avgDevicePerUser);
|
|
1174
1167
|
|
|
1175
1168
|
const user = {
|
|
1176
1169
|
distinct_id: user_id,
|
|
@@ -1275,9 +1268,13 @@ function TimeSoup(earliestTime, latestTime, peaks = 5, deviation = 2, mean = 0,
|
|
|
1275
1268
|
* @param {boolean} hasAvatar
|
|
1276
1269
|
* @param {boolean} hasAnonIds
|
|
1277
1270
|
* @param {boolean} hasSessionIds
|
|
1271
|
+
* @param {number} [datasetEndUnix]
|
|
1272
|
+
* @param {number} [avgDevicePerUser] - Whole number ≥ 0. When ≥ 1 (and `hasAnonIds: true`),
|
|
1273
|
+
* sets the per-user device pool size. `1` = exactly one device. `>1` = drawn from
|
|
1274
|
+
* `chance.normal({mean: avgDevicePerUser, dev: avgDevicePerUser/2})`, clamped ≥ 1, integer.
|
|
1278
1275
|
* @return {Person}
|
|
1279
1276
|
*/
|
|
1280
|
-
function person(userId, bornDaysAgo = 30, isAnonymous = false, hasAvatar = false, hasAnonIds = false, hasSessionIds = false, datasetEndUnix) {
|
|
1277
|
+
function person(userId, bornDaysAgo = 30, isAnonymous = false, hasAvatar = false, hasAnonIds = false, hasSessionIds = false, datasetEndUnix, avgDevicePerUser) {
|
|
1281
1278
|
const chance = getChance();
|
|
1282
1279
|
//names and photos
|
|
1283
1280
|
const l = chance.letter.bind(chance);
|
|
@@ -1320,9 +1317,25 @@ function person(userId, bornDaysAgo = 30, isAnonymous = false, hasAvatar = false
|
|
|
1320
1317
|
|
|
1321
1318
|
if (!hasAvatar) delete user.avatar;
|
|
1322
1319
|
|
|
1323
|
-
//
|
|
1320
|
+
// Device pool ("anonymousIds" — name preserved for backwards compat).
|
|
1321
|
+
// Phase 2 identity model: pool size is governed by `avgDevicePerUser` when set,
|
|
1322
|
+
// else legacy 2–10 random pool size for any dungeon that just sets `hasAnonIds: true`
|
|
1323
|
+
// without `avgDevicePerUser`. (Validator coerces `hasAnonIds: true` to an effective
|
|
1324
|
+
// `avgDevicePerUser >= 1`, so this branch fires whenever there's a device pool to build.)
|
|
1324
1325
|
if (hasAnonIds) {
|
|
1325
|
-
|
|
1326
|
+
let clusterSize;
|
|
1327
|
+
if (typeof avgDevicePerUser === 'number' && avgDevicePerUser >= 1) {
|
|
1328
|
+
if (avgDevicePerUser === 1) {
|
|
1329
|
+
clusterSize = 1;
|
|
1330
|
+
} else {
|
|
1331
|
+
const sd = avgDevicePerUser / 2;
|
|
1332
|
+
const sample = chance.normal({ mean: avgDevicePerUser, dev: sd });
|
|
1333
|
+
clusterSize = Math.max(1, Math.round(sample));
|
|
1334
|
+
}
|
|
1335
|
+
} else {
|
|
1336
|
+
// Legacy fallback (pre-1.4 behavior): random pool of 2–10 devices.
|
|
1337
|
+
clusterSize = integer(2, 10);
|
|
1338
|
+
}
|
|
1326
1339
|
for (let i = 0; i < clusterSize; i++) {
|
|
1327
1340
|
// Use seeded chance, not ak-tools uid() (which uses Math.random).
|
|
1328
1341
|
const anonId = chance.string({ length: 42, pool: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' });
|
|
@@ -1584,6 +1597,15 @@ function assignSessionIds(events, timeoutMinutes = 30) {
|
|
|
1584
1597
|
return events;
|
|
1585
1598
|
}
|
|
1586
1599
|
|
|
1600
|
+
async function deleteFile(filePath) {
|
|
1601
|
+
if (filePath.startsWith('gs://')) {
|
|
1602
|
+
const { bucket, file } = parseGCSUri(filePath);
|
|
1603
|
+
await storage.bucket(bucket).file(file).delete({ ignoreNotFound: true });
|
|
1604
|
+
} else {
|
|
1605
|
+
await fs.promises.unlink(filePath).catch(() => {});
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1587
1609
|
export {
|
|
1588
1610
|
pick,
|
|
1589
1611
|
date,
|
|
@@ -1638,4 +1660,5 @@ export {
|
|
|
1638
1660
|
assignSessionIds,
|
|
1639
1661
|
bunchIntoSessions,
|
|
1640
1662
|
setDatasetNow,
|
|
1663
|
+
deleteFile,
|
|
1641
1664
|
};
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mixpanel breakdown emulator.
|
|
3
|
+
*
|
|
4
|
+
* Best-effort approximation of the table shapes Mixpanel produces for the five
|
|
5
|
+
* analyses the Phase 4 hook patterns target. Used by `verify-hooks` to assert
|
|
6
|
+
* that engineered patterns actually produce the expected ratios in the data, AND
|
|
7
|
+
* by consumers who want to validate dungeons against expected business shapes
|
|
8
|
+
* outside of Mixpanel.
|
|
9
|
+
*
|
|
10
|
+
* Reference: Mixpanel Insights / Funnels / Flows reports, as of 2026-05.
|
|
11
|
+
*
|
|
12
|
+
* Caveats:
|
|
13
|
+
* - Mixpanel applies its own per-account UTC offset and time-bucketing rules. This
|
|
14
|
+
* emulator uses raw event times unless the breakdown explicitly involves a window.
|
|
15
|
+
* - Mixpanel "users" are typically distinct profiles with at least one event in
|
|
16
|
+
* the date range; this emulator counts unique `user_id` (falling back to
|
|
17
|
+
* `distinct_id`) found across the events array.
|
|
18
|
+
* - This is not bit-exact — it's the *shape* check Phase 4 verification needs.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {Object} EmulateOptions
|
|
23
|
+
* @property {'frequencyByFrequency'|'funnelFrequency'|'aggregatePerUser'|'timeToConvert'|'attributedBy'} type
|
|
24
|
+
*
|
|
25
|
+
* @property {string} [metricEvent]
|
|
26
|
+
* @property {string} [breakdownByFrequencyOf]
|
|
27
|
+
* @property {boolean} [perUser]
|
|
28
|
+
*
|
|
29
|
+
* @property {string[]} [steps]
|
|
30
|
+
*
|
|
31
|
+
* @property {string} [event]
|
|
32
|
+
* @property {string} [property]
|
|
33
|
+
* @property {'avg'|'sum'|'count'|'max'|'min'} [agg]
|
|
34
|
+
*
|
|
35
|
+
* @property {string} [fromEvent]
|
|
36
|
+
* @property {string} [toEvent]
|
|
37
|
+
* @property {string} [breakdownByUserProperty]
|
|
38
|
+
* @property {Array<Object>} [profiles]
|
|
39
|
+
*
|
|
40
|
+
* @property {string} [conversionEvent]
|
|
41
|
+
* @property {string} [attributionEvent]
|
|
42
|
+
* @property {string} [attributionProperty]
|
|
43
|
+
* @property {'firstTouch'|'lastTouch'} [model]
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Run a Mixpanel breakdown emulation against an events array.
|
|
48
|
+
* Routes to the type-specific implementation based on `config.type`.
|
|
49
|
+
*
|
|
50
|
+
* @param {Array<Object>} events
|
|
51
|
+
* @param {EmulateOptions} config
|
|
52
|
+
* @returns {Array<Object>} Breakdown table rows.
|
|
53
|
+
*/
|
|
54
|
+
export function emulateBreakdown(events, config) {
|
|
55
|
+
if (!Array.isArray(events)) throw new Error('emulateBreakdown: events must be an array');
|
|
56
|
+
if (!config || !config.type) throw new Error('emulateBreakdown: config.type is required');
|
|
57
|
+
switch (config.type) {
|
|
58
|
+
case 'frequencyByFrequency': return frequencyByFrequency(events, /** @type {*} */ (config));
|
|
59
|
+
case 'funnelFrequency': return funnelFrequency(events, /** @type {*} */ (config));
|
|
60
|
+
case 'aggregatePerUser': return aggregatePerUser(events, /** @type {*} */ (config));
|
|
61
|
+
case 'timeToConvert': return timeToConvert(events, /** @type {*} */ (config));
|
|
62
|
+
case 'attributedBy': return attributedBy(events, /** @type {*} */ (config));
|
|
63
|
+
default: throw new Error(`emulateBreakdown: unknown type "${config.type}"`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Frequency × Frequency (Insights, Frequency Distribution by per-user count of B) ──
|
|
68
|
+
|
|
69
|
+
function frequencyByFrequency(events, { metricEvent, breakdownByFrequencyOf }) {
|
|
70
|
+
if (!metricEvent || !breakdownByFrequencyOf) {
|
|
71
|
+
throw new Error('frequencyByFrequency requires metricEvent and breakdownByFrequencyOf');
|
|
72
|
+
}
|
|
73
|
+
const userMetric = new Map();
|
|
74
|
+
const userBreakdown = new Map();
|
|
75
|
+
const uids = new Set();
|
|
76
|
+
for (const ev of events) {
|
|
77
|
+
const uid = userIdOf(ev);
|
|
78
|
+
if (!uid) continue;
|
|
79
|
+
uids.add(uid);
|
|
80
|
+
if (ev.event === metricEvent) userMetric.set(uid, (userMetric.get(uid) || 0) + 1);
|
|
81
|
+
if (ev.event === breakdownByFrequencyOf) userBreakdown.set(uid, (userBreakdown.get(uid) || 0) + 1);
|
|
82
|
+
}
|
|
83
|
+
const cell = new Map(); // `${m}|${b}` → user_count
|
|
84
|
+
for (const uid of uids) {
|
|
85
|
+
const m = userMetric.get(uid) || 0;
|
|
86
|
+
const b = userBreakdown.get(uid) || 0;
|
|
87
|
+
const key = `${m}|${b}`;
|
|
88
|
+
cell.set(key, (cell.get(key) || 0) + 1);
|
|
89
|
+
}
|
|
90
|
+
return [...cell.entries()].map(([k, count]) => {
|
|
91
|
+
const [m, b] = k.split('|').map(Number);
|
|
92
|
+
return { metric_freq: m, breakdown_freq: b, user_count: count };
|
|
93
|
+
}).sort((x, y) => x.breakdown_freq - y.breakdown_freq || x.metric_freq - y.metric_freq);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Funnel Frequency Breakdown (Funnel report broken down by per-user count of X) ──
|
|
97
|
+
|
|
98
|
+
function funnelFrequency(events, { steps, breakdownByFrequencyOf }) {
|
|
99
|
+
if (!Array.isArray(steps) || !steps.length) throw new Error('funnelFrequency requires steps[]');
|
|
100
|
+
if (!breakdownByFrequencyOf) throw new Error('funnelFrequency requires breakdownByFrequencyOf');
|
|
101
|
+
const userEvents = groupByUser(events);
|
|
102
|
+
const userBreakdown = new Map();
|
|
103
|
+
for (const [uid, evs] of userEvents) {
|
|
104
|
+
const c = evs.filter(e => e && e.event === breakdownByFrequencyOf).length;
|
|
105
|
+
userBreakdown.set(uid, c);
|
|
106
|
+
}
|
|
107
|
+
const result = [];
|
|
108
|
+
for (let s = 0; s < steps.length; s++) {
|
|
109
|
+
const stepName = steps[s];
|
|
110
|
+
const conversions = new Map(); // breakdown_freq → count
|
|
111
|
+
for (const [uid, evs] of userEvents) {
|
|
112
|
+
const sorted = sortByTime(evs);
|
|
113
|
+
let stepIdx = 0;
|
|
114
|
+
for (const ev of sorted) {
|
|
115
|
+
if (ev.event === steps[stepIdx]) stepIdx++;
|
|
116
|
+
if (stepIdx > s) break;
|
|
117
|
+
}
|
|
118
|
+
if (stepIdx > s) {
|
|
119
|
+
const b = userBreakdown.get(uid) || 0;
|
|
120
|
+
conversions.set(b, (conversions.get(b) || 0) + 1);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const [b, c] of conversions) {
|
|
124
|
+
result.push({ step: stepName, step_index: s, breakdown_freq: b, conversions: c, conversion_pct: 0 });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Conversion % at each step relative to its own breakdown_freq's step-0 baseline.
|
|
128
|
+
const baseline = new Map();
|
|
129
|
+
for (const r of result) {
|
|
130
|
+
if (r.step_index === 0) baseline.set(r.breakdown_freq, r.conversions);
|
|
131
|
+
}
|
|
132
|
+
for (const r of result) {
|
|
133
|
+
const denom = baseline.get(r.breakdown_freq) || 0;
|
|
134
|
+
r.conversion_pct = denom ? (r.conversions / denom) * 100 : 0;
|
|
135
|
+
}
|
|
136
|
+
return result.sort((a, b) => a.step_index - b.step_index || a.breakdown_freq - b.breakdown_freq);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Aggregate per user (Insights, sum/avg of property X by per-user count of B) ──
|
|
140
|
+
|
|
141
|
+
function aggregatePerUser(events, { event, property, agg = 'avg', breakdownByFrequencyOf }) {
|
|
142
|
+
if (!event) throw new Error('aggregatePerUser requires event');
|
|
143
|
+
if (!breakdownByFrequencyOf) throw new Error('aggregatePerUser requires breakdownByFrequencyOf');
|
|
144
|
+
if (agg !== 'count' && !property) throw new Error('aggregatePerUser requires property unless agg is "count"');
|
|
145
|
+
const userVals = new Map();
|
|
146
|
+
const userBreakdown = new Map();
|
|
147
|
+
for (const ev of events) {
|
|
148
|
+
const uid = userIdOf(ev);
|
|
149
|
+
if (!uid) continue;
|
|
150
|
+
if (ev.event === event) {
|
|
151
|
+
// `agg: 'count'` → count occurrences of the event regardless of property type.
|
|
152
|
+
// All other aggs only consider numeric property values.
|
|
153
|
+
if (agg === 'count') {
|
|
154
|
+
if (!userVals.has(uid)) userVals.set(uid, []);
|
|
155
|
+
userVals.get(uid).push(1);
|
|
156
|
+
} else if (property && typeof ev[property] === 'number') {
|
|
157
|
+
if (!userVals.has(uid)) userVals.set(uid, []);
|
|
158
|
+
userVals.get(uid).push(ev[property]);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (ev.event === breakdownByFrequencyOf) userBreakdown.set(uid, (userBreakdown.get(uid) || 0) + 1);
|
|
162
|
+
}
|
|
163
|
+
const userAgg = new Map();
|
|
164
|
+
for (const [uid, vals] of userVals) userAgg.set(uid, applyAgg(vals, agg));
|
|
165
|
+
const buckets = new Map(); // breakdown_freq → [aggregates]
|
|
166
|
+
for (const [uid, v] of userAgg) {
|
|
167
|
+
const b = userBreakdown.get(uid) || 0;
|
|
168
|
+
if (!buckets.has(b)) buckets.set(b, []);
|
|
169
|
+
buckets.get(b).push(v);
|
|
170
|
+
}
|
|
171
|
+
return [...buckets.entries()].map(([b, vs]) => ({
|
|
172
|
+
breakdown_freq: b,
|
|
173
|
+
user_count: vs.length,
|
|
174
|
+
avg_aggregate: vs.reduce((a, x) => a + x, 0) / vs.length,
|
|
175
|
+
})).sort((x, y) => x.breakdown_freq - y.breakdown_freq);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── Time to Convert (Funnel TTC, broken down by user property) ──
|
|
179
|
+
|
|
180
|
+
function timeToConvert(events, { fromEvent, toEvent, breakdownByUserProperty, profiles = [] }) {
|
|
181
|
+
if (!fromEvent || !toEvent) throw new Error('timeToConvert requires fromEvent and toEvent');
|
|
182
|
+
const userEvents = groupByUser(events);
|
|
183
|
+
const profileByUid = new Map();
|
|
184
|
+
for (const p of profiles) {
|
|
185
|
+
if (!p) continue;
|
|
186
|
+
const uid = p.distinct_id || p.user_id;
|
|
187
|
+
if (uid) profileByUid.set(uid, p);
|
|
188
|
+
}
|
|
189
|
+
const buckets = new Map(); // segValue → [ttcMs]
|
|
190
|
+
for (const [uid, evs] of userEvents) {
|
|
191
|
+
const sorted = sortByTime(evs);
|
|
192
|
+
const a = sorted.find(e => e && e.event === fromEvent);
|
|
193
|
+
if (!a) continue;
|
|
194
|
+
const aIdx = sorted.indexOf(a);
|
|
195
|
+
const b = sorted.slice(aIdx + 1).find(e => e && e.event === toEvent);
|
|
196
|
+
if (!b) continue;
|
|
197
|
+
const ttcMs = toMs(b.time) - toMs(a.time);
|
|
198
|
+
if (!Number.isFinite(ttcMs) || ttcMs < 0) continue;
|
|
199
|
+
const profile = profileByUid.get(uid);
|
|
200
|
+
const segValue = breakdownByUserProperty
|
|
201
|
+
? (profile ? (profile[breakdownByUserProperty] ?? 'unknown') : 'unknown')
|
|
202
|
+
: 'all';
|
|
203
|
+
if (!buckets.has(segValue)) buckets.set(segValue, []);
|
|
204
|
+
buckets.get(segValue).push(ttcMs);
|
|
205
|
+
}
|
|
206
|
+
return [...buckets.entries()].map(([seg, ttcs]) => ({
|
|
207
|
+
segment_value: seg,
|
|
208
|
+
user_count: ttcs.length,
|
|
209
|
+
avg_ttc_ms: ttcs.reduce((a, x) => a + x, 0) / ttcs.length,
|
|
210
|
+
median_ttc_ms: median(ttcs),
|
|
211
|
+
})).sort((x, y) => String(x.segment_value).localeCompare(String(y.segment_value)));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Attributed By (first-/last-touch attribution by event property value) ──
|
|
215
|
+
|
|
216
|
+
function attributedBy(events, { conversionEvent, attributionEvent, attributionProperty, model = 'firstTouch' }) {
|
|
217
|
+
if (!conversionEvent || !attributionEvent || !attributionProperty) {
|
|
218
|
+
throw new Error('attributedBy requires conversionEvent, attributionEvent, attributionProperty');
|
|
219
|
+
}
|
|
220
|
+
const userEvents = groupByUser(events);
|
|
221
|
+
const counts = new Map();
|
|
222
|
+
for (const [uid, evs] of userEvents) {
|
|
223
|
+
const sorted = sortByTime(evs);
|
|
224
|
+
const conversion = sorted.find(e => e && e.event === conversionEvent);
|
|
225
|
+
if (!conversion) continue;
|
|
226
|
+
const conversionTime = toMs(conversion.time);
|
|
227
|
+
const touches = sorted.filter(e =>
|
|
228
|
+
e && e.event === attributionEvent && toMs(e.time) <= conversionTime
|
|
229
|
+
);
|
|
230
|
+
if (!touches.length) continue;
|
|
231
|
+
const touch = model === 'lastTouch' ? touches[touches.length - 1] : touches[0];
|
|
232
|
+
const v = touch[attributionProperty] ?? 'unknown';
|
|
233
|
+
counts.set(v, (counts.get(v) || 0) + 1);
|
|
234
|
+
}
|
|
235
|
+
return [...counts.entries()].map(([source, count]) => ({
|
|
236
|
+
attribution_value: source,
|
|
237
|
+
conversions: count,
|
|
238
|
+
})).sort((a, b) => b.conversions - a.conversions);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── shared helpers ──
|
|
242
|
+
|
|
243
|
+
function userIdOf(ev) {
|
|
244
|
+
return ev && (ev.user_id || ev.distinct_id || ev.device_id);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function groupByUser(events) {
|
|
248
|
+
const userEvents = new Map();
|
|
249
|
+
for (const ev of events) {
|
|
250
|
+
const uid = userIdOf(ev);
|
|
251
|
+
if (!uid) continue;
|
|
252
|
+
if (!userEvents.has(uid)) userEvents.set(uid, []);
|
|
253
|
+
userEvents.get(uid).push(ev);
|
|
254
|
+
}
|
|
255
|
+
return userEvents;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function sortByTime(evs) {
|
|
259
|
+
return evs.slice().sort((a, b) => toMs(a && a.time) - toMs(b && b.time));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
import { toMs } from '../hook-helpers/_internal.js';
|
|
263
|
+
|
|
264
|
+
function applyAgg(vals, agg) {
|
|
265
|
+
if (!vals || !vals.length) return 0;
|
|
266
|
+
switch (agg) {
|
|
267
|
+
case 'sum': return vals.reduce((a, b) => a + b, 0);
|
|
268
|
+
case 'count': return vals.length;
|
|
269
|
+
case 'max': return Math.max(...vals);
|
|
270
|
+
case 'min': return Math.min(...vals);
|
|
271
|
+
case 'avg':
|
|
272
|
+
default: return vals.reduce((a, b) => a + b, 0) / vals.length;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function median(arr) {
|
|
277
|
+
if (!arr.length) return 0;
|
|
278
|
+
const sorted = arr.slice().sort((a, b) => a - b);
|
|
279
|
+
const mid = Math.floor(sorted.length / 2);
|
|
280
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
281
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @ak--47/dungeon-master/verify — Phase 4 verification surface.
|
|
3
|
+
*
|
|
4
|
+
* `emulateBreakdown` produces the table shapes Mixpanel shows for the five
|
|
5
|
+
* supported analyses (frequencyByFrequency, funnelFrequency, aggregatePerUser,
|
|
6
|
+
* timeToConvert, attributedBy). `verifyDungeon` is the higher-level wrapper
|
|
7
|
+
* that runs a dungeon and asserts emulator outputs match expected ratios — wire
|
|
8
|
+
* this into your CI to catch dungeon drift.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { emulateBreakdown } from './emulate-breakdown.js';
|
|
12
|
+
export { verifyDungeon } from './verify-dungeon.js';
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* verifyDungeon — run a dungeon (in-memory) and run a series of emulator checks
|
|
3
|
+
* against its output, returning a structured report. Designed for CI use:
|
|
4
|
+
*
|
|
5
|
+
* const report = await verifyDungeon(dungeonConfig, [
|
|
6
|
+
* {
|
|
7
|
+
* name: 'engaged users do 2x purchases',
|
|
8
|
+
* breakdown: { type: 'frequencyByFrequency',
|
|
9
|
+
* metricEvent: 'Purchase', breakdownByFrequencyOf: 'Browse' },
|
|
10
|
+
* assert: (rows) => {
|
|
11
|
+
* // custom assertion against the emulator's output table
|
|
12
|
+
* return { pass: true, detail: 'looks good' };
|
|
13
|
+
* }
|
|
14
|
+
* }
|
|
15
|
+
* ]);
|
|
16
|
+
* if (!report.pass) process.exit(1);
|
|
17
|
+
*
|
|
18
|
+
* The dungeon is run via the same default-export entry point external consumers
|
|
19
|
+
* use, so this is a true end-to-end check.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import DUNGEON_MASTER from '../../index.js';
|
|
23
|
+
import { emulateBreakdown } from './emulate-breakdown.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {Object} VerifyCheck
|
|
27
|
+
* @property {string} name - Human-readable name for the check.
|
|
28
|
+
* @property {Object} breakdown - Argument passed to `emulateBreakdown`.
|
|
29
|
+
* @property {(rows: Array<Object>, ctx: { events: Array<Object>, profiles: Array<Object> }) => { pass: boolean, detail?: string }} assert
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {Object} config - Dungeon config (or path; passed straight to DUNGEON_MASTER).
|
|
34
|
+
* @param {VerifyCheck[]} checks
|
|
35
|
+
* @returns {Promise<{ pass: boolean, results: Array<{ name: string, pass: boolean, detail?: string, rows?: Array<Object> }> }>}
|
|
36
|
+
*/
|
|
37
|
+
export async function verifyDungeon(config, checks) {
|
|
38
|
+
if (!checks || !checks.length) throw new Error('verifyDungeon: at least one check required');
|
|
39
|
+
let result = await DUNGEON_MASTER(config);
|
|
40
|
+
if (Array.isArray(result)) result = result[0];
|
|
41
|
+
const events = Array.isArray(result.eventData) ? result.eventData : Array.from(result.eventData);
|
|
42
|
+
const profiles = Array.isArray(result.userProfilesData) ? result.userProfilesData : Array.from(result.userProfilesData);
|
|
43
|
+
const ctx = { events, profiles };
|
|
44
|
+
const results = [];
|
|
45
|
+
for (const check of checks) {
|
|
46
|
+
try {
|
|
47
|
+
const breakdownArgs = { ...check.breakdown };
|
|
48
|
+
// timeToConvert + attributedBy may want profiles; auto-inject if not provided.
|
|
49
|
+
if (breakdownArgs.type === 'timeToConvert' && !breakdownArgs.profiles) {
|
|
50
|
+
breakdownArgs.profiles = profiles;
|
|
51
|
+
}
|
|
52
|
+
const rows = emulateBreakdown(events, breakdownArgs);
|
|
53
|
+
const verdict = check.assert(rows, ctx);
|
|
54
|
+
results.push({ name: check.name, pass: !!verdict.pass, detail: verdict.detail, rows });
|
|
55
|
+
} catch (err) {
|
|
56
|
+
results.push({ name: check.name, pass: false, detail: `error: ${err.message}` });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const pass = results.every(r => r.pass);
|
|
60
|
+
return { pass, results };
|
|
61
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ak--47/dungeon-master",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "generate fancy datasets",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
"types": "./types.d.ts"
|
|
12
12
|
},
|
|
13
13
|
"./utils": "./lib/utils/utils.js",
|
|
14
|
-
"./text": "./lib/generators/text.js"
|
|
14
|
+
"./text": "./lib/generators/text.js",
|
|
15
|
+
"./hook-helpers": "./lib/hook-helpers/index.js",
|
|
16
|
+
"./hook-patterns": "./lib/hook-patterns/index.js",
|
|
17
|
+
"./verify": "./lib/verify/index.js"
|
|
15
18
|
},
|
|
16
19
|
"files": [
|
|
17
20
|
"index.js",
|
|
@@ -21,7 +24,6 @@
|
|
|
21
24
|
"!dungeons/customers/",
|
|
22
25
|
"!dungeons/user/",
|
|
23
26
|
"scripts/",
|
|
24
|
-
"!scripts/experiments/",
|
|
25
27
|
"package.json",
|
|
26
28
|
"README.md",
|
|
27
29
|
"CHANGELOG.md"
|
|
@@ -72,7 +74,7 @@
|
|
|
72
74
|
"dotenv": "^16.4.5",
|
|
73
75
|
"hyparquet-writer": "^0.6.1",
|
|
74
76
|
"mixpanel": "^0.18.0",
|
|
75
|
-
"mixpanel-import": "^3.
|
|
77
|
+
"mixpanel-import": "^3.3.1",
|
|
76
78
|
"p-limit": "^3.1.0",
|
|
77
79
|
"pino": "^9.0.0",
|
|
78
80
|
"pino-pretty": "^11.0.0",
|