@ak--47/dungeon-master 1.7.0 → 1.8.1

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 (43) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +30 -11
  2. package/.claude/skills/create-dungeon/SKILL.md +84 -44
  3. package/.claude/skills/create-project/SKILL.md +28 -3
  4. package/.claude/skills/create-project/context.mjs +89 -0
  5. package/.claude/skills/create-project/provision.mjs +1 -60
  6. package/.claude/skills/headless-build/SKILL.md +39 -12
  7. package/.claude/skills/powertools/SKILL.md +26 -3
  8. package/.claude/skills/release-check/SKILL.md +124 -0
  9. package/.claude/skills/verify-dungeon/SKILL.md +103 -29
  10. package/.claude/skills/verify-dungeon/references/alignment-contract.md +84 -0
  11. package/.claude/skills/verify-dungeon/references/counting-semantics.md +41 -16
  12. package/.claude/skills/verify-dungeon/references/report-format.md +41 -10
  13. package/.claude/skills/verify-dungeon/references/sql-recipes.md +171 -226
  14. package/.claude/skills/warehouse-metrics/GAPS-template.md +34 -0
  15. package/.claude/skills/warehouse-metrics/SKILL.md +111 -0
  16. package/.claude/skills/warehouse-metrics/deploy.mjs +651 -0
  17. package/.claude/skills/write-hooks/SKILL.md +94 -51
  18. package/CHANGELOG.md +183 -0
  19. package/HOOKS.md +165 -18
  20. package/README.md +265 -1
  21. package/docs/guides/1.8.0-upgrade-guide.md +151 -0
  22. package/docs/guides/1.8.1-upgrade-guide.md +153 -0
  23. package/dungeons/technical/warehouse.js +187 -0
  24. package/index.js +116 -2
  25. package/lib/core/config-validator.js +21 -0
  26. package/lib/core/dungeon-loader.js +1 -1
  27. package/lib/core/storage.js +51 -3
  28. package/lib/generators/events.js +6 -0
  29. package/lib/generators/funnels.js +15 -0
  30. package/lib/generators/standalone.js +248 -0
  31. package/lib/generators/warehouse.js +828 -0
  32. package/lib/hook-helpers/shape.js +73 -17
  33. package/lib/orchestrators/mixpanel-sender.js +27 -2
  34. package/lib/orchestrators/user-loop.js +83 -15
  35. package/lib/templates/story-spec.schema.json +41 -16
  36. package/lib/utils/utils.js +37 -12
  37. package/lib/verify/funnel-engine.js +66 -26
  38. package/lib/verify/index.js +1 -0
  39. package/lib/verify/story-runner.js +71 -8
  40. package/lib/verify/warehouse.js +683 -0
  41. package/package.json +4 -2
  42. package/scripts/verify-stories.mjs +150 -44
  43. package/types.d.ts +312 -9
package/HOOKS.md CHANGED
@@ -21,10 +21,12 @@ hook: function (record, type, meta) { ... return record; }
21
21
  | `event` | `events.js:176` | Single event (flat props) | **Used** (replaces event) | `user: { distinct_id }`, `config`, `datasetStart`, `datasetEnd` |
22
22
  | `funnel-post` | `funnels.js:153` | Array of funnel events | Ignored (mutate in-place) | `user`, `profile`, `scd`, `funnel`, `config`, `experiment` |
23
23
  | `everything` | `user-loop.js:280` | Array of ALL user events | **Used** if array returned | `profile`, `scd`, `config`, `datasetStart`, `datasetEnd`, `userIsBornInDataset`, `authTime`, `isPreAuth`, `persona` |
24
- | `ad-spend` | `storage.js` | Ad spend event | Ignored | -- |
25
- | `group` | `storage.js` | Group profile | Ignored | -- |
26
- | `mirror` | `storage.js` | Mirror data point | Ignored | -- |
27
- | `lookup` | `storage.js` | Lookup table entry | Ignored | -- |
24
+ | `ad-spend` | `storage.js` | Ad spend event | Object or array used | -- |
25
+ | `group` | `storage.js` | Group profile | Object or array used | -- |
26
+ | `mirror` | `storage.js` | Mirror data point | Object or array used | -- |
27
+ | `lookup` | `storage.js` | Lookup table entry | Object or array used | -- |
28
+ | `standalone` | `storage.js`, before user loop | Identity-less cadence event | Object or array used; `undefined` drops | `spec`, `config`, `datasetStart`, `datasetEnd` |
29
+ | `warehouse` | `storage.js` | One materialized warehouse row | Ignored | `spec`, `config`, `metricName`, `bucketIndex`, `bucketCount`, `grain`, `seriesKey`, `isBackfill`, `raw`, `datasetStart`, `datasetEnd` |
28
30
 
