Edward J. Schwartz commited on
Commit
16f323d
·
1 Parent(s): b2b5f1c

Update run defaults and make verification workers cancellable

Browse files
core CHANGED
@@ -1 +1 @@
1
- Subproject commit 989dc2dbe7fc787b2ad33353413c9430b8bd2446
 
1
+ Subproject commit 7258a0c4fe81545b0de6baf4256d6cd6ee60e01d
core-revision.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
  "repository": "https://github.com/edmcman/decompile-verify.git",
3
- "commit": "989dc2dbe7fc787b2ad33353413c9430b8bd2446",
4
  "version": "0.0.0"
5
  }
 
1
  {
2
  "repository": "https://github.com/edmcman/decompile-verify.git",
3
+ "commit": "7258a0c4fe81545b0de6baf4256d6cd6ee60e01d",
4
  "version": "0.0.0"
5
  }
demo/app.py CHANGED
@@ -20,6 +20,7 @@ from demo.presentation import banner, format_bit_ranges, json_detail, mismatch_s
20
  from demo.session import (UISession, begin_ui, cancel_session, controls, edit_inputs, idle_controls,
21
  reset_session, stream_ui)
22
  import verifier as api
 
23
  from verifier import Task, integer_text, parse_bindings, parse_function, yaml_mapping
24
 
25
  HEADERS = ["Seed", "Verdict", "Location", "Differing bits", "Original", "Candidate", "Reason"]
@@ -81,18 +82,20 @@ def show_assembly(binary, function, candidate, bindings, candidate_symbol,
81
 
82
 
83
  def run_verification(binary, function, candidate, bindings, candidate_symbol,
84
- seed_count, seed_start, instruction_budget, compiler, flags, interfaces="", *, report_directory=None):
85
  """Translate UI inputs, stream engine results, then supply the JSON download."""
86
  try:
87
  task = task_from_inputs(binary, function, candidate, bindings, candidate_symbol,
88
  seed_count, seed_start, instruction_budget, compiler, flags, interfaces)
89
- rows, detail = [], None
90
- with closing(api.verify(task, stream=True)) as iterator:
91
  for report in iterator:
92
  rows.append(result_row(report.results[-1]))
93
  if detail is None and report.first_mismatch is not None:
94
  detail = asdict(report.first_mismatch)
95
  yield banner(report), list(rows), detail, None, report.original_assembly, report.candidate_assembly
 
 
96
  output = report.to_json()
97
  directory = Path(report_directory) if report_directory else Path(tempfile.gettempdir()) / "decompile-verify-reports"
98
  directory.mkdir(parents=True, exist_ok=True)
@@ -177,7 +180,7 @@ aid; verification runs the binary.
177
  value="globals: {}\ncallees: {}\n", render=False)
178
  seed_count = gr.Number(label="Seed count", value=100, precision=0, minimum=1, render=False)
179
  seed_start = gr.Textbox(label="First seed", value="0", info="Nonnegative decimal or hex integer", render=False)
180
- instruction_budget = gr.Number(label="Instruction budget per run", value=100000, precision=0,
181
  minimum=1, render=False)
182
  compiler = gr.Dropdown(label="Candidate compiler", choices=["gcc", "clang"], value="gcc", render=False)
