shrinkray 25.12.28.0__tar.gz → 25.12.29.0__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.28.0/src/shrinkray.egg-info → shrinkray-25.12.29.0}/PKG-INFO +10 -2
  2. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/README.md +6 -1
  3. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/pyproject.toml +5 -2
  4. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/state.py +42 -47
  5. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/subprocess/protocol.py +10 -1
  6. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/subprocess/worker.py +59 -13
  7. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/tui.py +605 -32
  8. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0/src/shrinkray.egg-info}/PKG-INFO +10 -2
  9. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray.egg-info/requires.txt +3 -0
  10. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_state.py +129 -39
  11. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_subprocess_worker.py +75 -11
  12. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_tui.py +3836 -1743
  13. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_validation.py +1 -0
  14. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/LICENSE +0 -0
  15. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/setup.cfg +0 -0
  16. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/__init__.py +0 -0
  17. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/__main__.py +0 -0
  18. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/cli.py +0 -0
  19. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/formatting.py +0 -0
  20. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/passes/__init__.py +0 -0
  21. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/passes/bytes.py +0 -0
  22. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/passes/clangdelta.py +0 -0
  23. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/passes/definitions.py +0 -0
  24. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/passes/genericlanguages.py +0 -0
  25. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/passes/json.py +0 -0
  26. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/passes/patching.py +0 -0
  27. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/passes/python.py +0 -0
  28. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/passes/sat.py +0 -0
  29. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/passes/sequences.py +0 -0
  30. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/problem.py +0 -0
  31. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/process.py +0 -0
  32. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/py.typed +0 -0
  33. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/reducer.py +0 -0
  34. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/subprocess/__init__.py +0 -0
  35. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/subprocess/client.py +0 -0
  36. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/ui.py +0 -0
  37. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/validation.py +0 -0
  38. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray/work.py +0 -0
  39. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray.egg-info/SOURCES.txt +0 -0
  40. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray.egg-info/dependency_links.txt +0 -0
  41. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray.egg-info/entry_points.txt +0 -0
  42. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/src/shrinkray.egg-info/top_level.txt +0 -0
  43. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_byte_reduction_passes.py +0 -0
  44. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_clang_delta.py +0 -0
  45. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_cli.py +0 -0
  46. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_definitions.py +0 -0
  47. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_dimacs_cnf.py +0 -0
  48. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_formatting.py +0 -0
  49. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_generic_language.py +0 -0
  50. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_generic_shrinking_properties.py +0 -0
  51. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_json_passes.py +0 -0
  52. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_main.py +0 -0
  53. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_misc_reduction_performance.py +0 -0
  54. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_natural_sort_orders.py +0 -0
  55. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_patching.py +0 -0
  56. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_problem.py +0 -0
  57. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_process.py +0 -0
  58. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_python_reducers.py +0 -0
  59. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_reducer.py +0 -0
  60. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_reduction_passes.py +0 -0
  61. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_sat.py +0 -0
  62. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_subprocess_client.py +0 -0
  63. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_subprocess_integration.py +0 -0
  64. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_subprocess_protocol.py +0 -0
  65. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_tui_snapshots.py +0 -0
  66. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_ui.py +0 -0
  67. {shrinkray-25.12.28.0 → shrinkray-25.12.29.0}/tests/test_work.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shrinkray
3
- Version: 25.12.28.0
3
+ Version: 25.12.29.0
4
4
  Summary: Shrink Ray
5
5
  Author-email: "David R. MacIver" <david@drmaciver.com>
6
6
  License: MIT
@@ -16,6 +16,7 @@ Requires-Dist: click>=8.0.1
16
16
  Requires-Dist: chardet>=5.2.0
17
17
  Requires-Dist: trio>=0.28.0
18
18
  Requires-Dist: textual>=0.47.0
19
+ Requires-Dist: textual-plotext>=0.2.0
19
20
  Requires-Dist: humanize>=4.9.0
20
21
  Requires-Dist: libcst>=1.1.0
21
22
  Requires-Dist: exceptiongroup>=1.2.0
@@ -28,6 +29,8 @@ Requires-Dist: hypothesmith>=0.3.1; extra == "dev"
28
29
  Requires-Dist: pytest>=8.0.0; extra == "dev"
29
30
  Requires-Dist: pytest-trio>=0.8.0; extra == "dev"
30
31
  Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
32
+ Requires-Dist: pytest-xdist>=3.5.0; extra == "dev"
33
+ Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
31
34
  Requires-Dist: syrupy>=5.0.0; extra == "dev"
32
35
  Requires-Dist: jinja2>=3.0.0; extra == "dev"
33
36
  Requires-Dist: coverage[toml]>=7.4.0; extra == "dev"
@@ -78,7 +81,12 @@ shrinkray (or any other test-case reducer) then systematically tries smaller and
78
81
 
79
82
  While it runs, you will see the following user interface:
80
83
 
81
- ![Demo of shrink ray running](demo.png)
84
+ <video controls poster="gallery/enterprise-hello/hello.png">
85
+ <source src="https://drmaciver.github.io/shrinkray/assets/hello.mp4" type="video/mp4">
86
+ Your browser doesn't support video. <a href="gallery/enterprise-hello/hello.gif">View the GIF instead</a>.
87
+ </video>
88
+
89
+ (This is a toy example based on reducing a ridiculously bad version of hello world)
82
90
 
83
91
  When it finishes you will be left with the reduced test case in `mytestcase.py`.
84
92
 
@@ -38,7 +38,12 @@ shrinkray (or any other test-case reducer) then systematically tries smaller and
38
38
 
39
39
  While it runs, you will see the following user interface:
40
40
 
41
- ![Demo of shrink ray running](demo.png)
41
+ <video controls poster="gallery/enterprise-hello/hello.png">
42
+ <source src="https://drmaciver.github.io/shrinkray/assets/hello.mp4" type="video/mp4">
43
+ Your browser doesn't support video. <a href="gallery/enterprise-hello/hello.gif">View the GIF instead</a>.
44
+ </video>
45
+
46
+ (This is a toy example based on reducing a ridiculously bad version of hello world)
42
47
 
43
48
  When it finishes you will be left with the reduced test case in `mytestcase.py`.
44
49
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "shrinkray"
3
- version = "25.12.28.0"
3
+ version = "25.12.29.0"
4
4
  description = "Shrink Ray"
