@ak--47/dungeon-master 1.8.0 → 1.8.2

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 (33) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +9 -0
  2. package/.claude/skills/create-dungeon/SKILL.md +35 -34
  3. package/.claude/skills/create-project/SKILL.md +6 -0
  4. package/.claude/skills/headless-build/SKILL.md +21 -11
  5. package/.claude/skills/powertools/SKILL.md +6 -2
  6. package/.claude/skills/release-check/SKILL.md +27 -2
  7. package/.claude/skills/verify-dungeon/SKILL.md +32 -13
  8. package/.claude/skills/verify-dungeon/references/alignment-contract.md +110 -0
  9. package/.claude/skills/verify-dungeon/references/counting-semantics.md +29 -16
  10. package/.claude/skills/verify-dungeon/references/report-format.md +23 -9
  11. package/.claude/skills/verify-dungeon/references/sql-recipes.md +135 -225
  12. package/.claude/skills/warehouse-metrics/SKILL.md +6 -0
  13. package/.claude/skills/write-hooks/SKILL.md +61 -48
  14. package/CHANGELOG.md +82 -0
  15. package/HOOKS.md +105 -47
  16. package/README.md +41 -1
  17. package/docs/guides/1.8.1-upgrade-guide.md +153 -0
  18. package/docs/guides/1.8.2-upgrade-guide.md +110 -0
  19. package/lib/generators/events.js +6 -0
  20. package/lib/generators/funnels.js +16 -0
  21. package/lib/hook-helpers/shape.js +73 -17
  22. package/lib/hook-patterns/attributed-by-source.js +4 -3
  23. package/lib/hook-patterns/funnel-frequency-breakdown.js +4 -7
  24. package/lib/orchestrators/user-loop.js +82 -15
  25. package/lib/verify/counting.js +7 -10
  26. package/lib/verify/emulate-breakdown.js +48 -29
  27. package/lib/verify/funnel-engine.js +93 -40
  28. package/lib/verify/identity.js +32 -9
  29. package/lib/verify/story-runner.js +93 -30
  30. package/lib/verify/verify-dungeon.js +4 -1
  31. package/package.json +1 -1
  32. package/scripts/verify-stories.mjs +3 -3
  33. package/types.d.ts +9 -5
package/HOOKS.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # HOOKS.md -- Hook Encyclopedia
2
2
 
