@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,173 @@
1
+ -- ============================================================
2
+ -- media.sql — human-eyeball inspection queries (v1.6)
3
+ -- ============================================================
4
+ -- Mirrors the story reads in media.js `stories`. Run after:
5
+ -- node scripts/verify-runner.mjs dungeons/vertical/media/media.js verify-media
6
+ --
7
+ -- Derivation notes:
8
+ -- - H1 is a completions-per-selection ratio-of-ratios vs ANIMATION:
9
+ -- animation absorbs the same H6 selection-count inflation as
10
+ -- documentary, and per-selection normalization cancels the
11
+ -- favored-index popularity skew (action/romance selections run hot).
12
+ -- Genre exists on completions only via core-viewing funnel props.
13
+ -- - H7 normalizes rated counts by total events pre/post day 60 —
14
+ -- cancels the user-growth ramp (1/0.7 = 1.43 mechanism).
15
+ -- - H8's clean read is completion_percent; the duration x1.15 is
16
+ -- cancelled by subtitle users skewing into the H9 over-bucket.
17
+ -- - H9 over/sweet = 0.5/1.25 = 0.40 exact — the engagement confound
18
+ -- cancels between the two high-activity buckets.
19
+ -- - H10's TTC leg is NOT visible in cross-event SQL; see the emulator
20
+ -- timeToConvert assertion in media.js stories (6-HOUR window — at
21
+ -- multi-day windows the median sits on a bimodal mode boundary and
22
+ -- flips on sampling noise).
23
+
24
+ -- Hook 1: GENRE FUNNEL CONVERSION — doc/anim comp-per-sel RoR ~0.75
25
+ WITH g AS (
26
+ SELECT genre,
27
+ COUNT(*) FILTER (WHERE event = 'content selected') AS sel_n,
28
+ COUNT(*) FILTER (WHERE event = 'playback completed') AS comp_n
29
+ FROM read_json_auto('data/verify-media-EVENTS*.json', sample_size=-1, union_by_name=true)
30
+ WHERE genre IS NOT NULL AND event IN ('content selected', 'playback completed')
31
+ GROUP BY genre
32
+ )
33
+ SELECT genre, sel_n, comp_n, ROUND(comp_n::DOUBLE / sel_n, 4) AS comp_per_sel
34
+ FROM g ORDER BY comp_per_sel;
35
+
36
+ -- Hook 2: BINGE-WATCHING — binge cohort (max completed-streak >= 3,
37
+ -- started ignored) ~1.6x completions per user
38
+ WITH seq AS (
39
+ SELECT user_id::VARCHAR AS uid, event,
40
+ ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY time::TIMESTAMP, event) AS rn
41
+ FROM read_json_auto('data/verify-media-EVENTS*.json', sample_size=-1, union_by_name=true)
42
+ WHERE event != 'playback started'
43
+ ), runs AS (
44
+ SELECT uid, rn - ROW_NUMBER() OVER (PARTITION BY uid ORDER BY rn) AS grp
45
+ FROM seq WHERE event = 'playback completed'
46
+ ), streaks AS (
47
+ SELECT uid, MAX(cnt) AS max_streak
48
+ FROM (SELECT uid, grp, COUNT(*) AS cnt FROM runs GROUP BY uid, grp) GROUP BY uid
49
+ ), pu AS (
50
+ SELECT user_id::VARCHAR AS uid,
51
+ COUNT(*) FILTER (WHERE event = 'playback completed') AS completions
52
+ FROM read_json_auto('data/verify-media-EVENTS*.json', sample_size=-1, union_by_name=true)
53
+ GROUP BY 1
54
+ )
55
+ SELECT COALESCE(s.max_streak >= 3, false) AS binge, COUNT(*) AS users,
56
+ ROUND(AVG(p.completions), 2) AS completions_pu
57
+ FROM pu p LEFT JOIN streaks s USING (uid)
58
+ GROUP BY (COALESCE(s.max_streak >= 3, false)) ORDER BY binge;
59
+
60
+ -- Hook 3: WEEKEND VS WEEKDAY — ~1.45x avg duration (1.5 engineered,
61
+ -- diluted by fresh-draw clone durations)
62
+ SELECT
63
+ CASE WHEN dayofweek(time::TIMESTAMP) IN (0, 6) THEN 'weekend' ELSE 'weekday' END AS bucket,
64
+ COUNT(*) AS n, ROUND(AVG(watch_duration_min), 1) AS avg_min
65
+ FROM read_json_auto('data/verify-media-EVENTS*.json', sample_size=-1, union_by_name=true)
66
+ WHERE event = 'playback completed' AND watch_duration_min IS NOT NULL
67
+ GROUP BY (CASE WHEN dayofweek(time::TIMESTAMP) IN (0, 6) THEN 'weekend' ELSE 'weekday' END);
68
+
69
+ -- Hook 4: AD FATIGUE CHURN — fatigued post/pre ~0.04x rest (among users
70
+ -- whose lifecycle spans past day 52; late-born users dilute otherwise)
71
+ WITH pu AS (
72
+ SELECT user_id::VARCHAR AS uid, MIN(time::TIMESTAMP) AS t0, MAX(time::TIMESTAMP) AS tlast
73
+ FROM read_json_auto('data/verify-media-EVENTS*.json', sample_size=-1, union_by_name=true)
74
+ GROUP BY 1
75
+ ), flags AS (
76
+ SELECT p.uid,
77
+ COUNT(*) FILTER (WHERE e.event = 'ad impression' AND e.time::TIMESTAMP < p.t0 + INTERVAL 45 DAY) AS early_ads,
78
+ COUNT(*) FILTER (WHERE e.time::TIMESTAMP > p.t0 + INTERVAL 45 DAY) AS post,
79
+ COUNT(*) FILTER (WHERE e.time::TIMESTAMP <= p.t0 + INTERVAL 45 DAY) AS pre
80
+ FROM read_json_auto('data/verify-media-EVENTS*.json', sample_size=-1, union_by_name=true) e
81
+ JOIN pu p ON e.user_id::VARCHAR = p.uid
82
+ WHERE p.tlast > p.t0 + INTERVAL 52 DAY
83
+ GROUP BY 1
84
+ )
85
+ SELECT (early_ads >= 5) AS fatigued, COUNT(*) AS users,
86
+ ROUND(AVG(post::DOUBLE / NULLIF(pre, 0)), 4) AS avg_post_pre
87
+ FROM flags WHERE pre > 0
88
+ GROUP BY (early_ads >= 5) ORDER BY fatigued;
89
+
90
+ -- Hook 5: NEW RELEASE SPIKE — 20% blockbuster share of selections in
91
+ -- days 50-64, zero outside; blockbuster ratings pinned 4-5
92
+ SELECT (day_idx BETWEEN 50 AND 64) AS in_window, COUNT(*) AS n,
93
+ ROUND(AVG((content_id LIKE 'blockbuster%')::INT), 4) AS bb_share
94
+ FROM (
95
+ SELECT content_id,
96
+ date_diff('day', TIMESTAMP '2026-01-01 00:00:00', time::TIMESTAMP) AS day_idx
97
+ FROM read_json_auto('data/verify-media-EVENTS*.json', sample_size=-1, union_by_name=true)
98
+ WHERE event = 'content selected'
99
+ )
100
+ GROUP BY (day_idx BETWEEN 50 AND 64) ORDER BY in_window;
101
+
102
+ SELECT COUNT(*) AS bb_rated, MIN(rating) AS min_rating, MAX(rating) AS max_rating
103
+ FROM read_json_auto('data/verify-media-EVENTS*.json', sample_size=-1, union_by_name=true)
104
+ WHERE event = 'content rated' AND content_id LIKE 'blockbuster%';
105
+
106
+ -- Hook 6: KIDS PROFILE SAFETY — anim+doc ~32% of selected (vs ~20% base).
107
+ -- Read content selected ONLY: playback started carries genre solely via
108
+ -- funnel-2 props or this hook's stamp (its genre subset reads ~48%).
109
+ SELECT COUNT(*) AS n,
110
+ ROUND(AVG((genre IN ('animation', 'documentary'))::INT), 4) AS kid_share
111
+ FROM read_json_auto('data/verify-media-EVENTS*.json', sample_size=-1, union_by_name=true)
112
+ WHERE event = 'content selected' AND genre IS NOT NULL;
113
+
114
+ -- Hook 7: REC ENGINE IMPROVEMENT — post/pre rated-share-of-events ~1.43x
115
+ WITH ev AS (
116
+ SELECT event,
117
+ date_diff('day', TIMESTAMP '2026-01-01 00:00:00', time::TIMESTAMP) < 60 AS pre
118
+ FROM read_json_auto('data/verify-media-EVENTS*.json', sample_size=-1, union_by_name=true)
119
+ )
120
+ SELECT
121
+ COUNT(*) FILTER (WHERE pre AND event = 'content rated') AS pre_rated,
122
+ COUNT(*) FILTER (WHERE NOT pre AND event = 'content rated') AS post_rated,
123
+ ROUND((COUNT(*) FILTER (WHERE NOT pre AND event = 'content rated')::DOUBLE / COUNT(*) FILTER (WHERE NOT pre))
124
+ / (COUNT(*) FILTER (WHERE pre AND event = 'content rated')::DOUBLE / COUNT(*) FILTER (WHERE pre)), 4) AS share_ratio
125
+ FROM ev;
126
+
127
+ -- Hook 8: SUBTITLE USERS — completion_percent ~1.28x (the clean read)
128
+ WITH pu AS (
129
+ SELECT user_id::VARCHAR AS uid,
130
+ BOOL_OR(event = 'subtitle toggled' AND action = 'enabled') AS subs
131
+ FROM read_json_auto('data/verify-media-EVENTS*.json', sample_size=-1, union_by_name=true)
132
+ GROUP BY 1
133
+ )
134
+ SELECT p.subs, COUNT(DISTINCT p.uid) AS users,
135
+ ROUND(AVG(e.completion_percent), 2) AS avg_cp
136
+ FROM read_json_auto('data/verify-media-EVENTS*.json', sample_size=-1, union_by_name=true) e
137
+ JOIN pu p ON e.user_id::VARCHAR = p.uid
138
+ WHERE e.event = 'playback completed' AND e.completion_percent IS NOT NULL
139
+ GROUP BY p.subs ORDER BY p.subs;
140
+
141
+ -- Hook 9: REC-CLICK MAGIC NUMBER — sweet/low ~1.25x, over/sweet ~0.40x
142
+ WITH pu AS (
143
+ SELECT user_id::VARCHAR AS uid,
144
+ COUNT(*) FILTER (WHERE event = 'recommendation clicked') AS rcs
145
+ FROM read_json_auto('data/verify-media-EVENTS*.json', sample_size=-1, union_by_name=true)
146
+ GROUP BY 1
147
+ )
148
+ SELECT
149
+ CASE WHEN p.rcs BETWEEN 4 AND 6 THEN 'sweet' WHEN p.rcs >= 7 THEN 'over' ELSE 'low' END AS bucket,
150
+ COUNT(DISTINCT p.uid) AS users, ROUND(AVG(e.watch_duration_min), 1) AS avg_dur
151
+ FROM read_json_auto('data/verify-media-EVENTS*.json', sample_size=-1, union_by_name=true) e
152
+ JOIN pu p ON e.user_id::VARCHAR = p.uid
153
+ WHERE e.event = 'playback completed' AND e.watch_duration_min IS NOT NULL
154
+ GROUP BY (CASE WHEN p.rcs BETWEEN 4 AND 6 THEN 'sweet' WHEN p.rcs >= 7 THEN 'over' ELSE 'low' END)
155
+ ORDER BY bucket;
156
+
157
+ -- Hook 10: CORE VIEWING LOOP — free/premium avg duration ~2.0x. The TTC
158
+ -- leg is not visible here: scaleFunnelTTC rewrites gaps within one funnel
159
+ -- instance per user; see the emulator timeToConvert assertion in media.js
160
+ -- stories (6-hour conversion window).
161
+ SELECT subscription_plan, COUNT(*) AS n, ROUND(AVG(watch_duration_min), 1) AS avg_dur
162
+ FROM read_json_auto('data/verify-media-EVENTS*.json', sample_size=-1, union_by_name=true)
163
+ WHERE event = 'playback completed' AND watch_duration_min IS NOT NULL AND subscription_plan IS NOT NULL
164
+ GROUP BY subscription_plan ORDER BY avg_dur;
165
+
166
+ -- Identity invariants: uid coverage 1.0, device coverage ~0.998,
167
+ -- devices/user ~2 (avgDevicePerUser: 2), plan stamped on all events
168
+ SELECT
169
+ ROUND(AVG((user_id IS NOT NULL)::INT), 4) AS uid_share,
170
+ ROUND(AVG((device_id IS NOT NULL)::INT), 4) AS device_share,
171
+ ROUND(AVG((subscription_plan IS NOT NULL)::INT), 4) AS plan_share,
172
+ ROUND(COUNT(DISTINCT device_id)::DOUBLE / COUNT(DISTINCT user_id), 2) AS devices_per_user
173
+ FROM read_json_auto('data/verify-media-EVENTS*.json', sample_size=-1, union_by_name=true);
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * media.verify.mjs — thin wrapper around the story runner.
4
+ *
5
+ * All verification logic lives in the `stories` export of
6
+ * ./media.js; this script just streams the shards and
7
+ * delegates. It is equivalent to:
8
+ *
9
+ * node scripts/verify-stories.mjs dungeons/vertical/media/media.js --data-prefix verify-media
10
+ *
11
+ * Generate first:
12
+ * node scripts/verify-runner.mjs dungeons/vertical/media/media.js verify-media
13
+ * Run:
14
+ * node dungeons/vertical/media/media.verify.mjs [prefix] # default verify-media
15
+ */
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import readline from 'node:readline';
19
+ import { execFile } from 'node:child_process';
20
+ import { promisify } from 'node:util';
21
+ import { buildIdentityMap, evaluateStories, VERDICT_RANK } from '@ak--47/dungeon-master/verify';
22
+ import config, { stories } from './media.js';
23
+
24
+ const PREFIX = process.argv[2] || 'verify-media';
25
+ const prefixPath = path.join('data', PREFIX);
26
+
27
+ async function loadShards(suffix) {
28
+ // streaming load: full-fidelity EVENTS shards exceed the readFileSync cap
29
+ const dir = path.dirname(prefixPath), base = path.basename(prefixPath);
30
+ const out = [];
31
+ if (!fs.existsSync(dir)) return out;
32
+ for (const f of fs.readdirSync(dir).filter(f => f.startsWith(`${base}-${suffix}`) && f.endsWith('.json')).sort()) {
33
+ const rl = readline.createInterface({ input: fs.createReadStream(path.join(dir, f)), crlfDelay: Infinity });
34
+ for await (const line of rl) {
35
+ if (line.trim()) out.push(JSON.parse(line));
36
+ }
37
+ }
38
+ return out;
39
+ }
40
+
41
+ const events = await loadShards('EVENTS');
42
+ const profiles = await loadShards('USERS');
43
+ if (!events.length) {
44
+ console.error(`no shards at ${prefixPath}-EVENTS*.json — run: node scripts/verify-runner.mjs dungeons/vertical/media/media.js ${PREFIX}`);
45
+ process.exit(1);
46
+ }
47
+ console.log(`media — events=${events.length} users=${profiles.length} (${prefixPath})`);
48
+
49
+ const execFileP = promisify(execFile);
50
+ const runSql = async (sql) => {
51
+ const { stdout } = await execFileP('duckdb', ['-json', '-c', sql.replaceAll('{{PREFIX}}', prefixPath)], { maxBuffer: 64 * 1024 * 1024 });
52
+ return stdout.trim() ? JSON.parse(stdout) : [];
53
+ };
54
+
55
+ // Nine stories are DuckDB-backed (behavioral cohorts + ratio-of-ratios reads
56
+ // that cancel popularity/H6/growth confounds); H10 also dispatches to
57
+ // emulateBreakdown's timeToConvert at a 6-hour window — at multi-day
58
+ // windows the median TTC sits on a bimodal mode boundary (within-session
59
+ // vs cross-session) and flips on sampling noise.
60
+ const results = await evaluateStories(stories, events, {
61
+ profiles,
62
+ funnels: config.funnels,
63
+ identityMap: buildIdentityMap(profiles),
64
+ runSql,
65
+ });
66
+
67
+ let worst = 'NAILED';
68
+ for (const r of results) {
69
+ console.log(`${r.verdict.padEnd(7)} ${r.id}`);
70
+ for (const a of r.assertions) console.log(` ${a.verdict.padEnd(7)} ${a.detail}`);
71
+ if (VERDICT_RANK[r.verdict] < VERDICT_RANK[worst]) worst = r.verdict;
72
+ }
73
+ console.log(`\nworst verdict: ${worst}`);
74
+ process.exit(VERDICT_RANK[worst] >= VERDICT_RANK.STRONG ? 0 : 1);