5
5
  authors = [
6
6
  {name = "David R. MacIver", email = "david@drmaciver.com"}
@@ -16,6 +16,7 @@ dependencies = [
16
16
  "chardet>=5.2.0",
17
17
  "trio>=0.28.0",
18
18
  "textual>=0.47.0",
19
+ "textual-plotext>=0.2.0",
19
20
  "humanize>=4.9.0",
20
21
  "libcst>=1.1.0",
21
22
  "exceptiongroup>=1.2.0",
@@ -41,6 +42,8 @@ dev = [
41
42
  "pytest>=8.0.0",
42
43
  "pytest-trio>=0.8.0",
43
44
  "pytest-asyncio>=0.21.0",
45
+ "pytest-xdist>=3.5.0",
46
+ "pytest-cov>=4.1.0",
44
47
  # pytest-textual-snapshot is vendored in tests/pytest_textual_snapshot.py
45
48
  # to fix syrupy 5.0 compatibility. These are its dependencies:
46
49
  "syrupy>=5.0.0",
@@ -67,7 +70,7 @@ core = "ctrace"
67
70
 
68
71
  [tool.coverage.report]
69
72
  show_missing = true
70
- fail_under = 100
73
+ # fail_under is enforced in justfile to allow split parallel/serial runs
71
74
 
72
75
  [tool.ruff]
73
76
  line-length = 88
@@ -57,7 +57,7 @@ def compute_dynamic_timeout(runtime: float) -> float:
57
57
 
58
58
 
59
59
  @define
60
- class TestOutputManager:
60
+ class OutputCaptureManager:
61
61
  """Manages temporary files for test output capture.
62
62
 
63
63
  Allocates unique files for each test's stdout/stderr output,
@@ -74,7 +74,8 @@ class TestOutputManager:
74
74
 
75
75
  _sequence: int = 0
76
76
  _active_outputs: dict[int, str] = {}
77
- _completed_outputs: deque[tuple[int, str, float]] = deque()
77
+ # Completed outputs: (test_id, file_path, completion_time, return_code)
78
+ _completed_outputs: deque[tuple[int, str, float, int]] = deque()
78
79
 
79
80
  def __attrs_post_init__(self) -> None:
80
81
  # Initialize mutable defaults
@@ -89,11 +90,13 @@ class TestOutputManager:
89
90
  self._active_outputs[test_id] = file_path
90
91
  return test_id, file_path
91
92
 
92
- def mark_completed(self, test_id: int) -> None:
93
+ def mark_completed(self, test_id: int, return_code: int = 0) -> None:
93
94
  """Mark a test as completed and move to completed queue."""
94
95
  if test_id in self._active_outputs:
95
96
  file_path = self._active_outputs.pop(test_id)
96
- self._completed_outputs.append((test_id, file_path, time.time()))
97
+ self._completed_outputs.append(
98
+ (test_id, file_path, time.time(), return_code)
99
+ )
97
100
  self._cleanup_old_files()
98
101
 
99
102
  def _cleanup_old_files(self) -> None:
@@ -104,65 +107,55 @@ class TestOutputManager:
104
107
  self._completed_outputs
105
108
  and now - self._completed_outputs[0][2] > self.max_age_seconds
106
109
  ):
107
- _, file_path, _ = self._completed_outputs.popleft()
110
+ _, file_path, _, _ = self._completed_outputs.popleft()
108
111
  self._safe_delete(file_path)
109
112
  # Remove excess files beyond max_files
110
113
  while len(self._completed_outputs) > self.max_files:
111
- _, file_path, _ = self._completed_outputs.popleft()
114
+ _, file_path, _, _ = self._completed_outputs.popleft()
112
115
  self._safe_delete(file_path)
113
116
 
114
- def _should_show_completed(self) -> tuple[int, str] | None:
115
- """Check if we should show a completed test's output.
116
-
117
- Note: This method is only called when there are no active tests.
118
- It returns the completed test info if within the display window
119
- (min_display_seconds + grace_period).
120
- """
121
- if not self._completed_outputs:
122
- return None
123
- test_id, file_path, completion_time = self._completed_outputs[-1]
124
- elapsed = time.time() - completion_time
117
+ @staticmethod
118
+ def _file_has_content(path: str) -> bool:
119
+ """Check if a file exists and has non-zero size."""
120
+ try:
121
+ return os.path.getsize(path) > 0
122
+ except OSError:
123
+ return False
125
124
 
126
- # Show completed test during the full display window
127
- if elapsed < self.min_display_seconds + self.grace_period_seconds:
128
- return test_id, file_path
125
+ def get_current_output(self) -> tuple[str | None, int | None, int | None]:
126
+ """Get the current output to display.
129
127
 
130
- return None
128
+ Returns (file_path, test_id, return_code) where:
129
+ - file_path: path to the output file to display
130
+ - test_id: the test ID (for display in header)
131
+ - return_code: the return code (None if test is still running)
131
132
 
132
- def get_current_output_path(self) -> str | None:
133
- """Get the most relevant output file path.
134
-
135
- Active tests always take priority. If no active test, shows
136
- recently completed test output for min_display_seconds, plus
137
- an additional grace_period if no new test has started.
133
+ Active tests take priority only if they have produced output.
134
+ Otherwise, shows recently completed test output for min_display_seconds,
135
+ plus an additional grace_period if no new test has started.
138
136
  """
139
- # Active tests always take priority
137
+ # Active tests take priority only if they have content
140
138
  if self._active_outputs:
141
139
  max_id = max(self._active_outputs.keys())
142
- return self._active_outputs[max_id]
143
- # Then check for recently completed test that should stay visible
144
- recent = self._should_show_completed()
145
- if recent is not None:
146
- return recent[1]
147
- # Fall back to most recent completed (even if past display window)
140
+ active_path = self._active_outputs[max_id]
141
+ if self._file_has_content(active_path):
142
+ # Active test with output - no return code yet
143
+ return active_path, max_id, None
144
+ # Active test has no output yet - fall through to show previous output
145
+
146
+ # Check for recently completed test that should stay visible,
147
+ # or fall back to most recent completed (even if past display window)
148
148
  if self._completed_outputs:
149
- return self._completed_outputs[-1][1]
150
- return None
151
-
152
- def get_active_test_id(self) -> int | None:
153
- """Get the currently running test ID, if any.
149
+ test_id, file_path, _, return_code = self._completed_outputs[-1]
150
+ return file_path, test_id, return_code
154
151
 
155
- Returns the active test ID if one is running, None otherwise.
156
- """
157
- if self._active_outputs:
158
- return max(self._active_outputs.keys())
159
- return None
152
+ return None, None, None
160
153
 
161
154
  def cleanup_all(self) -> None:
162
155
  """Clean up all output files (called on shutdown)."""
163
156
  for file_path in self._active_outputs.values():
164
157
  self._safe_delete(file_path)
165
- for _, file_path, _ in self._completed_outputs:
158
+ for _, file_path, _, _ in self._completed_outputs:
166
159
  self._safe_delete(file_path)
167
160
  self._active_outputs.clear()
168
161
  self._completed_outputs.clear()
@@ -209,7 +202,7 @@ class ShrinkRayState[TestCase](ABC):
209
202
  _last_debug_output: str = ""
210
203
 
211
204
  # Optional output manager for capturing test output (TUI mode)
212
- output_manager: TestOutputManager | None = None
205
+ output_manager: OutputCaptureManager | None = None
213
206
 
214
207
  def __attrs_post_init__(self):
215
208
  self.is_interesting_limiter = trio.CapacityLimiter(max(self.parallelism, 1))
@@ -298,6 +291,7 @@ class ShrinkRayState[TestCase](ABC):
298
291
  # Determine output handling
299
292
  test_id: int | None = None
300
293
  output_file_handle = None
294
+ exit_code: int | None = None # Track for output manager
301
295
 
302
296
  if self.output_manager is not None:
303
297
  # Capture output to a file for TUI display
@@ -366,6 +360,7 @@ class ShrinkRayState[TestCase](ABC):
366
360
 
367
361
  result: int | None = sp.returncode
368
362
  assert result is not None
363
+ exit_code = result
369
364
 
370
365
  return result
371
366
  finally:
@@ -373,7 +368,7 @@ class ShrinkRayState[TestCase](ABC):
373
368
  if output_file_handle is not None:
374
369
  output_file_handle.close()
375
370
  if test_id is not None and self.output_manager is not None:
376
- self.output_manager.mark_completed(test_id)
371
+ self.output_manager.mark_completed(test_id, exit_code or 0)
377
372
 
378
373
  async def run_for_exit_code(self, test_case: TestCase, debug: bool = False) -> int:
379
374
  # Lazy import
@@ -57,8 +57,13 @@ class ProgressUpdate:
57
57
  disabled_passes: list[str] = field(default_factory=list)
58
58
  # Test output preview (last 4KB of current/recent test output)
59
59
  test_output_preview: str = ""
60
- # Currently running test ID (None if no test running)
60
+ # Test ID of the output being displayed (None if no output yet)
61
61
  active_test_id: int | None = None
62
+ # Return code of the displayed test (None if test is still running)
63
+ last_test_return_code: int | None = None
64
+ # New size history entries since last update: list of (runtime_seconds, size)
65
+ # Client should accumulate these over time
66
+ new_size_history: list[tuple[float, int]] = field(default_factory=list)
62
67
 
63
68
 
64
69
  @dataclass
@@ -120,6 +125,8 @@ def serialize(msg: Request | Response | ProgressUpdate) -> str:
120
125
  "disabled_passes": msg.disabled_passes,
121
126
  "test_output_preview": msg.test_output_preview,
122
127
  "active_test_id": msg.active_test_id,
128
+ "last_test_return_code": msg.last_test_return_code,
129
+ "new_size_history": msg.new_size_history,
123
130
  },
124
131
  }
125
132
  else:
@@ -169,6 +176,8 @@ def deserialize(line: str) -> Request | Response | ProgressUpdate:
169
176
  disabled_passes=d.get("disabled_passes", []),
170
177
  test_output_preview=d.get("test_output_preview", ""),
171
178
  active_test_id=d.get("active_test_id"),
179
+ last_test_return_code=d.get("last_test_return_code"),
180
+ new_size_history=[tuple(x) for x in d.get("new_size_history", [])],
172
181
  )
