shrinkray 25.12.26__tar.gz → 25.12.26.2__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.
Files changed (67) hide show
  1. {shrinkray-25.12.26/src/shrinkray.egg-info → shrinkray-25.12.26.2}/PKG-INFO +21 -17
  2. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/README.md +10 -6
  3. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/pyproject.toml +11 -11
  4. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray/problem.py +27 -0
  5. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray/reducer.py +129 -3
  6. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray/subprocess/client.py +34 -1
  7. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray/subprocess/protocol.py +49 -0
  8. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray/subprocess/worker.py +104 -11
  9. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray/tui.py +324 -4
  10. {shrinkray-25.12.26 → shrinkray-25.12.26.2/src/shrinkray.egg-info}/PKG-INFO +21 -17
  11. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray.egg-info/requires.txt +10 -10
  12. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/tests/test_clang_delta.py +1 -1
  13. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/tests/test_reducer.py +359 -0
  14. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/tests/test_subprocess_client.py +87 -0
  15. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/tests/test_subprocess_protocol.py +88 -0
  16. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/tests/test_subprocess_worker.py +311 -0
  17. shrinkray-25.12.26.2/tests/test_tui.py +3750 -0
  18. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/tests/test_tui_snapshots.py +80 -1
  19. shrinkray-25.12.26/tests/test_tui.py +0 -1720
  20. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/LICENSE +0 -0
  21. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/setup.cfg +0 -0
  22. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray/__init__.py +0 -0
  23. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray/__main__.py +0 -0
  24. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray/cli.py +0 -0
  25. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray/display.py +0 -0
  26. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray/formatting.py +0 -0
  27. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray/passes/__init__.py +0 -0
  28. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray/passes/bytes.py +0 -0
  29. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray/passes/clangdelta.py +0 -0
  30. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray/passes/definitions.py +0 -0
  31. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray/passes/genericlanguages.py +0 -0
  32. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray/passes/json.py +0 -0
  33. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray/passes/patching.py +0 -0
  34. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray/passes/python.py +0 -0
  35. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray/passes/sat.py +0 -0
  36. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray/passes/sequences.py +0 -0
  37. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray/process.py +0 -0
  38. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray/py.typed +0 -0
  39. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray/state.py +0 -0
  40. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray/subprocess/__init__.py +0 -0
  41. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray/ui.py +0 -0
  42. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray/work.py +0 -0
  43. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray.egg-info/SOURCES.txt +0 -0
  44. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray.egg-info/dependency_links.txt +0 -0
  45. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray.egg-info/entry_points.txt +0 -0
  46. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/src/shrinkray.egg-info/top_level.txt +0 -0
  47. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/tests/test_byte_reduction_passes.py +0 -0
  48. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/tests/test_cli.py +0 -0
  49. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/tests/test_definitions.py +0 -0
  50. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/tests/test_dimacs_cnf.py +0 -0
  51. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/tests/test_display.py +0 -0
  52. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/tests/test_formatting.py +0 -0
  53. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/tests/test_generic_language.py +0 -0
  54. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/tests/test_generic_shrinking_properties.py +0 -0
  55. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/tests/test_json_passes.py +0 -0
  56. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/tests/test_main.py +0 -0
  57. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/tests/test_misc_reduction_performance.py +0 -0
  58. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/tests/test_patching.py +0 -0
  59. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/tests/test_problem.py +0 -0
  60. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/tests/test_process.py +0 -0
  61. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/tests/test_python_reducers.py +0 -0
  62. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/tests/test_reduction_passes.py +0 -0
  63. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/tests/test_sat.py +0 -0
  64. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/tests/test_state.py +0 -0
  65. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/tests/test_subprocess_integration.py +0 -0
  66. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/tests/test_ui.py +0 -0
  67. {shrinkray-25.12.26 → shrinkray-25.12.26.2}/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.2
4
4
  Summary: Shrink Ray
5
5
  Author-email: "David R. MacIver" <david@drmaciver.com>
6
6
  License: MIT
@@ -20,19 +20,19 @@ Requires-Dist: humanize>=4.9.0
20
20
  Requires-Dist: libcst>=1.1.0
21
21
  Requires-Dist: exceptiongroup>=1.2.0
22
22
  Requires-Dist: binaryornot>=0.4.4
23
- Requires-Dist: black
23
+ Requires-Dist: black>=24.1.0
24
24
  Provides-Extra: dev
25
- Requires-Dist: coverage>=7.13.0; extra == "dev"
25
+ Requires-Dist: coverage>=7.4.0; extra == "dev"
26
26
  Requires-Dist: hypothesis>=6.92.1; extra == "dev"
