simloom 0.1.0__py3-none-any.whl
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.
- simloom/__init__.py +68 -0
- simloom/_buggify.py +62 -0
- simloom/_context.py +11 -0
- simloom/_errors.py +54 -0
- simloom/_eventlog.py +95 -0
- simloom/_explore.py +145 -0
- simloom/_loop.py +784 -0
- simloom/_net.py +559 -0
- simloom/_pytest_plugin.py +66 -0
- simloom/_run.py +311 -0
- simloom/_sched.py +140 -0
- simloom/_shrink.py +194 -0
- simloom/_tape.py +309 -0
- simloom/_testing.py +203 -0
- simloom/_version.py +8 -0
- simloom/_world.py +287 -0
- simloom/py.typed +0 -0
- simloom-0.1.0.dist-info/METADATA +220 -0
- simloom-0.1.0.dist-info/RECORD +22 -0
- simloom-0.1.0.dist-info/WHEEL +4 -0
- simloom-0.1.0.dist-info/entry_points.txt +2 -0
- simloom-0.1.0.dist-info/licenses/LICENSE +202 -0
simloom/__init__.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""simloom — deterministic simulation testing for asyncio.
|
|
2
|
+
|
|
3
|
+
Run unmodified asyncio programs inside a fully simulated universe: a seeded
|
|
4
|
+
choice tape owns every scheduling decision, the clock is virtual, and every
|
|
5
|
+
failure replays exactly from its recording.
|
|
6
|
+
|
|
7
|
+
Phase A surface: the deterministic loop, the choice tape, run/replay, escape
|
|
8
|
+
detection, and the versioned event log. The simulated world (hosts, network,
|
|
9
|
+
faults) and the explorer arrive in later phases — see docs/plan.md.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from ._buggify import draw, reached, sometimes
|
|
13
|
+
from ._errors import (
|
|
14
|
+
EscapedSimulationError,
|
|
15
|
+
SimDeadlockError,
|
|
16
|
+
SimloomError,
|
|
17
|
+
TapeMisalignmentError,
|
|
18
|
+
UnhandledExceptionError,
|
|
19
|
+
)
|
|
20
|
+
from ._eventlog import EVENT_LOG_FORMAT_VERSION, EventLog
|
|
21
|
+
from ._explore import Exploration, Failure, explore
|
|
22
|
+
from ._loop import SimLoop
|
|
23
|
+
from ._net import SimNetwork, SimServer, SimTransport
|
|
24
|
+
from ._run import RunResult, replay, run
|
|
25
|
+
from ._sched import PCT, RandomWalk
|
|
26
|
+
from ._shrink import ShrinkResult, shrink
|
|
27
|
+
from ._tape import TAPE_FORMAT_VERSION, Draw, MisalignmentPolicy, Tape
|
|
28
|
+
from ._testing import Settings, SimloomTestFailure, test
|
|
29
|
+
from ._version import __version__
|
|
30
|
+
from ._world import Host, SimDisk, World
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"EVENT_LOG_FORMAT_VERSION",
|
|
34
|
+
"PCT",
|
|
35
|
+
"TAPE_FORMAT_VERSION",
|
|
36
|
+
"Draw",
|
|
37
|
+
"EscapedSimulationError",
|
|
38
|
+
"EventLog",
|
|
39
|
+
"Exploration",
|
|
40
|
+
"Failure",
|
|
41
|
+
"Host",
|
|
42
|
+
"MisalignmentPolicy",
|
|
43
|
+
"RandomWalk",
|
|
44
|
+
"RunResult",
|
|
45
|
+
"Settings",
|
|
46
|
+
"ShrinkResult",
|
|
47
|
+
"SimDeadlockError",
|
|
48
|
+
"SimDisk",
|
|
49
|
+
"SimLoop",
|
|
50
|
+
"SimNetwork",
|
|
51
|
+
"SimServer",
|
|
52
|
+
"SimTransport",
|
|
53
|
+
"SimloomError",
|
|
54
|
+
"SimloomTestFailure",
|
|
55
|
+
"Tape",
|
|
56
|
+
"TapeMisalignmentError",
|
|
57
|
+
"UnhandledExceptionError",
|
|
58
|
+
"World",
|
|
59
|
+
"__version__",
|
|
60
|
+
"draw",
|
|
61
|
+
"explore",
|
|
62
|
+
"reached",
|
|
63
|
+
"replay",
|
|
64
|
+
"run",
|
|
65
|
+
"shrink",
|
|
66
|
+
"sometimes",
|
|
67
|
+
"test",
|
|
68
|
+
]
|
simloom/_buggify.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Buggify: tape-drawn randomness for the code under test.
|
|
2
|
+
|
|
3
|
+
FoundationDB's trick, adapted: user code annotates rare-but-legal behaviors
|
|
4
|
+
(`if simloom.sometimes("drop_cache"): ...`) and the simulation explores them;
|
|
5
|
+
in production the same call is a constant False, so the annotations cost
|
|
6
|
+
nothing and never fire outside a test.
|
|
7
|
+
|
|
8
|
+
Coverage counters (``reached``, and every ``sometimes`` that fires) land in
|
|
9
|
+
``RunResult.coverage`` so a corpus runner can assert that fault-handling
|
|
10
|
+
branches were actually exercised somewhere — the "sometimes assertion" that
|
|
11
|
+
catches dead recovery code.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio.events
|
|
17
|
+
|
|
18
|
+
from ._loop import SimLoop
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _sim_loop() -> SimLoop | None:
|
|
22
|
+
loop = asyncio.events._get_running_loop()
|
|
23
|
+
return loop if isinstance(loop, SimLoop) else None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def sometimes(label: str, percent: int = 25) -> bool:
|
|
27
|
+
"""True with roughly ``percent`` probability inside a simulation;
|
|
28
|
+
always False outside one (safe to leave in production code)."""
|
|
29
|
+
if not 0 <= percent <= 100:
|
|
30
|
+
raise ValueError("percent must be in [0, 100]")
|
|
31
|
+
loop = _sim_loop()
|
|
32
|
+
if loop is None:
|
|
33
|
+
return False
|
|
34
|
+
fired = loop.tape.draw(f"buggify.{label}", 100) < percent
|
|
35
|
+
if fired:
|
|
36
|
+
loop.record_coverage(label)
|
|
37
|
+
return fired
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def draw(label: str, bound: int) -> int:
|
|
41
|
+
"""A labeled integer draw in ``[0, bound)`` from the run's choice tape.
|
|
42
|
+
|
|
43
|
+
For randomness the *program under test* needs (election timeouts, victim
|
|
44
|
+
picks in chaos scripts) — it replays and shrinks with everything else.
|
|
45
|
+
Unlike :func:`sometimes` this has no meaningful production value, so it
|
|
46
|
+
raises outside a simulation.
|
|
47
|
+
"""
|
|
48
|
+
loop = _sim_loop()
|
|
49
|
+
if loop is None:
|
|
50
|
+
raise RuntimeError("simloom.draw() requires a running simulation")
|
|
51
|
+
return loop.tape.draw(label, bound)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def reached(label: str) -> None:
|
|
55
|
+
"""Record that this point was reached (no-op outside a simulation).
|
|
56
|
+
|
|
57
|
+
Counts appear in ``RunResult.coverage``; a corpus runner can assert a
|
|
58
|
+
label was reached somewhere across many seeds.
|
|
59
|
+
"""
|
|
60
|
+
loop = _sim_loop()
|
|
61
|
+
if loop is not None:
|
|
62
|
+
loop.record_coverage(label)
|
simloom/_context.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Context variables shared across simloom modules (no internal imports,
|
|
2
|
+
so anything may import this without cycles)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from contextvars import ContextVar
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
#: The simulated host whose code is currently executing, if any. Set by
|
|
10
|
+
#: Host.spawn's wrapper coroutine; inherited by child tasks via contextvars.
|
|
11
|
+
current_host: ContextVar[Any] = ContextVar("simloom_current_host", default=None)
|
simloom/_errors.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Exception types raised by simloom."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SimloomError(Exception):
|
|
7
|
+
"""Base class for every error simloom raises on its own behalf."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EscapedSimulationError(SimloomError):
|
|
11
|
+
"""The program under test reached for the real world from inside the sim.
|
|
12
|
+
|
|
13
|
+
Determinism only holds while every effect flows through the simulated
|
|
14
|
+
loop. Real sockets, file-descriptor callbacks, signal handlers, real DNS,
|
|
15
|
+
subprocesses — any of these would reintroduce nondeterminism silently, so
|
|
16
|
+
simloom turns them into this error at the exact call site instead.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, api: str, hint: str) -> None:
|
|
20
|
+
self.api = api
|
|
21
|
+
self.hint = hint
|
|
22
|
+
super().__init__(
|
|
23
|
+
f"{api} escapes the simulation: {hint} (see docs/determinism.md for the full boundary)"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SimDeadlockError(SimloomError):
|
|
28
|
+
"""The simulated universe went quiescent with work still pending.
|
|
29
|
+
|
|
30
|
+
No callback is runnable and no timer is scheduled, but the run has not
|
|
31
|
+
finished: every remaining task is waiting on something that can no longer
|
|
32
|
+
happen. This is the classic distributed-systems deadlock, caught at the
|
|
33
|
+
moment it forms instead of as a test timeout.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TapeMisalignmentError(SimloomError):
|
|
38
|
+
"""A replayed tape could not satisfy the draw the program asked for.
|
|
39
|
+
|
|
40
|
+
Replay re-executes the program and feeds it recorded decisions; if the
|
|
41
|
+
program requests a draw whose label or bound differs from what was
|
|
42
|
+
recorded — or runs past the end of the tape — the execution has diverged
|
|
43
|
+
from the recording (changed code, unpinned hash randomization, or an
|
|
44
|
+
escape simloom failed to catch).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class UnhandledExceptionError(SimloomError):
|
|
49
|
+
"""An exception reached the loop's exception handler and nothing else.
|
|
50
|
+
|
|
51
|
+
asyncio's default behavior is to log fire-and-forget task failures and
|
|
52
|
+
keep going; a testing harness must not let them pass silently. Configure
|
|
53
|
+
with ``on_unhandled`` if a test legitimately expects orphaned failures.
|
|
54
|
+
"""
|
simloom/_eventlog.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""The event log: a versioned, public record of everything a run did.
|
|
2
|
+
|
|
3
|
+
One JSONL document per run: a header object carrying metadata, then one
|
|
4
|
+
object per event in execution order. The format is a public contract —
|
|
5
|
+
failure artifacts ship it, tooling consumes it, and the planned time-travel
|
|
6
|
+
debugger replays from it — so schema changes bump the version. The schema is
|
|
7
|
+
documented in docs/event-log.md.
|
|
8
|
+
|
|
9
|
+
The digest covers the *events only*, not the header: two runs are the same
|
|
10
|
+
universe iff their event sequences are byte-identical, regardless of which
|
|
11
|
+
machine or interpreter produced them.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
from collections.abc import Iterator, Mapping
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
EVENT_LOG_FORMAT = "simloom-events"
|
|
23
|
+
EVENT_LOG_FORMAT_VERSION = 1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _canonical(obj: Mapping[str, Any]) -> str:
|
|
27
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class EventLog:
|
|
31
|
+
"""An append-only sequence of events with canonical serialization."""
|
|
32
|
+
|
|
33
|
+
__slots__ = ("_events", "metadata")
|
|
34
|
+
|
|
35
|
+
def __init__(self) -> None:
|
|
36
|
+
self._events: list[dict[str, Any]] = []
|
|
37
|
+
#: Run metadata for the header line; never part of the digest.
|
|
38
|
+
self.metadata: dict[str, Any] = {}
|
|
39
|
+
|
|
40
|
+
def emit(self, kind: str, t: float, **fields: Any) -> None:
|
|
41
|
+
"""Append one event at virtual time ``t``.
|
|
42
|
+
|
|
43
|
+
Field values must be JSON-serializable and deterministic — no memory
|
|
44
|
+
addresses, no wall-clock times, no process-global counters.
|
|
45
|
+
"""
|
|
46
|
+
event: dict[str, Any] = {"seq": len(self._events), "kind": kind, "t": t}
|
|
47
|
+
for key, value in fields.items():
|
|
48
|
+
if key in event:
|
|
49
|
+
raise ValueError(f"reserved event field: {key}")
|
|
50
|
+
event[key] = value
|
|
51
|
+
self._events.append(event)
|
|
52
|
+
|
|
53
|
+
# --- reading ---
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def events(self) -> tuple[Mapping[str, Any], ...]:
|
|
57
|
+
return tuple(self._events)
|
|
58
|
+
|
|
59
|
+
def __len__(self) -> int:
|
|
60
|
+
return len(self._events)
|
|
61
|
+
|
|
62
|
+
def __iter__(self) -> Iterator[Mapping[str, Any]]:
|
|
63
|
+
return iter(self._events)
|
|
64
|
+
|
|
65
|
+
def __repr__(self) -> str:
|
|
66
|
+
return f"<EventLog {len(self._events)} events digest={self.digest()[:12]}>"
|
|
67
|
+
|
|
68
|
+
# --- serialization ---
|
|
69
|
+
|
|
70
|
+
def header(self) -> dict[str, Any]:
|
|
71
|
+
return {
|
|
72
|
+
"format": EVENT_LOG_FORMAT,
|
|
73
|
+
"version": EVENT_LOG_FORMAT_VERSION,
|
|
74
|
+
**self.metadata,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
def event_lines(self) -> Iterator[str]:
|
|
78
|
+
for event in self._events:
|
|
79
|
+
yield _canonical(event)
|
|
80
|
+
|
|
81
|
+
def to_jsonl(self) -> str:
|
|
82
|
+
lines = [_canonical(self.header())]
|
|
83
|
+
lines.extend(self.event_lines())
|
|
84
|
+
return "\n".join(lines) + "\n"
|
|
85
|
+
|
|
86
|
+
def write_to(self, path: str | Path) -> None:
|
|
87
|
+
Path(path).write_text(self.to_jsonl(), encoding="utf-8")
|
|
88
|
+
|
|
89
|
+
def digest(self) -> str:
|
|
90
|
+
"""sha256 over the canonical event lines (header excluded)."""
|
|
91
|
+
h = hashlib.sha256()
|
|
92
|
+
for line in self.event_lines():
|
|
93
|
+
h.update(line.encode("utf-8"))
|
|
94
|
+
h.update(b"\n")
|
|
95
|
+
return h.hexdigest()
|
simloom/_explore.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""The explorer: run many fresh universes and collect what broke.
|
|
2
|
+
|
|
3
|
+
Random exploration is embarrassingly parallel: with ``processes > 1`` seeds
|
|
4
|
+
fan out over a process pool (``main`` must then be importable — a module-
|
|
5
|
+
level callable). Workers report which seeds failed; the parent re-runs the
|
|
6
|
+
first failing seed locally so the returned artifact is exactly reproducible
|
|
7
|
+
in the caller's process.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable, Coroutine
|
|
13
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from ._run import RunResult, run
|
|
18
|
+
from ._sched import SchedulerFactory
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class Failure:
|
|
23
|
+
seed: int
|
|
24
|
+
error: str # exception type name
|
|
25
|
+
message: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(slots=True)
|
|
29
|
+
class Exploration:
|
|
30
|
+
"""What ``explore`` found across a corpus of seeds."""
|
|
31
|
+
|
|
32
|
+
runs: int
|
|
33
|
+
failures: list[Failure]
|
|
34
|
+
#: Full artifact for the lowest failing seed (replayable, shrinkable).
|
|
35
|
+
first_failure: RunResult | None
|
|
36
|
+
#: Union of buggify/reached counters across all runs — the data for
|
|
37
|
+
#: corpus-level sometimes-assertions ("was this branch ever hit?").
|
|
38
|
+
coverage: dict[str, int] = field(default_factory=dict)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def failed(self) -> bool:
|
|
42
|
+
return bool(self.failures)
|
|
43
|
+
|
|
44
|
+
def summary(self) -> str:
|
|
45
|
+
if not self.failures:
|
|
46
|
+
return f"{self.runs} universes explored, none failed"
|
|
47
|
+
first = self.failures[0]
|
|
48
|
+
return (
|
|
49
|
+
f"{self.runs} universes explored, {len(self.failures)} failed; "
|
|
50
|
+
f"first: seed {first.seed} raised {first.error}: {first.message}"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def explore(
|
|
55
|
+
main: Callable[..., Coroutine[Any, Any, Any]],
|
|
56
|
+
*,
|
|
57
|
+
runs: int,
|
|
58
|
+
start_seed: int = 0,
|
|
59
|
+
stop_on_failure: bool = True,
|
|
60
|
+
processes: int = 1,
|
|
61
|
+
scheduler: str | SchedulerFactory | None = None,
|
|
62
|
+
**run_kwargs: Any,
|
|
63
|
+
) -> Exploration:
|
|
64
|
+
"""Run ``main`` under ``runs`` fresh seeds and report the failures."""
|
|
65
|
+
if runs < 1:
|
|
66
|
+
raise ValueError("runs must be >= 1")
|
|
67
|
+
seeds = range(start_seed, start_seed + runs)
|
|
68
|
+
if processes > 1:
|
|
69
|
+
return _explore_pool(main, seeds, stop_on_failure, processes, scheduler, run_kwargs)
|
|
70
|
+
|
|
71
|
+
failures: list[Failure] = []
|
|
72
|
+
first: RunResult | None = None
|
|
73
|
+
coverage: dict[str, int] = {}
|
|
74
|
+
executed = 0
|
|
75
|
+
for seed in seeds:
|
|
76
|
+
result = run(main, seed=seed, raise_on_error=False, scheduler=scheduler, **run_kwargs)
|
|
77
|
+
executed += 1
|
|
78
|
+
for label, count in result.coverage.items():
|
|
79
|
+
coverage[label] = coverage.get(label, 0) + count
|
|
80
|
+
if result.outcome == "error":
|
|
81
|
+
assert result.error is not None
|
|
82
|
+
failures.append(Failure(seed, type(result.error).__name__, str(result.error)[:200]))
|
|
83
|
+
if first is None:
|
|
84
|
+
first = result
|
|
85
|
+
if stop_on_failure:
|
|
86
|
+
break
|
|
87
|
+
return Exploration(executed, failures, first, coverage)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# --- process-pool fan-out ---
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _probe(
|
|
94
|
+
main: Callable[..., Coroutine[Any, Any, Any]],
|
|
95
|
+
seed: int,
|
|
96
|
+
scheduler: str | None,
|
|
97
|
+
run_kwargs: dict[str, Any],
|
|
98
|
+
) -> tuple[int, str | None, str, dict[str, int]]:
|
|
99
|
+
result = run(main, seed=seed, raise_on_error=False, scheduler=scheduler, **run_kwargs)
|
|
100
|
+
error = type(result.error).__name__ if result.error is not None else None
|
|
101
|
+
message = str(result.error)[:200] if result.error is not None else ""
|
|
102
|
+
return seed, error, message, result.coverage
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _explore_pool(
|
|
106
|
+
main: Callable[..., Coroutine[Any, Any, Any]],
|
|
107
|
+
seeds: range,
|
|
108
|
+
stop_on_failure: bool,
|
|
109
|
+
processes: int,
|
|
110
|
+
scheduler: str | SchedulerFactory | None,
|
|
111
|
+
run_kwargs: dict[str, Any],
|
|
112
|
+
) -> Exploration:
|
|
113
|
+
if scheduler is not None and not isinstance(scheduler, str):
|
|
114
|
+
raise TypeError("processes > 1 requires a string scheduler spec (picklable)")
|
|
115
|
+
failures: list[Failure] = []
|
|
116
|
+
coverage: dict[str, int] = {}
|
|
117
|
+
executed = 0
|
|
118
|
+
with ProcessPoolExecutor(max_workers=processes) as pool:
|
|
119
|
+
for seed, error, message, run_coverage in pool.map(
|
|
120
|
+
_probe,
|
|
121
|
+
(main for _ in seeds),
|
|
122
|
+
seeds,
|
|
123
|
+
(scheduler for _ in seeds),
|
|
124
|
+
(run_kwargs for _ in seeds),
|
|
125
|
+
chunksize=8,
|
|
126
|
+
):
|
|
127
|
+
executed += 1
|
|
128
|
+
for label, count in run_coverage.items():
|
|
129
|
+
coverage[label] = coverage.get(label, 0) + count
|
|
130
|
+
if error is not None:
|
|
131
|
+
failures.append(Failure(seed, error, message))
|
|
132
|
+
if stop_on_failure:
|
|
133
|
+
break
|
|
134
|
+
failures.sort(key=lambda f: f.seed)
|
|
135
|
+
first: RunResult | None = None
|
|
136
|
+
if failures:
|
|
137
|
+
# Re-run locally: the artifact must reproduce in the caller's process.
|
|
138
|
+
first = run(
|
|
139
|
+
main,
|
|
140
|
+
seed=failures[0].seed,
|
|
141
|
+
raise_on_error=False,
|
|
142
|
+
scheduler=scheduler,
|
|
143
|
+
**run_kwargs,
|
|
144
|
+
)
|
|
145
|
+
return Exploration(executed, failures, first, coverage)
|