ONNX
English
vons
research
candidate-selection
File size: 26,926 Bytes
49ad2ef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
"""Import the pinned official Mind2Web held-out tasks for local evaluation only.

Outputs are restricted: rows.jsonl contains labels; retriever.jsonl contains
only pre-action inputs and deterministic lexical ranks. Nothing executes a
browser action. Current operation/value and current/future action_reprs are
never model inputs. The history is the official, teacher-forced past history.

Example (large extraction/normalization requires an available compute window)::

    python tools/import_mind2web.py --input-dir data/restricted/mind2web/extracted \
        --split all --output-dir data/restricted/mind2web/normalized \
        --source-manifest data/restricted/mind2web/acquisition-manifest.json \
        --extract-archive data/restricted/mind2web/acquisition/test.zip

Reusing an extracted corpus omits --extract-archive. The source manifest must
be the acquisition record for the pinned ZIP; file hashes are filled during
bounded extraction. Existing outputs are never overwritten.
"""

from __future__ import annotations

import argparse
import hashlib
import json
import math
import re
import shutil
import stat
import tempfile
import zipfile
from collections import Counter
from collections.abc import Iterator, Mapping, Sequence
from pathlib import Path, PurePosixPath
from typing import TextIO
from xml.etree.ElementTree import ParseError, XMLParser

DATASET_REVISION = "17ece8eb89862368edc0cc806acee6fca5163474"
ARCHIVE_SHA256 = "8f5fbe72afab942fe97cdf7fb397e179885d89b5c16862288e9a14bc6d41ca89"
ARCHIVE_BYTES = 567745122
OFFICIAL_TASK_COUNTS = {"test_task": 252, "test_website": 177, "test_domain": 912}
OFFICIAL_FILE_COUNTS = {"test_task": 3, "test_website": 2, "test_domain": 10}
SPLITS = tuple(OFFICIAL_TASK_COUNTS)
MAX_ARCHIVE_BYTES = 1024**3
MAX_MEMBER_BYTES = 1024**3
MAX_EXTRACTED_BYTES = 8 * 1024**3
# Some official tasks contain over 128 Mi characters, mostly unused raw_html.
# A task cannot exceed its verified member's byte bound; do not cap it below that.
MAX_TASK_CHARS = MAX_MEMBER_BYTES
CHUNK_SIZE = 1024**2
# Allowlisted observable DOM attributes exclude annotation flags by construction.
TEXT_ATTRIBUTES = frozenset(
    {
        "alt",
        "aria_description",
        "aria_label",
        "aria_role",
        "class",
        "href",
        "id",
        "input_checked",
        "input_value",
        "label",
        "name",
        "option_selected",
        "placeholder",
        "role",
        "text_value",
        "title",
        "type",
        "value",
    }
)


def _object(value: object, label: str) -> Mapping[str, object]:
    if not isinstance(value, dict):
        raise TypeError(f"{label} must be an object")
    return value


def _array(value: object, label: str) -> list[object]:
    if not isinstance(value, list):
        raise TypeError(f"{label} must be an array")
    return value


def _text(value: object, label: str, *, allow_empty: bool = False) -> str:
    if not isinstance(value, str) or (not value.strip() and not allow_empty):
        raise ValueError(f"{label} must be a string")
    return value


def _identifier(value: object, label: str) -> str:
    value = _text(value, label)
    if not re.fullmatch(r"[A-Za-z0-9_.-]+", value):
        raise ValueError(f"{label} contains unsupported identifier characters")
    return value


def _canonical(value: object) -> str:
    return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))