29
31
  **Per-user execution order:** `user` -> `scd-pre` -> `funnel-pre` -> `event` -> `funnel-post` -> `everything`
30
32
 
@@ -36,7 +38,105 @@ double-fire mutations.
36
38
  **Return rules:**
37
39
  - `event`: return the (possibly replaced) event object.
38
40
  - `everything`: return the (possibly modified) array. Filtered array removes events.
39
- - All other types: mutate `record` in-place. Return value is ignored.
41
+ - `ad-spend`, `group`, `mirror`, `lookup`, `standalone`: return the record or an
42
+ array of records. Returning `undefined` drops the record.
43
+ - `user`, `scd-pre`, `funnel-pre`, `funnel-post`, `warehouse`: mutate `record`
44
+ in place. Return value is ignored.
45
+
46
+ `standaloneEvents` runs before the user loop; `warehouseMetrics` materializes
47
+ after it. Neither hook receives person metadata or enters `everything`.
48
+ Standalone synthetic `distinct_id` values identify series, never people. Use
49
+ disk-backed `duckdb` assertions on `{{PREFIX}}-STANDALONE*.json` for cadence
50
+ stories. Warehouse stories support `warehouse` and `warehouse-stats`
51
+ assertions, with automatic table audits even when no stories are exported.
52
+
53
+ ### 1.1 Warehouse rows (`type === 'warehouse'`)
54
+
55
+ `warehouse` fires once per materialized row, after the user loop and before the
56
+ rows are written to disk. the row already matches the manifest contract:
57
+
58
+ - `timeColumn`
59
+ - every `source.groupBy` key, in order
60
+ - `valueColumn`
61
+ - every declared key in `columns`
62
+
63
+ declare every key up front. warehouse containers are created with a fixed column
64
+ list, and the manifest is built from that same list. an undeclared key is not
65
+ part of the output contract even if it exists briefly in memory.
66
+
67
+ `meta.seriesKey` is the joined group tuple in `source.groupBy` order, separated
68
+ by `|`. examples:
69
+
70
+ - no `groupBy` → `''`
71
+ - `groupBy: ['region']` and `row.region === 'us'` → `'us'`
72
+ - `groupBy: ['region', 'plan_tier']` and `row.region === 'us'`, `row.plan_tier === 'enterprise'` → `'us|enterprise'`
73
+
74
+ `meta.bucketIndex` and `meta.bucketCount` are chronological and include history
75
+ buckets even when `sparse: true` skips repeated rows. `meta.isBackfill` is true
76
+ for the synthetic buckets created by `history`. `meta.raw` is the bucketed
77
+ source truth before `scale`, `noise`, and point-in-time carry-forward.
78
+
79
+ Treat the time axis as immutable. `row[spec.timeColumn]` drives ordering,
80
+ manifest SQL, and warehouse verification. mutate the value column or declared
81
+ extra columns instead.
82
+
83
+ Recipe: scale a point-in-time level for an in-window story slice
84
+
85
+ ```js
86
+ warehouseMetrics: [{
87
+ name: 'daily_active_subscriptions',
88
+ type: 'point-in-time',
89
+ source: { event: 'subscription_started', minus: 'subscription_cancelled', groupBy: 'region' },
90
+ baseline: 40,
91
+ timeColumn: 'date',
92
+ valueColumn: 'active_subscriptions',
93
+ columns: { lifted: false },
94
+ }],
95
+
96
+ hook: (row, type, meta) => {
97
+ if (type !== 'warehouse') return row;
98
+ if (meta.metricName !== 'daily_active_subscriptions') return row;
99
+ if (meta.isBackfill) return row;
100
+
101
+ const liveIndex = meta.bucketIndex - meta.spec.history;
102
+ if (meta.seriesKey === 'us' && liveIndex >= 7 && liveIndex < 14) {
103
+ row.active_subscriptions = Math.round(row.active_subscriptions * 1.2);
104
+ row.lifted = true;
105
+ }
106
+ return row;
107
+ }
108
+ ```
109
+
110
+ Recipe: apply an incident dip to one series only
111
+
112
+ ```js
113
+ warehouseMetrics: [{
114
+ name: 'daily_new_bookings',
115
+ source: {
116
+ event: 'new_booking',
117
+ measure: 'sum',
118
+ property: 'booking_value',
119
+ groupBy: ['region', 'plan_tier'],
120
+ },
121
+ timeColumn: 'date',
122
+ valueColumn: 'bookings',
123
+ columns: { incident: false },
124
+ }],
125
+
126
+ hook: (row, type, meta) => {
127
+ if (type !== 'warehouse') return row;
128
+ if (meta.metricName !== 'daily_new_bookings') return row;
129
+ if (meta.isBackfill) return row;
130
+ if (meta.seriesKey !== 'us|enterprise') return row;
131
+
132
+ const liveIndex = meta.bucketIndex - meta.spec.history;
133
+ if (liveIndex >= 14 && liveIndex <= 16) {
134
+ row.bookings = Math.round(row.bookings * 0.35);
135
+ row.incident = true;
136
+ }
137
+ return row;
138
+ }
139
+ ```
40
140
 
