@ak--47/dungeon-master 1.4.4 → 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.
Files changed (67) 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 +147 -0
  9. package/HOOKS.md +1243 -597
  10. package/README.md +140 -5
  11. package/dungeons/technical/ad-spend.js +1 -1
  12. package/dungeons/technical/anonymous-users.js +1 -1
  13. package/dungeons/technical/array-of-object-lookup.js +1 -1
  14. package/dungeons/technical/datagen-v15-verify.js +74 -0
  15. package/dungeons/technical/experiments.js +1 -1
  16. package/dungeons/technical/foobar.js +1 -1
  17. package/dungeons/technical/group-analytics.js +1 -1
  18. package/dungeons/technical/mirror-strategies.js +1 -1
  19. package/dungeons/technical/nested-objects.js +1 -1
  20. package/dungeons/technical/retention-cadence.js +1 -1
  21. package/dungeons/technical/sanity.js +1 -1
  22. package/dungeons/technical/scale-test.js +1 -1
  23. package/dungeons/technical/scd.js +1 -1
  24. package/dungeons/technical/simple.js +1 -1
  25. package/dungeons/technical/simplest.js +74 -20
  26. package/dungeons/technical/text-generation.js +1 -1
  27. package/dungeons/vertical/ai-platform.js +4 -0
  28. package/dungeons/vertical/community.js +9 -3
  29. package/dungeons/vertical/crypto.js +5 -0
  30. package/dungeons/vertical/dating.js +23 -10
  31. package/dungeons/vertical/devtools.js +10 -0
  32. package/dungeons/vertical/ecommerce.js +6 -0
  33. package/dungeons/vertical/education.js +11 -0
  34. package/dungeons/vertical/fintech.js +13 -0
  35. package/dungeons/vertical/fitness.js +10 -0
  36. package/dungeons/vertical/food-delivery.js +9 -0
  37. package/dungeons/vertical/gaming.js +10 -0
  38. package/dungeons/vertical/healthcare.js +5 -0
  39. package/dungeons/vertical/insurance-application.js +10 -0
  40. package/dungeons/vertical/logistics.js +8 -1
  41. package/dungeons/vertical/marketplace.js +7 -0
  42. package/dungeons/vertical/media.js +8 -0
  43. package/dungeons/vertical/real-estate.js +7 -1
  44. package/dungeons/vertical/sass.js +12 -0
  45. package/dungeons/vertical/social.js +9 -0
  46. package/dungeons/vertical/travel.js +5 -0
  47. package/index.js +45 -7
  48. package/lib/core/config-validator.js +270 -7
  49. package/lib/core/context.js +58 -0
  50. package/lib/core/dungeon-loader.js +2 -5
  51. package/lib/generators/events.js +12 -13
  52. package/lib/generators/funnels.js +72 -1
  53. package/lib/hook-helpers/index.js +1 -0
  54. package/lib/hook-helpers/inject.js +95 -0
  55. package/lib/orchestrators/mixpanel-sender.js +27 -1
  56. package/lib/orchestrators/user-loop.js +488 -29
  57. package/lib/templates/macro-presets.js +39 -9
  58. package/lib/utils/utils.js +16 -79
  59. package/lib/verify/counting.js +320 -0
  60. package/lib/verify/emulate-breakdown.js +512 -108
  61. package/lib/verify/funnel-engine.js +539 -0
  62. package/lib/verify/identity.js +78 -0
  63. package/lib/verify/index.js +19 -0
  64. package/lib/verify/verify-dungeon.js +58 -0
  65. package/package.json +4 -2
  66. package/types.d.ts +314 -4
  67. 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.4",
