@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.
Files changed (78) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +158 -0
  2. package/.claude/skills/create-dungeon/SKILL.md +464 -0
  3. package/.claude/skills/verify-dungeon/SKILL.md +157 -0
  4. package/.claude/skills/verify-dungeon/references/counting-semantics.md +161 -0
  5. package/.claude/skills/verify-dungeon/references/report-format.md +216 -0
  6. package/.claude/skills/verify-dungeon/references/sql-recipes.md +857 -0
  7. package/.claude/skills/write-hooks/SKILL.md +468 -0
  8. package/CHANGELOG.md +182 -0
  9. package/HOOKS.md +1256 -597
  10. package/README.md +140 -5
  11. package/dungeons/technical/ad-spend.js +41 -49
  12. package/dungeons/technical/anonymous-users.js +38 -36
  13. package/dungeons/technical/array-of-object-lookup.js +136 -153
  14. package/dungeons/technical/datagen-v15-verify.js +87 -0
  15. package/dungeons/technical/experiments.js +42 -40
  16. package/dungeons/technical/foobar.js +114 -118
  17. package/dungeons/technical/group-analytics.js +42 -40
  18. package/dungeons/technical/hook-helpers-verify.js +69 -50
  19. package/dungeons/technical/identity-model-verify.js +22 -12
  20. package/dungeons/technical/mirror-strategies.js +37 -39
  21. package/dungeons/technical/nested-objects.js +119 -118
  22. package/dungeons/technical/pattern-aggregate-by-bin.js +21 -8
  23. package/dungeons/technical/pattern-attributed-by-source.js +23 -9
  24. package/dungeons/technical/pattern-frequency-by-frequency.js +21 -8
  25. package/dungeons/technical/pattern-funnel-frequency.js +30 -15
  26. package/dungeons/technical/pattern-ttc-by-segment.js +21 -8
  27. package/dungeons/technical/retention-cadence.js +115 -112
  28. package/dungeons/technical/sanity.js +86 -80
  29. package/dungeons/technical/scale-test.js +34 -38
  30. package/dungeons/technical/scd.js +111 -128
  31. package/dungeons/technical/simple.js +134 -141
  32. package/dungeons/technical/simplest.js +111 -65
  33. package/dungeons/technical/text-generation.js +110 -146
  34. package/dungeons/vertical/ai-platform.js +300 -333
  35. package/dungeons/vertical/community.js +290 -255
  36. package/dungeons/vertical/crypto.js +400 -391
  37. package/dungeons/vertical/dating.js +421 -375
  38. package/dungeons/vertical/devtools.js +346 -298
  39. package/dungeons/vertical/ecommerce.js +322 -394
  40. package/dungeons/vertical/education.js +380 -325
  41. package/dungeons/vertical/fintech.js +371 -325
  42. package/dungeons/vertical/fitness.js +345 -291
  43. package/dungeons/vertical/food-delivery.js +352 -307
  44. package/dungeons/vertical/gaming.js +490 -444
  45. package/dungeons/vertical/healthcare.js +311 -262
  46. package/dungeons/vertical/insurance-application.js +437 -409
  47. package/dungeons/vertical/logistics.js +278 -252
  48. package/dungeons/vertical/marketplace.js +340 -323
  49. package/dungeons/vertical/media.js +390 -335
  50. package/dungeons/vertical/real-estate.js +402 -347
  51. package/dungeons/vertical/sass.js +331 -333
  52. package/dungeons/vertical/social.js +377 -316
  53. package/dungeons/vertical/travel.js +302 -295
  54. package/index.js +64 -7
  55. package/lib/core/config-validator.js +378 -17
  56. package/lib/core/dungeon-loader.js +2 -5
  57. package/lib/generators/events.js +12 -13
  58. package/lib/generators/funnels.js +76 -2
  59. package/lib/hook-helpers/index.js +1 -0
  60. package/lib/hook-helpers/inject.js +95 -0
  61. package/lib/orchestrators/mixpanel-sender.js +7 -0
  62. package/lib/orchestrators/user-loop.js +598 -48
  63. package/lib/templates/defaults.js +59 -59
  64. package/lib/templates/macro-presets.js +53 -11
  65. package/lib/utils/dataset-context.js +103 -0
  66. package/lib/utils/retention-curve.js +140 -0
  67. package/lib/utils/utils.js +157 -109
  68. package/lib/verify/counting.js +360 -0
  69. package/lib/verify/emulate-breakdown.js +531 -108
  70. package/lib/verify/funnel-engine.js +539 -0
  71. package/lib/verify/identity.js +78 -0
  72. package/lib/verify/index.js +20 -0
  73. package/lib/verify/schema-validator.js +3 -1
  74. package/lib/verify/verify-dungeon.js +58 -0
  75. package/package.json +14 -3
  76. package/scripts/run-dungeon.mjs +12 -1
  77. package/types.d.ts +353 -4
  78. 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(PACKAGE_ROOT, '.dungeon-tmp');
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
 
@@ -92,30 +92,29 @@ export async function makeEvent(
92
92
  defaultProps.browser = u.choose(defaults.browsers());
93
93
  }
94
94
 
95
- // Add campaigns with attribution likelihood.
96
- // When any event has isAttributionEvent, only stamp UTMs on those events (25% chance).
97
- // Otherwise, backwards-compat: ~25% of all events get UTMs.
98
- if (hasCampaigns) {
99
- const shouldStamp = config.hasAttributionFlags
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
- unixTime = u.TimeSoup(earliestTime, context.FIXED_NOW, peaks, deviation, mean, dayOfWeekWeights, hourOfDayWeights);
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
- const authTimeMs = runAuthExecIdx >= 0 && finalEvents[runAuthExecIdx]
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,
@@ -36,6 +36,7 @@ export {
36
36
  injectAfterEvent,
37
37
  injectBetween,
38
38
  injectBurst,
39
+ injectOnNewDays,
39
40
  } from './inject.js';
40
41
 
41
42
  export {
@@ -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`);