@ak--47/dungeon-master 1.5.1 → 1.5.3

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.
@@ -134,7 +134,12 @@ FROM (
134
134
 
135
135
  ## Step 3: Write Report
136
136
 
137
- Create `soup-analysis.md` in the project root with:
137
+ Create `soup-analysis.md` with the structure below. For a user dungeon
138
+ (`dungeons/user/<name>/<name>.js`) write it into the dungeon's folder
139
+ (`dungeons/user/<name>/soup-analysis.md`) — everything about a dungeon lives in
140
+ its folder. Otherwise write it to the project root. (The generated
141
+ `./data/soup-analysis-EVENTS.json` is throwaway verification data — leave it in
142
+ `./data/`.) Contents:
138
143
 
139
144
  1. **Config**: The soup parameters used (peaks, deviation, mean, numDays)
140
145
  2. **Summary stats**: Total events, event count, avg EPS
@@ -18,7 +18,7 @@ box. It does **NOT** engineer story trends or magic numbers — those are the
18
18
  should be:
19
19
 
20
20
  ```
21
- /write-hooks dungeons/user/<your-dungeon>.js "describe the trends to engineer"
21
+ /write-hooks dungeons/user/<name>/<name>.js "describe the trends to engineer"
22
22
  ```
23
23
 
24
24
  In scope here:
@@ -28,9 +28,18 @@ In scope here:
28
28
  `isFirstFunnel`, `attempts`
29
29
  - Event flags: `isAuthEvent`, `isAttributionEvent`, `isFirstEvent`,
30
30
  `isStrictEvent`, `isChurnEvent`, `isSessionStartEvent`
31
- - Top-level: `datasetStart`, `datasetEnd`, `numUsers`, `avgEventsPerUserPerDay`,
32
- `seed`, `format`, device flags, `avgDevicePerUser`, `hasLocation`,
33
- `hasCampaigns`, `hasSessionIds`, `hasAvatar`, `macro`, `soup`
31
+ - Top-level scale + data model: `datasetStart`, `datasetEnd`, `numUsers`,
32
+ `avgEventsPerUserPerDay`, `seed`, `userSeed`, `format`, `macro`, `soup`,
33
+ `retentionCurve`, `avgActiveDaysPerUser`, `maxTouchpointsPerUser`
34
+ - **Sub-object API (v1.5+):** group related keys into:
35
+ - `credentials: { token, region, serviceAccount, serviceSecret, projectId }`
36
+ - `switches: { hasLocation, hasCampaigns, hasSessionIds, hasAvatar,
37
+ hasIOSDevices, hasAndroidDevices, hasDesktopDevices, hasBrowser,
38
+ isAnonymous, alsoInferFunnels, hasAdSpend, hasAttributionFlags }`
39
+ - `identity: { avgDevicePerUser, sessionTimeout }`
40
+
41
+ Old top-level keys keep working (verbose warn nudges migration), but new
42
+ dungeons should ship the sub-object shape.
34
43
  - Surviving advanced entities: `personas`, `worldEvents`, `engagementDecay`,
35
44
  `dataQuality` — use sparingly
36
45
 
@@ -54,63 +63,83 @@ Before writing any code, scan:
54
63
  - `lib/utils/utils.js` — `pickAWinner`, `weighNumRange`, `initChance`, `exhaust`,
55
64
  `takeSome` for property value distributions
56
65
  - `dungeons/vertical/sass.js` — B2B reference dungeon with full identity model
57
- - `dungeons/user/my-buddy.js` — consumer-app reference (gitignored)
66
+ - `dungeons/user/my-buddy/my-buddy.js` — consumer-app reference (gitignored)
58
67
  - `dungeons/technical/identity-model-verify.js` — minimal identity-model fixture
59
68
 
60
69
  ## File structure
61
70
 
62
- ```javascript
63
- // ── TWEAK THESE ──
64
- const SEED = "dm4-VERTICAL";
65
- const num_days = 120;
66
- const num_users = 5_000;
67
- const avg_events_per_user_per_day = 1.2;
68
- let token = "your-mixpanel-token";
69
- if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
71
+ Use the canonical layout — sections in this fixed order. Skip any section
72
+ that doesn't apply (e.g., schema-only dungeons omit HOOK STORIES and KNOBS).
73
+ Section delimiter: `// ── SECTION NAME ──` (box-drawing chars).
70
74
 
75
+ ```javascript
76
+ // ── IMPORTS ──
71
77
  import dayjs from "dayjs";
78
+ import utc from "dayjs/plugin/utc.js";
79
+ dayjs.extend(utc);
72
80
  import "dotenv/config";
73
81
  import * as u from "../../lib/utils/utils.js";
74
82
  import * as v from "ak-tools";
75
-
76
- const chance = u.initChance(SEED);
77
-
78
- /** @typedef {import("../../types").Dungeon} Config */
79
-
80
- // Generate consistent IDs at module level
81
- const productIds = v.range(1, 200).map(n => `prod_${v.uid(8)}`);
82
-
83
- /**
84
- * ═══════════════════════════════════════════════════════════════
85
- * DATASET OVERVIEW
86
- * ═══════════════════════════════════════════════════════════════
83
+ /** @typedef {import("../../types").Dungeon} Config */
84
+
85
+ // ── OVERVIEW ──
86
+ /*
87
+ * NAME: <BrandName>
88
+ * APP: <2-4 line description: what users do, core flow, monetization>
89
+ * SCALE: <numUsers> users, ~<numEvents> events, <numDays> days (<start> <end>)
90
+ * CORE LOOP: <event1> → <event2> → <event3> → ...
87
91
  *
88
- * App Name — what it models, the core user loop, monetization.
89
- * - N users over M days, ~X events
90
- * - Key entities and relationships
91
- * - Why these events/properties were chosen
92
+ * EVENTS (N):
93
+ * <event name (weight)> > ... (sorted by weight desc)
92
94
  *
93
- * NO STORY TRENDS YET — schema only. Pass to /write-hooks for engineering.
95
+ * FUNNELS (N):
96
+ * - <Funnel name>: <step> → <step> (N%)
97
+ *
98
+ * USER PROPS: <prop1, prop2, ...>
99
+ * SUPER PROPS: <prop1, prop2, ...>
100
+ * SCD PROPS: <prop (values, freq, max)>
101
+ * GROUPS: <key1, key2 | none>
94
102
  */
95
103
 
96
- /** @type {Config} */
97
- const config = {
98
- token, seed: SEED,
99
- numDays: num_days,
100
- avgEventsPerUserPerDay: avg_events_per_user_per_day,
101
- numUsers: num_users,
104
+ // ── SCALE ──
105
+ const SEED = "dm4-VERTICAL";
106
+ const NUM_USERS = 5_000;
107
+ const DATASET_START = "2026-01-01T00:00:00Z";
108
+ const DATASET_END = "2026-05-01T23:59:59Z";
109
+ const EVENTS_PER_DAY = 1.2;
110
+ const token = process.env.MP_TOKEN || "your-mixpanel-token";
102
111
 
103
- // Identity model — see "Identity guidelines" below
104
- hasAnonIds: true,
105
- avgDevicePerUser: 2,
106
- hasSessionIds: true,
112
+ const chance = u.initChance(SEED);
107
113
 
108
- // I/O
109
- format: "json", gzip: true, writeToDisk: false, concurrency: 1,
114
+ // ── DATA ARRAYS ── (omit if none)
115
+ const productIds = v.range(1, 200).map(n => `prod_${v.uid(8)}`);
110
116
 
111
- // Realistic platform
112
- hasLocation: true, hasAndroidDevices: false, hasIOSDevices: false,
113
- hasDesktopDevices: true, hasBrowser: true, hasAvatar: true,
117
+ // ── CONFIG ──
118
+ /** @type {Config} */
119
+ const config = {
120
+ seed: SEED,
121
+ datasetStart: DATASET_START,
122
+ datasetEnd: DATASET_END,
123
+ numUsers: NUM_USERS,
124
+ avgEventsPerUserPerDay: EVENTS_PER_DAY,
125
+ format: "json",
126
+ gzip: true,
127
+ writeToDisk: false,
128
+ concurrency: 1,
129
+ macro: "flat", // optional — see "Trend shape" below
130
+ soup: "growth", // optional
131
+
132
+ credentials: { token },
133
+ switches: {
134
+ hasLocation: true,
135
+ hasAndroidDevices: false,
136
+ hasIOSDevices: false,
137
+ hasDesktopDevices: true,
138
+ hasBrowser: true,
139
+ hasAvatar: true,
140
+ hasSessionIds: true,
141
+ },
142
+ identity: { avgDevicePerUser: 2 },
114
143
 
115
144
  funnels: [ /* see "Funnels" below */ ],
116
145
  events: [ /* see "Events" below */ ],
@@ -125,6 +154,14 @@ const config = {
125
154
  export default config;
126
155
  ```
127
156
 
157
+ When the dungeon has hooks (added later by `/write-hooks`), the layout
158
+ extends with HOOK STORIES (full per-hook docs with Mixpanel report blocks),
159
+ KNOBS (extracted tunable constants — timing, thresholds, multipliers),
160
+ HOOK STATE (module-level Maps/Sets used across users), and HELPER FUNCTIONS
161
+ (per-type handlers like `handleEventHooks`, `handleEverythingHooks`).
162
+ `config.hook` becomes a thin dispatcher delegating to the helpers. See
163
+ `dungeons/vertical/ecommerce.js` as the canonical exemplar.
164
+
128
165
  ## Required components
129
166
 
130
167
  ### 1. Events (~15–20)
@@ -234,7 +271,9 @@ If you absolutely need a stub for downstream stamping consistency, leave a
234
271
 
235
272
  The identity model has three knobs:
236
273
 
237
- ### `avgDevicePerUser` (whole number, default 0)
274
+ ### `identity.avgDevicePerUser` (whole number, default 0)
275
+
276
+ Place inside the `identity` sub-object: `identity: { avgDevicePerUser: 2 }`.
238
277
 
239
278
  | App type | Recommended | Why |
240
279
  |----------|-------------|-----|
@@ -243,7 +282,11 @@ The identity model has three knobs:
243
282
  | Multi-device-heavy product (streaming, fitness) | 2–3 | TV + phone + tablet sessions distinguishable |
244
283
  | Server / API-only product | 0 | No client device concept |
245
284
 
246
- `hasAnonIds: true` aliases to `avgDevicePerUser: 1`. Set both for clarity.
285
+ **`hasAnonIds: true` is deprecated.** Use `identity.avgDevicePerUser: 1`
286
+ directly. The deprecated alias still works through 1.5.x — when
287
+ `hasAnonIds: true` is set without an explicit `avgDevicePerUser`, the
288
+ validator promotes to `identity.avgDevicePerUser: 1` and emits a verbose
289
+ warning.
247
290
 
248
291
  ### `isAuthEvent` placement
249
292
 
@@ -257,6 +300,15 @@ Flag the event that represents "user becomes identified". Put it in the
257
300
  The engine stamps user_id+device_id on this event; pre-auth funnel steps get
258
301
  device_id only; post-auth funnel steps get user_id only.
259
302
 
303
+ **Anonymous non-converters get `_drop: true` on their profile (v1.5.1).**
304
+ Born-in-dataset users who never reach an `isAuthEvent` step are anonymous —
305
+ their events still flow (tied to `device_id`), but `mixpanel-sender` filters
306
+ `_drop:true` profiles before `/engage` push. `result.profilesPushed` reports
307
+ actual push count vs `result.userProfilesData.size` (full population). The
308
+ `everything` hook can rescue a profile via `delete meta.profile._drop`.
309
+ Pre-existing users (born outside window) are always considered identified
310
+ and never get `_drop`.
311
+
260
312
  ### `attempts` (per-funnel, optional)
261
313
 
262
314
  ```js
@@ -406,13 +458,54 @@ skill handles this).
406
458
  ### `maxTouchpointsPerUser` (attribution cap)
407
459
 
408
460
  Top-level optional knob. Caps UTM stamping at this many events per user
409
- (default 10, matching Mixpanel `TOUCHPOINTS_LIMIT`). When `hasCampaigns: true`
461
+ (default 10, matching Mixpanel `TOUCHPOINTS_LIMIT`). When `switches.hasCampaigns: true`
410
462
  and a user has more eligible events than the cap, the engine takes a
411
463
  uniform-random sample across the user's lifetime and stamps UTMs on the
412
464
  sampled events only. Sampling across lifetime (NOT first-N) preserves
413
465
  realistic touch shape — Mixpanel's last-10-window then gives meaningful
414
466
  first/last-touch attribution. Set to `Infinity` to disable the cap.
415
467
 
468
+ **Generator/verifier asymmetry to know about:** the generator samples
469
+ uniformly across user lifetime; the verifier (`emulateBreakdown` with
470
+ `attributedBy`) and real Mixpanel attribution both read the **last N
471
+ touchpoints before each conversion** (per `attributed_value_reader.cpp`).
472
+ For users with ≤10 attribution-eligible events lifetime, no divergence
473
+ (cap is a no-op). For users with >10 eligible events and multiple
474
+ conversions, generator stamps may not align with Mixpanel's per-conversion
475
+ last-10 window. Real-world impact: minor for first-touch, occasional
476
+ divergence for last-touch in multi-conversion users. Tracked for 1.6.
477
+
478
+ ### `retentionCurve` (generator-side retention shape, v1.5+)
479
+
480
+ Top-level optional knob. Shape retention via log-linear interpolation
481
+ between waypoints. Independent of `engagementDecay`.
482
+
483
+ ```js
484
+ retentionCurve: [
485
+ { day: 0, retention: 1.0 },
486
+ { day: 1, retention: 0.80 },
487
+ { day: 7, retention: 0.50 },
488
+ { day: 30, retention: 0.20 },
489
+ ]
490
+ ```
491
+
492
+ Each born-in-dataset user's events get filtered based on the interpolated
493
+ retention at the event's age-from-first-event-day. Use when you want a
494
+ declarative retention shape at config level (analytical-style D1/D7/D30
495
+ targets) instead of writing hook logic.
496
+
497
+ ### `userSeed` (separate distinct_id RNG seed, v1.5+)
498
+
499
+ Top-level optional knob. Separates the distinct_id RNG seed from the main
500
+ `seed`. Lets you regenerate a dataset with a different event distribution
501
+ while keeping the user pool stable across runs — useful for incremental
502
+ data layering.
503
+
504
+ ```js
505
+ seed: "v2", // event-stream RNG (different distribution each version)
506
+ userSeed: "users-v1", // user-pool RNG (stable across versions)
507
+ ```
508
+
416
509
  ## SuperProp consistency rule
417
510
 
418
511
  If `superProps` and `userProps` both define a property like `Plan`, the
@@ -431,9 +524,9 @@ userProps: { Plan: PLANS, Region: REGIONS, Role: ROLES, ... },
431
524
 
432
525
  After writing the file:
433
526
 
434
- 1. Smoke-test: `node scripts/verify-runner.mjs dungeons/user/<file>.js verify-<file> --small`. Confirm zero errors.
435
- 2. Hand to the next skill: `/write-hooks dungeons/user/<file>.js "describe trends"`.
436
- 3. After hooks land: `/verify-dungeon dungeons/user/<file>.js`.
527
+ 1. Smoke-test: `node scripts/verify-runner.mjs dungeons/user/<name>/<name>.js verify-<name> --small`. Confirm zero errors.
528
+ 2. Hand to the next skill: `/write-hooks dungeons/user/<name>/<name>.js "describe trends"`.
529
+ 3. After hooks land: `/verify-dungeon dungeons/user/<name>/<name>.js`.
437
530
 
438
531
  ## Property Type Reference
439
532
 
@@ -457,8 +550,20 @@ When designing event properties, always consider which Mixpanel type best repres
457
550
 
458
551
  ## Output
459
552
 
460
- Write the file to `dungeons/user/<descriptive-name>.js`. Do NOT inject hooks.
461
- Do NOT use `subscription`, `attribution`, `geo`, `features`, or `anomalies`
462
- (the engine will silently strip them and warn).
553
+ **One folder per customer/dungeon.** Pick a short, kebab-case `<name>` from the
554
+ app/customer, then **create `dungeons/user/<name>/` if it doesn't already exist**
555
+ (`mkdir -p dungeons/user/<name>`) and write the dungeon to
556
+ `dungeons/user/<name>/<name>.js` (e.g. `dungeons/user/acme/acme.js`). Folder and
557
+ file share the name, matching `kodiak/kodiak.js`, `my-buddy/my-buddy.js`.
558
+
559
+ This keeps `dungeons/user/` organized — EVERYTHING about this dungeon lives in
560
+ the same folder: `hook-results.md` + `hook-query-log.txt` +
561
+ `<name>-verifications.sql` (from `verify-dungeon`), `soup-analysis.md` (from
562
+ `analyze-soup`), briefs, schema CSV/JSON, example data. The only thing kept
563
+ outside is the throwaway verification data the runs write to `./data/` (cleaned
564
+ after).
565
+
566
+ Do NOT inject hooks. Do NOT use `subscription`, `attribution`, `geo`,
567
+ `features`, or `anomalies` (the engine will silently strip them and warn).
463
568
 
464
569
  When done, tell the user the next skill to run.
@@ -102,13 +102,33 @@ For emulator details, identity-model dungeons (must pass `profiles`), and time-s
102
102
  - Experiment invariants (variant distribution, exposure timing, deterministic assignment) when any funnel uses `experiment:`
103
103
  - SuperProp consistency, SuperProp/UserProp mirror, Mixpanel default-property casing, funnel-pre dilution
104
104
 
105
- ### Step 5: Stash query log (optional)
105
+ ### Artifact location
106
106
 
107
- If `./research/` exists locally, write every DuckDB query execution to `./research/hook-query-log.txt`. Format and conventions: see [report-format.md "Query log format"](references/report-format.md#query-log-format). If `./research/` does not exist, skip — do not create the directory.
107
+ **Everything about a dungeon lives in its folder.** When the dungeon being
108
+ verified is a user dungeon at `dungeons/user/<name>/<name>.js`, write ALL
109
+ generated artifacts into `dungeons/user/<name>/`:
110
+ - `hook-results.md` (Step 6)
111
+ - `hook-query-log.txt` (Step 5)
112
+ - `<name>-verifications.sql` (Step 6b)
108
113
 
109
- ### Step 6: Write `./research/hook-results.md`
114
+ The ONLY exception is the throwaway verification data the run writes to
115
+ `./data/` (`verify-*` event/user files) — that stays in `./data/` and is
116
+ deleted in Step 7.
110
117
 
111
- Use the templates in [report-format.md](references/report-format.md):
118
+ For non-user dungeons (technical/vertical) or batch runs across many dungeons,
119
+ fall back to `./research/` for `hook-results.md` / `hook-query-log.txt`.
120
+
121
+ ### Step 5: Stash query log
122
+
123
+ Write every DuckDB query execution to `hook-query-log.txt`:
124
+ - **User dungeon:** always write to `dungeons/user/<name>/hook-query-log.txt`.
125
+ - **Otherwise:** if `./research/` exists locally, write to `./research/hook-query-log.txt`; if it doesn't exist, skip — do not create the directory.
126
+
127
+ Format and conventions: see [report-format.md "Query log format"](references/report-format.md#query-log-format).
128
+
129
+ ### Step 6: Write `hook-results.md`
130
+
131
+ Write to `dungeons/user/<name>/hook-results.md` for a user dungeon, else `./research/hook-results.md`. Use the templates in [report-format.md](references/report-format.md):
112
132
  - Single-dungeon report structure
113
133
  - Multi-dungeon report structure (when batch mode)
114
134
  - Per-hook detail block
@@ -118,7 +138,7 @@ Use the templates in [report-format.md](references/report-format.md):
118
138
 
119
139
  ### Step 6b: Write verification SQL (mandatory for user dungeons)
120
140
 
121
- When verifying a dungeon in `dungeons/user/`, also write a standalone DuckDB SQL file at `dungeons/user/<name>-verifications.sql`. Vertical dungeons already have their SQL in `verification/verticals/`. Format: see [report-format.md "Verification SQL file"](references/report-format.md#verification-sql-file-mandatory-for-user-dungeons).
141
+ When verifying a dungeon in `dungeons/user/`, also write a standalone DuckDB SQL file alongside the dungeon in its folder at `dungeons/user/<name>/<name>-verifications.sql`. Vertical dungeons already have their SQL in `verification/verticals/`. Format: see [report-format.md "Verification SQL file"](references/report-format.md#verification-sql-file-mandatory-for-user-dungeons).
122
142
 
123
143
  ### Step 7: Cleanup
124
144
 
@@ -148,9 +168,9 @@ Return-value behavior:
148
168
  ## Final output
149
169
 
150
170
  Tell the user:
151
- 1. Report path: `./research/hook-results.md`
152
- 2. Verification SQL path (for user dungeons): `dungeons/user/<name>-verifications.sql`
153
- 3. Query log path (if written): `./research/hook-query-log.txt`
171
+ 1. Report path: `dungeons/user/<name>/hook-results.md` (user dungeon) or `./research/hook-results.md`
172
+ 2. Verification SQL path (for user dungeons): `dungeons/user/<name>/<name>-verifications.sql`
173
+ 3. Query log path (if written): alongside the report (`dungeons/user/<name>/hook-query-log.txt`, else `./research/hook-query-log.txt`)
154
174
  4. Pass/weak/fail counts (per dungeon if batch mode)
155
175
  5. One-line summary of the most interesting finding
156
176
 
@@ -7,10 +7,25 @@ Mixpanel does NOT count the way naive SQL does. The verifier (and any DuckDB que
7
7
  | Concept | Mixpanel rule | Wrong SQL → Right SQL |
8
8
  |---------|--------------|----------------------|
9
9
  | Frequency / cohort by event count | Distinct calendar days, NOT total events | `COUNT(*)` → `COUNT(DISTINCT date_trunc('day', time::TIMESTAMP))` |
10
- | Funnels | Greedy single-pass, strict order, 2-second grace | NEVER hand-roll funnel SQL — use `emulateBreakdown` |
11
- | AVG / SUM / MIN / MAX | Skip null and non-numeric from BOTH num and denom | Always wrap in `TRY_CAST(prop AS DOUBLE)` |
12
- | Attribution | Cap at 10 touchpoints in lookback | Use `emulateBreakdown` with `attributedBy` |
13
- | Conversion window | Strict `<` boundary | Read `Funnel.conversionWindowDays` and respect it |
10
+ | Funnels | Greedy single-pass, strict order, 2-second grace (`history.cpp` `OUT_OF_ORDER_MILLISECONDS = 2000`) | NEVER hand-roll funnel SQL — use `emulateBreakdown` |
11
+ | AVG / SUM / MIN / MAX | Skip null and non-numeric from BOTH num and denom (`normal_query.cpp:1718-1733`) | Always wrap in `TRY_CAST(prop AS DOUBLE)` |
12
+ | Attribution | Cap at 10 touchpoints in lookback (`attributed_value_reader.cpp:16` `TOUCHPOINTS_LIMIT`) | Use `emulateBreakdown` with `attributedBy` |
13
+ | Conversion window | Strict `<` boundary (`conversion_window.cpp:48` `t1 < t2 + 1000*len`) | Read `Funnel.conversionWindowDays` and respect it |
14
+ | Sessions | 3-trigger split: timeout `>`, max duration `>`, day-idx change (`session_query.cpp:906-911`) | Trust pre-stamped `session_id`; group by `(user, session_id)` |
15
+ | Retention | Birth-anchored, ms-strict gate (default `birth_can_retain=false` → `<`), bucketed by `floor((ret−birth)/unit)` (`retention_query.cpp:1097-1109,1228-1231`) | Use `emulateBreakdown` with `retention` |
16
+
17
+ **Known divergences from Mixpanel C++** (1.5.1):
18
+ - `countDistinctPeriods` default = `algorithm: 'calendar'` (UTC bucket).
19
+ Mixpanel C++ (`addiction_query.cpp:359`) uses ROLLING window. Pass
20
+ `algorithm: 'rolling'` for exact Frequency-Distribution parity.
21
+ - COMPOUNDED retention is NOT implemented — verifier silently ignores
22
+ `compounded: true`. Use DuckDB or query Mixpanel directly for "DAU
23
+ coming back" reports.
24
+ - Touchpoint sampling: generator stamps uniform-random across user
25
+ lifetime; verifier reads last-N before conversion (matches C++).
26
+ For users with ≤10 attribution events lifetime, no divergence.
27
+ - List-typed property AVG/SUM: C++ auto-flattens lists per item; our
28
+ `nullAwareAvg` requires pre-flattened input.
14
29
 
15
30
  Full rules: see [HOOKS.md Section 2](../../../../HOOKS.md#2-how-mixpanel-counts-things).
16
31
 
@@ -63,7 +78,7 @@ For CI-style assertions, use `verifyDungeon` with a checks array; see `tests/e2e
63
78
  - **`Funnel.order` auto-dispatched.** For `sequential` / `interrupt` funnels, the emulator runs the greedy single-pass engine. For other order modes (`first-fixed`, `last-fixed`, `random`, etc.), it dispatches to `evaluateAnyOrderCompletion` (set-membership check). For `random` mode, results are `verificationKind: "informational"` — Mixpanel funnel shape doesn't apply; do not assert PASS/FAIL.
64
79
  - **Auto-sort means custom DuckDB queries can trust event order.** Per-user events arrive sorted ascending by time (default; opt out via `autoSortAfterEverything: false`). `LAG`/`LEAD` window functions work without explicit `ORDER BY time` in the partition.
65
80
  - **Auto-promote `isStrictEvent` is silent healing — not a regression.** If a stale dungeon's funnel-step events also live in `events[]`, the validator stamps `isStrictEvent: true` and warns. Verification of those dungeons may show CHANGED standalone-event counts vs older runs — that is correct behavior, not a bug to chase.
66
- - **Touchpoint cap = 10 enforced at generation.** `hasCampaigns: true` users get up to `maxTouchpointsPerUser` (default 10) UTM-stamped events, sampled across lifetime. Attribution checks via `attributedBy` should see realistic first/last-touch shapes, not all-stamps-at-birth.
81
+ - **Touchpoint cap = 10 enforced at generation.** `switches.hasCampaigns: true` users get up to `maxTouchpointsPerUser` (default 10) UTM-stamped events, sampled across lifetime. Attribution checks via `attributedBy` should see realistic first/last-touch shapes, not all-stamps-at-birth. The verifier's `attributedBy` reads last-N before conversion (matches Mixpanel); the generator samples uniform across lifetime. For users with >10 attribution events lifetime, expect minor divergence on multi-conversion users.
67
82
 
68
83
  ## Hook awareness for verification
69
84
 
@@ -128,7 +143,17 @@ When the dungeon's `Funnel` config sets these fields, `verifyDungeon` auto-appli
128
143
 
129
144
  ## Identity-model dungeons — pass profiles
130
145
 
131
- When `avgDevicePerUser > 0` or `hasAnonIds: true`, ALWAYS pass `profiles` to `emulateBreakdown`. Without it, pre-auth `device_id` events bucket as separate "users" and your funnel/retention/attribution numbers all deflate.
146
+ When `identity.avgDevicePerUser > 0` (or the deprecated `hasAnonIds: true`),
147
+ ALWAYS pass `profiles` to `emulateBreakdown`. Without it, pre-auth
148
+ `device_id` events bucket as separate "users" and your funnel/retention/
149
+ attribution numbers all deflate.
150
+
151
+ **v1.5.1 anonymous non-converter semantic:** born-in-dataset users who
152
+ never reach an `isAuthEvent` get `_drop: true` on their profile.
153
+ `result.profilesPushed` reports the actual push count;
154
+ `result.userProfilesData.size` reports full population (including dropped).
155
+ Don't be surprised if profile-count assertions show
156
+ `profilesPushed < userProfilesData.size` — that's correct.
132
157
 
133
158
  ```js
134
159
  const events = Array.from(result.eventData);
@@ -1,6 +1,6 @@
1
1
  # Report Format
2
2
 
3
- Templates and conventions for writing `./research/hook-results.md` and per-dungeon verification SQL.
3
+ Templates and conventions for writing `hook-results.md` and per-dungeon verification SQL. For user dungeons these live in the dungeon's folder (`dungeons/user/<name>/`); otherwise in `./research/`. See [SKILL.md "Artifact location"](../SKILL.md).
4
4
 
5
5
  ## Verdict criteria (5-tier)
6
6
 
@@ -147,9 +147,7 @@ Each hook's detailed section follows this template (same for single and multi-du
147
147
 
148
148
  ## Query log format
149
149
 
150
- If `./research/` exists locally, write a plain-text log of every DuckDB query execution to `./research/hook-query-log.txt`. If `./research/` does not exist, skip this step entirely do not create the directory.
151
-
152
- Check with: `ls -d ./research/ 2>/dev/null`
150
+ Write a plain-text log of every DuckDB query execution to `hook-query-log.txt`. For a user dungeon, write it to the dungeon's folder (`dungeons/user/<name>/hook-query-log.txt`). Otherwise, only if `./research/` exists locally write `./research/hook-query-log.txt` — if it doesn't exist, skip this step entirely (don't create the directory; check with `ls -d ./research/ 2>/dev/null`).
153
151
 
154
152
  Use a consistent delimited format — one block per query, separated by a ruler line. DuckDB table output is preserved verbatim:
155
153
 
@@ -191,7 +189,7 @@ grep "^DUNGEON:" research/hook-query-log.txt # list of dungeons querie
191
189
 
192
190
  ## Verification SQL file (mandatory for user dungeons)
193
191
 
194
- When verifying a dungeon in `dungeons/user/`, write a standalone DuckDB SQL file alongside the dungeon at `dungeons/user/<name>-verifications.sql`. This file is the reproducible verification artifact — anyone can re-run it against fresh data.
192
+ When verifying a dungeon in `dungeons/user/`, write a standalone DuckDB SQL file alongside the dungeon in its folder at `dungeons/user/<name>/<name>-verifications.sql`. This file is the reproducible verification artifact — anyone can re-run it against fresh data.
195
193
 
196
194
  Follow the format in `verification/verticals/`:
197
195
 
@@ -200,8 +198,8 @@ Follow the format in `verification/verticals/`:
200
198
  -- <name>.js — Hook Verification SQL (N hooks)
201
199
  -- ============================================================================
202
200
  -- USAGE:
203
- -- 1. node scripts/verify-runner.mjs dungeons/user/<name>.js verify-<name>
204
- -- 2. duckdb < dungeons/user/<name>-verifications.sql
201
+ -- 1. node scripts/verify-runner.mjs dungeons/user/<name>/<name>.js verify-<name>
202
+ -- 2. duckdb < dungeons/user/<name>/<name>-verifications.sql
205
203
  -- 3. rm -f verify-<name>-*
206
204
  -- ============================================================================
207
205
 
@@ -36,14 +36,14 @@ The expected set of columns per event type is derived from config:
36
36
  | Source | Keys | Condition |
37
37
  |--------|------|-----------|
38
38
  | Core | `event`, `time`, `insert_id`, `user_id` | Always |
39
- | Identity | `device_id` | `avgDevicePerUser > 0` |
40
- | Identity | `session_id` | `hasSessionIds` |
39
+ | Identity | `device_id` | `identity.avgDevicePerUser > 0` |
40
+ | Identity | `session_id` | `switches.hasSessionIds` |
41
41
  | Event config | `events[i].properties` keys | Per event type |
42
42
  | Super props | `superProps` keys | All event types |
43
- | Location | `city`, `region`, `country`, `country_code` | `hasLocation` |
44
- | Browser | `browser` | `hasBrowser` |
45
- | Device | `model`, `screen_height`, `screen_width`, `os`, `Platform`, `carrier`, `radio` | `hasAndroidDevices`/`hasIOSDevices`/`hasDesktopDevices` |
46
- | Campaigns | `utm_source`, `utm_campaign`, `utm_medium`, `utm_content`, `utm_term` | `hasCampaigns` |
43
+ | Location | `city`, `region`, `country`, `country_code` | `switches.hasLocation` |
44
+ | Browser | `browser` | `switches.hasBrowser` |
45
+ | Device | `model`, `screen_height`, `screen_width`, `os`, `carrier`, `radio` | `switches.hasAndroidDevices`/`hasIOSDevices`/`hasDesktopDevices`. **`Platform` removed in 1.5.1** — `os` covers the signal. Hooks/dungeons may opt back in by declaring `Platform` in event `properties`. |
46
+ | Campaigns | `utm_source`, `utm_campaign`, `utm_medium`, `utm_content`, `utm_term` | `switches.hasCampaigns` |
47
47
  | Group keys | group key name | Per event type from `groupKeys[i][2]`, or all if empty |
48
48
  | Funnel props | `funnel.props` keys | Events in funnel sequence |
49
49
  | Experiment | `Experiment name`, `Variant name` | `$experiment_started` event |
@@ -60,7 +60,7 @@ If any event type has SCHEMA-FAIL, flag it prominently and include specific reme
60
60
 
61
61
  ## Standard identity-model invariants
62
62
 
63
- Run these for every dungeon that uses the identity model (`isAuthEvent` + `attempts` + `avgDevicePerUser`), BEFORE per-pattern checks:
63
+ Run these for every dungeon that uses the identity model (`isAuthEvent` + `attempts` + `identity.avgDevicePerUser`), BEFORE per-pattern checks:
64
64
 
65
65
  ```sql
66
66
  -- Stitch event count must match converted-born count, exactly one per user.
@@ -420,7 +420,8 @@ When verifying `everything` hooks, you often MUST join events with user profiles
420
420
 
421
421
  ## Advanced feature verification
422
422
 
423
- Advanced features (personas, worldEvents, engagementDecay, dataQuality, subscription, attribution, geo, features, anomalies) produce data patterns alongside hooks. When verifying:
423
+ Supported advanced features (still active in 1.5.x): `personas`,
424
+ `worldEvents`, `engagementDecay`, `dataQuality`. When verifying:
424
425
 
425
426
  ```sql
426
427
  -- Personas: check distribution matches configured weights
@@ -432,27 +433,36 @@ SELECT promo, count(*) FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
432
433
  -- Data Quality: verify bots, nulls, empty events
433
434
  SELECT 'bots' as metric, count(*) FROM read_json_auto('./data/verify-dungeon-USERS.json') WHERE is_bot = true
434
435
  UNION ALL SELECT 'null_props', count(*) FROM read_json_auto('./data/verify-dungeon-EVENTS.json') WHERE category IS NULL;
436
+ ```
435
437
 
436
- -- Subscription: lifecycle events generated
437
- SELECT event, count(*) FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
438
- WHERE event IN ('trial started','subscription started','plan upgraded','subscription cancelled') GROUP BY 1;
438
+ Advanced feature patterns should ALWAYS be present (deterministic from config), unlike hooks which may have statistical variance.
439
439
 
440
- -- Attribution: campaign sources on profiles
441
- SELECT utm_source, count(*) FROM read_json_auto('./data/verify-dungeon-USERS.json') WHERE utm_source IS NOT NULL GROUP BY 1;
440
+ **Deprecated config blocks (silently stripped by validator since 1.4):**
441
+ `subscription`, `attribution`, `geo`, `features`, `anomalies`. If a
442
+ dungeon still references these, properties they used to generate
443
+ (`subscription_plan`, `_region`, `theme`, `_anomaly`, etc.) will be
444
+ missing from the output. Migration: add equivalents to `superProps` /
445
+ `userProps` and drive downstream effects in `user` or `everything` hooks.
442
446
 
443
- -- Geo: region distribution
444
- SELECT _region, count(*) FROM read_json_auto('./data/verify-dungeon-USERS.json') WHERE _region IS NOT NULL GROUP BY 1;
447
+ ## Standard verification checks (run for every dungeon)
445
448
 
446
- -- Features: progressive adoption properties
447
- SELECT theme, count(*) FROM read_json_auto('./data/verify-dungeon-EVENTS.json') WHERE theme IS NOT NULL GROUP BY 1;
449
+ ### 0. Anonymous non-converter `_drop` audit (v1.5.1, identity-model dungeons)
448
450
 
449
- -- Anomalies: burst/extreme events
450
- SELECT _anomaly, count(*) FROM read_json_auto('./data/verify-dungeon-EVENTS.json') WHERE _anomaly IS NOT NULL GROUP BY 1;
451
- ```
451
+ Born-in-dataset users who never reach an `isAuthEvent` get `_drop: true`
452
+ stamped on their profile. Real Mixpanel `/engage` skips these the
453
+ verifier's profile-count assertions should mirror that. Quick check:
452
454
 
453
- Advanced feature patterns should ALWAYS be present (deterministic from config), unlike hooks which may have statistical variance.
455
+ ```sql
456
+ SELECT
457
+ COUNT(*) AS total_profiles,
458
+ SUM(CASE WHEN _drop = true THEN 1 ELSE 0 END) AS dropped,
459
+ SUM(CASE WHEN _drop IS NULL OR _drop = false THEN 1 ELSE 0 END) AS would_push
460
+ FROM read_json_auto('./data/verify-dungeon-USERS.json');
461
+ ```
454
462
 
455
- ## Standard verification checks (run for every dungeon)
463
+ `would_push` should equal `result.profilesPushed` from the run output.
464
+ For pre-existing-only dungeons (`percentUsersBornInDataset: 0`) expect
465
+ `dropped = 0`.
456
466
 
457
467
  ### 1. SuperProp Consistency
458
468
  Verify each user has exactly 1 value per superProp:
@@ -476,9 +486,18 @@ Verdict: **STRONG** ≥99% consistent, **WEAK** 90-99%, **FAIL** <90%.
476
486
  Every superProp key should also appear on user profiles. Compare the dungeon's `superProps` keys against columns in the USERS file. Any superProp not mirrored in `userProps` means the stamping fix is incomplete.
477
487
 
478
488
  ### 3. Mixpanel Default Property Casing Check
479
- The system generates device properties with Mixpanel's standard casing (`Platform` capital P, `os`, `model`, etc.) and location properties (`city`, `region`, `country`). If a dungeon defines a superProp with conflicting casing (e.g., lowercase `platform`), both properties appear on events — confusing in Mixpanel. Check for:
480
- - `platform` (lowercase) vs system `Platform` — verdict **FAIL** if dungeon uses lowercase
481
- - `City`, `Region`, `Country` vs system `city`, `region`, `country` — check casing matches
489
+ The system generates device properties with Mixpanel's standard casing
490
+ (`os`, `model`, `screen_height`, `screen_width`, `carrier`, `radio`,
491
+ `browser`) and location properties (`city`, `region`, `country`,
492
+ `country_code`). If a dungeon defines a superProp with conflicting casing
493
+ (e.g., capitalized `City` vs system `city`), both properties appear on
494
+ events — confusing in Mixpanel. Check for:
495
+ - `City`, `Region`, `Country` (caps) vs system `city`, `region`, `country` — verdict **FAIL** if dungeon uses caps for these
496
+ - `Browser` (caps) vs system `browser` — verdict **FAIL** if mismatched
497
+
498
+ **Note:** `Platform` was REMOVED from default device props in 1.5.1.
499
+ If a dungeon explicitly declares `Platform` in event `properties`, that's
500
+ intentional opt-in — not a casing conflict.
482
501
 
483
502
  ### 4. funnel-pre Dilution Check
484
503
  For any dungeon with `funnel-pre` conversionRate modifications, verify the actual visible effect: