📦 EqualifyEverything / benchmarks-ai-alt

📄 export.mjs · 283 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283#!/usr/bin/env node
// Hand the reviewed items to the corpus-validation project for human review.
//
// This is the last step of construction and the first step of validation. It
// writes projects/corpus-validation/functional-images.jsonl, which that project's
// js/main.js reads directly.
//
// No images are copied. corpus-validation resolves an image as
// '../corpus-construction/' + item.image_file, and image_file is already
// pool/images/ID.ext relative to this project, so served from the repository root
// the path resolves on its own. Copying would mean two sets of bytes drifting
// apart.
//
// Only items that are `ready` with `alt_quality: good` are exported. An item
// whose shipped alt text is weak or wrong stays in the corpus as evidence but is
// never put in front of a person as though it were a reference.
//
// Usage:
//   node tools/export.mjs                  write the validation corpus
//   node tools/export.mjs --dry-run        report, write nothing
//   node tools/export.mjs --selftest       offline
//   --corpus FILE, --out FILE              work somewhere else
//
// Exit codes: 0 exported, 1 nothing to export, 2 refused, 3 bad usage or
// self-test failure.

import { readFileSync, writeFileSync, existsSync, mkdtempSync, mkdirSync,
  rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

const HERE = dirname(fileURLToPath(import.meta.url))
const PROJECT = resolve(HERE, '..')
const REPO = resolve(PROJECT, '..', '..')

// The prefix corpus-validation joins onto image_file. Hard-coded here on purpose:
// if that project changes it, this check should fail loudly rather than ship
// broken paths.
const VALIDATION_PREFIX = 'projects/corpus-construction/'

// Everything a person needs to judge the pair, and nothing about how we decided
// to show it to them. The first nine are what js/main.js reads.
const EXPORTED = [
  'id', 'image_file', 'image_url', 'element_html', 'observed_alt', 'page_url',
  'element_role', 'surrounding_text', 'image_coord_space',
  'domain', 'sector', 'implementation', 'category', 'subtype',
  'accessible_name', 'accessible_name_source', 'retrieved',
]

function readJsonl(path) {
  if (!existsSync(path)) return { rows: [], errors: [`${path}: not found`] }
  const rows = []
  const errors = []
  readFileSync(path, 'utf8').split('\n').forEach((raw, i) => {
    const line = raw.trim()
    if (line === '') return
    try { rows.push(JSON.parse(line)) } catch (e) {
      errors.push(`${path}:${i + 1}: invalid JSON, ${e.message}`)
    }
  })
  return { rows, errors }
}

export const exportable = (item) =>
  item.status === 'ready' && item.alt_quality === 'good'

export function shape(item) {
  const out = {}
  for (const field of EXPORTED) out[field] = item[field] ?? null
  return out
}

// Would corpus-validation actually find the bytes? Checked against the real
// filesystem, because a corpus of broken image paths reviews as a corpus of
// missing images.
export function checkImages(items, repoRoot) {
  const missing = []
  for (const item of items) {
    if (typeof item.image_file !== 'string' || item.image_file === '') {
      missing.push(`${item.id}: no image_file`)
      continue
    }
    const at = join(repoRoot, VALIDATION_PREFIX, item.image_file)
    if (!existsSync(at)) missing.push(`${item.id}: ${item.image_file} not on disk`)
  }
  return missing
}

// --- self-test -------------------------------------------------------------

function selftest() {
  let failures = 0
  const check = (name, cond, detail) => {
    if (cond) process.stdout.write(`PASS ${name}\n`)
    else { process.stdout.write(`FAIL ${name}: ${detail ?? ''}\n`); failures++ }
  }
  const item = (id, over = {}) => ({
    id, status: 'ready', alt_quality: 'good',
    image_file: `pool/images/${id}.png`, image_url: `https://e.example.com/${id}.png`,
    element_html: `<a href="/"><img src="${id}.png" alt="Home"></a>`,
    observed_alt: 'Home', page_url: 'https://e.example.com/', domain: 'e.example.com',
    element_role: 'link', surrounding_text: 'Home Products About',
    sector: 'commerce', implementation: 'img', category: 1,
    subtype: 'linked-standalone-logo', accessible_name: 'Home',
    accessible_name_source: 'alt', retrieved: '2026-08-27',
    review_verdict: 'keep', review_reason: 'Checked the archived image.',
    ...over,
  })

  check('a ready item with good alt is exported', exportable(item('fi-0001')))
  check('an unreviewed item is not exported',
    !exportable(item('fi-0001', { status: 'unreviewed', alt_quality: null })))
  check('a dropped item is not exported',
    !exportable(item('fi-0001', { status: 'dropped', alt_quality: 'wrong' })))
  check('a ready item with weak alt is not exported',
    !exportable(item('fi-0001', { alt_quality: 'weak' })))

  {
    const row = shape(item('fi-0001'))
    check('the exported record carries every field the reviewer renders',
      ['id', 'image_file', 'image_url', 'element_html', 'observed_alt',
        'page_url', 'element_role', 'surrounding_text', 'image_coord_space']
        .every((f) => row[f] !== undefined),
      JSON.stringify(Object.keys(row)))
    check('review bookkeeping is not exported',
      row.review_verdict === undefined && row.status === undefined &&
      row.alt_quality === undefined,
      JSON.stringify(Object.keys(row)))
  }
  {
    // An empty alt is a real value, not a missing one, and has to survive the
    // round trip as '' so the reviewer shows "(empty alt text)" rather than a
    // blank card.
    const row = shape(item('fi-0001', { observed_alt: '' }))
    check('an empty alt text survives as an empty string, not null',
      row.observed_alt === '', JSON.stringify(row.observed_alt))
  }
  {
    // The path join that the whole no-copy design rests on.
    const repo = mkdtempSync(join(tmpdir(), 'alt-export-'))
    const imagesDir = join(repo, VALIDATION_PREFIX, 'pool', 'images')
    mkdirSync(imagesDir, { recursive: true })
    writeFileSync(join(imagesDir, 'fi-0001.png'), 'bytes')
    check('an archived image resolves through the corpus-validation prefix',
      checkImages([item('fi-0001')], repo).length === 0,
      JSON.stringify(checkImages([item('fi-0001')], repo)))
    check('a missing image is reported rather than exported',
      checkImages([item('fi-0002')], repo).length === 1)
    rmSync(repo, { recursive: true, force: true })
  }
  {
    const dir = mkdtempSync(join(tmpdir(), 'alt-export-run-'))
    const corpusPath = join(dir, 'corpus.jsonl')
    const outPath = join(dir, 'out.jsonl')
    const imagesDir = join(dir, VALIDATION_PREFIX, 'pool', 'images')
    mkdirSync(imagesDir, { recursive: true })
    writeFileSync(join(imagesDir, 'fi-0001.png'), 'bytes')
    writeFileSync(corpusPath, [
      item('fi-0001'),
      item('fi-0002', { status: 'dropped', alt_quality: 'wrong' }),
    ].map((r) => JSON.stringify(r)).join('\n') + '\n')
    const rc = run(corpusPath, outPath, dir, false, () => {})
    const written = readFileSync(outPath, 'utf8').trim().split('\n')
    check('only the ready items reach the validation corpus',
      rc === 0 && written.length === 1 &&
      JSON.parse(written[0]).id === 'fi-0001',
      `rc ${rc}, ${written.length} line(s)`)
    rmSync(dir, { recursive: true, force: true })
  }
  {
    // Exporting an item whose bytes are gone would put an empty frame in front
    // of a person and ask them to judge alt text against it.
    const dir = mkdtempSync(join(tmpdir(), 'alt-export-gap-'))
    const corpusPath = join(dir, 'corpus.jsonl')
    const outPath = join(dir, 'out.jsonl')
    writeFileSync(corpusPath, JSON.stringify(item('fi-0001')) + '\n')
    const rc = run(corpusPath, outPath, dir, false, () => {})
    check('an item with no bytes on disk stops the export',
      rc === 2 && !existsSync(outPath), `rc ${rc}`)
    rmSync(dir, { recursive: true, force: true })
  }

  process.stdout.write(failures === 0
    ? '\nexport self-test passed\n'
    : `\nexport self-test failed, ${failures} case(s)\n`)
  return failures === 0 ? 0 : 3
}

// --- entry point -----------------------------------------------------------

function tally(items, field) {
  const counts = new Map()
  for (const item of items) counts.set(item[field], (counts.get(item[field]) ?? 0) + 1)
  return [...counts.entries()]
    .sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0])))
}