183
  flags = gr.Textbox(label="Candidate flags", value="-O2",
@@ -245,9 +248,11 @@ aid; verification runs the binary.
245
 
246
  def verify_ui(binary, function, candidate, bindings, candidate_symbol,
247
  seed_count, seed_start, instruction_budget, compiler, flags, interfaces, session, ticket):
248
- for update in stream_ui(run_verification, (binary, function, candidate, bindings, candidate_symbol,
249
- seed_count, seed_start, instruction_budget, compiler, flags, interfaces), session, ticket):
250
- yield update[0], update[1], json_detail(update[2]), *update[3:]
 
 
251
 
252
  def reset_ui(session):
253
  reset_session(session)
@@ -275,10 +280,9 @@ aid; verification runs the binary.
275
  verify_start = run.click(begin_ui, [session, *inputs], [verdict, *buttons, ticket],
276
  queue=False, api_visibility="private", show_progress="hidden")
277
  preview_event = gr.on(triggers=[], fn=show_assembly, inputs=inputs, outputs=outputs,
278
- api_name="assembly", concurrency_limit=1,
279
- concurrency_id="workbench", show_progress="hidden")
280
- event = verify_start.then(verify_ui, [*inputs, session, ticket], outputs, api_name="verify", concurrency_limit=1,
281
- concurrency_id="workbench", show_progress="hidden")
282
  event.then(idle_controls, session, buttons, queue=False,
283
  api_visibility="private", show_progress="hidden")
284
  gr.on([binary.upload, binary.delete, binary.clear, *[component.input for component in inputs[1:]]],
 
20
  from demo.session import (UISession, begin_ui, cancel_session, controls, edit_inputs, idle_controls,
21
  reset_session, stream_ui)
22
  import verifier as api
23
+ from demo.execution import verification_reports
24
  from verifier import Task, integer_text, parse_bindings, parse_function, yaml_mapping
25
 
26
  HEADERS = ["Seed", "Verdict", "Location", "Differing bits", "Original", "Candidate", "Reason"]
 
82
 
83
 
84
  def run_verification(binary, function, candidate, bindings, candidate_symbol,
85
+ seed_count, seed_start, instruction_budget, compiler, flags, interfaces="", *, report_directory=None, cancelled=None):
86
  """Translate UI inputs, stream engine results, then supply the JSON download."""
87
  try:
88
  task = task_from_inputs(binary, function, candidate, bindings, candidate_symbol,
89
  seed_count, seed_start, instruction_budget, compiler, flags, interfaces)
90
+ rows, detail, report = [], None, None
91
+ with closing(verification_reports(task, cancelled=cancelled)) as iterator:
92
  for report in iterator:
93
  rows.append(result_row(report.results[-1]))
94
  if detail is None and report.first_mismatch is not None:
95
  detail = asdict(report.first_mismatch)
96
  yield banner(report), list(rows), detail, None, report.original_assembly, report.candidate_assembly
97
+ if cancelled is not None and cancelled.is_set():
98
+ return
99
  output = report.to_json()
100
  directory = Path(report_directory) if report_directory else Path(tempfile.gettempdir()) / "decompile-verify-reports"
101
  directory.mkdir(parents=True, exist_ok=True)
 
180
  value="globals: {}\ncallees: {}\n", render=False)
181
  seed_count = gr.Number(label="Seed count", value=100, precision=0, minimum=1, render=False)
182
  seed_start = gr.Textbox(label="First seed", value="0", info="Nonnegative decimal or hex integer", render=False)
183
+ instruction_budget = gr.Number(label="Instruction budget per run", value=1000, precision=0,
184
  minimum=1, render=False)
185
  compiler = gr.Dropdown(label="Candidate compiler", choices=["gcc", "clang"], value="gcc", render=False)
186
  flags = gr.Textbox(label="Candidate flags", value="-O2",
 
248
 
249
  def verify_ui(binary, function, candidate, bindings, candidate_symbol,
250
  seed_count, seed_start, instruction_budget, compiler, flags, interfaces, session, ticket):
251
+ with closing(stream_ui(run_verification, (binary, function, candidate, bindings, candidate_symbol,
252
+ seed_count, seed_start, instruction_budget, compiler, flags, interfaces),
253
+ session, ticket, cancellable=True)) as updates:
254
+ for update in updates:
255
+ yield update[0], update[1], json_detail(update[2]), *update[3:]
256
 
257
  def reset_ui(session):
258
  reset_session(session)
 
280
  verify_start = run.click(begin_ui, [session, *inputs], [verdict, *buttons, ticket],
281
  queue=False, api_visibility="private", show_progress="hidden")
282
  preview_event = gr.on(triggers=[], fn=show_assembly, inputs=inputs, outputs=outputs,
283
+ api_name="assembly", concurrency_limit=None, show_progress="hidden")
284
+ event = verify_start.then(verify_ui, [*inputs, session, ticket], outputs, api_name="verify",
285
+ concurrency_limit=None, show_progress="hidden")
 
286
  event.then(idle_controls, session, buttons, queue=False,
287
  api_visibility="private", show_progress="hidden")
288
  gr.on([binary.upload, binary.delete, binary.clear, *[component.input for component in inputs[1:]]],
demo/execution.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run verification in an interruptible process."""
2
+
3
+ from contextlib import closing
4
+ import multiprocessing
5
+ import os
6
+ import signal
7
+ import tempfile
8
+
9
+
10
+ def _verify_worker(task, connection, directory):
11
+ # Compilers inherit this group so cancellation also stops child processes.
12
+ os.setsid()
13
+ tempfile.tempdir = directory
14
+ from verifier import verify
15
+
16
+ try:
17
+ with closing(verify(task, stream=True)) as reports:
18
+ for report in reports:
19
+ connection.send(("report", report))
20
+ connection.send(("done", None))
21
+ except Exception as error:
22
+ connection.send(("error", str(error)))
23
+ finally:
24
+ connection.close()
25
+
26
+
27
+ def verification_reports(task, *, cancelled=None, _worker=_verify_worker):
28
+ """Stream results until completion or cancellation."""
29
+ if cancelled is not None and cancelled.is_set():
30
+ return
31
+ context = multiprocessing.get_context("spawn")
32
+ receiver, sender = context.Pipe(duplex=False)
33
+ with tempfile.TemporaryDirectory(prefix="verify-request-") as directory:
34
+ process = context.Process(target=_worker, args=(task, sender, directory))
35
+ started = False
36
+ try:
37
+ process.start()
38
+ started = True
39
+ sender.close()
40
+ while True:
41
+ if cancelled is not None and cancelled.is_set():
42
+ return
43
+ # Poll briefly so cancellation can stop a blocked worker.
44
+ if not receiver.poll(0.1):
45
+ continue
46
+ if cancelled is not None and cancelled.is_set():
47
+ return
48
+ try:
49
+ kind, value = receiver.recv()
50
+ except EOFError as error:
51
+ raise ValueError("Verification worker exited without a result") from error
52
+ if kind == "done":
53
+ return
54
+ if kind == "error":
55
+ raise ValueError(value)
56
+ yield value
57
+ finally:
58
+ sender.close()
59
+ receiver.close()
60
+ if started:
61
+ # Kill the group even when the worker has exited but a child remains.
62
+ try:
63
+ os.killpg(process.pid, signal.SIGKILL)
64
+ except ProcessLookupError:
65
+ if process.is_alive():
66
+ process.kill()
67
+ process.join()
68
+ process.close()
demo/session.py CHANGED
@@ -1,7 +1,7 @@
1
  """Private, per-browser state for displayed inputs and cancellable result streams."""
2
 
3
  from contextlib import closing
4
- from threading import RLock
5
 
6
  from demo.presentation import ABI_SLOT, status, with_stale
7
 
@@ -13,6 +13,7 @@ class UISession:
13
  self.busy = False
14
  self.display = ""
15
  self.lock = RLock()
 
16
 
17
  def __deepcopy__(self, memo):
18
  # Each Gradio session gets its own state and synchronization primitive.
@@ -33,6 +34,7 @@ def edit_inputs(session, *values):
33
 
34
  def reset_session(session):
35
  with session.lock:
 
36
  session.generation += 1
37
  session.snapshot = session.current = None
38
  session.busy = False
@@ -41,6 +43,7 @@ def reset_session(session):
41
 
42
  def cancel_session(session):
43
  with session.lock:
 
44
  session.generation += 1
45
  session.busy = False
46
  _, separator, abi = session.display.partition(ABI_SLOT)
@@ -61,14 +64,16 @@ def idle_controls(session):
61
 
62
  def begin_ui(session, *values):
63
  with session.lock:
 
64
  session.generation += 1
 
65
  session.snapshot = session.current = tuple(values)
66
  session.busy = True
67
  session.display = status("Building")
68
  return session.rendered(), *controls(True), session.generation
69
 
70
 
71
- def stream_ui(handler, values, session, ticket=None):
72
  """Keep the six API outputs while protecting this session from obsolete streams."""
73
  with session.lock:
74
  if ticket is not None:
@@ -78,10 +83,12 @@ def stream_ui(handler, values, session, ticket=None):
78
  else:
79
  begin_ui(session, *values)
80
  generation = session.generation
 
81
  initial = session.rendered()
82
  try:
83
  yield initial, [], None, None, "", ""
84
- with closing(handler(*values)) as iterator:
 
85
  for update in iterator:
86
  with session.lock:
87
  if session.generation != generation:
 
1
  """Private, per-browser state for displayed inputs and cancellable result streams."""
2
 
3
  from contextlib import closing
4
+ from threading import Event, RLock
5
 
6
  from demo.presentation import ABI_SLOT, status, with_stale
7
 
 
13
  self.busy = False
14
  self.display = ""
15
  self.lock = RLock()
16
+ self.cancelled = Event()
17
 
18
  def __deepcopy__(self, memo):
19
  # Each Gradio session gets its own state and synchronization primitive.
 
34
 
35
  def reset_session(session):
36
  with session.lock:
37
+ session.cancelled.set()
38
  session.generation += 1
39
  session.snapshot = session.current = None
40
  session.busy = False
 
43
 
44
  def cancel_session(session):
45
  with session.lock:
46
+ session.cancelled.set()
47
  session.generation += 1
48
  session.busy = False
49
  _, separator, abi = session.display.partition(ABI_SLOT)
 
64
 
65
  def begin_ui(session, *values):
66
  with session.lock:
67
+ session.cancelled.set()
68
  session.generation += 1
69
+ session.cancelled = Event()
70
  session.snapshot = session.current = tuple(values)
71
  session.busy = True
72
  session.display = status("Building")
73
  return session.rendered(), *controls(True), session.generation
74
 
75
 
76
+ def stream_ui(handler, values, session, ticket=None, *, cancellable=False):
77
  """Keep the six API outputs while protecting this session from obsolete streams."""
78
  with session.lock:
79
  if ticket is not None:
 
83
  else:
84
  begin_ui(session, *values)
85
  generation = session.generation
86
+ cancelled = session.cancelled
87
  initial = session.rendered()
88
  try:
89
  yield initial, [], None, None, "", ""
90
+ options = {"cancelled": cancelled} if cancellable else {}
91
+ with closing(handler(*values, **options)) as iterator:
92
  for update in iterator:
93
  with session.lock:
94
  if session.generation != generation:
tests/test_execution.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Exercise worker cleanup without requiring Linux ELF compilers."""
2
+
3
+ import os
4
+ from time import sleep
5
+
6
+ import pytest
7
+
8
+ from demo.execution import verification_reports
9
+
10
+
11
+ def successful_worker(task, connection, directory):
12
+ os.setsid()
13
+ connection.send(("report", task))
14
+ connection.send(("done", None))
15
+ connection.close()
16
+
17
+
18
+ def failing_worker(task, connection, directory):
19
+ os.setsid()
20
+ connection.send(("error", "invalid candidate"))
21
+ connection.close()
22
+
23
+
24
+ def stalled_worker(task, connection, directory):
25
+ os.setsid()
26
+ if task:
27
+ connection.send(("report", os.getpid()))
28
+ sleep(60)
29
+
30
+
31
+ def test_reports_and_errors():
32
+ assert list(verification_reports("report", _worker=successful_worker)) == ["report"]
33
+ with pytest.raises(ValueError, match="invalid candidate"):
34
+ list(verification_reports(None, _worker=failing_worker))
35
+
36
+
37
+
38
+
39
+ def test_closing_stream_stops_worker():
40
+ reports = verification_reports(True, _worker=stalled_worker)
41
+ pid = next(reports)
42
+ reports.close()
43
+ with pytest.raises(ProcessLookupError):
44
+ os.kill(pid, 0)
45
+
46
+
47
+ def test_verifier_worker_reports_build_errors(tmp_path):
48
+ from verifier import Task
49
+
50
+ task = Task(binary=tmp_path / "missing.elf", function=0x401000,
51
+ candidate="unsigned target(void) { return 0; }")
52
+ with pytest.raises(ValueError, match="missing.elf"):
53
+ list(verification_reports(task))
tests/test_execution_cancel.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Exercise cancellation while a real spawned worker is blocked before a result."""
2
+ from concurrent.futures import ThreadPoolExecutor
3
+ import os
4
+ from pathlib import Path
5
+ import subprocess
6
+ from threading import Event
7
+ import time
8
+
9
+ import pytest
10
+
11
+ from demo.execution import verification_reports
12
+ from demo.session import UISession, begin_ui, cancel_session, reset_session, stream_ui
13
+
14
+
15
+ def blocked_worker(task, connection, directory):
16
+ os.setsid()
17
+ child = subprocess.Popen(["sleep", "30"])
18
+ Path(directory, "unfinished").write_text("temporary build")
19
+ Path(task).write_text(f"{os.getpid()} {child.pid}\n{directory}")
20
+ if not Path(task).name.startswith("build"):
21
+ connection.send(("report", "ready"))
22
+ time.sleep(30)
23
+
24
+
25
+ def completed_worker(task, connection, directory):
26
+ os.setsid()
27
+ connection.send(("report", "completed"))
28
+ connection.send(("done", None))
29
+ connection.close()
30
+
31
+
32
+ def assert_stopped(pid):
33
+ # A killed compiler child may remain briefly as a zombie until reaped by init.
34
+ result = subprocess.run(["ps", "-o", "stat=", "-p", str(pid)], capture_output=True, text=True)
35
+ assert not result.stdout.strip() or result.stdout.strip().startswith("Z")
36
+
37
+
38
+ @pytest.mark.parametrize("stop", [cancel_session, reset_session])
39
+ def test_cancel_blocked_build_releases_worker_and_allows_next_run(tmp_path, stop):
40
+ session = UISession()
41
+ marker = tmp_path / "worker"
42
+
43
+ def handler(*values, cancelled):
44
+ for value in verification_reports(marker, cancelled=cancelled, _worker=blocked_worker):
45
+ yield value, [], None, None, "", ""
46
+
47
+ ticket = begin_ui(session, "source")[-1]
48
+ old_signal = session.cancelled
49
+ updates = stream_ui(handler, (), session, ticket, cancellable=True)
50
+ next(updates)
51
+ assert next(updates)[0] == "ready"
52
+ pids, directory = marker.read_text().splitlines()
53
+ with ThreadPoolExecutor(max_workers=1) as executor:
54
+ waiting = executor.submit(lambda: list(updates))
55
+ time.sleep(0.2)
56
+ assert not waiting.done()
57
+ stop(session)
58
+ new_ticket = begin_ui(session, "new source")[-1]
59
+ assert old_signal.is_set() and not session.cancelled.is_set()
60
+ assert waiting.result(timeout=3) == []
61
+ assert session.busy and session.generation == new_ticket
62
+ assert not Path(directory).exists()
63
+ for pid in map(int, pids.split()):
64
+ assert_stopped(pid)
65
+ assert list(verification_reports(None, _worker=completed_worker)) == ["completed"]
66
+
67
+
68
+
69
+
70
+ def test_close_stream_cleans_up_worker(tmp_path):
71
+ marker = tmp_path / "worker"
72
+ reports = verification_reports(marker, _worker=blocked_worker)
73
+ assert next(reports) == "ready"
74
+ reports.close()
75
+ pids, directory = marker.read_text().splitlines()
76
+ assert not Path(directory).exists()
77
+ for pid in map(int, pids.split()):
78
+ assert_stopped(pid)
79
+
80
+
81
+ def test_already_cancelled_does_not_start_worker(tmp_path):
82
+ cancelled = Event()
83
+ cancelled.set()
84
+ marker = tmp_path / "worker"
85
+ assert list(verification_reports(marker, cancelled=cancelled, _worker=blocked_worker)) == []
86
+ assert not marker.exists()
87
+
88
+
89
+ def test_cancel_before_first_build_result(tmp_path):
90
+ marker = tmp_path / "build-worker"
91
+ cancelled = Event()
92
+ with ThreadPoolExecutor(max_workers=1) as executor:
93
+ waiting = executor.submit(lambda: list(verification_reports(
94
+ marker, cancelled=cancelled, _worker=blocked_worker)))
95
+ deadline = time.monotonic() + 10
96
+ while not marker.exists():
97
+ assert time.monotonic() < deadline
98
+ time.sleep(0.02)
99
+ assert not waiting.done()
100
+ cancelled.set()
101
+ assert waiting.result(timeout=3) == []
102
+ pids, directory = marker.read_text().splitlines()
103
+ assert not Path(directory).exists()
104
+ for pid in map(int, pids.split()):
105
+ assert_stopped(pid)
tests/test_faultless.py CHANGED
@@ -13,29 +13,30 @@ def test_faultless_frontend_stream(tmp_path, compiler, name, example_rows):
13
  rows = example_rows
14
  args = rows[EXAMPLE_NAMES.index(name)].copy()
15
  args[8] = compiler
 
16
  updates = list(run_verification(*args, report_directory=tmp_path / 'reports'))
17
  report = json.loads(Path(updates[-1][3]).read_text())
18
- assert len(updates) == 11
19
- assert report['completed'] == 10
20
  expected = example_metadata(name)['expected_verdict']
21
  if name == 'faultless-byte-reinterpretation' and compiler == 'clang':
22
  # The original is built with gcc, so the escaped frames differ in layout.
23
  expected = 'inconclusive'
24
  assert all(r['reason'] == 'escaped_frame_differs' for r in report['results'])
25
  assert report['verdict'] == expected
26
- assert [r['seed'] for r in report['results']] == list(range(10))
27
  if name == 'faultless-linked-list':
28
- assert report['counts']['inconclusive'] == 10
29
  elif name == 'faultless-deep-pointer':
30
- assert report['counts']['mismatch'] == 10
31
  assert all(r['divergence'] == 'call[1]' for r in report['results'])
32
  elif name == 'faultless-heap-loop':
33
  assert report['counts']['agree'] > 0
34
  assert report['counts']['mismatch'] > 0
35
  elif name == 'faultless-memory-dependency':
36
- assert report['counts']['agree'] == 10
37
  elif name == 'faultless-byte-reinterpretation':
38
- assert report['counts'][expected] == 10
39
  else:
40
- assert report['counts']['mismatch'] == 10
41
  assert all(r['divergence'] == 'call[0]' for r in report['results'])
 
13
  rows = example_rows
14
  args = rows[EXAMPLE_NAMES.index(name)].copy()
15
  args[8] = compiler
16
+ seed_count = args[5]
17
  updates = list(run_verification(*args, report_directory=tmp_path / 'reports'))
18
  report = json.loads(Path(updates[-1][3]).read_text())
19
+ assert len(updates) == seed_count + 1
20
+ assert report['completed'] == seed_count
21
  expected = example_metadata(name)['expected_verdict']
22
  if name == 'faultless-byte-reinterpretation' and compiler == 'clang':
23
  # The original is built with gcc, so the escaped frames differ in layout.
24
  expected = 'inconclusive'
25
  assert all(r['reason'] == 'escaped_frame_differs' for r in report['results'])
26
  assert report['verdict'] == expected
27
+ assert [r['seed'] for r in report['results']] == list(range(seed_count))
28
  if name == 'faultless-linked-list':
29
+ assert report['counts']['inconclusive'] == seed_count
30
  elif name == 'faultless-deep-pointer':
31
+ assert report['counts']['mismatch'] == seed_count
32
  assert all(r['divergence'] == 'call[1]' for r in report['results'])
33
  elif name == 'faultless-heap-loop':
34
  assert report['counts']['agree'] > 0
35
  assert report['counts']['mismatch'] > 0
36
  elif name == 'faultless-memory-dependency':
37
+ assert report['counts']['agree'] == seed_count
38
  elif name == 'faultless-byte-reinterpretation':
39
+ assert report['counts'][expected] == seed_count
40
  else:
41
+ assert report['counts']['mismatch'] == seed_count
42
  assert all(r['divergence'] == 'call[0]' for r in report['results'])