@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
@@ -114,6 +114,9 @@ export function applyLifecycleWave(events, uid, opts) {
114
114
  * For users where `hashFloat(uid) < share`, injects the `path` sequence —
115
115
  * each step cloned from the user's OWN existing event of that name — right
116
116
  * after the user's FIRST `anchor` occurrence, with tight monotonic gaps.
117
+ * This is append-only: original traffic is never moved or removed. Events
118
+ * already between the anchor and clones can interrupt the immediate branch.
119
+ * `share` selects injection recipients, not the measured Flows branch share.
117
120
  *
118
121
  * Why these rules (HOOKS.md §2.17): Flows' unique mode reads only the FIRST
119
122
  * flow per user, so the injection anchors on the first occurrence; gaps are
@@ -192,12 +195,23 @@ export function applyPathBias(events, uid, opts) {
192
195
  * Boundary guarantees (what makes derived sessions deterministic against
193
196
  * jitter — HOOKS.md §2.13): intra-session gaps stay well under Mixpanel's
194
197
  * 30-min timeout (even spacing capped at 20min + bounded jitter, worst case
195
- * <28min); inter-session gaps stay well over it (sessions land on distinct
196
- * days when possible; same-day sessions are centered in equal partitions of
197
- * the day, spaced ≥¼ partition guaranteed >30min up to 8 sessions/day);
198
+ * <28min); with explicit bounds, inter-session gaps stay strictly over it (sessions land on distinct
199
+ * days when possible; same-day slots reserve at least 30min + 1ms between
200
+ * clusters, compressing their spans when necessary);
198
201
  * and no engineered session crosses UTC midnight (a day-boundary split would
199
202
  * cut it — `session_query.cpp` daySplit). Valid precisely because of P2.1:
200
203
  * session_ids are re-derived after the everything hook.
204
+ * With both bounds omitted, placement keeps the legacy full-UTC-day slots
205
+ * between the user's first and last active days. Overfull legacy requests
206
+ * still run, but their clusters can merge under the 30-minute timeout.
207
+ * Optional bounds are additive: supply known dataset limits to constrain
208
+ * placement; the helper cannot infer datasetEnd from a user's last event.
209
+ * An omitted side defaults to the corresponding full UTC day edge. Partial
210
+ * days compress clusters, including zero-span clusters. Only explicit-bound
211
+ * mode throws for insufficient per-week capacity, before any record changes.
212
+ * The helper never silently reduces the cluster target or drops events;
213
+ * legacy full-day placement can exceed an unknown dataset end and be clipped
214
+ * later by the engine. Pass metadata bounds to prevent that clipping.
201
215
  *
202
216
  * @param {Array<Object>} events - Full user event array (everything hook).
203
217
  * @param {string} uid - Unused for hashing here; kept for atom-signature
@@ -207,10 +221,15 @@ export function applyPathBias(events, uid, opts) {
207
221
  * @param {number} opts.sessionsPerWeek - Target clusters per week (≥1).
208
222
  * @param {number} opts.eventsPerSession - Target events per cluster (≥1).
209
223
  * @param {number} opts.sessionMinutes - Max cluster span in minutes.
224
+ * @param {string|number} [opts.datasetStart] - Inclusive lower bound (ISO,
225
+ * unix seconds, or unix milliseconds); accepts meta.datasetStart directly.
226
+ * @param {string|number} [opts.datasetEnd] - Inclusive upper bound (same units);
227
+ * accepts meta.datasetEnd directly.
228
+ * @throws {RangeError} Invalid explicit bounds or insufficient bounded 30-minute-session capacity.
210
229
  * @returns {Array<Object>} The SAME array, timestamps rewritten.
211
230
  */
212
231
  export function applySessionShape(events, uid, opts) {
213
- const { sessionsPerWeek, eventsPerSession, sessionMinutes } = opts || {};
232
+ const { sessionsPerWeek, eventsPerSession, sessionMinutes, datasetStart, datasetEnd } = opts || {};
214
233
  if (!Array.isArray(events) || !events.length) return events;
215
234
  if (!isPos(sessionsPerWeek) || !isPos(eventsPerSession) || !isPos(sessionMinutes)) return events;
216
235
 
@@ -221,6 +240,13 @@ export function applySessionShape(events, uid, opts) {
221
240
  const N = timed.length;
222
241
  const firstMs = toMs(timed[0].time);
223
242
  const lastMs = toMs(timed[N - 1].time);
243
+ const bounded = datasetStart !== undefined || datasetEnd !== undefined;
244
+ const lowerMs = datasetStart === undefined ? Math.floor(firstMs / DAY_MS) * DAY_MS : toMs(datasetStart);
245
+ const upperMs = datasetEnd === undefined ? (Math.floor(lastMs / DAY_MS) + 1) * DAY_MS - 1 : toMs(datasetEnd);
246
+ if (!Number.isFinite(lowerMs) || !Number.isFinite(upperMs) || lowerMs > upperMs ||
247
+ Math.abs(lowerMs) > 8.64e15 || Math.abs(upperMs) > 8.64e15) {
248
+ throw new RangeError('applySessionShape: invalid dataset bounds');
249
+ }
224
250
  const weeks = Math.max(1, Math.ceil((lastMs - firstMs + 1) / WEEK_MS));
225
251
  const numSessions = Math.max(1, Math.min(
226
252
  Math.floor(sessionsPerWeek) * weeks,
@@ -235,6 +261,15 @@ export function applySessionShape(events, uid, opts) {
235
261
  const chance = getChance();
236
262
  const firstDay = Math.floor(firstMs / DAY_MS);
237
263
  const lastDay = Math.floor(lastMs / DAY_MS);
264
+ const separationMs = 30 * MIN_MS + 1;
265
+ const perDayCount = new Map();
266
+ const dayBounds = day => [Math.ceil(Math.max(day * DAY_MS, lowerMs)),
267
+ Math.floor(Math.min((day + 1) * DAY_MS - 1, upperMs))];
268
+ const remainingCapacity = day => {
269
+ if (!bounded) return Infinity;
270
+ const [start, end] = dayBounds(day);
271
+ return (end >= start ? Math.floor((end - start) / separationMs) + 1 : 0) - (perDayCount.get(day) || 0);
272
+ };
238
273
 
239
274
  // Pick one day per session, week by week: prefer the user's original
240
275
  // active days in the week, fill from the rest of the week's days, and
@@ -251,22 +286,38 @@ export function applySessionShape(events, uid, opts) {
251
286
  const t = toMs(ev.time);
252
287
  if (t >= weekStartMs && t < weekStartMs + WEEK_MS) originalDays.add(Math.floor(t / DAY_MS));
253
288
  }
254
- const pool = [...originalDays].filter(d => d >= dayLo && d <= dayHi);
289
+ const pool = [...originalDays].filter(d => d >= dayLo && d <= dayHi && remainingCapacity(d) > 0);
255
290
  const others = [];
256
- for (let d = dayLo; d <= dayHi; d++) if (!originalDays.has(d)) others.push(d);
291
+ for (let d = dayLo; d <= dayHi; d++) if (!originalDays.has(d) && remainingCapacity(d) > 0) others.push(d);
257
292
  const picked = chance.pickset(pool, Math.min(need, pool.length));
258
293
  if (picked.length < need) picked.push(...chance.pickset(others, Math.min(need - picked.length, others.length)));
259
- let i = 0;
260
- while (picked.length < need) picked.push(picked[i++ % Math.max(1, picked.length)]); // reuse days: > days/week sessions
294
+ for (const day of picked) perDayCount.set(day, (perDayCount.get(day) || 0) + 1);
295
+ const candidates = [...pool, ...others];
296
+ while (picked.length < need) {
297
+ if (!bounded) {
298
+ let reuseIndex = 0;
299
+ while (picked.length < need) {
300
+ const day = picked[reuseIndex++ % Math.max(1, picked.length)];
301
+ picked.push(day);
302
+ perDayCount.set(day, (perDayCount.get(day) || 0) + 1);
303
+ }
304
+ break;
305
+ }
306
+ const available = candidates.filter(day => remainingCapacity(day) > 0);
307
+ if (!available.length) {
308
+ throw new RangeError(`applySessionShape: insufficient session capacity in week ${w + 1} for ${need} clusters`);
309
+ }
310
+ for (const day of available) {
311
+ picked.push(day);
312
+ perDayCount.set(day, (perDayCount.get(day) || 0) + 1);
313
+ if (picked.length === need) break;
314
+ }
315
+ }
261
316
  picked.sort((a, b) => a - b);
262
317
  sessionDays.push(...picked);
263
318
  }
264
319
 
265
- // Place sessions within days. Same-day sessions get equal partitions of
266
- // the day; the session is centered in its partition with bounded jitter,
267
- // which guarantees the inter-session and midnight invariants above.
268
- const perDayCount = new Map();
269
- for (const d of sessionDays) perDayCount.set(d, (perDayCount.get(d) || 0) + 1);
320
+ // Bound each day's slots and reserve the strict timeout gap between them.
270
321
  const perDaySeen = new Map();
271
322
  const sesMs = sessionMinutes * MIN_MS;
272
323
 
@@ -280,10 +331,15 @@ export function applySessionShape(events, uid, opts) {
280
331
  const j = perDaySeen.get(day) || 0;
281
332
  perDaySeen.set(day, j + 1);
282
333
 
283
- const seg = DAY_MS / m;
284
- const span = Math.min(sesMs, seg * 0.5);
285
- const center = day * DAY_MS + j * seg + (seg - span) / 2;
286
- const q = Math.floor((seg - span) / 4);
334
+ const [dayStart, dayEnd] = dayBounds(day);
335
+ const seg = (dayEnd - dayStart - (m - 1) * separationMs) / m;
336
+ const slotStart = Math.floor(dayStart + j * (seg + separationMs));
337
+ const slotEnd = Math.floor(dayStart + (j + 1) * seg + j * separationMs);
338
+ const slotWidth = bounded ? slotEnd - slotStart : DAY_MS / m;
339
+ const span = bounded ? Math.floor(Math.min(sesMs, slotWidth * 0.5)) : Math.min(sesMs, slotWidth * 0.5);
340
+ const center = bounded ? slotStart + Math.floor((slotWidth - span) / 2)
341
+ : day * DAY_MS + j * slotWidth + (slotWidth - span) / 2;
342
+ const q = Math.floor((slotWidth - span) / 4);
287
343
  const start = center + (q > 0 ? chance.integer({ min: -q, max: q }) : 0);
288
344
 
289
345
  if (size === 1) {
@@ -71,6 +71,7 @@ async function _sendToMixpanel(context) {
71
71
  const { config, storage } = context;
72
72
  const {
73
73
  adSpendData,
74
+ standaloneEventData,
74
75
  eventData,
75
76
  groupProfilesData,
76
77
  scdTableData,
@@ -208,6 +209,29 @@ async function _sendToMixpanel(context) {
208
209
  importResults.adSpend = imported;
209
210
  }
210
211
 
212
+ // Import standalone (identity-less) metric snapshots — v1.8.0.
213
+ // Same shape as the ad-spend path: its own stream, imported as events.
214
+ // Gated on the config so a batch-mode run without `standaloneEvents` does
215
+ // not attempt an empty file read.
216
+ const hasStandaloneConfig = Array.isArray(config.standaloneEvents) && config.standaloneEvents.length > 0;
217
+ if (hasStandaloneConfig && (standaloneEventData?.length > 0 || isBATCH_MODE)) {
218
+ log(` Standalone Events`);
219
+ let standaloneToImport = u.deepClone(standaloneEventData);
220
+ const shouldReadFromFiles = isBATCH_MODE || (writeToDisk && standaloneEventData && standaloneEventData.length === 0);
221
+ if (shouldReadFromFiles && standaloneEventData?.getWrittenFiles) {
222
+ const files = standaloneEventData.getWrittenFiles();
223
+ if (files.length > 0) standaloneToImport = files;
224
+ }
225
+ const standaloneTotal = Array.isArray(standaloneToImport) ? standaloneToImport.length : 0;
226
+ const imported = await mp(creds, standaloneToImport, {
227
+ recordType: "event",
228
+ ...commonOpts,
229
+ progressCallback: makeProgressCallback(standaloneTotal),
230
+ });
231
+ log(` -> ${comma(imported.success)} standalone events sent\n`);
232
+ importResults.standalone = imported;
233
+ }
234
+
211
235
  // Import group profiles
212
236
  if (groupProfilesData && Array.isArray(groupProfilesData) && groupProfilesData.length > 0) {
213
237
  for (const groupEntity of groupProfilesData) {
@@ -403,11 +427,12 @@ function logProblems(problems) {
403
427
  */
404
428
  function collectWrittenFiles(storage) {
405
429
  const files = [];
430
+ if (storage.warehouseManifestFile) files.push(storage.warehouseManifestFile);
406
431
  for (const container of [storage.eventData, storage.userProfilesData, storage.adSpendData,
407
- storage.mirrorEventData, storage.groupEventData]) {
432
+ storage.standaloneEventData, storage.mirrorEventData, storage.groupEventData]) {
408
433
  if (container?.getWrittenFiles) files.push(...container.getWrittenFiles());
409
434
  }
410
- for (const arr of [storage.groupProfilesData, storage.scdTableData, storage.lookupTableData]) {
435
+ for (const arr of [storage.groupProfilesData, storage.scdTableData, storage.lookupTableData, storage.warehouseMetricData]) {
411
436
  if (Array.isArray(arr)) {
412
437
  for (const c of arr) {
413
438
  if (c?.getWrittenFiles) files.push(...c.getWrittenFiles());
@@ -11,7 +11,7 @@ import pLimit from 'p-limit';
11
11
  import os from 'os';
12
12
  import * as u from "../utils/utils.js";
13
13
  import * as t from 'ak-tools';
14
- import { makeEvent } from "../generators/events.js";
14
+ import { makeEvent, engineIdentity } from "../generators/events.js";
15
15
  import { makeFunnel } from "../generators/funnels.js";
16
16
  import { makeUserProfile } from "../generators/profiles.js";
17
17
  import { makeSCD } from "../generators/scd.js";
@@ -403,6 +403,8 @@ export async function userLoop(context) {
403
403
  }
404
404
 
405
405
  let userFirstEventTime;
406
+ let userUsageStartTime = context.FIXED_BEGIN;
407
+ const promisedAttemptEntries = new Set();
406
408
 
407
409
  // ── v1.5 Active-day scheduling ──
408
410
  // When `avgActiveDaysPerUser` is set, build a per-user day plan: a list
@@ -493,13 +495,9 @@ export async function userLoop(context) {
493
495
  // Active-day mode: anchor the first funnel on a picked day so the user's
494
496
  // signup lands within their planned active window. Legacy mode: anchor at
495
497
  // adjustedCreated minus a noise offset.
496
- let cursor;
497
- if (dayPlan) {
498
- const bounds = nextDayBounds();
499
- cursor = bounds ? bounds.earliest : adjustedCreated.subtract(noise(), 'seconds').unix();
500
- } else {
501
- cursor = adjustedCreated.subtract(noise(), 'seconds').unix();
502
- }
498
+ const lifecycleStart = Math.max(adjustedCreated.valueOf() / 1000, context.FIXED_BEGIN);
499
+ const firstDayBounds = dayPlan && !hasRetentionCurve ? nextDayBounds() : null;
500
+ let cursor = Math.max(lifecycleStart, firstDayBounds?.earliest ?? lifecycleStart);
503
501
 
504
502
  // Resolve attempts plan. `attempts.{min,max}` count FAILED PRIORS; total
505
503
  // passes = failedPriors + 1. Validator coerced bounds; default both 0.
@@ -509,6 +507,7 @@ export async function userLoop(context) {
509
507
  const failedPriors = (maxA > 0) ? chance.integer({ min: minA, max: maxA }) : 0;
510
508
  const totalAttempts = failedPriors + 1;
511
509
  let firstAttemptFirstEventTime = null;
510
+ if (failedPriors > 0) cursor = lifecycleStart;
512
511
 
513
512
  for (let attemptNum = 1; attemptNum <= totalAttempts; attemptNum++) {
514
513
  const isFinal = attemptNum === totalAttempts;
@@ -528,16 +527,23 @@ export async function userLoop(context) {
528
527
  truncateBeforeAuth: !isFinal,
529
528
  devicePool: userDevicePool,
530
529
  };
530
+ const firstFeatureCtx = {
531
+ ...featureCtx,
532
+ latestTime: firstDayBounds && failedPriors === 0 ? firstDayBounds.latest : context.FIXED_NOW,
533
+ pinFirstTime: hasRetentionCurve || failedPriors > 0,
534
+ };
531
535
  const [data, converted, authMs] = await makeFunnel(
532
- context, funnelToRun, user, cursor, profile, userSCD, persona, featureCtx, attemptMeta
536
+ context, funnelToRun, user, cursor, profile, userSCD, persona, firstFeatureCtx, attemptMeta
533
537
  );
534
538
  if (isFinal) userConverted = converted;
535
539
  if (data && data.length) {
540
+ if (failedPriors > 0) promisedAttemptEntries.add(data[0].insert_id);
536
541
  if (firstAttemptFirstEventTime === null) {
537
542
  firstAttemptFirstEventTime = dayjs(data[0].time).unix();
538
543
  }
539
544
  // Advance the cursor for the next attempt by a small abandon-and-retry gap.
540
- const lastTime = dayjs(data[data.length - 1].time).unix();
545
+ const lastTime = Math.max(...data.map(event => Date.parse(event.time))) / 1000;
546
+ userUsageStartTime = Math.max(userUsageStartTime, lastTime + 0.001);
541
547
  cursor = lastTime + chance.integer({ min: 60, max: 30 * 60 }); // 1–30 min later
542
548
  numEventsPreformed += data.length;
543
549
  usersEvents = usersEvents.concat(data);
@@ -547,6 +553,13 @@ export async function userLoop(context) {
547
553
  if (userAuthTimeMs === null) userAuthTimeMs = authMs;
548
554
  userAuthed = true;
549
555
  }
556
+ if (!isFinal && !data.length && config.events.find(event => event.event === firstFunnel.sequence[0])?.isAuthEvent) {
557
+ context.addWarning({
558
+ key: 'lifecycle.emptyPreAuthAttempt',
559
+ requested: 1, applied: 0, severity: 'warn',
560
+ reason: 'A failed prior has no pre-auth step because auth starts the funnel; the prior emits no rows and the final attempt still runs.',
561
+ });
562
+ }
550
563
  }
551
564
 
552
565
  userFirstEventTime = firstAttemptFirstEventTime !== null
@@ -614,6 +627,8 @@ export async function userLoop(context) {
614
627
  // gets re-anchored to the picked day's start (subsequent funnel steps
615
628
  // spill within `timeToConvert` hours; this is intentional).
616
629
  const dayBounds = dayPlan ? nextDayBounds() : null;
630
+ const usageEarliest = Math.max(dayBounds?.earliest ?? userFirstEventTime, userUsageStartTime);
631
+ if (usageEarliest > (dayBounds?.latest ?? context.FIXED_NOW)) continue;
617
632
  // Compute step1's `latestTime` so the funnel's relative span fits before
618
633
  // FIXED_NOW. Without this, born-late users + long-ttc funnels generate
619
634
  // large numbers of `_drop`'d events that consume budget cycles. The
@@ -639,7 +654,7 @@ export async function userLoop(context) {
639
654
  const ttcSec = (currentFunnel.timeToConvert || 0) * 3600;
640
655
  // Anchor cursor at picked day's start when active-day mode is on,
641
656
  // otherwise pass userFirstEventTime (constant). NO cursor accumulation.
642
- const funnelCursor = dayBounds ? dayBounds.earliest : userFirstEventTime;
657
+ const funnelCursor = usageEarliest;
643
658
  // Constrain funnel step1's TimeSoup latestTime so the full funnel fits in
644
659
  // window. Without this, late steps spill past FIXED_NOW and get `_drop`'d.
645
660
  //
@@ -683,7 +698,7 @@ export async function userLoop(context) {
683
698
  // — passing `true` here would pin every standalone event to the same
684
699
  // `earliestTime`, which the now-deleted bunchIntoSessions used to paper
685
700
  // over. With bunchIntoSessions removed, TimeSoup is the time source.
686
- const standaloneEarliest = dayBounds ? dayBounds.earliest : userFirstEventTime;
701
+ const standaloneEarliest = usageEarliest;
687
702
  // v1.5.1: pass `config.superProps` so standalone events get super-property
688
703
  // stamping. Pre-1.5.1, standalone events received `{}` here, but the
689
704
  // validator's auto-funnel (catch-all) consumed all non-strict events so
@@ -763,6 +778,8 @@ export async function userLoop(context) {
763
778
  for (const ev of usersEvents) {
764
779
  if (chance.bool({ likelihood: dataQuality.duplicateRate * 100 })) {
765
780
  const dupe = { ...ev };
781
+ const identityDescriptor = Object.getOwnPropertyDescriptor(ev, engineIdentity);
782
+ if (identityDescriptor) Object.defineProperty(dupe, engineIdentity, identityDescriptor);
766
783
  dupe.time = dayjs(ev.time).add(chance.integer({ min: 1, max: 60 }), 'seconds').toISOString();
767
784
  dupe.insert_id = randomUUID();
768
785
  dupes.push(dupe);
@@ -828,6 +845,13 @@ export async function userLoop(context) {
828
845
  profile._drop = true;
829
846
  }
830
847
 
848
+ const identityBeforeEverything = new Map(usersEvents.map(event => [event.insert_id, {
849
+ user_id: event.user_id, device_id: event.device_id,
850
+ original: event[engineIdentity],
851
+ }]));
852
+ const profileDropBeforeEverything = profile._drop;
853
+ let profileDropOverridden = false;
854
+
831
855
  // Hook for processing all user events (hooks override everything)
832
856
  if (config.hook) {
833
857
  // `meta.isPreAuth(event)` predicate bound to this user's auth state.
@@ -843,7 +867,16 @@ export async function userLoop(context) {
843
867
  return Number.isFinite(t) ? t < userAuthTimeMsLocal : false;
844
868
  };
845
869
  const newEvents = await config.hook(usersEvents, "everything", {
846
- profile,
870
+ profile: new Proxy(profile, {
871
+ deleteProperty(target, key) {
872
+ if (key === '_drop') profileDropOverridden = true;
873
+ return Reflect.deleteProperty(target, key);
874
+ },
875
+ set(target, key, value) {
876
+ if (key === '_drop') profileDropOverridden = true;
877
+ return Reflect.set(target, key, value);
878
+ },
879
+ }),
847
880
  scd: userSCD,
848
881
  config,
849
882
  userIsBornInDataset,
@@ -944,13 +977,44 @@ export async function userLoop(context) {
944
977
  // stream to the remaining room with a seeded uniform sample (keeps the
945
978
  // time shape; preserves array order). Only under the flag.
946
979
  if (strictEventCount && usersEvents.length > 0) {
980
+ const promisedEntries = usersEvents.filter(event => promisedAttemptEntries.has(event.insert_id));
947
981
  const room = numEvents - context.getStoredEventCount();
948
982
  if (room <= 0) {
949
983
  usersEvents = [];
950
984
  } else if (usersEvents.length > room) {
951
- const keep = new Set(chance.pickset(usersEvents, room));
985
+ const reserved = promisedEntries.slice(0, room);
986
+ const others = usersEvents.filter(event => !promisedAttemptEntries.has(event.insert_id));
987
+ const keep = new Set([...reserved, ...chance.pickset(others, Math.max(0, room - reserved.length))]);
952
988
  usersEvents = usersEvents.filter(e => keep.has(e));
953
989
  }
990
+ if (promisedEntries.some(event => !usersEvents.includes(event))) {
991
+ context.addWarning({
992
+ key: 'lifecycle.strictAttemptBudget',
993
+ requested: promisedEntries.length, applied: Math.max(0, room), severity: 'warn',
994
+ reason: 'The remaining strict event budget is smaller than the surviving first-funnel attempt entries; strictEventCount takes precedence and later entries are omitted.',
995
+ });
996
+ }
997
+ }
998
+
999
+ if (userIsBornInDataset && userDevicePool?.length) {
1000
+ const authNames = new Set(config.events.filter(event => event.isAuthEvent).map(event => event.event));
1001
+ const stitches = usersEvents.filter(event => authNames.has(event.event) && event.user_id && event.device_id);
1002
+ const emittedAuthTime = stitches.length ? Math.min(...stitches.map(event => Date.parse(event.time))) : null;
1003
+ for (const event of usersEvents) {
1004
+ if (event.event === '$experiment_started') continue;
1005
+ if (emittedAuthTime === null || Date.parse(event.time) < emittedAuthTime) {
1006
+ const before = identityBeforeEverything.get(event.insert_id);
1007
+ const original = event[engineIdentity] || before?.original;
1008
+ if (!original) continue;
1009
+ const explicitUser = event.user_id !== original.user_id;
1010
+ const explicitDevice = before
1011
+ ? event.device_id !== before.device_id || (!before.device_id && !!original.device_id)
1012
+ : event.device_id !== original.device_id;
1013
+ if (!explicitUser) delete event.user_id;
1014
+ if (!explicitUser && !explicitDevice) event.device_id ||= userDevicePool[0];
1015
+ }
1016
+ }
1017
+ if (emittedAuthTime === null && !profileDropOverridden && profile._drop === profileDropBeforeEverything) profile._drop = true;
954
1018
  }
955
1019
 
956
1020
  // Store all user data (skip profile push when a hook returned null
@@ -978,6 +1042,7 @@ export async function userLoop(context) {
978
1042
  }
979
1043
  }
980
1044
 
1045
+ context.warehouseAccumulator?.ingest(usersEvents);
981
1046
  await eventData.hookPush(usersEvents, { profile });
982
1047
  });
983
1048
 
@@ -1107,7 +1172,10 @@ export function amplifyWorldEvents(events, worldEvents, chance, fixedNow) {
1107
1172
  if (frac > 0 && chance.bool({ likelihood: frac * 100 })) copies++;
1108
1173
  for (let c = 0; c < copies; c++) {
1109
1174
  const t = chance.integer({ min: windowStart, max: windowEnd - 1 });
1110
- clones.push({ ...ev, time: dayjs.unix(t).toISOString(), insert_id: randomUUID() });
1175
+ const clone = { ...ev, time: dayjs.unix(t).toISOString(), insert_id: randomUUID() };
1176
+ const identityDescriptor = Object.getOwnPropertyDescriptor(ev, engineIdentity);
1177
+ if (identityDescriptor) Object.defineProperty(clone, engineIdentity, identityDescriptor);
1178
+ clones.push(clone);
1111
1179
  }
1112
1180
  }
1113
1181
  }
@@ -85,7 +85,6 @@
85
85
  "properties": {
86
86
  "where": {
87
87
  "type": "object",
88
- "minProperties": 1,
89
88
  "description": "Column → value (equality) or { op, value } comparison. All clauses must match (AND).",
90
89
  "additionalProperties": {
91
90
  "oneOf": [
@@ -123,23 +122,49 @@
123
122
  "properties": {
124
123
  "type": { "type": "string", "minLength": 1 }
125
124
  },
126
- "if": {
127
- "properties": { "type": { "const": "duckdb" } }
128
- },
129
- "then": {
130
- "required": ["type", "sql"],
131
- "properties": {
132
- "type": { "const": "duckdb" },
133
- "sql": {
134
- "type": "string",
135
- "minLength": 1,
136
- "description": "DuckDB SQL escape hatch, for bespoke shapes only. The runner shells out to the `duckdb` CLI (no npm dep) and substitutes the literal token {{PREFIX}} with the run's data prefix path (e.g. data/verify-<name>), so globs read read_json_auto('{{PREFIX}}-EVENTS*.json'). Result rows feed select/expect like emulator rows. Disk mode only — skipped (with a warning) under --in-memory."
125
+ "allOf": [
126
+ {
127
+ "if": {
128
+ "properties": { "type": { "const": "duckdb" } }
129
+ },
130
+ "then": {
131
+ "required": ["type", "sql"],
132
+ "properties": {
133
+ "type": { "const": "duckdb" },
134
+ "sql": {
135
+ "type": "string",
136
+ "minLength": 1,
137
+ "description": "DuckDB SQL escape hatch, for bespoke shapes only. The runner shells out to the `duckdb` CLI (no npm dep) and substitutes the literal token {{PREFIX}} with the run's data prefix path (e.g. data/verify-<name>), so globs read read_json_auto('{{PREFIX}}-EVENTS*.json'). Result rows feed select/expect like emulator rows. Disk mode only — skipped (with a warning) under --in-memory."
138
+ }
139
+ }
140
+ }
141
+ },
142
+ {
143
+ "if": {
144
+ "properties": { "type": { "const": "warehouse" } }
145
+ },
146
+ "then": {
147
+ "required": ["type", "table"],
148
+ "properties": {
149
+ "type": { "const": "warehouse" },
150
+ "table": { "type": "string", "minLength": 1 }
151
+ }
152
+ }
153
+ },
154
+ {
155
+ "if": {
156
+ "properties": { "type": { "const": "warehouse-stats" } }
157
+ },
158
+ "then": {
159
+ "required": ["type", "table"],
160
+ "properties": {
161
+ "type": { "const": "warehouse-stats" },
162
+ "table": { "type": "string", "minLength": 1 }
163
+ }
137
164
  }
138
165
  }
139
- },
140
- "else": {
141
- "description": "Anything other than 'duckdb' is passed byte-compatible to emulateBreakdown / verifyDungeon (frequencyByFrequency, funnelFrequency, aggregatePerUser, timeToConvert, attributedBy, sessionMetrics, retention, distinctCount, eventBreakdown, uniques, lifecycle, topPaths)."
142
- }
166
+ ],
167
+ "description": "Anything other than 'duckdb', 'warehouse', or 'warehouse-stats' is passed byte-compatible to emulateBreakdown / verifyDungeon (frequencyByFrequency, funnelFrequency, aggregatePerUser, timeToConvert, attributedBy, sessionMetrics, retention, distinctCount, eventBreakdown, uniques, lifecycle, topPaths)."
143
168
  },
144
169
  "expect": {
145
170
  "type": "object",
@@ -815,10 +815,26 @@ function streamJSON(filePath, data, options = {}) {
815
815
  });
816
816
  }
817
817
 
818
+ function csvRow(item, columns) {
819
+ return columns.map(col => {
820
+ const value = item[col];
821
+
822
+ if (value === null || value === undefined) {
823
+ return '';
824
+ }
825
+
826
+ const serialized = typeof value === 'object'
827
+ ? JSON.stringify(value)
828
+ : value.toString();
829
+
830
+ return `"${serialized.replace(/"/g, '""')}"`;
831
+ }).join(',');
832
+ }
833
+
818
834
  function streamCSV(filePath, data, options = {}) {
819
835
  return new Promise((resolve, reject) => {
820
836
  let writeStream;
821
- const { gzip = false } = options;
837
+ const { gzip = false, fixedColumns } = options;
822
838
 
823
839
  if (filePath?.startsWith('gs://')) {
824
840
  const { uri, bucket, file } = parseGCSUri(filePath);
@@ -842,20 +858,12 @@ function streamCSV(filePath, data, options = {}) {
842
858
  }
843
859
  }
844
860
 
845
- // Extract all unique keys from the data array
846
- const columns = getUniqueKeys(data); // Assuming getUniqueKeys properly retrieves all keys
861
+ const columns = Array.isArray(fixedColumns) ? fixedColumns : getUniqueKeys(data);
847
862
 
848
- // Stream the header
849
863
  writeStream.write(columns.join(',') + '\n');
850
864
 
851
- // Stream each data row
852
865
  data.forEach(item => {
853
- for (const key in item) {
854
- // Ensure all nested objects are properly stringified
855
- if (typeof item[key] === "object") item[key] = JSON.stringify(item[key]);
856
- }
857
- const row = columns.map(col => item[col] ? `"${item[col].toString().replace(/"/g, '""')}"` : "").join(',');
858
- writeStream.write(row + '\n');
866
+ writeStream.write(csvRow(item, columns) + '\n');
859
867
  });
860
868
 
861
869
  writeStream.end();
@@ -1370,7 +1378,7 @@ META
1370
1378
  * @param {Config} config
1371
1379
  */
1372
1380
  function buildFileNames(config) {
1373
- const { format = "csv", groupKeys = [], lookupTables = [] } = config;
1381
+ const { format = "csv", groupKeys = [], lookupTables = [], warehouseMetrics = [] } = config;
1374
1382
  let extension = "";
1375
1383
  extension = format === "csv" ? "csv" : "json";
1376
1384
  // const current = dayjs.utc().format("MM-DD-HH");
@@ -1388,10 +1396,12 @@ function buildFileNames(config) {
1388
1396
  eventFiles: [path.join(writeDir, `${simName}-EVENTS.${extension}`)],
1389
1397
  userFiles: [path.join(writeDir, `${simName}-USERS.${extension}`)],
1390
1398
  adSpendFiles: [],
1399
+ standaloneFiles: [],
1391
1400
  scdFiles: [],
1392
1401
  mirrorFiles: [],
1393
1402
  groupFiles: [],
1394
1403
  lookupFiles: [],
1404
+ warehouseFiles: [],
1395
1405
  folder: writeDir,
1396
1406
  };
1397
1407
  //add ad spend files
@@ -1399,6 +1409,11 @@ function buildFileNames(config) {
1399
1409
  writePaths.adSpendFiles.push(path.join(writeDir, `${simName}-AD-SPEND.${extension}`));
1400
1410
  }
1401
1411
 
1412
+ //add standalone (identity-less snapshot) files — v1.8.0
1413
+ if (Array.isArray(config?.standaloneEvents) && config.standaloneEvents.length > 0) {
1414
+ writePaths.standaloneFiles.push(path.join(writeDir, `${simName}-STANDALONE.${extension}`));
1415
+ }
1416
+
1402
1417
  //add SCD files
1403
1418
  const scdKeys = Object.keys(config?.scdProps || {});
1404
1419
  for (const key of scdKeys) {
@@ -1425,6 +1440,15 @@ function buildFileNames(config) {
1425
1440
  );
1426
1441
  }
1427
1442
 
1443
+ for (const warehouseMetric of warehouseMetrics) {
1444
+ const metricName = warehouseMetric?.name;
1445
+ if (typeof metricName !== 'string') continue;
1446
+ const metricFormat = warehouseMetric?.format || format || 'csv';
1447
+ writePaths.warehouseFiles.push(
1448
+ path.join(writeDir, `${simName}-WAREHOUSE-${metricName}.${metricFormat}`)
1449
+ );
1450
+ }
1451
+
1428
1452
  //add mirror files
1429
1453
  const mirrorProps = config?.mirrorProps || {};
1430
1454
  if (Object.keys(mirrorProps).length) {
@@ -1948,6 +1972,7 @@ export {
1948
1972
  generateUser,
1949
1973
  optimizedBoxMuller,
1950
1974
  buildFileNames,
1975
+ csvRow,
1951
1976
  streamJSON,
1952
1977
  streamCSV,
1953
1978
  streamParquet,