@ak--47/dungeon-master 1.4.5 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.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 +139 -0
- package/HOOKS.md +1243 -597
- package/README.md +140 -5
- package/dungeons/technical/ad-spend.js +1 -1
- package/dungeons/technical/anonymous-users.js +1 -1
- package/dungeons/technical/array-of-object-lookup.js +1 -1
- package/dungeons/technical/datagen-v15-verify.js +74 -0
- package/dungeons/technical/experiments.js +1 -1
- package/dungeons/technical/foobar.js +1 -1
- package/dungeons/technical/group-analytics.js +1 -1
- package/dungeons/technical/mirror-strategies.js +1 -1
- package/dungeons/technical/nested-objects.js +1 -1
- package/dungeons/technical/retention-cadence.js +1 -1
- package/dungeons/technical/sanity.js +1 -1
- package/dungeons/technical/scale-test.js +1 -1
- package/dungeons/technical/scd.js +1 -1
- package/dungeons/technical/simple.js +1 -1
- package/dungeons/technical/simplest.js +74 -20
- package/dungeons/technical/text-generation.js +1 -1
- package/dungeons/vertical/ai-platform.js +4 -0
- package/dungeons/vertical/community.js +9 -3
- package/dungeons/vertical/crypto.js +5 -0
- package/dungeons/vertical/dating.js +23 -10
- package/dungeons/vertical/devtools.js +10 -0
- package/dungeons/vertical/ecommerce.js +6 -0
- package/dungeons/vertical/education.js +11 -0
- package/dungeons/vertical/fintech.js +13 -0
- package/dungeons/vertical/fitness.js +10 -0
- package/dungeons/vertical/food-delivery.js +9 -0
- package/dungeons/vertical/gaming.js +10 -0
- package/dungeons/vertical/healthcare.js +5 -0
- package/dungeons/vertical/insurance-application.js +10 -0
- package/dungeons/vertical/logistics.js +8 -1
- package/dungeons/vertical/marketplace.js +7 -0
- package/dungeons/vertical/media.js +8 -0
- package/dungeons/vertical/real-estate.js +7 -1
- package/dungeons/vertical/sass.js +12 -0
- package/dungeons/vertical/social.js +9 -0
- package/dungeons/vertical/travel.js +5 -0
- package/index.js +19 -4
- package/lib/core/config-validator.js +270 -7
- package/lib/core/dungeon-loader.js +2 -5
- package/lib/generators/events.js +12 -13
- package/lib/generators/funnels.js +72 -1
- package/lib/hook-helpers/index.js +1 -0
- package/lib/hook-helpers/inject.js +95 -0
- package/lib/orchestrators/user-loop.js +478 -29
- package/lib/templates/macro-presets.js +39 -9
- package/lib/utils/utils.js +16 -79
- package/lib/verify/counting.js +320 -0
- package/lib/verify/emulate-breakdown.js +512 -108
- package/lib/verify/funnel-engine.js +539 -0
- package/lib/verify/identity.js +78 -0
- package/lib/verify/index.js +19 -0
- package/lib/verify/verify-dungeon.js +58 -0
- package/package.json +4 -2
- package/types.d.ts +237 -4
- package/scripts/smoke-test-all.mjs +0 -162
|
@@ -168,6 +168,85 @@ function stripKilledConfigKeys(config) {
|
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
/**
|
|
172
|
+
* v1.5: validate / default `Funnel.conversionWindowDays` in place.
|
|
173
|
+
*
|
|
174
|
+
* - Missing field + `timeToConvert/24 < 30` → set to 30 (Mixpanel UI default)
|
|
175
|
+
* - Missing field + `timeToConvert/24 >= 30` → auto-bump to `min(180, ceil(ttc * 1.5))` + warn
|
|
176
|
+
* - Field set > 180 → throw (Mixpanel hard cap)
|
|
177
|
+
*
|
|
178
|
+
* Reference: `backend/arb/reader/funnels/conversion_window.cpp`.
|
|
179
|
+
*
|
|
180
|
+
* @param {import('../../types.js').Funnel[]} funnels
|
|
181
|
+
*/
|
|
182
|
+
function validateConversionWindow(funnels) {
|
|
183
|
+
const DEFAULT_WINDOW_DAYS = 30;
|
|
184
|
+
const MAX_WINDOW_DAYS = 180;
|
|
185
|
+
for (const f of funnels) {
|
|
186
|
+
if (!f) continue;
|
|
187
|
+
const ttcHours = Number.isFinite(f.timeToConvert) ? f.timeToConvert : 24;
|
|
188
|
+
const ttcDays = ttcHours / 24;
|
|
189
|
+
if (f.conversionWindowDays === undefined || f.conversionWindowDays === null) {
|
|
190
|
+
if (ttcDays >= DEFAULT_WINDOW_DAYS) {
|
|
191
|
+
f.conversionWindowDays = Math.min(MAX_WINDOW_DAYS, Math.ceil(ttcDays * 1.5));
|
|
192
|
+
console.warn(
|
|
193
|
+
`⚠️ Funnel "${f.name || (f.sequence && f.sequence.join(' > '))}": ` +
|
|
194
|
+
`timeToConvert (${ttcDays.toFixed(1)}d) exceeds default 30d conversion window. ` +
|
|
195
|
+
`Auto-set conversionWindowDays=${f.conversionWindowDays}. Set explicitly to silence.`
|
|
196
|
+
);
|
|
197
|
+
} else {
|
|
198
|
+
f.conversionWindowDays = DEFAULT_WINDOW_DAYS;
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
if (!Number.isFinite(f.conversionWindowDays) || f.conversionWindowDays <= 0) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
`Funnel "${f.name || (f.sequence && f.sequence.join(' > '))}": ` +
|
|
204
|
+
`conversionWindowDays must be a positive finite number (got ${f.conversionWindowDays})`
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
if (f.conversionWindowDays > MAX_WINDOW_DAYS) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`Funnel "${f.name || (f.sequence && f.sequence.join(' > '))}": ` +
|
|
210
|
+
`conversionWindowDays cannot exceed ${MAX_WINDOW_DAYS} (Mixpanel hard cap)`
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* v1.5.0: validate `Funnel.exclusionEvents` in place.
|
|
219
|
+
* - Each entry must reference an event in `events[]` (schema-first guarantee).
|
|
220
|
+
* - Warn (don't throw) when an exclusion event is also a step in the funnel — the
|
|
221
|
+
* verifier will treat its presence as a terminator, but the same name appearing as
|
|
222
|
+
* a step is ambiguous.
|
|
223
|
+
*
|
|
224
|
+
* @param {import('../../types.js').Funnel[]} funnels
|
|
225
|
+
* @param {Array<{event?: string}>} events
|
|
226
|
+
*/
|
|
227
|
+
function validateExclusionEvents(funnels, events) {
|
|
228
|
+
if (!Array.isArray(funnels) || !Array.isArray(events)) return;
|
|
229
|
+
const eventNames = new Set(events.map(e => e && e.event).filter(Boolean));
|
|
230
|
+
for (const f of funnels) {
|
|
231
|
+
if (!f || !Array.isArray(f.exclusionEvents) || !f.exclusionEvents.length) continue;
|
|
232
|
+
for (const name of f.exclusionEvents) {
|
|
233
|
+
if (!eventNames.has(name)) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`Funnel "${f.name || (f.sequence && f.sequence.join(' > '))}": ` +
|
|
236
|
+
`exclusionEvents entry "${name}" is not declared in events[]. ` +
|
|
237
|
+
`Add it as an event (schema-first) before referencing it as an exclusion.`
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
if (Array.isArray(f.sequence) && f.sequence.includes(name)) {
|
|
241
|
+
console.warn(
|
|
242
|
+
`⚠️ Funnel "${f.name || (f.sequence && f.sequence.join(' > '))}": ` +
|
|
243
|
+
`exclusion event "${name}" is also a funnel step — semantics are ambiguous.`
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
171
250
|
/**
|
|
172
251
|
* Validate `Funnel.attempts` config in place. Coerces missing/invalid bounds so callers
|
|
173
252
|
* downstream don't have to re-defend. Throws on logically invalid configs (max < min).
|
|
@@ -373,6 +452,26 @@ export function validateDungeonConfig(config) {
|
|
|
373
452
|
avgEventsPerUserPerDay = numEvents / numUsers / numDays;
|
|
374
453
|
}
|
|
375
454
|
|
|
455
|
+
// ── v1.5 Active-day primitive validation ──
|
|
456
|
+
// `avgActiveDaysPerUser` is a CONCENTRATOR — total event count is preserved
|
|
457
|
+
// (`avgEventsPerUserPerDay × numDays`), but events cluster onto fewer days.
|
|
458
|
+
// The implied per-active-day rate inflates: warn when it exceeds 50.
|
|
459
|
+
const avgActiveDaysPerUser = config.avgActiveDaysPerUser;
|
|
460
|
+
if (avgActiveDaysPerUser !== undefined && avgActiveDaysPerUser !== null) {
|
|
461
|
+
if (!Number.isFinite(avgActiveDaysPerUser) || avgActiveDaysPerUser <= 0) {
|
|
462
|
+
throw new Error(`avgActiveDaysPerUser must be a positive finite number (got ${avgActiveDaysPerUser})`);
|
|
463
|
+
}
|
|
464
|
+
const impliedRatePerActiveDay = (avgEventsPerUserPerDay * numDays) / avgActiveDaysPerUser;
|
|
465
|
+
if (impliedRatePerActiveDay > 50) {
|
|
466
|
+
console.warn(
|
|
467
|
+
`⚠️ avgActiveDaysPerUser=${avgActiveDaysPerUser} concentrates ` +
|
|
468
|
+
`${Math.round(avgEventsPerUserPerDay * numDays).toLocaleString()} events into ${avgActiveDaysPerUser} day(s) ` +
|
|
469
|
+
`→ ${impliedRatePerActiveDay.toFixed(0)} events per active day. ` +
|
|
470
|
+
`If you want fewer total events, lower avgEventsPerUserPerDay.`
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
376
475
|
// Auto-enable batch mode for large datasets to prevent OOM.
|
|
377
476
|
// MUST run after rate→numEvents resolution above, otherwise dungeons that set
|
|
378
477
|
// only avgEventsPerUserPerDay would never trigger auto-batch.
|
|
@@ -396,14 +495,126 @@ export function validateDungeonConfig(config) {
|
|
|
396
495
|
let percentUsersBornInDataset = config.percentUsersBornInDataset !== undefined ? config.percentUsersBornInDataset : macroResolved.percentUsersBornInDataset;
|
|
397
496
|
let preExistingSpread = config.preExistingSpread !== undefined ? config.preExistingSpread : macroResolved.preExistingSpread;
|
|
398
497
|
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
498
|
+
// ── v1.5 Engine-validation strict clamps ──────────────────────────────
|
|
499
|
+
// Pathological knob combinations produce nosedive / right-edge explosion
|
|
500
|
+
// patterns that no engine fix can rescue. We clamp them at validation time
|
|
501
|
+
// with a clear warning so dungeon authors fix the config rather than ship
|
|
502
|
+
// a broken-looking dataset. See `plans/ENGINE-VALIDATION/FIX.md` for the
|
|
503
|
+
// sweep evidence behind each rule.
|
|
504
|
+
// User-explicit detection. Fires when the value comes from EITHER top-level
|
|
505
|
+
// dungeon config OR macro-object override (e.g., `macro: { preset: 'growth',
|
|
506
|
+
// bornRecentBias: 0.5 }`). Both paths represent user intent to override; only
|
|
507
|
+
// raw preset names (e.g., `macro: 'growth'`) are exempt — their preset values
|
|
508
|
+
// are designed to be safe.
|
|
509
|
+
const macroAsObj = (config.macro && typeof config.macro === 'object' && !Array.isArray(config.macro))
|
|
510
|
+
? /** @type {{preset?: string, percentUsersBornInDataset?: number, bornRecentBias?: number}} */ (config.macro)
|
|
511
|
+
: null;
|
|
512
|
+
const userBornExplicit =
|
|
513
|
+
(config.percentUsersBornInDataset !== undefined && config.percentUsersBornInDataset !== null)
|
|
514
|
+
|| (macroAsObj !== null && macroAsObj.percentUsersBornInDataset !== undefined && macroAsObj.percentUsersBornInDataset !== null);
|
|
515
|
+
const userBiasExplicit =
|
|
516
|
+
(config.bornRecentBias !== undefined && config.bornRecentBias !== null)
|
|
517
|
+
|| (macroAsObj !== null && macroAsObj.bornRecentBias !== undefined && macroAsObj.bornRecentBias !== null);
|
|
518
|
+
|
|
519
|
+
// Coerce non-finite to 0 first (sanity)
|
|
520
|
+
if (typeof bornRecentBias !== 'number' || !Number.isFinite(bornRecentBias)) {
|
|
404
521
|
bornRecentBias = 0;
|
|
405
522
|
}
|
|
406
523
|
|
|
524
|
+
// Clamp 1: hard absolute bounds on born% (data sanity)
|
|
525
|
+
if (typeof percentUsersBornInDataset !== 'number' || !Number.isFinite(percentUsersBornInDataset)) {
|
|
526
|
+
percentUsersBornInDataset = 0;
|
|
527
|
+
}
|
|
528
|
+
if (percentUsersBornInDataset > 100) {
|
|
529
|
+
console.warn(`⚠️ percentUsersBornInDataset=${percentUsersBornInDataset} clamped to 100. Values above 100 are not meaningful.`);
|
|
530
|
+
percentUsersBornInDataset = 100;
|
|
531
|
+
}
|
|
532
|
+
if (percentUsersBornInDataset < 0) {
|
|
533
|
+
console.warn(`⚠️ percentUsersBornInDataset=${percentUsersBornInDataset} clamped to 0. Negative values are not meaningful.`);
|
|
534
|
+
percentUsersBornInDataset = 0;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Clamp 2: per-macro born compatibility — fires when the user explicitly opts
|
|
538
|
+
// into a named macro AND explicitly sets born%. Macro = contract: "produce
|
|
539
|
+
// the shape this preset describes". Born% over the cap breaks that contract
|
|
540
|
+
// (cumulative-acquisition right-edge explosion). Caps match each preset's
|
|
541
|
+
// default `percentUsersBornInDataset` to preserve the macro's characteristic
|
|
542
|
+
// shape. Users who need higher born% should switch macros (flat→growth,
|
|
543
|
+
// growth→viral). When no macro is set, the clamp does NOT fire — legacy
|
|
544
|
+
// dungeons that set percentUsersBornInDataset directly without picking a
|
|
545
|
+
// macro keep their existing behavior. Tuned empirically against the
|
|
546
|
+
// engine-validation sweep matrix (research/engine-sweep-pass*.json).
|
|
547
|
+
const MACRO_BORN_CAP = { flat: 12, steady: 12, growth: 30, viral: 55, decline: 5 };
|
|
548
|
+
const macroExplicit = config.macro !== undefined && config.macro !== null;
|
|
549
|
+
const macroKey = (typeof config.macro === 'string')
|
|
550
|
+
? config.macro
|
|
551
|
+
: (config.macro && config.macro.preset) ? config.macro.preset : 'flat';
|
|
552
|
+
if (userBornExplicit && macroExplicit && MACRO_BORN_CAP[macroKey] !== undefined) {
|
|
553
|
+
const cap = MACRO_BORN_CAP[macroKey];
|
|
554
|
+
if (percentUsersBornInDataset > cap) {
|
|
555
|
+
console.warn(
|
|
556
|
+
`⚠️ macro="${macroKey}" + percentUsersBornInDataset=${percentUsersBornInDataset} ` +
|
|
557
|
+
`clamped to ${cap}. High born% with macro="${macroKey}" produces cumulative-acquisition ` +
|
|
558
|
+
`right-edge explosion. Use macro="growth" or "viral" for genuinely high-born configs. ` +
|
|
559
|
+
`To suppress, fix the config.`
|
|
560
|
+
);
|
|
561
|
+
percentUsersBornInDataset = cap;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Clamp 3: bornRecentBias safe range. Plan PROMPT.md "[-0.5, 0.5]". Only fires
|
|
566
|
+
// on user-set values — viral preset (0.6) is allowed by design.
|
|
567
|
+
if (userBiasExplicit) {
|
|
568
|
+
if (bornRecentBias > 0.5) {
|
|
569
|
+
console.warn(`⚠️ bornRecentBias=${bornRecentBias} clamped to 0.5. Above 0.5 produces unusable right-skew. To suppress, fix the config.`);
|
|
570
|
+
bornRecentBias = 0.5;
|
|
571
|
+
}
|
|
572
|
+
if (bornRecentBias < -0.5) {
|
|
573
|
+
console.warn(`⚠️ bornRecentBias=${bornRecentBias} clamped to -0.5. Below -0.5 produces unusable left-skew. To suppress, fix the config.`);
|
|
574
|
+
bornRecentBias = -0.5;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Clamp 4: bias × born compound check (only on explicit user values).
|
|
579
|
+
// Plan PROMPT.md: "born > 80 + bias > 0.4 → clamp bias to 0.3".
|
|
580
|
+
if ((userBornExplicit || userBiasExplicit) && percentUsersBornInDataset > 60 && bornRecentBias > 0.4) {
|
|
581
|
+
console.warn(
|
|
582
|
+
`⚠️ percentUsersBornInDataset=${percentUsersBornInDataset} + bornRecentBias=${bornRecentBias} ` +
|
|
583
|
+
`compounds to right-edge explosion. Clamping bornRecentBias to 0.3. To suppress, fix the config.`
|
|
584
|
+
);
|
|
585
|
+
bornRecentBias = 0.3;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Final sanity: bias must be in [-1, 1] (Math.pow guards)
|
|
589
|
+
bornRecentBias = Math.max(-1, Math.min(1, bornRecentBias));
|
|
590
|
+
|
|
591
|
+
// Clamp 5: avgEventsPerUserPerDay safe range. Above 50 produces unrealistic
|
|
592
|
+
// load + memory cost. Plan PROMPT.md: clamp to 50.
|
|
593
|
+
if (Number.isFinite(avgEventsPerUserPerDay) && avgEventsPerUserPerDay > 50) {
|
|
594
|
+
console.warn(`⚠️ avgEventsPerUserPerDay=${avgEventsPerUserPerDay} clamped to 50. Above 50 is unrealistic load + memory cost. To suppress, fix the config.`);
|
|
595
|
+
avgEventsPerUserPerDay = 50;
|
|
596
|
+
numEvents = Math.round(avgEventsPerUserPerDay * numUsers * numDays);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Clamp 6: avgActiveDaysPerUser cap at numDays/2. Above defeats the
|
|
600
|
+
// concentrator purpose. Reassign config so the user-loop sees the clamped value.
|
|
601
|
+
let avgActiveDaysClamped = avgActiveDaysPerUser;
|
|
602
|
+
if (avgActiveDaysPerUser !== undefined && avgActiveDaysPerUser !== null && avgActiveDaysPerUser > numDays * 0.5) {
|
|
603
|
+
const cap = Math.max(1, Math.floor(numDays * 0.5));
|
|
604
|
+
console.warn(`⚠️ avgActiveDaysPerUser=${avgActiveDaysPerUser} > numDays/2 (${numDays}/2); clamped to ${cap}. Above 50% defeats the concentrator purpose. To suppress, fix the config.`);
|
|
605
|
+
avgActiveDaysClamped = cap;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Clamp 7: numDays minimum. Below 14 makes the strict-bar 14-day window
|
|
609
|
+
// meaningless. We WARN but do NOT clamp here because the dataset window has
|
|
610
|
+
// already been resolved upstream — clamping numDays alone would desync the
|
|
611
|
+
// engine. Pre-validator numDays bound is preferred (validator throws on
|
|
612
|
+
// numDays <= 0 already at line ~422). Just warn for visibility.
|
|
613
|
+
if (numDays < 14) {
|
|
614
|
+
console.warn(`⚠️ numDays=${numDays} < 14. Strict-bar engine-validation metrics use 14-day windows; results are noisy below 14 days. Consider increasing numDays.`);
|
|
615
|
+
}
|
|
616
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
617
|
+
|
|
407
618
|
// Use provided name if non-empty string, otherwise generate one
|
|
408
619
|
if (!name || name === "") {
|
|
409
620
|
name = makeName();
|
|
@@ -461,6 +672,26 @@ export function validateDungeonConfig(config) {
|
|
|
461
672
|
funnels = [...funnels, ...inferredFunnels];
|
|
462
673
|
}
|
|
463
674
|
|
|
675
|
+
// v1.5: auto-promote funnel-step events to `isStrictEvent: true`. Run BEFORE the
|
|
676
|
+
// catch-all funnel below so the catch-all only sweeps non-strict events. Without
|
|
677
|
+
// this, the greedy single-pass funnel engine consumes standalone instances as
|
|
678
|
+
// funnel step matches — corrupting both the standalone count AND the funnel TTC.
|
|
679
|
+
// Explicit `isStrictEvent: false` opts out (advanced; preserves mixed semantics).
|
|
680
|
+
// Skip `$experiment_started` since it's prepended by experiments, not user-declared.
|
|
681
|
+
const userDeclaredFunnelSteps = new Set(funnels.flatMap(f => Array.isArray(f.sequence) ? f.sequence : []));
|
|
682
|
+
userDeclaredFunnelSteps.delete('$experiment_started');
|
|
683
|
+
for (const ev of events) {
|
|
684
|
+
if (!ev || typeof ev.event !== 'string') continue;
|
|
685
|
+
if (!userDeclaredFunnelSteps.has(ev.event)) continue;
|
|
686
|
+
if (ev.isStrictEvent === false) continue; // explicit opt-out
|
|
687
|
+
if (ev.isStrictEvent === true) continue; // already set
|
|
688
|
+
ev.isStrictEvent = true;
|
|
689
|
+
console.warn(
|
|
690
|
+
`⚠️ Auto-promoted "${ev.event}" to isStrictEvent: true (appears as a funnel step). ` +
|
|
691
|
+
`Set isStrictEvent: false to opt out and allow standalone instances.`
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
|
|
464
695
|
// Create funnel for events not in other funnels
|
|
465
696
|
const eventContainedInFunnels = Array.from(funnels.reduce((acc, f) => {
|
|
466
697
|
const events = f.sequence;
|
|
@@ -494,7 +725,15 @@ export function validateDungeonConfig(config) {
|
|
|
494
725
|
sequence,
|
|
495
726
|
conversionRate: 50,
|
|
496
727
|
order: 'random',
|
|
497
|
-
|
|
728
|
+
// v1.5 engine bunchiness fix: shortened catch-all ttc from 14d → 1d.
|
|
729
|
+
// The user-loop fix constrains funnel step1's TimeSoup `latestTime` to
|
|
730
|
+
// `FIXED_NOW - ttc` to prevent spillover-and-_drop. With ttc=14d, this
|
|
731
|
+
// created a 14-day "no event zone" at the right edge of every dataset
|
|
732
|
+
// — flattening growth/viral macros into near-baseline shapes. ttc=1d
|
|
733
|
+
// gives the catch-all a 1-day right-edge zone, restoring magnitude
|
|
734
|
+
// distinction across macro presets while keeping spillover near zero.
|
|
735
|
+
// Explicit user-defined funnels keep their declared `timeToConvert`.
|
|
736
|
+
timeToConvert: 24,
|
|
498
737
|
requireRepeats: false,
|
|
499
738
|
});
|
|
500
739
|
}
|
|
@@ -510,7 +749,7 @@ export function validateDungeonConfig(config) {
|
|
|
510
749
|
|
|
511
750
|
|
|
512
751
|
|
|
513
|
-
// Event validation
|
|
752
|
+
// Event validation
|
|
514
753
|
const validatedEvents = u.validateEventConfig(events);
|
|
515
754
|
|
|
516
755
|
// ── Validate and resolve advanced features ──
|
|
@@ -542,6 +781,12 @@ export function validateDungeonConfig(config) {
|
|
|
542
781
|
// Phase 1: validate Funnel.attempts on every funnel (additive — most have none).
|
|
543
782
|
validateAttempts(funnels);
|
|
544
783
|
|
|
784
|
+
// v1.5: default + auto-bump Funnel.conversionWindowDays.
|
|
785
|
+
validateConversionWindow(funnels);
|
|
786
|
+
|
|
787
|
+
// v1.5.0: validate Funnel.exclusionEvents — entries must exist in events[].
|
|
788
|
+
validateExclusionEvents(funnels, validatedEvents);
|
|
789
|
+
|
|
545
790
|
// Normalize experiment configs: true → default 3-variant, object → validated.
|
|
546
791
|
normalizeExperiments(funnels, datasetEndUnix);
|
|
547
792
|
|
|
@@ -565,6 +810,16 @@ export function validateDungeonConfig(config) {
|
|
|
565
810
|
// Precompute whether any event has isAttributionEvent for UTM stamping logic.
|
|
566
811
|
const hasAttributionFlags = validatedEvents.some(e => e.isAttributionEvent);
|
|
567
812
|
|
|
813
|
+
// v1.5: Touchpoint cap. Default 10 (Mixpanel TOUCHPOINTS_LIMIT). Setting Infinity
|
|
814
|
+
// disables the cap (every eligible event gets stamped). Negative or zero disables
|
|
815
|
+
// stamping entirely (treat as "don't apply touchpoint cap pass").
|
|
816
|
+
let maxTouchpointsPerUser = config.maxTouchpointsPerUser;
|
|
817
|
+
if (maxTouchpointsPerUser === undefined || maxTouchpointsPerUser === null) {
|
|
818
|
+
maxTouchpointsPerUser = 10;
|
|
819
|
+
} else if (maxTouchpointsPerUser !== Infinity && (!Number.isFinite(maxTouchpointsPerUser) || maxTouchpointsPerUser < 0)) {
|
|
820
|
+
throw new Error(`maxTouchpointsPerUser must be a non-negative finite number or Infinity (got ${maxTouchpointsPerUser})`);
|
|
821
|
+
}
|
|
822
|
+
|
|
568
823
|
// Build final config object
|
|
569
824
|
const validatedConfig = {
|
|
570
825
|
...config,
|
|
@@ -616,6 +871,14 @@ export function validateDungeonConfig(config) {
|
|
|
616
871
|
bornRecentBias,
|
|
617
872
|
percentUsersBornInDataset,
|
|
618
873
|
preExistingSpread,
|
|
874
|
+
// v1.5 distinct-day primitive (concentrator). undefined = legacy behavior.
|
|
875
|
+
avgActiveDaysPerUser: avgActiveDaysClamped !== undefined && avgActiveDaysClamped !== null
|
|
876
|
+
? avgActiveDaysClamped
|
|
877
|
+
: undefined,
|
|
878
|
+
// v1.5 attribution touchpoint cap (Mixpanel TOUCHPOINTS_LIMIT = 10).
|
|
879
|
+
maxTouchpointsPerUser,
|
|
880
|
+
// v1.5 auto-sort after everything hook. Default true. Opt out with explicit `false`.
|
|
881
|
+
autoSortAfterEverything: config.autoSortAfterEverything !== false,
|
|
619
882
|
// Advanced features (kept after 1.4)
|
|
620
883
|
personas,
|
|
621
884
|
worldEvents,
|
|
@@ -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
|
|
@@ -306,6 +329,54 @@ export async function makeFunnel(context, funnel, user, firstEventTime, profile
|
|
|
306
329
|
? Date.parse(finalEvents[runAuthExecIdx].time) || null
|
|
307
330
|
: null;
|
|
308
331
|
|
|
332
|
+
// v1.5.0: inject exclusion events for non-converters. When `funnel.exclusionEvents`
|
|
333
|
+
// is set and the user dropped off mid-funnel (≥1 step completed but < sequence.length),
|
|
334
|
+
// stamp 1-2 cloned events bearing one of the listed exclusion event names between the
|
|
335
|
+
// last completed step and where the next step would have been. The verifier reads
|
|
336
|
+
// `funnel.exclusionEvents` and applies them as exclusionSteps to terminate the attempt.
|
|
337
|
+
//
|
|
338
|
+
// Schema-first: copy ONLY identity + super props + group keys from the source event,
|
|
339
|
+
// plus props declared on the exclusion event's own config. Source-event-specific
|
|
340
|
+
// props (e.g. `cart_value` on `Add to Cart`) MUST NOT bleed onto a different event
|
|
341
|
+
// type or the schema validator will flag undeclared columns.
|
|
342
|
+
if (Array.isArray(funnel.exclusionEvents) && funnel.exclusionEvents.length
|
|
343
|
+
&& !doesUserConvert && finalEvents.length > 0 && finalEvents.length < sequence.length) {
|
|
344
|
+
const lastEvent = finalEvents[finalEvents.length - 1];
|
|
345
|
+
const lastTimeMs = Date.parse(lastEvent.time);
|
|
346
|
+
if (Number.isFinite(lastTimeMs)) {
|
|
347
|
+
const IDENTITY_KEYS = ['user_id', 'device_id', 'distinct_id', 'session_id', 'insert_id'];
|
|
348
|
+
const superPropKeys = Object.keys(superProps || {});
|
|
349
|
+
const groupKeyNames = (groupKeys || []).map(gk => Array.isArray(gk) ? gk[0] : gk).filter(Boolean);
|
|
350
|
+
const numToInject = chance.integer({ min: 1, max: 2 });
|
|
351
|
+
for (let i = 0; i < numToInject; i++) {
|
|
352
|
+
const excName = chance.pickone(funnel.exclusionEvents);
|
|
353
|
+
const excConfig = (config.events || []).find(e => e.event === excName);
|
|
354
|
+
const offsetMs = (i + 1) * chance.integer({ min: 30_000, max: 300_000 });
|
|
355
|
+
const cloned = {
|
|
356
|
+
event: excName,
|
|
357
|
+
time: new Date(lastTimeMs + offsetMs).toISOString(),
|
|
358
|
+
};
|
|
359
|
+
// Identity from source event (correct user/device/session attribution).
|
|
360
|
+
for (const k of IDENTITY_KEYS) {
|
|
361
|
+
if (k in lastEvent) cloned[k] = lastEvent[k];
|
|
362
|
+
}
|
|
363
|
+
// Super props + group keys carry over (user-stable values).
|
|
364
|
+
for (const k of superPropKeys) if (k in lastEvent) cloned[k] = lastEvent[k];
|
|
365
|
+
for (const k of groupKeyNames) if (k in lastEvent) cloned[k] = lastEvent[k];
|
|
366
|
+
// Resolve declared props on the exclusion event's config.
|
|
367
|
+
if (excConfig && excConfig.properties) {
|
|
368
|
+
for (const k of Object.keys(excConfig.properties)) {
|
|
369
|
+
try { cloned[k] = u.choose(excConfig.properties[k]); }
|
|
370
|
+
catch (e) { cloned[k] = null; }
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
// Fresh insert_id so the new event isn't a duplicate of the source.
|
|
374
|
+
cloned.insert_id = `${excName}-${cloned.time}-${chance.string({ length: 10, alpha: true })}`;
|
|
375
|
+
finalEvents.push(cloned);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
309
380
|
// Call post-funnel hook
|
|
310
381
|
await hook(finalEvents, "funnel-post", {
|
|
311
382
|
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
|
+
|