grudge-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. examples/__init__.py +1 -0
  2. examples/leaky_agent/__init__.py +1 -0
  3. examples/leaky_agent/agent.py +250 -0
  4. examples/leaky_agent/agent_fixed.py +183 -0
  5. examples/leaky_agent/tools.py +153 -0
  6. grudge/__init__.py +5 -0
  7. grudge/__main__.py +6 -0
  8. grudge/_stubs.py +8 -0
  9. grudge/admission/__init__.py +6 -0
  10. grudge/admission/court.py +239 -0
  11. grudge/admission/verdict.py +26 -0
  12. grudge/cli/__init__.py +1 -0
  13. grudge/cli/_common.py +126 -0
  14. grudge/cli/admit.py +93 -0
  15. grudge/cli/capture.py +39 -0
  16. grudge/cli/compile.py +37 -0
  17. grudge/cli/demo.py +136 -0
  18. grudge/cli/main.py +54 -0
  19. grudge/cli/run.py +60 -0
  20. grudge/cli/scars.py +91 -0
  21. grudge/cli/shrink.py +108 -0
  22. grudge/config.py +35 -0
  23. grudge/demo/__init__.py +1 -0
  24. grudge/demo/events.py +228 -0
  25. grudge/demo/orchestrator.py +326 -0
  26. grudge/demo/server.py +222 -0
  27. grudge/errors.py +25 -0
  28. grudge/fuzzer/__init__.py +1 -0
  29. grudge/fuzzer/fingerprint_guard.py +10 -0
  30. grudge/fuzzer/mutators.py +10 -0
  31. grudge/gate/__init__.py +5 -0
  32. grudge/gate/report.py +125 -0
  33. grudge/gate/runner.py +323 -0
  34. grudge/gate/selector.py +10 -0
  35. grudge/gate/wilson.py +36 -0
  36. grudge/guard/__init__.py +5 -0
  37. grudge/guard/compile.py +251 -0
  38. grudge/guard/import_session.py +106 -0
  39. grudge/integrations/__init__.py +1 -0
  40. grudge/integrations/github_comment.py +10 -0
  41. grudge/integrations/pytest_plugin.py +10 -0
  42. grudge/llm/__init__.py +6 -0
  43. grudge/llm/cache.py +92 -0
  44. grudge/llm/client.py +82 -0
  45. grudge/locator/__init__.py +1 -0
  46. grudge/locator/fork.py +10 -0
  47. grudge/locator/point_of_no_return.py +10 -0
  48. grudge/predicates/__init__.py +25 -0
  49. grudge/predicates/call_shape.py +116 -0
  50. grudge/predicates/compiler.py +116 -0
  51. grudge/predicates/dsl.py +295 -0
  52. grudge/predicates/fallback_rules.py +10 -0
  53. grudge/predicates/ordering.py +33 -0
  54. grudge/predicates/outcome.py +14 -0
  55. grudge/predicates/prompts/synthesize.md +15 -0
  56. grudge/predicates/synthesizer.py +127 -0
  57. grudge/predicates/taint_flow.py +84 -0
  58. grudge/recorder/__init__.py +5 -0
  59. grudge/recorder/adapters/__init__.py +1 -0
  60. grudge/recorder/adapters/langgraph.py +8 -0
  61. grudge/recorder/adapters/mcp_proxy.py +8 -0
  62. grudge/recorder/adapters/raw_sdk.py +159 -0
  63. grudge/recorder/recorder.py +374 -0
  64. grudge/recorder/redact.py +105 -0
  65. grudge/registry/__init__.py +1 -0
  66. grudge/registry/db.py +10 -0
  67. grudge/registry/fingerprint.py +10 -0
  68. grudge/registry/flakiness.py +10 -0
  69. grudge/registry/retirement.py +10 -0
  70. grudge/replay/__init__.py +19 -0
  71. grudge/replay/cassette_player.py +159 -0
  72. grudge/replay/sandbox.py +340 -0
  73. grudge/replay/scripted_llm.py +382 -0
  74. grudge/schema/__init__.py +45 -0
  75. grudge/schema/cassette.py +206 -0
  76. grudge/schema/predicate.py +263 -0
  77. grudge/schema/scar.py +113 -0
  78. grudge/schema/trace.py +243 -0
  79. grudge/shrinker/__init__.py +12 -0
  80. grudge/shrinker/cassette_builder.py +328 -0
  81. grudge/shrinker/ddmin.py +243 -0
  82. grudge/shrinker/oracle.py +230 -0
  83. grudge/shrinker/reducers.py +331 -0
  84. grudge_cli-0.1.0.dist-info/LICENSE +21 -0
  85. grudge_cli-0.1.0.dist-info/METADATA +313 -0
  86. grudge_cli-0.1.0.dist-info/RECORD +89 -0
  87. grudge_cli-0.1.0.dist-info/WHEEL +5 -0
  88. grudge_cli-0.1.0.dist-info/entry_points.txt +2 -0
  89. grudge_cli-0.1.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,328 @@