27
27
  Requires-Dist: hypothesmith>=0.3.1; extra == "dev"
28
- Requires-Dist: pytest; extra == "dev"
29
- Requires-Dist: pytest-trio; extra == "dev"
30
- Requires-Dist: pytest-asyncio; extra == "dev"
31
- Requires-Dist: pytest-textual-snapshot; extra == "dev"
32
- Requires-Dist: coverage[toml]; extra == "dev"
33
- Requires-Dist: pygments; extra == "dev"
34
- Requires-Dist: basedpyright; extra == "dev"
35
- Requires-Dist: ruff; extra == "dev"
28
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
29
+ Requires-Dist: pytest-trio>=0.8.0; extra == "dev"
30
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
31
+ Requires-Dist: pytest-textual-snapshot>=1.0.0; extra == "dev"
32
+ Requires-Dist: coverage[toml]>=7.4.0; extra == "dev"
33
+ Requires-Dist: pygments>=2.17.0; extra == "dev"
34
+ Requires-Dist: basedpyright>=1.1.0; extra == "dev"
35
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
36
36
  Requires-Dist: pexpect>=4.9.0; extra == "dev"
37
37
  Requires-Dist: pyte>=0.8.2; extra == "dev"
38
38
  Dynamic: license-file
@@ -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
- Currently shrink ray is a "prerelease" version in the sense that there is no official release yet and you're expected to just run off main (don't worry this is easy to do), as it's a bit experimental.
92
+ ## Versioning and Releases
93
93
 
94
- That being said this probably doesn't matter that much for the question of whether to use it. It's in the nature of test-case reduction that it doesn't matter all that much if it's bad, because it's still going to do a bunch of work that you didn't have to do by hand. Try it out, see if it works. If it doesn't, please tell me and I'll make it work better.
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.11 or later, and can be installed using pip.
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
- Official releases for shrink ray are infrequent, and I recommend running off main. You can install it as follows:
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.11 or later and won't work on earlier versions. If everything is working correctly, it should refuse to install
111
- on versions it's incompatible with. If you do not have Python 3.11 installed, I recommend [pyenv](https://github.com/pyenv/pyenv) for managing
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
- Currently shrink ray is a "prerelease" version in the sense that there is no official release yet and you're expected to just run off main (don't worry this is easy to do), as it's a bit experimental.
53
+ ## Versioning and Releases
54
54
 
55
- That being said this probably doesn't matter that much for the question of whether to use it. It's in the nature of test-case reduction that it doesn't matter all that much if it's bad, because it's still going to do a bunch of work that you didn't have to do by hand. Try it out, see if it works. If it doesn't, please tell me and I'll make it work better.
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.11 or later, and can be installed using pip.
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
- Official releases for shrink ray are infrequent, and I recommend running off main. You can install it as follows:
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.11 or later and won't work on earlier versions. If everything is working correctly, it should refuse to install
72
- on versions it's incompatible with. If you do not have Python 3.11 installed, I recommend [pyenv](https://github.com/pyenv/pyenv) for managing
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:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "shrinkray"
3
- version = "25.12.26"
3
+ version = "25.12.26.2"
4
4
  description = "Shrink Ray"
5
5
  authors = [
6
6
  {name = "David R. MacIver", email = "david@drmaciver.com"}
@@ -20,7 +20,7 @@ dependencies = [
20
20
  "libcst>=1.1.0",
21
21
  "exceptiongroup>=1.2.0",
22
22
  "binaryornot>=0.4.4",
23
- "black",
23
+ "black>=24.1.0",
24
24
  ]
25
25
 
26
26
  [project.urls]
@@ -35,17 +35,17 @@ shrinkray-worker = "shrinkray.__main__:worker_main"
35
35
 
36
36
  [project.optional-dependencies]
37
37
  dev = [
38
- "coverage>=7.13.0",
38
+ "coverage>=7.4.0",
39
39
  "hypothesis>=6.92.1",
40
40
  "hypothesmith>=0.3.1",
41
- "pytest",
42
- "pytest-trio",
43
- "pytest-asyncio",
44
- "pytest-textual-snapshot",
45
- "coverage[toml]",
46
- "pygments",
47
- "basedpyright",
48
- "ruff",
41
+ "pytest>=8.0.0",
42
+ "pytest-trio>=0.8.0",
43
+ "pytest-asyncio>=0.21.0",
44
+ "pytest-textual-snapshot>=1.0.0",
45
+ "coverage[toml]>=7.4.0",
46
+ "pygments>=2.17.0",
47
+ "basedpyright>=1.1.0",
48
+ "ruff>=0.1.0",
49
49
  "pexpect>=4.9.0",
50
50
  "pyte>=0.8.2",
51
51
  ]
@@ -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
- await rp(self.target)
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
- break
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
- pass
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)