3
- Hook reference and recipe catalog for dungeon-master. Every recipe is calibrated
4
- against Mixpanel's actual counting semantics (greedy single-pass funnels,
5
- distinct-period frequency, null-aware aggregation, capped attribution) see
6
- [Section 2](#2-how-mixpanel-counts-things) before adapting any pattern.
3
+ Hook reference and recipe catalog for dungeon-master. Recipes have different
4
+ counting and evidence requirements. Read [Section 2](#2-how-mixpanel-counts-things)
5
+ before adapting a pattern, then verify the exact report on the emitted dataset.
6
+ The [1.8.2 guide](docs/guides/1.8.2-upgrade-guide.md) records live-tested scope and limits.
7
7
 
8
8
  ---
9
9
 
@@ -172,20 +172,28 @@ verification emulator (`@ak--47/dungeon-master/verify`) now matches these
172
172
  rules; old recipes that ignored them will look correct on the dataset but
173
173
  fail when verified or queried in Mixpanel.
174
174
 
175
- ### 2.1 Frequency reports count DISTINCT PERIODS, not total events
175
+ ### 2.1 Frequency depends on the selected report
176
176
 
177
- Mixpanel's frequency distribution / cohort-by-event-count reports count
178
- **distinct time periods** (default: days) on which the user fired the
179
- event. Two purchases on the same day = frequency **1**, not 2.
177
+ Three measurements require different checks:
178
+
179
+ - **Raw per-user event count:** two purchases on one day count as two. Insights
180
+ per-user count histograms and frequency breakdowns using total event count use
181
+ this axis. `applyFrequencyByFrequency` scales this target count.
182
+ - **Distinct calendar-day activity:** those purchases count as one active day.
183
+ `countDistinctPeriods` defaults to UTC calendar buckets (`ui-bucket`), and
184
+ `binBy: 'distinctDays'` uses the same rule. This default is retained for
185
+ compatibility; it is not a universal Mixpanel frequency-report rule.
186
+ - **Rolling Frequency/Addiction:** an event counts when it is at least one
187
+ selected unit after the last counted event. Histories reset per report interval.
188
+ Two events around midnight can occupy two calendar days but one rolling period.
180
189
 
181
190
  Two related rules exist (v1.6 names them for what they are; the old
182
191
  `'calendar'` / `'rolling'` names remain as silent aliases, unknown names
183
192
  now throw):
184
193
 
185
194
  - **`algorithm: 'ui-bucket'`** (default in our verifier):
186
- `COUNT(DISTINCT date_trunc(unit, time))` in UTC. Matches what the Mixpanel
187
- UI shows and what [`injectOnNewDays`](lib/hook-helpers/inject.js) uses
188
- internally.
195
+ `COUNT(DISTINCT date_trunc(unit, time))` in UTC. This is also what
196
+ [`injectOnNewDays`](lib/hook-helpers/inject.js) uses internally.
189
197
  - **`algorithm: 'mixpanel-rolling'`**: the C++
190
198
  `addiction_query.cpp` rule `qtz_time >= last_counted + seconds_for_unit`
191
199
  (`addiction_query_update_history`, `addiction_query.cpp:363-374`) — what
@@ -193,8 +201,8 @@ now throw):
193
201
  boundaries (events at 23:59 + 00:01 next day = 1 rolling period, 2
194
202
  calendar periods).
195
203
 
196
- Use the default (`ui-bucket`) for hooks. Use `'mixpanel-rolling'` only
197
- when verifying behavior that explicitly depends on the C++ implementation.
204
+ Choose the algorithm from the requested report. Do not replace a raw-count
205
+ histogram with active-day counts, or use calendar days to claim rolling parity.
198
206
 
199
207
  **The actual Frequency report shape** is `frequencyHistogram(events,
200
208
  { event, unit, intervalDays, profiles })` (v1.6): per report interval, a
@@ -205,19 +213,19 @@ zero bucket (`addiction_query.cpp:546-573`). Array length is
205
213
  `ceil(interval / unit)` (`unit.c:108-113`). Use it when a dungeon targets
206
214
  the Frequency report itself rather than a frequency-derived cohort.
207
215
 
208
- **Implication for hooks:** `scaleEventCount(record, "Buy", 3)` clones 3x as
209
- many Buy events at sub-second offsets they all land on the same calendar
210
- day, so the user moves **zero bins** in Mixpanel's frequency report. Use
216
+ **Implication for hooks:** `scaleEventCount(record, "Buy", 3)` targets 3x as
217
+ many Buy events at one-second offsets. This changes raw-count histogram bins.
218
+ It generally does not add active days or rolling periods. Use
211
219
  [`injectOnNewDays`](lib/hook-helpers/inject.js) when the goal is to move
212
- users between frequency bins. Both `injectOnNewDays` and the default
220
+ users between calendar-day activity bins. Both `injectOnNewDays` and the default
213
221
  `countDistinctPeriods` algorithm use calendar-bucket math, so they agree
214
222
  at boundaries.
215
223
 
216
224
  One second is also below Mixpanel's 30-minute session gap, so the default
217
225
  spread cannot create a new session either. The full list of metrics a default
218
- `scaleEventCount` call **cannot** move: active days per user, DAU, stickiness
219
- (DAU÷MAU), sessions per user, frequency bins, and retention. It moves event
220
- volume, and nothing else.
226
+ `scaleEventCount` call does not reliably move: active days per user, DAU,
227
+ stickiness (DAU/MAU), sessions per user, rolling frequency, and retention.
228
+ At dataset edges, clones can be clipped. Verify surviving counts separately.
221
229
 
222
230
  As of v1.6.4 you can pass `{ spreadDays: N }` to scatter the clones across the
223
231
  next N days instead:
@@ -363,7 +371,7 @@ sampling.
363
371
 
364
372
  ### 2.5 Active-day distribution is config-first
365
373
 
366
- Mixpanel frequency reports count distinct days (§2.1). The v1.5 engine
374
+ Distinct-day activity is a separate measurement from raw counts (§2.1). The engine
367
375
  exposes `Dungeon.avgActiveDaysPerUser` as the canonical primitive for this
368
376
  shape. Set it at the config level and the engine concentrates each user's
369
377
  events onto a sampled subset of days drawn from `normal(mean=N, sd=N/3)`,
@@ -537,12 +545,22 @@ enter on birth, or drop next-day spill in an `everything` hook.
537
545
 
538
546
  ### 2.8 Funnel reentry: state machine resets after completion
539
547
 
540
- Reference: `history.cpp` (`last_step_starts_next_funnel`). With reentry
541
- enabled, after the state machine reaches the final step the engine resets to
542
- step 0 and continues scanning. `result.completions` reports the total. In
543
- `countMode: 'totals'` the engine returns one `FunnelResult` per completion
544
- (simultaneous histories one user, many funnel completions). Without
545
- reentry the funnel runs once per user.
548
+ Reference: `history.cpp` (`history_is_mutable`) and `funnel_query.cpp`
549
+ (shared first/last step handling). With `reentry: true`, completion absorbs
550
+ events through the inclusive 2-second grace period. The next event beyond
551
+ grace starts the next scan. Conversion-window expiry can restart earlier.
552
+ `graceperiod: false` disables the completion wait.
553
+
554
+ When an event records the ordered last step and also matches the ordered
555
+ first step, it closes one attempt and anchors the next immediately. Both
556
+ selectors must match. Any-order edges do not use this exception.
557
+ `woRepeat` still restarts only at window expiry.
558
+
559
+ `result.completions` reports repeat completions in uniques mode. In
560
+ `countMode: 'totals'`, the engine returns one `FunnelResult` per attempt,
561
+ including partial attempts. **Compatibility unchanged:** `reentry` defaults
562
+ to `false`, even for totals. Totals alone does not enable analytics general
563
+ counting's repeat-history behavior.
546
564
 
547
565
  ### 2.9 HPC (Hold Property Constant) — parallel sub-funnels
548
566
 
@@ -555,13 +573,24 @@ directly, or (v1.6) pass `holdPropertyConstant: '<prop>'` to the
555
573
  `funnelFrequency` emulator — it routes through the HPC engine and reports
556
574
  per-held-value sub-funnel counts.
557
575
 
576
+ Session windows derive ordinals from the full user stream before HPC
577
+ partitioning. Events with another held value can bridge a session but cannot
578
+ fill steps in the current bucket. This applies to explicit session windows
579
+ and `countMode: 'sessions'`. The local session defaults remain a 30-minute
580
+ timeout, 24-hour maximum, and UTC day boundaries.
581
+
558
582
  ### 2.10 Funnel segment modes (FIRST_TOUCH / LAST_TOUCH / STEP)
559
583
 
560
584
  Reference: `options.hpp` `funnel_segment_mode`; `history.cpp`
561
585
  `property_set_buffer`. The engine snapshots the matched event's properties
562
- at every funnel step. Segmentation chooses which step's properties to use:
563
- FIRST_TOUCH (step 0), LAST_TOUCH (last reached), or STEP N (specific index).
564
- Enable with `evaluateFunnel({ trackStepProperties: true })`, then pick with
586
+ at every reached position. FIRST_TOUCH and LAST_TOUCH merge those snapshots
587
+ in recorded path order, including partial and any-order paths. The first
588
+ or last defined non-null value wins, respectively. Undefined never replaces
589
+ a defined value; null never replaces a defined non-null value. If only null
590
+ and undefined are present, null wins. Snapshots remain unchanged.
591
+
592
+ STEP N selects one reached position without merging fallback values.
593
+ Enable with `evaluateFunnel(events, steps, { trackStepProperties: true })`, then pick with
565
594
  `resolveFunnelSegment(result, 'first' | 'last' | { step: N })`.
566
595
 
567
596
  ### 2.11 Engine-validation guarantees (v1.5+)
@@ -855,15 +884,12 @@ Reference: `flows_query.cpp:988-994` (next-anchor-only), `flows.cpp:680-717`
855
884
 
856
885
  ### New principles from the emulator alignment
857
886
 
858
- 21. **Distinct-day vs total-event binning.** Frequency-distribution reports in
859
- Mixpanel count distinct days (Section 2.1). For any hook whose verification
860
- target is a frequency report, use [`binByDistinctPeriods`](lib/verify/counting.js)
861
- instead of `binUsersByEventCount`. For hooks targeting raw event counts
862
- (Insights `total events`, `events per user`), `binUsersByEventCount` is
863
- still correct.
887
+ 21. **Match the bin to the report.** Use `binUsersByEventCount` for raw event
888
+ counts and `binByDistinctPeriods` for calendar-day activity. Use rolling
889
+ counting for the Frequency/Addiction report. Section 2.1 distinguishes them.
864
890
 
865
- 22. **`scaleEventCount` does not move users between frequency bins.** Cloning
866
- Buy events at sub-second offsets places them on the same calendar day and
891
+ 22. **`scaleEventCount` changes raw-count bins.** Cloning
892
+ Buy events at one-second offsets usually places them on the same calendar day and
867
893
  inside the same 30-minute session window, so distinct days, sessions, DAU,
868
894
  stickiness, and retention are all unchanged. It moves event volume only. To
869
895
  shift any of the others, pass `{ spreadDays: N }` (v1.6.4) or use
@@ -877,11 +903,10 @@ Reference: `flows_query.cpp:988-994` (next-anchor-only), `flows.cpp:680-717`
877
903
  after the prior step's timestamp (with margin > 2 seconds for the grace
878
904
  window).
