@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
@@ -0,0 +1,151 @@
1
+ # 1.8.0 Upgrade Guide
2
+
3
+ **TL;DR: 1.8.0 adds two new metric-table surfaces and one CSV serialization fix.**
4
+ `standaloneEvents` is the identity-less event stream shipped earlier on this
5
+ branch. `warehouseMetrics` is new in this release: warehouse source tables plus a
6
+ manifest, derived from the run's own events. The one behavior change is that CSV
7
+ output now preserves falsy cells, so `0` and `false` stop turning into empty
8
+ strings.
9
+
10
+ ## What changed
11
+
12
+ ### 1. `standaloneEvents` is the general identity-less stream
13
+
14
+ If you already adopted the branch version of `standaloneEvents`, 1.8.0 is the
15
+ release that ships it. These rows describe a system, not a person. They carry no
16
+ `user_id` and no `device_id`, but they do import to Mixpanel as normal events.
17
+
18
+ ```js
19
+ standaloneEvents: [{
20
+ event: 'cdn_egress',
21
+ cadence: 'day',
22
+ dimensions: { region: ['us-east', 'us-west', 'eu'] },
23
+ distinctIdFrom: 'region',
24
+ properties: {
25
+ gb_out: (ctx) => 400 + ctx.tickIndex * 3,
26
+ cost_usd: (ctx) => (400 + ctx.tickIndex * 3) * 0.085,
27
+ },
28
+ }]
29
+ ```
30
+
31
+ Use it for infra, finance, and ops telemetry. If the thing should show up in
32
+ Mixpanel as an event stream, use `standaloneEvents`.
33
+
34
+ ### 2. `warehouseMetrics` materializes local warehouse tables
35
+
36
+ If the thing should become a warehouse metric source table, use
37
+ `warehouseMetrics` instead. This pass runs after event generation, reads the
38
+ run's final event stream, and emits local CSV/JSON tables plus a manifest.
39
+
40
+ ```js
41
+ warehouseMetrics: [
42
+ {
43
+ name: 'daily_new_bookings',
44
+ source: { event: 'new_booking', measure: 'sum', property: 'booking_value' },
45
+ timeColumn: 'date',
46
+ valueColumn: 'bookings',
47
+ },
48
+ {
49
+ name: 'daily_active_subscriptions',
50
+ type: 'point-in-time',
51
+ source: { event: 'subscription_started', minus: 'subscription_cancelled', measure: 'count' },
52
+ baseline: 40,
53
+ timeColumn: 'date',
54
+ valueColumn: 'active_subscriptions',
55
+ },
56
+ {
57
+ name: 'monthly_arr_snapshot',
58
+ type: 'point-in-time',
59
+ grain: 'month',
60
+ sparse: true,
61
+ history: 18,
62
+ source: {
63
+ event: 'subscription_started',
64
+ minus: 'subscription_cancelled',
65
+ measure: 'sum',
66
+ property: 'monthly_value',
67
+ },
68
+ baseline: 24000,
69
+ scale: 12,
70
+ timeColumn: 'month',
71
+ valueColumn: 'arr_usd',
72
+ },
73
+ ]
74
+ ```
75
+
76
+ Key rules:
77
+
78
+ - `type`: `'additive'` or `'point-in-time'`.
79
+ - `grain`: `'day'`, `'week'`, `'month'`.
80
+ - `source.measure`: `'count'`, `'sum'`, `'avg'`, `'dau'`, `'users'`.
81
+ - `sum` and `avg` require `source.property`.
82
+ - `point-in-time` forbids `avg` and `dau`.
83
+ - `source.groupBy` allows up to two declared keys.
84
+ - `history` prepends synthetic buckets before the live window.
85
+ - `sparse: true` is only valid on point-in-time metrics and emits the first row
86
+ plus changed values.
87
+
88
+ Artifacts:
89
+
90
+ - `result.warehouseMetricData`
91
+ - `result.warehouseManifest`
92
+ - `<name>-WAREHOUSE-<table>.csv|json`
93
+ - `<name>-WAREHOUSE-MANIFEST.json`
94
+
95
+ `token` does not import these tables. They stay local until you deploy them.
96
+
97
+ ### 3. Warehouse deploy is a separate, confirm-before-live step
98
+
99
+ The shipped path is `/warehouse-metrics` or:
100
+
101
+ ```bash
102
+ node .claude/skills/warehouse-metrics/deploy.mjs <dungeon-path> --data-prefix <verified-prefix> --dry-run
103
+ ```
104
+
105
+ Review the SQL files and `warehouse/GAPS.md` first. Live mode uses
106
+ `bq load --replace`, so it overwrites destination tables. The direct live script
107
+ does not prompt, so the operator or agent must obtain explicit consent before
108
+ running the live command.
109
+
110
+ If the warehouse metric CRUD docs route is unavailable (`GET
111
+ /crud/getWarehouseMetrics` returns 404), deploy still loads the tables and
112
+ connects the source, then writes `warehouse/GAPS.md` for the manual create flow.
113
+
114
+ Two real gotchas are now documented in the shipped flow:
115
+
116
+ - Manifest `recommendedAggregation: 'last value'` becomes API
117
+ `aggregation: 'last_value'`.
118
+ - `previewWarehouseMetric` blocks raw substrings like `CREATE` and `UPDATE`, so
119
+ identifiers like `created_at` and `updated_at` fail preview. A fake alias does
120
+ not help if the blocked text still appears anywhere in the SQL.
121
+
122
+ ### 4. CSV falsy cells stop collapsing to empty strings
123
+
124
+ Before 1.8.0, CSV serialization wrote `0` and `false` as blank cells. That made
125
+ some downstream previews and ad hoc SQL look fine until a real warehouse load or
126
+ verification run compared them to JSON or in-memory output.
127
+
128
+ Now:
129
+
130
+ - `0` stays `0`
131
+ - `false` stays `false`
132
+ - only missing values stay empty
133
+
134
+ If you have downstream logic that treated `''` as a stand-in for zero or false,
135
+ fix that logic.
136
+
137
+ ## Migration checklist
138
+
139
+ 1. Keep `standaloneEvents` when you want Mixpanel events without a person.
140
+ 2. Add `warehouseMetrics` when you want local warehouse tables and a manifest.
141
+ 3. Run the dungeon once before deploy so the warehouse artifacts exist.
142
+ 4. Use `/warehouse-metrics` in dry-run mode, then obtain explicit operator
143
+ consent before the live `bq load --replace` step.
144
+ 5. Recheck any CSV consumers that depended on blank falsy cells.
145
+
146
+ ## Notes on row counts
147
+
148
+ The shipped warehouse fixture is a good shape reference, not a row-count
149
+ contract. It uses a 60-day live window and `history: 18` on the monthly ARR
150
+ table. `grain`, `history`, `sparse`, and `groupBy` all change row counts, so
151
+ sample numbers in review output are illustrative.
@@ -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,187 @@
1
+ // ── IMPORTS ──
2
+ /** @typedef {import('../../types').Dungeon} Config */
3
+
4
+ // ── OVERVIEW ──
5
+ /*
6
+ * NAME: warehouse
7
+ * PURPOSE: Minimal warehouse-metrics fixture covering the three canonical table shapes.
8
+ * SCALE: 200 users, 120 events, 60 days
9
+ * EVENTS (4): page_view (14) > subscription_started (2) > new_booking (1) > subscription_cancelled (1)
10
+ * FUNNELS: none
11
+ * USER PROPS: none
12
+ * SUPER PROPS: none
13
+ * GROUPS: none
14
+ */
15
+
16
+ // ── SCALE ──
17
+ const SEED = 'warehouse-fixture';
18
+ const DATASET_START = '2025-01-01T00:00:00Z';
19
+ const DATASET_END = '2025-03-01T23:59:59Z';
20
+
21
+ // ── CONFIG ──
22
+ /** @type {Config} */
23
+ const config = {
24
+ name: 'warehouse',
25
+ seed: SEED,
26
+ datasetStart: DATASET_START,
27
+ datasetEnd: DATASET_END,
28
+ numUsers: 200,
29
+ numEvents: 120,
30
+ format: 'csv',
31
+ writeToDisk: false,
32
+ verbose: false,
33
+ concurrency: 1,
34
+ credentials: {
35
+ token: '',
36
+ region: 'US',
37
+ },
38
+ switches: {
39
+ hasSessionIds: false,
40
+ hasAdSpend: false,
41
+ hasLocation: false,
42
+ hasAndroidDevices: false,
43
+ hasIOSDevices: false,
44
+ hasDesktopDevices: false,
45
+ hasBrowser: false,
46
+ hasCampaigns: false,
47
+ isAnonymous: false,
48
+ alsoInferFunnels: false,
49
+ },
50
+ events: [
51
+ {
52
+ event: 'page_view',
53
+ weight: 14,
54
+ isStrictEvent: false,
55
+ properties: {
56
+ page: ['/', '/pricing', '/reports', '/billing'],
57
+ },
58
+ },
59
+ {
60
+ event: 'new_booking',
61
+ weight: 1,
62
+ isStrictEvent: false,
63
+ properties: {
64
+ booking_value: [1200, 1800, 2400, 3600],
65
+ },
66
+ },
67
+ {
68
+ event: 'subscription_started',
69
+ weight: 2,
70
+ isStrictEvent: false,
71
+ properties: {
72
+ monthly_value: [100, 250, 500],
73
+ },
74
+ },
75
+ {
76
+ event: 'subscription_cancelled',
77
+ weight: 1,
78
+ isStrictEvent: false,
79
+ properties: {
80
+ monthly_value: [100, 250, 500],
81
+ },
82
+ },
83
+ ],
84
+ warehouseMetrics: [
85
+ {
86
+ name: 'daily_new_bookings',
87
+ type: 'additive',
88
+ grain: 'day',
89
+ source: {
90
+ event: 'new_booking',
91
+ measure: 'sum',
92
+ property: 'booking_value',
93
+ },
94
+ timeColumn: 'date',
95
+ valueColumn: 'bookings',
96
+ },
97
+ {
98
+ name: 'daily_active_subscriptions',
99
+ type: 'point-in-time',
100
+ grain: 'day',
101
+ source: {
102
+ event: 'subscription_started',
103
+ minus: 'subscription_cancelled',
104
+ measure: 'count',
105
+ },
106
+ baseline: 40,
107
+ timeColumn: 'date',
108
+ valueColumn: 'active_subscriptions',
109
+ },
110
+ {
111
+ name: 'monthly_arr_snapshot',
112
+ type: 'point-in-time',
113
+ grain: 'month',
114
+ sparse: true,
115
+ history: 18,
116
+ source: {
117
+ event: 'subscription_started',
118
+ minus: 'subscription_cancelled',
119
+ measure: 'sum',
120
+ property: 'monthly_value',
121
+ },
122
+ baseline: 24000,
123
+ scale: 12,
124
+ timeColumn: 'month',
125
+ valueColumn: 'arr_usd',
126
+ },
127
+ ],
128
+ };
129
+
130
+ export default config;
131
+
132
+ export const stories = [
133
+ {
134
+ id: 'H1-bookings-corr',
135
+ hook: 'H1',
136
+ archetype: 'temporal-inflection',
137
+ narrative: 'The additive warehouse bookings table should track the generated booking revenue closely enough for a warehouse metric demo.',
138
+ assertions: [
139
+ {
140
+ breakdown: { type: 'warehouse-stats', table: 'daily_new_bookings' },
141
+ select: { s: { where: {} } },
142
+ expect: { metric: 's.corr', op: '>=', target: 0.9, floor: 0.7 },
143
+ },
144
+ ],
145
+ },
146
+ {
147
+ id: 'H2-active-subs-shape',
148
+ hook: 'H2',
149
+ archetype: 'session-shape',
150
+ narrative: 'The dense active subscription snapshot should stay fully ordered, gap-free, and numerically populated across the full dataset window.',
151
+ assertions: [
152
+ {
153
+ breakdown: { type: 'warehouse-stats', table: 'daily_active_subscriptions' },
154
+ select: { s: { where: {} } },
155
+ expect: { metric: 's.gaps', op: '<=', target: 0 },
156
+ },
157
+ {
158
+ breakdown: { type: 'warehouse-stats', table: 'daily_active_subscriptions' },
159
+ select: { s: { where: {} } },
160
+ expect: { metric: 's.emptyNumericCells', op: '<=', target: 0 },
161
+ },
162
+ {
163
+ breakdown: { type: 'warehouse-stats', table: 'daily_active_subscriptions' },
164
+ select: { s: { where: {} } },
165
+ expect: { metric: 's.nonMonotonicTime', op: '<=', target: 0 },
166
+ },
167
+ ],
168
+ },
169
+ {
170
+ id: 'H3-arr-history',
171
+ hook: 'H3',
172
+ archetype: 'composition-drift',
173
+ narrative: 'The sparse ARR snapshot should carry meaningful monthly history before the event window without a large seam jump into the live months.',
174
+ assertions: [
175
+ {
176
+ breakdown: { type: 'warehouse-stats', table: 'monthly_arr_snapshot' },
177
+ select: { s: { where: {} } },
178
+ expect: { metric: 's.buckets', op: '>=', target: 18 },
179
+ },
180
+ {
181
+ breakdown: { type: 'warehouse-stats', table: 'monthly_arr_snapshot' },
182
+ select: { s: { where: {} } },
183
+ expect: { metric: 's.seamJumpPct', op: '<=', target: 30 },
184
+ },
185
+ ],
186
+ },
187
+ ];
package/index.js CHANGED
@@ -23,14 +23,18 @@ import { userLoop } from './lib/orchestrators/user-loop.js';
23
23
  import { sendToMixpanel, collectWrittenFiles, releaseConnections } from './lib/orchestrators/mixpanel-sender.js';
