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.
- examples/__init__.py +1 -0
- examples/leaky_agent/__init__.py +1 -0
- examples/leaky_agent/agent.py +250 -0
- examples/leaky_agent/agent_fixed.py +183 -0
- examples/leaky_agent/tools.py +153 -0
- grudge/__init__.py +5 -0
- grudge/__main__.py +6 -0
- grudge/_stubs.py +8 -0
- grudge/admission/__init__.py +6 -0
- grudge/admission/court.py +239 -0
- grudge/admission/verdict.py +26 -0
- grudge/cli/__init__.py +1 -0
- grudge/cli/_common.py +126 -0
- grudge/cli/admit.py +93 -0
- grudge/cli/capture.py +39 -0
- grudge/cli/compile.py +37 -0
- grudge/cli/demo.py +136 -0
- grudge/cli/main.py +54 -0
- grudge/cli/run.py +60 -0
- grudge/cli/scars.py +91 -0
- grudge/cli/shrink.py +108 -0
- grudge/config.py +35 -0
- grudge/demo/__init__.py +1 -0
- grudge/demo/events.py +228 -0
- grudge/demo/orchestrator.py +326 -0
- grudge/demo/server.py +222 -0
- grudge/errors.py +25 -0
- grudge/fuzzer/__init__.py +1 -0
- grudge/fuzzer/fingerprint_guard.py +10 -0
- grudge/fuzzer/mutators.py +10 -0
- grudge/gate/__init__.py +5 -0
- grudge/gate/report.py +125 -0
- grudge/gate/runner.py +323 -0
- grudge/gate/selector.py +10 -0
- grudge/gate/wilson.py +36 -0
- grudge/guard/__init__.py +5 -0
- grudge/guard/compile.py +251 -0
- grudge/guard/import_session.py +106 -0
- grudge/integrations/__init__.py +1 -0
- grudge/integrations/github_comment.py +10 -0
- grudge/integrations/pytest_plugin.py +10 -0
- grudge/llm/__init__.py +6 -0
- grudge/llm/cache.py +92 -0
- grudge/llm/client.py +82 -0
- grudge/locator/__init__.py +1 -0
- grudge/locator/fork.py +10 -0
- grudge/locator/point_of_no_return.py +10 -0
- grudge/predicates/__init__.py +25 -0
- grudge/predicates/call_shape.py +116 -0
- grudge/predicates/compiler.py +116 -0
- grudge/predicates/dsl.py +295 -0
- grudge/predicates/fallback_rules.py +10 -0
- grudge/predicates/ordering.py +33 -0
- grudge/predicates/outcome.py +14 -0
- grudge/predicates/prompts/synthesize.md +15 -0
- grudge/predicates/synthesizer.py +127 -0
- grudge/predicates/taint_flow.py +84 -0
- grudge/recorder/__init__.py +5 -0
- grudge/recorder/adapters/__init__.py +1 -0
- grudge/recorder/adapters/langgraph.py +8 -0
- grudge/recorder/adapters/mcp_proxy.py +8 -0
- grudge/recorder/adapters/raw_sdk.py +159 -0
- grudge/recorder/recorder.py +374 -0
- grudge/recorder/redact.py +105 -0
- grudge/registry/__init__.py +1 -0
- grudge/registry/db.py +10 -0
- grudge/registry/fingerprint.py +10 -0
- grudge/registry/flakiness.py +10 -0
- grudge/registry/retirement.py +10 -0
- grudge/replay/__init__.py +19 -0
- grudge/replay/cassette_player.py +159 -0
- grudge/replay/sandbox.py +340 -0
- grudge/replay/scripted_llm.py +382 -0
- grudge/schema/__init__.py +45 -0
- grudge/schema/cassette.py +206 -0
- grudge/schema/predicate.py +263 -0
- grudge/schema/scar.py +113 -0
- grudge/schema/trace.py +243 -0
- grudge/shrinker/__init__.py +12 -0
- grudge/shrinker/cassette_builder.py +328 -0
- grudge/shrinker/ddmin.py +243 -0
- grudge/shrinker/oracle.py +230 -0
- grudge/shrinker/reducers.py +331 -0
- grudge_cli-0.1.0.dist-info/LICENSE +21 -0
- grudge_cli-0.1.0.dist-info/METADATA +313 -0
- grudge_cli-0.1.0.dist-info/RECORD +89 -0
- grudge_cli-0.1.0.dist-info/WHEEL +5 -0
- grudge_cli-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|
grudge/shrinker/ddmin.py
ADDED
|
@@ -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"]
|