879
905
 
880
- 24. **Attribution stamping is capped at 10 touchpoints.** When biasing
881
- `firstTouch` attribution by stamping touchpoint events, ≤10 touches per
882
- user enter the candidate pool. Stamping 50 weighted Touch events per
883
- user gives the same answer as stamping 10. Aim for sparse, distinct
884
- touches with deterministic weight ratios.
906
+ 24. **Separate stamping caps from attribution reads.** The generator defaults
907
+ to ten stamped touches per user. Mixpanel first/last reads are uncapped
908
+ within their conversion-bounded lookback. Distinct touch timestamps avoid
909
+ ambiguous attribution when different values tie at the first or last time.
885
910
 
886
911
  25. **Null-aware aggregation removes the need to "fill" defaults.** Don't
887
912
  coalesce missing numeric properties to 0 to keep AVG sane — Mixpanel
@@ -1720,6 +1745,13 @@ touch with a seeded weighted pick (models: `firstTouch`, `lastTouch`,
1720
1745
  `both`). It never stamps unstamped events, so total touch count is
1721
1746
  unchanged.
1722
1747
 
1748
+ The helper selects lifetime endpoints. A report reads eligible touches before
1749
+ each conversion and inside its lookback. The lifetime-last touch can occur after
1750
+ the conversion; the lifetime-first can expire. Neither endpoint is a general
1751
+ conversion-aware guarantee. Pass a deliberately eligible stream to the existing
1752
+ helper or implement that selection in the hook, then verify the requested report.
1753
+ Conflicting sources at an equal timestamp have no stable cross-ingestion order.
1754
+
1723
1755
  ---
