shrinkray 25.12.26__tar.gz → 25.12.26.1__tar.gz
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.
- {shrinkray-25.12.26/src/shrinkray.egg-info → shrinkray-25.12.26.1}/PKG-INFO +11 -7
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/README.md +10 -6
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/pyproject.toml +1 -1
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray/problem.py +27 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray/reducer.py +129 -3
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray/subprocess/client.py +34 -1
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray/subprocess/protocol.py +49 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray/subprocess/worker.py +104 -11
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray/tui.py +324 -4
- {shrinkray-25.12.26 → shrinkray-25.12.26.1/src/shrinkray.egg-info}/PKG-INFO +11 -7
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/tests/test_clang_delta.py +1 -1
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/tests/test_reducer.py +359 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/tests/test_subprocess_client.py +87 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/tests/test_subprocess_protocol.py +88 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/tests/test_subprocess_worker.py +311 -0
- shrinkray-25.12.26.1/tests/test_tui.py +3750 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/tests/test_tui_snapshots.py +80 -1
- shrinkray-25.12.26/tests/test_tui.py +0 -1720
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/LICENSE +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/setup.cfg +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray/__init__.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray/__main__.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray/cli.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray/display.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray/formatting.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray/passes/__init__.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray/passes/bytes.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray/passes/clangdelta.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray/passes/definitions.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray/passes/genericlanguages.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray/passes/json.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray/passes/patching.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray/passes/python.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray/passes/sat.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray/passes/sequences.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray/process.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray/py.typed +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray/state.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray/subprocess/__init__.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray/ui.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray/work.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray.egg-info/SOURCES.txt +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray.egg-info/dependency_links.txt +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray.egg-info/entry_points.txt +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray.egg-info/requires.txt +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/src/shrinkray.egg-info/top_level.txt +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/tests/test_byte_reduction_passes.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/tests/test_cli.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/tests/test_definitions.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/tests/test_dimacs_cnf.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/tests/test_display.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/tests/test_formatting.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/tests/test_generic_language.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/tests/test_generic_shrinking_properties.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/tests/test_json_passes.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/tests/test_main.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/tests/test_misc_reduction_performance.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/tests/test_patching.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/tests/test_problem.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/tests/test_process.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/tests/test_python_reducers.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/tests/test_reduction_passes.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/tests/test_sat.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/tests/test_state.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/tests/test_subprocess_integration.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/tests/test_ui.py +0 -0
- {shrinkray-25.12.26 → shrinkray-25.12.26.1}/tests/test_work.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: shrinkray
|
|
3
|
-
Version: 25.12.26
|
|
3
|
+
Version: 25.12.26.1
|
|
4
4
|
Summary: Shrink Ray
|
|
5
5
|
Author-email: "David R. MacIver" <david@drmaciver.com>
|
|
6
6
|
License: MIT
|
|
@@ -89,15 +89,19 @@ Most test-case reducers only work well on a few formats. Shrink Ray is designed
|
|
|
89
89
|
|
|
90
90
|
It's designed to be highly parallel, and work with a very wide variety of formats, through a mix of good generic algorithms and format-specific reduction passes.
|
|
91
91
|
|
|
92
|
-
|
|
92
|
+
## Versioning and Releases
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
Shrink Ray uses calendar versioning (calver) in the format YY.M.D.N (e.g., 25.12.26.0 for the first release on December 26, 2025, 25.12.26.1 for the second, etc.).
|
|
95
|
+
|
|
96
|
+
New releases are published automatically when changes are pushed to main if there are any changes to the source code or pyproject.toml since the previous release.
|
|
97
|
+
|
|
98
|
+
Shrinkray makes no particularly strong backwards compatibility guarantees. I aim to keep its behaviour relatively stable between releases, but for example will not be particularly shy about dropping old versions of Python or adding new dependencies. The basic workflow of running a simple reduction will rarely, if ever, change, but the UI is likely to be continuously evolving for some time.
|
|
95
99
|
|
|
96
100
|
## Installation
|
|
97
101
|
|
|
98
|
-
Shrink Ray requires Python 3.
|
|
102
|
+
Shrink Ray requires Python 3.12 or later, and can be installed using pip or uv like any other python package.
|
|
99
103
|
|
|
100
|
-
|
|
104
|
+
You can install the latest release from PyPI or run directly from the main branch:
|
|
101
105
|
|
|
102
106
|
```
|
|
103
107
|
pipx install shrinkray
|
|
@@ -107,8 +111,8 @@ pipx install git+https://github.com/DRMacIver/shrinkray.git
|
|
|
107
111
|
|
|
108
112
|
(if you don't have or want [pipx](https://pypa.github.io/pipx/) you could also do this with pip or `uv pip` and it would work fine)
|
|
109
113
|
|
|
110
|
-
Shrink Ray requires Python 3.
|
|
111
|
-
on versions it's incompatible with. If you do not have Python 3.
|
|
114
|
+
Shrink Ray requires Python 3.12 or later and won't work on earlier versions. If everything is working correctly, it should refuse to install
|
|
115
|
+
on versions it's incompatible with. If you do not have Python 3.12 installed, I recommend [pyenv](https://github.com/pyenv/pyenv) for managing
|
|
112
116
|
Python installs.
|
|
113
117
|
|
|
114
118
|
If you want to use it from the git repo directly, you can do the following:
|
|
@@ -50,15 +50,19 @@ Most test-case reducers only work well on a few formats. Shrink Ray is designed
|
|
|
50
50
|
|
|
51
51
|
It's designed to be highly parallel, and work with a very wide variety of formats, through a mix of good generic algorithms and format-specific reduction passes.
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
## Versioning and Releases
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
Shrink Ray uses calendar versioning (calver) in the format YY.M.D.N (e.g., 25.12.26.0 for the first release on December 26, 2025, 25.12.26.1 for the second, etc.).
|
|
56
|
+
|
|
57
|
+
New releases are published automatically when changes are pushed to main if there are any changes to the source code or pyproject.toml since the previous release.
|
|
58
|
+
|
|
59
|
+
Shrinkray makes no particularly strong backwards compatibility guarantees. I aim to keep its behaviour relatively stable between releases, but for example will not be particularly shy about dropping old versions of Python or adding new dependencies. The basic workflow of running a simple reduction will rarely, if ever, change, but the UI is likely to be continuously evolving for some time.
|
|
56
60
|
|
|
57
61
|
## Installation
|
|
58
62
|
|
|
59
|
-
Shrink Ray requires Python 3.
|
|
63
|
+
Shrink Ray requires Python 3.12 or later, and can be installed using pip or uv like any other python package.
|
|
60
64
|
|
|
61
|
-
|
|
65
|
+
You can install the latest release from PyPI or run directly from the main branch:
|
|
62
66
|
|
|
63
67
|
```
|
|
64
68
|
pipx install shrinkray
|
|
@@ -68,8 +72,8 @@ pipx install git+https://github.com/DRMacIver/shrinkray.git
|
|
|
68
72
|
|
|
69
73
|
(if you don't have or want [pipx](https://pypa.github.io/pipx/) you could also do this with pip or `uv pip` and it would work fine)
|
|
70
74
|
|
|
71
|
-
Shrink Ray requires Python 3.
|
|
72
|
-
on versions it's incompatible with. If you do not have Python 3.
|
|
75
|
+
Shrink Ray requires Python 3.12 or later and won't work on earlier versions. If everything is working correctly, it should refuse to install
|
|
76
|
+
on versions it's incompatible with. If you do not have Python 3.12 installed, I recommend [pyenv](https://github.com/pyenv/pyenv) for managing
|
|
73
77
|
Python installs.
|
|
74
78
|
|
|
75
79
|
If you want to use it from the git repo directly, you can do the following:
|
|
@@ -19,6 +19,7 @@ from datetime import timedelta
|
|
|
19
19
|
from typing import (
|
|
20
20
|
TYPE_CHECKING,
|
|
21
21
|
Any,
|
|
22
|
+
Protocol,
|
|
22
23
|
TypeVar,
|
|
23
24
|
cast,
|
|
24
25
|
)
|
|
@@ -38,6 +39,18 @@ S = TypeVar("S")
|
|
|
38
39
|
T = TypeVar("T")
|
|
39
40
|
|
|
40
41
|
|
|
42
|
+
class PassStatsProtocol(Protocol):
|
|
43
|
+
"""Protocol for pass statistics tracking.
|
|
44
|
+
|
|
45
|
+
This allows problem.py to track stats without importing from reducer.py,
|
|
46
|
+
avoiding circular dependencies.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
test_evaluations: int
|
|
50
|
+
successful_reductions: int
|
|
51
|
+
bytes_deleted: int
|
|
52
|
+
|
|
53
|
+
|
|
41
54
|
def shortlex[SizedT: Sized](value: SizedT) -> tuple[int, SizedT]:
|
|
42
55
|
"""Return a comparison key for shortlex ordering.
|
|
43
56
|
|
|
@@ -162,6 +175,8 @@ class ReductionProblem[T](ABC):
|
|
|
162
175
|
"""
|
|
163
176
|
|
|
164
177
|
work: WorkContext
|
|
178
|
+
# Track current pass stats for real-time updates (set by reducer)
|
|
179
|
+
current_pass_stats: PassStatsProtocol | None = None
|
|
165
180
|
|
|
166
181
|
def __attrs_post_init__(self) -> None:
|
|
167
182
|
# Cache of View objects for each Format, to avoid re-parsing
|
|
@@ -377,6 +392,11 @@ class BasicReductionProblem(ReductionProblem[T]):
|
|
|
377
392
|
self.__is_interesting_cache[cache_key] = result
|
|
378
393
|
self.stats.failed_reductions += 1
|
|
379
394
|
self.stats.calls += 1
|
|
395
|
+
|
|
396
|
+
# Update current pass stats if a pass is running
|
|
397
|
+
if self.current_pass_stats is not None:
|
|
398
|
+
self.current_pass_stats.test_evaluations += 1
|
|
399
|
+
|
|
380
400
|
if result:
|
|
381
401
|
self.stats.interesting_calls += 1
|
|
382
402
|
if self.sort_key(test_case) < self.sort_key(self.current_test_case):
|
|
@@ -384,6 +404,13 @@ class BasicReductionProblem(ReductionProblem[T]):
|
|
|
384
404
|
self.stats.failed_reductions -= 1
|
|
385
405
|
self.stats.reductions += 1
|
|
386
406
|
self.stats.time_of_last_reduction = time.time()
|
|
407
|
+
|
|
408
|
+
# Update current pass stats for reductions
|
|
409
|
+
if self.current_pass_stats is not None:
|
|
410
|
+
self.current_pass_stats.successful_reductions += 1
|
|
411
|
+
size_diff = self.size(self.current_test_case) - self.size(test_case)
|
|
412
|
+
self.current_pass_stats.bytes_deleted += size_diff
|
|
413
|
+
|
|
387
414
|
self.stats.current_test_case_size = self.size(test_case)
|
|
388
415
|
self.__current = test_case
|
|
389
416
|
for f in self.__on_reduce_callbacks:
|
|
@@ -56,6 +56,11 @@ from shrinkray.problem import ReductionProblem, ReductionStats, shortlex
|
|
|
56
56
|
class Reducer[T](ABC):
|
|
57
57
|
target: ReductionProblem[T]
|
|
58
58
|
|
|
59
|
+
# Optional pass statistics tracking (implemented by ShrinkRay)
|
|
60
|
+
pass_stats: "PassStatsTracker | None" = attrs.field(default=None, init=False)
|
|
61
|
+
# Optional current pass tracking (implemented by ShrinkRay)
|
|
62
|
+
current_reduction_pass: "ReductionPass[T] | None" = attrs.field(default=None, init=False)
|
|
63
|
+
|
|
59
64
|
@contextmanager
|
|
60
65
|
def backtrack(self, restart: T) -> Generator[None, None, None]:
|
|
61
66
|
current = self.target
|
|
@@ -72,6 +77,20 @@ class Reducer[T](ABC):
|
|
|
72
77
|
def status(self) -> str:
|
|
73
78
|
return ""
|
|
74
79
|
|
|
80
|
+
@property
|
|
81
|
+
def disabled_passes(self) -> set[str]:
|
|
82
|
+
"""Set of disabled pass names. Override in subclasses for pass control."""
|
|
83
|
+
return set()
|
|
84
|
+
|
|
85
|
+
def disable_pass(self, pass_name: str) -> None: # noqa: B027
|
|
86
|
+
"""Disable a pass by name. Override in subclasses for pass control."""
|
|
87
|
+
|
|
88
|
+
def enable_pass(self, pass_name: str) -> None: # noqa: B027
|
|
89
|
+
"""Enable a pass by name. Override in subclasses for pass control."""
|
|
90
|
+
|
|
91
|
+
def skip_current_pass(self) -> None: # noqa: B027
|
|
92
|
+
"""Skip the currently running pass. Override in subclasses for pass control."""
|
|
93
|
+
|
|
75
94
|
|
|
76
95
|
@define
|
|
77
96
|
class BasicReducer[T](Reducer[T]):
|
|
@@ -117,14 +136,88 @@ class RestartPass(Exception):
|
|
|
117
136
|
pass
|
|
118
137
|
|
|
119
138
|
|
|
139
|
+
@define
|
|
140
|
+
class PassStats:
|
|
141
|
+
"""Statistics for a single reduction pass."""
|
|
142
|
+
|
|
143
|
+
pass_name: str
|
|
144
|
+
bytes_deleted: int = 0
|
|
145
|
+
run_count: int = 0
|
|
146
|
+
test_evaluations: int = 0
|
|
147
|
+
successful_reductions: int = 0
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def success_rate(self) -> float:
|
|
151
|
+
"""Percentage of test evaluations that led to a reduction."""
|
|
152
|
+
if self.test_evaluations == 0:
|
|
153
|
+
return 0.0
|
|
154
|
+
return (self.successful_reductions / self.test_evaluations) * 100.0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@define
|
|
158
|
+
class PassStatsTracker:
|
|
159
|
+
"""Tracks statistics for all reduction passes.
|
|
160
|
+
|
|
161
|
+
Python 3.7+ dicts maintain insertion order, so stats are returned
|
|
162
|
+
in the order passes were first run.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
_stats: dict[str, PassStats] = attrs.Factory(dict)
|
|
166
|
+
|
|
167
|
+
def get_or_create(self, pass_name: str) -> PassStats:
|
|
168
|
+
if pass_name not in self._stats:
|
|
169
|
+
self._stats[pass_name] = PassStats(pass_name=pass_name)
|
|
170
|
+
return self._stats[pass_name]
|
|
171
|
+
|
|
172
|
+
def get_stats_in_order(self) -> list[PassStats]:
|
|
173
|
+
"""Get stats in the order passes were first run."""
|
|
174
|
+
return list(self._stats.values())
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class SkipPass(Exception):
|
|
178
|
+
"""Raised to skip the current pass."""
|
|
179
|
+
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
|
|
120
183
|
@define
|
|
121
184
|
class ShrinkRay(Reducer[bytes]):
|
|
122
185
|
clang_delta: ClangDelta | None = None
|
|
123
186
|
|
|
124
|
-
current_reduction_pass: ReductionPass[bytes] | None = None
|
|
125
187
|
current_pump: ReductionPump[bytes] | None = None
|
|
126
188
|
|
|
127
189
|
unlocked_ok_passes: bool = False
|
|
190
|
+
pass_stats: PassStatsTracker | None = attrs.Factory(PassStatsTracker)
|
|
191
|
+
|
|
192
|
+
# Pass control: disabled passes and skip functionality
|
|
193
|
+
disabled_passes: set[str] = attrs.Factory(set)
|
|
194
|
+
_skip_requested: bool = attrs.field(default=False, init=False)
|
|
195
|
+
_current_pass_scope: "trio.CancelScope | None" = attrs.field(default=None, init=False)
|
|
196
|
+
_passes_were_skipped: bool = attrs.field(default=False, init=False)
|
|
197
|
+
|
|
198
|
+
def disable_pass(self, pass_name: str) -> None:
|
|
199
|
+
"""Disable a pass by name. If it's currently running, skip it."""
|
|
200
|
+
self.disabled_passes.add(pass_name)
|
|
201
|
+
# If this pass is currently running, skip it
|
|
202
|
+
if (
|
|
203
|
+
self.current_reduction_pass is not None
|
|
204
|
+
and self.current_reduction_pass.__name__ == pass_name
|
|
205
|
+
):
|
|
206
|
+
self.skip_current_pass()
|
|
207
|
+
|
|
208
|
+
def enable_pass(self, pass_name: str) -> None:
|
|
209
|
+
"""Enable a previously disabled pass."""
|
|
210
|
+
self.disabled_passes.discard(pass_name)
|
|
211
|
+
|
|
212
|
+
def is_pass_disabled(self, pass_name: str) -> bool:
|
|
213
|
+
"""Check if a pass is disabled."""
|
|
214
|
+
return pass_name in self.disabled_passes
|
|
215
|
+
|
|
216
|
+
def skip_current_pass(self) -> None:
|
|
217
|
+
"""Request to skip the currently running pass."""
|
|
218
|
+
self._skip_requested = True
|
|
219
|
+
if self._current_pass_scope is not None:
|
|
220
|
+
self._current_pass_scope.cancel()
|
|
128
221
|
|
|
129
222
|
initial_cuts: list[ReductionPass[bytes]] = attrs.Factory(
|
|
130
223
|
lambda: [
|
|
@@ -222,12 +315,39 @@ class ShrinkRay(Reducer[bytes]):
|
|
|
222
315
|
return f"Running reduction pump {self.current_pump.__name__}"
|
|
223
316
|
|
|
224
317
|
async def run_pass(self, rp: ReductionPass[bytes]) -> None:
|
|
318
|
+
pass_name = rp.__name__
|
|
319
|
+
|
|
320
|
+
# Skip if pass is disabled
|
|
321
|
+
if self.is_pass_disabled(pass_name):
|
|
322
|
+
return
|
|
323
|
+
|
|
225
324
|
try:
|
|
226
325
|
assert self.current_reduction_pass is None
|
|
227
326
|
self.current_reduction_pass = rp
|
|
228
|
-
|
|
327
|
+
self._skip_requested = False
|
|
328
|
+
|
|
329
|
+
# Get or create stats entry for this pass
|
|
330
|
+
assert self.pass_stats is not None # Always set by Factory
|
|
331
|
+
stats_entry = self.pass_stats.get_or_create(pass_name)
|
|
332
|
+
stats_entry.run_count += 1
|
|
333
|
+
|
|
334
|
+
# Set current pass stats on the problem for real-time updates
|
|
335
|
+
self.target.current_pass_stats = stats_entry
|
|
336
|
+
|
|
337
|
+
# Run the pass with a cancel scope that can be externally cancelled
|
|
338
|
+
with trio.CancelScope() as scope:
|
|
339
|
+
self._current_pass_scope = scope
|
|
340
|
+
await rp(self.target)
|
|
341
|
+
|
|
342
|
+
# If the pass was cancelled/skipped, mark that passes were skipped
|
|
343
|
+
if scope.cancelled_caught:
|
|
344
|
+
self._passes_were_skipped = True
|
|
345
|
+
|
|
229
346
|
finally:
|
|
230
347
|
self.current_reduction_pass = None
|
|
348
|
+
self.target.current_pass_stats = None
|
|
349
|
+
self._current_pass_scope = None
|
|
350
|
+
self._skip_requested = False
|
|
231
351
|
|
|
232
352
|
async def pump(self, rp: ReductionPump[bytes]) -> None:
|
|
233
353
|
try:
|
|
@@ -357,6 +477,9 @@ class ShrinkRay(Reducer[bytes]):
|
|
|
357
477
|
await self.initial_cut()
|
|
358
478
|
|
|
359
479
|
while True:
|
|
480
|
+
# Reset skip tracking for this iteration
|
|
481
|
+
self._passes_were_skipped = False
|
|
482
|
+
|
|
360
483
|
prev = self.target.current_test_case
|
|
361
484
|
await self.run_some_passes()
|
|
362
485
|
if self.target.current_test_case != prev:
|
|
@@ -364,7 +487,10 @@ class ShrinkRay(Reducer[bytes]):
|
|
|
364
487
|
for pump in self.pumps:
|
|
365
488
|
await self.pump(pump)
|
|
366
489
|
if self.target.current_test_case == prev:
|
|
367
|
-
|
|
490
|
+
# Only terminate if no passes were skipped
|
|
491
|
+
# If passes were skipped, we need another full run to be sure
|
|
492
|
+
if not self._passes_were_skipped:
|
|
493
|
+
break
|
|
368
494
|
|
|
369
495
|
|
|
370
496
|
class UpdateKeys(Patches[dict[str, bytes], dict[str, bytes]]):
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import sys
|
|
5
|
+
import traceback
|
|
5
6
|
import uuid
|
|
6
7
|
from collections.abc import AsyncIterator
|
|
7
8
|
from typing import Any
|
|
@@ -58,6 +59,7 @@ class SubprocessClient:
|
|
|
58
59
|
if line:
|
|
59
60
|
await self._handle_message(line.decode("utf-8"))
|
|
60
61
|
except Exception:
|
|
62
|
+
traceback.print_exc()
|
|
61
63
|
break
|
|
62
64
|
|
|
63
65
|
async def _handle_message(self, line: str) -> None:
|
|
@@ -65,6 +67,7 @@ class SubprocessClient:
|
|
|
65
67
|
try:
|
|
66
68
|
msg = deserialize(line)
|
|
67
69
|
except Exception:
|
|
70
|
+
traceback.print_exc()
|
|
68
71
|
return
|
|
69
72
|
|
|
70
73
|
if isinstance(msg, ProgressUpdate):
|
|
@@ -167,6 +170,36 @@ class SubprocessClient:
|
|
|
167
170
|
except Exception:
|
|
168
171
|
return Response(id="", result={"status": "cancelled"})
|
|
169
172
|
|
|
173
|
+
async def disable_pass(self, pass_name: str) -> Response:
|
|
174
|
+
"""Disable a reduction pass by name."""
|
|
175
|
+
if self._completed:
|
|
176
|
+
return Response(id="", result={"status": "already_completed"})
|
|
177
|
+
try:
|
|
178
|
+
return await self.send_command("disable_pass", {"pass_name": pass_name})
|
|
179
|
+
except Exception:
|
|
180
|
+
traceback.print_exc()
|
|
181
|
+
return Response(id="", error="Failed to disable pass")
|
|
182
|
+
|
|
183
|
+
async def enable_pass(self, pass_name: str) -> Response:
|
|
184
|
+
"""Enable a previously disabled reduction pass."""
|
|
185
|
+
if self._completed:
|
|
186
|
+
return Response(id="", result={"status": "already_completed"})
|
|
187
|
+
try:
|
|
188
|
+
return await self.send_command("enable_pass", {"pass_name": pass_name})
|
|
189
|
+
except Exception:
|
|
190
|
+
traceback.print_exc()
|
|
191
|
+
return Response(id="", error="Failed to enable pass")
|
|
192
|
+
|
|
193
|
+
async def skip_current_pass(self) -> Response:
|
|
194
|
+
"""Skip the currently running pass."""
|
|
195
|
+
if self._completed:
|
|
196
|
+
return Response(id="", result={"status": "already_completed"})
|
|
197
|
+
try:
|
|
198
|
+
return await self.send_command("skip_pass")
|
|
199
|
+
except Exception:
|
|
200
|
+
traceback.print_exc()
|
|
201
|
+
return Response(id="", error="Failed to skip pass")
|
|
202
|
+
|
|
170
203
|
async def get_progress_updates(self) -> AsyncIterator[ProgressUpdate]:
|
|
171
204
|
"""Yield progress updates as they arrive."""
|
|
172
205
|
while not self._completed:
|
|
@@ -200,7 +233,7 @@ class SubprocessClient:
|
|
|
200
233
|
try:
|
|
201
234
|
self._process.stdin.close()
|
|
202
235
|
except Exception:
|
|
203
|
-
|
|
236
|
+
traceback.print_exc()
|
|
204
237
|
# Only terminate if still running
|
|
205
238
|
if self._process.returncode is None:
|
|
206
239
|
try:
|
|
@@ -50,6 +50,24 @@ class ProgressUpdate:
|
|
|
50
50
|
content_preview: str = ""
|
|
51
51
|
# Whether content is hex mode
|
|
52
52
|
hex_mode: bool = False
|
|
53
|
+
# Pass statistics (only passes with test evaluations)
|
|
54
|
+
pass_stats: list["PassStatsData"] = field(default_factory=list)
|
|
55
|
+
# Currently running pass name (for highlighting)
|
|
56
|
+
current_pass_name: str = ""
|
|
57
|
+
# List of disabled pass names
|
|
58
|
+
disabled_passes: list[str] = field(default_factory=list)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class PassStatsData:
|
|
63
|
+
"""Statistics for a single pass (serializable)."""
|
|
64
|
+
|
|
65
|
+
pass_name: str
|
|
66
|
+
bytes_deleted: int
|
|
67
|
+
run_count: int
|
|
68
|
+
test_evaluations: int
|
|
69
|
+
successful_reductions: int
|
|
70
|
+
success_rate: float
|
|
53
71
|
|
|
54
72
|
|
|
55
73
|
def serialize(msg: Request | Response | ProgressUpdate) -> str:
|
|
@@ -84,6 +102,19 @@ def serialize(msg: Request | Response | ProgressUpdate) -> str:
|
|
|
84
102
|
"time_since_last_reduction": msg.time_since_last_reduction,
|
|
85
103
|
"content_preview": msg.content_preview,
|
|
86
104
|
"hex_mode": msg.hex_mode,
|
|
105
|
+
"pass_stats": [
|
|
106
|
+
{
|
|
107
|
+
"pass_name": ps.pass_name,
|
|
108
|
+
"bytes_deleted": ps.bytes_deleted,
|
|
109
|
+
"run_count": ps.run_count,
|
|
110
|
+
"test_evaluations": ps.test_evaluations,
|
|
111
|
+
"successful_reductions": ps.successful_reductions,
|
|
112
|
+
"success_rate": ps.success_rate,
|
|
113
|
+
}
|
|
114
|
+
for ps in msg.pass_stats
|
|
115
|
+
],
|
|
116
|
+
"current_pass_name": msg.current_pass_name,
|
|
117
|
+
"disabled_passes": msg.disabled_passes,
|
|
87
118
|
},
|
|
88
119
|
}
|
|
89
120
|
else:
|
|
@@ -98,6 +129,21 @@ def deserialize(line: str) -> Request | Response | ProgressUpdate:
|
|
|
98
129
|
# Check for progress update (has "type" field)
|
|
99
130
|
if data.get("type") == "progress":
|
|
100
131
|
d = data["data"]
|
|
132
|
+
|
|
133
|
+
# Parse pass stats
|
|
134
|
+
pass_stats_data = []
|
|
135
|
+
for ps_dict in d.get("pass_stats", []):
|
|
136
|
+
pass_stats_data.append(
|
|
137
|
+
PassStatsData(
|
|
138
|
+
pass_name=ps_dict["pass_name"],
|
|
139
|
+
bytes_deleted=ps_dict["bytes_deleted"],
|
|
140
|
+
run_count=ps_dict["run_count"],
|
|
141
|
+
test_evaluations=ps_dict["test_evaluations"],
|
|
142
|
+
successful_reductions=ps_dict["successful_reductions"],
|
|
143
|
+
success_rate=ps_dict["success_rate"],
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
|
|
101
147
|
return ProgressUpdate(
|
|
102
148
|
status=d["status"],
|
|
103
149
|
size=d["size"],
|
|
@@ -113,6 +159,9 @@ def deserialize(line: str) -> Request | Response | ProgressUpdate:
|
|
|
113
159
|
time_since_last_reduction=d.get("time_since_last_reduction", 0.0),
|
|
114
160
|
content_preview=d.get("content_preview", ""),
|
|
115
161
|
hex_mode=d.get("hex_mode", False),
|
|
162
|
+
pass_stats=pass_stats_data,
|
|
163
|
+
current_pass_name=d.get("current_pass_name", ""),
|
|
164
|
+
disabled_passes=d.get("disabled_passes", []),
|
|
116
165
|
)
|
|
117
166
|
|
|
118
167
|
# Check for response (has "result" or "error" field)
|
|
@@ -11,6 +11,7 @@ from binaryornot.helpers import is_binary_string
|
|
|
11
11
|
|
|
12
12
|
from shrinkray.problem import InvalidInitialExample
|
|
13
13
|
from shrinkray.subprocess.protocol import (
|
|
14
|
+
PassStatsData,
|
|
14
15
|
ProgressUpdate,
|
|
15
16
|
Request,
|
|
16
17
|
Response,
|
|
@@ -104,21 +105,26 @@ class ReducerWorker:
|
|
|
104
105
|
response = await self.handle_command(request)
|
|
105
106
|
await self.emit(response)
|
|
106
107
|
except Exception as e:
|
|
108
|
+
traceback.print_exc()
|
|
107
109
|
await self.emit(Response(id="", error=str(e)))
|
|
108
110
|
|
|
109
111
|
async def handle_command(self, request: Request) -> Response:
|
|
110
112
|
"""Handle a command request and return a response."""
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
113
|
+
match request.command:
|
|
114
|
+
case "start":
|
|
115
|
+
return await self._handle_start(request.id, request.params)
|
|
116
|
+
case "status":
|
|
117
|
+
return self._handle_status(request.id)
|
|
118
|
+
case "cancel":
|
|
119
|
+
return self._handle_cancel(request.id)
|
|
120
|
+
case "disable_pass":
|
|
121
|
+
return self._handle_disable_pass(request.id, request.params)
|
|
122
|
+
case "enable_pass":
|
|
123
|
+
return self._handle_enable_pass(request.id, request.params)
|
|
124
|
+
case "skip_pass":
|
|
125
|
+
return self._handle_skip_pass(request.id)
|
|
126
|
+
case _:
|
|
127
|
+
return Response(id=request.id, error=f"Unknown command: {request.command}")
|
|
122
128
|
|
|
123
129
|
async def _handle_start(self, request_id: str, params: dict) -> Response:
|
|
124
130
|
"""Start the reduction process."""
|
|
@@ -236,6 +242,55 @@ class ReducerWorker:
|
|
|
236
242
|
self.running = False
|
|
237
243
|
return Response(id=request_id, result={"status": "cancelled"})
|
|
238
244
|
|
|
245
|
+
def _get_known_pass_names(self) -> set[str]:
|
|
246
|
+
"""Get the set of known pass names from pass stats."""
|
|
247
|
+
if self.reducer is None or self.reducer.pass_stats is None:
|
|
248
|
+
return set()
|
|
249
|
+
return set(self.reducer.pass_stats._stats.keys())
|
|
250
|
+
|
|
251
|
+
def _handle_disable_pass(self, request_id: str, params: dict) -> Response:
|
|
252
|
+
"""Disable a reduction pass by name."""
|
|
253
|
+
pass_name = params.get("pass_name", "")
|
|
254
|
+
if not pass_name:
|
|
255
|
+
return Response(id=request_id, error="pass_name is required")
|
|
256
|
+
|
|
257
|
+
known_passes = self._get_known_pass_names()
|
|
258
|
+
if known_passes and pass_name not in known_passes:
|
|
259
|
+
return Response(
|
|
260
|
+
id=request_id,
|
|
261
|
+
error=f"Unknown pass '{pass_name}'. Known passes: {sorted(known_passes)}",
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
if self.reducer is not None and hasattr(self.reducer, "disable_pass"):
|
|
265
|
+
self.reducer.disable_pass(pass_name)
|
|
266
|
+
return Response(id=request_id, result={"status": "disabled", "pass_name": pass_name})
|
|
267
|
+
return Response(id=request_id, error="Reducer does not support pass control")
|
|
268
|
+
|
|
269
|
+
def _handle_enable_pass(self, request_id: str, params: dict) -> Response:
|
|
270
|
+
"""Enable a previously disabled reduction pass."""
|
|
271
|
+
pass_name = params.get("pass_name", "")
|
|
272
|
+
if not pass_name:
|
|
273
|
+
return Response(id=request_id, error="pass_name is required")
|
|
274
|
+
|
|
275
|
+
known_passes = self._get_known_pass_names()
|
|
276
|
+
if known_passes and pass_name not in known_passes:
|
|
277
|
+
return Response(
|
|
278
|
+
id=request_id,
|
|
279
|
+
error=f"Unknown pass '{pass_name}'. Known passes: {sorted(known_passes)}",
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
if self.reducer is not None and hasattr(self.reducer, "enable_pass"):
|
|
283
|
+
self.reducer.enable_pass(pass_name)
|
|
284
|
+
return Response(id=request_id, result={"status": "enabled", "pass_name": pass_name})
|
|
285
|
+
return Response(id=request_id, error="Reducer does not support pass control")
|
|
286
|
+
|
|
287
|
+
def _handle_skip_pass(self, request_id: str) -> Response:
|
|
288
|
+
"""Skip the currently running pass."""
|
|
289
|
+
if self.reducer is not None and hasattr(self.reducer, "skip_current_pass"):
|
|
290
|
+
self.reducer.skip_current_pass()
|
|
291
|
+
return Response(id=request_id, result={"status": "skipped"})
|
|
292
|
+
return Response(id=request_id, error="Reducer does not support pass control")
|
|
293
|
+
|
|
239
294
|
def _get_content_preview(self) -> tuple[str, bool]:
|
|
240
295
|
"""Get a preview of the current test case content."""
|
|
241
296
|
if self.problem is None:
|
|
@@ -304,6 +359,40 @@ class ReducerWorker:
|
|
|
304
359
|
)
|
|
305
360
|
effective_parallelism = average_parallelism * (1.0 - wasteage)
|
|
306
361
|
|
|
362
|
+
# Collect pass statistics in run order (only those with test evaluations)
|
|
363
|
+
pass_stats_list = []
|
|
364
|
+
current_pass_name = ""
|
|
365
|
+
if self.reducer is not None:
|
|
366
|
+
# Get the currently running pass name
|
|
367
|
+
current_pass = self.reducer.current_reduction_pass
|
|
368
|
+
if current_pass is not None:
|
|
369
|
+
current_pass_name = getattr(current_pass, "__name__", "")
|
|
370
|
+
|
|
371
|
+
# Get all stats in the order they were first run
|
|
372
|
+
pass_stats = self.reducer.pass_stats
|
|
373
|
+
if pass_stats is not None:
|
|
374
|
+
all_stats = pass_stats.get_stats_in_order()
|
|
375
|
+
|
|
376
|
+
# Only include passes that have made at least one test evaluation
|
|
377
|
+
pass_stats_list = [
|
|
378
|
+
PassStatsData(
|
|
379
|
+
pass_name=ps.pass_name,
|
|
380
|
+
bytes_deleted=ps.bytes_deleted,
|
|
381
|
+
run_count=ps.run_count,
|
|
382
|
+
test_evaluations=ps.test_evaluations,
|
|
383
|
+
successful_reductions=ps.successful_reductions,
|
|
384
|
+
success_rate=ps.success_rate,
|
|
385
|
+
)
|
|
386
|
+
for ps in all_stats
|
|
387
|
+
if ps.test_evaluations > 0
|
|
388
|
+
]
|
|
389
|
+
|
|
390
|
+
# Get disabled passes
|
|
391
|
+
if self.reducer is not None and hasattr(self.reducer, "disabled_passes"):
|
|
392
|
+
disabled_passes = list(self.reducer.disabled_passes)
|
|
393
|
+
else:
|
|
394
|
+
disabled_passes = []
|
|
395
|
+
|
|
307
396
|
return ProgressUpdate(
|
|
308
397
|
status=self.reducer.status if self.reducer else "",
|
|
309
398
|
size=stats.current_test_case_size,
|
|
@@ -319,6 +408,9 @@ class ReducerWorker:
|
|
|
319
408
|
time_since_last_reduction=stats.time_since_last_reduction(),
|
|
320
409
|
content_preview=content_preview,
|
|
321
410
|
hex_mode=hex_mode,
|
|
411
|
+
pass_stats=pass_stats_list,
|
|
412
|
+
current_pass_name=current_pass_name,
|
|
413
|
+
disabled_passes=disabled_passes,
|
|
322
414
|
)
|
|
323
415
|
|
|
324
416
|
async def emit_progress_updates(self) -> None:
|
|
@@ -360,6 +452,7 @@ class ReducerWorker:
|
|
|
360
452
|
await self.emit(Response(id="", error=error_message))
|
|
361
453
|
except* Exception as e:
|
|
362
454
|
# Catch any other exception during reduction and emit as error
|
|
455
|
+
traceback.print_exc()
|
|
363
456
|
await self.emit(Response(id="", error=str(e.exceptions[0])))
|
|
364
457
|
finally:
|
|
365
458
|
self._cancel_scope = None
|