@ak--47/dungeon-master 1.5.4 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +21 -12
  2. package/.claude/skills/create-dungeon/SKILL.md +65 -5
  3. package/.claude/skills/create-project/SKILL.md +1 -1
  4. package/.claude/skills/create-project/provision.mjs +57 -10
  5. package/.claude/skills/verify-dungeon/SKILL.md +56 -25
  6. package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
  7. package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
  8. package/.claude/skills/write-hooks/SKILL.md +156 -18
  9. package/CHANGELOG.md +183 -0
  10. package/HOOKS.md +566 -112
  11. package/README.md +28 -9
  12. package/docs/guides/1.3.0-upgrade-guide.md +262 -0
  13. package/docs/guides/1.3.1-upgrade-guide.md +328 -0
  14. package/docs/guides/1.4.0-upgrade-guide.md +130 -0
  15. package/docs/guides/1.4.1-upgrade-guide.md +125 -0
  16. package/docs/guides/1.4.3-upgrade-guide.md +200 -0
  17. package/docs/guides/1.4.4-upgrade-guide.md +95 -0
  18. package/docs/guides/1.5.0-upgrade-guide.md +426 -0
  19. package/docs/guides/1.5.1-upgrade-guide.md +178 -0
  20. package/docs/guides/1.5.3-upgrade-guide.md +117 -0
  21. package/docs/guides/1.6.0-upgrade-guide.md +204 -0
  22. package/dungeons/technical/stories-verify.js +152 -0
  23. package/dungeons/vertical/README.md +154 -0
  24. package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
  25. package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
  26. package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
  27. package/dungeons/vertical/community/community.js +1589 -0
  28. package/dungeons/vertical/community/community.sql +216 -0
  29. package/dungeons/vertical/community/community.verify.mjs +72 -0
  30. package/dungeons/vertical/crypto/crypto.js +1483 -0
  31. package/dungeons/vertical/crypto/crypto.sql +194 -0
  32. package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
  33. package/dungeons/vertical/dating/dating.js +1540 -0
  34. package/dungeons/vertical/dating/dating.sql +288 -0
  35. package/dungeons/vertical/dating/dating.verify.mjs +72 -0
  36. package/dungeons/vertical/devtools/devtools.js +1524 -0
  37. package/dungeons/vertical/devtools/devtools.sql +161 -0
  38. package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
  39. package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
  40. package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
  41. package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
  42. package/dungeons/vertical/education/education.js +1763 -0
  43. package/dungeons/vertical/education/education.sql +243 -0
  44. package/dungeons/vertical/education/education.verify.mjs +75 -0
  45. package/dungeons/vertical/fintech/fintech.js +1410 -0
  46. package/dungeons/vertical/fintech/fintech.sql +175 -0
  47. package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
  48. package/dungeons/vertical/fitness/fitness.js +1495 -0
  49. package/dungeons/vertical/fitness/fitness.sql +206 -0
  50. package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
  51. package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
  52. package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
  53. package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
  54. package/dungeons/vertical/gaming/gaming.js +1741 -0
  55. package/dungeons/vertical/gaming/gaming.sql +249 -0
  56. package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
  57. package/dungeons/vertical/healthcare/healthcare.js +1488 -0
  58. package/dungeons/vertical/healthcare/healthcare.sql +193 -0
  59. package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
  60. package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
  61. package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
  62. package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
  63. package/dungeons/vertical/logistics/logistics.js +1541 -0
  64. package/dungeons/vertical/logistics/logistics.sql +205 -0
  65. package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
  66. package/dungeons/vertical/marketplace/marketplace.js +1438 -0
  67. package/dungeons/vertical/marketplace/marketplace.sql +148 -0
  68. package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
  69. package/dungeons/vertical/{media.js → media/media.js} +557 -58
  70. package/dungeons/vertical/media/media.sql +173 -0
  71. package/dungeons/vertical/media/media.verify.mjs +74 -0
  72. package/dungeons/vertical/real-estate/real-estate.js +1629 -0
  73. package/dungeons/vertical/real-estate/real-estate.sql +167 -0
  74. package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
  75. package/dungeons/vertical/sass/sass.js +1733 -0
  76. package/dungeons/vertical/sass/sass.sql +173 -0
  77. package/dungeons/vertical/sass/sass.verify.mjs +74 -0
  78. package/dungeons/vertical/social/social.js +1456 -0
  79. package/dungeons/vertical/social/social.sql +160 -0
  80. package/dungeons/vertical/social/social.verify.mjs +76 -0
  81. package/dungeons/vertical/streaming/streaming.js +677 -0
  82. package/dungeons/vertical/streaming/streaming.sql +101 -0
  83. package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
  84. package/dungeons/vertical/support-desk/support-desk.js +741 -0
  85. package/dungeons/vertical/support-desk/support-desk.sql +137 -0
  86. package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
  87. package/dungeons/vertical/travel/travel.js +1603 -0
  88. package/dungeons/vertical/travel/travel.sql +236 -0
  89. package/dungeons/vertical/travel/travel.verify.mjs +72 -0
  90. package/index.js +2 -2
  91. package/lib/core/config-validator.js +37 -2
  92. package/lib/core/context.js +4 -2
  93. package/lib/core/dungeon-loader.js +20 -15
  94. package/lib/core/dungeon-to-json.js +22 -4
  95. package/lib/core/storage.js +3 -3
  96. package/lib/generators/funnels.js +36 -9
  97. package/lib/hook-helpers/cohort.js +53 -0
  98. package/lib/hook-helpers/index.js +8 -0
  99. package/lib/hook-helpers/mutate.js +1 -1
  100. package/lib/hook-helpers/shape.js +319 -0
  101. package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
  102. package/lib/hook-patterns/attributed-by-source.js +60 -58
  103. package/lib/hook-patterns/frequency-by-frequency.js +14 -4
  104. package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
  105. package/lib/hook-patterns/index.js +1 -1
  106. package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
  107. package/lib/orchestrators/user-loop.js +41 -0
  108. package/lib/templates/story-spec.schema.json +175 -0
  109. package/lib/utils/json-evaluator.js +1 -1
  110. package/lib/utils/utils.js +5 -4
  111. package/lib/verify/coerce.js +186 -0
  112. package/lib/verify/counting.js +270 -32
  113. package/lib/verify/emulate-breakdown.js +1285 -203
  114. package/lib/verify/first-time.js +89 -0
  115. package/lib/verify/flows.js +679 -0
  116. package/lib/verify/formula.js +259 -0
  117. package/lib/verify/funnel-engine.js +697 -113
  118. package/lib/verify/index.js +32 -4
  119. package/lib/verify/sessionize.js +275 -0
  120. package/lib/verify/story-runner.js +483 -0
  121. package/lib/verify/verify-dungeon.js +59 -37
  122. package/package.json +2 -1
  123. package/scripts/extract-dungeon-schema.mjs +2 -2
  124. package/scripts/run-many.mjs +2 -2
  125. package/scripts/verify-runner.mjs +2 -2
  126. package/scripts/verify-stories.mjs +244 -0
  127. package/types.d.ts +296 -30
  128. package/dungeons/vertical/ai-platform.js +0 -818
  129. package/dungeons/vertical/community.js +0 -966
  130. package/dungeons/vertical/crypto.js +0 -895
  131. package/dungeons/vertical/dating.js +0 -830
  132. package/dungeons/vertical/devtools.js +0 -1050
  133. package/dungeons/vertical/ecommerce.js +0 -811
  134. package/dungeons/vertical/education.js +0 -964
  135. package/dungeons/vertical/fintech.js +0 -946
  136. package/dungeons/vertical/fitness.js +0 -935
  137. package/dungeons/vertical/food-delivery.js +0 -934
  138. package/dungeons/vertical/gaming.js +0 -1120
  139. package/dungeons/vertical/healthcare.js +0 -945
  140. package/dungeons/vertical/insurance-application.js +0 -1039
  141. package/dungeons/vertical/logistics.js +0 -986
  142. package/dungeons/vertical/marketplace.js +0 -1019
  143. package/dungeons/vertical/real-estate.js +0 -872
  144. package/dungeons/vertical/sass.js +0 -995
  145. package/dungeons/vertical/social.js +0 -904
  146. package/dungeons/vertical/travel.js +0 -930
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * @ak--47/dungeon-master/verify — Phase 4 verification surface.
3
3
  *