173
182
 
174
183
  # Check for response (has "result" or "error" field)
@@ -58,6 +58,11 @@ class ReducerWorker:
58
58
  self._output_stream = output_stream
59
59
  # Output directory for test output capture (cleaned up on shutdown)
60
60
  self._output_dir: str | None = None
61
+ # Size history for graphing: list of (runtime_seconds, size) tuples
62
+ self._size_history: list[tuple[float, int]] = []
63
+ self._last_sent_history_index: int = 0
64
+ self._last_recorded_size: int = 0
65
+ self._last_history_time: float = 0.0
61
66
 
62
67
  async def emit(self, msg: Response | ProgressUpdate) -> None:
63
68
  """Write a message to the output stream."""
@@ -157,9 +162,9 @@ class ReducerWorker:
157
162
  find_clang_delta,
158
163
  )
159
164
  from shrinkray.state import (
165
+ OutputCaptureManager,
160
166
  ShrinkRayDirectoryState,
161
167
  ShrinkRayStateSingleFile,
162
- TestOutputManager,
163
168
  )
164
169
  from shrinkray.work import Volume
165
170
 
@@ -213,7 +218,7 @@ class ReducerWorker:
213
218
 
214
219
  # Create output manager for test output capture (always enabled for TUI)
215
220
  self._output_dir = tempfile.mkdtemp(prefix="shrinkray-output-")