41
141
  **What 1.7.0 changed for hooks.** No hook signature, `meta` field, or firing
42
142
  order changed, and the hook-helper atoms and patterns are untouched. What a hook
@@ -437,12 +537,22 @@ enter on birth, or drop next-day spill in an `everything` hook.
437
537
 
438
538
  ### 2.8 Funnel reentry: state machine resets after completion
439
539
 
440
- Reference: `history.cpp` (`last_step_starts_next_funnel`). With reentry
441
- enabled, after the state machine reaches the final step the engine resets to
442
- step 0 and continues scanning. `result.completions` reports the total. In
443
- `countMode: 'totals'` the engine returns one `FunnelResult` per completion
444
- (simultaneous histories one user, many funnel completions). Without
445
- reentry the funnel runs once per user.
540
+ Reference: `history.cpp` (`history_is_mutable`) and `funnel_query.cpp`
541
+ (shared first/last step handling). With `reentry: true`, completion absorbs
542
+ events through the inclusive 2-second grace period. The next event beyond
543
+ grace starts the next scan. Conversion-window expiry can restart earlier.
544
+ `graceperiod: false` disables the completion wait.
545
+
546
+ When an event records the ordered last step and also matches the ordered
547
+ first step, it closes one attempt and anchors the next immediately. Both
548
+ selectors must match. Any-order edges do not use this exception.
549
+ `woRepeat` still restarts only at window expiry.
550
+
551
+ `result.completions` reports repeat completions in uniques mode. In
552
+ `countMode: 'totals'`, the engine returns one `FunnelResult` per attempt,
553
+ including partial attempts. **Compatibility unchanged:** `reentry` defaults
554
+ to `false`, even for totals. Totals alone does not enable analytics general
555
+ counting's repeat-history behavior.
446
556
 
447
557
  ### 2.9 HPC (Hold Property Constant) — parallel sub-funnels
448
558
 
@@ -455,13 +565,24 @@ directly, or (v1.6) pass `holdPropertyConstant: '<prop>'` to the
455
565
  `funnelFrequency` emulator — it routes through the HPC engine and reports
456
566
  per-held-value sub-funnel counts.
457
567
 
568
+ Session windows derive ordinals from the full user stream before HPC
569
+ partitioning. Events with another held value can bridge a session but cannot
570
+ fill steps in the current bucket. This applies to explicit session windows
571
+ and `countMode: 'sessions'`. The local session defaults remain a 30-minute
572
+ timeout, 24-hour maximum, and UTC day boundaries.
573
+
458
574
  ### 2.10 Funnel segment modes (FIRST_TOUCH / LAST_TOUCH / STEP)
459
575
 
460
576
  Reference: `options.hpp` `funnel_segment_mode`; `history.cpp`
461
577
  `property_set_buffer`. The engine snapshots the matched event's properties
462
- at every funnel step. Segmentation chooses which step's properties to use:
463
- FIRST_TOUCH (step 0), LAST_TOUCH (last reached), or STEP N (specific index).
464
- Enable with `evaluateFunnel({ trackStepProperties: true })`, then pick with
578
+ at every reached position. FIRST_TOUCH and LAST_TOUCH merge those snapshots
579
+ in recorded path order, including partial and any-order paths. The first
580
+ or last defined non-null value wins, respectively. Undefined never replaces
581
+ a defined value; null never replaces a defined non-null value. If only null
582
+ and undefined are present, null wins. Snapshots remain unchanged.
583
+
584
+ STEP N selects one reached position without merging fallback values.
585
+ Enable with `evaluateFunnel(events, steps, { trackStepProperties: true })`, then pick with
465
586
  `resolveFunnelSegment(result, 'first' | 'last' | { step: N })`.
466
587
 