1724
1756
 
1725
1757
  #### 4.27 Active-Day Cohort Engineering (v1.5)
@@ -1843,8 +1875,9 @@ event set rather than one value moment.
1843
1875
  **Hook:** `everything`
1844
1876
  **Mixpanel report:** Flows — top paths after the anchor event show the engineered branch (Section 2.17)
1845
1877
 
1846
- **In Mixpanel:** ~30% of users who view an item proceed straight down
1847
- `add to cart begin checkout`, making it the dominant Sankey branch.
1878
+ **In Mixpanel:** Bias the first branch toward `add to cart begin checkout`.
1879
+ The helper selects ~30% of users for append-only injection. Existing traffic
1880
+ can interrupt the branch; the final branch share is not guaranteed to be 30%.
1848
1881
 
1849
1882
  ```js
1850
1883
  import { applyPathBias } from "@ak--47/dungeon-master/hook-helpers";
@@ -1889,6 +1922,8 @@ if (type === "everything") {
1889
1922
  sessionsPerWeek: 3,
1890
1923
  eventsPerSession: 5,
1891
1924
  sessionMinutes: 25,
1925
+ datasetStart: meta.datasetStart,
1926
+ datasetEnd: meta.datasetEnd,
1892
1927
  });
1893
1928
  }
1894
1929
  ```