1
+ """Two-level cassette shrinking orchestration.
2
+
3
+ Level one removes whole cassette entries using ordered delta debugging. Level
4
+ two removes irrelevant response fields, list members, text tokens, and matcher
5
+ detail. Every attempted candidate is recorded in ``history`` so callers can
6
+ animate or audit the deterministic search.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable, Iterable, Mapping, Sequence
12
+ from typing import Any, TypeAlias
13
+
14
+ from grudge.errors import OracleError
15
+ from grudge.schema.cassette import ArgMatcher, Cassette, CassetteEntry
16
+ from grudge.shrinker.oracle import Oracle, PredicateOracle, SeedPredicate
17
+
18
+ ReductionEvent: TypeAlias = dict[str, Any]
19
+ ContentReducer: TypeAlias = Callable[[Cassette], Iterable[tuple[str, Cassette]]]
20
+
21
+
22
+ class ShrinkResult:
23
+ """The original cassette, its minimal candidate, and deterministic history."""
24
+
25
+ def __init__(
26
+ self,
27
+ original: Cassette,
28
+ minimal: Cassette,
29
+ stats: Mapping[str, int],
30
+ history: Sequence[ReductionEvent],
31
+ ) -> None:
32
+ self.original = original
33
+ self.minimal = minimal
34
+ self.stats = dict(stats)
35
+ self.history = list(history)
36
+
37
+
38
+ def _candidate(
39
+ current: Cassette,
40
+ entries: Sequence[CassetteEntry],
41
+ ) -> Cassette:
42
+ """Copy a cassette while replacing its ordered world."""
43
+
44
+ return current.model_copy(update={"world": list(entries)}, deep=True)
45
+
46
+
47
+ def _event(
48
+ *,
49
+ level: str,
50
+ reducer: str,
51
+ kept: bool,
52
+ before: Cassette,
53
+ after: Cassette,
54
+ attempted_removal: int,
55
+ ) -> ReductionEvent:
56
+ return {
57
+ "level": level,
58
+ "reducer": reducer,
59
+ "attempted_removal": attempted_removal,
60
+ "kept": kept,
61
+ "size_before": len(before.world),
62
+ "size_after": len(after.world),
63
+ "bytes_before": before.size_bytes(),
64
+ "bytes_after": after.size_bytes(),
65
+ }
66
+
67
+
68
+ def _chunks(items: Sequence[CassetteEntry], count: int) -> list[list[CassetteEntry]]:
69
+ """Partition items into contiguous, stable chunks."""
70
+
71
+ size, remainder = divmod(len(items), count)
72
+ chunks: list[list[CassetteEntry]] = []
73
+ start = 0
74
+ for index in range(count):
75
+ width = size + (1 if index < remainder else 0)
76
+ if width:
77
+ chunks.append(list(items[start : start + width]))
78
+ start += width
79
+ return chunks
80
+
81
+
82
+ def _complement(
83
+ items: Sequence[CassetteEntry], subset: Sequence[CassetteEntry]
84
+ ) -> list[CassetteEntry]:
85
+ """Remove one occurrence of each subset item, preserving duplicates."""
86
+
87
+ remaining = list(subset)
88
+ complement: list[CassetteEntry] = []
89
+ for item in items:
90
+ try:
91
+ index = remaining.index(item)
92
+ except ValueError:
93
+ complement.append(item)
94
+ else:
95
+ remaining.pop(index)
96
+ return complement
97
+
98
+
99
+ def _entry_ddmin(
100
+ cassette: Cassette,
101
+ oracle: Oracle,
102
+ history: list[ReductionEvent],
103
+ ) -> Cassette:
104
+ """Perform classic Zeller ddmin over the ordered cassette entries."""
105
+
106
+ current = cassette
107
+ granularity = 2
108
+ while len(current.world) >= 2:
109
+ granularity = min(granularity, len(current.world))
110
+ partitions = _chunks(current.world, granularity)
111
+ reduced = False
112
+
113
+ # First try each subset, then each complement. This is the classic
114
+ # algorithm and handles both single culprits and interacting pairs.
115
+ for subset in partitions:
116
+ candidate = _candidate(current, subset)
117
+ result = oracle.reproduces(candidate)
118
+ history.append(
119
+ _event(
120
+ level="entries",
121
+ reducer="ddmin-subset",
122
+ kept=result.reproduced,
123
+ before=current,
124
+ after=candidate if result.reproduced else current,
125
+ attempted_removal=len(current.world) - len(subset),
126
+ )
127
+ )
128
+ if result.reproduced:
129
+ current = candidate
130
+ granularity = max(granularity - 1, 2)
131
+ reduced = True
132
+ break
133
+ if reduced:
134
+ continue
135
+
136
+ for subset in partitions:
137
+ complement = _complement(current.world, subset)
138
+ candidate = _candidate(current, complement)
139
+ result = oracle.reproduces(candidate)
140
+ history.append(
141
+ _event(
142
+ level="entries",
143
+ reducer="ddmin-complement",
144
+ kept=result.reproduced,
145
+ before=current,
146
+ after=candidate if result.reproduced else current,
147
+ attempted_removal=len(current.world) - len(complement),
148
+ )
149
+ )
150
+ if result.reproduced:
151
+ current = candidate
152
+ granularity = max(granularity - 1, 2)
153
+ reduced = True
154
+ break
155
+ if reduced:
156
+ continue
157
+
158
+ if granularity == len(current.world):
159
+ break
160
+ granularity = min(len(current.world), granularity * 2)
161
+ return current
162
+
163
+
164
+ def _string_variants(value: str) -> Iterable[str]:
165
+ """Yield stable text reductions from coarse to fine."""
166
+
167
+ if not value:
168
+ return
169
+ yield ""
170
+ words = value.split()
171
+ if len(words) > 1:
172
+ # Chunk removal is enough to preserve the two meaningful fragments in
173
+ # the demo injection while discarding its prose and filler.
174
+ for count in range(2, len(words) + 1):
175
+ chunks = _word_chunks(words, count)
176
+ for chunk in chunks:
177
+ yield " ".join(
178
+ word for index, word in enumerate(words) if index not in chunk
179
+ )
180
+ if len(value) > 1:
181
+ yield value[: len(value) // 2]
182
+
183
+
184
+ def _word_chunks(words: Sequence[str], count: int) -> list[set[int]]:
185
+ """Return index chunks for deterministic token-level reduction."""
186
+
187
+ width, remainder = divmod(len(words), count)
188
+ chunks: list[set[int]] = []
189
+ start = 0
190
+ for index in range(count):
191
+ chunk_width = width + (1 if index < remainder else 0)
192
+ if chunk_width:
193
+ chunks.append(set(range(start, start + chunk_width)))
194
+ start += chunk_width
195
+ return chunks
196
+
197
+
198
+ def _value_variants(value: Any) -> Iterable[Any]:
199
+ """Yield one deterministic, local reduction for a JSON-like value."""
200
+
201
+ if isinstance(value, str):
202
+ yield from _string_variants(value)
203
+ return
204
+ if isinstance(value, Mapping):
205
+ for key in value:
206
+ reduced = dict(value)
207
+ del reduced[key]
208
+ yield reduced
209
+ for key, child in value.items():
210
+ for replacement in _value_variants(child):
211
+ reduced = dict(value)
212
+ reduced[key] = replacement
213
+ yield reduced
214
+ return
215
+ if isinstance(value, list):
216
+ for index in range(len(value)):
217
+ yield value[:index] + value[index + 1 :]
218
+ for index, child in enumerate(value):
219
+ for replacement in _value_variants(child):
220
+ reduced_list = list(value)
221
+ reduced_list[index] = replacement
222
+ yield reduced_list
223
+
224
+
225
+ def _content_candidates(cassette: Cassette) -> Iterable[tuple[str, Cassette]]:
226
+ """Yield candidate cassettes for the current level-two pass."""
227
+
228
+ for entry_index, entry in enumerate(cassette.world):
229
+ for replacement in _value_variants(entry.response):
230
+ entries = list(cassette.world)
231
+ entries[entry_index] = entry.model_copy(update={"response": replacement})
232
+ yield "content", _candidate(cassette, entries)
233
+ if entry.match.kind != "any":
234
+ entries = list(cassette.world)
235
+ entries[entry_index] = entry.model_copy(update={"match": ArgMatcher.any()})
236
+ yield "matcher", _candidate(cassette, entries)
237
+
238
+
239
+ def _reduce_content(
240
+ cassette: Cassette,
241
+ oracle: Oracle,
242
+ history: list[ReductionEvent],
243
+ ) -> Cassette:
244
+ """Apply content reductions to a fixed point in stable order."""
245
+
246
+ current = cassette
247
+ while True:
248
+ accepted = False
249
+ for reducer, candidate in _content_candidates(current):
250
+ result = oracle.reproduces(candidate)
251
+ history.append(
252
+ _event(
253
+ level="content",
254
+ reducer=reducer,
255
+ kept=result.reproduced,
256
+ before=current,
257
+ after=candidate if result.reproduced else current,
258
+ attempted_removal=0,
259
+ )
260
+ )
261
+ if result.reproduced:
262
+ current = candidate
263
+ accepted = True
264
+ break
265
+ if not accepted:
266
+ return current
267
+
268
+
269
+ def build_minimal_cassette(
270
+ original: Cassette,
271
+ oracle: Oracle | None = None,
272
+ *,
273
+ agent_fn: Callable[..., Any] | None = None,
274
+ seed_predicate: SeedPredicate | Any | None = None,
275
+ llm: Any = None,
276
+ repeats: int = 1,
277
+ max_oracle_calls: int | None = 1000,
278
+ timeout: float = 2.0,
279
+ max_steps: int = 128,
280
+ ) -> ShrinkResult:
281
+ """Build a minimal reproducing cassette with ordered reduction history.
282
+
283
+ Callers may provide an existing ``Oracle`` or the ``agent_fn`` and
284
+ ``seed_predicate`` needed to construct a :class:`PredicateOracle`.
285
+ """
286
+
287
+ if oracle is None:
288
+ if agent_fn is None or seed_predicate is None:
289
+ raise TypeError(
290
+ "provide oracle, or provide both agent_fn and seed_predicate"
291
+ )
292
+ oracle = PredicateOracle(
293
+ agent_fn,
294
+ seed_predicate,
295
+ llm=llm,
296
+ repeats=repeats,
297
+ max_oracle_calls=max_oracle_calls,
298
+ timeout=timeout,
299
+ max_steps=max_steps,
300
+ )
301
+ original_copy = original.model_copy(deep=True)
302
+ initial = oracle.reproduces(original_copy)
303
+ if not initial.reproduced:
304
+ raise OracleError(
305
+ "cannot shrink a cassette that does not reproduce: " + initial.reason
306
+ )
307
+
308
+ history: list[ReductionEvent] = []
309
+ entry_minimal = _entry_ddmin(original_copy, oracle, history)
310
+ minimal = _reduce_content(entry_minimal, oracle, history)
311
+ stats = {
312
+ "oracle_calls": int(getattr(oracle, "calls", 0)),
313
+ "cache_hits": int(getattr(oracle, "cache_hits", 0)),
314
+ "history_events": len(history),
315
+ "original_entries": len(original_copy.world),
316
+ "minimal_entries": len(minimal.world),
317
+ "original_bytes": original_copy.size_bytes(),
318
+ "minimal_bytes": minimal.size_bytes(),
319
+ }
320
+ return ShrinkResult(original_copy, minimal, stats, history)
321
+
322
+
323
+ __all__ = [
324
+ "ContentReducer",
325
+ "ReductionEvent",
326
+ "ShrinkResult",
327
+ "build_minimal_cassette",
328
+ ]
@@ -0,0 +1,243 @@
1
+ """Pure Zeller delta debugging for the cassette shrinker.
2
+
3
+ This module deliberately knows nothing about cassettes or agents. It takes a
4
+ sequence and a deterministic reproduction predicate, then returns a reduced
5
+ list together with the ordered attempts made while searching.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable, Sequence
11
+ from dataclasses import dataclass
12
+ from typing import Any, TypeVar
13
+
14
+ ItemT = TypeVar("ItemT")
15
+ ProgressEvent = dict[str, Any]
16
+ ProgressCallback = Callable[[ProgressEvent], None]
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class DdminStats:
21
+ """Deterministic accounting and history for one ddmin invocation."""
22
+
23
+ attempts: int
24
+ reductions: int
25
+ test_calls: int
26
+ granularity: int
27
+ budget_exhausted: bool
28
+ history: tuple[ProgressEvent, ...]
29
+
30
+ @property
31
+ def calls(self) -> int:
32
+ """Compatibility alias for the number of predicate calls."""
33
+
34
+ return self.test_calls
35
+
36
+ def __getitem__(self, key: str) -> Any:
37
+ """Allow stats to be consumed like the small mapping it represents."""
38
+
39
+ if key == "calls":
40
+ return self.test_calls
41
+ return getattr(self, key)
42
+
43
+ def get(self, key: str, default: Any = None) -> Any:
44
+ """Return a field without requiring callers to know the dataclass API."""
45
+
46
+ try:
47
+ return self[key]
48
+ except AttributeError:
49
+ return default
50
+
51
+ def as_dict(self) -> dict[str, Any]:
52
+ """Return JSON-compatible stats for event and report consumers."""
53
+
54
+ return {
55
+ "attempts": self.attempts,
56
+ "reductions": self.reductions,
57
+ "test_calls": self.test_calls,
58
+ "calls": self.test_calls,
59
+ "granularity": self.granularity,
60
+ "budget_exhausted": self.budget_exhausted,
61
+ "history": [dict(event) for event in self.history],
62
+ }
63
+
64
+
65
+ def _jsonish(value: Any) -> Any:
66
+ """Keep progress history serializable for cassette-entry viewers."""
67
+
68
+ dump = getattr(value, "model_dump", None)
69
+ if callable(dump):
70
+ return dump(mode="json")
71
+ if isinstance(value, list):
72
+ return [_jsonish(item) for item in value]
73
+ if isinstance(value, tuple):
74
+ return [_jsonish(item) for item in value]
75
+ if isinstance(value, dict):
76
+ return {str(key): _jsonish(item) for key, item in value.items()}
77
+ return value
78
+
79
+
80
+ def _partitions(items: list[ItemT], granularity: int) -> list[list[ItemT]]:
81
+ """Split ``items`` into deterministic, contiguous, non-empty chunks."""
82
+
83
+ count = min(max(granularity, 1), len(items))
84
+ quotient, remainder = divmod(len(items), count)
85
+ partitions: list[list[ItemT]] = []
86
+ start = 0
87
+ for index in range(count):
88
+ width = quotient + (1 if index < remainder else 0)
89
+ partitions.append(items[start : start + width])
90
+ start += width
91
+ return partitions
92
+
93
+
94
+ def _event(
95
+ removed: list[ItemT], kept: bool, size_before: int, size_after: int
96
+ ) -> ProgressEvent:
97
+ """Build one stable progress record."""
98
+
99
+ return {
100
+ "attempted_removal": [_jsonish(item) for item in removed],
101
+ "kept": kept,
102
+ "size_before": size_before,
103
+ "size_after": size_after,
104
+ }
105
+
106
+
107
+ def minimize(
108
+ items: Sequence[ItemT],
109
+ test: Callable[[list[ItemT]], bool],
110
+ *,
111
+ progress: ProgressCallback | None = None,
112
+ progress_callback: ProgressCallback | None = None,
113
+ on_progress: ProgressCallback | None = None,
114
+ max_attempts: int | None = None,
115
+ max_calls: int | None = None,
116
+ ) -> tuple[list[ItemT], DdminStats]:
117
+ """Return a 1-minimal subsequence that still satisfies ``test``.
118
+
119
+ The implementation follows Zeller's algorithm: start with two chunks,
120
+ test each chunk before its complement, and double the granularity when a
121
+ pass makes no progress. A successful reduction resets granularity toward
122
+ two. ``test`` receives a fresh list for every candidate and is never
123
+ allowed to mutate the working list through the public API.
124
+
125
+ ``progress``, ``progress_callback``, and ``on_progress`` are aliases;
126
+ supplying more than one is an error. A budget is optional and only
127
+ affects calls when explicitly set.
128
+ Without a budget, termination checks every singleton removal and is thus
129
+ 1-minimal. A budget-limited result is reported through
130
+ :attr:`DdminStats.budget_exhausted`.
131
+ """
132
+
133
+ callbacks = [
134
+ callback
135
+ for callback in (progress, progress_callback, on_progress)
136
+ if callback is not None
137
+ ]
138
+ if len(callbacks) > 1:
139
+ raise TypeError("provide only one progress callback")
140
+ callback = callbacks[0] if callbacks else None
141
+ if max_attempts is not None and max_attempts < 0:
142
+ raise ValueError("max_attempts must be non-negative")
143
+ if max_calls is not None and max_calls < 0:
144
+ raise ValueError("max_calls must be non-negative")
145
+ if max_attempts is not None and max_calls is not None:
146
+ if max_attempts != max_calls:
147
+ raise ValueError("max_attempts and max_calls disagree")
148
+ budget = max_attempts if max_attempts is not None else max_calls
149
+
150
+ current = list(items)
151
+ history: list[ProgressEvent] = []
152
+ attempts = 0
153
+ reductions = 0
154
+ granularity = 2 if len(current) > 1 else len(current)
155
+ budget_exhausted = False
156
+
157
+ def try_candidate(candidate: list[ItemT], removed: list[ItemT]) -> bool:
158
+ nonlocal attempts, reductions, budget_exhausted, current
159
+ if budget is not None and attempts >= budget:
160
+ budget_exhausted = True
161
+ return False
162
+ size_before = len(current)
163
+ attempts += 1
164
+ kept = bool(test(list(candidate)))
165
+ event = _event(
166
+ removed,
167
+ kept,
168
+ size_before,
169
+ len(candidate) if kept else size_before,
170
+ )
171
+ history.append(event)
172
+ if callback is not None:
173
+ callback(dict(event))
174
+ if kept:
175
+ current = candidate
176
+ reductions += 1
177
+ return kept
178
+
179
+ while len(current) > 1 and not budget_exhausted:
180
+ granularity = min(max(granularity, 2), len(current))
181
+ partitions = _partitions(current, granularity)
182
+ reduced = False
183
+
184
+ # First try retaining each partition (removing its complement).
185
+ for index, subset in enumerate(partitions):
186
+ removed = [
187
+ item
188
+ for part_index, part in enumerate(partitions)
189
+ if part_index != index
190
+ for item in part
191
+ ]
192
+ if try_candidate(list(subset), removed):
193
+ granularity = max(granularity - 1, 2)
194
+ reduced = True
195
+ break
196
+ if budget_exhausted:
197
+ break
198
+ if reduced or budget_exhausted:
199
+ continue
200
+
201
+ # Then try retaining each complement (removing the partition).
202
+ for index, subset in enumerate(partitions):
203
+ complement = [
204
+ item
205
+ for part_index, part in enumerate(partitions)
206
+ if part_index != index
207
+ for item in part
208
+ ]
209
+ if try_candidate(complement, list(subset)):
210
+ granularity = max(granularity - 1, 2)
211
+ reduced = True
212
+ break
213
+ if budget_exhausted:
214
+ break
215
+ if reduced or budget_exhausted:
216
+ continue
217
+
218
+ if granularity >= len(current):
219
+ break
220
+ granularity = min(len(current), granularity * 2)
221
+
222
+ stats = DdminStats(
223
+ attempts=attempts,
224
+ reductions=reductions,
225
+ test_calls=attempts,
226
+ granularity=granularity,
227
+ budget_exhausted=budget_exhausted,
228
+ history=tuple(dict(event) for event in history),
229
+ )
230
+ return current, stats
231
+
232
+
233
+ def ddmin(
234
+ items: Sequence[ItemT],
235
+ test: Callable[[list[ItemT]], bool],
236
+ **kwargs: Any,
237
+ ) -> tuple[list[ItemT], DdminStats]:
238
+ """Explicit algorithm name; an alias of :func:`minimize`."""
239
+
240
+ return minimize(items, test, **kwargs)
241
+
242
+
243
+ __all__ = ["DdminStats", "ProgressEvent", "ddmin", "minimize"]