function run(corpusPath, outPath, repoRoot, dryRun, out) {
  const corpus = readJsonl(corpusPath)
  if (corpus.errors.length > 0) {
    out('refused to export:\n')
    for (const e of corpus.errors) out(`  ${e}\n`)
    return 2
  }

  const ready = corpus.rows.filter(exportable)
  const counts = {
    total: corpus.rows.length,
    unreviewed: corpus.rows.filter((r) => r.status === 'unreviewed').length,
    dropped: corpus.rows.filter((r) => r.status === 'dropped').length,
    held: corpus.rows.filter((r) => r.status === 'ready' &&
      r.alt_quality !== 'good').length,
  }
  out(`corpus:      ${counts.total} item(s)\n`)
  out(`unreviewed:  ${counts.unreviewed}\n`)
  out(`dropped:     ${counts.dropped}\n`)
  if (counts.held > 0) {
    out(`held back:   ${counts.held} ready but the shipped alt is not good\n`)
  }
  out(`exporting:   ${ready.length}\n`)

  if (ready.length === 0) {
    out('\nNothing is ready to export. Review a batch first: ' +
      './run.sh --prompt review\n')
    return 1
  }

  const missing = checkImages(ready, repoRoot)
  if (missing.length > 0) {
    out(`\nrefused to export, ${missing.length} item(s) have no archived ` +
      'image where corpus-validation looks for it. Nothing was written:\n')
    for (const m of missing) out(`  ${m}\n`)
    out('\nRun node tools/fetch-images.mjs, then export again.\n')
    return 2
  }

  out('\nby sector\n')
  for (const [k, n] of tally(ready, 'sector')) {
    out(`  ${String(n).padStart(4)}  ${k}\n`)
  }
  out('by sub-type\n')
  for (const [k, n] of tally(ready, 'subtype')) {
    out(`  ${String(n).padStart(4)}  ${k}\n`)
  }

  if (dryRun) {
    out('\nnothing written, --dry-run\n')
    return 0
  }
  mkdirSync(dirname(outPath), { recursive: true })
  writeFileSync(outPath, ready.map((r) => JSON.stringify(shape(r))).join('\n') + '\n')
  out(`\nwrote ${ready.length} item(s) to ` +
    `${outPath.replace(repoRoot + '/', '')}\n`)
  out('Serve the repository root over HTTP and open ' +
    'projects/corpus-validation/index.html to review them.\n')
  return 0
}

function main(argv) {
  let corpusPath = join(PROJECT, 'corpus', 'functional-images.jsonl')
  let outPath = join(REPO, 'projects', 'corpus-validation',
    'functional-images.jsonl')
  let dryRun = false

  for (let i = 0; i < argv.length; i++) {
    const arg = argv[i]
    if (arg === '--selftest') return selftest()
    else if (arg === '--dry-run') dryRun = true
    else if (arg === '--corpus') corpusPath = resolve(argv[++i] ?? '')
    else if (arg === '--out') outPath = resolve(argv[++i] ?? '')
    else {
      process.stderr.write(`export.mjs: unknown argument "${arg}"\n` +
        'usage: export.mjs [--dry-run] [--corpus FILE] [--out FILE] ' +
        '[--selftest]\n')
      return 3
    }
  }
  return run(corpusPath, outPath, REPO, dryRun, (s) => process.stdout.write(s))
}

process.exit(main(process.argv.slice(2)))