@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
|
@@ -30,6 +30,28 @@ import { validateSchema } from './schema-validator.js';
|
|
|
30
30
|
* @property {(rows: Array<Object>, ctx: { events: Array<Object>, profiles: Array<Object> }) => { pass: boolean, detail?: string }} assert
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Try to find the dungeon funnel whose `sequence` matches the breakdown's funnel
|
|
35
|
+
* steps. Used to auto-apply v1.5 `conversionWindowDays` + `order`-mode dispatch
|
|
36
|
+
* without forcing every check author to thread these args by hand.
|
|
37
|
+
* @param {Array<Object>} funnels
|
|
38
|
+
* @param {string[]} steps
|
|
39
|
+
* @returns {Object | null}
|
|
40
|
+
*/
|
|
41
|
+
function findMatchingFunnel(funnels, steps) {
|
|
42
|
+
if (!Array.isArray(funnels) || !Array.isArray(steps) || !steps.length) return null;
|
|
43
|
+
for (const f of funnels) {
|
|
44
|
+
if (!f || !Array.isArray(f.sequence)) continue;
|
|
45
|
+
if (f.sequence.length !== steps.length) continue;
|
|
46
|
+
let same = true;
|
|
47
|
+
for (let i = 0; i < steps.length; i++) {
|
|
48
|
+
if (f.sequence[i] !== steps[i]) { same = false; break; }
|
|
49
|
+
}
|
|
50
|
+
if (same) return f;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
33
55
|
/**
|
|
34
56
|
* @param {Object} config - Dungeon config (or path; passed straight to DUNGEON_MASTER).
|
|
35
57
|
* @param {VerifyCheck[]} checks
|
|
@@ -44,12 +66,48 @@ export async function verifyDungeon(config, checks) {
|
|
|
44
66
|
const schemaReport = validateSchema(events, config);
|
|
45
67
|
const ctx = { events, profiles, schemaReport };
|
|
46
68
|
const results = [];
|
|
69
|
+
// `validateDungeonConfig` mutates funnels in place — by the time we read here,
|
|
70
|
+
// `conversionWindowDays` is populated and `order` is the validated value.
|
|
71
|
+
const validatedFunnels = (config && Array.isArray(config.funnels)) ? config.funnels : [];
|
|
47
72
|
for (const check of checks) {
|
|
48
73
|
try {
|
|
49
74
|
const breakdownArgs = { ...check.breakdown };
|
|
50
75
|
if (breakdownArgs.type === 'timeToConvert' && !breakdownArgs.profiles) {
|
|
51
76
|
breakdownArgs.profiles = profiles;
|
|
52
77
|
}
|
|
78
|
+
// v1.5: auto-apply funnel-level config when the check targets a funnel.
|
|
79
|
+
if (breakdownArgs.type === 'funnelFrequency' || breakdownArgs.type === 'timeToConvert') {
|
|
80
|
+
const targetSteps = breakdownArgs.steps
|
|
81
|
+
|| (breakdownArgs.fromEvent && breakdownArgs.toEvent
|
|
82
|
+
? [breakdownArgs.fromEvent, breakdownArgs.toEvent]
|
|
83
|
+
: null);
|
|
84
|
+
const matched = findMatchingFunnel(validatedFunnels, targetSteps);
|
|
85
|
+
if (matched) {
|
|
86
|
+
if (breakdownArgs.conversionWindowMs === undefined && Number.isFinite(matched.conversionWindowDays)) {
|
|
87
|
+
breakdownArgs.conversionWindowMs = matched.conversionWindowDays * 86400000;
|
|
88
|
+
}
|
|
89
|
+
if (breakdownArgs.funnelOrder === undefined && matched.order) {
|
|
90
|
+
breakdownArgs.funnelOrder = matched.order;
|
|
91
|
+
}
|
|
92
|
+
// v1.5.0: thread Funnel-level extension hints through to the verifier.
|
|
93
|
+
if (breakdownArgs.reentry === undefined && matched.reentry !== undefined) {
|
|
94
|
+
breakdownArgs.reentry = matched.reentry;
|
|
95
|
+
}
|
|
96
|
+
if (breakdownArgs.exclusionSteps === undefined && Array.isArray(matched.exclusionEvents) && matched.exclusionEvents.length) {
|
|
97
|
+
breakdownArgs.exclusionSteps = matched.exclusionEvents.map(name => ({ event: name }));
|
|
98
|
+
}
|
|
99
|
+
// stepFilters: Record<number, { prop, op, value }> → mutate steps to attach where-clause.
|
|
100
|
+
if (matched.stepFilters && breakdownArgs.steps && Array.isArray(breakdownArgs.steps)) {
|
|
101
|
+
breakdownArgs.steps = breakdownArgs.steps.map((s, i) => {
|
|
102
|
+
const filter = matched.stepFilters[i];
|
|
103
|
+
if (!filter) return s;
|
|
104
|
+
const stepObj = typeof s === 'string' ? { event: s } : { ...s };
|
|
105
|
+
if (!stepObj.where) stepObj.where = filter;
|
|
106
|
+
return stepObj;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
53
111
|
const rows = emulateBreakdown(events, breakdownArgs);
|
|
54
112
|
const verdict = check.assert(rows, ctx);
|
|
55
113
|
results.push({ name: check.name, pass: !!verdict.pass, detail: verdict.detail, rows });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ak--47/dungeon-master",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"description": "generate fancy datasets",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
"dungeons/",
|
|
24
24
|
"!dungeons/customers/",
|
|
25
25
|
"!dungeons/user/",
|
|
26
|
+
"!dungeons/capstone/",
|
|
26
27
|
"scripts/",
|
|
28
|
+
".claude/skills/",
|
|
27
29
|
"package.json",
|
|
28
30
|
"README.md",
|
|
29
31
|
"CHANGELOG.md",
|
|
@@ -41,11 +43,19 @@
|
|
|
41
43
|
"test:coverage": "NODE_ENV=test vitest run --coverage",
|
|
42
44
|
"typecheck": "tsc --noEmit",
|
|
43
45
|
"dev": "nodemon scratch.mjs --ignore ./data/*",
|
|
44
|
-
"prune": "
|
|
46
|
+
"prune": "find ./data -mindepth 1 -not -name .gitkeep -delete && find ./tmp -mindepth 1 -not -name .gitkeep -delete && rm -f vscode-profile-*",
|
|
45
47
|
"dungeon:run": "node ./scripts/run-dungeon.mjs",
|
|
46
48
|
"dungeon:to-json": "node ./scripts/dungeon-to-json.mjs",
|
|
47
49
|
"dungeon:from-json": "node ./scripts/json-to-dungeon.mjs",
|
|
48
|
-
"dungeon:schema": "node ./scripts/extract-dungeon-schema.mjs"
|
|
50
|
+
"dungeon:schema": "node ./scripts/extract-dungeon-schema.mjs",
|
|
51
|
+
"kodiak:deploy": "gcloud builds submit --config dungeons/user/kodiak/cloudbuild.yaml",
|
|
52
|
+
"kodiak:plan": "node dungeons/user/kodiak/kickoff.mjs --plan-only",
|
|
53
|
+
"kodiak:smoke:local": "bash dungeons/user/kodiak/smoke-local.sh",
|
|
54
|
+
"kodiak:smoke": "node dungeons/user/kodiak/kickoff.mjs --total-events 1000000000 --chunk-cap 10",
|
|
55
|
+
"kodiak:generate": "node dungeons/user/kodiak/kickoff.mjs --no-wait",
|
|
56
|
+
"kodiak:status": "node dungeons/user/kodiak/status.mjs",
|
|
57
|
+
"kodiak:run": "bash dungeons/user/kodiak/run-all.sh",
|
|
58
|
+
"kodiak:load": "node dungeons/user/kodiak/load-bq.mjs --replace --no-wait"
|
|
49
59
|
},
|
|
50
60
|
"repository": {
|
|
51
61
|
"type": "git",
|
|
@@ -68,6 +78,7 @@
|
|
|
68
78
|
},
|
|
69
79
|
"homepage": "https://github.com/ak--47/dungeon-master#readme",
|
|
70
80
|
"dependencies": {
|
|
81
|
+
"@google-cloud/bigquery": "^7.9.4",
|
|
71
82
|
"@google-cloud/storage": "^7.14.0",
|
|
72
83
|
"ak-tools": "^1.1.12",
|
|
73
84
|
"chance": "^1.1.11",
|
package/scripts/run-dungeon.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import path from 'path';
|
|
4
|
+
import fs from 'fs';
|
|
4
5
|
import generate from '../index.js';
|
|
5
6
|
const { NODE_ENV = "unknown" } = process.env;
|
|
6
7
|
|
|
@@ -14,10 +15,20 @@ if (!dungeonPath) {
|
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
// Resolve the absolute path
|
|
17
|
-
|
|
18
|
+
let absolutePath = path.isAbsolute(dungeonPath)
|
|
18
19
|
? dungeonPath
|
|
19
20
|
: path.resolve(process.cwd(), dungeonPath);
|
|
20
21
|
|
|
22
|
+
// Extension fallback: ESM requires explicit extension. Try .js / .mjs / .json.
|
|
23
|
+
if (!fs.existsSync(absolutePath)) {
|
|
24
|
+
for (const ext of ['.js', '.mjs', '.json']) {
|
|
25
|
+
if (fs.existsSync(absolutePath + ext)) {
|
|
26
|
+
absolutePath += ext;
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
21
32
|
// Handle Ctrl+C gracefully — let user-loop finish current user, then exit
|
|
22
33
|
process.on('SIGINT', () => {
|
|
23
34
|
// Second Ctrl+C forces immediate exit
|
package/types.d.ts
CHANGED
|
@@ -12,10 +12,66 @@ type Primitives = string | number | boolean | Date | Record<string, any>;
|
|
|
12
12
|
*/
|
|
13
13
|
export type ValueValid = Primitives | ValueValid[] | (() => ValueValid);
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* v1.5.1 — credentials sub-object. Groups Mixpanel project credentials. Top-level
|
|
17
|
+
* `token` / `region` / etc. remain functional as a back-compat alias; when both
|
|
18
|
+
* are set, the top-level value wins with a verbose warning.
|
|
19
|
+
*/
|
|
20
|
+
export interface DungeonCredentials {
|
|
21
|
+
token?: string;
|
|
22
|
+
region?: 'US' | 'EU' | 'IN';
|
|
23
|
+
serviceAccount?: string;
|
|
24
|
+
serviceSecret?: string;
|
|
25
|
+
projectId?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* v1.5.1 — switches sub-object. Groups data-shape booleans. Top-level keys
|
|
30
|
+
* remain functional as a back-compat alias; same precedence rules as
|
|
31
|
+
* `DungeonCredentials`.
|
|
32
|
+
*/
|
|
33
|
+
export interface DungeonSwitches {
|
|
34
|
+
hasLocation?: boolean;
|
|
35
|
+
hasCampaigns?: boolean;
|
|
36
|
+
hasAdSpend?: boolean;
|
|
37
|
+
hasSessionIds?: boolean;
|
|
38
|
+
hasAvatar?: boolean;
|
|
39
|
+
hasIOSDevices?: boolean;
|
|
40
|
+
hasAndroidDevices?: boolean;
|
|
41
|
+
hasDesktopDevices?: boolean;
|
|
42
|
+
hasBrowser?: boolean;
|
|
43
|
+
isAnonymous?: boolean;
|
|
44
|
+
alsoInferFunnels?: boolean;
|
|
45
|
+
hasAttributionFlags?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* v1.5.1 — identity sub-object. Groups identity-model knobs. Top-level
|
|
50
|
+
* `avgDevicePerUser` / `sessionTimeout` remain functional as a back-compat
|
|
51
|
+
* alias.
|
|
52
|
+
*
|
|
53
|
+
* `hasAnonIds` is DEPRECATED — when present here, it maps to
|
|
54
|
+
* `avgDevicePerUser: 1` with a verbose warning. Use `avgDevicePerUser` instead.
|
|
55
|
+
*/
|
|
56
|
+
export interface DungeonIdentity {
|
|
57
|
+
avgDevicePerUser?: number;
|
|
58
|
+
sessionTimeout?: number;
|
|
59
|
+
/** @deprecated v1.5.1 — use `avgDevicePerUser: 1` instead. */
|
|
60
|
+
hasAnonIds?: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
15
63
|
/**
|
|
16
64
|
* main config object for the entire data generation
|
|
17
65
|
*/
|
|
18
66
|
export interface Dungeon {
|
|
67
|
+
// ── v1.5.1 sub-object grouping (optional) ──
|
|
68
|
+
/** v1.5.1 — credentials sub-object. See `DungeonCredentials`. */
|
|
69
|
+
credentials?: DungeonCredentials;
|
|
70
|
+
/** v1.5.1 — switches sub-object. See `DungeonSwitches`. */
|
|
71
|
+
switches?: DungeonSwitches;
|
|
72
|
+
/** v1.5.1 — identity sub-object. See `DungeonIdentity`. */
|
|
73
|
+
identity?: DungeonIdentity;
|
|
74
|
+
|
|
19
75
|
// ── Core Parameters ──
|
|
20
76
|
/** Optional dungeon version. Not used by the engine — serves as metadata for tracking revisions when configs are saved/shared. */
|
|
21
77
|
version?: string | number;
|
|
@@ -25,9 +81,27 @@ export interface Dungeon {
|
|
|
25
81
|
token?: string;
|
|
26
82
|
/** RNG seed for reproducible output. Same seed + concurrency=1 = identical data. */
|
|
27
83
|
seed?: string;
|
|
84
|
+
/**
|
|
85
|
+
* Optional separate RNG seed dedicated to `distinct_id` generation.
|
|
86
|
+
*
|
|
87
|
+
* When set, two runs with the same `userSeed` but different `seed` produce
|
|
88
|
+
* the SAME pool of user IDs but DIFFERENT events. Designed for sharded /
|
|
89
|
+
* massively-parallel runs (e.g., Cloud Run Job fan-out) that need cross-shard
|
|
90
|
+
* user identity — every shard generating bucket N pulls from the same 3M
|
|
91
|
+
* user IDs while producing unique events of its own.
|
|
92
|
+
*
|
|
93
|
+
* When unset, the engine falls back to `seed` for user-id generation —
|
|
94
|
+
* existing dungeons stay byte-identical.
|
|
95
|
+
*/
|
|
96
|
+
userSeed?: string;
|
|
28
97
|
/**
|
|
29
98
|
* Number of days the dataset spans. Default: 30.
|
|
30
99
|
*
|
|
100
|
+
* Safe range: `[14, 365]`. Below 14 → strict-bar engine-validation metrics use
|
|
101
|
+
* 14-day windows; results are noisy. Above 365 → memory cost grows linearly.
|
|
102
|
+
* Validator emits a warning below 14; does not clamp (because the window may have
|
|
103
|
+
* been pinned via `datasetStart`/`datasetEnd` upstream).
|
|
104
|
+
*
|
|
31
105
|
* Three resolution modes:
|
|
32
106
|
* 1. **`numDays` alone (no datasetStart/End):** Window = `[today - numDays, today]`.
|
|
33
107
|
* Simplest API for ad-hoc dungeons. NOT deterministic across runs (today changes).
|
|
@@ -58,7 +132,14 @@ export interface Dungeon {
|
|
|
58
132
|
numEvents?: number;
|
|
59
133
|
/** Number of unique users to generate. */
|
|
60
134
|
numUsers?: number;
|
|
61
|
-
/**
|
|
135
|
+
/**
|
|
136
|
+
* Average events per user per active day. The canonical event-volume primitive —
|
|
137
|
+
* born-late users get this rate × their remaining window, so per-day density stays
|
|
138
|
+
* constant. If both this and numEvents are set, this wins.
|
|
139
|
+
*
|
|
140
|
+
* Safe range: `[0.1, 50]`. Above 50 → unrealistic load + memory cost; the v1.5
|
|
141
|
+
* validator strict-clamps to 50 with a warning.
|
|
142
|
+
*/
|
|
62
143
|
avgEventsPerUserPerDay?: number;
|
|
63
144
|
/** Output format for files written to disk. */
|
|
64
145
|
format?: "csv" | "json" | "parquet" | string;
|
|
@@ -242,12 +323,107 @@ export interface Dungeon {
|
|
|
242
323
|
// ── Distribution Controls ──
|
|
243
324
|
// These three knobs are normally set by the `macro` preset (default "flat").
|
|
244
325
|
// Setting them on the dungeon config directly overrides the preset's value.
|
|
245
|
-
/**
|
|
326
|
+
/**
|
|
327
|
+
* Percentage of users whose account creation falls within the dataset window (vs. pre-existing).
|
|
328
|
+
*
|
|
329
|
+
* Safe range: `[0, 100]` (sanity-clamped). Recommended `[0, 60]`. **v1.5 strict-clamp:**
|
|
330
|
+
* when `macro` is set to a named preset AND the user explicitly sets this field, it is
|
|
331
|
+
* clamped to the preset's default born% (flat=12, steady=12, growth=30, viral=55,
|
|
332
|
+
* decline=5) to preserve the macro's characteristic shape. Users who need higher born%
|
|
333
|
+
* should switch macros (flat→growth, growth→viral). When `macro` is not set, no clamp
|
|
334
|
+
* fires (legacy backward-compat).
|
|
335
|
+
*/
|
|
246
336
|
percentUsersBornInDataset?: number;
|
|
247
|
-
/**
|
|
337
|
+
/**
|
|
338
|
+
* Bias for birth dates of users born in dataset. -1..1; negative = early skew,
|
|
339
|
+
* positive = recent skew, 0 = uniform.
|
|
340
|
+
*
|
|
341
|
+
* Safe range: `[-0.5, 0.5]`. **v1.5 strict-clamp:** values outside `[-0.5, 0.5]` are
|
|
342
|
+
* clamped to the nearest bound (above 0.5 = unusable right-skew; below -0.5 = unusable
|
|
343
|
+
* left-skew). Compound clamp: when explicit `percentUsersBornInDataset > 60` AND
|
|
344
|
+
* explicit `bornRecentBias > 0.4`, bias is clamped to 0.3 to prevent right-edge
|
|
345
|
+
* cumulative-acquisition explosion. Macro presets (e.g., viral=0.6) are exempt.
|
|
346
|
+
*/
|
|
248
347
|
bornRecentBias?: number;
|
|
249
348
|
/** How pre-existing users' first event time is placed. "pinned" stacks them all at FIXED_BEGIN; "uniform" spreads across [FIXED_BEGIN-30d, FIXED_BEGIN]. Default (from macro: "flat"): "uniform" */
|
|
250
349
|
preExistingSpread?: "pinned" | "uniform";
|
|
350
|
+
|
|
351
|
+
// ── v1.5 Distinct-Day + Mixpanel Cap Primitives ──
|
|
352
|
+
/**
|
|
353
|
+
* Mean number of distinct UTC days each user fires events on. CONCENTRATOR semantic —
|
|
354
|
+
* total event count is preserved (still `avgEventsPerUserPerDay × userActiveDays`),
|
|
355
|
+
* but events cluster onto fewer days. Per-user count drawn from
|
|
356
|
+
* `normal(mean=avgActiveDaysPerUser, sd=mean/3)`, clamped to `[1, userActiveDays]`.
|
|
357
|
+
*
|
|
358
|
+
* Default: undefined (legacy — every window-day potentially active, no concentration).
|
|
359
|
+
*
|
|
360
|
+
* Per-active-day rate inflates: `(avgEventsPerUserPerDay × numDays) / avgActiveDaysPerUser`.
|
|
361
|
+
* Validator warns when implied per-active-day rate > 50.
|
|
362
|
+
*
|
|
363
|
+
* Day picking: weighted-without-replacement from candidate UTC days using
|
|
364
|
+
* `soup.dayOfWeekWeights`, so weekly rhythm is preserved at the cohort level.
|
|
365
|
+
*
|
|
366
|
+
* **Incompatibility with `engagementDecay`:** decay drops events from late picked days,
|
|
367
|
+
* eroding the effective active-day count below the configured target. Use one or the
|
|
368
|
+
* other; if you need both, write decay logic in an `everything` hook scoped to specific
|
|
369
|
+
* cohorts. See HOOKS.md §2.5.
|
|
370
|
+
*
|
|
371
|
+
* Safe range: `[1, numDays * 0.5]`. Above 50% of `numDays` defeats the concentrator
|
|
372
|
+
* purpose; the v1.5 validator strict-clamps to `floor(numDays * 0.5)` with a warning.
|
|
373
|
+
*/
|
|
374
|
+
avgActiveDaysPerUser?: number;
|
|
375
|
+
/**
|
|
376
|
+
* v1.5.1 — target retention shape. Anchor points `day1`, `day7`, `day30`
|
|
377
|
+
* etc. define the per-day-offset weight a user is active. When set, biases
|
|
378
|
+
* `buildActiveDayPlan` toward the curve and the effective
|
|
379
|
+
* `avgActiveDaysPerUser` is derived from the curve's sum across the user's
|
|
380
|
+
* window (curve wins over an explicit `avgActiveDaysPerUser`).
|
|
381
|
+
*
|
|
382
|
+
* - `type`: `'logarithmic'` (default, real-world retention shape) or `'linear'`.
|
|
383
|
+
* - `dayN` keys: fraction active on day N from birth (0..1). Day 0 is
|
|
384
|
+
* implicitly 1.0 (every user is active on their birth day).
|
|
385
|
+
* - Days beyond the largest anchor extrapolate from the last segment.
|
|
386
|
+
*
|
|
387
|
+
* Example: `{ day1: 0.40, day7: 0.20, day30: 0.08 }` produces a curve that
|
|
388
|
+
* approximates a typical product's 30-day retention.
|
|
389
|
+
*/
|
|
390
|
+
retentionCurve?: {
|
|
391
|
+
type?: 'logarithmic' | 'linear';
|
|
392
|
+
day1?: number;
|
|
393
|
+
day3?: number;
|
|
394
|
+
day7?: number;
|
|
395
|
+
day14?: number;
|
|
396
|
+
day30?: number;
|
|
397
|
+
day60?: number;
|
|
398
|
+
day90?: number;
|
|
399
|
+
[dayKey: string]: number | 'logarithmic' | 'linear' | undefined;
|
|
400
|
+
};
|
|
401
|
+
/**
|
|
402
|
+
* Maximum number of UTM-stamped events per user. Matches Mixpanel's `TOUCHPOINTS_LIMIT`
|
|
403
|
+
* (`backend/libquery/properties_over_time/attributed_value_reader.cpp` line 16).
|
|
404
|
+
*
|
|
405
|
+
* Default: 10.
|
|
406
|
+
*
|
|
407
|
+
* When `hasCampaigns: true` and a user has more eligible events than the cap, the
|
|
408
|
+
* engine takes a uniform-random sample of size `cap` from the eligible pool (seeded,
|
|
409
|
+
* deterministic), sorts the sample chronologically, then stamps UTMs. Sampling
|
|
410
|
+
* across the user's lifetime — NOT first-N-chronological — preserves realistic
|
|
411
|
+
* touch distribution and lets Mixpanel's last-10-window report give meaningful
|
|
412
|
+
* first/last-touch attribution.
|
|
413
|
+
*
|
|
414
|
+
* Set to `Infinity` to disable the cap (legacy behavior, ~25% of eligible events stamped).
|
|
415
|
+
*/
|
|
416
|
+
maxTouchpointsPerUser?: number;
|
|
417
|
+
/**
|
|
418
|
+
* If true (default), the engine sorts each user's events ascending by `time` after
|
|
419
|
+
* the `everything` hook returns, before the events are pushed to storage. Defends
|
|
420
|
+
* against the most common new footgun: hooks that `push()` cloned events with arbitrary
|
|
421
|
+
* timestamps and break the greedy funnel engine's chronological-order requirement.
|
|
422
|
+
*
|
|
423
|
+
* Set to `false` to preserve the hook's order (advanced — hook must guarantee its own
|
|
424
|
+
* ordering for downstream Mixpanel-aligned counting).
|
|
425
|
+
*/
|
|
426
|
+
autoSortAfterEverything?: boolean;
|
|
251
427
|
}
|
|
252
428
|
|
|
253
429
|
export type SCDProp = {
|
|
@@ -820,6 +996,22 @@ export interface Funnel {
|
|
|
820
996
|
* the time it takes (on average) to convert in hours
|
|
821
997
|
*/
|
|
822
998
|
timeToConvert?: number;
|
|
999
|
+
/**
|
|
1000
|
+
* Mixpanel-style conversion window cap, in DAYS. Funnel-step events after step 0 must
|
|
1001
|
+
* land within `conversionWindowDays * 86400000` ms of step 0's timestamp (strict `<`,
|
|
1002
|
+
* matching `is_within_conversion_window` in `backend/arb/reader/funnels/conversion_window.cpp`).
|
|
1003
|
+
*
|
|
1004
|
+
* Default: 30 (Mixpanel UI default).
|
|
1005
|
+
*
|
|
1006
|
+
* If unset and `timeToConvert / 24 >= 30`, the validator auto-bumps to
|
|
1007
|
+
* `min(180, ceil(timeToConvert / 24 * 1.5))` and warns. Hard cap: 180 days
|
|
1008
|
+
* (Mixpanel's maximum). Validator throws if explicitly set above 180.
|
|
1009
|
+
*
|
|
1010
|
+
* The generator caps funnel runs at `conversionWindowDays * 86400000 - 1` ms (1ms slack
|
|
1011
|
+
* to clear the strict-`<` boundary). The verifier (`verifyDungeon`) reads this field
|
|
1012
|
+
* and applies it automatically when emulating funnel breakdowns.
|
|
1013
|
+
*/
|
|
1014
|
+
conversionWindowDays?: number;
|
|
823
1015
|
/**
|
|
824
1016
|
* funnel properties go onto each event in the funnel and are held constant
|
|
825
1017
|
*/
|
|
@@ -858,6 +1050,32 @@ export interface Funnel {
|
|
|
858
1050
|
* @see AttemptsConfig
|
|
859
1051
|
*/
|
|
860
1052
|
attempts?: AttemptsConfig;
|
|
1053
|
+
|
|
1054
|
+
// v1.5.0 — funnel extensions
|
|
1055
|
+
/**
|
|
1056
|
+
* v1.5.0 — Events that terminate the funnel attempt for non-converters. The generator
|
|
1057
|
+
* stamps 1-2 cloned exclusion events between the last completed step and where the next
|
|
1058
|
+
* step would have been; the verifier reads this and applies them as exclusionSteps to
|
|
1059
|
+
* `evaluateFunnel`. Each entry MUST be declared in `events[]` (schema-first) — the
|
|
1060
|
+
* validator throws otherwise.
|
|
1061
|
+
*/
|
|
1062
|
+
exclusionEvents?: string[];
|
|
1063
|
+
/**
|
|
1064
|
+
* v1.5.0 — Verifier-only hint. When `true`, the verifier evaluates with reentry
|
|
1065
|
+
* enabled (counts every completion). Generator behavior unchanged.
|
|
1066
|
+
*/
|
|
1067
|
+
reentry?: boolean;
|
|
1068
|
+
/**
|
|
1069
|
+
* v1.5.0 — Verifier-only hint. Per-step property conditions; the verifier mutates
|
|
1070
|
+
* its step list to attach `where`-clauses at the matching index. Generator behavior
|
|
1071
|
+
* unchanged.
|
|
1072
|
+
*/
|
|
1073
|
+
stepFilters?: Record<number, {
|
|
1074
|
+
prop: string;
|
|
1075
|
+
op: 'eq' | 'neq' | 'gt' | 'lt' | 'gte' | 'lte' | 'contains' | 'not_contains';
|
|
1076
|
+
value: unknown;
|
|
1077
|
+
}>;
|
|
1078
|
+
|
|
861
1079
|
/** @internal Resolved experiment config set by config-validator. */
|
|
862
1080
|
_experiment?: { name: string; variants: Array<{ name: string; conversionMultiplier: number; ttcMultiplier: number; weight: number }>; startUnix: number | null };
|
|
863
1081
|
/** @internal Set by funnels.js during experiment handling. */
|
|
@@ -992,6 +1210,15 @@ export interface UserProfile {
|
|
|
992
1210
|
avatar?: string;
|
|
993
1211
|
created: string | undefined;
|
|
994
1212
|
distinct_id: string;
|
|
1213
|
+
/**
|
|
1214
|
+
* v1.5.1: when `true`, the engine considers this profile "anonymous" — the
|
|
1215
|
+
* user never reached an `isAuthEvent` step. Profile still exists in
|
|
1216
|
+
* `userProfilesData` (so hooks and downstream tools can see the full
|
|
1217
|
+
* population), but `mixpanel-sender` filters it out before pushing to
|
|
1218
|
+
* `/engage`. Hooks can rescue by deleting the flag inside the `everything`
|
|
1219
|
+
* hook.
|
|
1220
|
+
*/
|
|
1221
|
+
_drop?: boolean;
|
|
995
1222
|
[key: string]: ValueValid;
|
|
996
1223
|
}
|
|
997
1224
|
|
|
@@ -1077,6 +1304,12 @@ export type Result = {
|
|
|
1077
1304
|
userCount?: number;
|
|
1078
1305
|
groupCount?: number;
|
|
1079
1306
|
avgEPS?: number;
|
|
1307
|
+
/**
|
|
1308
|
+
* v1.5.1: count of profiles eligible for Mixpanel `/engage` push (i.e., not
|
|
1309
|
+
* flagged with `_drop: true`). Anonymous non-converters carry `_drop: true`
|
|
1310
|
+
* so `userProfilesData.length - profilesPushed` = dropped profile count.
|
|
1311
|
+
*/
|
|
1312
|
+
profilesPushed?: number;
|
|
1080
1313
|
/** Progress callback summary. Only present when `onProgress` was provided. */
|
|
1081
1314
|
progress?: ProgressSummary;
|
|
1082
1315
|
};
|
|
@@ -1627,7 +1860,7 @@ declare module '@ak--47/dungeon-master/hook-patterns' {
|
|
|
1627
1860
|
* | `attributedBy` | `conversionEvent`, `attributionEvent`, `attributionProperty` | `model` (default: `'lastTouch'`) |
|
|
1628
1861
|
*/
|
|
1629
1862
|
export interface EmulateOptions {
|
|
1630
|
-
type: 'frequencyByFrequency' | 'funnelFrequency' | 'aggregatePerUser' | 'timeToConvert' | 'attributedBy';
|
|
1863
|
+
type: 'frequencyByFrequency' | 'funnelFrequency' | 'aggregatePerUser' | 'timeToConvert' | 'attributedBy' | 'sessionMetrics' | 'retention' | 'distinctCount';
|
|
1631
1864
|
metricEvent?: string;
|
|
1632
1865
|
breakdownByFrequencyOf?: string;
|
|
1633
1866
|
perUser?: boolean;
|
|
@@ -1643,6 +1876,78 @@ export interface EmulateOptions {
|
|
|
1643
1876
|
attributionEvent?: string;
|
|
1644
1877
|
attributionProperty?: string;
|
|
1645
1878
|
model?: 'firstTouch' | 'lastTouch';
|
|
1879
|
+
/**
|
|
1880
|
+
* Pre-built device→user map (e.g. from `buildIdentityMap(profiles)`). When omitted but
|
|
1881
|
+
* `profiles` are supplied with `device_ids`, the map is built automatically. Threaded
|
|
1882
|
+
* through every breakdown type so pre-auth (device_id) events resolve to the same
|
|
1883
|
+
* canonical user as post-auth (user_id) events.
|
|
1884
|
+
*/
|
|
1885
|
+
identityMap?: Map<string, string>;
|
|
1886
|
+
/** v1.5.0 — funnel reentry. After completing all steps, reset and continue scanning. Used by funnelFrequency + timeToConvert sequential modes. */
|
|
1887
|
+
reentry?: boolean;
|
|
1888
|
+
/** v1.5.0 — funnel exclusion steps. If an exclusion event fires between specified steps, terminate the attempt. */
|
|
1889
|
+
exclusionSteps?: Array<{ event: string; afterStep?: number; beforeStep?: number }>;
|
|
1890
|
+
/** v1.5.0 — funnel step property tracking. Captures matched event properties at each step into FunnelResult.stepProperties. */
|
|
1891
|
+
trackStepProperties?: boolean | string[];
|
|
1892
|
+
/** v1.5.0 — partition funnel evaluation by `session_id`; only steps in the same session can complete. */
|
|
1893
|
+
sessionScoped?: boolean;
|
|
1894
|
+
/** v1.5.0 — cross-cutting time-bucketed output. Wraps any breakdown type, returning rows tagged with `period` per UTC bucket. */
|
|
1895
|
+
timeBucket?: 'day' | 'week' | 'month';
|
|
1896
|
+
/**
|
|
1897
|
+
* v1.5.0 — when set with `timeBucket`, enumerates EVERY bucket in `[from, to]`
|
|
1898
|
+
* and emits a `{ period, _empty: true }` marker for buckets with no events
|
|
1899
|
+
* (Mixpanel normal_query.cpp emits zero rows for empty intervals). Without
|
|
1900
|
+
* this, only buckets containing events are returned.
|
|
1901
|
+
*
|
|
1902
|
+
* **Empty-row contract:** consumers MUST filter `r._empty` before any
|
|
1903
|
+
* numerical aggregation. Populated rows have full breakdown fields plus
|
|
1904
|
+
* `period`; empty rows have ONLY `period` and `_empty: true`.
|
|
1905
|
+
*/
|
|
1906
|
+
timeBucketRange?: { from: number | string; to: number | string };
|
|
1907
|
+
/** v1.5.0 — sessionMetrics: filter to sessions containing this event. Omit for all sessions. */
|
|
1908
|
+
metrics?: Array<'count' | 'duration' | 'eventsPerSession'>;
|
|
1909
|
+
|
|
1910
|
+
// v1.5.0 retention extensions
|
|
1911
|
+
/** Retention birth event name. */
|
|
1912
|
+
cohortEvent?: string;
|
|
1913
|
+
/** Retention return event name. */
|
|
1914
|
+
returnEvent?: string;
|
|
1915
|
+
/** Day buckets to check (offsets from birth, ≥1). */
|
|
1916
|
+
dayBuckets?: number[];
|
|
1917
|
+
/** Segment cohort by birth event property value (Mixpanel segment_event=FIRST). */
|
|
1918
|
+
segmentBy?: string;
|
|
1919
|
+
/** CARRY_FORWARD unbounded mode — once retained, counted on all later buckets. */
|
|
1920
|
+
carry_forward?: boolean;
|
|
1921
|
+
/**
|
|
1922
|
+
* v1.5.0 — Mixpanel `birth_can_retain` (retention_query.cpp:1097-1109). Default
|
|
1923
|
+
* `false`: a return event at the EXACT birth ms is NOT counted (strict `<`).
|
|
1924
|
+
* Set `true` to count exact-birth-ms returns (rare; usually a same-event-as-birth
|
|
1925
|
+
* pattern requires COMPOUNDED retention which is not supported here).
|
|
1926
|
+
*/
|
|
1927
|
+
birthCanRetain?: boolean;
|
|
1928
|
+
|
|
1929
|
+
// v1.5.1 distinctCount extensions
|
|
1930
|
+
/** Optional cap on the number of top-N values returned in `top_values`. Defaults to 25 (matches Mixpanel UI). */
|
|
1931
|
+
topN?: number;
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
/** v1.5.0 — config for `emulateBreakdown({ type: 'retention' })`. */
|
|
1935
|
+
export interface RetentionConfig extends EmulateOptions {
|
|
1936
|
+
type: 'retention';
|
|
1937
|
+
cohortEvent: string;
|
|
1938
|
+
returnEvent: string;
|
|
1939
|
+
dayBuckets: number[];
|
|
1940
|
+
segmentBy?: string;
|
|
1941
|
+
carry_forward?: boolean;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
/** v1.5.0 — config for `emulateBreakdown({ type: 'sessionMetrics' })`. */
|
|
1945
|
+
export interface SessionMetricsConfig extends EmulateOptions {
|
|
1946
|
+
type: 'sessionMetrics';
|
|
1947
|
+
/** Optional event filter — only sessions containing this event qualify. */
|
|
1948
|
+
event?: string;
|
|
1949
|
+
/** Which metrics to compute. Default: `['count', 'duration', 'eventsPerSession']`. */
|
|
1950
|
+
metrics?: Array<'count' | 'duration' | 'eventsPerSession'>;
|
|
1646
1951
|
}
|
|
1647
1952
|
|
|
1648
1953
|
declare module '@ak--47/dungeon-master/verify' {
|
|
@@ -1650,6 +1955,48 @@ declare module '@ak--47/dungeon-master/verify' {
|
|
|
1650
1955
|
export function verifyDungeon(config: Dungeon, checks: Array<{ name: string; breakdown: EmulateOptions; assert: (rows: Array<Record<string, unknown>>, ctx: { events: EventSchema[]; profiles: UserProfile[] }) => { pass: boolean; detail?: string } }>): Promise<{ pass: boolean; results: Array<{ name: string; pass: boolean; detail?: string; rows?: Array<Record<string, unknown>> }>; schemaReport: SchemaReport }>;
|
|
1651
1956
|
export function deriveExpectedSchema(config: Dungeon): Map<string, Set<string>>;
|
|
1652
1957
|
export function validateSchema(events: EventSchema[], config: Dungeon): SchemaReport;
|
|
1958
|
+
/** Build a `Map<device_id, canonical_user_id>` by inverting each profile's `device_ids` array. */
|
|
1959
|
+
export function buildIdentityMap(profiles: UserProfile[]): Map<string, string>;
|
|
1960
|
+
/** Resolve canonical user id for an event using the identity map (device→user merge). */
|
|
1961
|
+
export function resolveUserId(event: EventSchema | Record<string, unknown>, identityMap?: Map<string, string>): string | undefined;
|
|
1962
|
+
|
|
1963
|
+
/** Funnel step type — string OR `{ event, where? }` for step-level property filters. */
|
|
1964
|
+
export type FunnelStep = string | { event: string; where?: { prop: string; op: 'eq' | 'neq' | 'gt' | 'lt' | 'gte' | 'lte' | 'contains' | 'not_contains'; value: unknown } };
|
|
1965
|
+
/** Exclusion step config — fires between `afterStep` and `beforeStep` to terminate the funnel attempt. */
|
|
1966
|
+
export interface ExclusionStep { event: string; afterStep?: number; beforeStep?: number }
|
|
1967
|
+
/** Greedy single-pass funnel evaluator options. */
|
|
1968
|
+
export interface FunnelOptions {
|
|
1969
|
+
conversionWindowMs?: number;
|
|
1970
|
+
graceperiod?: boolean;
|
|
1971
|
+
reentry?: boolean;
|
|
1972
|
+
exclusionSteps?: ExclusionStep[];
|
|
1973
|
+
trackStepProperties?: boolean | string[];
|
|
1974
|
+
countMode?: 'uniques' | 'totals';
|
|
1975
|
+
sessionScoped?: boolean;
|
|
1976
|
+
}
|
|
1977
|
+
/** Per-attempt funnel result. */
|
|
1978
|
+
export interface FunnelResult {
|
|
1979
|
+
completed: boolean;
|
|
1980
|
+
reached: number;
|
|
1981
|
+
stepEvents: Array<Record<string, unknown> | null>;
|
|
1982
|
+
stepTimes: Array<number | null>;
|
|
1983
|
+
ttcMs: number | null;
|
|
1984
|
+
completions: number;
|
|
1985
|
+
stepProperties?: Array<Record<string, unknown>>;
|
|
1986
|
+
sessionId?: string;
|
|
1987
|
+
}
|
|
1988
|
+
/** Evaluate a funnel against a user's events. Returns FunnelResult or array (totals mode). */
|
|
1989
|
+
export function evaluateFunnel(events: Array<Record<string, unknown>>, steps: FunnelStep[], options?: FunnelOptions): FunnelResult | FunnelResult[];
|
|
1990
|
+
/** Hold Property Constant — runs parallel sub-funnels per unique value of `holdProperty`. */
|
|
1991
|
+
export function evaluateFunnelHPC(events: Array<Record<string, unknown>>, steps: FunnelStep[], holdProperty: string, options?: FunnelOptions): Map<string | number, FunnelResult | FunnelResult[]>;
|
|
1992
|
+
/** Pick a property snapshot from a FunnelResult for the given segment mode. */
|
|
1993
|
+
export function resolveFunnelSegment(result: FunnelResult, mode: 'first' | 'last' | { step: number }): Record<string, unknown> | undefined;
|
|
1994
|
+
/** Normalize a FunnelStep to the `{ event, where? }` canonical shape. */
|
|
1995
|
+
export function normalizeStep(step: FunnelStep): { event: string; where?: { prop: string; op: string; value: unknown } };
|
|
1996
|
+
/** Test whether an event qualifies for a step's where-clause. */
|
|
1997
|
+
export function matchesStepFilter(event: Record<string, unknown>, where?: { prop: string; op: string; value: unknown }): boolean;
|
|
1998
|
+
/** Partition events into UTC `day` / `week` (ISO) / `month` buckets. */
|
|
1999
|
+
export function partitionByTimeBucket(events: Array<Record<string, unknown>>, bucket: 'day' | 'week' | 'month'): Array<{ period: string; events: Array<Record<string, unknown>> }>;
|
|
1653
2000
|
|
|
1654
2001
|
interface SchemaReport {
|
|
1655
2002
|
pass: boolean;
|
|
@@ -1673,6 +2020,8 @@ declare module '@ak--47/dungeon-master/utils' {
|
|
|
1673
2020
|
export function weighNumRange(min: number, max: number, skew?: number, size?: number): number[];
|
|
1674
2021
|
export function pickAWinner(items: string[], mostChosenIndex?: number): () => string[];
|
|
1675
2022
|
export function initChance(seed?: string): unknown;
|
|
2023
|
+
export function initUserChance(seed?: string): unknown;
|
|
2024
|
+
export function getUserChance(): unknown;
|
|
1676
2025
|
export function TimeSoup(earliestTime: number, latestTime: number, peaks?: number, deviation?: number, mean?: number, dayOfWeekWeights?: number[] | null, hourOfDayWeights?: number[] | null): number;
|
|
1677
2026
|
export function weighArray<T>(items: T[]): T[];
|
|
1678
2027
|
export function generateUser(user_id: string, opts: Record<string, unknown>): Record<string, unknown>;
|