216
- self.state.output_manager = TestOutputManager(output_dir=self._output_dir)
221
+ self.state.output_manager = OutputCaptureManager(output_dir=self._output_dir)
217
222
 
218
223
  self.problem = self.state.problem
219
224
  self.reducer = self.state.reducer
@@ -304,17 +309,23 @@ class ReducerWorker:
304
309
  return Response(id=request_id, result={"status": "skipped"})
305
310
  return Response(id=request_id, error="Reducer does not support pass control")
306
311
 
307
- def _get_test_output_preview(self) -> tuple[str, int | None]:
308
- """Get preview of current test output and active test ID."""
312
+ def _get_test_output_preview(self) -> tuple[str, int | None, int | None]:
313
+ """Get preview of current test output, test ID, and return code.
314
+
315
+ Returns (content, test_id, return_code) where:
316
+ - content: the last 4KB of the output file
317
+ - test_id: the test ID being displayed
318
+ - return_code: None if test is still running, otherwise the exit code
319
+ """
309
320
  if self.state is None or self.state.output_manager is None:
310
- return "", None
321
+ return "", None, None
311
322
 
312
- manager = self.state.output_manager
313
- active_test_id = manager.get_active_test_id()
314
- output_path = manager.get_current_output_path()
323
+ output_path, test_id, return_code = (
324
+ self.state.output_manager.get_current_output()
325
+ )
315
326
 
