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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) 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/powertools/SKILL.md +75 -0
  6. package/.claude/skills/powertools/pt.mjs +91 -0
  7. package/.claude/skills/powertools/snapshot-project.mjs +124 -0
  8. package/.claude/skills/verify-dungeon/SKILL.md +56 -25
  9. package/.claude/skills/verify-dungeon/references/counting-semantics.md +26 -6
  10. package/.claude/skills/verify-dungeon/references/report-format.md +14 -8
  11. package/.claude/skills/write-hooks/SKILL.md +156 -18
  12. package/CHANGELOG.md +224 -0
  13. package/HOOKS.md +566 -112
  14. package/README.md +30 -9
  15. package/docs/guides/1.3.0-upgrade-guide.md +262 -0
  16. package/docs/guides/1.3.1-upgrade-guide.md +328 -0
  17. package/docs/guides/1.4.0-upgrade-guide.md +130 -0
  18. package/docs/guides/1.4.1-upgrade-guide.md +125 -0
  19. package/docs/guides/1.4.3-upgrade-guide.md +200 -0
  20. package/docs/guides/1.4.4-upgrade-guide.md +95 -0
  21. package/docs/guides/1.5.0-upgrade-guide.md +426 -0
  22. package/docs/guides/1.5.1-upgrade-guide.md +178 -0
  23. package/docs/guides/1.5.3-upgrade-guide.md +117 -0
  24. package/docs/guides/1.6.0-upgrade-guide.md +204 -0
  25. package/dungeons/technical/stories-verify.js +152 -0
  26. package/dungeons/vertical/README.md +154 -0
  27. package/dungeons/vertical/ai-platform/ai-platform.js +1424 -0
  28. package/dungeons/vertical/ai-platform/ai-platform.sql +184 -0
  29. package/dungeons/vertical/ai-platform/ai-platform.verify.mjs +71 -0
  30. package/dungeons/vertical/community/community.js +1589 -0
  31. package/dungeons/vertical/community/community.sql +216 -0
  32. package/dungeons/vertical/community/community.verify.mjs +72 -0
  33. package/dungeons/vertical/crypto/crypto.js +1483 -0
  34. package/dungeons/vertical/crypto/crypto.sql +194 -0
  35. package/dungeons/vertical/crypto/crypto.verify.mjs +78 -0
  36. package/dungeons/vertical/dating/dating.js +1540 -0
  37. package/dungeons/vertical/dating/dating.sql +288 -0
  38. package/dungeons/vertical/dating/dating.verify.mjs +72 -0
  39. package/dungeons/vertical/devtools/devtools.js +1524 -0
  40. package/dungeons/vertical/devtools/devtools.sql +161 -0
  41. package/dungeons/vertical/devtools/devtools.verify.mjs +73 -0
  42. package/dungeons/vertical/ecommerce/ecommerce.js +1480 -0
  43. package/dungeons/vertical/ecommerce/ecommerce.sql +218 -0
  44. package/dungeons/vertical/ecommerce/ecommerce.verify.mjs +70 -0
  45. package/dungeons/vertical/education/education.js +1763 -0
  46. package/dungeons/vertical/education/education.sql +243 -0
  47. package/dungeons/vertical/education/education.verify.mjs +75 -0
  48. package/dungeons/vertical/fintech/fintech.js +1410 -0
  49. package/dungeons/vertical/fintech/fintech.sql +175 -0
  50. package/dungeons/vertical/fintech/fintech.verify.mjs +70 -0
  51. package/dungeons/vertical/fitness/fitness.js +1495 -0
  52. package/dungeons/vertical/fitness/fitness.sql +206 -0
  53. package/dungeons/vertical/fitness/fitness.verify.mjs +71 -0
  54. package/dungeons/vertical/food-delivery/food-delivery.js +1641 -0
  55. package/dungeons/vertical/food-delivery/food-delivery.sql +183 -0
  56. package/dungeons/vertical/food-delivery/food-delivery.verify.mjs +75 -0
  57. package/dungeons/vertical/gaming/gaming.js +1741 -0
  58. package/dungeons/vertical/gaming/gaming.sql +249 -0
  59. package/dungeons/vertical/gaming/gaming.verify.mjs +70 -0
  60. package/dungeons/vertical/healthcare/healthcare.js +1488 -0
  61. package/dungeons/vertical/healthcare/healthcare.sql +193 -0
  62. package/dungeons/vertical/healthcare/healthcare.verify.mjs +72 -0
  63. package/dungeons/vertical/insurance-application/insurance-application.js +1852 -0
  64. package/dungeons/vertical/insurance-application/insurance-application.sql +174 -0
  65. package/dungeons/vertical/insurance-application/insurance-application.verify.mjs +74 -0
  66. package/dungeons/vertical/logistics/logistics.js +1541 -0
  67. package/dungeons/vertical/logistics/logistics.sql +205 -0
  68. package/dungeons/vertical/logistics/logistics.verify.mjs +73 -0
  69. package/dungeons/vertical/marketplace/marketplace.js +1438 -0
  70. package/dungeons/vertical/marketplace/marketplace.sql +148 -0
  71. package/dungeons/vertical/marketplace/marketplace.verify.mjs +74 -0
  72. package/dungeons/vertical/{media.js → media/media.js} +557 -58
  73. package/dungeons/vertical/media/media.sql +173 -0
  74. package/dungeons/vertical/media/media.verify.mjs +74 -0
  75. package/dungeons/vertical/real-estate/real-estate.js +1629 -0
  76. package/dungeons/vertical/real-estate/real-estate.sql +167 -0
  77. package/dungeons/vertical/real-estate/real-estate.verify.mjs +75 -0
  78. package/dungeons/vertical/sass/sass.js +1733 -0
  79. package/dungeons/vertical/sass/sass.sql +173 -0
  80. package/dungeons/vertical/sass/sass.verify.mjs +74 -0
  81. package/dungeons/vertical/social/social.js +1456 -0
  82. package/dungeons/vertical/social/social.sql +160 -0
  83. package/dungeons/vertical/social/social.verify.mjs +76 -0
  84. package/dungeons/vertical/streaming/streaming.js +677 -0
  85. package/dungeons/vertical/streaming/streaming.sql +101 -0
  86. package/dungeons/vertical/streaming/streaming.verify.mjs +75 -0
  87. package/dungeons/vertical/support-desk/support-desk.js +741 -0
  88. package/dungeons/vertical/support-desk/support-desk.sql +137 -0
  89. package/dungeons/vertical/support-desk/support-desk.verify.mjs +75 -0
  90. package/dungeons/vertical/travel/travel.js +1603 -0
  91. package/dungeons/vertical/travel/travel.sql +236 -0
  92. package/dungeons/vertical/travel/travel.verify.mjs +72 -0
  93. package/index.js +7 -3
  94. package/lib/core/config-validator.js +37 -2
  95. package/lib/core/context.js +4 -2
  96. package/lib/core/dungeon-loader.js +20 -15
  97. package/lib/core/dungeon-to-json.js +22 -4
  98. package/lib/core/storage.js +3 -3
  99. package/lib/generators/funnels.js +36 -9
  100. package/lib/hook-helpers/cohort.js +53 -0
  101. package/lib/hook-helpers/index.js +8 -0
  102. package/lib/hook-helpers/mutate.js +1 -1
  103. package/lib/hook-helpers/shape.js +319 -0
  104. package/lib/hook-patterns/aggregate-per-user-by-bin.js +13 -5
  105. package/lib/hook-patterns/attributed-by-source.js +60 -58
  106. package/lib/hook-patterns/frequency-by-frequency.js +14 -4
  107. package/lib/hook-patterns/funnel-frequency-breakdown.js +24 -12
  108. package/lib/hook-patterns/index.js +1 -1
  109. package/lib/hook-patterns/time-to-convert-by-segment.js +75 -13
  110. package/lib/orchestrators/user-loop.js +41 -0
  111. package/lib/templates/story-spec.schema.json +175 -0
  112. package/lib/utils/json-evaluator.js +1 -1
  113. package/lib/utils/utils.js +151 -59
  114. package/lib/verify/coerce.js +186 -0
  115. package/lib/verify/counting.js +270 -32
  116. package/lib/verify/emulate-breakdown.js +1285 -203
  117. package/lib/verify/first-time.js +89 -0
  118. package/lib/verify/flows.js +679 -0
  119. package/lib/verify/formula.js +259 -0
  120. package/lib/verify/funnel-engine.js +697 -113
  121. package/lib/verify/index.js +32 -4
  122. package/lib/verify/sessionize.js +275 -0
  123. package/lib/verify/story-runner.js +483 -0
  124. package/lib/verify/verify-dungeon.js +59 -37
  125. package/package.json +2 -1
  126. package/scripts/extract-dungeon-schema.mjs +2 -2
  127. package/scripts/run-many.mjs +2 -2
  128. package/scripts/verify-runner.mjs +2 -2
  129. package/scripts/verify-stories.mjs +244 -0
  130. package/types.d.ts +298 -30
  131. package/dungeons/vertical/ai-platform.js +0 -818
  132. package/dungeons/vertical/community.js +0 -966
  133. package/dungeons/vertical/crypto.js +0 -895
  134. package/dungeons/vertical/dating.js +0 -830
  135. package/dungeons/vertical/devtools.js +0 -1050
  136. package/dungeons/vertical/ecommerce.js +0 -811
  137. package/dungeons/vertical/education.js +0 -964
  138. package/dungeons/vertical/fintech.js +0 -946
  139. package/dungeons/vertical/fitness.js +0 -935
  140. package/dungeons/vertical/food-delivery.js +0 -934
  141. package/dungeons/vertical/gaming.js +0 -1120
  142. package/dungeons/vertical/healthcare.js +0 -945
  143. package/dungeons/vertical/insurance-application.js +0 -1039
  144. package/dungeons/vertical/logistics.js +0 -986
  145. package/dungeons/vertical/marketplace.js +0 -1019
  146. package/dungeons/vertical/real-estate.js +0 -872
  147. package/dungeons/vertical/sass.js +0 -995
  148. package/dungeons/vertical/social.js +0 -904
  149. package/dungeons/vertical/travel.js +0 -930
