@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
@@ -0,0 +1,741 @@
1
+ // ── IMPORTS ──
2
+ import "dotenv/config";
3
+ import * as u from "@ak--47/dungeon-master/utils";
4
+ import { hashFloat, applyPathBias, applySessionShape } from "@ak--47/dungeon-master/hook-helpers";
5
+ /** @typedef {import("../../../types").Dungeon} Config */
6
+
7
+ // ── OVERVIEW ──
8
+ /*
9
+ * NAME: DeskHero
10
+ * APP: B2B SaaS support-desk: requesters file and track tickets,
11
+ * agents work queues in long daily sessions, a knowledge base
12
+ * deflects, escalations route to tier 2/3.
13
+ * SCALE: 5,000 users, ~1.7M events, 84 days (2026-03-01 → 2026-05-23)
14
+ * CORE LOOP: ticket created → reply sent → ticket resolved (→ csat)
15
+ *
16
+ * PURPOSE: v1.6 FLOWS + SESSIONS SHOWCASE. This dungeon exists to
17
+ * demonstrate the `applyPathBias` and `applySessionShape` atoms
18
+ * and the topPaths / funnelFrequency (session conversion
19
+ * windows) / sessionMetrics / uniques-sessions emulator
20
+ * families. Everything else is deliberately plain: no SCDs, no
21
+ * campaigns, no group keys, no lifecycle waves.
22
+ *
23
+ * EVENTS (10):
24
+ * ticket viewed (20) > dashboard viewed (14) > kb article viewed (12)
25
+ * > search (10) > ticket created (8) > reply sent (2) > csat submitted (2)
26
+ * > ticket resolved (1) > ticket escalated (1) > account created (1)
27
+ *
28
+ * FUNNELS (4):
29
+ * - Onboarding: account created → dashboard viewed → ticket created (70%, isFirstFunnel)
30
+ * - Resolution: ticket created → reply sent → ticket resolved (50%)
31
+ * - Deflection: search → kb article viewed (60%)
32
+ * - CSAT: ticket resolved → csat submitted (40%)
33
+ *
34
+ * USER PROPS: role, team_size, industry, support_plan
35
+ * SUPER PROPS: role, plan_tier
36
+ *
37
+ * IDENTITY: avgDevicePerUser: 2; account created is isFirstEvent +
38
+ * isAuthEvent, so every event carries user_id and ~all carry
39
+ * device_id.
40
+ *
41
+ * ═══════════════════════════════════════════════════════════════════
42
+ * ENGINEERED PATTERNS (hooks) — 3 hooks, disjoint hashFloat cohorts
43
+ * ═══════════════════════════════════════════════════════════════════
44
+ *
45
+ * Cohort gating uses disjoint hashFloat(uid) bands (NOT nested hashCohort
46
+ * calls — hashCohort(id, 45) ⊂ hashCohort(id, 70), so bands gated that way
47
+ * would overlap). The `role` super prop is pinned by the hook on EVERY
48
+ * event from the same band (the engine stamps super props randomly per
49
+ * event), and mirrored onto the profile in the `user` hook, so event
50
+ * cohorts, profile cohorts, and hashFloat bands all agree.
51
+ *
52
+ * ORDERING RULE (HOOKS.md §2.13/§2.17): `applySessionShape` retimes the
53
+ * whole stream, so it runs FIRST; `applyPathBias` injections go in AFTER
54
+ * shaping so their engineered gaps survive. H1's tight gaps glue its path
55
+ * into the anchor's derived session; H2's 1-4h gaps deliberately cross
56
+ * derived session boundaries (inter-session > 30 min).
57
+ *
58
+ * 1. ONE-TOUCH RESOLUTION PATH (everything) — hashFloat band [0.00, 0.45)
59
+ * PATTERN: applyPathBias injects `reply sent → ticket resolved` 5-20s
60
+ * after the user's FIRST `ticket created` — a canned-macro instant
61
+ * resolution, inside the same session as the ticket. Together with H2 the
62
+ * hook makes Flows BIMODAL: a one-touch resolve branch pinned to this
63
+ * band's share, and H2's reply-grind branch beside it. (The label-only
64
+ * Flows read is NOT "hooked > organic": organic F2 passes eventually
65
+ * produce the same labels for ~62% of users — the hook's signature is the
66
+ * split, and the fact that this band resolves in ONE session.)
67
+ * Discovery: Flows on "ticket created" shows two dominant branches;
68
+ * Funnels with a 1-session conversion window shows in-session resolution.
69
+ *
70
+ * Report 1: Flows, anchor "ticket created", hide all but reply/resolve/
71
+ * escalate steps, unique count type
72
+ * - Measure: share of entered users on created→reply→resolved
73
+ * - Expected (2K reduced run, hooked | organic): 0.425 (= the band,
74
+ * 892/2000 with ~95% template capture) | 0.618 (diffuse, label-only)
75
+ *
76
+ * Report 2: Funnels "created → reply → resolved", conversion window
77
+ * 1 SESSION, breakdown by frequency of "ticket created"
78
+ * - Measure: converters / entered at the 1-session window
79
+ * - Expected (2K, hooked | organic): 0.689 | 0.0015. The hooked mass is
80
+ * this band (0.464 of entered) plus organic F2 passes that H3's
81
+ * session shaping compressed into single sessions; organic streams
82
+ * average ~1.2 events per derived session so nothing converts
83
+ * in-session there.
84
+ *
85
+ * 2. ESCALATION CROSS-SESSION PATH (everything) — hashFloat band [0.45, 0.70)
86
+ * PATTERN: after shaping, applyPathBias injects `ticket escalated → reply
87
+ * sent → ticket resolved` with 1-4h gaps after the FIRST ticket created,
88
+ * then the hook drops the band's ORGANIC ticket resolved events (the
89
+ * injected chain becomes the band's only resolution route). Each 1-4h gap
90
+ * exceeds the 30-min session timeout, so the chain lands in three extra
91
+ * derived sessions: the Resolution funnel does NOT convert in 1 session
92
+ * (resolved sits at session ordinal +3) but DOES at a 4-session window —
93
+ * the session-count conversion contrast funnelFrequency exists to read.
94
+ * Discovery: Funnels conversion at 1-session vs 4-session windows.
95
+ *
96
+ * Report 1: Funnels "created → reply → resolved", conversion window
97
+ * swept over 1/3/4 SESSIONS
98
+ * - Measure: converters/entered at each window. The band converts only
99
+ * at ≥4 sessions, so the sweep is FLAT from n=1 to n=3 and jumps at
100
+ * n=4 by exactly the band's entered-share.
101
+ * - Expected (2K, hooked | organic): n1 0.689 → n3 0.712 → n4 0.982.
102
+ * Jump n4−n3 = 0.271 ≈ band entered-share 0.268; flatness n3−n1 =
103
+ * 0.023. | Organic: n1 0.0015 → n3 0.475 → n4 0.585 — a smooth ramp
104
+ * (n3−n1 = 0.473, n4−n3 = 0.111), no cliff at any ordinal.
105
+ *
106
+ * Report 2: Flows, anchor "ticket created", visible reply/resolve/
107
+ * escalate — the REPLY-GRIND branch
108
+ * - Measure: share of entered on created→reply→reply (the band's organic
109
+ * resolved events are dropped, so its flows end in the grind; NOT the
110
+ * escalated-anchor path — organic F2 reply→resolved pairs follow any
111
+ * escalation closely enough that that read shows no contrast).
112
+ * - Expected (2K, hooked | organic): 0.418 | 0.171
113
+ *
114
+ * 3. AGENT vs REQUESTER SESSION SHAPE (everything) — role split at 0.90
115
+ * PATTERN: applySessionShape retimes agents ([0.90, 1.00), ~10%) into ~5
116
+ * long sessions/week (6 events over 45 min) and requesters ([0.00, 0.90))
117
+ * into ~1 short session/week (28 events over 10 min). Derived sessions
118
+ * (30-min timeout) reproduce the cadence exactly: agents live in the
119
+ * product, requesters drop in weekly.
120
+ * Discovery: Session metrics (duration bimodality) + weekly unique
121
+ * sessions split by role.
122
+ *
123
+ * Report 1: Insights, session duration distribution
124
+ * - Measure: p90 / median session duration (two modes: the 10-min
125
+ * requester mass at the median, the 45-min agent mass at p90). Both
126
+ * modes are near-deterministic: applySessionShape spreads events
127
+ * evenly across exactly sessionMinutes, so median = 600000 ms and
128
+ * p90 = 2700000 ms.
129
+ * - Expected (2K, hooked | organic): ratio 4.50 (600000 → 2700000 ms)
130
+ * | organic median 0 ms (~1.2 events per derived session — mostly
131
+ * singletons, ratio undefined). eventsPerSession median 26 | 1.
132
+ *
133
+ * Report 2: Insights, unique sessions per week filtered by role
134
+ * - Measure: median weekly unique sessions / cohort size (per-capita
135
+ * cadence)
136
+ * - Expected (2K, hooked | organic): agents 3.92/wk | 25.8; requesters
137
+ * 0.85/wk | 22.3. Per-user sessionize medians: agents 4.58
138
+ * sessions/wk @ 45.0 min, requesters 1.00/wk @ 10.0 min (cadence
139
+ * ratio 4.58x, duration ratio 4.50x | organic ratio 1.04x). The
140
+ * uniques per-capita runs slightly under the per-user cadence because
141
+ * splitEvenly hands remainder sessions to the EARLIEST weeks —
142
+ * low-budget users thin out in later calendar weeks.
143
+ *
144
+ * NOTE: engine shape guarantees apply to no-hook configs; this dungeon
145
+ * retimes 100% of user streams into engineered session cadences. Daily
146
+ * event totals stay flat but intraday/DOW soup structure is intentionally
147
+ * replaced. That is the point of the dungeon.
148
+ */
149
+
150
+ // ── SCALE ──
151
+ const SEED = "harness-support-desk";
152
+ const NUM_USERS = 5_000;
153
+ const DATASET_START = "2026-03-01T00:00:00Z";
154
+ const DATASET_END = "2026-05-23T23:59:59Z"; // 84 days = 12 whole weeks
155
+ const EVENTS_PER_DAY = 4;
156
+ const token = process.env.MP_TOKEN || "your-mixpanel-token";
157
+
158
+ // ── KNOBS (tweak these to reshape stories) ──
159
+ // Disjoint hashFloat(uid) cohort bands — see OVERVIEW for why not hashCohort.
160
+ const H1_BAND = [0.00, 0.45]; // one-touch resolution path, ~45%
161
+ const H2_BAND = [0.45, 0.70]; // cross-session escalation path, ~25%
162
+ const AGENT_BAND = [0.90, 1.00]; // session-shape cohort, ~10% ([0.70, 0.90) = untouched tail)
163
+
164
+ const ANCHOR = "ticket created";
165
+ // H1 gaps sit UNDER the requester in-session spacing (~21s between events:
166
+ // 28 events over 10 min) so no organic step cuts into the injected path.
167
+ const H1_GAP_SECONDS = [5, 20];
168
+ // H2 gaps sit OVER the 30-min session timeout so every step of the chain
169
+ // lands in its own derived session (resolved at session ordinal +3).
170
+ const H2_GAP_SECONDS = [3600, 14400];
171
+
172
+ const AGENT_SHAPE = { sessionsPerWeek: 5, eventsPerSession: 6, sessionMinutes: 45 };
173
+ const REQUESTER_SHAPE = { sessionsPerWeek: 1, eventsPerSession: 28, sessionMinutes: 10 };
174
+
175
+ const inBand = (x, [lo, hi]) => x >= lo && x < hi;
176
+
177
+ // ── HOOK ──
178
+ function hook(record, type, meta) {
179
+ if (type === "user") {
180
+ // Mirror the event-level role pin onto the profile (same hashFloat
181
+ // band) so profile cohorts agree with event cohorts.
182
+ if (record?.distinct_id) {
183
+ record.role = inBand(hashFloat(record.distinct_id), AGENT_BAND) ? "agent" : "requester";
184
+ }
185
+ return record;
186
+ }
187
+ if (type !== "everything") return record;
188
+
189
+ const userEvents = record;
190
+ if (!Array.isArray(userEvents) || userEvents.length === 0) return record;
191
+ const uid = meta?.user?.distinct_id || userEvents.find(e => e?.user_id)?.user_id;
192
+ if (!uid) return record;
193
+ const h = hashFloat(uid);
194
+ const isAgent = inBand(h, AGENT_BAND);
195
+
196
+ // Role pin: the engine stamps super props randomly per event — overwrite
197
+ // on every event so the role cohort is deterministic (= hashFloat band).
198
+ const role = isAgent ? "agent" : "requester";
199
+ for (const e of userEvents) if (e) e.role = role;
200
+
201
+ // H3: SESSION SHAPE — retime the whole stream first (see ORDERING RULE).
202
+ applySessionShape(userEvents, uid, isAgent ? AGENT_SHAPE : REQUESTER_SHAPE);
203
+
204
+ // H1: ONE-TOUCH RESOLUTION — in-session path after the first ticket.
205
+ // share: 1 because the band gate is external (applyPathBias's internal
206
+ // hashFloat gate would nest, not compose, with the band).
207
+ if (inBand(h, H1_BAND)) {
208
+ return applyPathBias(userEvents, uid, {
209
+ anchor: ANCHOR,
210
+ path: ["reply sent", "ticket resolved"],
211
+ share: 1,
212
+ gapSeconds: H1_GAP_SECONDS,
213
+ });
214
+ }
215
+
216
+ // H2: ESCALATION CROSS-SESSION — inject the chain, then drop the band's
217
+ // organic resolutions so the injected chain is the ONLY route to
218
+ // "ticket resolved" (the 1-vs-4 session funnel contrast is then true by
219
+ // construction). Injection first: applyPathBias clones from the user's
220
+ // OWN stream, so the organic events must still be present as templates.
221
+ if (inBand(h, H2_BAND)) {
222
+ const before = userEvents.length;
223
+ const organicResolved = new Set();
224
+ for (const e of userEvents) if (e?.event === "ticket resolved") organicResolved.add(e);
225
+ applyPathBias(userEvents, uid, {
226
+ anchor: ANCHOR,
227
+ path: ["ticket escalated", "reply sent", "ticket resolved"],
228
+ share: 1,
229
+ gapSeconds: H2_GAP_SECONDS,
230
+ });
231
+ // Skipped user (no anchor or missing template) → keep organic
232
+ // resolutions; dropping them without the injected replacement would
233
+ // leave the band resolution-less instead of slow-to-resolve.
234
+ if (userEvents.length === before) return userEvents;
235
+ return userEvents.filter(e => !(e?.event === "ticket resolved" && organicResolved.has(e)));
236
+ }
237
+
238
+ return userEvents;
239
+ }
240
+
241
+ // ── CONFIG ──
242
+ /** @type {Config} */
243
+ const config = {
244
+ version: 2,
245
+ seed: SEED,
246
+ datasetStart: DATASET_START,
247
+ datasetEnd: DATASET_END,
248
+ avgEventsPerUserPerDay: EVENTS_PER_DAY,
249
+ numUsers: NUM_USERS,
250
+ format: "json",
251
+ gzip: true,
252
+ credentials: {
253
+ token,
254
+ },
255
+ switches: {
256
+ hasSessionIds: true,
257
+ alsoInferFunnels: false,
258
+ hasLocation: true,
259
+ hasAndroidDevices: false,
260
+ hasIOSDevices: false,
261
+ hasDesktopDevices: true,
262
+ hasBrowser: true,
263
+ hasCampaigns: false,
264
+ isAnonymous: false,
265
+ hasAdSpend: false,
266
+ hasAvatar: false,
267
+ },
268
+ identity: {
269
+ avgDevicePerUser: 2,
270
+ },
271
+ concurrency: 1,
272
+ writeToDisk: false,
273
+
274
+ funnels: [
275
+ {
276
+ sequence: ["account created", "dashboard viewed", "ticket created"],
277
+ isFirstFunnel: true,
278
+ conversionRate: 70,
279
+ timeToConvert: 1,
280
+ },
281
+ {
282
+ sequence: ["ticket created", "reply sent", "ticket resolved"],
283
+ conversionRate: 50,
284
+ timeToConvert: 4,
285
+ weight: 3,
286
+ },
287
+ {
288
+ sequence: ["search", "kb article viewed"],
289
+ conversionRate: 60,
290
+ timeToConvert: 0.5,
291
+ weight: 2,
292
+ },
293
+ {
294
+ sequence: ["ticket resolved", "csat submitted"],
295
+ conversionRate: 40,
296
+ timeToConvert: 1,
297
+ weight: 1,
298
+ },
299
+ ],
300
+
301
+ events: [
302
+ {
303
+ event: "account created",
304
+ weight: 1,
305
+ isFirstEvent: true,
306
+ isAuthEvent: true,
307
+ properties: {
308
+ signup_method: ["email", "google", "sso"],
309
+ company_size_bucket: ["1-10", "11-50", "11-50", "51-200", "201-1000", "1000+"],
310
+ },
311
+ },
312
+ {
313
+ event: "ticket viewed",
314
+ weight: 20,
315
+ properties: {
316
+ source: ["inbox", "inbox", "search", "notification", "direct_link"],
317
+ unread: [true, false, false],
318
+ },
319
+ },
320
+ {
321
+ event: "dashboard viewed",
322
+ weight: 14,
323
+ properties: {
324
+ view: ["inbox", "inbox", "my_tickets", "reports", "overview"],
325
+ widget_count: u.weighNumRange(1, 12, 0.3, 4),
326
+ },
327
+ },
328
+ {
329
+ event: "kb article viewed",
330
+ weight: 12,
331
+ properties: {
332
+ article_category: ["getting_started", "billing", "integrations", "troubleshooting", "troubleshooting"],
333
+ helpful_vote: ["none", "none", "none", "up", "down"],
334
+ },
335
+ },
336
+ {
337
+ event: "search",
338
+ weight: 10,
339
+ properties: {
340
+ query_length: u.weighNumRange(2, 40, 0.3, 12),
341
+ results_count: u.weighNumRange(0, 80, 0.3, 9),
342
+ },
343
+ },
344
+ {
345
+ event: "ticket created",
346
+ weight: 8,
347
+ properties: {
348
+ channel: ["email", "web_form", "web_form", "chat", "api"],
349
+ priority: ["low", "normal", "normal", "high", "urgent"],
350
+ category: ["billing", "bug", "bug", "how_to", "feature_request", "account"],
351
+ },
352
+ },
353
+ {
354
+ event: "reply sent",
355
+ weight: 2,
356
+ properties: {
357
+ is_first_response: [true, false, false],
358
+ response_length_chars: u.weighNumRange(40, 4000, 0.3, 320),
359
+ has_attachment: [false, false, false, true],
360
+ },
361
+ },
362
+ {
363
+ event: "csat submitted",
364
+ weight: 2,
365
+ properties: {
366
+ score: [5, 5, 4, 4, 3, 2, 1],
367
+ comment_left: [false, false, true],
368
+ },
369
+ },
370
+ {
371
+ event: "ticket resolved",
372
+ weight: 1,
373
+ properties: {
374
+ resolution: ["solved", "solved", "workaround", "duplicate", "no_response"],
375
+ reopened: [false, false, false, false, true],
376
+ },
377
+ },
378
+ {
379
+ event: "ticket escalated",
380
+ weight: 1,
381
+ properties: {
382
+ escalation_reason: ["sla_breach", "complexity", "complexity", "vip_customer", "wrong_queue"],
383
+ tier: ["tier_2", "tier_2", "tier_3"],
384
+ },
385
+ },
386
+ ],
387
+
388
+ superProps: {
389
+ role: ["requester", "requester", "requester", "agent"],
390
+ plan_tier: ["starter", "growth", "growth", "enterprise"],
391
+ },
392
+
393
+ userProps: {
394
+ role: ["requester", "requester", "requester", "agent"],
395
+ team_size: u.weighNumRange(1, 500, 0.3, 25),
396
+ industry: ["software", "software", "ecommerce", "finance", "healthcare", "education"],
397
+ support_plan: ["standard", "standard", "priority", "premium"],
398
+ },
399
+
400
+ hook,
401
+ };
402
+
403
+ export default config;
404
+
405
+ // ── STORIES ──
406
+ /*
407
+ * Derivation notes (2K reduced run iter-support-desk-1 vs organic
408
+ * counterfactual iter-support-desk-0, hook overridden to identity; full
409
+ * fidelity = 5K users, expected populations ≈ 2.5x the 2K numbers; scale
410
+ * guards at ~50% of that). The hook makes chance draws, so the hooked and
411
+ * organic runs have DIFFERENT downstream user populations — organic
412
+ * numbers are a statistical counterfactual, not per-user paired.
413
+ *
414
+ * - bands at 2K: h1 892, h2 537, tail 401, agent 170 (profile role pin
415
+ * agrees exactly: 170 agents).
416
+ * - H1 flows (topPaths anchor created, forward 2, visible reply/resolve/
417
+ * escalate, unique): totalEntered 1924; created→reply→resolved 817 =
418
+ * 0.4246 (the H1 band with ~95% template capture); created→reply→reply
419
+ * 804 = 0.418 (H2's grind); created→reply→escalated 170 = 0.088.
420
+ * Re-derived for fix-round B1 (leaf-row list semantics,
421
+ * flows_merger.cpp:362/:174): list rows are now one-per-leaf with
422
+ * count = leaf total_count. All three paths above sit at slot 2 — the
423
+ * full forward-2 capacity — so their nodes are max-depth leaves where
424
+ * leaf total == the old terminus dropoff: the counts and shares are
425
+ * UNCHANGED. Short-prefix terminus rows (e.g. bare 'ticket created')
426
+ * no longer appear as rows; pathShare's exact label match never
427
+ * referenced them.
428
+ * ORGANIC: target 0.618 (any converting F2 pass eventually yields the
429
+ * same labels — the label-only read INVERTS), grind 0.171. So H1
430
+ * asserts the share is PINNED to the band (INVERSE = organic-diffuse
431
+ * high side), not "hooked > organic".
432
+ * - funnelFrequency created→reply→resolved, session windows (entered
433
+ * 1924 hooked / 1945 organic): hooked n1 0.6887, n2 0.7053, n3 0.7115,
434
+ * n4 0.9823, n6 0.9875 — flat n1→n3 (+0.023), cliff at n4 (+0.2708 ≈
435
+ * H2 entered-share 0.268, resolved at session ordinal +3 exactly).
436
+ * ORGANIC: n1 0.0015 (3/1945 — ~1.2 events per derived session, nothing
437
+ * converts in-session), n3 0.4746, n4 0.5851 — smooth ramp, no cliff.
438
+ * Honest attribution for n1: H1's band is 0.464 of entered; the rest of
439
+ * the hooked 0.689 is organic F2 passes that H3's session shaping glued
440
+ * into single derived sessions (requesters get ~28-event sessions).
441
+ * - sessionMetrics (30-min timeout): hooked total_sessions 27552;
442
+ * duration median_ms 600000 / p90_ms 2700000 (ratio 4.50 — both modes
443
+ * near-deterministic: applySessionShape spreads events evenly across
444
+ * exactly sessionMinutes); eventsPerSession median 26. ORGANIC: 530359
445
+ * sessions, duration median_ms 0 (singletons), eventsPerSession avg 1.2.
446
+ * - uniques weekly sessions by role (per-capita = median weekly uniques /
447
+ * profile cohort): agents 667/170 = 3.924/wk, requesters 1561/1830 =
448
+ * 0.853/wk. Weekly requester counts DECLINE 1645→1214 across the
449
+ * window: splitEvenly hands remainder sessions to the earliest weeks,
450
+ * so low-budget users thin out late. Fix-round Q5 (S1): NAILED bands
451
+ * are now the knob ±10% (5/wk and 1/wk); the uniques attenuation is
452
+ * absorbed by the STRONG bands and both per-capita legs are EXPECTED
453
+ * to read STRONG at 2K fidelity. ORGANIC: agents 25.845/wk, requesters
454
+ * 22.288/wk (ratio 1.04x).
455
+ * - sessionize per-user medians (cross-check, duckdb LAG >30min): agents
456
+ * 4.583 sessions/wk @ 45.00 min, requesters 1.000/wk @ 10.00 min —
457
+ * cadence ratio 4.58x, duration ratio 4.50x (organic 1.04x / n/a).
458
+ * - identity: uid_share 1.0, device_share 0.9994, devices/user 2.05
459
+ * (avgDevicePerUser: 2).
460
+ */
461
+
462
+ const EV = `read_json_auto('{{PREFIX}}-EVENTS*.json', sample_size=-1, union_by_name=true)`;
463
+
464
+ const bandVerdict = (x, nailed, strong, detail, inverse = () => false) => {
465
+ if (x == null || Number.isNaN(Number(x))) return { verdict: "NONE", detail: `${detail} — metric missing` };
466
+ const v = Number(x);
467
+ if (inverse(v)) return { verdict: "INVERSE", detail };
468
+ if (v >= nailed[0] && v <= nailed[1]) return { verdict: "NAILED", detail };
469
+ if (v >= strong[0] && v <= strong[1]) return { verdict: "STRONG", detail };
470
+ return { verdict: "WEAK", detail };
471
+ };
472
+
473
+ const guarded = (ok, detail, inner) => ok ? inner() : { verdict: "WEAK", detail: `${detail} — cohort below scale guard (expected at reduced scale)` };
474
+
475
+ const worstOf = (...verdicts) => {
476
+ const order = ["INVERSE", "NONE", "WEAK", "STRONG", "NAILED"];
477
+ const worst = order.find(o => verdicts.some(v => v.verdict === o)) || "NONE";
478
+ return { verdict: worst, detail: verdicts.map(v => v.detail).join("; ") };
479
+ };
480
+
481
+ const medianOf = (arr) => {
482
+ const s = [...arr].sort((a, b) => a - b);
483
+ const m = Math.floor(s.length / 2);
484
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
485
+ };
486
+
487
+ // funnelFrequency rows are keyed (step_index, breakdown_freq); conversions
488
+ // are COUNT_LIKE — entered = sum at step 0, converters = sum at max step.
489
+ const freqRate = (rows) => {
490
+ if (!Array.isArray(rows) || !rows.length) return null;
491
+ const maxStep = Math.max(...rows.map(r => r.step_index));
492
+ const entered = rows.filter(r => r.step_index === 0).reduce((a, r) => a + r.conversions, 0);
493
+ const converted = rows.filter(r => r.step_index === maxStep).reduce((a, r) => a + r.conversions, 0);
494
+ return { entered, converted, rate: entered ? converted / entered : null };
495
+ };
496
+
497
+ const RESOLUTION_STEPS = ["ticket created", "reply sent", "ticket resolved"];
498
+ const FLOWS_ARGS = {
499
+ type: "topPaths",
500
+ anchors: [ANCHOR],
501
+ forward: 2,
502
+ reverse: 0,
503
+ countType: "unique",
504
+ visibleEvents: ["reply sent", "ticket resolved", "ticket escalated"],
505
+ };
506
+ const pathShare = (tp, labels) => {
507
+ if (!tp || !Array.isArray(tp.paths) || !tp.totalEntered) return null;
508
+ const hit = tp.paths.find(p => p.steps.map(s => s.label).join("|") === labels.join("|"));
509
+ return (hit?.count ?? 0) / tp.totalEntered;
510
+ };
511
+
512
+ export const stories = [
513
+ {
514
+ id: "support-desk-h1-one-touch-resolution",
515
+ hook: "H1",
516
+ archetype: "path-share",
517
+ narrative: "The [0, 0.45) band gets a canned-macro resolution injected 5-20s after its first ticket: Flows on 'ticket created' splits bimodally — the one-touch branch share is PINNED to the band (~0.42; organically the label-only read is a diffuse 0.62, so too-high is the failure mode) — and the Resolution funnel converts in ONE derived session for ~0.69 of entered (organic 0.0015: ~1.2 events per organic session, nothing converts in-session).",
518
+ assertions: [
519
+ {
520
+ breakdown: FLOWS_ARGS,
521
+ assert: (tp) => {
522
+ const share = pathShare(tp, RESOLUTION_STEPS);
523
+ if (share == null) return { verdict: "NONE", detail: `topPaths returned no paths (totalEntered=${tp?.totalEntered})` };
524
+ return guarded(tp.totalEntered >= 2400, `${tp.totalEntered} entered`,
525
+ () => bandVerdict(share, [0.36, 0.49], [0.32, 0.53],
526
+ `created→reply→resolved share ${share.toFixed(3)} of ${tp.totalEntered} entered (expect ~0.42 pinned to band; organic 0.618 diffuse)`,
527
+ v => v > 0.55));
528
+ },
529
+ },
530
+ {
531
+ breakdown: {
532
+ type: "funnelFrequency",
533
+ steps: RESOLUTION_STEPS,
534
+ breakdownByFrequencyOf: ANCHOR,
535
+ conversionWindow: { unit: "sessions", n: 1 },
536
+ },
537
+ assert: (rows) => {
538
+ const f = freqRate(rows);
539
+ if (!f || f.rate == null) return { verdict: "NONE", detail: "funnelFrequency returned no rows" };
540
+ return guarded(f.entered >= 2400, `${f.entered} entered`,
541
+ () => bandVerdict(f.rate, [0.60, 0.78], [0.50, 0.85],
542
+ `1-session conversion ${f.converted}/${f.entered} = ${f.rate.toFixed(4)} (expect ~0.69, organic 0.0015)`,
543
+ v => v < 0.05));
544
+ },
545
+ },
546
+ ],
547
+ },
548
+ {
549
+ id: "support-desk-h2-cross-session-escalation",
550
+ hook: "H2",
551
+ archetype: "frequency-sweet-spot",
552
+ narrative: "The [0.45, 0.70) band's organic resolutions are dropped and replaced by an injected escalated→reply→resolved chain with 1-4h gaps — resolved lands at session ordinal +3, so the Resolution funnel is FLAT from 1- to 3-session windows (~0.71 floor) and jumps to ~0.98 at 4 sessions (the +0.27 cliff = the band's entered-share; organic ramps smoothly 0.47→0.59 with no cliff). In Flows the band's paths end in the reply grind (created→reply→reply ~0.42 vs organic 0.17).",
553
+ assertions: [
554
+ {
555
+ breakdown: {
556
+ type: "funnelFrequency",
557
+ steps: RESOLUTION_STEPS,
558
+ breakdownByFrequencyOf: ANCHOR,
559
+ conversionWindow: { unit: "sessions", n: 3 },
560
+ },
561
+ assert: (rows) => {
562
+ const f = freqRate(rows);
563
+ if (!f || f.rate == null) return { verdict: "NONE", detail: "funnelFrequency returned no rows" };
564
+ return guarded(f.entered >= 2400, `${f.entered} entered`,
565
+ () => bandVerdict(f.rate, [0.63, 0.79], [0.55, 0.85],
566
+ `3-session conversion ${f.rate.toFixed(4)} — the pre-cliff floor (expect ~0.71: H2's band still unconverted; organic 0.475)`,
567
+ v => v < 0.25));
568
+ },
569
+ },
570
+ {
571
+ breakdown: {
572
+ type: "funnelFrequency",
573
+ steps: RESOLUTION_STEPS,
574
+ breakdownByFrequencyOf: ANCHOR,
575
+ conversionWindow: { unit: "sessions", n: 4 },
576
+ },
577
+ assert: (rows) => {
578
+ const f = freqRate(rows);
579
+ if (!f || f.rate == null) return { verdict: "NONE", detail: "funnelFrequency returned no rows" };
580
+ return guarded(f.entered >= 2400, `${f.entered} entered`,
581
+ () => bandVerdict(f.rate, [0.94, 1.0], [0.88, 1.0],
582
+ `4-session conversion ${f.rate.toFixed(4)} — past the cliff (expect ~0.98; organic 0.585). With the 3-session floor ≤0.79 this pins the ordinal-+3 jump`,
583
+ v => v < 0.70));
584
+ },
585
+ },
586
+ {
587
+ breakdown: FLOWS_ARGS,
588
+ assert: (tp) => {
589
+ const share = pathShare(tp, ["ticket created", "reply sent", "reply sent"]);
590
+ if (share == null) return { verdict: "NONE", detail: `topPaths returned no paths (totalEntered=${tp?.totalEntered})` };
591
+ return guarded(tp.totalEntered >= 2400, `${tp.totalEntered} entered`,
592
+ () => bandVerdict(share, [0.34, 0.49], [0.28, 0.53],
593
+ `created→reply→reply grind share ${share.toFixed(3)} (expect ~0.42: H1's band + H2's resolved-drop; organic 0.171)`,
594
+ v => v < 0.20));
595
+ },
596
+ },
597
+ ],
598
+ },
599
+ {
600
+ id: "support-desk-h3-agent-requester-session-shape",
601
+ hook: "H3",
602
+ archetype: "session-shape",
603
+ narrative: "applySessionShape retimes agents ([0.90, 1.00), ~10%, role pinned on profile + every event) into ~5 long 45-min sessions/week and requesters into ~1 short 10-min session/week of ~28 events: session duration is bimodal (median 600000 ms = the requester mode, p90 2700000 ms = the agent mode, ratio 4.5), eventsPerSession median ~26, and weekly unique sessions per capita split ~3.9/wk (agents) vs ~0.85/wk (requesters) — organic sessions are ~1.2-event singletons (median duration 0) at ~22-26 sessions/user/wk for BOTH roles.",
604
+ assertions: [
605
+ {
606
+ breakdown: { type: "sessionMetrics", metrics: ["count", "duration", "eventsPerSession"] },
607
+ assert: (rows) => {
608
+ if (!Array.isArray(rows) || !rows.length) return { verdict: "NONE", detail: "sessionMetrics returned no rows" };
609
+ const count = rows.find(r => r.metric === "count");
610
+ const dur = rows.find(r => r.metric === "duration");
611
+ const eps = rows.find(r => r.metric === "eventsPerSession");
612
+ if (!count || !dur || !eps) return { verdict: "NONE", detail: "missing sessionMetrics rows" };
613
+ // Fix-round Q5 (S1): NAILED bands re-derived as knob ±10%.
614
+ // duration median knob = REQUESTER_SHAPE.sessionMinutes (10 min =
615
+ // 600000 ms); p90/median knob = 45/10 = 4.5; eventsPerSession
616
+ // knob = REQUESTER_SHAPE.eventsPerSession (28 — the median
617
+ // session is a requester session; the event budget attenuates
618
+ // the realized median to ~26, within ±10% of the knob). STRONG
619
+ // bands stay author-wide to absorb window-alignment shift.
620
+ return guarded(count.total_sessions >= 34000, `${count.total_sessions} sessions`, () => {
621
+ const legMedian = bandVerdict(dur.median_ms, [540000, 660000], [360000, 720000],
622
+ `duration median ${dur.median_ms}ms (knob 600000 — the 10-min requester mode; organic 0)`,
623
+ v => v < 120000);
624
+ const ratio = dur.median_ms > 0 ? dur.p90_ms / dur.median_ms : null;
625
+ const legRatio = bandVerdict(ratio, [4.05, 4.95], [3.0, 6.5],
626
+ `duration p90/median ${ratio == null ? "n/a" : ratio.toFixed(2)} (knob 4.50 — 45-min agent mode over 10-min requester mode)`,
627
+ v => v < 1.5);
628
+ const legEps = bandVerdict(eps.median, [25.2, 30.8], [19, 30.8],
629
+ `eventsPerSession median ${eps.median} (knob 28, budget-attenuated ~26; organic ~1)`,
630
+ v => v <= 3);
631
+ return worstOf(legMedian, legRatio, legEps);
632
+ });
633
+ },
634
+ },
635
+ {
636
+ breakdown: { type: "uniques", unit: "week", countType: "sessions", where: { role: "agent" } },
637
+ assert: (rows, ctx) => {
638
+ if (!Array.isArray(rows) || !rows.length) return { verdict: "NONE", detail: "uniques returned no rows" };
639
+ const cohort = (ctx?.profiles || []).filter(p => p.role === "agent").length;
640
+ if (!cohort) return { verdict: "NONE", detail: "no agent profiles in ctx" };
641
+ const perCapita = medianOf(rows.map(r => r.uniques)) / cohort;
642
+ // Fix-round Q5 (S1): NAILED re-derived from the knob
643
+ // (AGENT_SHAPE.sessionsPerWeek = 5, ±10% = [4.5, 5.5]),
644
+ // replacing the measurement-wrapped [3.2, 4.6] that excluded
645
+ // the knob entirely. Median weekly UNIQUES runs below the
646
+ // per-user cadence (partial weeks + splitEvenly's early-week
647
+ // remainder), so at 2K fidelity this reads STRONG (~3.9) —
648
+ // the honest verdict for a documented attenuation whose
649
+ // magnitude is not knob-derivable.
650
+ return guarded(cohort >= 210, `${cohort} agents`,
651
+ () => bandVerdict(perCapita, [4.5, 5.5], [2.8, 5.5],
652
+ `agent weekly sessions per capita ${perCapita.toFixed(2)} (knob 5/wk, uniques-attenuated ~3.9; organic ~25.8)`,
653
+ v => v > 8));
654
+ },
655
+ },
656
+ {
657
+ breakdown: { type: "uniques", unit: "week", countType: "sessions", where: { role: "requester" } },
658
+ assert: (rows, ctx) => {
659
+ if (!Array.isArray(rows) || !rows.length) return { verdict: "NONE", detail: "uniques returned no rows" };
660
+ const cohort = (ctx?.profiles || []).filter(p => p.role === "requester").length;
661
+ if (!cohort) return { verdict: "NONE", detail: "no requester profiles in ctx" };
662
+ const perCapita = medianOf(rows.map(r => r.uniques)) / cohort;
663
+ // Fix-round Q5 (S1): NAILED = knob ±10%
664
+ // (REQUESTER_SHAPE.sessionsPerWeek = 1 → [0.9, 1.1]); the
665
+ // measured ~0.85 (same uniques attenuation as agents) reads
666
+ // STRONG honestly.
667
+ return guarded(cohort >= 2250, `${cohort} requesters`,
668
+ () => bandVerdict(perCapita, [0.9, 1.1], [0.55, 1.30],
669
+ `requester weekly sessions per capita ${perCapita.toFixed(2)} (knob 1/wk, uniques-attenuated ~0.85, sagging late from splitEvenly's early-week remainder; organic ~22.3)`,
670
+ v => v > 3));
671
+ },
672
+ },
673
+ {
674
+ breakdown: {
675
+ type: "duckdb",
676
+ sql: `WITH e AS (
677
+ SELECT user_id::VARCHAR AS uid, role, time::TIMESTAMP AS t,
678
+ CASE WHEN LAG(time::TIMESTAMP) OVER (PARTITION BY user_id::VARCHAR ORDER BY time::TIMESTAMP) IS NULL
679
+ OR time::TIMESTAMP - LAG(time::TIMESTAMP) OVER (PARTITION BY user_id::VARCHAR ORDER BY time::TIMESTAMP) > INTERVAL 30 MINUTE
680
+ THEN 1 ELSE 0 END AS is_start
681
+ FROM ${EV} WHERE user_id IS NOT NULL
682
+ ), s AS (
683
+ SELECT uid, role, t, SUM(is_start) OVER (PARTITION BY uid ORDER BY t) AS sid FROM e
684
+ ), sess AS (
685
+ SELECT uid, ANY_VALUE(role) AS role, sid, EXTRACT(EPOCH FROM MAX(t) - MIN(t))::DOUBLE / 60.0 AS dur_min
686
+ FROM s GROUP BY uid, sid
687
+ ), per_user AS (
688
+ SELECT uid, ANY_VALUE(role) AS role, COUNT(*)::DOUBLE / 12.0 AS spw FROM sess GROUP BY uid
689
+ )
690
+ SELECT
691
+ (SELECT COUNT(*) FROM per_user WHERE role = 'agent') AS agent_users,
692
+ (SELECT MEDIAN(spw) FROM per_user WHERE role = 'agent') AS agent_spw,
693
+ (SELECT MEDIAN(spw) FROM per_user WHERE role = 'requester') AS req_spw,
694
+ (SELECT MEDIAN(dur_min) FROM sess WHERE role = 'agent') AS agent_dur,
695
+ (SELECT MEDIAN(dur_min) FROM sess WHERE role = 'requester') AS req_dur`,
696
+ },
697
+ assert: (rows) => {
698
+ const r = rows?.[0];
699
+ if (!r) return { verdict: "NONE", detail: "session cadence query returned no rows" };
700
+ return guarded(Number(r.agent_users) >= 210, `${r.agent_users} agent users`, () => {
701
+ const cadence = Number(r.agent_spw) / Number(r.req_spw);
702
+ const durRatio = Number(r.agent_dur) / Number(r.req_dur);
703
+ // Fix-round Q5 (S1): NAILED bands = knob ±10% — cadence knob
704
+ // 5/1 = 5x → [4.5, 5.5]; duration knob 45/10 = 4.5x →
705
+ // [4.05, 4.95]. 2K measured 4.58x / 4.50x — both in-band.
706
+ const legCadence = bandVerdict(cadence, [4.5, 5.5], [3.0, 6.5],
707
+ `per-user cadence ratio ${Number(r.agent_spw).toFixed(2)}/${Number(r.req_spw).toFixed(2)} = ${cadence.toFixed(2)}x (knob 5x; organic 1.04x)`,
708
+ v => v < 1.5);
709
+ const legDur = bandVerdict(durRatio, [4.05, 4.95], [2.8, 6.5],
710
+ `median session duration ratio ${Number(r.agent_dur).toFixed(1)}min/${Number(r.req_dur).toFixed(1)}min = ${durRatio.toFixed(2)}x (knob 4.5x)`,
711
+ v => v < 1.5);
712
+ return worstOf(legCadence, legDur);
713
+ });
714
+ },
715
+ },
716
+ {
717
+ breakdown: {
718
+ type: "duckdb",
719
+ sql: `SELECT COUNT(*) AS n,
720
+ AVG((user_id IS NOT NULL)::INT) AS uid_share,
721
+ AVG((device_id IS NOT NULL)::INT) AS device_share,
722
+ COUNT(DISTINCT device_id)::DOUBLE / COUNT(DISTINCT user_id) AS dpu
723
+ FROM ${EV}`,
724
+ },
725
+ assert: (rows) => {
726
+ const r = rows?.[0];
727
+ if (!r) return { verdict: "NONE", detail: "identity query returned no rows" };
728
+ const legUid = bandVerdict(Number(r.uid_share), [1, 1], [0.999, 1],
729
+ `uid_share ${Number(r.uid_share).toFixed(4)} (every event carries user_id)`, v => v < 0.99);
730
+ const legDev = bandVerdict(Number(r.device_share), [0.99, 1], [0.97, 1],
731
+ `device_share ${Number(r.device_share).toFixed(4)} (avgDevicePerUser: 2)`, v => v < 0.9);
732
+ // Fix-round Q5 (S1): NAILED = knob ±10% (avgDevicePerUser: 2 →
733
+ // [1.8, 2.2]); 2K measured 2.05.
734
+ const legDpu = bandVerdict(Number(r.dpu), [1.8, 2.2], [1.4, 2.6],
735
+ `devices/user ${Number(r.dpu).toFixed(2)} (knob 2)`, v => v < 1.05);
736
+ return worstOf(legUid, legDev, legDpu);
737
+ },
738
+ },
739
+ ],
740
+ },
741
+ ];