@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.
- 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/powertools/SKILL.md +75 -0
- package/.claude/skills/powertools/pt.mjs +91 -0
- package/.claude/skills/powertools/snapshot-project.mjs +124 -0
- 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 +224 -0
- package/HOOKS.md +566 -112
- package/README.md +30 -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 +7 -3
- 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 +151 -59
- 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 +298 -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,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);
|