467
588
  ### 2.11 Engine-validation guarantees (v1.5+)
@@ -1743,8 +1864,9 @@ event set rather than one value moment.
1743
1864
  **Hook:** `everything`
1744
1865
  **Mixpanel report:** Flows — top paths after the anchor event show the engineered branch (Section 2.17)
1745
1866
 
1746
- **In Mixpanel:** ~30% of users who view an item proceed straight down
1747
- `add to cart begin checkout`, making it the dominant Sankey branch.
1867
+ **In Mixpanel:** Bias the first branch toward `add to cart begin checkout`.
1868
+ The helper selects ~30% of users for append-only injection. Existing traffic
1869
+ can interrupt the branch; the final branch share is not guaranteed to be 30%.
1748
1870
 
1749
1871
  ```js
1750
1872
  import { applyPathBias } from "@ak--47/dungeon-master/hook-helpers";
@@ -1789,6 +1911,8 @@ if (type === "everything") {
1789
1911
  sessionsPerWeek: 3,
1790
1912
  eventsPerSession: 5,
1791
1913
  sessionMinutes: 25,
1914
+ datasetStart: meta.datasetStart,
1915
+ datasetEnd: meta.datasetEnd,
1792
1916
  });
1793
1917
  }
1794
1918
  ```
@@ -1797,12 +1921,35 @@ if (type === "everything") {
1797
1921
  event set (after the `everything` hook), so wholesale timestamp rewrites no
1798
1922
  longer leave stale session labels. The atom keeps intra-session gaps well
1799
1923
  under the 30-min timeout (spacing capped at 20min + bounded jitter), keeps
1800
- inter-session gaps well over it, and never crosses UTC midnight inside one
1924
+ inter-session gaps over it when explicit bounds are supplied, and never crosses UTC midnight inside one
1801
1925
  engineered session (the day-boundary split would cut it). Retiming only — no
1802
1926
  events are added or dropped, so total counts and event mixes are untouched.
1803
1927
  Session count follows `min(sessionsPerWeek × weeks, ceil(N /
1804
1928
  eventsPerSession))`: scarce users get fewer sessions, not fabricated events.
1805
1929
 
1930
+ `datasetStart` and `datasetEnd` are additive, optional arguments. Existing
1931
+ calls with neither bound keep the original full-UTC-day placement between
1932
+ the user's first and last active days. A two-event stream at 12:00/12:20 can
1933
+ still request two sessions with `eventsPerSession: 1` and `sessionMinutes: 5`.
1934
+ Legacy overfull requests also keep their old behavior: they do not throw,
1935
+ but their clusters can merge under the 30-minute timeout.
1936
+
1937
+ The bounds accept ISO strings, unix seconds, or unix milliseconds. Hook
1938
+ metadata uses unix seconds and can be passed directly, as in the example.
1939
+ Either bound enables constrained placement; an omitted side uses the start
1940
+ of the first active UTC day or the end of the last active UTC day. The helper
1941
+ cannot infer `datasetEnd` from the last event. Pass known bounds when the
1942
+ dataset ends partway through an active day, including an inclusive midnight
1943
+ endpoint, to prevent later engine clipping.
1944
+
1945
+ With explicit bounds, partial days compress clusters, including zero-duration
1946
+ clusters at midnight. If the requested sessions cannot fit inside a week's
1947
+ available day slices, the helper throws `RangeError` before changing any
1948
+ events. Invalid bounds also throw. It never silently reduces the target or
1949
+ drops records. Widen the allowed window or reduce the session target. Exact
1950
+ session separation assumes UTC and the default 30-minute timeout, without
1951
+ a maximum session duration.
1952
+
1806
1953
  ---
1807
1954
 
1808
1955
  ## 5. Phase 3 Atom Reference
@@ -1833,7 +1980,7 @@ Import from `@ak--47/dungeon-master/hook-helpers`:
1833
1980
  | `splitByAuth` | identity | `(events, authTime) -> { preAuth, postAuth, stitch }` | Partition by auth boundary |
1834
1981
  | **`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 |
1835
1982
  | **`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) |
1836
- | **`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) |
1983
+ | **`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) |
1837
1984
 
1838
1985
  **Inject atoms + v1.5:** the engine auto-sorts events by time after the