316
327
  if output_path is None:
317
- return "", active_test_id
328
+ return "", None, None
318
329
 
319
330
  # Read last 4KB of file
320
331
  try:
@@ -326,9 +337,13 @@ class ReducerWorker:
326
337
  else:
327
338
  f.seek(0)
328
339
  data = f.read()
329
- return data.decode("utf-8", errors="replace"), active_test_id
340
+ return (
341
+ data.decode("utf-8", errors="replace"),
342
+ test_id,
343
+ return_code,
344
+ )
330
345
  except OSError:
331
- return "", active_test_id
346
+ return "", test_id, return_code
332
347
 
333
348
  def _get_content_preview(self) -> tuple[str, bool]:
334
349
  """Get a preview of the current test case content."""
@@ -379,6 +394,29 @@ class ReducerWorker:
379
394
  return None
380
395
 
381
396
  stats = self.problem.stats
397
+ runtime = time.time() - stats.start_time
398
+ current_size = stats.current_test_case_size
399
+
400
+ # Record size history when size changes or periodically
401
+ # Use 200ms interval for first 5 minutes, then 1s (ticks are at 1-minute intervals)
402
+ history_interval = 1.0 if runtime >= 300 else 0.2
403
+
404
+ if not self._size_history:
405
+ # First sample: record initial size at time 0
406
+ self._size_history.append((0.0, stats.initial_test_case_size))
407
+ self._last_recorded_size = stats.initial_test_case_size
408
+ self._last_history_time = 0.0
409
+
410
+ if current_size != self._last_recorded_size:
411
+ # Size changed - always record
412
+ self._size_history.append((runtime, current_size))
413
+ self._last_recorded_size = current_size
414
+ self._last_history_time = runtime
415
+ elif runtime - self._last_history_time >= history_interval:
416
+ # No size change but interval passed - record periodic update
417
+ self._size_history.append((runtime, current_size))
418
+ self._last_history_time = runtime
419
+
382
420
  content_preview, hex_mode = self._get_content_preview()
383
421
 
384
422
  # Get parallel workers count and track average
@@ -433,7 +471,13 @@ class ReducerWorker:
433
471
  disabled_passes = []
434
472
 
435
473
  # Get test output preview
436
- test_output_preview, active_test_id = self._get_test_output_preview()
474
+ test_output_preview, active_test_id, last_return_code = (
475
+ self._get_test_output_preview()
476
+ )
477
+
478
+ # Get new size history entries since last update
479
+ new_entries = self._size_history[self._last_sent_history_index :]
480
+ self._last_sent_history_index = len(self._size_history)
437
481
 
438
482
  return ProgressUpdate(
439
483
  status=self.reducer.status if self.reducer else "",
@@ -443,7 +487,7 @@ class ReducerWorker:
443
487
  reductions=stats.reductions,
444
488
  interesting_calls=stats.interesting_calls,
445
489
  wasted_calls=stats.wasted_interesting_calls,
446
- runtime=time.time() - stats.start_time,
490
+ runtime=runtime,
447
491
  parallel_workers=parallel_workers,
448
492
  average_parallelism=average_parallelism,
449
493
  effective_parallelism=effective_parallelism,
@@ -455,6 +499,8 @@ class ReducerWorker:
455
499
  disabled_passes=disabled_passes,
456
500
  test_output_preview=test_output_preview,
457
501
  active_test_id=active_test_id,
502
+ last_test_return_code=last_return_code,
503
+ new_size_history=new_entries,
458
504
  )
459
505
 
460
506
  async def emit_progress_updates(self) -> None: