@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
@@ -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.4.5",
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": "rm -f ./data/* && rm -f ./tmp/* && rm -f vscode-profile-*",
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",
@@ -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
- const absolutePath = path.isAbsolute(dungeonPath)
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
- /** Average events per user per active day. The canonical event-volume primitive — born-late users get this rate × their remaining window, so per-day density stays constant. If both this and numEvents are set, this wins. */
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
- /** Percentage of users whose account creation falls within the dataset window (vs. pre-existing). Default (from macro: "flat"): 50 */
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
- /** Bias for birth dates of users born in dataset. -1..1; negative = early skew, positive = recent skew, 0 = uniform. Default (from macro: "flat"): 0 */
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>;