1839
1986
  `everything` hook (`autoSortAfterEverything: true` default — see Principle
package/README.md CHANGED
@@ -91,6 +91,11 @@ const result = await DUNGEON_MASTER({
91
91
  console.log(result.importResults);
92
92
  ```
93
93
 
94
+ `token` imports event-shaped streams only: events, users, groups, ad spend, and
95
+ `standaloneEvents`. `warehouseMetrics` does **not** import through this path.
96
+ warehouse tables are materialized locally and need a separate warehouse deploy
97
+ step after the run.
98
+
94
99
  ## dungeons
95
100
 
96
101
  a dungeon is a javascript file that exports a configuration object. it defines your entire data model: events, funnels, user properties, group analytics, SCDs, and a hook function that engineers discoverable patterns into the data.
@@ -537,6 +542,8 @@ dungeon-master generates multiple data types that mirror a real analytics implem
537
542
  | SCDs | `scdProps` | slowly changing dimensions (subscription tier over time) |
538
543
  | lookup tables | `lookupTables` | dimension tables (product catalog, region mapping) |
539
544
  | ad spend | `hasAdSpend` | daily ad spend with impressions, clicks, cost metrics |
545
+ | standalone events | `standaloneEvents` | identity-less metric snapshots on a cadence (infrastructure, finance, ops) |
546
+ | warehouse metrics | `warehouseMetrics` | warehouse source tables derived from generated events, with a manifest for downstream deploy |
540
547
  | mirror datasets | `mirrorProps` | transformed copies of event data (A/B versions) |
541
548
  | organic text | `createTextGenerator` | reviews, support tickets, search queries, chat messages |
542
549
 
@@ -606,6 +613,222 @@ variant assignment is **sticky by default**: a deterministic hash of `user_id` +
606
613
 
607
614
  **the variant lands on the user profile (1.7.0).** every exposed user carries `"Experiment: <name>": "<variant>"` (e.g. `"Experiment: Checkout Redesign": "New Checkout"`), so the funnel breaks down by variant in Mixpanel with a user-property breakdown — no cohort built from the exposure event. stamped when the user is first exposed (respects `startDaysBeforeEnd`); never-exposed users carry nothing; the `user` hook fires before exposure and does not see it, the `everything` hook does. `stampProfile: false` turns it off; `sticky: false` implies off. measured: 0 mismatches between the profile value and the `Variant name` on 13,005 exposure events.
608
615
 
616
+ ## standalone events (identity-less metric snapshots)
617
+
618
+ `standaloneEvents` generates records that describe a **system, not a person**. they carry
619
+ no `user_id` and no `device_id`. use them for infrastructure, finance, and ops telemetry:
620
+ daily CDN egress per region, weekly billing rollups per plan tier, hourly queue depth per
621
+ cluster. `hasAdSpend` is the same idea hard-coded to `$ad_spend`; this is the general form
622
+ and it does not use a Mixpanel reserved event name.
623
+
624
+ ```javascript
625
+ standaloneEvents: [
626
+ {
627
+ event: 'cdn_egress',
628
+ cadence: 'day', // 'hour' | 'day' | 'week' (default 'day')
629
+ dimensions: { region: ['us-east', 'us-west', 'eu'] }, // cross-producted
630
+ distinctIdFrom: 'region', // synthetic id, never a person
631
+ properties: {
632
+ gb_out: (ctx) => 400 + ctx.tickIndex * 3, // shape a trend across the window
633
+ cost_usd: (ctx) => (400 + ctx.tickIndex * 3) * 0.085,
634
+ p95_ms: [120, 140, 160], // same ValueValid forms as event props
635
+ },
636
+ },
637
+ {
638
+ event: 'billing_rollup',
639
+ cadence: 'week',
640
+ dimensions: { tier: ['free', 'pro', 'max'] },
641
+ properties: { mrr_usd: (ctx) => ..., churn_usd: (ctx) => ... },
642
+ },
643
+ ]
644
+ ```
645
+
646
+ the engine emits **one record per cadence tick per dimension cross-product row**. the
647
+ example above produces 3 records per day (`cdn_egress`) plus 3 records per week
648
+ (`billing_rollup`). ticks start at the dataset start and step by the cadence; the last tick
649
+ is the final one at or before the dataset end, so nothing lands in the future.
650
+
651
+ each record carries `event`, `time`, `insert_id`, `distinct_id`, every dimension as a flat
652
+ property, and every resolved entry in `properties`.
653
+
654
+ | field | behavior |
655
+ |---|---|
656
+ | `event` | required, unique across `standaloneEvents` |
657
+ | `cadence` | `'hour'`, `'day'`, or `'week'`. default `'day'` |
658
+ | `dimensions` | object of non-empty arrays, cross-producted. omit for one record per tick |
659
+ | `distinctIdFrom` | must name a declared dimension. omitted → `distinct_id` is the event name |
660
+ | `properties` | keys may not collide with a dimension or with `event`/`time`/`insert_id`/`distinct_id`/`user_id`/`device_id` |
661
+
662
+ property value functions receive a `StandaloneValueContext`: `{ time, config, dimensions,
663
+ tickIndex, tickCount, cadence, event }`. `tickIndex / (tickCount - 1)` is window progress —
664
+ use it to shape growth, a dip, or a spike, guarding `tickCount <= 1` before division.
665
+
666
+ the stream lands in `result.standaloneEventData`, writes to its own `-STANDALONE` file
667
+ shard, and imports to Mixpanel as its own event stream. hooks fire with type
668
+ `"standalone"`; `meta.spec` carries the resolved config so a hook can tell streams apart.
669
+ the hook runs before the user loop. return the record object or an array of records;
670
+ returning `undefined` drops the record. it has no person metadata and never enters
671
+ `everything`. warehouse hooks have a different contract: mutate the row in place;
672
+ their return values are ignored.
673
+
674
+ ```javascript
675
+ hook: (record, type, meta) => {
676
+ if (type === 'standalone' && meta.spec.event === 'cdn_egress' && record.region === 'us-east') {
677
+ record.p95_ms *= 40;
678
+ }
679
+ return record;
680
+ }
681
+ ```
682
+
683
+ validation throws rather than skipping. a malformed entry would silently drop a whole data
684
+ stream, and you would not notice until the charts were wrong.
685
+
686
+ ## warehouse metrics (local source tables)
687
+
688
+ `warehouseMetrics` materializes warehouse-ready tables from the run's own event
689
+ stream after user generation completes. use it when you need a bookings table, a
690
+ subscription level snapshot, or an ARR table that reads like a real warehouse
691
+ source. these rows land in `result.warehouseMetricData`, write to
692
+ `<name>-WAREHOUSE-<table>.csv|json`, and emit one manifest at
693
+ `<name>-WAREHOUSE-MANIFEST.json`.
694
+
695
+ they are **not** imported by `token`. that is deliberate. the live path is:
696
+
697
+ 1. run the dungeon
698
+ 2. review `/warehouse-metrics` in dry-run mode
699
+ 3. obtain explicit operator consent for live execution
700
+ 4. load the tables to bigquery and save the metrics there
701
+
702
+ live deploy uses `bq load --replace`, so it overwrites the destination warehouse
703
+ tables. the shipped script does not prompt on its own, so the operator or agent
704
+ must obtain explicit consent before running it in live mode. if the
705
+ warehouse CRUD docs route returns 404, the deploy still loads tables and connects
706
+ the source, then writes `warehouse/GAPS.md` for manual metric creation.
707
+
708
+ the manifest carries `recommendedAggregation: 'sum' | 'last value'`. the
709
+ Mixpanel warehouse metric API spells that second value as `last_value`; the
710
+ deploy flow maps it for you.
711
+
712
+ there is one real preview trap: `previewWarehouseMetric` rejects raw SQL
713
+ containing `DROP`, `DELETE`, `TRUNCATE`, `ALTER`, `CREATE`, `INSERT`, or
714
+ `UPDATE` as plain substrings. `created_at` trips `CREATE`; `updated_at` trips
715
+ `UPDATE`. aliasing only helps if the blocked text disappears from the query
716
+ entirely.
717
+
718
+ ### canonical shapes
719
+
720
+ additive daily bookings:
721
+
722
+ ```javascript
723
+ warehouseMetrics: [{
724
+ name: 'daily_new_bookings',
725
+ source: {
726
+ event: 'new_booking',
727
+ measure: 'sum',
728
+ property: 'booking_value',
729
+ },
730
+ timeColumn: 'date',
731
+ valueColumn: 'bookings',
732
+ }]
733
+ ```
734
+
735
+ point-in-time daily active subscriptions:
736
+
737
+ ```javascript
738
+ warehouseMetrics: [{
739
+ name: 'daily_active_subscriptions',
740
+ type: 'point-in-time',
741
+ source: {
742
+ event: 'subscription_started',
743
+ minus: 'subscription_cancelled',
744
+ measure: 'count',
745
+ },
746
+ baseline: 40,
747
+ timeColumn: 'date',
748
+ valueColumn: 'active_subscriptions',
749
+ }]
750
+ ```
751
+
752
+ sparse monthly ARR with backfill:
753
+
754
+ ```javascript
755
+ warehouseMetrics: [{
756
+ name: 'monthly_arr_snapshot',
757
+ type: 'point-in-time',
758
+ grain: 'month',
759
+ sparse: true,
760
+ history: 18,
761
+ source: {
762
+ event: 'subscription_started',
763
+ minus: 'subscription_cancelled',
764
+ measure: 'sum',
765
+ property: 'monthly_value',
766
+ },
767
+ baseline: 24000,
768
+ scale: 12,
769
+ timeColumn: 'month',
770
+ valueColumn: 'arr_usd',
771
+ }]
772
+ ```
773
+
774
+ the shipped technical fixture uses a 60-day live window plus 18 monthly backfill
775
+ buckets. sample row counts are illustrative only. `grain`, `history`, `sparse`,
776
+ and `groupBy` all change how many rows a table emits.
777
+
778
+ ### config surface
779
+
780
+ | key | default | range / contract |
781
+ |---|---|---|
782
+ | `name` | required | unique table / metric name, `/^[a-z][a-z0-9_]{0,63}$/` |
783
+ | `type` | `'additive'` | `'additive'` or `'point-in-time'` |
784
+ | `grain` | `'day'` | `'day'`, `'week'`, `'month'` |
785
+ | `sparse` | `false` | boolean, valid only with `type: 'point-in-time'` |
786
+ | `source.event` | required | string or string[] of declared source events |
787
+ | `source.minus` | `[]` | string or string[] of declared subtractive events |
788
+ | `source.measure` | `'count'` | `'count'`, `'sum'`, `'avg'`, `'dau'`, `'users'`; point-in-time forbids `'avg'` and `'dau'` |
789
+ | `source.property` | `null` | required for `'sum'` and `'avg'`; must be declared on every source event or in `superProps` |
790
+ | `source.where` | `null` | optional function over flat event rows |
791
+ | `source.groupBy` | `[]` | up to 2 keys, each declared on every source event or in `superProps`; observed cardinality above 50 warns |
792
+ | `timeColumn` | `'date'` | valid JS identifier; becomes the ordered time axis in rows and manifest |
793
+ | `valueColumn` | `'value'` | valid JS identifier |
794
+ | `baseline` | `0` | number `>= 0`; used only for point-in-time metrics, ignored on additive |
795
+ | `scale` | `1` | finite number `> 0`, applied after bucket aggregation |
796
+ | `noise` | `0` | finite number, clamped to `[0, 0.5]` with a warning |
797
+ | `history` | `0` | integer `>= 0`; warns above roughly 3 years at each grain (`1095` day, `156` week, `36` month) |
798
+ | `columns` | `{}` | extra declared output columns; keys must be valid identifiers and cannot collide with time/value/groupBy columns |
799
+ | `format` | dungeon `format`, else `'csv'` | `'csv'` or `'json'` |
800
+
801
+ materialized tables are deterministic at the same seed and do not perturb the
802
+ event stream. the warehouse pass runs after the user loop, so seeded noise and
803
+ derived columns never change generated events.
804
+
805
+ ### result and manifest
806
+
807
+ ```javascript
808
+ const result = await DUNGEON_MASTER(config);
809
+
810
+ result.warehouseMetricData.daily_new_bookings
811
+ result.warehouseManifest.tables
812
+ result.files
813
+ ```
814
+
815
+ each manifest table includes:
816
+
817
+ | field | meaning |
818
+ |---|---|
819
+ | `table` | warehouse table name |
820
+ | `file` | file prefix without extension |
821
+ | `format` | `'csv'` or `'json'` |
822
+ | `grain` | bucket grain |
823
+ | `type` | additive vs point-in-time |
824
+ | `timeColumn` | date axis column |
825
+ | `valueColumn` | numeric value column |
826
+ | `dimensionColumns` | copied `groupBy` keys |
827
+ | `columns` | ordered BigQuery schema (`DATE`, `FLOAT64`, `BOOL`, `STRING`) |
828
+ | `recommendedAggregation` | `'sum'` or `'last value'` |
829
+ | `sql` | `SELECT * FROM \`{{DATASET}}.<table>\` ORDER BY <timeColumn>` |
830
+ | `refreshHint` | currently `'hourly'` |
831
+
609
832
  ## user generation
610
833
 
611
834
  users are generated with configurable birth distributions, normally controlled via the `macro` preset (see "time shape" above). these three knobs can also be set directly on the dungeon config — they override the preset's values.
@@ -697,8 +920,11 @@ result.userProfilesData // user profiles
697
920
  result.scdTableData // SCD mutations
698
921
  result.groupProfilesData // group profiles
699
922
  result.adSpendData // ad spend data
923
+ result.standaloneEventData // identity-less event snapshots
700
924
  result.lookupTableData // lookup table entries
701
925
  result.mirrorEventData // mirror dataset
926
+ result.warehouseMetricData // warehouse tables keyed by metric name
927
+ result.warehouseManifest // warehouse table manifest
702
928
 
703
929
  result.eventCount // total event count
704
930
  result.userCount // total user count
@@ -731,7 +957,7 @@ styles: `support`, `review`, `search`, `feedback`, `chat`, `email`, `forum`, `co
731
957
  ## scripts
732
958
 
733
959
  ```bash
734
- npm test # full vitest test suite
960
+ npm test # default unit/integration/e2e suite; prunes data/tmp
735
961
  npm run typecheck # typescript check
736
962
  npm run dungeon:run # run a dungeon file locally
737
963
  npm run dungeon:to-json # convert JS dungeon to JSON (for UI import)
@@ -752,6 +978,8 @@ node scripts/verify-runner.mjs <path> [prefix] # generate at full fidelity for
752
978
 
753
979
  ## tests
754
980
 
981
+ 1.8.1 compatibility and output changes: [upgrade guide](docs/guides/1.8.1-upgrade-guide.md).
982
+
755
983
  vitest tests live under `tests/` in three tiers:
756
984
 
757
985
  | dir | scope | wall time |
@@ -773,6 +1001,40 @@ npx vitest tests/unit # watch mode
773
1001
 
774
1002
  `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).
775
1003
 
1004
+ ### editor and offline alignment tests
1005
+
1006
+ VS Code discovers unit, integration, E2E, and alignment tests through one serial
1007
+ `vitest.editor.config.js`. The workspace disables Go test
1008
+ discovery and ignores the overlapping diagnostic Vitest configs. After changing
1009
+ these settings, run **Developer: Reload Window** if stale providers or test runs
1010
+ remain in the Testing panel. Editor runs omit the pruning setup, but they are not
1011
+ OS-sandboxed.
1012
+
1013
+ Use **Tasks: Run Test Task** for `test: regression (offline, macOS)`, or choose
1014
+ the named alignment and sweep tasks from **Tasks: Run Task**. These test tasks
1015
+ never invoke the prune or dungeon-run tasks. Existing dungeon-run cleanup is
1016
+ unchanged and remains separate from testing.
1017
+
1018
+ The `test: engine matrix`, `test: engine short sweep`, and `test: engine full sweep`
1019
+ tasks expose the direct-run engine checks. They are opt-in and use OS network denial.
1020
+ The full engine wrapper is visible under E2E but skipped until `RUN_FULL_SWEEP=1`.
1021
+ Direct-run `.mjs` scripts are not Vitest tests, so they do not get a separate
1022
+ `engine` folder in the Testing tree. The legacy engine sweeps do not use the
1023
+ alignment runner's ten-minute deadline. E2E tests may write files or perform
1024
+ network operations when run directly in the editor; editor execution is not an
1025
+ offline guarantee.
1026
+
1027
+ ```sh
1028
+ node tests/alignment/run.mjs # offline alignment gate
1029
+ node tests/alignment/run.mjs --sweep --timeout-ms=600000 # opt-in bounded sweep
1030
+ ```
1031
+
1032
+ Alignment is a separate test family, excluded from `npm test` but visible in the editor.
1033
+ Its runner enforces OS network denial on macOS, fails closed elsewhere, and kills
1034
+ workers at the ten-minute deadline. Tests and reports live in the source checkout;
1035
+ they are not included in the npm package. The default `npm test` and direct root
1036
+ Vitest commands still prune `data` and `tmp` through their global setup.
1037
+
776
1038
  ### engine tests (direct-run, NOT vitest)
777
1039
 
778
1040
  `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).
@@ -899,6 +1161,8 @@ see [types.d.ts](types.d.ts) for the complete `Dungeon` interface. here are the
899
1161
  | `hasLocation` | boolean | false | include geo properties |
900
1162
  | `hasCampaigns` | boolean | false | include UTM properties |
901
1163
  | `hasAdSpend` | boolean | false | generate ad spend data |
1164
+ | `standaloneEvents` | array | `[]` | identity-less cadence streams that import as events |
1165
+ | `warehouseMetrics` | array | `[]` | local warehouse source tables + manifest, derived from generated events |
902
1166
  | `hasAnonIds` | boolean | false | generate anonymous IDs |
903
1167
  | `hasSessionIds` | boolean | false | generate session IDs |
904
1168
  | `alsoInferFunnels` | boolean | false | auto-generate funnels from events |