24
24
  // Generators
25
25
  import { makeAdSpend } from './lib/generators/adspend.js';
26
+ import { makeStandaloneEvents } from './lib/generators/standalone.js';
26
27
  import { makeMirror } from './lib/generators/mirror.js';
27
28
  import { makeGroupProfile, makeProfile } from './lib/generators/profiles.js';
29
+ import { WarehouseAccumulator, materializeWarehouseMetrics, buildManifest } from './lib/generators/warehouse.js';
28
30
 
29
31
  // Utilities
30
- import { initChance, initUserChance, resetUserChance, resetValueCaches, setAutoPowerLaw, setDatasetNow, setDatasetBegin, deleteFile } from './lib/utils/utils.js';
32
+ import { initChance, initUserChance, resetUserChance, resetValueCaches, setAutoPowerLaw, setDatasetNow, setDatasetBegin, deleteFile, getChance } from './lib/utils/utils.js';
31
33
  import { runWithDataset } from './lib/utils/dataset-context.js';
32
34
 
33
35
  // External dependencies
36
+ import { writeFile } from 'node:fs/promises';
37
+ import path from 'node:path';
34
38
  import dayjs from "dayjs";
35
39
  import utc from "dayjs/plugin/utc.js";
36
40
  import { timer } from 'ak-tools';
