File size: 22,220 Bytes
e1acc15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""CPU assembly of option-A generation taps; no network or inference calls.

Prepare a NEW decode plan from a top-20 replay tree and a frozen, durably flushed
copy of capture files. The replay taps/equivalence gate are deliberately unused.
Generation-request top20 can be supplied in the same normalized array layout,
but requires an image-bound smoke plus a frozen completions-validation receipt. No relabeling of
prefill taps is possible: every capture header/ledger/range/hash/id is checked.
"""
from __future__ import annotations

import argparse
from collections.abc import Mapping, Sequence
import hashlib
import json
import os
from pathlib import Path
import re
from typing import cast

import numpy as np
import torch

from . import shards as S
from .assembly_paths import AssemblyPaths, parse_mappings
from .replay import ROW_BYTES, load_seq, read_plan
from .sequence_audit import sequence_audit
from .window_checks import response_entry

SCHEMA = 'd2.decode-capture.v1'
PLAN = 'd2.decode-plan.v1'


def assembly_paths(meta: Mapping[str, object]) -> AssemblyPaths | None:
    relocation = meta.get('relocation')
    if relocation is None:
        return None
    r = mapping(relocation)
    maps = mapping(r.get('mappings'))
    return AssemblyPaths(Path(text(r.get('root'))), {k: text(v) for k, v in maps.items()})


def obj(path: Path) -> dict[str, object]:
    value = json.loads(path.read_text())
    if not isinstance(value, dict):
        raise ValueError('expected JSON metadata object: ' + str(path))
    return cast(dict[str, object], value)


def digest(path: Path) -> str:
    with path.open('rb') as fh:
        return hashlib.file_digest(fh, 'sha256').hexdigest()


def number(value: object) -> int:
    if not isinstance(value, int) or isinstance(value, bool) or value < 0:
        raise ValueError('invalid nonnegative capture integer')
    return value


def text(value: object) -> str:
    if not isinstance(value, str) or not value:
        raise ValueError('missing capture identity')
    return value


def mapping(value: object) -> Mapping[str, object]:
    if not isinstance(value, dict):
        raise ValueError('missing metadata object')
    return cast(Mapping[str, object], value)


def named_error_policy(root: Path, enabled: bool) -> dict[str, object]:
    """Global errors/count drift are never eligible for quarantine."""
    path = root / 'ERRORS.jsonl'
    errors = [mapping(json.loads(line)) for line in path.read_text().splitlines()] if path.exists() else []
    ids: set[str] = set()
    for error in errors:
        seqs = error.get('seq_ids')
        if (error.get('schema') != SCHEMA or not isinstance(seqs, list) or not seqs
                or any(not isinstance(s, str) or re.fullmatch('[A-Za-z0-9_-]+', s) is None for s in seqs)):
            raise ValueError('unattributed capture error; quarantine refused')
        ids.update(cast(list[str], seqs))
    flushed, request = obj(root / 'FLUSHED.json'), obj(root / 'FLUSH.json')
    count = obj(root / 'ERROR_COUNT.json')
    if (flushed.get('schema') != SCHEMA or number(flushed.get('errors')) != len(errors)
            or not request.get('nonce') or request['nonce'] != flushed.get('nonce')
            or count.get('schema') != 'd2.decode-error-count.v1'
            or number(count.get('errors')) != len(errors) or count.get('global_refusal') is not False):
        raise ValueError('capture flush/error-count mismatch or global refusal')
    if ids and not enabled:
        raise ValueError('named capture errors require --quarantine-named-errors')
    return {'enabled': enabled, 'seq_ids': sorted(ids),
            'errors_sha256': digest(path) if path.exists() else None}


def capture_rows(root: Path, sid: str, r0: int, end: int,
                 tokens: Sequence[int]) -> tuple[torch.Tensor, dict[str, object]]:
    """Coverage r0..L_gen-2; extra aborted/checkpoint suffix rows are verified but excluded."""
    if not re.fullmatch('[A-Za-z0-9_-]+', sid):
        raise ValueError('unsafe sequence identity')
    errors_path = root / 'ERRORS.jsonl'
    errors = [mapping(json.loads(line)) for line in errors_path.read_text().splitlines()] if errors_path.exists() else []
    for error in errors:
        seqs = error.get('seq_ids')
        if (error.get('schema') != SCHEMA or not isinstance(seqs, list) or not seqs
                or any(not isinstance(s, str) or re.fullmatch('[A-Za-z0-9_-]+', s) is None for s in seqs)
                or sid in seqs):
            raise ValueError('capture writer recorded errors for this sequence or globally; data refused')
    flushed = obj(root / 'FLUSHED.json')
    request = obj(root / 'FLUSH.json')
    if (flushed.get('schema') != SCHEMA or number(flushed.get('errors')) != len(errors)
            or not request.get('nonce') or flushed.get('nonce') != request['nonce']):
        raise ValueError('missing/mismatched durable flush receipt')
    f = mapping(mapping(flushed.get('sequences')).get(sid))
    d = root / sid
    head = obj(d / 'CAPTURE.json')
    if (head.get('schema') != SCHEMA or head.get('taps_source') != 'decode_capture'
            or head.get('seq') != sid or head.get('from') != r0 or head.get('row_bytes') != ROW_BYTES
            or head.get('capture_sha256') != digest(Path(__file__).parents[1] / 'capture/decode_capture.py')):
        raise ValueError('capture mode/origin/sequence header mismatch')
    lp, tp, ip = (d / x for x in ('ledger.jsonl', 'taps.bin', 'ids.bin'))
    if (digest(lp) != f.get('ledger_sha256') or tp.stat().st_size != f.get('taps_bytes')
            or ip.stat().st_size != f.get('ids_bytes')):
        raise ValueError('capture changed after durable flush')
    covered: set[int] = set()
    # Replay ownership before inspecting disk: overwritten bytes can no longer
    # satisfy an older stage's aggregate hash. Every current row must satisfy
    # its newest actual writer's row hash; retained duplicates agree with that
    # writer, and only an explicitly named older segment may be superseded.
    writers: dict[int, tuple[int, str, str]] = {}
    dup = differing = zero = replacements = 0
    ledger = [cast(dict[str, object], json.loads(line)) for line in lp.read_text().splitlines()]
    with tp.open('rb') as taps, ip.open('rb') as ids:
        for row in ledger:
            first, n = number(row.get('first')), number(row.get('rows'))
            if (row.get('schema') != SCHEMA or row.get('seq') != sid or first < r0 or n < 1
                    or number(row.get('tag_r0')) < r0 or first < number(row.get('tag_r0'))):
                raise ValueError('invalid capture ledger identity/range')
            seg = number(row.get('seg'))
            prompt = number(row.get('prompt_length'))
            if max(0, prompt - 128) != number(row.get('tag_r0')):
                raise ValueError('capture ledger prompt length mismatch')
            number(row.get('round'))
            ht, hi = row.get('row_sha256_taps'), row.get('row_sha256_ids')
            if not isinstance(ht, list) or not isinstance(hi, list) or len(ht) != n or len(hi) != n:
                raise ValueError('missing capture per-row hashes')
            replaced = row.get('replaced_rows')
            if not isinstance(replaced, list):
                raise ValueError('missing capture replacement ledger')
            replace: dict[int, int] = {}
            for raw in cast(list[object], replaced):
                claim = mapping(raw)
                p, old_seg = number(claim.get('position')), number(claim.get('seg'))
                if p in replace or not first <= p < first + n:
                    raise ValueError('invalid capture replacement position')
                replace[p] = old_seg
            actual_dup = 0
            for j, p in enumerate(range(first, first + n)):
                th, ih = text(ht[j]), text(hi[j])
                if not re.fullmatch('[0-9a-f]{64}', th) or not re.fullmatch('[0-9a-f]{64}', ih):
                    raise ValueError('invalid capture per-row hash')
                previous = writers.get(p)
                if previous is not None:
                    actual_dup += 1
                    if p in replace:
                        if p < prompt or previous[0] != replace[p] or previous[0] >= seg:
                            raise ValueError('capture superseded writer is not an older segment suffix')
                    else:
                        if (th, ih) != previous[1:]:
                            raise ValueError('capture retained duplicate differs from its writer')
                        continue
                elif p in replace:
                    raise ValueError('capture replacement without an older writer')
                writers[p] = (seg, th, ih)
            if number(row.get('dup_rows')) != actual_dup:
                raise ValueError('capture ledger duplicate count mismatch')
            covered.update(range(first, first + n))
            dup += actual_dup
            nd = number(row.get('dup_rows_differing'))
            if nd > actual_dup:
                raise ValueError('invalid differing duplicate count')
            differing += nd
            zero += number(row.get('zero_rows'))
            replacements += len(replace)
        # Verify each final row against the newest writer, including any suffix
        # beyond the driver's final checkpoint (it is verified, then excluded).
        for p, (_, th, ih) in writers.items():
            taps.seek((p - r0) * ROW_BYTES)
            ids.seek((p - r0) * 4)
            tb, ib = taps.read(ROW_BYTES), ids.read(4)
            if len(tb) != ROW_BYTES or len(ib) != 4 or hashlib.sha256(tb).hexdigest() != th or hashlib.sha256(ib).hexdigest() != ih:
                raise ValueError('capture newest-writer bytes/hash mismatch')
        for row in ledger:
            first, n = number(row['first']), number(row['rows'])
            ht, hi = cast(list[str], row['row_sha256_taps']), cast(list[str], row['row_sha256_ids'])
            # Aggregate hashes remain mandatory wherever no later write changed
            # this stage's retained bytes. Superseded claims are checked above.
            if all(writers[p][1:] == (ht[j], hi[j]) for j, p in enumerate(range(first, first + n))):
                taps.seek((first - r0) * ROW_BYTES)
                ids.seek((first - r0) * 4)
                if (hashlib.sha256(taps.read(n * ROW_BYTES)).hexdigest() != row.get('sha256_taps')
                        or hashlib.sha256(ids.read(n * 4)).hexdigest() != row.get('sha256_ids')):
                    raise ValueError('capture ledger bytes/hash mismatch')
        if zero:
            raise ValueError('zero taps rows in capture ledger')
        if not set(range(r0, end)).issubset(covered):
            raise ValueError('decode capture coverage gap (including prompt tail)')
        ids.seek(0)
        actual_ids = np.frombuffer(ids.read((end - r0) * 4), dtype='<i4').tolist()
        if actual_ids != list(tokens[r0:end]):
            raise ValueError('decode ids.bin differs from final committed tokens')
        taps.seek(0)
        raw = bytearray(taps.read((end - r0) * ROW_BYTES))
    tensor = torch.frombuffer(raw, dtype=torch.bfloat16).view(end - r0, S.TAPW)
    if bool((tensor.view(-1, 3, 5120) == 0).all(-1).any()):
        raise ValueError('zero layer row in capture bytes')
    return tensor, {'schema': SCHEMA, 'from': r0, 'end': end, 'rows': end - r0,
                    'ledger_sha256': digest(lp), 'header_sha256': digest(d / 'CAPTURE.json'),
                    'flush_sha256': digest(root / 'FLUSHED.json'), 'capture_sha256': head.get('capture_sha256'),
                    'dup_rows': dup, 'dup_rows_differing': differing, 'zero_rows': zero, 'replaced_rows': replacements,
                    'extra_rows_excluded': len(covered - set(range(r0, end)))}


def decode_identity(root: Path, *, full_copy: bool = False) -> dict[str, object]:
    meta = obj(root / 'DECODE.json')
    if meta.get('schema') != PLAN or meta.get('taps_source') != 'decode_capture':
        raise ValueError('not a decode capture plan')
    paths = assembly_paths(meta)
    if paths:
        for path in (root / 'DECODE.json', root / 'plan.jsonl'):
            paths.inside(path)
        for entry in read_plan(str(root)):
            paths.inside(Path(text(entry.get('src'))))
        from .copy_binding import verify_copy
        receipt = paths.inside(paths.root / 'COPY_RECEIPT.json')
        if digest(receipt) != meta.get('copy_receipt_sha256'):
            raise ValueError('decode copy receipt changed')
        if full_copy and verify_copy(paths.root) != meta['copy_receipt_sha256']:
            raise ValueError('decode copy receipt changed during validation')
        for key in ('captures', 'top20_root', 'verification'):
            paths.inside(Path(text(meta[key])))
        if Path(text(meta['captures'])) != paths.root / 'capture':
            raise ValueError('captures are not the receipt-bound copy tree')
        paths.inside(root)
        if meta.get('quarantine') is not None:
            quarantine = mapping(meta['quarantine'])
            if named_error_policy(Path(text(meta['captures'])), quarantine.get('enabled') is True) != quarantine:
                raise ValueError('named capture error quarantine changed')
            if any(e['seq_id'] in cast(list[str], quarantine['seq_ids']) for e in read_plan(str(root))):
                raise ValueError('quarantined sequence present in decode plan')
        if digest(root / 'plan.jsonl') != meta.get('plan_sha256'):
            raise ValueError('relocated decode plan changed')
    source = meta.get('top20_source')
    if source == 'generation_verified':
        path = Path(text(meta.get('verification')))
        if digest(path) != meta.get('verification_sha256'):
            raise ValueError('generation top20 requires a successful real image-bound smoke and completions receipt')
        from .generation_export import verify
        verify(Path(text(meta['top20_root'])), path, paths=paths)
    elif source != 'prefill_replay_top20_only':
        raise ValueError('unverified top20 source')
    return meta


def build_record(entry: Mapping[str, object], root: str, pman: Mapping[str, object],
                 context: Mapping[str, object] | None) -> S.SeqRecord:
    meta = decode_identity(Path(root))
    sid, src = text(entry['seq_id']), text(entry['src'])
    if meta['top20_source'] == 'generation_verified':
        from .generation_export import verify_sequence
        verify_sequence(Path(text(meta['top20_root'])), entry, obj(Path(text(meta['verification']))),
                        paths=assembly_paths(meta))
    state = load_seq(src)
    toks = cast(list[int], state['tokens'])
    Lgen, g, r0 = number(entry['L']), number(entry['gen_start']), number(entry['from'])
    if (len(toks) != Lgen or S.tokens_sha256(toks) != entry['tokens_sha256']
            or state['gen_start'] != g or r0 != max(0, g - 128) or Lgen - g < 3):
        raise ValueError('decode source tokens/L/gen_start differ from plan or span too short')
    L = Lgen - 1
    taps, capture = capture_rows(Path(text(meta['captures'])), sid, r0, L, toks)
    lp_root = Path(text(meta['top20_root']))
    lm = obj(lp_root / 'logprobs' / (sid + '.json'))
    old_entry = dict(entry)
    old_entry['request_id'] = entry['logprob_request_id']
    if any(key not in lm for key in ('seq_id', 'request_id', 'L', 'gen_start', 'from', 'tokens_sha256')):
        raise ValueError('top20 response identity missing')
    response_entry(lm, old_entry)
    zp = lp_root / 'logprobs' / (sid + '.npz')
    if digest(zp) != entry['top20_sha256']:
        raise ValueError('top20 arrays changed since decode plan')
    arrays: dict[str, torch.Tensor] = {}
    with np.load(zp) as z:
        for key in ('top_ids', 'top_lp', 'tok_lp', 'tok_rank', 'resid'):
            if z[key].shape[0] != Lgen - g:
                raise ValueError('top20 span is incomplete')
            arrays[key] = torch.from_numpy(z[key][:-1].copy())
    pm = mapping(pman.get(text(entry['prompt_sha256']), {}))
    side: dict[str, object] = {key: entry[key] for key in ('seq_id', 'split', 'family', 'prompt_sha256',
                                                        'finish_reason', 'segments')}
    side.update(source=pm.get('source'), source_docs=pm.get('source_docs', []),
                generation_L=Lgen, generation_tokens_sha256=entry['tokens_sha256'],
                taps_source='decode_capture', decode_capture=capture, top20_source=meta['top20_source'],
                top20_sha256=entry['top20_sha256'], logprob_request_id=entry['logprob_request_id'])
    if context is not None:
        # Audit the FULL raw generation to preserve segment coverage, then record the shard truncation explicitly.
        audit = sequence_audit(toks, g, context, obj(Path(src)), text(entry['kind']))
        audit['shard_interval'] = [0, L]
        audit['dropped_final_sample'] = True
        side['sequence_audit'] = audit
    else:
        side['sequence_audit'] = {'schema': 'd2.sequence-audit.synthetic.v1', 'not_served_data': True}
    return S.SeqRecord(side, torch.tensor(toks[:L], dtype=torch.int32), g, taps,
                       arrays['top_ids'], arrays['top_lp'], arrays['tok_lp'], arrays['tok_rank'], arrays['resid'])


def prepare(top20: Path, captures: Path, out: Path, source: str, verification: Path | None = None, *,
            assembly_root: Path | None = None, path_maps: Mapping[str, str] | None = None,
            quarantine_named_errors: bool = False) -> None:
    if out.exists():
        raise ValueError('decode plan requires a NEW output directory')
    paths = AssemblyPaths(assembly_root, path_maps or {}) if assembly_root else None
    if path_maps and paths is None:
        raise ValueError('path maps require an explicit assembly root')
    if paths:
        from .copy_binding import verify_copy
        copy_sha = verify_copy(paths.root)
        top20, captures = paths.inside(top20), paths.inside(captures)
        if captures != paths.root / 'capture':
            raise ValueError('captures are not the receipt-bound copy tree')
        # out does not exist yet; check its existing parent instead.
        paths.inside(out.parent)
        if verification is not None:
            verification = paths.inside(verification)
    if source == 'generation_verified':
        from .generation_export import verify
        if verification is None:
            raise ValueError('generation top20 requires a completions validation receipt')
        verify(top20, verification, full=True, paths=paths)
    quarantine = (named_error_policy(captures, quarantine_named_errors)
                  if paths or quarantine_named_errors else None)
    entries = read_plan(str(top20))
    if not entries:
        raise ValueError('empty top20 plan')
    planned: list[dict[str, object]] = []
    for e in entries:
        sid, g = text(e['seq_id']), number(e['gen_start'])
        if not re.fullmatch('[A-Za-z0-9_-]+', sid):
            raise ValueError('unsafe sequence identity')
        if e['kind'] != 'gen':
            raise ValueError('original acap requires its existing decode exports; cannot relabel replay taps')
        if quarantine and sid in cast(list[str], quarantine['seq_ids']):
            continue
        head = obj(captures / sid / 'CAPTURE.json')
        if head.get('schema') != SCHEMA or head.get('taps_source') != 'decode_capture':
            raise ValueError('non-decode taps refused')
        row = dict(e)
        if paths:
            row['src'] = str(paths.resolve(text(e['src'])))
            copied_state = paths.inside(paths.root / 'generator/seqs' / (sid + '.json'))
            if obj(Path(text(row['src']))) != obj(copied_state):
                raise ValueError('export sequence differs from receipt-bound generator state')
        row.update(logprob_request_id=e['request_id'], request_id=f'd2c.{sid}.0.{max(0, g - 128)}.{g}',
                   top20_sha256=digest(top20 / 'logprobs' / (sid + '.npz')))
        planned.append(row)
    meta: dict[str, object] = {'schema': PLAN, 'taps_source': 'decode_capture', 'captures': str(captures.resolve()),
            'top20_root': str(top20.resolve()), 'top20_source': source,
            'verification': str(verification.resolve()) if verification else None,
            'verification_sha256': digest(verification) if verification else None}
    if not planned:
        raise ValueError('no sequences remain after named-error quarantine')
    if quarantine is not None:
        meta['quarantine'] = quarantine
    plan_text = ''.join(json.dumps(e) + '\n' for e in planned)
    if paths:
        meta['copy_receipt_sha256'] = copy_sha
        meta['relocation'] = {'root': str(paths.root), 'mappings': paths.mappings,
                              'top20_plan_sha256': digest(top20 / 'plan.jsonl'),
                              'policy': 'resolve only; original bytes and sha256 fields unchanged'}
        meta['plan_sha256'] = hashlib.sha256(plan_text.encode()).hexdigest()
    out.mkdir(mode=0o700, parents=True)
    (out / 'DECODE.json').write_text(json.dumps(meta))
    (out / 'plan.jsonl').write_text(plan_text)
    for path in (out / 'DECODE.json', out / 'plan.jsonl'):
        os.chmod(path, 0o600)
    decode_identity(out)


def main() -> None:
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument('--top20', type=Path, required=True)
    ap.add_argument('--captures', type=Path, required=True)
    ap.add_argument('--out', type=Path, required=True)
    ap.add_argument('--top20-source', choices=('prefill_replay_top20_only', 'generation_verified'),
                    default='prefill_replay_top20_only')
    ap.add_argument('--verification', type=Path)
    ap.add_argument('--quarantine-named-errors', action='store_true')
    ap.add_argument('--assembly-root', type=Path)
    ap.add_argument('--path-map', action='append', default=[], metavar='OLD_ABSOLUTE=ROOT_RELATIVE')
    a = ap.parse_args()
    prepare(a.top20, a.captures, a.out, a.top20_source, a.verification,
            assembly_root=a.assembly_root, path_maps=parse_mappings(a.path_map), quarantine_named_errors=a.quarantine_named_errors)


if __name__ == '__main__':
    main()