@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
@@ -0,0 +1,857 @@
1
+ # DuckDB SQL Recipes for Verification
2
+
3
+ Use DuckDB only for schema integrity, identity-model invariants, experiment invariants, and bespoke patterns the emulator can't express. For funnel / frequency / aggregate / TTC / attribution patterns, use `emulateBreakdown` instead — see [counting-semantics.md](counting-semantics.md).
4
+
5
+ ## Schema validation queries
6
+
7
+ For each unique event type in the output, compare actual columns against the config-declared properties:
8
+
9
+ ```sql
10
+ WITH event_data AS (
11
+ SELECT * FROM read_json_auto('./data/<run-name>-EVENTS.json', sample_size=-1)
12
+ WHERE event = '<EVENT_TYPE>'
13
+ )
14
+ SELECT
15
+ unnest(map_keys(columns(*))) as col_name,
16
+ COUNT(*) as total_events,
17
+ COUNT(col_name) FILTER (WHERE col_name IS NOT NULL) as non_null_count,
18
+ ROUND(COUNT(col_name) FILTER (WHERE col_name IS NOT NULL) * 100.0 / COUNT(*), 1) as coverage_pct
19
+ FROM event_data
20
+ GROUP BY col_name
21
+ ORDER BY coverage_pct DESC;
22
+ ```
23
+
24
+ Or use the programmatic API (`lib/verify/schema-validator.js`):
25
+
26
+ ```javascript
27
+ import { deriveExpectedSchema, validateSchema } from './lib/verify/index.js';
28
+ // deriveExpectedSchema(config) → Map<eventName, Set<propKey>>
29
+ // validateSchema(events, config) → { pass, eventTypes, summary, flagStamping }
30
+ ```
31
+
32
+ ### Expected schema sources
33
+
34
+ The expected set of columns per event type is derived from config:
35
+
36
+ | Source | Keys | Condition |
37
+ |--------|------|-----------|
38
+ | Core | `event`, `time`, `insert_id`, `user_id` | Always |
39
+ | Identity | `device_id` | `avgDevicePerUser > 0` |
40
+ | Identity | `session_id` | `hasSessionIds` |
41
+ | Event config | `events[i].properties` keys | Per event type |
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` |
47
+ | Group keys | group key name | Per event type from `groupKeys[i][2]`, or all if empty |
48
+ | Funnel props | `funnel.props` keys | Events in funnel sequence |
49
+ | Experiment | `Experiment name`, `Variant name` | `$experiment_started` event |
50
+ | World events | `worldEvent.injectProps` keys | Events matching `affectsEvents` |
51
+
52
+ ### Schema verdicts
53
+
54
+ For each event type, classify any column present in output but NOT in expected schema:
55
+
56
+ - **SCHEMA-PASS** — Column appears on 100% of events of this type. Uniform enrichment is acceptable.
57
+ - **SCHEMA-FAIL** — Column appears on <100% of events of this type. This is flag stamping — hook conditionally adds a property, creating an inconsistent schema.
58
+
59
+ If any event type has SCHEMA-FAIL, flag it prominently and include specific remediation: which hook line adds the property and how to remove it while preserving the intended pattern.
60
+
61
+ ## Standard identity-model invariants
62
+
63
+ Run these for every dungeon that uses the identity model (`isAuthEvent` + `attempts` + `avgDevicePerUser`), BEFORE per-pattern checks:
64
+
65
+ ```sql
66
+ -- Stitch event count must match converted-born count, exactly one per user.
67
+ WITH e AS (SELECT * FROM read_json_auto('./data/<file>-EVENTS.json')),
68
+ auth_event AS (SELECT 'Sign Up' AS name) -- name of your isAuthEvent
69
+ SELECT
70
+ COUNT(*) AS auth_events_total,
71
+ SUM(CASE WHEN user_id IS NOT NULL AND device_id IS NOT NULL THEN 1 ELSE 0 END) AS stitches,
72
+ COUNT(DISTINCT CASE WHEN user_id IS NOT NULL THEN user_id END) AS converted_users
73
+ FROM e WHERE event = (SELECT name FROM auth_event);
74
+
75
+ -- Pre-existing users must have user_id on every event (no anon-only records).
76
+ WITH e AS (SELECT * FROM read_json_auto('./data/<file>-EVENTS.json')),
77
+ u AS (SELECT * FROM read_json_auto('./data/<file>-USERS.json'))
78
+ SELECT COUNT(*) AS preexisting_anon_only_records
79
+ FROM e JOIN u ON u.distinct_id::VARCHAR = e.user_id::VARCHAR
80
+ WHERE u.created < (SELECT MIN(time::TIMESTAMP) FROM e)
81
+ AND e.user_id IS NULL;
82
+ ```
83
+
84
+ Failures usually indicate incomplete identity-model migration. Flag in report.
85
+
86
+ ## Experiment invariants
87
+
88
+ Run when dungeon uses `experiment:` on any funnel:
89
+
90
+ ```sql
91
+ -- Variant distribution should be roughly even (within ±10% of expected share)
92
+ SELECT "Variant name", COUNT(*) AS exposure_count,
93
+ COUNT(DISTINCT user_id) AS unique_users
94
+ FROM read_json_auto('./data/<file>-EVENTS.json')
95
+ WHERE event = '$experiment_started'
96
+ GROUP BY "Variant name"
97
+ ORDER BY exposure_count DESC;
98
+
99
+ -- $experiment_started should only appear after experiment start date
100
+ SELECT MIN(time) AS earliest_exposure, MAX(time) AS latest_exposure
101
+ FROM read_json_auto('./data/<file>-EVENTS.json')
102
+ WHERE event = '$experiment_started';
103
+
104
+ -- Same user should always be in the same variant (deterministic assignment)
105
+ SELECT user_id, COUNT(DISTINCT "Variant name") AS variant_count
106
+ FROM read_json_auto('./data/<file>-EVENTS.json')
107
+ WHERE event = '$experiment_started' AND user_id IS NOT NULL
108
+ GROUP BY user_id
109
+ HAVING variant_count > 1;
110
+ -- Expected: 0 rows (no user in multiple variants)
111
+ ```
112
+
113
+ ## DuckDB notes
114
+
115
+ - Output is **JSONL** (newline-delimited JSON) — `read_json_auto()` handles this natively
116
+ - **Properties are FLAT on event records** — use `event.amount`, NOT `event.properties.amount`
117
+ - **Time field** is an ISO string — use `CAST(time AS TIMESTAMP)` or `time::TIMESTAMP`
118
+ - Use `COALESCE(column, default)` for properties that only exist on some events (spliced events may lack some fields)
119
+ - Use `TRY_CAST()` instead of `CAST()` for columns with mixed types
120
+ - For large queries, use `LIMIT` to keep output manageable
121
+ - Escape single quotes in bash: use `$'...'` syntax or double-quote the SQL and escape internal quotes
122
+
123
+ ## DuckDB pitfalls
124
+
125
+ ### Bot/Anomaly user_id breaks UUID type inference
126
+
127
+ When a dungeon uses `dataQuality.botUsers > 0` or `anomalies` features, some events have `user_id` like `"bot_db9a7a37"` or `"anomaly_f148a044"` instead of UUIDs. DuckDB auto-inference reads first chunk as UUID, then fails on string IDs:
128
+
129
+ ```
130
+ Conversion Error: Could not convert string 'bot_db9a7a37' to INT128
131
+ ```
132
+
133
+ **Fix:** every query against EVENTS must use `sample_size=-1` to scan all rows for typing AND filter out synthetic IDs:
134
+
135
+ ```sql
136
+ SELECT ... FROM read_json_auto('./data/verify-X-EVENTS.json', sample_size=-1)
137
+ WHERE user_id NOT LIKE 'bot_%' AND user_id NOT LIKE 'anomaly_%'
138
+ ```
139
+
140
+ For joins on USERS where the join key is UUID, cast both sides to VARCHAR:
141
+ ```sql
142
+ JOIN read_json_auto('./data/verify-X-USERS.json') u
143
+ ON u.distinct_id::VARCHAR = e.user_id::VARCHAR
144
+ ```
145
+
146
+ ### Multi-part EVENTS files (batch mode)
147
+
148
+ Dungeons that produce >2M total events auto-enable batch mode. Output is split into part files:
149
+
150
+ ```
151
+ data/verify-X-EVENTS-part-1.json
152
+ data/verify-X-EVENTS-part-2.json
153
+ data/verify-X-EVENTS-part-3.json
154
+ ```
155
+
156
+ Use a glob plus `union_by_name=true` (schemas may differ slightly across parts):
157
+
158
+ ```sql
159
+ SELECT ... FROM read_json_auto('./data/verify-X-EVENTS-part-*.json',
160
+ sample_size=-1, union_by_name=true)
161
+ ```
162
+
163
+ ### DuckDB reserved words
164
+
165
+ DuckDB reserves common identifiers including `on`, `at`, `from`, `to`, `order`, `group`. If you name a CTE column `on` (e.g. "order count"), the parser fails:
166
+
167
+ ```
168
+ Parser Error: syntax error at or near "on"
169
+ ```
170
+
171
+ Use suffixed names: `order_n`, `txn_n`, `swap_n`. Same applies to `at` / `to` etc.
172
+
173
+ ### Schema mismatch between JSDoc and actual data
174
+
175
+ Stale JSDocs sometimes reference field names that don't exist in the actual data. When a query returns 0 rows or NULL where you expected data, run `DESCRIBE SELECT * FROM read_json_auto(...)` to inspect actual columns and adjust the query. If the doc is wrong (not the hook), note this in results.md as a doc nit.
176
+
177
+ ### Nested properties
178
+
179
+ Some events store data in struct/array columns (e.g. ecommerce checkout has `cart STRUCT(...)[]`). The flat columns `amount`/`total_value` will be NULL — actual data is inside the array. Use `UNNEST(cart)` or `cart[1].total_value` to access.
180
+
181
+ ## How hooks work (critical for query design)
182
+
183
+ Hooks do NOT add new properties to the schema. They modify existing property values, filter/remove events, and inject events cloned from existing ones. This means you often CANNOT verify a hook by checking for a boolean flag's existence. Instead, verify by:
184
+
185
+ 1. **Comparing value magnitudes** across segments — e.g., power users should have ~3x higher avg purchase amount
186
+ 2. **Comparing value distributions in time windows** — e.g., avg amount on 1st/15th of month vs other days
187
+ 3. **Deriving behavioral segments from the data itself** — e.g., sessionize the event stream, count sessions, compare users with >20 sessions vs fewer
188
+ 4. **Checking event density patterns** — e.g., cloned/injected events create unusually dense clusters within short time windows
189
+ 5. **Cross-table joins** — e.g., join user profiles with events to see if user-level properties correlate with event-level value differences
190
+
191
+ Some hooks DO define boolean properties in the config with defaults (e.g., `payday: [false]`) that the hook sets to `true`. For those, you CAN query `WHERE payday = true`. But always check the dungeon's event config to see what properties are defined — don't assume a hook-created flag exists just because the documentation mentions a pattern.
192
+
193
+ ## Query design approach
194
+
195
+ For each hook, design a query that compares:
196
+ - **Affected group** (users/events where the hook should have had an effect)
197
+ - **Control group** (users/events where the hook should NOT have had an effect)
198
+ - **Metric** (the specific measure that should differ between groups)
199
+
200
+ Then compute a **ratio** or **difference** and compare it to the expected effect size.
201
+
202
+ ## Query templates by hook archetype
203
+
204
+ ### Segment Comparison (e.g., "premium users have higher engagement")
205
+ ```sql
206
+ SELECT
207
+ segment_property,
208
+ COUNT(*) as event_count,
209
+ AVG(metric) as avg_metric,
210
+ COUNT(DISTINCT user_id) as unique_users
211
+ FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
212
+ WHERE event = 'relevant_event'
213
+ GROUP BY segment_property
214
+ ORDER BY segment_property;
215
+ ```
216
+
217
+ ### Time-Based Anomaly (e.g., "cursed week has higher death rate")
218
+ ```sql
219
+ WITH events AS (
220
+ SELECT *, time::TIMESTAMP as ts
221
+ FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
222
+ )
223
+ SELECT
224
+ CASE
225
+ WHEN ts BETWEEN 'start_date' AND 'end_date' THEN 'anomaly_window'
226
+ ELSE 'normal'
227
+ END as period,
228
+ COUNT(*) FILTER (WHERE event = 'target_event') as target_count,
229
+ COUNT(*) as total_events,
230
+ ROUND(COUNT(*) FILTER (WHERE event = 'target_event') * 100.0 / COUNT(*), 2) as target_pct
231
+ FROM events
232
+ GROUP BY period;
233
+ ```
234
+
235
+ ### Retention / Churn (e.g., "early guild joiners retain better")
236
+ ```sql
237
+ WITH user_first_event AS (
238
+ SELECT user_id, MIN(time::TIMESTAMP) as first_seen
239
+ FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
240
+ GROUP BY user_id
241
+ ),
242
+ user_segments AS (
243
+ SELECT
244
+ e.user_id,
245
+ BOOL_OR(e.event = 'guild joined'
246
+ AND (e.time::TIMESTAMP - f.first_seen) < INTERVAL '3 days') as early_joiner
247
+ FROM read_json_auto('./data/verify-dungeon-EVENTS.json') e
248
+ JOIN user_first_event f ON e.user_id = f.user_id
249
+ GROUP BY e.user_id
250
+ ),
251
+ user_activity AS (
252
+ SELECT
253
+ e.user_id,
254
+ MAX(e.time::TIMESTAMP) - MIN(e.time::TIMESTAMP) as active_span
255
+ FROM read_json_auto('./data/verify-dungeon-EVENTS.json') e
256
+ GROUP BY e.user_id
257
+ )
258
+ SELECT
259
+ s.early_joiner,
260
+ COUNT(*) as users,
261
+ AVG(EXTRACT(DAY FROM a.active_span)) as avg_active_days
262
+ FROM user_segments s
263
+ JOIN user_activity a ON s.user_id = a.user_id
264
+ GROUP BY s.early_joiner;
265
+ ```
266
+
267
+ ### Revenue / LTV (e.g., "lucky charm buyers spend 5x more")
268
+ ```sql
269
+ WITH buyer_segments AS (
270
+ SELECT
271
+ user_id,
272
+ BOOL_OR(event = 'real money purchase' AND product = 'Lucky Charm Pack') as is_target_buyer
273
+ FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
274
+ GROUP BY user_id
275
+ )
276
+ SELECT
277
+ b.is_target_buyer,
278
+ COUNT(*) FILTER (WHERE e.event = 'real money purchase') as purchase_count,
279
+ ROUND(AVG(TRY_CAST(e.price_usd AS DOUBLE)), 2) as avg_purchase,
280
+ ROUND(SUM(TRY_CAST(e.price_usd AS DOUBLE)), 2) as total_revenue,
281
+ COUNT(DISTINCT b.user_id) as users
282
+ FROM buyer_segments b
283
+ JOIN read_json_auto('./data/verify-dungeon-EVENTS.json') e ON b.user_id = e.user_id
284
+ GROUP BY b.is_target_buyer;
285
+ ```
286
+
287
+ ### Funnel Conversion by Segment (when emulator can't do it)
288
+ ```sql
289
+ WITH step1 AS (
290
+ SELECT DISTINCT user_id, segment_prop
291
+ FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
292
+ WHERE event = 'funnel_step_1'
293
+ ),
294
+ step2 AS (
295
+ SELECT DISTINCT user_id
296
+ FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
297
+ WHERE event = 'funnel_step_2'
298
+ )
299
+ SELECT
300
+ s1.segment_prop,
301
+ COUNT(DISTINCT s1.user_id) as started,
302
+ COUNT(DISTINCT s2.user_id) as completed,
303
+ ROUND(COUNT(DISTINCT s2.user_id) * 100.0 / COUNT(DISTINCT s1.user_id), 2) as conversion_pct
304
+ FROM step1 s1
305
+ LEFT JOIN step2 s2 ON s1.user_id = s2.user_id
306
+ GROUP BY s1.segment_prop;
307
+ ```
308
+
309
+ For Mixpanel-accurate funnel verification, prefer `emulateBreakdown({type: 'funnelFrequency'})` — see [counting-semantics.md](counting-semantics.md).
310
+
311
+ ### Property Distribution Shift
312
+ ```sql
313
+ SELECT
314
+ segment_column,
315
+ property_column,
316
+ COUNT(*) as cnt,
317
+ ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER (PARTITION BY segment_column), 2) as pct
318
+ FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
319
+ WHERE event = 'relevant_event'
320
+ GROUP BY segment_column, property_column
321
+ ORDER BY segment_column, cnt DESC;
322
+ ```
323
+
324
+ ### Event Existence After Date
325
+ ```sql
326
+ SELECT
327
+ CASE WHEN time::TIMESTAMP < 'release_date' THEN 'before' ELSE 'after' END as period,
328
+ COUNT(*) as occurrences
329
+ FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
330
+ WHERE event = 'find treasure' AND treasure_type = 'Shadowmourne Legendary'
331
+ GROUP BY period;
332
+ ```
333
+
334
+ ### Value Magnitude by Behavioral Segment (sessionize derived cohorts)
335
+ ```sql
336
+ WITH ordered AS (
337
+ SELECT *, time::TIMESTAMP as ts,
338
+ LAG(time::TIMESTAMP) OVER (PARTITION BY user_id ORDER BY time) as prev_ts
339
+ FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
340
+ ),
341
+ sessions AS (
342
+ SELECT user_id,
343
+ SUM(CASE WHEN prev_ts IS NULL OR ts - prev_ts > INTERVAL '30 minutes' THEN 1 ELSE 0 END) as session_count
344
+ FROM ordered
345
+ GROUP BY user_id
346
+ ),
347
+ segments AS (
348
+ SELECT user_id,
349
+ CASE WHEN session_count > 20 THEN 'power_user' ELSE 'regular' END as segment
350
+ FROM sessions
351
+ )
352
+ SELECT
353
+ seg.segment,
354
+ COUNT(*) as purchase_count,
355
+ ROUND(AVG(TRY_CAST(e.amount AS DOUBLE)), 2) as avg_amount,
356
+ COUNT(DISTINCT seg.user_id) as users
357
+ FROM segments seg
358
+ JOIN read_json_auto('./data/verify-dungeon-EVENTS.json') e ON seg.user_id = e.user_id
359
+ WHERE e.event = 'purchase'
360
+ GROUP BY seg.segment;
361
+ ```
362
+
363
+ ### Temporal Value Scaling (e.g., 3x amounts on 1st/15th)
364
+ ```sql
365
+ SELECT
366
+ CASE WHEN EXTRACT(DAY FROM time::TIMESTAMP) IN (1, 15) THEN 'payday' ELSE 'normal_day' END as period,
367
+ COUNT(*) as event_count,
368
+ ROUND(AVG(TRY_CAST(amount AS DOUBLE)), 2) as avg_amount,
369
+ ROUND(MEDIAN(TRY_CAST(amount AS DOUBLE)), 2) as median_amount
370
+ FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
371
+ WHERE event = 'transaction completed'
372
+ GROUP BY period;
373
+ ```
374
+
375
+ ### Injected Event Detection (cloned events create density anomalies)
376
+ ```sql
377
+ WITH events AS (
378
+ SELECT *, time::TIMESTAMP as ts,
379
+ LAG(time::TIMESTAMP) OVER (PARTITION BY user_id, event ORDER BY time) as prev_same_event
380
+ FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
381
+ WHERE event = 'purchase'
382
+ )
383
+ SELECT
384
+ CASE WHEN prev_same_event IS NOT NULL AND ts - prev_same_event < INTERVAL '10 minutes'
385
+ THEN 'rapid_cluster' ELSE 'normal_spacing' END as pattern,
386
+ COUNT(*) as count,
387
+ ROUND(AVG(TRY_CAST(amount AS DOUBLE)), 2) as avg_amount
388
+ FROM events
389
+ GROUP BY pattern;
390
+ ```
391
+
392
+ ### Cross-Table Correlation (everything hook reads meta.profile)
393
+ ```sql
394
+ WITH users AS (
395
+ SELECT * FROM read_json_auto('./data/verify-dungeon-USERS.json')
396
+ ),
397
+ events AS (
398
+ SELECT * FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
399
+ )
400
+ SELECT
401
+ u.tier,
402
+ COUNT(*) as event_count,
403
+ COUNT(DISTINCT e.user_id) as user_count,
404
+ ROUND(COUNT(*) * 1.0 / COUNT(DISTINCT e.user_id), 2) as events_per_user,
405
+ AVG(TRY_CAST(e.metric AS DOUBLE)) as avg_metric
406
+ FROM events e
407
+ JOIN users u ON e.user_id = u.distinct_id
408
+ GROUP BY u.tier
409
+ ORDER BY u.tier;
410
+ ```
411
+
412
+ When verifying `everything` hooks, you often MUST join events with user profiles. Join key is **`events.user_id = users.distinct_id`**.
413
+
414
+ ## Output files by data type
415
+
416
+ - `verify-dungeon-EVENTS.json` — events (most hooks produce effects here)
417
+ - `verify-dungeon-USERS.json` — user profiles (check for `user` hook enrichment)
418
+ - `verify-dungeon-*-GROUPS.json` — group profiles (if groups configured)
419
+ - `verify-dungeon-*-SCD.json` — SCD data (if SCDs configured)
420
+
421
+ ## Advanced feature verification
422
+
423
+ Advanced features (personas, worldEvents, engagementDecay, dataQuality, subscription, attribution, geo, features, anomalies) produce data patterns alongside hooks. When verifying:
424
+
425
+ ```sql
426
+ -- Personas: check distribution matches configured weights
427
+ SELECT _persona, count(*) as users FROM read_json_auto('./data/verify-dungeon-USERS.json') WHERE _persona IS NOT NULL GROUP BY 1;
428
+
429
+ -- World Events: check injected properties exist during event windows
430
+ SELECT promo, count(*) FROM read_json_auto('./data/verify-dungeon-EVENTS.json') WHERE promo IS NOT NULL GROUP BY 1;
431
+
432
+ -- Data Quality: verify bots, nulls, empty events
433
+ SELECT 'bots' as metric, count(*) FROM read_json_auto('./data/verify-dungeon-USERS.json') WHERE is_bot = true
434
+ UNION ALL SELECT 'null_props', count(*) FROM read_json_auto('./data/verify-dungeon-EVENTS.json') WHERE category IS NULL;
435
+
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;
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;
442
+
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;
445
+
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;
448
+
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
+ ```
452
+
453
+ Advanced feature patterns should ALWAYS be present (deterministic from config), unlike hooks which may have statistical variance.
454
+
455
+ ## Standard verification checks (run for every dungeon)
456
+
457
+ ### 1. SuperProp Consistency
458
+ Verify each user has exactly 1 value per superProp:
459
+
460
+ ```sql
461
+ SELECT
462
+ 'PROP_NAME' as prop,
463
+ COUNT(*) as total_users,
464
+ COUNT(*) FILTER (WHERE n = 1) as consistent,
465
+ COUNT(*) FILTER (WHERE n > 1) as inconsistent,
466
+ ROUND(COUNT(*) FILTER (WHERE n = 1) * 100.0 / COUNT(*), 1) as consistency_pct
467
+ FROM (
468
+ SELECT user_id, COUNT(DISTINCT PROP_NAME) as n
469
+ FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
470
+ GROUP BY user_id
471
+ );
472
+ ```
473
+ Verdict: **STRONG** ≥99% consistent, **WEAK** 90-99%, **FAIL** <90%.
474
+
475
+ ### 2. SuperProp-UserProp Mirror Check
476
+ 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
+
478
+ ### 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
482
+
483
+ ### 4. funnel-pre Dilution Check
484
+ For any dungeon with `funnel-pre` conversionRate modifications, verify the actual visible effect:
485
+ - A `conversionRate *= 1.5` in funnel-pre typically shows as ~1.02-1.08x in the data (diluted by organic events)
486
+ - If observed ratio is <1.1x for a funnel-pre conversionRate hook, verdict is **FAIL** with note: "funnel-pre conversionRate diluted by organic events — migrate to `everything` hook event filtering"
487
+ - When the dungeon uses `everything` hook filtering instead, expect the full intended ratio (1.3-1.5x)
488
+
489
+ ## Population threshold validation
490
+
491
+ When a hook targets a specific segment, verify the affected population is large enough to produce a visible signal:
492
+
493
+ ```sql
494
+ SELECT
495
+ segment_column,
496
+ COUNT(DISTINCT user_id) as users,
497
+ ROUND(COUNT(DISTINCT user_id) * 100.0 / (SELECT COUNT(DISTINCT user_id) FROM read_json_auto('./data/verify-dungeon-EVENTS.json')), 1) as pct_of_users
498
+ FROM read_json_auto('./data/verify-dungeon-EVENTS.json')
499
+ WHERE event = 'relevant_event'
500
+ GROUP BY segment_column
501
+ ORDER BY users DESC;
502
+ ```
503
+
504
+ **Thresholds (at 1K users):**
505
+ - Segment <20 users (<2%): hook signal will be WEAK or invisible — flag as "insufficient population"
506
+ - Segment 20-50 users: may show signal but with high variance — note in report
507
+ - Segment >50 users: should show clear signal if hook effect ≥1.3x
508
+
509
+ ## Statistical caveats
510
+
511
+ This skill always runs at full fidelity (the dungeon's own scale). At full fidelity, cohorts of all sizes should produce clear signal because the absolute population is large. WEAK or FAIL results at full fidelity indicate a real problem — investigate, do not retry at smaller scale.
512
+
513
+ `--small` mode is a developer-troubleshooting escape hatch on the runner script; verdicts from `--small` runs are unreliable and not permitted in this skill's output.
514
+
515
+ ## Verifying no-flag cohort patterns
516
+
517
+ Modern dungeons hide cohort effects behind raw event mutations rather than stamping flags like `is_whale=true`. Verification must DERIVE the cohort behaviorally, then measure the downstream metric.
518
+
519
+ **Magic-number BEHAVIORAL pattern** — count an event per user, bin into low/sweet/over, compare downstream metric per bucket:
520
+
521
+ ```sql
522
+ WITH x_counts AS (
523
+ SELECT user_id, COUNT(*) FILTER (WHERE event = '<X_EVENT>') AS x_n
524
+ FROM read_json_auto('./data/<run>-EVENTS.json')
525
+ GROUP BY user_id
526
+ ),
527
+ buckets AS (
528
+ SELECT user_id,
529
+ CASE WHEN x_n < <SWEET_LOW> THEN 'low'
530
+ WHEN x_n <= <SWEET_HIGH> THEN 'sweet'
531
+ ELSE 'over' END AS bucket
532
+ FROM x_counts
533
+ )
534
+ SELECT b.bucket,
535
+ COUNT(DISTINCT b.user_id) AS users,
536
+ AVG(TRY_CAST(e.<TARGET_PROP> AS DOUBLE)) AS avg_target_prop,
537
+ COUNT(*) FILTER (WHERE e.event = '<TARGET_EVENT>') AS total_target_events,
538
+ ROUND(COUNT(*) FILTER (WHERE e.event = '<TARGET_EVENT>') * 1.0 /
539
+ COUNT(DISTINCT b.user_id), 2) AS target_per_user
540
+ FROM buckets b
541
+ JOIN read_json_auto('./data/<run>-EVENTS.json') e ON b.user_id = e.user_id
542
+ GROUP BY b.bucket;
543
+ ```
544
+
545
+ **Verdict criteria for inverted-U**:
546
+ - `sweet` bucket avg target ≥1.2x `low` bucket → boost present (STRONG)
547
+ - `over` bucket target_per_user ≤0.7x `sweet` bucket → drop present (STRONG)
548
+ - Both visible → inverted-U STRONG
549
+ - Either missing → flag as WEAK or FAIL with note about cohort sizes
550
+
551
+ **Inverted-U cohort confound — use NORMALIZED metric for the drop side.** The "over" bucket users have higher activity by definition (they crossed the threshold). So per-user metrics for downstream events naturally INCREASE with bucket — masking any drop hook. Example:
552
+
553
+ | bucket | builds | deploys | deploys_per_user | deploys_per_build (NORMALIZED) |
554
+ |--------|--------|---------|------------------|--------------------------------|
555
+ | low | 10457 | 6623 | 5.02 | 0.633 |
556
+ | sweet | 43351 | 36770 | 18.28 (looks BIG)| 0.848 (boost visible) |
557
+ | over | 86765 | 52024 | 31.43 (looks BIGGER)| 0.600 (drop visible) |
558
+
559
+ `deploys_per_user` shows over > sweet > low (cohort effect dominates). `deploys_per_build` correctly shows boost (sweet > low) AND drop (over < sweet). Always include the normalized variant in the drop-side query.
560
+
561
+ When the dungeon doesn't have a natural "per-X" denominator, compute one from the cohort-binning event: `target_events / cohort_event_count`.
562
+
563
+ **Time-to-convert (funnel-post) verification** — compute median A→B time per profile segment:
564
+
565
+ ```sql
566
+ WITH funnel AS (
567
+ SELECT user_id,
568
+ MIN(time::TIMESTAMP) FILTER (WHERE event = '<STEP_A>') AS a_time,
569
+ MIN(time::TIMESTAMP) FILTER (WHERE event = '<STEP_B>') AS b_time
570
+ FROM read_json_auto('./data/<run>-EVENTS.json')
571
+ GROUP BY user_id
572
+ )
573
+ SELECT u.<SEGMENT_KEY>,
574
+ COUNT(*) AS users,
575
+ ROUND(MEDIAN(EXTRACT(EPOCH FROM (b_time - a_time)) / 60), 2) AS median_min_a_to_b
576
+ FROM funnel f
577
+ JOIN read_json_auto('./data/<run>-USERS.json') u ON f.user_id = u.distinct_id
578
+ WHERE a_time IS NOT NULL AND b_time IS NOT NULL
579
+ GROUP BY u.<SEGMENT_KEY>
580
+ ORDER BY median_min_a_to_b;
581
+ ```
582
+
583
+ **Verdict for T2C**:
584
+ - Fast segment ≤0.85x baseline → STRONG
585
+ - Slow segment ≥1.2x baseline → STRONG
586
+ - Both directions visible → STRONG
587
+ - One/both missing → check that funnel exists in `funnels:` config and segment property is on `meta.profile`
588
+
589
+ **Two-tier T2C interpretation**: When a dungeon has only 2 tiers (e.g. Free vs Paid) the funnel-post hook factor `1.0` branch never fires — both tiers fall into either fast or slow. Pick the slower of the two as the implicit baseline, then verify the faster shows ≤0.85x of it.
590
+
591
+ **No-flag verification rule**: NEVER attempt to verify a hook by querying for a flag like `WHERE sweet_spot = true`. If a dungeon has such flags, treat them as a doc bug — the hook should be reworked to hide the cohort behaviorally. The validator's job is to derive cohorts behaviorally.
592
+
593
+ ## Drop-event funnel dilution diagnosis
594
+
595
+ Many dungeons have hooks of pattern `record.filter(e => e.event === 'X' && chance.bool({likelihood: 30}))` to drop ~30% of step-3 events for non-paid tier. The doc claims the hook produces a 30% conversion drop in the funnel — but funnel completion rates often barely move (e.g. 95% vs 97%).
596
+
597
+ **Why:** the hook drops EVENTS not users. A user with 5 step-3 events still appears in the funnel after losing 1-2 events. Funnel completion = `users with ≥1 step-3 event` — only zero-step-3 users disappear from the conversion count, which is rare.
598
+
599
+ **Correct verification metric:** per-user volume of step-3 events by tier:
600
+
601
+ ```sql
602
+ SELECT u.subscription_tier,
603
+ COUNT(DISTINCT user_id) AS users,
604
+ COUNT(*) AS total_step3,
605
+ ROUND(COUNT(*) * 1.0 / COUNT(DISTINCT user_id), 2) AS per_user
606
+ FROM read_json_auto('./data/<run>-EVENTS.json')
607
+ WHERE event = '<STEP_3_EVENT>'
608
+ GROUP BY u.subscription_tier
609
+ ORDER BY per_user DESC;
610
+ ```
611
+
612
+ Expected: paid tier ~1.5x non-paid per_user (matches 30% drop on non-paid → paid keeps 100%, non-paid keeps 70%, ratio 1/0.7 = 1.43x).
613
+
614
+ If funnel completion gap <5pt but per_user gap ≥30%, the hook IS firing — the doc just points to the wrong metric. Mark STRONG, recommend doc redirect to per-user query.
615
+
616
+ ## Subscription tier cohort sizing check
617
+
618
+ Before testing any hook gated on `subscription_tier === "annual"` or `"family"`, check cohort sizes:
619
+
620
+ ```sql
621
+ SELECT subscription_plan, COUNT(*) FROM read_json_auto('./data/<run>-USERS.json')
622
+ GROUP BY subscription_plan;
623
+ ```
624
+
625
+ The default subscription lifecycle (`trialToPayRate=0.30`, `upgradeRate=0.06-0.08`) produces ~85% NULL/Free, ~10-15% Monthly, <2% Annual, ~0% Family at 5K users. Cohorts <50 users will not produce statistically clean signal at any effect size.
626
+
627
+ **If annual cohort <50 users:**
628
+ - Don't trust per-tier ratios — note "cohort too small" in results.md
629
+ - Bump `numUsers` up to 5x to enlarge cohorts
630
+ - Or recommend dungeon author tighten subscription lifecycle config
631
+
632
+ ## Per-day normalization for time-window hooks
633
+
634
+ Time-window hooks (e.g. "5x deaths in cursed week d40-47") often produce similar per-USER counts across windows because users active in the window are different from users active overall. Compare per-DAY rates instead:
635
+
636
+ ```
637
+ cursed period (7 days): 20711 deaths / 2948 users = 7.03 deaths/user
638
+ other period (~93 days): 34857 deaths / 4984 users = 6.99 deaths/user
639
+
640
+ Per-day rate:
641
+ cursed: 7.03 / 7 = 1.00 deaths/user/day
642
+ other: 6.99 / 93 = 0.075 deaths/user/day
643
+ ratio: 13x ← signal lives here
644
+ ```
645
+
646
+ For any spike/burst hook with a tight day window, ALWAYS normalize by window length before comparing.
647
+
648
+ ## Determinism check (optional confidence test)
649
+
650
+ The pinned `datasetStart`/`datasetEnd` window plus seeded RNG produces near-bit-exact output across runs. To confirm no NEW non-determinism crept in (e.g. wall-clock leak in a hook):
651
+
652
+ 1. Run a previously-passing dungeon a second time.
653
+ 2. Compare `eventCount` in the runner's JSON output — should match within ~0.5%.
654
+ 3. Re-run the hook's headline query and verify ratios match to 2 decimals.
655
+
656
+ **Tolerance note**: most vertical dungeons produce bit-exact event counts across runs, but a few show <0.5% variance from RNG-state interactions. Variance at this scale does NOT affect hook signal direction or magnitude — all signals remain stable across runs. Treat <1% event-count drift as acceptable; investigate only if drift exceeds 1% OR a hook ratio swings meaningfully (>10% relative change between runs).
657
+
658
+ If event count differs by >1% OR a hook ratio swings sharply, the hook has a fresh non-determinism source (typically `dayjs()`, `Date.now()`, `Math.random()`, or stale module-level state). Fix before continuing.
659
+
660
+ ## Critical time-window verification pattern
661
+
662
+ Many dungeons use relative time windows (e.g., "spike on days 75-85"). The post-shift dataset start is exposed to hooks as `meta.datasetStart` (unix seconds). For DuckDB verification, use the same anchor:
663
+
664
+ ```sql
665
+ -- WRONG: uses MIN(time) which is up to 30 days BEFORE dataset start (pre-existing user spread)
666
+ SELECT *, EXTRACT(EPOCH FROM (time::TIMESTAMP - (SELECT MIN(time::TIMESTAMP) FROM events))) / 86400 as day_in
667
+ FROM events;
668
+
669
+ -- RIGHT: anchor to MAX(time) - num_days, which is the post-shift dataset start
670
+ WITH bounds AS (
671
+ SELECT MAX(time::TIMESTAMP) - INTERVAL 'NUM_DAYS' day as datasetStart
672
+ FROM events
673
+ )
674
+ SELECT *, EXTRACT(EPOCH FROM (e.time::TIMESTAMP - b.datasetStart)) / 86400 as day_in
675
+ FROM events e, bounds b;
676
+ ```
677
+
678
+ Pre-existing users have events for up to 30 days BEFORE the dataset start (`preExistingSpread: 'uniform'` default in macro). MIN(time) reflects those pre-existing events, not the dataset window. Always anchor to MAX(time) - num_days for "day in dataset" calculations.
679
+
680
+ ## TTC hook verification — two approaches
681
+
682
+ TTC hooks come in two forms. Use the matching verification approach:
683
+
684
+ ### Approach 1: Property-Scaling TTC (preferred — produces NAILED verdicts)
685
+
686
+ The hook scales a timing PROPERTY (e.g., `response_time_mins *= 0.67`) by segment. Verification is trivial:
687
+
688
+ ```sql
689
+ SELECT segment,
690
+ ROUND(AVG(response_time_mins), 1) AS avg_response,
691
+ ROUND(AVG(resolution_time_mins), 1) AS avg_resolution
692
+ FROM events
693
+ WHERE event IN ('alert acknowledged', 'alert resolved')
694
+ GROUP BY segment ORDER BY avg_response;
695
+ ```
696
+
697
+ This consistently produces exact matches to the hook factors (e.g., 0.67x target → 0.665x measured).
698
+
699
+ ### Approach 2: Timestamp-Shifting TTC (use when no timing property exists)
700
+
701
+ The hook shifts event timestamps in the everything hook using `scaleFunnelTTC()` or manual gap scaling. Verification requires a **bound-sequence query** — never use the lazy MIN→MIN proxy:
702
+
703
+ ```sql
704
+ -- WRONG: lazy MIN→MIN proxy (mixes events from different funnel passes)
705
+ SELECT user_id, MIN(a.time) AS start, MIN(b.time) AS end ...
706
+
707
+ -- RIGHT: bound-sequence (first A, then first B AFTER that A)
708
+ WITH steps AS (
709
+ SELECT user_id, event, time::TIMESTAMP AS t
710
+ FROM events WHERE event IN ('step_a', 'step_b', 'step_c')
711
+ ),
712
+ funnel AS (
713
+ SELECT DISTINCT ON (a.user_id) a.user_id, a.t AS start_t,
714
+ (SELECT MIN(t) FROM steps c
715
+ WHERE c.user_id = a.user_id AND c.event = 'step_c' AND c.t > a.t) AS end_t
716
+ FROM steps a WHERE a.event = 'step_a'
717
+ ORDER BY a.user_id, a.t
718
+ )
719
+ SELECT segment,
720
+ COUNT(*) AS users,
721
+ ROUND(MEDIAN(EXTRACT(EPOCH FROM (end_t - start_t)) / 60), 1) AS median_min
722
+ FROM funnel JOIN users USING (user_id)
723
+ WHERE end_t IS NOT NULL
724
+ GROUP BY segment ORDER BY median_min;
725
+ ```
726
+
727
+ The bound-sequence pattern finds the first A per user, then the first C strictly after that A. This matches how the everything hook operates and typically produces STRONG verdicts. The lazy MIN→MIN proxy produces flat or inverted results because it grabs unrelated events from different funnel passes.
728
+
729
+ ### Which approach to recommend when writing hooks
730
+
731
+ Property scaling is strictly better for verification. When creating new TTC hooks, always prefer scaling timing properties (see HOOKS.md principle #15). Reserve timestamp shifting for cases where no numeric timing property exists on the relevant events.
732
+
733
+ ### Legacy funnel-post TTC hooks
734
+
735
+ If a dungeon still uses `funnel-post` for TTC (not yet migrated to `everything`), the effect is only visible in Mixpanel's funnel median TTC report, not in any SQL query. Mark as STRONG by code inspection and recommend migration to property scaling or everything-hook timestamp shifting.
736
+
737
+ ## Magic-number cohort sizing — inspect distribution first
738
+
739
+ Before checking inverted-U signal magnitude, confirm the cohort sizes are statistically meaningful (≥200 in sweet bucket). If cohort is too small, signal magnitude is irrelevant:
740
+
741
+ ```sql
742
+ SELECT pn, COUNT(*) FROM (
743
+ SELECT user_id, COUNT(*) FILTER (WHERE event = '<X_EVENT>') AS pn
744
+ FROM events GROUP BY user_id
745
+ ) GROUP BY pn ORDER BY pn LIMIT 20;
746
+ ```
747
+
748
+ If 90%+ of users have 0-1 events of X, the dungeon's `sweet=4-7 / over=8+` ranges produce <50 users in sweet → no signal possible. Two fixes:
749
+ 1. Bump `numUsers` 5x (cohort grows linearly with users; preserves story)
750
+ 2. Recommend the dungeon author redefine ranges to match actual distribution (e.g. `sweet=2-5 / over=6+`)
751
+
752
+ Choice depends on whether the JSDoc's stated ranges are load-bearing for the dungeon's narrative ("you need 8+ photos to seem fake" — preserve range, scale up users) or arbitrary ("sweet 4-7" can shift to "sweet 2-5" without losing the story).
753
+
754
+ ## Re-run required after hook edits
755
+
756
+ If you edit a hook then query the existing data files, you'll get STALE results. The verifier must re-run the dungeon AND wait for full completion before re-querying:
757
+
758
+ ```bash
759
+ rm -f ./data/verify-<NAME>-*
760
+ node scripts/verify-runner.mjs dungeons/vertical/<NAME>.js verify-<NAME>
761
+ # Wait for the {"mode":"full","eventCount":...} JSON to print before querying
762
+ ```
763
+
764
+ For batched output (multi-million events), the runner writes `verify-<NAME>-EVENTS-part-*.json` instead of a single `verify-<NAME>-EVENTS.json`. Use glob in queries:
765
+
766
+ ```sql
767
+ read_json_auto('./data/verify-<NAME>-EVENTS-part-*.json', sample_size=-1, union_by_name=true)
768
+ ```
769
+
770
+ Without the glob, queries against `verify-<NAME>-EVENTS.json` fail with "No files found".
771
+
772
+ ## Event hook meta.datasetStart pitfall
773
+
774
+ The `event` hook receives `meta.datasetStart` as a unix timestamp, but temporal hooks checking `dayInDataset >= N` often produce NONE verdicts because the anchor doesn't match expectations. Proven fix: move temporal windowing to the `everything` hook where `meta.datasetStart` is verified reliable. The `everything` hook also allows push() for event cloning instead of return (which replaces the event in the `event` hook).
775
+
776
+ **When to move temporal hooks to `everything`:**
777
+ - Any hook that checks `dayInDataset` ranges and scores NONE at verification
778
+ - Any hook that needs to CLONE events (push to array) rather than REPLACE
779
+ - Any hook that needs access to the user's full event history for context
780
+
781
+ **When to keep hooks in `event` type:**
782
+ - Closure-based state patterns (module-level Maps) that track across users
783
+ - Event REPLACEMENT (returning a different event, e.g., alert → incident)
784
+ - Simple property mutations that don't need temporal context
785
+
786
+ ## Property baseline dilution
787
+
788
+ When a hook overrides a property value (e.g., `event_type = "plan_upgraded"`), the effect is invisible if the baseline distribution already has a high rate of that value. Example: if `plan_upgraded` is 1 of 5 values (20% baseline), a 40% hook override produces ~28% observed — nearly invisible.
789
+
790
+ **Fix:** skew the baseline distribution AWAY from the hook's target value. Make `plan_upgraded` 1 of 8+ values (12.5% baseline), then the 40% hook produces ~48% in the window — a clear 4x spike.
791
+
792
+ Similarly, if a hook forces `scale_direction = "down"` but the baseline is already 86% "down" (6:1 ratio in config), the hook is invisible. Change the baseline to favor "up" (e.g., 3:1 up:down) so the hook's forced "down" creates a measurable shift.
793
+
794
+ ## Computing the dataset window
795
+
796
+ Dungeons declare their time window in one of three ways — the verifier must derive the actual start/end before writing DuckDB queries:
797
+
798
+ | Config shape | How to derive window |
799
+ |---|---|
800
+ | `datasetStart` + `datasetEnd` | Use directly |
801
+ | `numDays` only (no explicit start/end) | `datasetEnd = NOW`, `datasetStart = NOW - numDays` |
802
+ | `datasetStart` + `numDays` | `datasetEnd = datasetStart + numDays` |
803
+
804
+ The engine always resolves to a `[datasetStart, datasetEnd]` pair internally (see `config-validator.js`). To find the actual window from the OUTPUT data:
805
+
806
+ ```sql
807
+ SELECT
808
+ MAX(time::TIMESTAMP) as datasetEnd,
809
+ MAX(time::TIMESTAMP) - INTERVAL '<numDays>' DAY as datasetStart
810
+ FROM read_json_auto('./data/verify-X-EVENTS*.json', sample_size=-1);
811
+ ```
812
+
813
+ Use `datasetStart` (derived above) as the DuckDB anchor for day-in-dataset:
814
+
815
+ ```sql
816
+ WITH bounds AS (
817
+ SELECT MAX(time::TIMESTAMP) - INTERVAL '<numDays>' DAY as ds_start
818
+ FROM read_json_auto('./data/verify-X-EVENTS*.json', sample_size=-1)
819
+ )
820
+ SELECT EXTRACT(EPOCH FROM (e.time::TIMESTAMP - b.ds_start)) / 86400 as day_in
821
+ FROM events e, bounds b;
822
+ ```
823
+
824
+ Do NOT use `MIN(time)` as the anchor — pre-existing users have events up to 30 days before `datasetStart` (from `preExistingSpread: 'uniform'`).
825
+
826
+ When the dungeon has explicit `datasetStart` (e.g., `"2026-01-01T00:00:00Z"`), use it directly: `TIMESTAMP '2026-01-01'`. When `numDays` is used without explicit start, derive from MAX(time) as shown above.
827
+
828
+ ## No flag stamping audit
829
+
830
+ Hooks must NEVER add cohort flags like `is_whale`, `power_user`, `sweet_spot`, `is_churned`, etc. All cohorts must be derived behaviorally from raw event data. When auditing a dungeon, check the hook for any property assignments that create boolean/categorical flags not defined in the original schema. If found, remove them and rewrite the hook to achieve the same effect through property value mutations, event filtering, or event injection.
831
+
832
+ ## Clone dilution of temporal effects
833
+
834
+ When a dungeon has BOTH temporal value mutations (e.g., "days 30-60 offer_price 2.5x") AND event cloning hooks (e.g., "pre-approved users get 5 extra offers"), cloned events with time offsets can land inside the temporal window without receiving the mutation — because the temporal hook ran BEFORE the cloning.
835
+
836
+ **Diagnosis:** The temporal effect shows a lower ratio than expected (e.g., 1.2x instead of 2.5x). Check if other hooks clone events that could land in the temporal window.
837
+
838
+ **Fix:** Move the temporal value mutation to the END of the everything hook, after all cloning/injection hooks. Re-run and verify.
839
+
840
+ ## Cohort detection survives filtering
841
+
842
+ If Hook A classifies users by event presence (`events.some(e => e.event === X)`) and Hook B later removes events (churn, retention filter), the verification query may misclassify users whose marker events were filtered. The "non-cohort" group gets contaminated with cohort members, diluting the measured ratio.
843
+
844
+ **Diagnosis:** Expected ratio is 8x but observed is <2x. Check if the cohort detection event is also affected by a downstream filter.
845
+
846
+ **Fixes:**
847
+ 1. Require 3+ marker events instead of 1+ (surviving events still identify)
848
+ 2. Accept the verification limitation and note it in the report
849
+ 3. Use a metric that doesn't depend on cohort reconstruction (e.g., overall distribution shift instead of cohort comparison)
850
+
851
+ ## Deprecated feature property gaps
852
+
853
+ Dungeons using deprecated config blocks (`subscription`, `attribution`, `features`, `geo`, `anomalies`) may have hooks that depend on properties those blocks used to generate. The engine silently strips deprecated configs, so properties like `coaching_mode`, `subscription_plan`, or `feature_tier` never appear in the data.
854
+
855
+ **Diagnosis:** Hook logic references a property that's always NULL/undefined in the output. Check if the property was produced by a deprecated feature.
856
+
857
+ **Fix:** The dungeon author must add equivalent property generation in the hook itself (via `user` or `everything` hook) or add the property to `superProps`/`userProps` with appropriate values. This is a schema-level fix, not a verification fix — flag it in the report as "NONE: deprecated feature property missing" with the recommended fix.