@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.
- package/.claude/skills/analyze-soup/SKILL.md +21 -12
- package/.claude/skills/create-dungeon/SKILL.md +65 -5
- package/.claude/skills/create-project/SKILL.md +1 -1
- package/.claude/skills/create-project/provision.mjs +57 -10
- package/.claude/skills/verify-dungeon/SKILL.md +56 -25
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
- package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
- package/.claude/skills/write-hooks/SKILL.md +156 -18
- package/CHANGELOG.md +183 -0
- package/HOOKS.md +566 -112
- package/README.md +28 -9
- package/docs/guides/1.3.0-upgrade-guide.md +262 -0
- package/docs/guides/1.3.1-upgrade-guide.md +328 -0
- package/docs/guides/1.4.0-upgrade-guide.md +130 -0
- package/docs/guides/1.4.1-upgrade-guide.md +125 -0
- package/docs/guides/1.4.3-upgrade-guide.md +200 -0
- package/docs/guides/1.4.4-upgrade-guide.md +95 -0
- package/docs/guides/1.5.0-upgrade-guide.md +426 -0
- package/docs/guides/1.5.1-upgrade-guide.md +178 -0
- package/docs/guides/1.5.3-upgrade-guide.md +117 -0
- package/docs/guides/1.6.0-upgrade-guide.md +204 -0
- package/dungeons/technical/stories-verify.js +152 -0
- package/dungeons/vertical/README.md +154 -0
- package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
- package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
- package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
- package/dungeons/vertical/community/community.js +1589 -0
- package/dungeons/vertical/community/community.sql +216 -0
- package/dungeons/vertical/community/community.verify.mjs +72 -0
- package/dungeons/vertical/crypto/crypto.js +1483 -0
- package/dungeons/vertical/crypto/crypto.sql +194 -0
- package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
- package/dungeons/vertical/dating/dating.js +1540 -0
- package/dungeons/vertical/dating/dating.sql +288 -0
- package/dungeons/vertical/dating/dating.verify.mjs +72 -0
- package/dungeons/vertical/devtools/devtools.js +1524 -0
- package/dungeons/vertical/devtools/devtools.sql +161 -0
- package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
- package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
- package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
- package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
- package/dungeons/vertical/education/education.js +1763 -0
- package/dungeons/vertical/education/education.sql +243 -0
- package/dungeons/vertical/education/education.verify.mjs +75 -0
- package/dungeons/vertical/fintech/fintech.js +1410 -0
- package/dungeons/vertical/fintech/fintech.sql +175 -0
- package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
- package/dungeons/vertical/fitness/fitness.js +1495 -0
- package/dungeons/vertical/fitness/fitness.sql +206 -0
- package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
- package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
- package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
- package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
- package/dungeons/vertical/gaming/gaming.js +1741 -0
- package/dungeons/vertical/gaming/gaming.sql +249 -0
- package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
- package/dungeons/vertical/healthcare/healthcare.js +1488 -0
- package/dungeons/vertical/healthcare/healthcare.sql +193 -0
- package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
- package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
- package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
- package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
- package/dungeons/vertical/logistics/logistics.js +1541 -0
- package/dungeons/vertical/logistics/logistics.sql +205 -0
- package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
- package/dungeons/vertical/marketplace/marketplace.js +1438 -0
- package/dungeons/vertical/marketplace/marketplace.sql +148 -0
- package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
- package/dungeons/vertical/{media.js → media/media.js} +557 -58
- package/dungeons/vertical/media/media.sql +173 -0
- package/dungeons/vertical/media/media.verify.mjs +74 -0
- package/dungeons/vertical/real-estate/real-estate.js +1629 -0
- package/dungeons/vertical/real-estate/real-estate.sql +167 -0
- package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
- package/dungeons/vertical/sass/sass.js +1733 -0
- package/dungeons/vertical/sass/sass.sql +173 -0
- package/dungeons/vertical/sass/sass.verify.mjs +74 -0
- package/dungeons/vertical/social/social.js +1456 -0
- package/dungeons/vertical/social/social.sql +160 -0
- package/dungeons/vertical/social/social.verify.mjs +76 -0
- package/dungeons/vertical/streaming/streaming.js +677 -0
- package/dungeons/vertical/streaming/streaming.sql +101 -0
- package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
- package/dungeons/vertical/support-desk/support-desk.js +741 -0
- package/dungeons/vertical/support-desk/support-desk.sql +137 -0
- package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
- package/dungeons/vertical/travel/travel.js +1603 -0
- package/dungeons/vertical/travel/travel.sql +236 -0
- package/dungeons/vertical/travel/travel.verify.mjs +72 -0
- package/index.js +2 -2
- package/lib/core/config-validator.js +37 -2
- package/lib/core/context.js +4 -2
- package/lib/core/dungeon-loader.js +20 -15
- package/lib/core/dungeon-to-json.js +22 -4
- package/lib/core/storage.js +3 -3
- package/lib/generators/funnels.js +36 -9
- package/lib/hook-helpers/cohort.js +53 -0
- package/lib/hook-helpers/index.js +8 -0
- package/lib/hook-helpers/mutate.js +1 -1
- package/lib/hook-helpers/shape.js +319 -0
- package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
- package/lib/hook-patterns/attributed-by-source.js +60 -58
- package/lib/hook-patterns/frequency-by-frequency.js +14 -4
- package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
- package/lib/hook-patterns/index.js +1 -1
- package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
- package/lib/orchestrators/user-loop.js +41 -0
- package/lib/templates/story-spec.schema.json +175 -0
- package/lib/utils/json-evaluator.js +1 -1
- package/lib/utils/utils.js +5 -4
- package/lib/verify/coerce.js +186 -0
- package/lib/verify/counting.js +270 -32
- package/lib/verify/emulate-breakdown.js +1285 -203
- package/lib/verify/first-time.js +89 -0
- package/lib/verify/flows.js +679 -0
- package/lib/verify/formula.js +259 -0
- package/lib/verify/funnel-engine.js +697 -113
- package/lib/verify/index.js +32 -4
- package/lib/verify/sessionize.js +275 -0
- package/lib/verify/story-runner.js +483 -0
- package/lib/verify/verify-dungeon.js +59 -37
- package/package.json +2 -1
- package/scripts/extract-dungeon-schema.mjs +2 -2
- package/scripts/run-many.mjs +2 -2
- package/scripts/verify-runner.mjs +2 -2
- package/scripts/verify-stories.mjs +244 -0
- package/types.d.ts +296 -30
- package/dungeons/vertical/ai-platform.js +0 -818
- package/dungeons/vertical/community.js +0 -966
- package/dungeons/vertical/crypto.js +0 -895
- package/dungeons/vertical/dating.js +0 -830
- package/dungeons/vertical/devtools.js +0 -1050
- package/dungeons/vertical/ecommerce.js +0 -811
- package/dungeons/vertical/education.js +0 -964
- package/dungeons/vertical/fintech.js +0 -946
- package/dungeons/vertical/fitness.js +0 -935
- package/dungeons/vertical/food-delivery.js +0 -934
- package/dungeons/vertical/gaming.js +0 -1120
- package/dungeons/vertical/healthcare.js +0 -945
- package/dungeons/vertical/insurance-application.js +0 -1039
- package/dungeons/vertical/logistics.js +0 -986
- package/dungeons/vertical/marketplace.js +0 -1019
- package/dungeons/vertical/real-estate.js +0 -872
- package/dungeons/vertical/sass.js +0 -995
- package/dungeons/vertical/social.js +0 -904
- 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
|
+
];
|