@@ -1897,12 +1932,35 @@ if (type === "everything") {
1897
1932
  event set (after the `everything` hook), so wholesale timestamp rewrites no
1898
1933
  longer leave stale session labels. The atom keeps intra-session gaps well
1899
1934
  under the 30-min timeout (spacing capped at 20min + bounded jitter), keeps
1900
- inter-session gaps well over it, and never crosses UTC midnight inside one
1935
+ inter-session gaps over it when explicit bounds are supplied, and never crosses UTC midnight inside one
1901
1936
  engineered session (the day-boundary split would cut it). Retiming only — no
1902
1937
  events are added or dropped, so total counts and event mixes are untouched.
1903
1938
  Session count follows `min(sessionsPerWeek × weeks, ceil(N /
1904
1939
  eventsPerSession))`: scarce users get fewer sessions, not fabricated events.
1905
1940
 
1941
+ `datasetStart` and `datasetEnd` are additive, optional arguments. Existing
1942
+ calls with neither bound keep the original full-UTC-day placement between
1943
+ the user's first and last active days. A two-event stream at 12:00/12:20 can
1944
+ still request two sessions with `eventsPerSession: 1` and `sessionMinutes: 5`.
1945
+ Legacy overfull requests also keep their old behavior: they do not throw,
1946
+ but their clusters can merge under the 30-minute timeout.
1947
+
1948
+ The bounds accept ISO strings, unix seconds, or unix milliseconds. Hook
1949
+ metadata uses unix seconds and can be passed directly, as in the example.
1950
+ Either bound enables constrained placement; an omitted side uses the start
1951
+ of the first active UTC day or the end of the last active UTC day. The helper
1952
+ cannot infer `datasetEnd` from the last event. Pass known bounds when the
1953
+ dataset ends partway through an active day, including an inclusive midnight
1954
+ endpoint, to prevent later engine clipping.
1955
+
1956
+ With explicit bounds, partial days compress clusters, including zero-duration
1957
+ clusters at midnight. If the requested sessions cannot fit inside a week's
1958
+ available day slices, the helper throws `RangeError` before changing any
1959
+ events. Invalid bounds also throw. It never silently reduces the target or
1960
+ drops records. Widen the allowed window or reduce the session target. Exact
1961
+ session separation assumes UTC and the default 30-minute timeout, without
1962
+ a maximum session duration.
1963
+
1906
1964
  ---
1907
1965
 
1908
1966
  ## 5. Phase 3 Atom Reference
@@ -1933,7 +1991,7 @@ Import from `@ak--47/dungeon-master/hook-helpers`:
1933
1991
  | `splitByAuth` | identity | `(events, authTime) -> { preAuth, postAuth, stitch }` | Partition by auth boundary |
1934
1992
  | **`applyLifecycleWave`** | shape | `(events, uid, { dormantFromDay, dormantDays, resurrectBurst?, valueMomentEvent, dropAll? }) -> events[]` | Clean dormancy gap + resurrection burst; sweeps the ENTIRE window by timestamp (v1.6, recipe 4.29). Returns a NEW array |
1935
1993
  | **`applyPathBias`** | shape | `(events, uid, { anchor, path, share, gapSeconds? }) -> events[]` | Inject a Flows path after the user's first anchor for ~`share` (fraction) of users; skips users missing any step template (v1.6, recipe 4.30) |
1936
- | **`applySessionShape`** | shape | `(events, uid, { sessionsPerWeek, eventsPerSession, sessionMinutes }) -> events[]` | Retime the stream into deterministic session clusters intra-gaps 30min, inter-gaps 30min, never crosses UTC midnight (v1.6, recipe 4.31) |
1994
+ | **`applySessionShape`** | shape | `(events, uid, { sessionsPerWeek, eventsPerSession, sessionMinutes, datasetStart?, datasetEnd? }) -> events[]` | Preserve records; default legacy full-UTC-day placement. Optional bounds constrain placement and throw atomically on insufficient capacity (recipe 4.31) |
1937
1995
 
1938
1996
  **Inject atoms + v1.5:** the engine auto-sorts events by time after the
1939
1997
  `everything` hook (`autoSortAfterEverything: true` default — see Principle
package/README.md CHANGED
@@ -18,6 +18,10 @@ i built this because i needed it. and after using it across hundreds of customer
18
18
  npm install @ak--47/dungeon-master
19
19
  ```
20
20
 
21
+ 1.8.2 adds live counting verification and fixes identity, session-funnel,
22
+ attribution, and experiment-filter defects. See the
23
+ [upgrade guide](docs/guides/1.8.2-upgrade-guide.md) for compatibility and tested limits.
24
+
21
25
  ## quick start
22
26
 
23
27
  ```javascript
@@ -957,7 +961,7 @@ styles: `support`, `review`, `search`, `feedback`, `chat`, `email`, `forum`, `co
957
961
  ## scripts
958
962
 
959
963
  ```bash
960
- npm test # full vitest test suite
964
+ npm test # default unit/integration/e2e suite; prunes data/tmp
961
965
  npm run typecheck # typescript check
962
966
  npm run dungeon:run # run a dungeon file locally
963
967
  npm run dungeon:to-json # convert JS dungeon to JSON (for UI import)
@@ -978,6 +982,8 @@ node scripts/verify-runner.mjs <path> [prefix] # generate at full fidelity for
978
982
 
979
983
  ## tests
980
984
 
985
+ 1.8.1 compatibility and output changes: [upgrade guide](docs/guides/1.8.1-upgrade-guide.md).
986
+
981
987
  vitest tests live under `tests/` in three tiers:
982
988
 
983
989
  | dir | scope | wall time |
@@ -999,6 +1005,40 @@ npx vitest tests/unit # watch mode
999
1005
 
1000
1006
  `tests/e2e/engine-shape-full-sweep.test.js` skips itself unless `RUN_FULL_SWEEP=1` is set (it wraps the long-running 194-combo engine sweep).
1001
1007
 
1008
+ ### editor and offline alignment tests
1009
+
1010
+ VS Code discovers unit, integration, E2E, and alignment tests through one serial
1011
+ `vitest.editor.config.js`. The workspace disables Go test
1012
+ discovery and ignores the overlapping diagnostic Vitest configs. After changing
1013
+ these settings, run **Developer: Reload Window** if stale providers or test runs
1014
+ remain in the Testing panel. Editor runs omit the pruning setup, but they are not
1015
+ OS-sandboxed.
1016
+
1017
+ Use **Tasks: Run Test Task** for `test: regression (offline, macOS)`, or choose
1018
+ the named alignment and sweep tasks from **Tasks: Run Task**. These test tasks
1019
+ never invoke the prune or dungeon-run tasks. Existing dungeon-run cleanup is
1020
+ unchanged and remains separate from testing.
1021
+
1022
+ The `test: engine matrix`, `test: engine short sweep`, and `test: engine full sweep`
1023
+ tasks expose the direct-run engine checks. They are opt-in and use OS network denial.
1024
+ The full engine wrapper is visible under E2E but skipped until `RUN_FULL_SWEEP=1`.
1025
+ Direct-run `.mjs` scripts are not Vitest tests, so they do not get a separate
1026
+ `engine` folder in the Testing tree. The legacy engine sweeps do not use the
1027
+ alignment runner's ten-minute deadline. E2E tests may write files or perform
1028
+ network operations when run directly in the editor; editor execution is not an
1029
+ offline guarantee.
1030
+
1031
+ ```sh
1032
+ node tests/alignment/run.mjs # offline alignment gate
1033
+ node tests/alignment/run.mjs --sweep --timeout-ms=600000 # opt-in bounded sweep
1034
+ ```
1035
+
1036
+ Alignment is a separate test family, excluded from `npm test` but visible in the editor.
1037
+ Its runner enforces OS network denial on macOS, fails closed elsewhere, and kills
1038
+ workers at the ten-minute deadline. Tests and reports live in the source checkout;
1039
+ they are not included in the npm package. The default `npm test` and direct root
1040
+ Vitest commands still prune `data` and `tmp` through their global setup.
1041
+
1002
1042
  ### engine tests (direct-run, NOT vitest)
1003
1043
 
1004
1044
  `tests/engine/` houses direct-run regression tests at scale. these are NOT vitest-compatible — invoke with `node` directly. used to catch engine regressions across a wide variety of dungeon configurations and for ad-hoc chart inspection. outputs land in `./tmp/` (gitignored).
@@ -0,0 +1,153 @@
1
+ # 1.8.1 Upgrade Guide
2
+
3
+ 1.8.1 repairs funnel counting, lifecycle timing, and emitted identity. Existing
4
+ configurations and call forms remain supported. No property renames or required
5
+ configuration changes are introduced. `applySessionShape` adds optional dataset
6
+ bounds; its existing unbounded calls retain their behavior.
7
+
8
+ Generated timestamps, identities, and report counts can change from 1.8.0 because
9
+ the previous output contained defects. Recheck saved story expectations rather
10
+ than expecting byte-identical output across versions.
11
+
12
+ ## Update the dependency
13
+
14
+ After 1.8.1 is published:
15
+
16
+ ```sh
17
+ npm install @ak--47/dungeon-master@1.8.1
18
+ ```
19
+
20
+ No additional runtime dependency is required. The package still requires Node.js
21
+ 20.20.0 or newer. This guide does not indicate that npm publication has occurred.
22
+
23
+ ## Funnel counts can change
24
+
25
+ - An ordered funnel whose first and last steps match now finalizes its conversion
26
+ correctly. With reentry enabled, the closing event can also start the next
27
+ attempt. Without reentry, it still completes and does not create another attempt.
28
+ - Ordinary completed histories consume events through the inclusive two-second
29
+ completion grace period. Those events do not become a second attempt. The
30
+ existing `graceperiod: false` option disables that wait.
31
+ - Hold-property-constant session windows use session boundaries from the full
32
+ stream before property filtering. Unrelated activity can keep a session open.
33
+ - First/last-touch segmentation merges properties across reached steps. A missing
34
+ property on the preferred step can fall back to another reached step. Explicit
35
+ step selection remains separate.
36
+
37
+ `countMode: 'totals'` still defaults to `reentry: false`. Specify `reentry: true`
38
+ when repeated histories are intended. This compatibility default differs from
39
+ Mixpanel general totals. Do not change existing report options implicitly when
40
+ comparing old and new results.
41
+
42
+ ## Lifecycle and identity output is corrected
43
+
44
+ Retention entry follows adjusted creation, retries precede the final onboarding
45
+ attempt, and usage follows onboarding completion. The engine reconciles its own
46
+ identity fields against surviving auth events after filtering. Engine-created
47
+ data-quality duplicates and world-event clones preserve identity provenance.
48
+
49
+ Explicit hook identity overrides and the synthetic experiment identity exception
50
+ remain supported. A later valid event carrying both IDs can provide identity
51
+ mapping evidence; this is not restricted to the first funnel.
52
+
53
+ Short windows retain partial output instead of introducing a required exception.
54
+ Inspect the existing `result.warnings` collection for:
55
+
56
+ | warning key | meaning |
57
+ | --- | --- |
58
+ | `lifecycle.firstFunnelClipped` | Configured timing cannot fit before dataset end; future steps are omitted without compressing TTC. |
59
+ | `lifecycle.emptyPreAuthAttempt` | Auth is the first step, so a failed pre-auth attempt emits no rows. |
60
+ | `lifecycle.strictAttemptBudget` | The strict event budget cannot retain every surviving attempt entry. |
61
+
62
+ Configured suppression and hook filtering remain authoritative. The engine does
63
+ not recreate intentionally removed events to satisfy an attempt count.
64
+
65
+ ## Bound session reshaping when the dataset end matters
66
+
67
+ Calls without bounds retain legacy full-UTC-day placement. They cannot infer the
68
+ dataset end from the last observed event and may still move events beyond it.
69
+ Pass known bounds to prevent that clipping:
70
+
71
+ ```js
72
+ import { applySessionShape } from '@ak--47/dungeon-master/hook-helpers';
73
+
74
+ const hook = (records, type, meta) => {
75
+ if (type === 'everything') {
76
+ applySessionShape(records, meta.profile.distinct_id, {
77
+ sessionsPerWeek: 3,
78
+ eventsPerSession: 5,
79
+ sessionMinutes: 10,
80
+ datasetStart: meta.datasetStart,
81
+ datasetEnd: meta.datasetEnd,
82
+ });
83
+ }
84
+ return records;
85
+ };
86
+ ```
87
+
88
+ Bounds accept ISO strings, Unix seconds, or Unix milliseconds. Either explicit
89
+ bound enables constrained placement; an omitted side uses the original UTC day
90
+ edge. Invalid bounds or insufficient per-week session capacity throw `RangeError`
91
+ before records are mutated. Two same-day sessions separated by more than thirty
92
+ minutes cannot fit in a twenty-minute interval. Adjust the requested cadence or
93
+ available window rather than swallowing that error.
94
+
95
+ The helper retimes the same records. It does not add or drop events. Unbounded
96
+ legacy requests remain nonthrowing, including overfull layouts where derived
97
+ sessions can merge.
98
+
99
+ ## Path injection remains append-only
100
+
101
+ `applyPathBias` selects the earliest chronological anchor and leaves original
102
+ traffic intact. `share: 1` selects all eligible users for injection. Competing
103
+ events can still interrupt the immediate branch, so it does not promise a 100%
104
+ Flows branch share. Recheck the observed branch, not only the injected count.
105
+
106
+ ## Revalidate the story, not just the schema
107
+
108
+ Run a pinned, seeded representative dungeon with ordinary standalone traffic and
109
+ competing funnels. Compare counts using explicit report settings. For deterministic
110
+ same-version comparisons, use `concurrency: 1` and strip only `insert_id`.
111
+
112
+ The new alignment checks establish selected source-derived Mixpanel contracts.
113
+ They do not execute Mixpanel's engine. Timezone/DST variants, list-valued HPC,
114
+ project-specific session exclusions, all parameter combinations, and arbitrary
115
+ hooks are not covered by a universal parity claim.
116
+
117
+ The recorded sweep produced 17.08 million events across 594 dungeons within ten
118
+ minutes. Its largest single dungeon had 281,751 events. Of 297 cells, 125 met their
119
+ evidence criteria and 172 had insufficient eligible populations. Increasing total
120
+ events is not a substitute for enough eligible users or converters.
121
+
122
+ ## Repository test workflows
123
+
124
+ These commands require a repository checkout with development dependencies.
125
+ The npm package ships this guide, but does not ship the test suites.
126
+
127
+ ```sh
128
+ node tests/alignment/run.mjs
129
+ node tests/alignment/run.mjs --sweep --timeout-ms=600000
130
+ ```
131
+
132
+ Both commands use macOS OS-level network denial and a hard maximum of ten minutes.
133
+ They fail closed on unsupported platforms. The editor's Testing panel is a separate
134
+ workflow: one serial editor config discovers unit, integration, E2E, and alignment
135
+ tests without global pruning. Direct-run engine scripts have opt-in tasks; their
136
+ full-sweep E2E wrapper is visible but skipped by default. Editor runs do not provide
137
+ an OS network sandbox. Use the named offline test tasks when network denial is
138
+ required, especially for E2E tests that can perform external operations.
139
+
140
+ `npm test` retains the default unit/integration/E2E suite and excludes alignment.
141
+ Its global setup prunes local `data` and `tmp`; preserve artifacts before using it.
142
+ The explicit `prune` task and default dungeon-run task retain their existing cleanup
143
+ behavior. None of the new offline test tasks depend on them.
144
+
145
+ ## Before publishing
146
+
147
+ Confirm package version and changelog are 1.8.1. Inspect `npm pack --dry-run --json`
148
+ and rerun the relevant release checks. Publishing to npm is a separate operator
149
+ action; neither test execution nor a Git commit publishes the package.
150
+
151
+ See the [changelog](../../CHANGELOG.md), [hook reference](../../HOOKS.md), and
152
+ [1.8.0 guide](1.8.0-upgrade-guide.md) for the unchanged standalone and warehouse
153
+ metric APIs.
@@ -0,0 +1,110 @@
1
+ # 1.8.2 upgrade guide
2
+
3
+ 1.8.2 fixes verifier counting and experiment-filter defects found while importing
4
+ synthetic datasets and querying them in Mixpanel. Public exports, signatures,
5
+ options, and defaults remain unchanged. No new runtime dependency is required.
6
+
7
+ After publication:
8
+
9
+ ```sh
10
+ npm install @ak--47/dungeon-master@1.8.2
11
+ ```
12
+
13
+ This guide describes a release candidate. A branch, PR, or passing test does not
14
+ publish a package.
15
+
16
+ ## identity requires emitted evidence
17
+
18
+ Automatic verification links a device only when an emitted event contains both
19
+ `device_id` and `user_id`. A profile device pool alone cannot create a link.
20
+ Ordinary event names work; linking is not restricted to an auth-named event.
21
+
22
+ `buildIdentityMap(profiles)` remains unchanged for explicit caller overrides.
23
+ Pass that map only when it represents trusted identity knowledge. Automatic
24
+ mapping preserves an explicit authenticated user on a conflicting device event.
25
+ Original-merge projects, B2B identity rules, external mapping history, and all
26
+ ingestion validation policies are not fully emulated.
27
+
28
+ Historical anonymous events may join their user after the import receipt returns.
29
+ The live verification run observed that delay and later verified the same fixture
30
+ without sending it again. Check event totals and identity readiness before accepting
31
+ a funnel comparison. No sender ordering change or fixed sleep claims to solve this.
32
+
33
+ ## report wrappers use the supported counting code
34
+
35
+ Held-property funnels now support session counting through `funnelFrequency`.
36
+ Matching dungeon defaults no longer inject a millisecond conversion window into
37
+ an implicit one-session report. List-valued held properties expand into per-value
38
+ histories while preserving full-stream session boundaries.
39
+
40
+ Scalar-only held-property keys retain their existing behavior. Mixed list/scalar
41
+ keys use the source-derived string representation. Explicit list-only mode,
42
+ extreme value formatting, and engine cardinality caps remain outside this API.
43
+
44
+ Totals still defaults to `reentry: false`. Set `reentry: true` when comparing
45
+ repeated histories with Mixpanel general totals. Session tests use UTC and the
46
+ default thirty-minute inactivity timeout. Project exclusions and non-UTC/DST
47
+ variants require separate proof.
48
+
49
+ ## passing stories need enough independent users
50
+
51
+ `minCohort` no longer sums overlapping period populations or treats arbitrary rows
52
+ as independent users. It uses a conservative lower bound and applies to custom
53
+ callbacks too. Missing denominator evidence caps an otherwise passing result at
54
+ `WEAK`, with an explanation. Assertions without `minCohort` retain their behavior.
55
+
56
+ This can expose previously optimistic story verdicts. Inspect the selected users,
57
+ entrants, converters, or mature retention cohorts. Do not lower the threshold just
58
+ to restore a green verdict.
59
+
60
+ ## exposure events retain global filters
61
+
62
+ Synthetic `$experiment_started` events now include declared `superProps`, including
63
+ context-aware values. Experiment name and variant fields remain authoritative.
64
+ Run-isolated experiment queries therefore retain the exposure and outcome rows.
65
+ This can change seeded output when global property generation consumes randomness.
66
+ Same-version deterministic runs still exclude only fresh `insert_id` values.
67
+
68
+ Funnel-frequency keep/drop decisions now use stable event fields instead of random
69
+ insertion IDs. Their outcome can change from 1.8.1 but is reproducible on rerun.
70
+
71
+ ## frequency and attribution have distinct contracts
72
+
73
+ Raw event-count histograms, calendar-day activity, and rolling Frequency/Addiction
74
+ reports are separate measurements. `applyFrequencyByFrequency` scales raw target
75
+ event counts. Its existing `binBy: 'distinctDays'` default only selects the cohort
76
+ axis; it does not make clones add new active days. Use explicit report settings.
77
+
78
+ `attributedBy` now ignores null or absent touch values, retains touchless conversions
79
+ as `unknown`, and preserves earlier touches across conversion time buckets.
80
+ The public default still selects the first conversion per user; `perConversion:
81
+ 'all'` evaluates every conversion. The API has no finite-lookback argument.
82
+
83
+ `applyAttributedBySource` retains lifetime endpoint selection. Verify that the
84
+ selected endpoint is inside each intended conversion's lookback. Distinct touch
85
+ timestamps are required for reproducible conflicting source order. Same-time
86
+ ties and backend transition compression can differ from raw event-array order.
87
+
88
+ ## verified scope and remaining work
89
+
90
+ The repository's `tests/alignment/live/REPORT.md` records source revision, run IDs,
91
+ queries, counts, practical effects, neutral controls, and historical failures.
92
+ The local analytics checkout supplied counting contracts; live Mixpanel queries
93
+ tested their behavior on imported data. The C++ engine was not built locally.
94
+
95
+ Evidence covers selected generated conversion, TTC, retention, volume, weights,
96
+ incidents, raw-count frequency, numeric aggregates, attribution, sessions, and
97
+ Flows. It does not establish every configuration cross-product, literal retention
98
+ probability calibration, arbitrary hook behavior, warehouse deployment, or every
99
+ project setting. Capacity and ambiguous-order limits remain explicit.
100
+
101
+ Repository gates preserve data and deny network access:
102
+
103
+ ```sh
104
+ node tests/alignment/run.mjs
105
+ node tests/alignment/run.mjs --sweep --timeout-ms=600000
106
+ ```
107
+
108
+ Live scripts are opt-in and require project-specific authorization and credentials.
109
+ They are excluded from the npm package. Do not use the default pruning test setup
110
+ while retained live datasets are still needed.
@@ -17,6 +17,8 @@ import { dataLogger as logger } from "../utils/logger.js";
17
17
  // Keys that must never be nulled by data quality gremlins
18
18
  const NULL_EXEMPT_KEYS = new Set(['event', 'time', 'insert_id', 'user_id', 'device_id', 'distinct_id', '_drop', '_anomaly', '_persona']);
19
19
 
20
+ export const engineIdentity = Symbol('engineIdentity');
21
+
20
22
 
21
23
  /**
22
24
  * Creates a Mixpanel event with a flat shape
@@ -293,6 +295,9 @@ export async function makeEvent(
293
295
 
294
296
  eventTemplate.insert_id = randomUUID();
295
297
 
298
+ const originalIdentity = { user_id: eventTemplate.user_id, device_id: eventTemplate.device_id };
299
+ Object.defineProperty(eventTemplate, engineIdentity, { value: originalIdentity, configurable: true });
300
+
296
301
  // Call hook if configured (hooks override everything — they are the final authority)
297
302
  const { hook } = config;
298
303
  if (hook) {
@@ -305,6 +310,7 @@ export async function makeEvent(
305
310
  });
306
311
  // If hook returns a modified event, use it; otherwise use original
307
312
  if (hookedEvent && typeof hookedEvent === 'object') {
313
+ Object.defineProperty(hookedEvent, engineIdentity, { value: originalIdentity, configurable: true });
308
314
  return hookedEvent;
309
315
  }
310
316
  }