3
+ "version": "1.5.0",
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,7 +43,7 @@
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",
package/types.d.ts CHANGED
@@ -28,6 +28,11 @@ export interface Dungeon {
28
28
  /**
29
29
  * Number of days the dataset spans. Default: 30.
30
30
  *
31
+ * Safe range: `[14, 365]`. Below 14 → strict-bar engine-validation metrics use
32
+ * 14-day windows; results are noisy. Above 365 → memory cost grows linearly.
33
+ * Validator emits a warning below 14; does not clamp (because the window may have
34
+ * been pinned via `datasetStart`/`datasetEnd` upstream).
35
+ *
31
36
  * Three resolution modes:
32
37
  * 1. **`numDays` alone (no datasetStart/End):** Window = `[today - numDays, today]`.
33
38
  * Simplest API for ad-hoc dungeons. NOT deterministic across runs (today changes).
@@ -58,7 +63,14 @@ export interface Dungeon {
58
63
  numEvents?: number;
59
64
  /** Number of unique users to generate. */
60
65
  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. */
66
+ /**
67
+ * Average events per user per active day. The canonical event-volume primitive —
68
+ * born-late users get this rate × their remaining window, so per-day density stays
69
+ * constant. If both this and numEvents are set, this wins.
70
+ *
71
+ * Safe range: `[0.1, 50]`. Above 50 → unrealistic load + memory cost; the v1.5
72
+ * validator strict-clamps to 50 with a warning.
73
+ */
62
74
  avgEventsPerUserPerDay?: number;
63
75
  /** Output format for files written to disk. */
64
76
  format?: "csv" | "json" | "parquet" | string;
@@ -119,6 +131,24 @@ export interface Dungeon {
119
131
  gzip?: boolean;
120
132
  /** If true, prints progress to stdout during generation. */
121
133
  verbose?: boolean;
134
+ /**
135
+ * Optional callback that receives periodic progress updates during generation,
136
+ * import, and pipeline step transitions. Fire-and-forget: the callback is never
137
+ * awaited. If it throws 3 times, it is silently disabled for the rest of the job.
138
+ *
139
+ * The `update` argument is a discriminated union on `phase`:
140
+ * - `"generation"` — user/event counts, EPS, memory, percent complete
141
+ * - `"import"` — record type, processed/total counts, EPS, bytes
142
+ * - `"step"` — pipeline step name with start/complete status and duration
143
+ *
144
+ * @example
145
+ * onProgress: (update) => {
146
+ * if (update.phase === 'generation') ws.send(JSON.stringify(update));
147
+ * }
148
+ */
149
+ onProgress?: (update: ProgressUpdate) => void;
150
+ /** Minimum interval (ms) between progress callback invocations. Default: 500. Only throttles `generation` and `import` phases; `step` updates always fire immediately. */
151
+ progressInterval?: number;
122
152
  /**
123
153
  * @deprecated Prefer `avgDevicePerUser`. `true` is now an alias for `avgDevicePerUser: 1`
124
154
  * (single sticky device per user, every event stamped with that `device_id`). `false`
@@ -224,12 +254,81 @@ export interface Dungeon {
224
254
  // ── Distribution Controls ──
225
255
  // These three knobs are normally set by the `macro` preset (default "flat").
226
256
  // Setting them on the dungeon config directly overrides the preset's value.
227
- /** Percentage of users whose account creation falls within the dataset window (vs. pre-existing). Default (from macro: "flat"): 50 */
257
+ /**
258
+ * Percentage of users whose account creation falls within the dataset window (vs. pre-existing).
259
+ *
260
+ * Safe range: `[0, 100]` (sanity-clamped). Recommended `[0, 60]`. **v1.5 strict-clamp:**
261
+ * when `macro` is set to a named preset AND the user explicitly sets this field, it is
262
+ * clamped to the preset's default born% (flat=12, steady=12, growth=30, viral=55,
263
+ * decline=5) to preserve the macro's characteristic shape. Users who need higher born%
264
+ * should switch macros (flat→growth, growth→viral). When `macro` is not set, no clamp
265
+ * fires (legacy backward-compat).
266
+ */
228
267
  percentUsersBornInDataset?: number;
229
- /** Bias for birth dates of users born in dataset. -1..1; negative = early skew, positive = recent skew, 0 = uniform. Default (from macro: "flat"): 0 */
268
+ /**
269
+ * Bias for birth dates of users born in dataset. -1..1; negative = early skew,
270
+ * positive = recent skew, 0 = uniform.
271
+ *
272
+ * Safe range: `[-0.5, 0.5]`. **v1.5 strict-clamp:** values outside `[-0.5, 0.5]` are
273
+ * clamped to the nearest bound (above 0.5 = unusable right-skew; below -0.5 = unusable
274
+ * left-skew). Compound clamp: when explicit `percentUsersBornInDataset > 60` AND
275
+ * explicit `bornRecentBias > 0.4`, bias is clamped to 0.3 to prevent right-edge
276
+ * cumulative-acquisition explosion. Macro presets (e.g., viral=0.6) are exempt.
277
+ */
230
278
  bornRecentBias?: number;
231
279
  /** 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" */
232
280
  preExistingSpread?: "pinned" | "uniform";
281
+
282
+ // ── v1.5 Distinct-Day + Mixpanel Cap Primitives ──
283
+ /**
284
+ * Mean number of distinct UTC days each user fires events on. CONCENTRATOR semantic —
285
+ * total event count is preserved (still `avgEventsPerUserPerDay × userActiveDays`),
286
+ * but events cluster onto fewer days. Per-user count drawn from
287
+ * `normal(mean=avgActiveDaysPerUser, sd=mean/3)`, clamped to `[1, userActiveDays]`.
288
+ *
289
+ * Default: undefined (legacy — every window-day potentially active, no concentration).
290
+ *
291
+ * Per-active-day rate inflates: `(avgEventsPerUserPerDay × numDays) / avgActiveDaysPerUser`.
292
+ * Validator warns when implied per-active-day rate > 50.
293
+ *
294
+ * Day picking: weighted-without-replacement from candidate UTC days using
295
+ * `soup.dayOfWeekWeights`, so weekly rhythm is preserved at the cohort level.
296
+ *
297
+ * **Incompatibility with `engagementDecay`:** decay drops events from late picked days,
298
+ * eroding the effective active-day count below the configured target. Use one or the
299
+ * other; if you need both, write decay logic in an `everything` hook scoped to specific
300
+ * cohorts. See HOOKS.md §2.5.
301
+ *
302
+ * Safe range: `[1, numDays * 0.5]`. Above 50% of `numDays` defeats the concentrator
303
+ * purpose; the v1.5 validator strict-clamps to `floor(numDays * 0.5)` with a warning.
304
+ */
305
+ avgActiveDaysPerUser?: number;
306
+ /**
307
+ * Maximum number of UTM-stamped events per user. Matches Mixpanel's `TOUCHPOINTS_LIMIT`
308
+ * (`backend/libquery/properties_over_time/attributed_value_reader.cpp` line 16).
309
+ *
310
+ * Default: 10.
311
+ *
312
+ * When `hasCampaigns: true` and a user has more eligible events than the cap, the
313
+ * engine takes a uniform-random sample of size `cap` from the eligible pool (seeded,
314
+ * deterministic), sorts the sample chronologically, then stamps UTMs. Sampling
315
+ * across the user's lifetime — NOT first-N-chronological — preserves realistic
316
+ * touch distribution and lets Mixpanel's last-10-window report give meaningful
317
+ * first/last-touch attribution.
318
+ *
319
+ * Set to `Infinity` to disable the cap (legacy behavior, ~25% of eligible events stamped).
320
+ */
321
+ maxTouchpointsPerUser?: number;
322
+ /**
323
+ * If true (default), the engine sorts each user's events ascending by `time` after
324
+ * the `everything` hook returns, before the events are pushed to storage. Defends
325
+ * against the most common new footgun: hooks that `push()` cloned events with arbitrary
326
+ * timestamps and break the greedy funnel engine's chronological-order requirement.
327
+ *
328
+ * Set to `false` to preserve the hook's order (advanced — hook must guarantee its own
329
+ * ordering for downstream Mixpanel-aligned counting).
330
+ */
331
+ autoSortAfterEverything?: boolean;
233
332
  }
234
333
 
235
334
  export type SCDProp = {
@@ -657,6 +756,12 @@ export interface Context {
657
756
  incrementUserCount(): void;
658
757
  incrementEventCount(): void;
659
758
  isBatchMode(): boolean;
759
+
760
+ // Progress callback
761
+ /** Fire a progress update to the caller's `onProgress` callback (throttled, fault-tolerant). */
762
+ reportProgress(update: ProgressUpdate): void;
763
+ /** Return the progress callback summary (updates delivered, errors, disabled flag). */
764
+ getProgressSummary(): ProgressSummary;
660
765
  }
661
766
 
662
767
  /**
@@ -796,6 +901,22 @@ export interface Funnel {
796
901
  * the time it takes (on average) to convert in hours
797
902
  */
798
903
  timeToConvert?: number;
904
+ /**
905
+ * Mixpanel-style conversion window cap, in DAYS. Funnel-step events after step 0 must
906
+ * land within `conversionWindowDays * 86400000` ms of step 0's timestamp (strict `<`,
907
+ * matching `is_within_conversion_window` in `backend/arb/reader/funnels/conversion_window.cpp`).
908
+ *
909
+ * Default: 30 (Mixpanel UI default).
910
+ *
911
+ * If unset and `timeToConvert / 24 >= 30`, the validator auto-bumps to
912
+ * `min(180, ceil(timeToConvert / 24 * 1.5))` and warns. Hard cap: 180 days
913
+ * (Mixpanel's maximum). Validator throws if explicitly set above 180.
914
+ *
915
+ * The generator caps funnel runs at `conversionWindowDays * 86400000 - 1` ms (1ms slack
916
+ * to clear the strict-`<` boundary). The verifier (`verifyDungeon`) reads this field
917
+ * and applies it automatically when emulating funnel breakdowns.
918
+ */
919
+ conversionWindowDays?: number;
799
920
  /**
800
921
  * funnel properties go onto each event in the funnel and are held constant
801
922
  */
@@ -834,6 +955,32 @@ export interface Funnel {
834
955
  * @see AttemptsConfig
835
956
  */
836
957
  attempts?: AttemptsConfig;
958
+
959
+ // v1.5.0 — funnel extensions
960
+ /**
961
+ * v1.5.0 — Events that terminate the funnel attempt for non-converters. The generator
962
+ * stamps 1-2 cloned exclusion events between the last completed step and where the next
963
+ * step would have been; the verifier reads this and applies them as exclusionSteps to
964
+ * `evaluateFunnel`. Each entry MUST be declared in `events[]` (schema-first) — the
965
+ * validator throws otherwise.
966
+ */
967
+ exclusionEvents?: string[];
968
+ /**
969
+ * v1.5.0 — Verifier-only hint. When `true`, the verifier evaluates with reentry
970
+ * enabled (counts every completion). Generator behavior unchanged.
971
+ */
972
+ reentry?: boolean;
973
+ /**
974
+ * v1.5.0 — Verifier-only hint. Per-step property conditions; the verifier mutates
975
+ * its step list to attach `where`-clauses at the matching index. Generator behavior
976
+ * unchanged.
977
+ */
978
+ stepFilters?: Record<number, {
979
+ prop: string;
980
+ op: 'eq' | 'neq' | 'gt' | 'lt' | 'gte' | 'lte' | 'contains' | 'not_contains';
981
+ value: unknown;
982
+ }>;
983
+
837
984
  /** @internal Resolved experiment config set by config-validator. */
838
985
  _experiment?: { name: string; variants: Array<{ name: string; conversionMultiplier: number; ttcMultiplier: number; weight: number }>; startUnix: number | null };
839
986
  /** @internal Set by funnels.js during experiment handling. */
@@ -1053,8 +1200,61 @@ export type Result = {
1053
1200
  userCount?: number;
1054
1201
  groupCount?: number;
1055
1202
  avgEPS?: number;
1203
+ /** Progress callback summary. Only present when `onProgress` was provided. */
1204
+ progress?: ProgressSummary;
1056
1205
  };
1057
1206
 
1207
+ // ============= Progress Callback Types =============
1208
+
1209
+ /** Discriminator for progress update types. */
1210
+ export type ProgressPhase = "generation" | "import" | "step";
1211
+
1212
+ /** Progress update emitted during user/event generation (throttled to `progressInterval`). */
1213
+ export interface ProgressGeneration {
1214
+ phase: "generation";
1215
+ users: number;
1216
+ events: number;
1217
+ eps: number;
1218
+ memory: string;
1219
+ elapsed: string;
1220
+ percentComplete: number;
1221
+ }
1222
+
1223
+ /** Progress update emitted during Mixpanel import (throttled to `progressInterval`). */
1224
+ export interface ProgressImport {
1225
+ phase: "import";
1226
+ recordType: string;
1227
+ processed: number;
1228
+ total: number;
1229
+ eps: string;
1230
+ bytesProcessed: number;
1231
+ }
1232
+
1233
+ /** Progress update emitted at pipeline step boundaries (not throttled). */
1234
+ export interface ProgressStep {
1235
+ phase: "step";
1236
+ step: string;
1237
+ status: "start" | "complete";
1238
+ /** Milliseconds elapsed (only present on `status: "complete"`). */
1239
+ duration?: number;
1240
+ }
1241
+
1242
+ /** Discriminated union of all progress update types. Discriminate on the `phase` field. */
1243
+ export type ProgressUpdate = ProgressGeneration | ProgressImport | ProgressStep;
1244
+
1245
+ /** Convenience type for the `onProgress` callback signature. */
1246
+ export type ProgressCallback = (update: ProgressUpdate) => void;
1247
+
1248
+ /** Summary of progress callback activity, included in the job result. */
1249
+ export interface ProgressSummary {
1250
+ /** Total number of updates successfully delivered to the callback. */
1251
+ updates: number;
1252
+ /** Number of times the callback threw (0-3; disabled after 3). */
1253
+ errors: number;
1254
+ /** True if the callback was disabled due to repeated failures. */
1255
+ disabled: boolean;
1256
+ }
1257
+
1058
1258
  // ============= Advanced Feature Types =============
1059
1259
 
1060
1260
  /**
@@ -1550,7 +1750,7 @@ declare module '@ak--47/dungeon-master/hook-patterns' {
1550
1750
  * | `attributedBy` | `conversionEvent`, `attributionEvent`, `attributionProperty` | `model` (default: `'lastTouch'`) |
1551
1751
  */
1552
1752
  export interface EmulateOptions {
1553
- type: 'frequencyByFrequency' | 'funnelFrequency' | 'aggregatePerUser' | 'timeToConvert' | 'attributedBy';
1753
+ type: 'frequencyByFrequency' | 'funnelFrequency' | 'aggregatePerUser' | 'timeToConvert' | 'attributedBy' | 'sessionMetrics' | 'retention';
1554
1754
  metricEvent?: string;
1555
1755
  breakdownByFrequencyOf?: string;
1556
1756
  perUser?: boolean;
@@ -1566,6 +1766,74 @@ export interface EmulateOptions {
1566
1766
  attributionEvent?: string;
1567
1767
  attributionProperty?: string;
1568
1768
  model?: 'firstTouch' | 'lastTouch';
1769
+ /**
1770
+ * Pre-built device→user map (e.g. from `buildIdentityMap(profiles)`). When omitted but
1771
+ * `profiles` are supplied with `device_ids`, the map is built automatically. Threaded
1772
+ * through every breakdown type so pre-auth (device_id) events resolve to the same
1773
+ * canonical user as post-auth (user_id) events.
1774
+ */
1775
+ identityMap?: Map<string, string>;
1776
+ /** v1.5.0 — funnel reentry. After completing all steps, reset and continue scanning. Used by funnelFrequency + timeToConvert sequential modes. */
1777
+ reentry?: boolean;
1778
+ /** v1.5.0 — funnel exclusion steps. If an exclusion event fires between specified steps, terminate the attempt. */
1779
+ exclusionSteps?: Array<{ event: string; afterStep?: number; beforeStep?: number }>;
1780
+ /** v1.5.0 — funnel step property tracking. Captures matched event properties at each step into FunnelResult.stepProperties. */
1781
+ trackStepProperties?: boolean | string[];
1782
+ /** v1.5.0 — partition funnel evaluation by `session_id`; only steps in the same session can complete. */
1783
+ sessionScoped?: boolean;
1784
+ /** v1.5.0 — cross-cutting time-bucketed output. Wraps any breakdown type, returning rows tagged with `period` per UTC bucket. */
1785
+ timeBucket?: 'day' | 'week' | 'month';
1786
+ /**
1787
+ * v1.5.0 — when set with `timeBucket`, enumerates EVERY bucket in `[from, to]`
1788
+ * and emits a `{ period, _empty: true }` marker for buckets with no events
1789
+ * (Mixpanel normal_query.cpp emits zero rows for empty intervals). Without
1790
+ * this, only buckets containing events are returned.
1791
+ *
1792
+ * **Empty-row contract:** consumers MUST filter `r._empty` before any
1793
+ * numerical aggregation. Populated rows have full breakdown fields plus
1794
+ * `period`; empty rows have ONLY `period` and `_empty: true`.
1795
+ */
1796
+ timeBucketRange?: { from: number | string; to: number | string };
1797
+ /** v1.5.0 — sessionMetrics: filter to sessions containing this event. Omit for all sessions. */
1798
+ metrics?: Array<'count' | 'duration' | 'eventsPerSession'>;
1799
+
1800
+ // v1.5.0 retention extensions
1801
+ /** Retention birth event name. */
1802
+ cohortEvent?: string;
1803
+ /** Retention return event name. */
1804
+ returnEvent?: string;
1805
+ /** Day buckets to check (offsets from birth, ≥1). */
1806
+ dayBuckets?: number[];
1807
+ /** Segment cohort by birth event property value (Mixpanel segment_event=FIRST). */
1808
+ segmentBy?: string;
1809
+ /** CARRY_FORWARD unbounded mode — once retained, counted on all later buckets. */
1810
+ carry_forward?: boolean;
1811
+ /**
1812
+ * v1.5.0 — Mixpanel `birth_can_retain` (retention_query.cpp:1097-1109). Default
1813
+ * `false`: a return event at the EXACT birth ms is NOT counted (strict `<`).
1814
+ * Set `true` to count exact-birth-ms returns (rare; usually a same-event-as-birth
1815
+ * pattern requires COMPOUNDED retention which is not supported here).
1816
+ */
1817
+ birthCanRetain?: boolean;
1818
+ }
1819
+
1820
+ /** v1.5.0 — config for `emulateBreakdown({ type: 'retention' })`. */
1821
+ export interface RetentionConfig extends EmulateOptions {
1822
+ type: 'retention';
1823
+ cohortEvent: string;
1824
+ returnEvent: string;
1825
+ dayBuckets: number[];
1826
+ segmentBy?: string;
1827
+ carry_forward?: boolean;
1828
+ }
1829
+
1830
+ /** v1.5.0 — config for `emulateBreakdown({ type: 'sessionMetrics' })`. */
1831
+ export interface SessionMetricsConfig extends EmulateOptions {
1832
+ type: 'sessionMetrics';
1833
+ /** Optional event filter — only sessions containing this event qualify. */
1834
+ event?: string;
1835
+ /** Which metrics to compute. Default: `['count', 'duration', 'eventsPerSession']`. */
1836
+ metrics?: Array<'count' | 'duration' | 'eventsPerSession'>;
1569
1837
  }
1570
1838
 
1571
1839
  declare module '@ak--47/dungeon-master/verify' {
@@ -1573,6 +1841,48 @@ declare module '@ak--47/dungeon-master/verify' {
1573
1841
  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 }>;
1574
1842
  export function deriveExpectedSchema(config: Dungeon): Map<string, Set<string>>;
1575
1843
  export function validateSchema(events: EventSchema[], config: Dungeon): SchemaReport;
1844
+ /** Build a `Map<device_id, canonical_user_id>` by inverting each profile's `device_ids` array. */
1845
+ export function buildIdentityMap(profiles: UserProfile[]): Map<string, string>;
1846
+ /** Resolve canonical user id for an event using the identity map (device→user merge). */
1847
+ export function resolveUserId(event: EventSchema | Record<string, unknown>, identityMap?: Map<string, string>): string | undefined;
1848
+
1849
+ /** Funnel step type — string OR `{ event, where? }` for step-level property filters. */
1850
+ export type FunnelStep = string | { event: string; where?: { prop: string; op: 'eq' | 'neq' | 'gt' | 'lt' | 'gte' | 'lte' | 'contains' | 'not_contains'; value: unknown } };
1851
+ /** Exclusion step config — fires between `afterStep` and `beforeStep` to terminate the funnel attempt. */
1852
+ export interface ExclusionStep { event: string; afterStep?: number; beforeStep?: number }
1853
+ /** Greedy single-pass funnel evaluator options. */
1854
+ export interface FunnelOptions {
1855
+ conversionWindowMs?: number;
1856
+ graceperiod?: boolean;
1857
+ reentry?: boolean;
1858
+ exclusionSteps?: ExclusionStep[];
1859
+ trackStepProperties?: boolean | string[];
1860
+ countMode?: 'uniques' | 'totals';
1861
+ sessionScoped?: boolean;
1862
+ }
1863
+ /** Per-attempt funnel result. */
1864
+ export interface FunnelResult {
1865
+ completed: boolean;
1866
+ reached: number;
1867
+ stepEvents: Array<Record<string, unknown> | null>;
1868
+ stepTimes: Array<number | null>;
1869
+ ttcMs: number | null;
1870
+ completions: number;
1871
+ stepProperties?: Array<Record<string, unknown>>;
1872
+ sessionId?: string;
1873
+ }
1874
+ /** Evaluate a funnel against a user's events. Returns FunnelResult or array (totals mode). */
1875
+ export function evaluateFunnel(events: Array<Record<string, unknown>>, steps: FunnelStep[], options?: FunnelOptions): FunnelResult | FunnelResult[];
1876
+ /** Hold Property Constant — runs parallel sub-funnels per unique value of `holdProperty`. */
1877
+ export function evaluateFunnelHPC(events: Array<Record<string, unknown>>, steps: FunnelStep[], holdProperty: string, options?: FunnelOptions): Map<string | number, FunnelResult | FunnelResult[]>;
1878
+ /** Pick a property snapshot from a FunnelResult for the given segment mode. */
1879
+ export function resolveFunnelSegment(result: FunnelResult, mode: 'first' | 'last' | { step: number }): Record<string, unknown> | undefined;
1880
+ /** Normalize a FunnelStep to the `{ event, where? }` canonical shape. */
1881
+ export function normalizeStep(step: FunnelStep): { event: string; where?: { prop: string; op: string; value: unknown } };
1882
+ /** Test whether an event qualifies for a step's where-clause. */
1883
+ export function matchesStepFilter(event: Record<string, unknown>, where?: { prop: string; op: string; value: unknown }): boolean;
1884
+ /** Partition events into UTC `day` / `week` (ISO) / `month` buckets. */
1885
+ export function partitionByTimeBucket(events: Array<Record<string, unknown>>, bucket: 'day' | 'week' | 'month'): Array<{ period: string; events: Array<Record<string, unknown>> }>;
1576
1886
 
1577
1887
  interface SchemaReport {
1578
1888
  pass: boolean;
@@ -1,162 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Smoke-test runner — runs every dungeon at tiny scale in parallel to verify it
4
- * loads, generates events, and writes output without crashing.
5
- *
6
- * Default scale: 100 users, 1000 events per dungeon. NOT for verification —
7
- * use scripts/verify-runner.mjs at full fidelity for that.
8
- *
9
- * Usage:
10
- * node scripts/smoke-test-all.mjs
11
- * node scripts/smoke-test-all.mjs --dir dungeons/vertical # default
12
- * node scripts/smoke-test-all.mjs --dir dungeons/technical
13
- * node scripts/smoke-test-all.mjs --dir dungeons # both subdirs
14
- * node scripts/smoke-test-all.mjs --concurrency 4 # default: cpu count
15
- * node scripts/smoke-test-all.mjs --users 500 --events 5000 # override scale
16
- *
17
- * Output: per-dungeon PASS/FAIL line + a final summary table. Spawns each
18
- * dungeon as a child node process so a crash in one doesn't abort the run.
19
- */
20
- import { spawn } from 'child_process';
21
- import { readdirSync, statSync, rmSync, existsSync, mkdirSync } from 'fs';
22
- import path from 'path';
23
- import os from 'os';
24
-
25
- const args = process.argv.slice(2);
26
- function arg(name, fallback) {
27
- const i = args.indexOf(`--${name}`);
28
- return i === -1 ? fallback : args[i + 1];
29
- }
30
-
31
- const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
32
- const dirArg = arg('dir', 'dungeons/vertical');
33
- const numUsers = parseInt(arg('users', '100'), 10);
34
- const numEvents = parseInt(arg('events', '1000'), 10);
35
- const concurrency = parseInt(arg('concurrency', String(Math.max(2, os.cpus().length))), 10);
36
- const keep = args.includes('--keep');
37
-
38
- function findDungeons(dir) {
39
- const abs = path.resolve(ROOT, dir);
40
- if (!existsSync(abs)) return [];
41
- const stat = statSync(abs);
42
- if (stat.isFile()) return [abs];
43
- const out = [];
44
- for (const entry of readdirSync(abs)) {
45
- const full = path.join(abs, entry);
46
- const s = statSync(full);
47
- if (s.isDirectory()) out.push(...findDungeons(full));
48
- else if (entry.endsWith('.js')) out.push(full);
49
- }
50
- return out;
51
- }
52
-
53
- const dungeons = findDungeons(dirArg).sort();
54
- if (dungeons.length === 0) {
55
- console.error(`No dungeons found under ${dirArg}`);
56
- process.exit(1);
57
- }
58
-
59
- const dataDir = path.join(ROOT, 'data');
60
- mkdirSync(dataDir, { recursive: true });
61
-
62
- const RUNNER = `
63
- import generate from '${path.join(ROOT, 'index.js')}';
64
- // With node -e, process.argv = [nodePath, ...userArgs] — no script slot.
65
- const [dungeonPath, name, numUsers, numEvents] = process.argv.slice(1);
66
- const r = await generate(dungeonPath, {
67
- numUsers: parseInt(numUsers, 10),
68
- numEvents: parseInt(numEvents, 10),
69
- avgEventsPerUserPerDay: undefined, // force numEvents path
70
- writeToDisk: true,
71
- name,
72
- format: 'json',
73
- verbose: false,
74
- concurrency: 1,
75
- token: '',
76
- serviceAccount: 'fake', serviceSecret: 'fake', projectId: '1',
77
- });
78
- console.log(JSON.stringify({ eventCount: r.eventCount, userCount: r.userCount }));
79
- `;
80
-
81
- function runOne(dungeonPath) {
82
- const base = path.basename(dungeonPath, '.js');
83
- const namePrefix = `smoke-${base}`;
84
- return new Promise(resolve => {
85
- const start = Date.now();
86
- const child = spawn(
87
- process.execPath,
88
- ['--input-type=module', '-e', RUNNER, dungeonPath, namePrefix, String(numUsers), String(numEvents)],
89
- { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'] }
90
- );
91
- let out = '';
92
- let err = '';
93
- child.stdout.on('data', d => { out += d.toString(); });
94
- child.stderr.on('data', d => { err += d.toString(); });
95
- child.on('close', code => {
96
- const ms = Date.now() - start;
97
- let result;
98
- try {
99
- const lastLine = out.trim().split('\n').filter(Boolean).pop() || '{}';
100
- result = JSON.parse(lastLine);
101
- } catch {
102
- result = {};
103
- }
104
- const ok = code === 0 && result.eventCount > 0;
105
- if (!keep) {
106
- try {
107
- for (const f of readdirSync(dataDir)) {
108
- if (f.startsWith(`${namePrefix}-`)) rmSync(path.join(dataDir, f));
109
- }
110
- } catch {}
111
- }
112
- resolve({
113
- dungeon: path.relative(ROOT, dungeonPath),
114
- ok,
115
- code,
116
- ms,
117
- eventCount: result.eventCount || 0,
118
- userCount: result.userCount || 0,
119
- err: ok ? '' : (err.trim().split('\n').slice(-3).join(' | ') || `exit ${code}`),
120
- });
121
- });
122
- });
123
- }
124
-
125
- console.log(`Smoke test: ${dungeons.length} dungeons, ${numUsers} users / ${numEvents} events each, concurrency=${concurrency}`);
126
- const queue = [...dungeons];
127
- const results = [];
128
- const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
129
- while (queue.length) {
130
- const d = queue.shift();
131
- const res = await runOne(d);
132
- const tag = res.ok ? 'PASS' : 'FAIL';
133
- console.log(` [${tag}] ${path.basename(res.dungeon)} — ${res.eventCount} events, ${res.userCount} users, ${(res.ms / 1000).toFixed(2)}s${res.ok ? '' : ` — ${res.err}`}`);
134
- results.push(res);
135
- }
136
- });
137
- await Promise.all(workers);
138
-
139
- results.sort((a, b) => a.dungeon.localeCompare(b.dungeon));
140
- const fails = results.filter(r => !r.ok);
141
-
142
- console.log('\n┌─────────────────────────────────────────────────┬───────┬────────┬───────┬──────┐');
143
- console.log('│ Dungeon │ State │ Events │ Users │ ms │');
144
- console.log('├─────────────────────────────────────────────────┼───────┼────────┼───────┼──────┤');
145
- for (const r of results) {
146
- const name = r.dungeon.padEnd(47).slice(0, 47);
147
- const state = (r.ok ? 'PASS' : 'FAIL').padEnd(5);
148
- const ev = String(r.eventCount).padStart(6);
149
- const u = String(r.userCount).padStart(5);
150
- const ms = String(r.ms).padStart(4);
151
- console.log(`│ ${name} │ ${state} │ ${ev} │ ${u} │ ${ms} │`);
152
- }
153
- console.log('└─────────────────────────────────────────────────┴───────┴────────┴───────┴──────┘');
154
-
155
- console.log(`\n${results.length - fails.length}/${results.length} passed`);
156
- if (fails.length > 0) {
157
- console.log('\nFailures:');
158
- for (const f of fails) {
159
- console.log(` ✗ ${f.dungeon}: ${f.err}`);
160
- }
161
- process.exit(1);
162
- }