def _sha256(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        while chunk := handle.read(CHUNK_SIZE):
            digest.update(chunk)
    return digest.hexdigest()


def _json_hash(value: object) -> str:
    return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()


def _clean(value: str) -> str:
    return " ".join(value.split())


def _attributes(value: Mapping[str, object]) -> dict[str, str]:
    result: dict[str, str] = {}
    for name, raw in value.items():
        name = name.lower().replace("-", "_")
        if name in TEXT_ATTRIBUTES and isinstance(raw, (str, bool, int, float)):
            cleaned = _clean(str(raw))
            if cleaned:
                result[name] = cleaned
    return result


class _CandidateDOM:
    """XML parser target for pre-action DOM nodes, including iframe descendants.

    The official cleaned DOM is XML serialization. Browser HTML parsing treats
    iframe contents as raw text and silently loses released candidate nodes.
    """

    def __init__(self, candidate_ids: set[str]) -> None:
        self.candidate_ids = candidate_ids
        self.stack: list[tuple[str, str | None]] = []
        self.nodes: dict[str, tuple[str, dict[str, str], list[str]]] = {}

    def start(self, tag: str, attrs: dict[str, str]) -> None:
        self._boundary()
        node_id = attrs.get("backend_node_id")
        candidate_id = node_id if node_id in self.candidate_ids else None
        if candidate_id is not None:
            if candidate_id in self.nodes:
                raise ValueError("duplicate candidate node id in cleaned DOM")
            self.nodes[candidate_id] = (tag, _attributes(attrs), [])
        self.stack.append((tag, candidate_id))

    def end(self, tag: str) -> None:
        if self.stack[-1][0] != tag:
            raise ValueError("unbalanced cleaned DOM")
        self._boundary()
        self.stack.pop()

    def _boundary(self) -> None:
        for _, candidate_id in self.stack:
            if candidate_id is not None:
                self.nodes[candidate_id][2].append(" ")

    def data(self, data: str) -> None:
        if any(tag in {"script", "style"} for tag, _ in self.stack):
            return
        if data:
            for _, candidate_id in self.stack:
                if candidate_id is not None:
                    self.nodes[candidate_id][2].append(data)

    def close(self) -> None:
        return None


def candidate_universe(action: Mapping[str, object]) -> list[dict[str, str]]:
    """Merge the released pool without exposing annotation membership or order."""
    candidates: dict[str, Mapping[str, object]] = {}
    for field in ("pos_candidates", "neg_candidates"):
        for raw in _array(action.get(field), field):
            candidate = _object(raw, "candidate")
            node_id = _identifier(candidate.get("backend_node_id"), "backend_node_id")
            if node_id in candidates:
                raise ValueError("duplicate candidate id in released pool")
            candidates[node_id] = candidate
    cleaned_html = _text(action.get("cleaned_html"), "cleaned_html", allow_empty=True)
    dom = _CandidateDOM(set(candidates))
    if cleaned_html:
        parser = XMLParser(target=dom)
        try:
            parser.feed(cleaned_html)
            parser.close()
        except ParseError:
            raise ValueError("cleaned DOM is not valid XML serialization") from None
    if set(dom.nodes) != set(candidates):
        raise ValueError("released candidate missing from cleaned DOM")
    result: list[dict[str, str]] = []
    for node_id in sorted(candidates):
        tag, dom_attributes, content = dom.nodes[node_id]
        raw_attributes = candidates[node_id].get("attributes", "{}")
        if isinstance(raw_attributes, str):
            try:
                raw_attributes = json.loads(raw_attributes)
            except json.JSONDecodeError:
                raise ValueError("invalid candidate attributes JSON") from None
        attributes = _attributes(_object(raw_attributes, "candidate attributes"))
        attributes.update(dom_attributes)
        parts = [tag, *(f"{key}={attributes[key]}" for key in sorted(attributes)), "".join(content)]
        result.append({"id": node_id, "text": _clean(" ".join(parts))})
    return result


def lexical_rank(query: str, candidates: Sequence[Mapping[str, str]]) -> list[dict[str, object]]:
    """Deterministic BM25 (k1=1.2, b=0.75); the interface has no labels."""
    ids = [candidate["id"] for candidate in candidates]
    if len(ids) != len(set(ids)):
        raise ValueError("lexical candidates must have unique ids")
    query_terms = set(re.findall(r"\w+", query.casefold()))
    terms = [Counter(re.findall(r"\w+", candidate["text"].casefold())) for candidate in candidates]
    lengths = [sum(row.values()) for row in terms]
    average_length = sum(lengths) / len(lengths) if lengths else 1.0
    document_frequency: Counter[str] = Counter()
    for row in terms:
        document_frequency.update(set(row))
    ranked: list[dict[str, object]] = []
    for candidate, counts, length in zip(candidates, terms, lengths):
        score = 0.0
        for term in sorted(query_terms):
            frequency = counts[term]
            if frequency:
                inverse_frequency = math.log1p(
                    (len(candidates) - document_frequency[term] + 0.5)
                    / (document_frequency[term] + 0.5)
                )
                denominator = frequency + 1.2 * (0.25 + 0.75 * length / average_length)
                score += inverse_frequency * frequency * 2.2 / denominator
        ranked.append({"id": candidate["id"], "text": candidate["text"], "score": score})
    ranked.sort(key=lambda row: (-float(row["score"]), str(row["id"])))
    return [{**row, "rank": rank} for rank, row in enumerate(ranked, start=1)]


def normalize_task(
    task: Mapping[str, object],
    split: str,
) -> Iterator[tuple[dict[str, object], dict[str, object]]]:
    if split not in SPLITS:
        raise ValueError("only official held-out splits are supported")
    if "split" in task and task["split"] != split:
        raise ValueError("task split disagrees with official source file")
    task_id = _identifier(task.get("annotation_id"), "annotation_id")
    website = _text(task.get("website"), "website")
    domain = _text(task.get("domain"), "domain")
    goal = _text(task.get("confirmed_task"), "confirmed_task")
    actions = _array(task.get("actions"), "actions")
    history = _array(task.get("action_reprs"), "action_reprs")
    if not actions or len(history) != len(actions):
        raise ValueError("actions and action_reprs must be nonempty and aligned")
    seen_actions: set[str] = set()
    for index, raw_action in enumerate(actions):
        action = _object(raw_action, "action")
        action_id = _identifier(action.get("action_uid"), "action_uid")
        if action_id in seen_actions:
            raise ValueError("duplicate action id within task")
        seen_actions.add(action_id)
        candidates = candidate_universe(action)
        previous_actions = [_text(item, "past action_repr") for item in history[:index]]
        state = {"goal": goal, "previous_actions": previous_actions}
        query = goal + (
            "\nPrevious actions:\n" + "\n".join(previous_actions) if previous_actions else ""
        )
        ranked = lexical_rank(query, candidates)
        identity = {
            "id": f"{task_id}:{action_id}",
            "task_id": task_id,
            "action_id": action_id,
            "split": split,
            "website": website,
            "domain": domain,
        }
        # Membership is consulted only after label-blind inputs/ranks are complete.
        positive_ids = sorted(
            _identifier(
                _object(item, "positive candidate").get("backend_node_id"), "backend_node_id"
            )
            for item in _array(action.get("pos_candidates"), "pos_candidates")
        )
        labels = {
            **identity,
            "candidate_ids": [item["id"] for item in candidates],
            "positive_ids": positive_ids,
            "no_positive": not positive_ids,
        }
        retrieval = {
            **identity,
            "state": state,
            "query": query,
            "candidates": ranked,
            "generated_candidates": [item["id"] for item in ranked],
            "input_sha256": _json_hash({"state": state, "candidates": candidates}),
        }
        yield labels, retrieval


def iter_tasks(handle: TextIO) -> Iterator[Mapping[str, object]]:
    """Decode one task at a time from an official JSON array with a size bound."""
    decoder = json.JSONDecoder()
    buffer = ""
    eof = False

    def more(read_size: int = CHUNK_SIZE) -> bool:
        nonlocal buffer, eof
        chunk = handle.read(read_size)
        eof = not chunk
        buffer += chunk
        if len(buffer) > MAX_TASK_CHARS:
            raise ValueError("task JSON exceeds configured buffer bound")
        return bool(chunk)

    def strip_space() -> None:
        nonlocal buffer
        buffer = buffer.lstrip()
        while not buffer and not eof:
            more()
            buffer = buffer.lstrip()

    strip_space()
    if not buffer.startswith("["):
        raise ValueError("official task file must be a JSON array")
    buffer = buffer[1:]
    strip_space()
    if buffer.startswith("]"):
        buffer = buffer[1:]
    else:
        while True:
            read_size = CHUNK_SIZE
            while True:
                try:
                    task, end = decoder.raw_decode(buffer)
                    break
                except json.JSONDecodeError:
                    if eof or not more(read_size):
                        raise ValueError("invalid or truncated task JSON") from None
                    # Re-decoding an incomplete large task every MiB is quadratic.
                    read_size = min(read_size * 2, MAX_TASK_CHARS)
            yield _object(task, "task")
            buffer = buffer[end:]
            strip_space()
            if buffer.startswith("]"):
                buffer = buffer[1:]
                break
            if not buffer.startswith(","):
                raise ValueError("invalid task array separator")
            buffer = buffer[1:]
            strip_space()
    strip_space()
    if buffer:
        raise ValueError("trailing content after task array")


def _member_split(name: str) -> str:
    path = PurePosixPath(name)
    if (
        path.is_absolute()
        or len(path.parts) != 2
        or "\\" in name
        or ".." in path.parts
        or path.as_posix() != name
    ):
        raise ValueError("unsafe or unexpected archive member path")
    split = path.parts[0]
    if split not in SPLITS or not re.fullmatch(rf"{split}_\d+\.json", path.name):
        raise ValueError("archive member has unknown split or filename")
    return split


def _validate_manifest(manifest: Mapping[str, object]) -> Mapping[str, object]:
    archive = _object(manifest.get("archive"), "archive")
    if (
        manifest.get("schema") != "vons.mind2web-acquisition/v1"
        or manifest.get("dataset") != "osunlp/Mind2Web"
        or manifest.get("dataset_revision") != DATASET_REVISION
        or archive.get("sha256") != ARCHIVE_SHA256
        or archive.get("bytes") != ARCHIVE_BYTES
        or archive.get("status") != "downloaded-and-hash-verified"
    ):
        raise ValueError("source manifest does not match the pinned official test archive")
    restrictions = _object(manifest.get("restrictions"), "restrictions")
    if (
        manifest.get("usage") != "evaluation-only"
        or restrictions.get("no_training") is not True
        or restrictions.get("no_unzipped_redistribution") is not True
    ):
        raise ValueError("source manifest must preserve evaluation-only usage restrictions")
    return archive


def validate_archive_members(infos: Sequence[zipfile.ZipInfo]) -> None:
    expected_names = {
        f"{split}/{split}_{index}.json"
        for split, count in OFFICIAL_FILE_COUNTS.items()
        for index in range(count)
    }
    names: set[str] = set()
    total = 0
    for info in infos:
        _member_split(info.filename)
        mode = info.external_attr >> 16
        if info.is_dir() or stat.S_ISLNK(mode):
            raise ValueError("archive contains a directory or symlink")
        if info.filename in names:
            raise ValueError("archive contains a duplicate member")
        names.add(info.filename)
        if not 0 < info.file_size <= MAX_MEMBER_BYTES:
            raise ValueError("archive member exceeds extraction size bound")
        total += info.file_size
    if names != expected_names or total > MAX_EXTRACTED_BYTES:
        raise ValueError("archive member set or total size differs from official bounds")


def extract_official_archive(archive_path: Path, input_dir: Path, manifest_path: Path) -> None:
    manifest = dict(_object(json.loads(manifest_path.read_text()), "source manifest"))
    archive = _validate_manifest(manifest)
    if archive_path.stat().st_size > MAX_ARCHIVE_BYTES:
        raise ValueError("archive exceeds download bound")
    if (
        archive_path.stat().st_size != archive["bytes"]
        or _sha256(archive_path) != archive["sha256"]
    ):
        raise ValueError("archive failed pinned size/hash verification")
    if input_dir.exists():
        raise ValueError("refusing to overwrite existing extraction directory")
    input_dir.parent.mkdir(parents=True, exist_ok=True)
    with zipfile.ZipFile(archive_path) as source:
        infos = sorted(source.infolist(), key=lambda info: info.filename)
        validate_archive_members(infos)
        if shutil.disk_usage(input_dir.parent).free < sum(info.file_size for info in infos):
            raise ValueError("insufficient free disk for bounded extraction")
        with tempfile.TemporaryDirectory(prefix=".mind2web-extract-", dir=input_dir.parent) as temp:
            staging = Path(temp) / "data"
            staging.mkdir()
            records: list[dict[str, object]] = []
            for info in infos:
                path = staging / info.filename
                path.parent.mkdir(parents=True, exist_ok=True)
                digest = hashlib.sha256()
                size = 0
                with source.open(info, pwd=b"mind2web") as reader, path.open("xb") as writer:
                    while chunk := reader.read(CHUNK_SIZE):
                        size += len(chunk)
                        if size > info.file_size:
                            raise ValueError("decompressed member exceeded declared size")
                        writer.write(chunk)
                        digest.update(chunk)
                if size != info.file_size:
                    raise ValueError("decompressed member size mismatch")
                records.append(
                    {
                        "path": info.filename,
                        "split": _member_split(info.filename),
                        "bytes": size,
                        "sha256": digest.hexdigest(),
                    }
                )
            staging.rename(input_dir)
    manifest["files"] = records
    manifest["extraction"] = {
        "status": "complete",
        "file_count": len(records),
        "uncompressed_bytes": sum(int(row["bytes"]) for row in records),
    }
    manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")


def import_split(
    input_dir: Path,
    splits: Sequence[str],
    output_dir: Path,
    manifest_path: Path,
) -> dict[str, object]:
    if (
        not splits
        or len(splits) != len(set(splits))
        or any(split not in SPLITS for split in splits)
    ):
        raise ValueError("split selection must contain distinct official held-out split names")
    manifest = _object(json.loads(manifest_path.read_text(encoding="utf-8")), "source manifest")
    _validate_manifest(manifest)
    if _object(manifest.get("extraction"), "extraction").get("status") != "complete":
        raise ValueError("official archive extraction is incomplete")
    records = [_object(row, "file record") for row in _array(manifest.get("files"), "files")]
    paths: set[str] = set()
    for record in records:
        name = _text(record.get("path"), "source path")
        split = _member_split(name)
        if name in paths or record.get("split") != split:
            raise ValueError("duplicate source path or incorrect official split identity")
        paths.add(name)
    expected = {
        f"{split}/{split}_{index}.json"
        for split, count in OFFICIAL_FILE_COUNTS.items()
        for index in range(count)
    }
    if paths != expected:
        raise ValueError("source file manifest does not cover the official held-out corpus")
    selected = sorted(
        (row for row in records if row["split"] in splits), key=lambda row: str(row["path"])
    )
    if output_dir.exists():
        raise ValueError("refusing to overwrite existing output directory")
    input_root = input_dir.resolve()
    for record in selected:
        path = input_dir / str(record["path"])
        if path.is_symlink() or not path.resolve().is_relative_to(input_root):
            raise ValueError("source file resolves outside the input directory")
        if path.stat().st_size != record.get("bytes") or _sha256(path) != record.get("sha256"):
            raise ValueError("source file failed size/hash verification")
    output_dir.parent.mkdir(parents=True, exist_ok=True)
    task_counts: Counter[str] = Counter()
    row_counts: Counter[str] = Counter()
    no_positive_counts: Counter[str] = Counter()
    seen_tasks: set[str] = set()
    seen_actions: set[str] = set()
    with tempfile.TemporaryDirectory(prefix=".mind2web-import-", dir=output_dir.parent) as temp:
        staging = Path(temp) / "data"
        staging.mkdir()
        rows_path = staging / "rows.jsonl"
        retriever_path = staging / "retriever.jsonl"
        with (
            rows_path.open("w", encoding="utf-8") as labels_out,
            retriever_path.open("w", encoding="utf-8") as retrieval_out,
        ):
            for record in selected:
                split = str(record["split"])
                with (input_dir / str(record["path"])).open(encoding="utf-8") as handle:
                    for task in iter_tasks(handle):
                        task_id = _identifier(task.get("annotation_id"), "annotation_id")
                        if task_id in seen_tasks:
                            raise ValueError("duplicate task id across input files or splits")
                        seen_tasks.add(task_id)
                        task_counts[split] += 1
                        for labels, retrieval in normalize_task(task, split):
                            action_id = str(labels["action_id"])
                            if action_id in seen_actions:
                                raise ValueError("duplicate action id across tasks or splits")
                            seen_actions.add(action_id)
                            provenance = {
                                "dataset_revision": DATASET_REVISION,
                                "source_file": record["path"],
                                "source_sha256": record["sha256"],
                            }
                            labels["provenance"] = provenance
                            retrieval["provenance"] = provenance
                            labels_out.write(_canonical(labels) + "\n")
                            retrieval_out.write(_canonical(retrieval) + "\n")
                            row_counts[split] += 1
                            no_positive_counts[split] += int(bool(labels["no_positive"]))
        if any(task_counts[split] != OFFICIAL_TASK_COUNTS[split] for split in splits):
            raise ValueError("task counts do not match complete official held-out splits")
        report: dict[str, object] = {
            "schema": "vons.mind2web-import/v1",
            "dataset_revision": DATASET_REVISION,
            "archive_sha256": ARCHIVE_SHA256,
            "source_manifest_sha256": _sha256(manifest_path),
            "importer_sha256": _sha256(Path(__file__)),
            "splits": list(splits),
            "task_counts": dict(task_counts),
            "row_counts": dict(row_counts),
            "no_positive_counts": dict(no_positive_counts),
            "source_files": selected,
            "outputs": [
                {"path": path.name, "sha256": _sha256(path), "bytes": path.stat().st_size}
                for path in (rows_path, retriever_path)
            ],
            "retriever": {
                "name": "lexical-bm25",
                "k1": 1.2,
                "b": 0.75,
                "tokenization": "Unicode word tokens, casefold, unique query terms",
                "tie_break": "lexicographic candidate id",
                "top_k": None,
            },
            "candidate_order": "lexicographic candidate id after merging the released pool",
            "history": "teacher-forced official action_reprs strictly before the current action",
            "usage": "evaluation-only; no training or public unzipped redistribution",
            "scope": "official held-out candidate retrieval; no model or browser task evaluation",
            "limitations": [
                "Released candidate-pool recall bounds the task; this does not generate DOM candidates.",
                "No training split was acquired; train/test identity overlap is not independently audited.",
                "Duplicate identities are checked across the selected splits only.",
            ],
        }
        (staging / "manifest.json").write_text(
            json.dumps(report, indent=2) + "\n", encoding="utf-8"
        )
        (staging / ".gitignore").write_text("*\n", encoding="utf-8")
        staging.rename(output_dir)
    return report


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--input-dir", type=Path, required=True)
    parser.add_argument("--split", nargs="+", choices=[*SPLITS, "all"], required=True)
    parser.add_argument("--output-dir", type=Path, required=True)
    parser.add_argument("--source-manifest", type=Path, required=True)
    parser.add_argument("--extract-archive", type=Path)
    args = parser.parse_args()
    if "all" in args.split and args.split != ["all"]:
        parser.error("all cannot be combined with another split")
    try:
        if args.extract_archive:
            extract_official_archive(args.extract_archive, args.input_dir, args.source_manifest)
        report = import_split(
            args.input_dir,
            SPLITS if args.split == ["all"] else args.split,
            args.output_dir,
            args.source_manifest,
        )
    except (ValueError, TypeError, OSError, zipfile.BadZipFile, RuntimeError) as exc:
        # Deliberately omit source text and JSON decoder excerpts from public logs.
        parser.exit(2, f"Mind2Web import failed ({type(exc).__name__}); no evaluation claimed.\n")
    print(
        json.dumps(
            {
                "status": "imported",
                "task_counts": report["task_counts"],
                "row_counts": report["row_counts"],
                "scope": report["scope"],
            }
        )
    )


if __name__ == "__main__":
    main()