@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.
- package/.claude/skills/analyze-soup/SKILL.md +6 -1
- package/.claude/skills/create-dungeon/SKILL.md +159 -54
- package/.claude/skills/verify-dungeon/SKILL.md +28 -8
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +31 -6
- package/.claude/skills/verify-dungeon/references/report-format.md +5 -7
- package/.claude/skills/verify-dungeon/references/sql-recipes.md +44 -25
- package/.claude/skills/write-hooks/SKILL.md +33 -5
- package/CHANGELOG.md +93 -0
- package/README.md +26 -0
- package/index.js +3 -1
- package/lib/core/dungeon-to-json.js +220 -0
- package/lib/core/extract-comments.js +120 -0
- package/package.json +2 -2
- package/scripts/dungeon-to-json.mjs +5 -124
- package/types.d.ts +73 -0
|
@@ -134,7 +134,12 @@ FROM (
|
|
|
134
134
|
|
|
135
135
|
## Step 3: Write Report
|
|
136
136
|
|
|
137
|
-
Create `soup-analysis.md`
|
|
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/<
|
|
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`,
|
|
32
|
-
`seed`, `format`,
|
|
33
|
-
`
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
*
|
|
89
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
104
|
-
hasAnonIds: true,
|
|
105
|
-
avgDevicePerUser: 2,
|
|
106
|
-
hasSessionIds: true,
|
|
112
|
+
const chance = u.initChance(SEED);
|
|
107
113
|
|
|
108
|
-
|
|
109
|
-
|
|
114
|
+
// ── DATA ARRAYS ── (omit if none)
|
|
115
|
+
const productIds = v.range(1, 200).map(n => `prod_${v.uid(8)}`);
|
|
110
116
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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/<
|
|
435
|
-
2. Hand to the next skill: `/write-hooks dungeons/user/<
|
|
436
|
-
3. After hooks land: `/verify-dungeon dungeons/user/<
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
(
|
|
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
|
-
###
|
|
105
|
+
### Artifact location
|
|
106
106
|
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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`, `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
441
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
450
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
480
|
-
|
|
481
|
-
|
|
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:
|