@@ -0,0 +1,243 @@
1
+ -- ============================================================
2
+ -- education.js — v1.6 human-inspection queries (DuckDB)
3
+ --
4
+ -- Every query is keyed to a story id in education.js's `stories` export;
5
+ -- the machine-checked verdicts come from:
6
+ -- node scripts/verify-stories.mjs dungeons/vertical/education/education.js --data-prefix verify-education
7
+ -- Generate first:
8
+ -- node scripts/verify-runner.mjs dungeons/vertical/education/education.js verify-education
9
+ -- Run this file:
10
+ -- duckdb -c ".read dungeons/vertical/education/education.sql"
11
+ --
12
+ -- NOTE: this dungeon uses a LITERAL historical window (datasetStart
13
+ -- 2026-01-01 → datasetEnd 2026-05-01, no forward shift), so day indexes
14
+ -- are computed from the actual min event date.
15
+ -- ============================================================
16
+
17
+ -- ── identity-resolution prelude ─────────────────────────────
18
+ -- avgDevicePerUser: 2 + 'account registered' is both isAuthEvent and
19
+ -- isFirstEvent, so born users auth on their first event; the device-pool
20
+ -- resolve is belt-and-braces for any device-only edge.
21
+ CREATE OR REPLACE VIEW users AS
22
+ SELECT * FROM read_json_auto('data/verify-education-USERS*.json', sample_size=-1, union_by_name=true);
23
+
24
+ CREATE OR REPLACE VIEW device_map AS
25
+ -- profiles store the device pool under the legacy "anonymousIds" key
26
+ SELECT unnest("anonymousIds") AS device_id, distinct_id FROM users;
27
+
28
+ CREATE OR REPLACE VIEW ev AS
29
+ -- ::VARCHAR casts — user_id sniffs as UUID, device_id as VARCHAR; DuckDB
30
+ -- refuses to coalesce mixed types
31
+ SELECT coalesce(m.distinct_id::VARCHAR, e.user_id::VARCHAR, e.device_id::VARCHAR) AS uid,
32
+ e.time::TIMESTAMP AS t,
33
+ e.*
34
+ FROM read_json_auto('data/verify-education-EVENTS*.json', sample_size=-1, union_by_name=true) e
35
+ LEFT JOIN device_map m ON e.device_id = m.device_id;
36
+
37
+ -- Per-user counts. H4's churn (delete ALL events after firstEvent+14d for
38
+ -- non-early-joiners with a raw sub-60 quiz) is the only removal touching
39
+ -- lectures/quizzes, so lifespan > 14.5d identifies the not-churned
40
+ -- population exactly — and within it, output note/speed counts equal the
41
+ -- hook-time counts the score treatments keyed on.
42
+ CREATE OR REPLACE VIEW per_user AS
43
+ SELECT e.uid,
44
+ min(e.t) AS first_t, max(e.t) AS last_t,
45
+ count(*) FILTER (WHERE event = 'lecture completed' AND notes_taken) AS notes,
46
+ count(*) FILTER (WHERE event = 'lecture completed' AND playback_speed >= 2.0) AS fast_lex,
47
+ count(*) FILTER (WHERE event = 'quiz completed') AS quizzes,
48
+ count(*) FILTER (WHERE event = 'certificate earned') AS certs,
49
+ count(*) FILTER (WHERE event = 'course enrolled') AS enrolls,
50
+ count(*) FILTER (WHERE event = 'discussion posted') AS discussions,
51
+ min(CASE WHEN event = 'study group joined' THEN e.t END) AS first_join_t
52
+ FROM ev e GROUP BY 1;
53
+
54
+ CREATE OR REPLACE VIEW per_user_u AS
55
+ SELECT p.*, u.subscription_status, u.account_type,
56
+ (p.first_join_t IS NOT NULL AND date_diff('hour', p.first_t, p.first_join_t) <= 240) AS early_join,
57
+ (p.last_t > p.first_t + INTERVAL '14 days 12 hours') AS retained
58
+ FROM per_user p JOIN users u ON p.uid = u.distinct_id::VARCHAR;
59
+
60
+
61
+ -- ── H1-role-profiles ────────────────────────────────────────
62
+ -- 8:1 student pool (instructor share 1/9 ≈ 0.111); role-exclusive profile
63
+ -- attributes; the everything hook stamps account_type on 'account
64
+ -- registered' events from the profile, so the event breakdown is exact.
65
+ SELECT account_type, count(*) AS n,
66
+ round(avg(courses_created), 1) AS avg_courses_created,
67
+ round(avg(instructor_rating), 2) AS avg_rating,
68
+ round(avg(study_hours_per_week), 1) AS avg_study_hrs
69
+ FROM users GROUP BY 1 ORDER BY n DESC;
70
+ -- read: student ~89% with courses_created 0; instructor ~11% with
71
+ -- study_hours 0
72
+
73
+ SELECT round(avg((e.account_type = u.account_type)::INT), 4) AS event_profile_agreement, count(*) AS n
74
+ FROM ev e JOIN users u ON e.uid = u.distinct_id::VARCHAR
75
+ WHERE e.event = 'account registered';
76
+ -- read: agreement = 1.0 (hook-stamped)
77
+
78
+
79
+ -- ── H2-deadline-cramming ────────────────────────────────────
80
+ -- Sun/Mon: is_late redrawn at 60% (organic ~20%); quiz scores -25 (clamp 0).
81
+ -- DuckDB dayofweek: Sunday=0, Monday=1.
82
+ SELECT dayofweek(t) AS dow, count(*) AS n,
83
+ round(avg(is_late::INT) * 100, 1) AS pct_late
84
+ FROM ev WHERE event = 'assignment submitted'
85
+ GROUP BY 1 ORDER BY 1;
86
+ -- read: dow 0/1 ≈ 60%; dow 2-6 ≈ 20%
87
+
88
+ SELECT (dayofweek(t) IN (0, 1)) AS sun_mon, count(*) AS n,
89
+ round(avg(score_percent), 2) AS avg_score
90
+ FROM ev WHERE event = 'quiz completed'
91
+ GROUP BY 1 ORDER BY 1;
92
+ -- read: gap ≈ 24 pts (25 knob minus clamp-at-0 attenuation)
93
+
94
+
95
+ -- ── H3-notes-magic-number ───────────────────────────────────
96
+ -- 5-8 notes → quiz ×1.3 (cap 100) + 40% bonus cert; 9+ notes → 35% of
97
+ -- certs dropped. Score read: retained non-speed users, non-Sun/Mon
98
+ -- quizzes (isolates H3 from H8's +8 and H2's -25).
99
+ WITH b AS (
100
+ SELECT uid, CASE WHEN notes BETWEEN 5 AND 8 THEN 'sweet'
101
+ WHEN notes <= 4 THEN 'low' ELSE 'over' END AS bin
102
+ FROM per_user_u WHERE retained AND fast_lex < 3
103
+ )
104
+ SELECT b.bin, count(DISTINCT e.uid) AS users, round(avg(e.score_percent), 2) AS avg_score
105
+ FROM ev e JOIN b ON e.uid = b.uid
106
+ WHERE e.event = 'quiz completed' AND dayofweek(e.t) NOT IN (0, 1)
107
+ GROUP BY 1 ORDER BY 1;
108
+ -- read: sweet/low ≈ 1.3; over/low ≈ 1.0 (placebo — 9+ scores untreated)
109
+
110
+ SELECT CASE WHEN notes <= 4 THEN 'low' WHEN notes BETWEEN 5 AND 8 THEN 'sweet' ELSE 'over' END AS bin,
111
+ count(*) AS users, round(sum(certs)::DOUBLE / nullif(sum(enrolls), 0), 4) AS certs_per_enroll
112
+ FROM per_user_u WHERE retained GROUP BY 1 ORDER BY 1;
113
+ -- read: over/sweet ≈ 0.70 (0.65 keep knob, diluted by sweet's bonus
114
+ -- certs); sweet/low ≈ 1.0 (flatness — activity coupling nets out)
115
+
116
+
117
+ -- ── H4-study-group-retention ────────────────────────────────
118
+ -- Non-early-joiners with ANY raw sub-60 quiz lose all events after
119
+ -- day 14 — near-deterministic at organic score mean ~40. Restrict to
120
+ -- users with >= 20d possible tenure.
121
+ SELECT early_join, count(*) AS users,
122
+ round(avg(retained::INT), 4) AS d14_activity,
123
+ round(avg(discussions), 2) AS discussions_per_user
124
+ FROM per_user_u WHERE first_t <= (SELECT max(t) - INTERVAL 20 DAY FROM ev)
125
+ GROUP BY 1 ORDER BY 1;
126
+ -- read: early_join ≈ 1.00 vs ≈ 0.01; discussions ratio ≈ 18x (churn
127
+ -- truncation + 60% single-clone bonus)
128
+
129
+
130
+ -- ── H5-hint-dependency ──────────────────────────────────────
131
+ -- hint → easy forced 60%; no-hint → hard forced 40%. Organic difficulty
132
+ -- pool is NOT uniform (easy 0.36 / med 0.29 / hard 0.35 measured), so
133
+ -- expected = knob + (1-knob) × organic share.
134
+ SELECT hint_used, count(*) AS n,
135
+ round(avg((difficulty = 'easy')::INT), 4) AS p_easy,
136
+ round(avg((difficulty = 'medium')::INT), 4) AS p_med,
137
+ round(avg((difficulty = 'hard')::INT), 4) AS p_hard
138
+ FROM ev WHERE event = 'practice problem solved'
139
+ GROUP BY 1 ORDER BY 1;
140
+ -- read: p_easy|hint ≈ 0.745; p_hard|no-hint ≈ 0.610
141
+
142
+
143
+ -- ── H6-semester-spike ───────────────────────────────────────
144
+ -- Days 75-84 from dataset start: quiz started / quiz completed /
145
+ -- assignment submitted duplicated at 80% → ~1.8x. Flanks 60-74 + 85-100.
146
+ WITH d AS (
147
+ SELECT date_diff('day', (SELECT min(t)::DATE FROM ev), t::DATE) AS day_idx,
148
+ event IN ('quiz started', 'quiz completed', 'assignment submitted') AS spikable
149
+ FROM ev
150
+ )
151
+ SELECT CASE WHEN day_idx BETWEEN 75 AND 84 THEN 'window'
152
+ WHEN day_idx BETWEEN 60 AND 74 OR day_idx BETWEEN 85 AND 100 THEN 'flank' END AS zone,
153
+ round(count(*) FILTER (WHERE spikable) / count(DISTINCT day_idx)::DOUBLE, 1) AS spikable_per_day,
154
+ round(count(*) FILTER (WHERE NOT spikable) / count(DISTINCT day_idx)::DOUBLE, 1) AS other_per_day
155
+ FROM d WHERE day_idx BETWEEN 60 AND 100 GROUP BY 1 ORDER BY 1;
156
+ -- read: spikable window/flank ≈ 1.9; other (placebo) ≈ 1.1 (organic
157
+ -- mid-dataset ramp)
158
+
159
+
160
+ -- ── H7-free-vs-paid ─────────────────────────────────────────
161
+ -- Cert-funnel conversion ×0.5 free / ×1.5 paid (funnel-pre), THEN free
162
+ -- loses 55% of certs (everything). Compound = 3 × 1/0.45 = 6.67x on the
163
+ -- funnel read; certs-per-enrollment lands ~6.0 (diluted by standalone
164
+ -- certs). annual vs monthly is the placebo (identical treatment; H9
165
+ -- moves cert TIMES, not counts).
166
+ SELECT subscription_status, count(*) AS users,
167
+ sum(certs) AS certs, sum(enrolls) AS enrolls,
168
+ round(sum(certs)::DOUBLE / nullif(sum(enrolls), 0), 4) AS certs_per_enroll
169
+ FROM per_user_u GROUP BY 1 ORDER BY 1;
170
+ -- read: monthly/free ≈ 6.0; annual/monthly ≈ 1.0-1.1 (placebo)
171
+
172
+
173
+ -- ── H8-playback-speed ───────────────────────────────────────
174
+ -- speed >= 2.0: watch ×0.6 (floor 3); speed <= 1.0: ×1.4 (cap 90, never
175
+ -- binds). Mid (1.25/1.5) untreated.
176
+ SELECT CASE WHEN playback_speed >= 2.0 THEN 'fast'
177
+ WHEN playback_speed <= 1.0 THEN 'slow' ELSE 'mid' END AS bucket,
178
+ count(*) AS n, round(avg(watch_time_mins), 2) AS avg_watch
179
+ FROM ev WHERE event = 'lecture completed' GROUP BY 1 ORDER BY 1;
180
+ -- read: fast/mid ≈ 0.59 (Math.floor costs ~2%); slow/mid ≈ 1.40
181
+
182
+ -- +8 quiz boost for 3+-fast-lecture users — read as a DIFFERENCE among
183
+ -- retained non-sweet-notes users on non-Sun/Mon quizzes (isolates H8
184
+ -- from H3 and H2).
185
+ WITH c AS (SELECT uid, fast_lex >= 3 AS speedy FROM per_user_u
186
+ WHERE retained AND notes NOT BETWEEN 5 AND 8)
187
+ SELECT c.speedy, count(DISTINCT e.uid) AS users, round(avg(e.score_percent), 2) AS avg_score
188
+ FROM ev e JOIN c ON e.uid = c.uid
189
+ WHERE e.event = 'quiz completed' AND dayofweek(e.t) NOT IN (0, 1)
190
+ GROUP BY 1 ORDER BY 1;
191
+ -- read: speedy − rest ≈ +8 to +9 pts
192
+
193
+
194
+ -- ── H9-completion-ttc ───────────────────────────────────────
195
+ -- Cert gap to nearest preceding enrollment rescaled: annual ×0.5, free
196
+ -- ×1.8 (monthly untouched). Cross-event proxy: first-enroll → first-cert
197
+ -- per user. The story's authoritative read is the emulator's 2-step
198
+ -- timeToConvert at 86.4h (see education.verify.mjs) — this SQL proxy is
199
+ -- directional only (first-cert pairing is not Mixpanel's greedy pick).
200
+ WITH fe AS (
201
+ SELECT uid, min(t) FILTER (WHERE event = 'course enrolled') AS first_enroll,
202
+ min(t) FILTER (WHERE event = 'certificate earned') AS first_cert
203
+ FROM ev GROUP BY 1
204
+ )
205
+ SELECT u.subscription_status, count(*) AS converters,
206
+ round(median(date_diff('minute', fe.first_enroll, fe.first_cert)) / 60.0, 2) AS median_ttc_h
207
+ FROM fe JOIN users u ON fe.uid = u.distinct_id::VARCHAR
208
+ WHERE fe.first_cert IS NOT NULL AND fe.first_cert > fe.first_enroll
209
+ GROUP BY 1 ORDER BY 2;
210
+ -- read: annual < monthly < free (direction; magnitudes compress because
211
+ -- first-pair TTC spans multiple enrollments)
212
+
213
+
214
+ -- ── H10-ai-study-buddy ──────────────────────────────────────
215
+ -- 'AI Study Buddy' A/B on Social Learning (last 30 days): conversion
216
+ -- ×1.4 generative (~1.37 observed after ~0.035 organic pollution), TTC ×0.85.
217
+ -- Strict pairing anchors at funnel ENTRY (first 'discussion posted' at/
218
+ -- after $experiment_started; the exp→entry lag is arm-dependent) with a
219
+ -- 12h conversion window and an interior 'study group joined'.
220
+ WITH exp AS (
221
+ SELECT uid, t, "Variant name" AS variant FROM ev WHERE event = '$experiment_started'
222
+ ),
223
+ a AS (
224
+ SELECT exp.uid, exp.variant, exp.t,
225
+ (SELECT min(x.t) FROM ev x WHERE x.uid = exp.uid AND x.event = 'discussion posted'
226
+ AND x.t >= exp.t - INTERVAL 1 MINUTE) AS s1
227
+ FROM exp
228
+ ),
229
+ c AS (
230
+ SELECT a.*, (
231
+ SELECT min(r.t) FROM ev r
232
+ WHERE r.uid = a.uid AND r.event = 'resource downloaded'
233
+ AND r.t > a.s1 AND r.t <= a.s1 + INTERVAL 12 HOUR
234
+ AND EXISTS (SELECT 1 FROM ev s WHERE s.uid = a.uid AND s.event = 'study group joined'
235
+ AND s.t > a.s1 AND s.t < r.t)
236
+ ) AS conv_t
237
+ FROM a WHERE a.s1 IS NOT NULL AND a.s1 <= a.t + INTERVAL 24 HOUR
238
+ )
239
+ SELECT variant, count(*) AS attempts, count(conv_t) AS conversions,
240
+ round(count(conv_t)::DOUBLE / count(*), 4) AS conv_rate,
241
+ round(median(date_diff('minute', s1, conv_t)) / 60.0, 2) AS median_ttc_h
242
+ FROM c GROUP BY 1 ORDER BY 1;
243
+ -- read: lift ≈ 1.37 (AI/Control); TTC ratio ≈ 0.85
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * education.verify.mjs — thin wrapper around the story runner.
4
+ *
5
+ * All verification logic lives in the `stories` export of ./education.js;
6
+ * this script just streams the shards and delegates. It is equivalent to:
7
+ *
8
+ * node scripts/verify-stories.mjs dungeons/vertical/education/education.js --data-prefix verify-education
9
+ *
10
+ * Generate first:
11
+ * node scripts/verify-runner.mjs dungeons/vertical/education/education.js verify-education
12
+ * Run:
13
+ * node dungeons/vertical/education/education.verify.mjs [prefix] # default verify-education
14
+ */
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import readline from 'node:readline';
18
+ import { execFile } from 'node:child_process';
19
+ import { promisify } from 'node:util';
20
+ import { buildIdentityMap, evaluateStories, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
21
+ import config, { stories } from './education.js';
22
+
23
+ const PREFIX = process.argv[2] || 'verify-education';
24
+ const prefixPath = path.join('data', PREFIX);
25
+
26
+ async function loadShards(suffix) {
27
+ // streaming load: full-fidelity EVENTS shards exceed the readFileSync cap
28
+ const dir = path.dirname(prefixPath), base = path.basename(prefixPath);
29
+ const out = [];
30
+ if (!fs.existsSync(dir)) return out;
31
+ for (const f of fs.readdirSync(dir).filter(f => f.startsWith(`${base}-${suffix}`) && f.endsWith('.json')).sort()) {
32
+ const rl = readline.createInterface({ input: fs.createReadStream(path.join(dir, f)), crlfDelay: Infinity });
33
+ for await (const line of rl) {
34
+ if (line.trim()) out.push(JSON.parse(line));
35
+ }
36
+ }
37
+ return out;
38
+ }
39
+
40
+ const events = await loadShards('EVENTS');
41
+ const profiles = await loadShards('USERS');
42
+ if (!events.length) {
43
+ console.error(`no shards at ${prefixPath}-EVENTS*.json — run: node scripts/verify-runner.mjs dungeons/vertical/education/education.js ${PREFIX}`);
44
+ process.exit(1);
45
+ }
46
+ console.log(`education — events=${events.length} users=${profiles.length} (${prefixPath})`);
47
+
48
+ const execFileP = promisify(execFile);
49
+ const runSql = async (sql) => {
50
+ const { stdout } = await execFileP('duckdb', ['-json', '-c', sql.replaceAll('{{PREFIX}}', prefixPath)], { maxBuffer: 64 * 1024 * 1024 });
51
+ return stdout.trim() ? JSON.parse(stdout) : [];
52
+ };
53
+
54
+ // funnels passed raw (unvalidated) — the emulator stories carry their own
55
+ // explicit conversion windows (H7/H9: 86.4h = 48h generative × the 1.8 free
56
+ // TTC stretch, on the 2-step enrolled→cert read; the 4-step doc funnel would
57
+ // break because H9's annual ×0.5 compression can move a cert before the
58
+ // interior quiz step). H10 pairs in SQL anchored at funnel ENTRY (first
59
+ // 'discussion posted' at/after $experiment_started, conversion within 12h of
60
+ // entry) because the exp→entry lag is arm-dependent.
61
+ const results = await evaluateStories(stories, events, {
62
+ profiles,
63
+ funnels: config.funnels,
64
+ identityMap: buildIdentityMap(profiles),
65
+ runSql,
66
+ });
67
+
68
+ let worst = 'NAILED';
69
+ for (const r of results) {
70
+ console.log(`${r.verdict.padEnd(7)} ${r.id}`);
71
+ for (const a of r.assertions) console.log(` ${a.verdict.padEnd(7)} ${a.detail}`);
72
+ if (VERDICT_RANK[r.verdict] < VERDICT_RANK[worst]) worst = r.verdict;
73
+ }
74
+ console.log(`\nworst verdict: ${worst}`);
75
+ process.exit(VERDICT_RANK[worst] >= VERDICT_RANK.STRONG ? 0 : 1);