@ak--47/dungeon-master 1.4.5 → 1.5.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/.claude/skills/analyze-soup/SKILL.md +158 -0
- package/.claude/skills/create-dungeon/SKILL.md +464 -0
- package/.claude/skills/verify-dungeon/SKILL.md +157 -0
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +161 -0
- package/.claude/skills/verify-dungeon/references/report-format.md +216 -0
- package/.claude/skills/verify-dungeon/references/sql-recipes.md +857 -0
- package/.claude/skills/write-hooks/SKILL.md +468 -0
- package/CHANGELOG.md +182 -0
- package/HOOKS.md +1256 -597
- package/README.md +140 -5
- package/dungeons/technical/ad-spend.js +41 -49
- package/dungeons/technical/anonymous-users.js +38 -36
- package/dungeons/technical/array-of-object-lookup.js +136 -153
- package/dungeons/technical/datagen-v15-verify.js +87 -0
- package/dungeons/technical/experiments.js +42 -40
- package/dungeons/technical/foobar.js +114 -118
- package/dungeons/technical/group-analytics.js +42 -40
- package/dungeons/technical/hook-helpers-verify.js +69 -50
- package/dungeons/technical/identity-model-verify.js +22 -12
- package/dungeons/technical/mirror-strategies.js +37 -39
- package/dungeons/technical/nested-objects.js +119 -118
- package/dungeons/technical/pattern-aggregate-by-bin.js +21 -8
- package/dungeons/technical/pattern-attributed-by-source.js +23 -9
- package/dungeons/technical/pattern-frequency-by-frequency.js +21 -8
- package/dungeons/technical/pattern-funnel-frequency.js +30 -15
- package/dungeons/technical/pattern-ttc-by-segment.js +21 -8
- package/dungeons/technical/retention-cadence.js +115 -112
- package/dungeons/technical/sanity.js +86 -80
- package/dungeons/technical/scale-test.js +34 -38
- package/dungeons/technical/scd.js +111 -128
- package/dungeons/technical/simple.js +134 -141
- package/dungeons/technical/simplest.js +111 -65
- package/dungeons/technical/text-generation.js +110 -146
- package/dungeons/vertical/ai-platform.js +300 -333
- package/dungeons/vertical/community.js +290 -255
- package/dungeons/vertical/crypto.js +400 -391
- package/dungeons/vertical/dating.js +421 -375
- package/dungeons/vertical/devtools.js +346 -298
- package/dungeons/vertical/ecommerce.js +322 -394
- package/dungeons/vertical/education.js +380 -325
- package/dungeons/vertical/fintech.js +371 -325
- package/dungeons/vertical/fitness.js +345 -291
- package/dungeons/vertical/food-delivery.js +352 -307
- package/dungeons/vertical/gaming.js +490 -444
- package/dungeons/vertical/healthcare.js +311 -262
- package/dungeons/vertical/insurance-application.js +437 -409
- package/dungeons/vertical/logistics.js +278 -252
- package/dungeons/vertical/marketplace.js +340 -323
- package/dungeons/vertical/media.js +390 -335
- package/dungeons/vertical/real-estate.js +402 -347
- package/dungeons/vertical/sass.js +331 -333
- package/dungeons/vertical/social.js +377 -316
- package/dungeons/vertical/travel.js +302 -295
- package/index.js +64 -7
- package/lib/core/config-validator.js +378 -17
- package/lib/core/dungeon-loader.js +2 -5
- package/lib/generators/events.js +12 -13
- package/lib/generators/funnels.js +76 -2
- package/lib/hook-helpers/index.js +1 -0
- package/lib/hook-helpers/inject.js +95 -0
- package/lib/orchestrators/mixpanel-sender.js +7 -0
- package/lib/orchestrators/user-loop.js +598 -48
- package/lib/templates/defaults.js +59 -59
- package/lib/templates/macro-presets.js +53 -11
- package/lib/utils/dataset-context.js +103 -0
- package/lib/utils/retention-curve.js +140 -0
- package/lib/utils/utils.js +157 -109
- package/lib/verify/counting.js +360 -0
- package/lib/verify/emulate-breakdown.js +531 -108
- package/lib/verify/funnel-engine.js +539 -0
- package/lib/verify/identity.js +78 -0
- package/lib/verify/index.js +20 -0
- package/lib/verify/schema-validator.js +3 -1
- package/lib/verify/verify-dungeon.js +58 -0
- package/package.json +14 -3
- package/scripts/run-dungeon.mjs +12 -1
- package/types.d.ts +353 -4
- package/scripts/smoke-test-all.mjs +0 -162
|
@@ -4,14 +4,11 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import path from 'path';
|
|
7
|
+
import os from 'os';
|
|
7
8
|
import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync } from 'fs';
|
|
8
|
-
import { fileURLToPath } from 'url';
|
|
9
9
|
import { randomBytes } from 'crypto';
|
|
10
10
|
import Chance from 'chance';
|
|
11
11
|
|
|
12
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
-
const PACKAGE_ROOT = path.resolve(__dirname, '..', '..');
|
|
14
|
-
|
|
15
12
|
/**
|
|
16
13
|
* Detect what kind of input was passed and normalize it
|
|
17
14
|
* @param {any} input - The raw input to DUNGEON_MASTER
|
|
@@ -221,7 +218,7 @@ function reviveFunctionObject(obj) {
|
|
|
221
218
|
* @returns {Promise<import('../../types').Dungeon>}
|
|
222
219
|
*/
|
|
223
220
|
export async function loadFromText(code) {
|
|
224
|
-
const tmpDir = path.join(
|
|
221
|
+
const tmpDir = path.join(os.tmpdir(), 'dungeon-master');
|
|
225
222
|
const tmpId = randomBytes(8).toString('hex');
|
|
226
223
|
const tmpFile = path.join(tmpDir, `dungeon-${tmpId}.mjs`);
|
|
227
224
|
|
package/lib/generators/events.js
CHANGED
|
@@ -92,30 +92,29 @@ export async function makeEvent(
|
|
|
92
92
|
defaultProps.browser = u.choose(defaults.browsers());
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
? (chosenEvent.isAttributionEvent && chance.bool({ likelihood: 25 }))
|
|
101
|
-
: chance.bool({ likelihood: 25 });
|
|
102
|
-
if (shouldStamp) {
|
|
103
|
-
defaultProps.campaigns = u.pickRandom(defaults.campaigns());
|
|
104
|
-
}
|
|
105
|
-
}
|
|
95
|
+
// v1.5: UTM stamping moved to per-user post-generation pass in user-loop.js
|
|
96
|
+
// (`applyTouchpointCap`). The pass identifies eligible events (per
|
|
97
|
+
// `isAttributionEvent` flag presence), samples up to `maxTouchpointsPerUser`
|
|
98
|
+
// (default 10) across the user's lifetime, and stamps UTMs on the sample.
|
|
99
|
+
// Stamping here per-event would defeat the lifetime-distributed sampling.
|
|
106
100
|
|
|
107
101
|
// PERFORMANCE: Use pre-computed device pool instead of rebuilding every time
|
|
108
102
|
if (defaults.allDevices.length) {
|
|
109
103
|
defaultProps.device = u.pickRandom(defaults.allDevices);
|
|
110
104
|
}
|
|
111
105
|
|
|
112
|
-
// Set event time using TimeSoup for realistic distribution
|
|
106
|
+
// Set event time using TimeSoup for realistic distribution.
|
|
107
|
+
// v1.5: active-day mode passes `featureCtx.latestTime` to constrain TimeSoup
|
|
108
|
+
// to a specific UTC day. When unset, defaults to FIXED_NOW (legacy behavior).
|
|
113
109
|
if (earliestTime) {
|
|
114
110
|
let unixTime;
|
|
115
111
|
if (isFirstEvent) {
|
|
116
112
|
unixTime = earliestTime;
|
|
117
113
|
} else {
|
|
118
|
-
|
|
114
|
+
const latestTime = (featureCtx && Number.isFinite(featureCtx.latestTime))
|
|
115
|
+
? featureCtx.latestTime
|
|
116
|
+
: context.FIXED_NOW;
|
|
117
|
+
unixTime = u.TimeSoup(earliestTime, latestTime, peaks, deviation, mean, dayOfWeekWeights, hourOfDayWeights);
|
|
119
118
|
}
|
|
120
119
|
eventTemplate.time = dayjs.unix(unixTime).toISOString();
|
|
121
120
|
}
|
|
@@ -249,19 +249,42 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
|
|
|
249
249
|
numStepsUserWillTake
|
|
250
250
|
);
|
|
251
251
|
|
|
252
|
+
// v1.5: cap the funnel's total span at conversionWindowDays * 86400000 - 1 ms
|
|
253
|
+
// (1ms slack to clear Mixpanel's strict-`<` boundary in conversion_window.cpp).
|
|
254
|
+
// When a funnel's `timeToConvert` would push the last step past the window, scale
|
|
255
|
+
// all relative offsets proportionally to fit. Validator already auto-bumped
|
|
256
|
+
// `conversionWindowDays` for long-TTC funnels, so this rarely fires — but it
|
|
257
|
+
// makes the contract explicit at generation time.
|
|
258
|
+
const conversionWindowDays = funnel.conversionWindowDays;
|
|
259
|
+
if (Number.isFinite(conversionWindowDays) && conversionWindowDays > 0 && funnelEventsWithTiming.length > 1) {
|
|
260
|
+
const maxSpanMs = conversionWindowDays * 86400000 - 1;
|
|
261
|
+
const lastEvent = funnelEventsWithTiming[funnelEventsWithTiming.length - 1];
|
|
262
|
+
if (Number.isFinite(lastEvent.relativeTimeMs) && lastEvent.relativeTimeMs > maxSpanMs) {
|
|
263
|
+
const scale = maxSpanMs / lastEvent.relativeTimeMs;
|
|
264
|
+
for (let i = 1; i < funnelEventsWithTiming.length; i++) {
|
|
265
|
+
if (Number.isFinite(funnelEventsWithTiming[i].relativeTimeMs)) {
|
|
266
|
+
funnelEventsWithTiming[i].relativeTimeMs = Math.floor(funnelEventsWithTiming[i].relativeTimeMs * scale);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
252
272
|
// Add session start event if configured (clone to avoid mutating shared config)
|
|
253
273
|
if (sessionStartEvents.length) {
|
|
254
274
|
const sessionStartEvent = { ...chance.pickone(sessionStartEvents), relativeTimeMs: -15000 };
|
|
255
275
|
funnelEventsWithTiming.push(sessionStartEvent);
|
|
256
276
|
}
|
|
257
277
|
|
|
258
|
-
// Build complete feature context: merge passed-in featureCtx with config fallbacks
|
|
278
|
+
// Build complete feature context: merge passed-in featureCtx with config fallbacks.
|
|
279
|
+
// v1.5: preserve `latestTime` if active-day mode is in effect, so makeEvent's
|
|
280
|
+
// TimeSoup constrains the funnel's first event to the picked day.
|
|
259
281
|
const funnelFeatureCtx = {
|
|
260
282
|
persona: featureCtx.persona || persona || null,
|
|
261
283
|
userCampaign: featureCtx.userCampaign || null,
|
|
262
284
|
userLocation: featureCtx.userLocation || null,
|
|
263
285
|
worldEventsTimeline: featureCtx.worldEventsTimeline || context.config.worldEvents || null,
|
|
264
286
|
dataQuality: featureCtx.dataQuality || context.config.dataQuality || null,
|
|
287
|
+
latestTime: Number.isFinite(featureCtx.latestTime) ? featureCtx.latestTime : undefined,
|
|
265
288
|
};
|
|
266
289
|
|
|
267
290
|
// Pre-compute per-step stamping modes for execution order. For isFirstFunnel + isBorn
|
|
@@ -302,10 +325,61 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
|
|
|
302
325
|
);
|
|
303
326
|
|
|
304
327
|
// Compute the auth-time of the actual stitch event in execution order, if any.
|
|
305
|
-
|
|
328
|
+
// Skip when the stitch event itself is _drop'd (e.g. born-late users whose auth
|
|
329
|
+
// event lands past FIXED_NOW and gets filtered out): the user has no real auth
|
|
330
|
+
// event, so userAuthTimeMs must stay null to keep downstream stamping consistent.
|
|
331
|
+
const authTimeMs = runAuthExecIdx >= 0 && finalEvents[runAuthExecIdx] && !finalEvents[runAuthExecIdx]._drop
|
|
306
332
|
? Date.parse(finalEvents[runAuthExecIdx].time) || null
|
|
307
333
|
: null;
|
|
308
334
|
|
|
335
|
+
// v1.5.0: inject exclusion events for non-converters. When `funnel.exclusionEvents`
|
|
336
|
+
// is set and the user dropped off mid-funnel (≥1 step completed but < sequence.length),
|
|
337
|
+
// stamp 1-2 cloned events bearing one of the listed exclusion event names between the
|
|
338
|
+
// last completed step and where the next step would have been. The verifier reads
|
|
339
|
+
// `funnel.exclusionEvents` and applies them as exclusionSteps to terminate the attempt.
|
|
340
|
+
//
|
|
341
|
+
// Schema-first: copy ONLY identity + super props + group keys from the source event,
|
|
342
|
+
// plus props declared on the exclusion event's own config. Source-event-specific
|
|
343
|
+
// props (e.g. `cart_value` on `Add to Cart`) MUST NOT bleed onto a different event
|
|
344
|
+
// type or the schema validator will flag undeclared columns.
|
|
345
|
+
if (Array.isArray(funnel.exclusionEvents) && funnel.exclusionEvents.length
|
|
346
|
+
&& !doesUserConvert && finalEvents.length > 0 && finalEvents.length < sequence.length) {
|
|
347
|
+
const lastEvent = finalEvents[finalEvents.length - 1];
|
|
348
|
+
const lastTimeMs = Date.parse(lastEvent.time);
|
|
349
|
+
if (Number.isFinite(lastTimeMs)) {
|
|
350
|
+
const IDENTITY_KEYS = ['user_id', 'device_id', 'distinct_id', 'session_id', 'insert_id'];
|
|
351
|
+
const superPropKeys = Object.keys(superProps || {});
|
|
352
|
+
const groupKeyNames = (groupKeys || []).map(gk => Array.isArray(gk) ? gk[0] : gk).filter(Boolean);
|
|
353
|
+
const numToInject = chance.integer({ min: 1, max: 2 });
|
|
354
|
+
for (let i = 0; i < numToInject; i++) {
|
|
355
|
+
const excName = chance.pickone(funnel.exclusionEvents);
|
|
356
|
+
const excConfig = (config.events || []).find(e => e.event === excName);
|
|
357
|
+
const offsetMs = (i + 1) * chance.integer({ min: 30_000, max: 300_000 });
|
|
358
|
+
const cloned = {
|
|
359
|
+
event: excName,
|
|
360
|
+
time: new Date(lastTimeMs + offsetMs).toISOString(),
|
|
361
|
+
};
|
|
362
|
+
// Identity from source event (correct user/device/session attribution).
|
|
363
|
+
for (const k of IDENTITY_KEYS) {
|
|
364
|
+
if (k in lastEvent) cloned[k] = lastEvent[k];
|
|
365
|
+
}
|
|
366
|
+
// Super props + group keys carry over (user-stable values).
|
|
367
|
+
for (const k of superPropKeys) if (k in lastEvent) cloned[k] = lastEvent[k];
|
|
368
|
+
for (const k of groupKeyNames) if (k in lastEvent) cloned[k] = lastEvent[k];
|
|
369
|
+
// Resolve declared props on the exclusion event's config.
|
|
370
|
+
if (excConfig && excConfig.properties) {
|
|
371
|
+
for (const k of Object.keys(excConfig.properties)) {
|
|
372
|
+
try { cloned[k] = u.choose(excConfig.properties[k]); }
|
|
373
|
+
catch (e) { cloned[k] = null; }
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// Fresh insert_id so the new event isn't a duplicate of the source.
|
|
377
|
+
cloned.insert_id = `${excName}-${cloned.time}-${chance.string({ length: 10, alpha: true })}`;
|
|
378
|
+
finalEvents.push(cloned);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
309
383
|
// Call post-funnel hook
|
|
310
384
|
await hook(finalEvents, "funnel-post", {
|
|
311
385
|
user, profile, scd, funnel, config,
|
|
@@ -97,3 +97,98 @@ export function injectBurst(events, templateEvent, count, anchorTime, spreadMs,
|
|
|
97
97
|
return created;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
const DAY_MS = 86400000;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Inject cloned events on days where the user had no activity for this event
|
|
104
|
+
* type. Increases distinct-day frequency without disturbing existing event
|
|
105
|
+
* ordering or inflating same-day counts.
|
|
106
|
+
*
|
|
107
|
+
* Designed for use in `everything` hooks to move users between frequency bins
|
|
108
|
+
* in Mixpanel's frequency distribution reports — which count DISTINCT DAYS
|
|
109
|
+
* (not total events). See `lib/verify/counting.js#countDistinctPeriods` and
|
|
110
|
+
* `mixpanel/analytics` `addiction_query.cpp` for the counting rule.
|
|
111
|
+
*
|
|
112
|
+
* Behavior:
|
|
113
|
+
* 1. Collect all existing distinct UTC days for `eventName`.
|
|
114
|
+
* 2. If the user already has >= `targetDays` distinct days, return unchanged.
|
|
115
|
+
* 3. Restrict candidate days to the user's active window (first event time
|
|
116
|
+
* to last event time, by default).
|
|
117
|
+
* 4. Pick `targetDays - existing` random days that have no `eventName`
|
|
118
|
+
* activity (uses seeded RNG).
|
|
119
|
+
* 5. Find a template event of `eventName`; if none exist for this user,
|
|
120
|
+
* return unchanged (we honor schema-first: don't fabricate events).
|
|
121
|
+
* 6. Clone the template onto each picked day at a random hour within the
|
|
122
|
+
* day. `insert_id` is stripped (Mixpanel re-deduplicates on import).
|
|
123
|
+
* 7. Append clones to the array. Caller's downstream sort handles ordering.
|
|
124
|
+
*
|
|
125
|
+
* @param {Object[]} events - Full user event array (from `everything` hook).
|
|
126
|
+
* @param {string} eventName - Event name to inject.
|
|
127
|
+
* @param {number} targetDays - Desired absolute count of distinct active days.
|
|
128
|
+
* @param {Object} [options]
|
|
129
|
+
* @param {('active')} [options.timeRange='active'] - 'active' = user's
|
|
130
|
+
* first-to-last event window. Reserved for future range modes.
|
|
131
|
+
* @param {Object} [options.overrides] - Property overrides applied via spread
|
|
132
|
+
* on top of the cloned template.
|
|
133
|
+
* @returns {Object[]} The (possibly mutated) events array. Same reference as
|
|
134
|
+
* the input — convenient to return from an `everything` hook.
|
|
135
|
+
*/
|
|
136
|
+
export function injectOnNewDays(events, eventName, targetDays, options = {}) {
|
|
137
|
+
if (!Array.isArray(events) || !eventName || typeof targetDays !== 'number' || targetDays <= 0) return events;
|
|
138
|
+
const overrides = options.overrides || {};
|
|
139
|
+
|
|
140
|
+
// Collect timestamps for the named event + global window for the user.
|
|
141
|
+
const matches = [];
|
|
142
|
+
const allTimes = [];
|
|
143
|
+
let template = null;
|
|
144
|
+
for (const ev of events) {
|
|
145
|
+
if (!ev) continue;
|
|
146
|
+
const t = toMs(ev.time);
|
|
147
|
+
if (Number.isFinite(t)) allTimes.push(t);
|
|
148
|
+
if (ev.event === eventName && Number.isFinite(t)) {
|
|
149
|
+
matches.push(t);
|
|
150
|
+
template = template || ev;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (!template) return events;
|
|
154
|
+
if (!allTimes.length) return events;
|
|
155
|
+
|
|
156
|
+
// Distinct UTC days that already have an event of `eventName`.
|
|
157
|
+
const existingDays = new Set();
|
|
158
|
+
for (const t of matches) existingDays.add(Math.floor(t / DAY_MS));
|
|
159
|
+
if (existingDays.size >= targetDays) return events;
|
|
160
|
+
|
|
161
|
+
const minMs = Math.min(...allTimes);
|
|
162
|
+
const maxMs = Math.max(...allTimes);
|
|
163
|
+
const firstDay = Math.floor(minMs / DAY_MS);
|
|
164
|
+
const lastDay = Math.floor(maxMs / DAY_MS);
|
|
165
|
+
|
|
166
|
+
const candidateDays = [];
|
|
167
|
+
for (let d = firstDay; d <= lastDay; d++) {
|
|
168
|
+
if (!existingDays.has(d)) candidateDays.push(d);
|
|
169
|
+
}
|
|
170
|
+
const need = targetDays - existingDays.size;
|
|
171
|
+
if (candidateDays.length === 0 || need <= 0) return events;
|
|
172
|
+
|
|
173
|
+
const chance = getChance();
|
|
174
|
+
// Use chance.pickset for a unique-without-replacement sample from the
|
|
175
|
+
// candidate day pool. If `need` exceeds candidates, fall back to all.
|
|
176
|
+
const pickCount = Math.min(need, candidateDays.length);
|
|
177
|
+
const picked = chance.pickset(candidateDays, pickCount);
|
|
178
|
+
|
|
179
|
+
for (const day of picked) {
|
|
180
|
+
// Pick a random ms within this UTC day. Clamp to active window.
|
|
181
|
+
const dayStart = day * DAY_MS;
|
|
182
|
+
const dayEnd = dayStart + DAY_MS - 1;
|
|
183
|
+
const lo = Math.max(dayStart, minMs);
|
|
184
|
+
const hi = Math.min(dayEnd, maxMs);
|
|
185
|
+
const newMs = lo >= hi ? lo : chance.integer({ min: lo, max: hi });
|
|
186
|
+
const clone = { ...template, ...overrides };
|
|
187
|
+
writeTime(clone, newMs);
|
|
188
|
+
delete clone.insert_id;
|
|
189
|
+
events.push(clone);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return events;
|
|
193
|
+
}
|
|
194
|
+
|
|
@@ -110,10 +110,17 @@ export async function sendToMixpanel(context) {
|
|
|
110
110
|
const files = userProfilesData.getWrittenFiles();
|
|
111
111
|
if (files.length > 0) userProfilesToImport = files;
|
|
112
112
|
}
|
|
113
|
+
// v1.5.1: skip `_drop` profiles (anonymous non-converters — see user-loop.js).
|
|
114
|
+
// In-memory path: filter the array. Disk path: `transformFunc` filters
|
|
115
|
+
// per-record as mixpanel-import streams the files.
|
|
116
|
+
if (Array.isArray(userProfilesToImport)) {
|
|
117
|
+
userProfilesToImport = userProfilesToImport.filter(p => !p || !p._drop);
|
|
118
|
+
}
|
|
113
119
|
const userTotal = Array.isArray(userProfilesToImport) ? userProfilesToImport.length : 0;
|
|
114
120
|
const imported = await mp(creds, userProfilesToImport, {
|
|
115
121
|
recordType: "user",
|
|
116
122
|
...commonOpts,
|
|
123
|
+
transformFunc: (record) => (record && record._drop) ? {} : record,
|
|
117
124
|
progressCallback: makeProgressCallback(userTotal),
|
|
118
125
|
});
|
|
119
126
|
log(` -> ${comma(imported.success)} user profiles sent\n`);
|