4
- * `emulateBreakdown` produces the table shapes Mixpanel shows for the five
4
+ * `emulateBreakdown` produces the table shapes Mixpanel shows for the
5
5
  * supported analyses (frequencyByFrequency, funnelFrequency, aggregatePerUser,
6
- * timeToConvert, attributedBy). `verifyDungeon` is the higher-level wrapper
7
- * that runs a dungeon and asserts emulator outputs match expected ratios — wire
8
- * this into your CI to catch dungeon drift.
6
+ * timeToConvert, attributedBy, sessionMetrics, retention, distinctCount,
7
+ * eventBreakdown, uniques, lifecycle, topPaths). `verifyDungeon` is the
8
+ * higher-level wrapper that runs a dungeon and asserts emulator outputs match
9
+ * expected ratios — wire this into your CI to catch dungeon drift.
9
10
  */
10
11
 
11
12
  export { emulateBreakdown } from './emulate-breakdown.js';
@@ -16,6 +17,7 @@ export {
16
17
  evaluateFunnelHPC,
17
18
  resolveFunnelSegment,
18
19
  normalizeStep,
20
+ normalizeFunnelSteps,
19
21
  matchesStepFilter,
20
22
  timestampComesAfter,
21
23
  withinConversionWindow,
@@ -25,9 +27,35 @@ export { buildIdentityMap, resolveUserId } from './identity.js';
25
27
  export {
26
28
  countDistinctPeriods,
27
29
  countDistinctValues,
30
+ countEvents,
28
31
  nullAwareAvg,
29
32
  nullAwareSum,
30
33
  nullAwareExtreme,
31
34
  binByDistinctPeriods,
32
35
  partitionByTimeBucket,
36
+ frequencyHistogram,
33
37
  } from './counting.js';
38
+ export { extractFlows, aggregateFlows, UNCOMMON_FLOWS_EVENT } from './flows.js';
39
+ export {
40
+ coerceToBreakdownKey,
41
+ breakdownSegmentKey,
42
+ filterEquals,
43
+ filterCompare,
44
+ filterContains,
45
+ matchesWhere,
46
+ } from './coerce.js';
47
+ export { evaluateFormula } from './formula.js';
48
+ export { filterFirstTimeEver } from './first-time.js';
49
+ export { sessionize, sessionOrdinals, SESSION_COPY_PROPERTIES } from './sessionize.js';
50
+ export { applyFunnelDefaults } from './verify-dungeon.js';
51
+ export {
52
+ STORY_ARCHETYPES,
53
+ VERDICT_RANK,
54
+ parseMetric,
55
+ selectRows,
56
+ verdictFor,
57
+ evaluateAssertion,
58
+ validateStories,
59
+ storiesToChecks,
60
+ evaluateStories,
61
+ } from './story-runner.js';
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Query-time sessionization — Mixpanel's session pre-pass, re-derived.
3
+ *
4
+ * Mixpanel sessions are computed at QUERY time from raw event timestamps and
5
+ * emitted as synthetic $session_start/$session_end events; they are never
6
+ * stored (backend/arb/reader/queries/session_query.cpp; sessions.go). The
7
+ * verifier re-derives them the same way rather than trusting any
8
+ * generator-stamped `session_id`.
9
+ *
10
+ * ARB rules implemented (all from session_query.cpp, TIMEOUT trigger mode):
11
+ * - Three split triggers (the timeout branch of
12
+ * session_query_params_process_event):
13
+ * 1. gap: time_ms − last_event_time_ms > session_timeout_ms (strict >)
14
+ * 2. max: time_ms > first_event_time_ms + session_max_time_ms
15
+ * (strict >, anchored to the session's FIRST event)
16
+ * 3. UTC day-index change: floor(ms / DAY) differs from the previous
17
+ * event's ("an index to uniquely identify each day. this is used to
18
+ * ensure sessions terminate per day"). We are UTC-everywhere; the
19
+ * project-timezone projection (time_project_ms_to_query_ms) is the
20
+ * qtz seam — findings #10, deferred.
21
+ * A trigger disabled by 0/undefined follows ARB's `> 0` guards; the day
22
+ * trigger is unconditional. NOTE: with the day trigger active,
23
+ * maxSessionMs only ever fires when configured BELOW 24h — any ≥24h span
24
+ * crosses a UTC midnight first.
25
+ * - On split AND at end-of-scan, the session end is stamped at the
26
+ * PREVIOUS/LAST event's timestamp (sortable_event_create_session_end is
27
+ * called with event_order_state.time_ms = u->last_event_time_ms; see
28
+ * also finalize_sessions) — $duration_s never includes the idle tail.
29
+ * - $duration_s = integer division (last_ms − first_ms) / 1000
30
+ * (uint32_t duration_s in sortable_event_create_session_end).
31
+ * - $event_count = inclusive raw event count (u->event_count++ per event;
32
+ * reset to 0 by sortable_event_create_session_start).
33
+ * - $origin_start / $origin_end = first / last event names; all four
34
+ * computed props are stamped on BOTH the start and end synthetic events
35
+ * (the `is_computed_props_set` back-fill block).
36
+ * - Copy props are FIRST-wins across the session (copy_to_property_set_copy
37
+ * skips slots already set in the filled_properties bitset; undefined
38
+ * values never fill) over DEFAULT_COPY_PROPERTIES
39
+ * (api/version_2_0/segmentation/models.py:78-97), stamped on both
40
+ * synthetic events.
41
+ * - Namespace rule: synthetic session events are EVENT_TYPE_SESSION — a
42
+ * separate selector namespace from regular events
43
+ * (libquery/event/filter.h event_type enum). Name filters and
44
+ * "all events" never match them, so they are returned in a SEPARATE
45
+ * array; callers opt in explicitly.
46
+ * - Sort tiebreak at equal ms: session start < regular event < session end
47
+ * (canonical_event_sort_comparer_lazy, libquery/event/event.c).
48
+ */
49
+
50
+ import { resolveUserId } from './identity.js';
51
+ import { toMs } from '../hook-helpers/_internal.js';
52
+
53
+ const DAY_MS = 86400 * 1000;
54
+
55
+ /**
56
+ * The default session copy-property list —
57
+ * api/version_2_0/segmentation/models.py:78-97 DEFAULT_COPY_PROPERTIES.
58
+ * First non-undefined value per property across the session wins.
59
+ */
60
+ export const SESSION_COPY_PROPERTIES = [
61
+ '$app_build_number',
62
+ '$app_version_string',
63
+ '$browser',
64
+ '$city',
65
+ '$country_code',
66
+ '$current_url',
67
+ '$device',
68
+ '$manufacturer',
69
+ '$os',
70
+ '$region',
71
+ 'mp_country_code',
72
+ 'mp_lib',
73
+ 'mp_platform',
74
+ 'utm_campaign',
75
+ 'utm_content',
76
+ 'utm_source',
77
+ '$referring_domain',
78
+ 'utm_medium',
79
+ ];
80
+
81
+ /**
82
+ * @typedef {Object} Session
83
+ * @property {string} userId Resolved canonical user id.
84
+ * @property {number} startMs First event's timestamp (ms).
85
+ * @property {number} endMs LAST event's timestamp (ms) — never the idle tail.
86
+ * @property {number} duration_s floor((endMs − startMs) / 1000).
87
+ * @property {number} event_count Inclusive raw event count.
88
+ * @property {string} origin_start First event's name.
89
+ * @property {string} origin_end Last event's name.
90
+ * @property {Object} copyProps First-wins values over SESSION_COPY_PROPERTIES.
91
+ * @property {Array<Object>} events The raw event objects, time-ordered.
92
+ */
93
+
94
+ /**
95
+ * Split one user's time-sorted `{ e, ms }` list into sessions per the three
96
+ * ARB triggers. Shared by `sessionize` (full derivation) and
97
+ * `sessionOrdinals` (funnel session-window support).
98
+ *
99
+ * @param {string} uid
100
+ * @param {Array<{e: Object, ms: number}>} list - MUST be time-sorted.
101
+ * @param {number} timeoutMs
102
+ * @param {number} maxSessionMs
103
+ * @returns {Array<Session>}
104
+ */
105
+ function buildUserSessions(uid, list, timeoutMs, maxSessionMs) {
106
+ /** @type {Array<Session>} */
107
+ const out = [];
108
+ /** @type {Session | null} */
109
+ let cur = null;
110
+
111
+ const open = ({ e, ms }) => {
112
+ cur = {
113
+ userId: uid,
114
+ startMs: ms,
115
+ endMs: ms,
116
+ duration_s: 0,
117
+ event_count: 0,
118
+ origin_start: e.event,
119
+ origin_end: e.event,
120
+ copyProps: {},
121
+ events: [],
122
+ };
123
+ };
124
+ const absorb = ({ e, ms }) => {
125
+ cur.endMs = ms;
126
+ cur.origin_end = e.event;
127
+ cur.event_count += 1;
128
+ cur.events.push(e);
129
+ // First-wins copy props (copy_to_property_set_copy — filled slots
130
+ // are skipped; undefined never fills).
131
+ for (const p of SESSION_COPY_PROPERTIES) {
132
+ if (p in cur.copyProps) continue;
133
+ const v = e[p];
134
+ if (v !== undefined && v !== null) cur.copyProps[p] = v;
135
+ }
136
+ };
137
+ const close = () => {
138
+ // End stamped at the LAST event's timestamp — never the idle tail.
139
+ cur.duration_s = Math.floor((cur.endMs - cur.startMs) / 1000);
140
+ out.push(cur);
141
+ cur = null;
142
+ };
143
+
144
+ for (const item of list) {
145
+ if (!cur) {
146
+ open(item);
147
+ } else {
148
+ const gapSplit = !!timeoutMs && (item.ms - cur.endMs > timeoutMs);
149
+ const maxSplit = !!maxSessionMs && (item.ms > cur.startMs + maxSessionMs);
150
+ const daySplit = Math.floor(item.ms / DAY_MS) !== Math.floor(cur.endMs / DAY_MS);
151
+ if (gapSplit || maxSplit || daySplit) {
152
+ close();
153
+ open(item);
154
+ }
155
+ }
156
+ absorb(item);
157
+ }
158
+ if (cur) close();
159
+ return out;
160
+ }
161
+
162
+ /**
163
+ * Derive sessions from raw events the way Mixpanel's session query does.
164
+ *
165
+ * @param {Array<Object>} events
166
+ * @param {Object} [options]
167
+ * @param {number} [options.timeoutMs=1800000] Gap trigger, strict `>`. Falsy disables (ARB `> 0` guard).
168
+ * @param {number} [options.maxSessionMs=86400000] Max-length trigger from the session's FIRST event, strict `>`. Falsy disables.
169
+ * @param {Map<string,string>} [options.identityMap] device_id → canonical id (see buildIdentityMap).
170
+ * @returns {{ sessions: Array<Session>, syntheticEvents: Array<Object> }}
171
+ * `syntheticEvents` is separate on purpose — synthetic session events live
172
+ * outside the regular event-name namespace (libquery/event/filter.h).
173
+ */
174
+ export function sessionize(events, { timeoutMs = 30 * 60_000, maxSessionMs = 24 * 3_600_000, identityMap } = {}) {
175
+ if (!Array.isArray(events)) throw new Error('sessionize: events must be an array');
176
+
177
+ // Group per resolved user, dropping events with no resolvable id
178
+ // (empty-uid events are skipped by ARB's user-state container).
179
+ const byUser = new Map();
180
+ for (const e of events) {
181
+ if (!e) continue;
182
+ const uid = resolveUserId(e, identityMap);
183
+ if (!uid) continue;
184
+ const ms = toMs(e.time);
185
+ if (!Number.isFinite(ms)) continue;
186
+ if (!byUser.has(uid)) byUser.set(uid, []);
187
+ byUser.get(uid).push({ e, ms });
188
+ }
189
+
190
+ /** @type {Array<Session>} */
191
+ const sessions = [];
192
+
193
+ for (const [uid, list] of byUser) {
194
+ list.sort((a, b) => a.ms - b.ms);
195
+ sessions.push(...buildUserSessions(uid, list, timeoutMs, maxSessionMs));
196
+ }
197
+
198
+ // Deterministic global order: by start time, then user id.
199
+ sessions.sort((a, b) => a.startMs - b.startMs || String(a.userId).localeCompare(String(b.userId)));
200
+
201
+ // Synthetic $session_start/$session_end pairs. All four computed props +
202
+ // copy props are stamped on BOTH events (the is_computed_props_set
203
+ // back-fill in sortable_event_create_session_end).
204
+ const syntheticEvents = [];
205
+ for (const s of sessions) {
206
+ const shared = {
207
+ $duration_s: s.duration_s,
208
+ $event_count: s.event_count,
209
+ $origin_start: s.origin_start,
210
+ $origin_end: s.origin_end,
211
+ ...s.copyProps,
212
+ };
213
+ syntheticEvents.push({
214
+ event: '$session_start',
215
+ time: new Date(s.startMs).toISOString(),
216
+ user_id: s.userId,
217
+ ...shared,
218
+ });
219
+ syntheticEvents.push({
220
+ event: '$session_end',
221
+ time: new Date(s.endMs).toISOString(),
222
+ user_id: s.userId,
223
+ ...shared,
224
+ });
225
+ }
226
+ // Canonical tiebreak at equal ms: start < end
227
+ // (canonical_event_sort_comparer_lazy).
228
+ syntheticEvents.sort((a, b) =>
229
+ toMs(a.time) - toMs(b.time)
230
+ || (a.event === b.event ? 0 : a.event === '$session_start' ? -1 : 1));
231
+
232
+ return { sessions, syntheticEvents };
233
+ }
234
+
235
+ /**
236
+ * Per-event session ordinals for ONE user's event stream — funnel
237
+ * session-count conversion-window support (P1.6.1).
238
+ *
239
+ * The funnel reader keeps a per-user session counter incremented on each
240
+ * $session_end event AFTER that event is processed
241
+ * (per_user_funnel_state_increment_session_id in funnel_query.cpp — the
242
+ * increment sits below funnel_query_process_event, and the equal-ms sort
243
+ * tiebreak puts session ends after regular events). So an event's ordinal is
244
+ * the count of session ENDS strictly before it, seeded 0 — equivalently the
245
+ * index of the session containing it — and the event that closes a session
246
+ * belongs to the session it closes.
247
+ *
248
+ * The whole stream is treated as a single user (funnel evaluation is
249
+ * per-user; events here often carry no id at all). For multi-user data,
250
+ * partition first, then call this per user.
251
+ *
252
+ * @param {Array<Object>} events
253
+ * @param {Object} [options]
254
+ * @param {number} [options.timeoutMs=1800000]
255
+ * @param {number} [options.maxSessionMs=86400000]
256
+ * @returns {Map<Object, number>} event object → session ordinal. Events with
257
+ * unparseable times are absent from the map.
258
+ */
259
+ export function sessionOrdinals(events, { timeoutMs = 30 * 60_000, maxSessionMs = 24 * 3_600_000 } = {}) {
260
+ if (!Array.isArray(events)) throw new Error('sessionOrdinals: events must be an array');
261
+ const list = [];
262
+ for (const e of events) {
263
+ if (!e) continue;
264
+ const ms = toMs(e.time);
265
+ if (!Number.isFinite(ms)) continue;
266
+ list.push({ e, ms });
267
+ }
268
+ list.sort((a, b) => a.ms - b.ms);
269
+ const sessions = buildUserSessions('__stream__', list, timeoutMs, maxSessionMs);
270
+ const ordinals = new Map();
271
+ for (let i = 0; i < sessions.length; i++) {
272
+ for (const e of sessions[i].events) ordinals.set(e, i);
273
+ }
274
+ return ordinals;
275
+ }