@@ -184,6 +188,13 @@ async function runDungeon(config) {
184
188
  storage = await storageManager.initializeContainers();
185
189
  updateContextWithStorage(context, storage);
186
190
 
191
+ if (validatedConfig.warehouseMetrics?.length > 0) {
192
+ context.warehouseAccumulator = new WarehouseAccumulator(validatedConfig.warehouseMetrics, {
193
+ FIXED_BEGIN: context.FIXED_BEGIN,
194
+ FIXED_NOW: context.FIXED_NOW,
195
+ });
196
+ }
197
+
187
198
  // ! DATA GENERATION STARTS HERE
188
199
 
189
200
  // Step 4: Generate ad spend data (if enabled)
@@ -194,6 +205,14 @@ async function runDungeon(config) {
194
205
  context.reportProgress({ phase: "step", step: "adspend", status: "complete", duration: Date.now() - _t4 });
195
206
  }
196
207
 
208
+ // Step 4b: Generate standalone identity-less metric snapshots (if configured) — v1.8.0
209
+ if (validatedConfig.standaloneEvents?.length > 0) {
210
+ context.reportProgress({ phase: "step", step: "standalone", status: "start" });
211
+ const _t4b = Date.now();
212
+ await generateStandaloneData(context);
213
+ context.reportProgress({ phase: "step", step: "standalone", status: "complete", duration: Date.now() - _t4b });
214
+ }
215
+
197
216
  if (context.config.verbose) logger.info('Starting user and event generation...');
198
217
  // Step 5: Main user and event generation
199
218
  context.reportProgress({ phase: "step", step: "users", status: "start" });
@@ -233,6 +252,13 @@ async function runDungeon(config) {
233
252
  context.reportProgress({ phase: "step", step: "mirrors", status: "complete", duration: Date.now() - _t9 });
234
253
  }
235
254
 
255
+ if (validatedConfig.warehouseMetrics?.length > 0) {
256
+ context.reportProgress({ phase: "step", step: "warehouse", status: "start" });
257
+ const _t9b = Date.now();
258
+ await generateWarehouseData(context);
259
+ context.reportProgress({ phase: "step", step: "warehouse", status: "complete", duration: Date.now() - _t9b });
260
+ }
261
+
236
262
  if (context.config.verbose) logger.info('Data generation completed successfully');
237
263
 
238
264
  // ! DATA GENERATION ENDS HERE
@@ -293,11 +319,17 @@ async function runDungeon(config) {
293
319
  // users matching no funnel, …). Always present, even when empty.
294
320
  const warnings = [
295
321
  ...(Array.isArray(validatedConfig._warnings) ? validatedConfig._warnings : []),
322
+ ...(Array.isArray(context.warehouseAccumulator?.warnings) ? context.warehouseAccumulator.warnings.map((reason) => ({
323
+ key: 'warehouseMetrics',
324
+ reason,
325
+ severity: 'warn',
326
+ })) : []),
296
327
  ...context.getWarnings(),
297
328
  ];
298
329
 
299
330
  return {
300
331
  ...extractedData,
332
+ warehouseManifest: context.warehouseManifest,
301
333
  importResults,
302
334
  warnings,
303
335
  files: extractFileInfo(storage),
@@ -356,6 +388,79 @@ async function generateAdSpendData(context) {
356
388
  }
357
389
  }
358
390
 
391
+ /**
392
+ * Generate standalone identity-less metric snapshots — v1.8.0.
393
+ *
394
+ * One record per cadence tick per dimension cross-product row. Records carry no
395
+ * `user_id` and no `device_id`; they describe a system, not a person.
396
+ *
397
+ * @param {Context} context - Context object
398
+ */
399
+ async function generateStandaloneData(context) {
400
+ const { config, storage } = context;
401
+ const specs = /** @type {import('./types').ResolvedStandaloneEventConfig[]} */ (config.standaloneEvents);
402
+
403
+ for (const spec of specs) {
404
+ const records = makeStandaloneEvents(context, spec);
405
+ for (const record of records) {
406
+ // The `standalone` hook fires on push, like ad-spend. Meta carries the
407
+ // stream's resolved spec so a hook can tell the streams apart.
408
+ // `datasetStart`/`datasetEnd` are added by hookPush itself.
409
+ await storage.standaloneEventData.hookPush(
410
+ /** @type {import('./types').EventSchema} */ (record),
411
+ { spec, config }
412
+ );
413
+ }
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Materialize configured warehouse metric tables after the user loop completes.
419
+ *
420
+ * The accumulator taps the final per-user event stream during Step 5. This step
421
+ * runs afterward so seeded noise and column callbacks cannot perturb event generation.
422
+ *
423
+ * @param {Context} context - Context object
424
+ */
425
+ async function generateWarehouseData(context) {
426
+ const { config, storage } = context;
427
+ const specs = /** @type {import('./types').ResolvedWarehouseMetricConfig[]} */ (config.warehouseMetrics);
428
+ const accumulator = context.warehouseAccumulator;
429
+ if (!Array.isArray(specs) || specs.length === 0 || !accumulator) return;
430
+
431
+ const materialized = materializeWarehouseMetrics({
432
+ specs,
433
+ accumulator,
434
+ chance: getChance(),
435
+ FIXED_BEGIN: context.FIXED_BEGIN,
436
+ FIXED_NOW: context.FIXED_NOW,
437
+ configName: config.name,
438
+ config,
439
+ });
440
+
441
+ for (let index = 0; index < materialized.length; index += 1) {
442
+ const entry = materialized[index];
443
+ const container = storage.warehouseMetricData?.[index];
444
+ if (!container) continue;
445
+
446
+ for (let rowIndex = 0; rowIndex < entry.rows.length; rowIndex += 1) {
447
+ await container.hookPush(entry.rows[rowIndex], entry.metas[rowIndex]);
448
+ }
449
+ }
450
+
451
+ const postHookMaterialized = specs.map((spec, index) => ({
452
+ spec,
453
+ rows: Array.from(storage.warehouseMetricData?.[index] || []),
454
+ }));
455
+ context.warehouseManifest = buildManifest(specs, postHookMaterialized, config.name);
456
+
457
+ if (config.writeToDisk && storage.warehouseMetricData?.[0]?.getWriteDir) {
458
+ const manifestPath = path.join(storage.warehouseMetricData[0].getWriteDir(), `${config.name}-WAREHOUSE-MANIFEST.json`);
459
+ await writeFile(manifestPath, JSON.stringify(context.warehouseManifest, null, 2));
460
+ storage.warehouseManifestFile = manifestPath;
461
+ }
462
+ }
463
+
359
464
  /**
360
465
  * Generate group profiles for all configured group keys
361
466
  * @param {Context} context - Context object
@@ -567,11 +672,12 @@ async function flushStorageToDisk(storage, config) {
567
672
  if (storage.eventData?.flush) flushPromises.push(storage.eventData.flush());
568
673
  if (storage.userProfilesData?.flush) flushPromises.push(storage.userProfilesData.flush());
569
674
  if (storage.adSpendData?.flush) flushPromises.push(storage.adSpendData.flush());
675
+ if (storage.standaloneEventData?.flush) flushPromises.push(storage.standaloneEventData.flush());
570
676
  if (storage.mirrorEventData?.flush) flushPromises.push(storage.mirrorEventData.flush());
571
677
  if (storage.groupEventData?.flush) flushPromises.push(storage.groupEventData.flush());
572
678
 
573
679
  // Flush arrays of HookedArrays (excluding lookup tables which are handled separately)
574
- [storage.scdTableData, storage.groupProfilesData].forEach(arrayOfContainers => {
680
+ [storage.scdTableData, storage.groupProfilesData, storage.warehouseMetricData].forEach(arrayOfContainers => {
575
681
  if (Array.isArray(arrayOfContainers)) {
576
682
  arrayOfContainers.forEach(container => {
577
683
  if (container?.flush) flushPromises.push(container.flush());
@@ -635,11 +741,19 @@ function countProfilesPushed(profilesContainer) {
635
741
  * @returns {object} Extracted data in Result format
636
742
  */
637
743
  function extractStorageData(storage) {
744
+ const warehouseMetricData = {};
745
+ for (const container of storage.warehouseMetricData || []) {
746
+ if (!container?.metricName) continue;
747
+ warehouseMetricData[container.metricName] = Array.from(container);
748
+ }
749
+
638
750
  return {
639
751
  eventData: storage.eventData || [],
640
752
  mirrorEventData: storage.mirrorEventData || [],
641
753
  userProfilesData: storage.userProfilesData || [],
642
754
  adSpendData: storage.adSpendData || [],
755
+ standaloneEventData: storage.standaloneEventData || [],
756
+ warehouseMetricData,
643
757
  // Keep arrays of HookedArrays as separate arrays (don't flatten)
644
758
  scdTableData: storage.scdTableData || [],
645
759
  groupProfilesData: storage.groupProfilesData || [],