shrinkray 25.12.29.0__tar.gz → 26.1.1.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 (69) hide show
  1. {shrinkray-25.12.29.0/src/shrinkray.egg-info → shrinkray-26.1.1.0}/PKG-INFO +2 -5
  2. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/README.md +1 -4
  3. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/pyproject.toml +1 -1
  4. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/__main__.py +48 -1
  5. shrinkray-26.1.1.0/src/shrinkray/history.py +446 -0
  6. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/state.py +247 -41
  7. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/subprocess/client.py +53 -4
  8. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/subprocess/protocol.py +8 -0
  9. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/subprocess/worker.py +196 -31
  10. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/tui.py +527 -19
  11. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0/src/shrinkray.egg-info}/PKG-INFO +2 -5
  12. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray.egg-info/SOURCES.txt +2 -0
  13. shrinkray-26.1.1.0/tests/test_history.py +1061 -0
  14. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_main.py +277 -0
  15. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_misc_reduction_performance.py +1 -0
  16. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_state.py +1343 -0
  17. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_subprocess_client.py +53 -0
  18. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_subprocess_worker.py +1131 -6
  19. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_tui.py +1480 -5
  20. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_tui_snapshots.py +94 -0
  21. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/LICENSE +0 -0
  22. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/setup.cfg +0 -0
  23. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/__init__.py +0 -0
  24. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/cli.py +0 -0
  25. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/formatting.py +0 -0
  26. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/passes/__init__.py +0 -0
  27. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/passes/bytes.py +0 -0
  28. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/passes/clangdelta.py +0 -0
  29. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/passes/definitions.py +0 -0
  30. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/passes/genericlanguages.py +0 -0
  31. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/passes/json.py +0 -0
  32. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/passes/patching.py +0 -0
  33. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/passes/python.py +0 -0
  34. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/passes/sat.py +0 -0
  35. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/passes/sequences.py +0 -0
  36. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/problem.py +0 -0
  37. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/process.py +0 -0
  38. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/py.typed +0 -0
  39. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/reducer.py +0 -0
  40. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/subprocess/__init__.py +0 -0
  41. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/ui.py +0 -0
  42. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/validation.py +0 -0
  43. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray/work.py +0 -0
  44. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray.egg-info/dependency_links.txt +0 -0
  45. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray.egg-info/entry_points.txt +0 -0
  46. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray.egg-info/requires.txt +0 -0
  47. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/src/shrinkray.egg-info/top_level.txt +0 -0
  48. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_byte_reduction_passes.py +0 -0
  49. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_clang_delta.py +0 -0
  50. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_cli.py +0 -0
  51. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_definitions.py +0 -0
  52. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_dimacs_cnf.py +0 -0
  53. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_formatting.py +0 -0
  54. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_generic_language.py +0 -0
  55. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_generic_shrinking_properties.py +0 -0
  56. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_json_passes.py +0 -0
  57. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_natural_sort_orders.py +0 -0
  58. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_patching.py +0 -0
  59. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_problem.py +0 -0
  60. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_process.py +0 -0
  61. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_python_reducers.py +0 -0
  62. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_reducer.py +0 -0
  63. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_reduction_passes.py +0 -0
  64. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_sat.py +0 -0
  65. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_subprocess_integration.py +0 -0
  66. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_subprocess_protocol.py +0 -0
  67. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_ui.py +0 -0
  68. {shrinkray-25.12.29.0 → shrinkray-26.1.1.0}/tests/test_validation.py +0 -0
  69. {shrinkray-25.12.29.0 → shrinkray-26.1.1.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.29.0
3
+ Version: 26.1.1.0
4
4
  Summary: Shrink Ray
5
5
  Author-email: "David R. MacIver" <david@drmaciver.com>
6
6
  License: MIT
@@ -81,10 +81,7 @@ shrinkray (or any other test-case reducer) then systematically tries smaller and
81
81
 
82
82
  While it runs, you will see the following user interface:
83
83
 
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>
84
+ ![Shrink Ray demo](gallery/enterprise-hello/hello.gif)
88
85
 
89
86
  (This is a toy example based on reducing a ridiculously bad version of hello world)
90
87
 
@@ -38,10 +38,7 @@ 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
- <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>
41
+ ![Shrink Ray demo](gallery/enterprise-hello/hello.gif)
45
42
 
46
43
  (This is a toy example based on reducing a ridiculously bad version of hello world)
47
44
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "shrinkray"
3
- version = "25.12.29.0"
3
+ version = "26.1.1.0"
4
4
  description = "Shrink Ray"
5
5
  authors = [
6
6
  {name = "David R. MacIver", email = "david@drmaciver.com"}
@@ -197,6 +197,29 @@ This behaviour can be disabled by passing --trivial-is-not-error.
197
197
  default=False,
198
198
  help="Pass this if you do not want to use clang delta for C/C++ transformations.",
199
199
  )
200
+ @click.option(
201
+ "--history/--no-history",
202
+ default=True,
203
+ help="""
204
+ Record reduction history to a .shrinkray directory. Each run creates a unique
205
+ subdirectory containing the initial test case and all successful reductions.
206
+ This is useful for debugging and analyzing the reduction process.
207
+ Enabled by default; use --no-history to disable.
208
+ """.strip(),
209
+ )
210
+ @click.option(
211
+ "--also-interesting",
212
+ type=int,
213
+ default=101,
214
+ help="""
215
+ Exit code indicating a test case is interesting enough to record but should not
216
+ be used for reduction. When the test script returns this code, the test case is
217
+ saved to the also-interesting/ directory within the history folder.
218
+ If --no-history is passed, also-interesting recording is disabled unless
219
+ --also-interesting is explicitly specified (in which case only also-interesting
220
+ cases are recorded, not reductions). Set to 0 to disable. Default: 101.
221
+ """.strip(),
222
+ )
200
223
  @click.option(
201
224
  "--clang-delta",
202
225
  default="",
@@ -224,6 +247,8 @@ def main(
224
247
  exit_on_completion: bool,
225
248
  ui_type: UIType,
226
249
  theme: str,
250
+ history: bool,
251
+ also_interesting: int,
227
252
  ) -> None:
228
253
  if timeout is not None and timeout <= 0:
229
254
  timeout = float("inf")
@@ -291,6 +316,21 @@ def main(
291
316
 
292
317
  print("\nStarting reduction...", file=sys.stderr, flush=True)
293
318
 
319
+ # Determine if --also-interesting was explicitly passed
320
+ # If --no-history and --also-interesting not explicit, disable also-interesting
321
+ ctx = click.get_current_context()
322
+ also_interesting_explicit = (
323
+ ctx.get_parameter_source("also_interesting")
324
+ == click.core.ParameterSource.COMMANDLINE
325
+ )
326
+ if also_interesting == 0:
327
+ also_interesting_code: int | None = None
328
+ elif not history and not also_interesting_explicit:
329
+ # --no-history without explicit --also-interesting: disable both
330
+ also_interesting_code = None
331
+ else:
332
+ also_interesting_code = also_interesting
333
+
294
334
  state_kwargs: dict[str, Any] = {
295
335
  "input_type": input_type,
296
336
  "in_place": in_place,
@@ -304,6 +344,8 @@ def main(
304
344
  "seed": seed,
305
345
  "volume": volume,
306
346
  "clang_delta_executable": clang_delta_executable,
347
+ "history_enabled": history,
348
+ "also_interesting_code": also_interesting_code,
307
349
  }
308
350
 
309
351
  state: ShrinkRayState[Any]
@@ -357,6 +399,8 @@ def main(
357
399
  trivial_is_error=trivial_is_error,
358
400
  exit_on_completion=exit_on_completion,
359
401
  theme=theme, # type: ignore[arg-type]
402
+ history_enabled=history,
403
+ also_interesting_code=also_interesting_code,
360
404
  )
361
405
  return
362
406
 
@@ -382,7 +426,10 @@ def main(
382
426
 
383
427
  def worker_main() -> None:
384
428
  """Entry point for the worker subprocess."""
385
- from shrinkray.subprocess.worker import main as worker_entry
429
+ # Lazy import to avoid loading worker module in main process (fast CLI startup)
430
+ from shrinkray.subprocess.worker import ( # noqa: I001, no-import-in-function
431
+ main as worker_entry,
432
+ )
386
433
 
387
434
  worker_entry()
388
435
 
@@ -0,0 +1,446 @@
1
+ """History management for shrink ray reduction sessions.
2
+
3
+ Records initial state and all successful reductions to a .shrinkray directory,
4
+ allowing analysis and reproduction of reduction runs.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import json
11
+ import os
12
+ import re
13
+ import shlex
14
+ import shutil
15
+ from datetime import datetime
16
+
17
+ from attrs import define
18
+
19
+
20
+ def sanitize_for_filename(s: str) -> str:
21
+ """Replace unsafe characters with underscores, limit length.
22
+
23
+ Keeps alphanumeric characters, dashes, underscores, and dots.
24
+ Collapses multiple underscores and limits to 50 characters.
25
+ """
26
+ # Replace unsafe characters with underscore
27
+ safe = re.sub(r"[^\w\-.]", "_", s)
28
+ # Collapse multiple underscores
29
+ safe = re.sub(r"_+", "_", safe)
30
+ # Strip leading/trailing underscores and limit length
31
+ return safe[:50].strip("_")
32
+
33
+
34
+ @define
35
+ class HistoryManager:
36
+ """Manages history directory structure for reduction sessions.
37
+
38
+ Creates and maintains a directory structure like:
39
+ .shrinkray/<run_id>/
40
+ initial/
41
+ <target_file> - Original file
42
+ <test_file> - Copy of interestingness test (if local)
43
+ run.sh - Wrapper script with exact original args
44
+ reductions/
45
+ 0001/
46
+ <target_file> - Reduced file
47
+ <target_file>.out - stdout+stderr output
48
+ 0002/
49
+ ...
50
+ """
51
+
52
+ run_id: str
53
+ history_dir: str # Path to .shrinkray/<run_id>
54
+ target_basename: str
55
+ reduction_counter: int = 0
56
+ also_interesting_counter: int = 0
57
+ initialized: bool = False
58
+ record_reductions: bool = True # If False, only record also-interesting
59
+ is_directory: bool = False # True if target is a directory
60
+
61
+ @classmethod
62
+ def create(
63
+ cls,
64
+ test: list[str],
65
+ filename: str,
66
+ *,
67
+ record_reductions: bool = True,
68
+ is_directory: bool = False,
69
+ base_dir: str | None = None,
70
+ ) -> HistoryManager:
71
+ """Create a new HistoryManager with a unique run ID.
72
+
73
+ Args:
74
+ test: The interestingness test command (list of strings)
75
+ filename: Path to the target file being reduced
76
+ record_reductions: If True, record successful reductions. If False,
77
+ only record also-interesting cases (useful when --no-history
78
+ but --also-interesting is explicitly passed).
79
+ is_directory: If True, the target is a directory and test cases
80
+ are dict[str, bytes] instead of bytes.
81
+ base_dir: Base directory for .shrinkray folder. Defaults to cwd.
82
+ """
83
+ # Generate run ID: (test-basename)-(filename)-(datetime)-(random hex)
84
+ test_name = sanitize_for_filename(os.path.basename(test[0]))
85
+ file_name = sanitize_for_filename(os.path.basename(filename))
86
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
87
+ random_hex = os.urandom(4).hex()
88
+ run_id = f"{test_name}-{file_name}-{timestamp}-{random_hex}"
89
+
90
+ if base_dir is None:
91
+ # Check environment variable first, then fall back to cwd
92
+ base_dir = os.environ.get("SHRINKRAY_DIRECTORY") or os.getcwd()
93
+ history_dir = os.path.join(base_dir, ".shrinkray", run_id)
94
+ target_basename = os.path.basename(filename)
95
+
96
+ return cls(
97
+ run_id=run_id,
98
+ history_dir=history_dir,
99
+ target_basename=target_basename,
100
+ record_reductions=record_reductions,
101
+ is_directory=is_directory,
102
+ )
103
+
104
+ def initialize(
105
+ self, initial_content: bytes, test: list[str], filename: str
106
+ ) -> None:
107
+ """Create initial directory structure and copy files.
108
+
109
+ Args:
110
+ initial_content: The original file content
111
+ test: The interestingness test command
112
+ filename: Path to the original target file
113
+ """
114
+ if self.initialized:
115
+ return
116
+
117
+ # Create directories
118
+ initial_dir = os.path.join(self.history_dir, "initial")
119
+ os.makedirs(initial_dir, exist_ok=True)
120
+ if self.record_reductions:
121
+ os.makedirs(os.path.join(self.history_dir, "reductions"), exist_ok=True)
122
+
123
+ # Copy original target file
124
+ target_path = os.path.join(initial_dir, self.target_basename)
125
+ with open(target_path, "wb") as f:
126
+ f.write(initial_content)
127
+
128
+ # Copy interestingness test if it's a local file
129
+ test_path = test[0]
130
+ copied_test_basename: str | None = None
131
+ if os.path.isfile(test_path):
132
+ copied_test_basename = os.path.basename(test_path)
133
+ shutil.copy2(test_path, os.path.join(initial_dir, copied_test_basename))
134
+
135
+ # Create wrapper script
136
+ self._create_wrapper_script(test, copied_test_basename, initial_dir)
137
+
138
+ self.initialized = True
139
+
140
+ def _create_wrapper_script(
141
+ self,
142
+ test: list[str],
143
+ copied_test_basename: str | None,
144
+ initial_dir: str,
145
+ ) -> None:
146
+ """Create run.sh wrapper script.
147
+
148
+ Args:
149
+ test: The original test command
150
+ copied_test_basename: Basename of copied test file, or None if not copied
151
+ initial_dir: Path to the initial directory
152
+ """
153
+ script_path = os.path.join(initial_dir, "run.sh")
154
+
155
+ # Build command, referencing test file via $(dirname "$0") if it was copied
156
+ if copied_test_basename is not None:
157
+ test_ref = f'"$(dirname "$0")/{copied_test_basename}"'
158
+ else:
159
+ test_ref = shlex.quote(test[0])
160
+
161
+ # Quote remaining arguments
162
+ args = " ".join(shlex.quote(arg) for arg in test[1:])
163
+ if args:
164
+ command = f"{test_ref} {args}"
165
+ else:
166
+ command = test_ref
167
+
168
+ script_content = f"""#!/bin/bash
169
+ # Shrink Ray interestingness test wrapper
170
+ # Run with: ./run.sh [target_file]
171
+ # If no target_file specified, uses the original
172
+
173
+ DIR="$(dirname "$0")"
174
+ TARGET="${{1:-"$DIR/{self.target_basename}"}}"
175
+
176
+ {command} "$TARGET"
177
+ """
178
+ with open(script_path, "w") as f:
179
+ f.write(script_content)
180
+ os.chmod(script_path, 0o755)
181
+
182
+ def record_reduction(self, test_case: bytes, output: bytes | None = None) -> None:
183
+ """Record a successful reduction.
184
+
185
+ Args:
186
+ test_case: The reduced file content (or serialized directory content)
187
+ output: Combined stdout/stderr from the test, or None if not captured
188
+ """
189
+ if not self.record_reductions:
190
+ return
191
+ self.reduction_counter += 1
192
+ subdir = os.path.join(
193
+ self.history_dir,
194
+ "reductions",
195
+ f"{self.reduction_counter:04d}",
196
+ )
197
+ os.makedirs(subdir, exist_ok=True)
198
+
199
+ if self.is_directory:
200
+ # Deserialize and write directory structure
201
+ content = self._deserialize_directory(test_case)
202
+ target_dir = os.path.join(subdir, self.target_basename)
203
+ self._write_directory_content(target_dir, content)
204
+ else:
205
+ # Write reduced file
206
+ with open(os.path.join(subdir, self.target_basename), "wb") as f:
207
+ f.write(test_case)
208
+
209
+ # Write output if available
210
+ if output is not None:
211
+ output_name = f"{self.target_basename}.out"
212
+ with open(os.path.join(subdir, output_name), "wb") as f:
213
+ f.write(output)
214
+
215
+ def record_also_interesting(
216
+ self, test_case: bytes, output: bytes | None = None
217
+ ) -> None:
218
+ """Record an also-interesting test case.
219
+
220
+ These are test cases that don't satisfy the main interestingness test
221
+ but have some other interesting property indicated by a special exit code.
222
+
223
+ Args:
224
+ test_case: The file content (or serialized directory content)
225
+ output: Combined stdout/stderr from the test, or None if not captured
226
+ """
227
+ self.also_interesting_counter += 1
228
+ subdir = os.path.join(
229
+ self.history_dir,
230
+ "also-interesting",
231
+ f"{self.also_interesting_counter:04d}",
232
+ )
233
+ os.makedirs(subdir, exist_ok=True)
234
+
235
+ if self.is_directory:
236
+ # Deserialize and write directory structure
237
+ content = self._deserialize_directory(test_case)
238
+ target_dir = os.path.join(subdir, self.target_basename)
239
+ self._write_directory_content(target_dir, content)
240
+ else:
241
+ # Write file
242
+ with open(os.path.join(subdir, self.target_basename), "wb") as f:
243
+ f.write(test_case)
244
+
245
+ # Write output if available
246
+ if output is not None:
247
+ output_name = f"{self.target_basename}.out"
248
+ with open(os.path.join(subdir, output_name), "wb") as f:
249
+ f.write(output)
250
+
251
+ def get_reduction_content(self, reduction_number: int) -> bytes:
252
+ """Get the content of a specific reduction.
253
+
254
+ Args:
255
+ reduction_number: The reduction number (e.g., 3 for 0003)
256
+
257
+ Returns:
258
+ The file content as bytes. For directory mode, returns the serialized
259
+ directory content for use with _set_initial_for_restart.
260
+
261
+ Raises:
262
+ FileNotFoundError: If the reduction doesn't exist
263
+ """
264
+ target_path = os.path.join(
265
+ self.history_dir,
266
+ "reductions",
267
+ f"{reduction_number:04d}",
268
+ self.target_basename,
269
+ )
270
+ if self.is_directory:
271
+ # For directory mode, read and serialize the directory content
272
+ content = self._read_directory_content(target_path)
273
+ return self._serialize_directory(content)
274
+ else:
275
+ with open(target_path, "rb") as f:
276
+ return f.read()
277
+
278
+ def restart_from_reduction(self, reduction_number: int) -> tuple[bytes, set[bytes]]:
279
+ """Move reductions after reduction_number to also-interesting.
280
+
281
+ This is used for the "restart from this point" feature. All reductions
282
+ after the specified point are moved to also-interesting (so they won't
283
+ be lost) and their contents are returned for exclusion from future
284
+ interestingness tests.
285
+
286
+ Args:
287
+ reduction_number: The reduction to restart from (e.g., 3 for 0003)
288
+
289
+ Returns:
290
+ tuple: (content_of_reduction_N, set_of_excluded_test_cases)
291
+
292
+ Raises:
293
+ FileNotFoundError: If the reduction doesn't exist
294
+ """
295
+ excluded_test_cases: set[bytes] = set()
296
+ reductions_dir = os.path.join(self.history_dir, "reductions")
297
+ also_interesting_dir = os.path.join(self.history_dir, "also-interesting")
298
+ os.makedirs(also_interesting_dir, exist_ok=True)
299
+
300
+ # Find all reductions after the restart point
301
+ entries_to_move: list[tuple[int, str]] = []
302
+ for entry_name in os.listdir(reductions_dir):
303
+ try:
304
+ entry_num = int(entry_name)
305
+ except ValueError:
306
+ continue # Skip non-numeric entries
307
+ if entry_num > reduction_number:
308
+ entries_to_move.append((entry_num, entry_name))
309
+
310
+ # Sort to process in order
311
+ entries_to_move.sort()
312
+
313
+ for _, entry_name in entries_to_move:
314
+ entry_path = os.path.join(reductions_dir, entry_name)
315
+ target_path = os.path.join(entry_path, self.target_basename)
316
+
317
+ # Read content for exclusion set
318
+ if self.is_directory:
319
+ # For directories, read and serialize content
320
+ content = self._read_directory_content(target_path)
321
+ excluded_test_cases.add(self._serialize_directory(content))
322
+ else:
323
+ with open(target_path, "rb") as f:
324
+ excluded_test_cases.add(f.read())
325
+
326
+ # Move to also-interesting with new numbering
327
+ self.also_interesting_counter += 1
328
+ new_path = os.path.join(
329
+ also_interesting_dir,
330
+ f"{self.also_interesting_counter:04d}",
331
+ )
332
+ shutil.move(entry_path, new_path)
333
+
334
+ # Update reduction counter
335
+ self.reduction_counter = reduction_number
336
+
337
+ # Return content to restart from
338
+ restart_content = self.get_reduction_content(reduction_number)
339
+ return restart_content, excluded_test_cases
340
+
341
+ # === Directory mode methods ===
342
+
343
+ def initialize_directory(
344
+ self, initial_content: dict[str, bytes], test: list[str], filename: str
345
+ ) -> None:
346
+ """Create initial directory structure for directory test cases.
347
+
348
+ Args:
349
+ initial_content: The original directory content as {path: bytes}
350
+ test: The interestingness test command
351
+ filename: Path to the original target directory
352
+ """
353
+ if self.initialized:
354
+ return
355
+
356
+ # Create directories
357
+ initial_dir = os.path.join(self.history_dir, "initial")
358
+ os.makedirs(initial_dir, exist_ok=True)
359
+ if self.record_reductions:
360
+ os.makedirs(os.path.join(self.history_dir, "reductions"), exist_ok=True)
361
+
362
+ # Copy original target directory structure
363
+ target_dir = os.path.join(initial_dir, self.target_basename)
364
+ self._write_directory_content(target_dir, initial_content)
365
+
366
+ # Copy interestingness test if it's a local file
367
+ test_path = test[0]
368
+ copied_test_basename: str | None = None
369
+ if os.path.isfile(test_path):
370
+ copied_test_basename = os.path.basename(test_path)
371
+ shutil.copy2(test_path, os.path.join(initial_dir, copied_test_basename))
372
+
373
+ # Create wrapper script
374
+ self._create_wrapper_script(test, copied_test_basename, initial_dir)
375
+
376
+ self.initialized = True
377
+
378
+ def _write_directory_content(
379
+ self, target_dir: str, content: dict[str, bytes]
380
+ ) -> None:
381
+ """Write directory content to disk.
382
+
383
+ Args:
384
+ target_dir: The directory to write to
385
+ content: The directory content as {relative_path: bytes}
386
+ """
387
+ os.makedirs(target_dir, exist_ok=True)
388
+ for rel_path, file_content in content.items():
389
+ file_path = os.path.join(target_dir, rel_path)
390
+ # parent_dir is always non-empty since file_path includes target_dir
391
+ parent_dir = os.path.dirname(file_path)
392
+ os.makedirs(parent_dir, exist_ok=True)
393
+ with open(file_path, "wb") as f:
394
+ f.write(file_content)
395
+
396
+ def _read_directory_content(self, target_dir: str) -> dict[str, bytes]:
397
+ """Read directory content from disk.
398
+
399
+ Args:
400
+ target_dir: The directory to read from
401
+
402
+ Returns:
403
+ The directory content as {relative_path: bytes}
404
+ """
405
+ content: dict[str, bytes] = {}
406
+ for root, _, files in os.walk(target_dir):
407
+ for file_name in files:
408
+ file_path = os.path.join(root, file_name)
409
+ rel_path = os.path.relpath(file_path, target_dir)
410
+ with open(file_path, "rb") as f:
411
+ content[rel_path] = f.read()
412
+ return content
413
+
414
+ @staticmethod
415
+ def _deserialize_directory(data: bytes) -> dict[str, bytes]:
416
+ return deserialize_directory(data)
417
+
418
+ @staticmethod
419
+ def _serialize_directory(content: dict[str, bytes]) -> bytes:
420
+ return serialize_directory(content)
421
+
422
+
423
+ def serialize_directory(content: dict[str, bytes]) -> bytes:
424
+ """Serialize directory content to bytes.
425
+
426
+ Args:
427
+ content: The directory content as {relative_path: bytes}
428
+
429
+ Returns:
430
+ JSON-encoded directory content with base64 file contents
431
+ """
432
+ serialized = {k: base64.b64encode(v).decode() for k, v in sorted(content.items())}
433
+ return json.dumps(serialized, sort_keys=True).encode()
434
+
435
+
436
+ def deserialize_directory(data: bytes) -> dict[str, bytes]:
437
+ """Deserialize bytes back to directory content.
438
+
439
+ Args:
440
+ data: JSON-encoded directory content with base64 file contents
441
+
442
+ Returns:
443
+ The directory content as {relative_path: bytes}
444
+ """
445
+ serialized = json.loads(data.decode())
446
+ return {k: base64.b64decode(v) for k, v in serialized.items()}