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,230 @@
|
|
|
1
|
+
"""Deterministic failure-reproduction oracles for cassette shrinking.
|
|
2
|
+
|
|
3
|
+
The oracle is the seam between the pure shrink search and Phase 4 replay. A
|
|
4
|
+
candidate cassette is evaluated by running the live policy in the hermetic
|
|
5
|
+
sandbox and asking a small, deterministic seed predicate whether the failure
|
|
6
|
+
is still present.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import inspect
|
|
12
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, Protocol, runtime_checkable
|
|
15
|
+
|
|
16
|
+
from grudge.errors import OracleError
|
|
17
|
+
from grudge.replay.sandbox import run_agent
|
|
18
|
+
from grudge.schema.cassette import Cassette
|
|
19
|
+
from grudge.schema.predicate import (
|
|
20
|
+
CallShape,
|
|
21
|
+
Outcome,
|
|
22
|
+
PredicateSet,
|
|
23
|
+
TaintFlow,
|
|
24
|
+
)
|
|
25
|
+
from grudge.schema.trace import ToolResult, Trace
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class OracleResult:
|
|
30
|
+
"""The result of one candidate evaluation."""
|
|
31
|
+
|
|
32
|
+
reproduced: bool
|
|
33
|
+
trace: Trace | None
|
|
34
|
+
reason: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@runtime_checkable
|
|
38
|
+
class Oracle(Protocol):
|
|
39
|
+
"""Protocol implemented by objects that can test a cassette candidate."""
|
|
40
|
+
|
|
41
|
+
calls: int
|
|
42
|
+
cache_hits: int
|
|
43
|
+
|
|
44
|
+
def reproduces(self, cassette: Cassette) -> OracleResult:
|
|
45
|
+
"""Return whether ``cassette`` still reproduces the seed failure."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
SeedPredicate = Callable[[Trace], bool] | CallShape | TaintFlow | Outcome | PredicateSet
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _value_contains(value: Any, needle: Any) -> bool:
|
|
52
|
+
"""Return whether ``needle`` occurs in a JSON-like value."""
|
|
53
|
+
|
|
54
|
+
if needle is None:
|
|
55
|
+
return False
|
|
56
|
+
if isinstance(value, Mapping):
|
|
57
|
+
return any(_value_contains(item, needle) for item in value.values())
|
|
58
|
+
if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray):
|
|
59
|
+
return any(_value_contains(item, needle) for item in value)
|
|
60
|
+
if isinstance(needle, str):
|
|
61
|
+
return needle in str(value)
|
|
62
|
+
return bool(value == needle)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _field(value: Any, name: str | None) -> Any:
|
|
66
|
+
"""Read an optional field from a mapping, leaving scalar results intact."""
|
|
67
|
+
|
|
68
|
+
if name is None:
|
|
69
|
+
return value
|
|
70
|
+
if isinstance(value, Mapping):
|
|
71
|
+
return value.get(name)
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _schema_predicate(predicate: Any, trace: Trace) -> bool:
|
|
76
|
+
"""Evaluate the Phase 1 predicate shapes used by the seed detectors."""
|
|
77
|
+
|
|
78
|
+
if isinstance(predicate, CallShape):
|
|
79
|
+
return any(
|
|
80
|
+
step.tool == predicate.tool
|
|
81
|
+
and all(
|
|
82
|
+
step.args.get(key) == value for key, value in predicate.args.items()
|
|
83
|
+
)
|
|
84
|
+
for step in trace.tool_calls()
|
|
85
|
+
)
|
|
86
|
+
if isinstance(predicate, TaintFlow):
|
|
87
|
+
calls = trace.tool_calls()
|
|
88
|
+
results = {
|
|
89
|
+
step.call_id: step for step in trace.steps if isinstance(step, ToolResult)
|
|
90
|
+
}
|
|
91
|
+
for source_index, source_call in enumerate(calls):
|
|
92
|
+
if source_call.tool != predicate.source:
|
|
93
|
+
continue
|
|
94
|
+
source_result = results.get(source_call.call_id)
|
|
95
|
+
if source_result is None:
|
|
96
|
+
continue
|
|
97
|
+
source_value = _field(source_result.content, predicate.source_field)
|
|
98
|
+
for sink_call in calls[source_index + 1 :]:
|
|
99
|
+
if sink_call.tool != predicate.sink:
|
|
100
|
+
continue
|
|
101
|
+
sink_value = _field(sink_call.args, predicate.sink_field)
|
|
102
|
+
if _value_contains(sink_value, source_value):
|
|
103
|
+
return True
|
|
104
|
+
return False
|
|
105
|
+
if isinstance(predicate, Outcome):
|
|
106
|
+
return trace.outcome == predicate.outcome
|
|
107
|
+
if isinstance(predicate, PredicateSet):
|
|
108
|
+
values = [_schema_predicate(item, trace) for item in predicate.predicates]
|
|
109
|
+
if predicate.kind == "all_of":
|
|
110
|
+
return all(values)
|
|
111
|
+
if predicate.kind == "any_of":
|
|
112
|
+
return any(values)
|
|
113
|
+
return not values[0]
|
|
114
|
+
raise TypeError(f"unsupported seed predicate: {type(predicate).__name__}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _evaluate(predicate: Any, trace: Trace, cassette: Cassette) -> bool:
|
|
118
|
+
"""Evaluate a callable, schema predicate, or small checker-like object."""
|
|
119
|
+
|
|
120
|
+
if isinstance(predicate, CallShape | TaintFlow | Outcome | PredicateSet):
|
|
121
|
+
return _schema_predicate(predicate, trace)
|
|
122
|
+
checker = getattr(predicate, "check", None)
|
|
123
|
+
if callable(checker):
|
|
124
|
+
result = checker(trace)
|
|
125
|
+
return bool(getattr(result, "violated", result))
|
|
126
|
+
evaluator = getattr(predicate, "evaluate", None)
|
|
127
|
+
if callable(evaluator):
|
|
128
|
+
result = evaluator(trace)
|
|
129
|
+
return bool(getattr(result, "violated", result))
|
|
130
|
+
if not callable(predicate):
|
|
131
|
+
raise TypeError(f"unsupported seed predicate: {type(predicate).__name__}")
|
|
132
|
+
try:
|
|
133
|
+
parameters = dict(inspect.signature(predicate).parameters)
|
|
134
|
+
except (TypeError, ValueError):
|
|
135
|
+
parameters = {}
|
|
136
|
+
positional = [
|
|
137
|
+
parameter
|
|
138
|
+
for parameter in parameters.values()
|
|
139
|
+
if parameter.kind
|
|
140
|
+
in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
|
141
|
+
]
|
|
142
|
+
if len(positional) >= 2:
|
|
143
|
+
return bool(predicate(trace, cassette))
|
|
144
|
+
return bool(predicate(trace))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class PredicateOracle:
|
|
148
|
+
"""Run an agent in the replay sandbox and memoize predicate outcomes."""
|
|
149
|
+
|
|
150
|
+
def __init__(
|
|
151
|
+
self,
|
|
152
|
+
agent_fn: Callable[..., Any],
|
|
153
|
+
seed_predicate: SeedPredicate | Any,
|
|
154
|
+
*,
|
|
155
|
+
llm: Any = None,
|
|
156
|
+
repeats: int = 1,
|
|
157
|
+
max_oracle_calls: int | None = 1000,
|
|
158
|
+
timeout: float = 2.0,
|
|
159
|
+
max_steps: int = 128,
|
|
160
|
+
) -> None:
|
|
161
|
+
if repeats < 1:
|
|
162
|
+
raise ValueError("repeats must be at least 1")
|
|
163
|
+
if max_oracle_calls is not None and max_oracle_calls < 1:
|
|
164
|
+
raise ValueError("max_oracle_calls must be at least 1")
|
|
165
|
+
if timeout <= 0 or max_steps < 0:
|
|
166
|
+
raise ValueError("timeout must be positive and max_steps non-negative")
|
|
167
|
+
self.agent_fn = agent_fn
|
|
168
|
+
self.seed_predicate = seed_predicate
|
|
169
|
+
self.llm = llm
|
|
170
|
+
self.repeats = repeats
|
|
171
|
+
self.max_oracle_calls = max_oracle_calls
|
|
172
|
+
self.timeout = timeout
|
|
173
|
+
self.max_steps = max_steps
|
|
174
|
+
self.calls = 0
|
|
175
|
+
self.cache_hits = 0
|
|
176
|
+
self._cache: dict[str, OracleResult] = {}
|
|
177
|
+
|
|
178
|
+
def reproduces(self, cassette: Cassette) -> OracleResult:
|
|
179
|
+
"""Evaluate ``cassette`` once, or return its memoized result."""
|
|
180
|
+
|
|
181
|
+
cache_key = cassette.content_hash
|
|
182
|
+
cached = self._cache.get(cache_key)
|
|
183
|
+
if cached is not None:
|
|
184
|
+
self.cache_hits += 1
|
|
185
|
+
return cached
|
|
186
|
+
if self.max_oracle_calls is not None and self.calls >= self.max_oracle_calls:
|
|
187
|
+
raise OracleError(
|
|
188
|
+
"oracle call budget exhausted: "
|
|
189
|
+
f"{self.calls} calls used, max_oracle_calls="
|
|
190
|
+
f"{self.max_oracle_calls}"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
self.calls += 1
|
|
194
|
+
traces: list[Trace] = []
|
|
195
|
+
failures: list[str] = []
|
|
196
|
+
reproduced_runs = 0
|
|
197
|
+
for _ in range(self.repeats):
|
|
198
|
+
trace = run_agent(
|
|
199
|
+
self.agent_fn,
|
|
200
|
+
cassette,
|
|
201
|
+
self.llm,
|
|
202
|
+
timeout=self.timeout,
|
|
203
|
+
max_steps=self.max_steps,
|
|
204
|
+
)
|
|
205
|
+
traces.append(trace)
|
|
206
|
+
try:
|
|
207
|
+
reproduced = _evaluate(self.seed_predicate, trace, cassette)
|
|
208
|
+
except Exception as exc:
|
|
209
|
+
reproduced = False
|
|
210
|
+
failures.append(f"predicate error: {type(exc).__name__}: {exc}")
|
|
211
|
+
if reproduced:
|
|
212
|
+
reproduced_runs += 1
|
|
213
|
+
if not reproduced:
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
if failures:
|
|
217
|
+
reason = failures[0]
|
|
218
|
+
else:
|
|
219
|
+
run_summary = f"{reproduced_runs}/{self.repeats}"
|
|
220
|
+
reason = f"seed predicate reproduced in {run_summary} runs"
|
|
221
|
+
result = OracleResult(
|
|
222
|
+
reproduced_runs == self.repeats,
|
|
223
|
+
traces[0] if traces else None,
|
|
224
|
+
reason,
|
|
225
|
+
)
|
|
226
|
+
self._cache[cache_key] = result
|
|
227
|
+
return result
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
__all__ = ["Oracle", "OracleResult", "PredicateOracle", "SeedPredicate"]
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Pure content reducers used after cassette-entry delta debugging.
|
|
2
|
+
|
|
3
|
+
Each reducer accepts a JSON-compatible value (including a validated Pydantic
|
|
4
|
+
model), makes copies before trying candidates, and keeps a candidate only when
|
|
5
|
+
the supplied reproduction test still passes. That makes the reducers safe to
|
|
6
|
+
compose and keeps cassette validation at the model boundary.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import copy
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
14
|
+
from typing import Any, Protocol
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
|
|
18
|
+
from grudge.shrinker.ddmin import minimize
|
|
19
|
+
|
|
20
|
+
PathPart = str | int
|
|
21
|
+
Path = tuple[PathPart, ...]
|
|
22
|
+
ContentTest = Callable[[Any], bool]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ContentReducer(Protocol):
|
|
26
|
+
"""The small common interface implemented by all content reducers."""
|
|
27
|
+
|
|
28
|
+
def reduce(self, value: Any, test: ContentTest) -> Any:
|
|
29
|
+
"""Return the smallest accepted value this reducer can find."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _passes(test: ContentTest, value: Any) -> bool:
|
|
33
|
+
"""Treat structural lookup failures as a rejected candidate."""
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
return bool(test(value))
|
|
37
|
+
except (AttributeError, IndexError, KeyError, TypeError):
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _children(value: Any) -> Iterable[tuple[PathPart, Any]]:
|
|
42
|
+
if isinstance(value, BaseModel):
|
|
43
|
+
for name in type(value).model_fields:
|
|
44
|
+
yield name, getattr(value, name)
|
|
45
|
+
elif isinstance(value, Mapping):
|
|
46
|
+
for key, child in value.items():
|
|
47
|
+
if isinstance(key, str | int):
|
|
48
|
+
yield key, child
|
|
49
|
+
elif isinstance(value, list):
|
|
50
|
+
yield from enumerate(value)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _paths(value: Any, prefix: Path = ()) -> Iterable[Path]:
|
|
54
|
+
for part, child in _children(value):
|
|
55
|
+
path = (*prefix, part)
|
|
56
|
+
yield path
|
|
57
|
+
yield from _paths(child, path)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _get_path(value: Any, path: Path) -> Any:
|
|
61
|
+
current = value
|
|
62
|
+
for part in path:
|
|
63
|
+
if isinstance(current, BaseModel):
|
|
64
|
+
current = getattr(current, str(part))
|
|
65
|
+
else:
|
|
66
|
+
current = current[part]
|
|
67
|
+
return current
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _set_path(value: Any, path: Path, replacement: Any) -> None:
|
|
71
|
+
if not path:
|
|
72
|
+
raise ValueError("a reducer path must not be empty")
|
|
73
|
+
parent = _get_path(value, path[:-1])
|
|
74
|
+
part = path[-1]
|
|
75
|
+
if isinstance(parent, BaseModel):
|
|
76
|
+
setattr(parent, str(part), replacement)
|
|
77
|
+
else:
|
|
78
|
+
parent[part] = replacement
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _candidate(value: Any, path: Path, replacement: Any) -> Any | None:
|
|
82
|
+
try:
|
|
83
|
+
result = copy.deepcopy(value)
|
|
84
|
+
_set_path(result, path, replacement)
|
|
85
|
+
except (AttributeError, KeyError, IndexError, TypeError, ValueError):
|
|
86
|
+
# Invalid assignments are expected for constrained Cassette fields
|
|
87
|
+
# (for example, replacing a required tool name with "").
|
|
88
|
+
return None
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _sentences(text: str) -> list[str]:
|
|
93
|
+
"""Split prose without dropping its terminal punctuation."""
|
|
94
|
+
|
|
95
|
+
parts = re.split(r"(?<=[.!?])\s+", text)
|
|
96
|
+
return [part for part in parts if part]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _join_chunks(chunks: Sequence[str], *, lines: bool) -> str:
|
|
100
|
+
return ("" if lines else " ").join(chunks)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _tests_text(value: Any, path: Path, text: str, test: ContentTest) -> bool:
|
|
104
|
+
candidate = _candidate(value, path, text)
|
|
105
|
+
return candidate is not None and _passes(test, candidate)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class TrimDocument:
|
|
109
|
+
"""Remove irrelevant lines and sentences from text fields via ddmin."""
|
|
110
|
+
|
|
111
|
+
def reduce(self, value: Any, test: ContentTest) -> Any:
|
|
112
|
+
"""Try every text field in stable traversal order."""
|
|
113
|
+
|
|
114
|
+
result = copy.deepcopy(value)
|
|
115
|
+
for path in list(_paths(result)):
|
|
116
|
+
try:
|
|
117
|
+
text = _get_path(result, path)
|
|
118
|
+
except (AttributeError, KeyError, IndexError, TypeError):
|
|
119
|
+
continue
|
|
120
|
+
if not isinstance(text, str) or len(text) < 2:
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
lines = text.splitlines(keepends=True)
|
|
124
|
+
chunks = lines if len(lines) > 1 else _sentences(text)
|
|
125
|
+
if len(chunks) > 1:
|
|
126
|
+
|
|
127
|
+
def accepts_lines(
|
|
128
|
+
candidate: list[str],
|
|
129
|
+
base: Any = result,
|
|
130
|
+
field_path: Path = path,
|
|
131
|
+
line_mode: bool = len(lines) > 1,
|
|
132
|
+
) -> bool:
|
|
133
|
+
return _tests_text(
|
|
134
|
+
base,
|
|
135
|
+
field_path,
|
|
136
|
+
_join_chunks(candidate, lines=line_mode),
|
|
137
|
+
test,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
reduced_chunks, _ = minimize(
|
|
141
|
+
chunks,
|
|
142
|
+
accepts_lines,
|
|
143
|
+
)
|
|
144
|
+
replacement = _join_chunks(reduced_chunks, lines=len(lines) > 1)
|
|
145
|
+
candidate = _candidate(result, path, replacement)
|
|
146
|
+
if candidate is not None and _passes(test, candidate):
|
|
147
|
+
result = candidate
|
|
148
|
+
|
|
149
|
+
# Once line noise is removed, sentence-level reduction can still
|
|
150
|
+
# remove irrelevant prose within the surviving line(s).
|
|
151
|
+
try:
|
|
152
|
+
current_text = _get_path(result, path)
|
|
153
|
+
except (AttributeError, KeyError, IndexError, TypeError):
|
|
154
|
+
continue
|
|
155
|
+
if not isinstance(current_text, str):
|
|
156
|
+
continue
|
|
157
|
+
sentences = _sentences(current_text)
|
|
158
|
+
if len(sentences) <= 1:
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
def accepts_sentences(
|
|
162
|
+
candidate: list[str],
|
|
163
|
+
base: Any = result,
|
|
164
|
+
field_path: Path = path,
|
|
165
|
+
) -> bool:
|
|
166
|
+
return _tests_text(
|
|
167
|
+
base,
|
|
168
|
+
field_path,
|
|
169
|
+
_join_chunks(candidate, lines=False),
|
|
170
|
+
test,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
reduced_sentences, _ = minimize(
|
|
174
|
+
sentences,
|
|
175
|
+
accepts_sentences,
|
|
176
|
+
)
|
|
177
|
+
replacement = _join_chunks(reduced_sentences, lines=False)
|
|
178
|
+
candidate = _candidate(result, path, replacement)
|
|
179
|
+
if candidate is not None and _passes(test, candidate):
|
|
180
|
+
result = candidate
|
|
181
|
+
return result
|
|
182
|
+
|
|
183
|
+
apply = reduce
|
|
184
|
+
|
|
185
|
+
def __call__(self, value: Any, test: ContentTest) -> Any:
|
|
186
|
+
return self.reduce(value, test)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _neutral(value: Any) -> Any:
|
|
190
|
+
if isinstance(value, str):
|
|
191
|
+
return ""
|
|
192
|
+
if isinstance(value, bool):
|
|
193
|
+
return False
|
|
194
|
+
if isinstance(value, int | float):
|
|
195
|
+
return 0
|
|
196
|
+
if isinstance(value, list):
|
|
197
|
+
return []
|
|
198
|
+
if isinstance(value, dict):
|
|
199
|
+
return {}
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class BlankField:
|
|
204
|
+
"""Blank fields one at a time whenever reproduction remains sound."""
|
|
205
|
+
|
|
206
|
+
def __init__(self, fields: Sequence[str] | None = None) -> None:
|
|
207
|
+
self.fields = frozenset(fields) if fields is not None else None
|
|
208
|
+
|
|
209
|
+
def reduce(self, value: Any, test: ContentTest) -> Any:
|
|
210
|
+
result = copy.deepcopy(value)
|
|
211
|
+
for path in list(_paths(result)):
|
|
212
|
+
if self.fields is not None and str(path[-1]) not in self.fields:
|
|
213
|
+
continue
|
|
214
|
+
try:
|
|
215
|
+
old = _get_path(result, path)
|
|
216
|
+
except (AttributeError, KeyError, IndexError, TypeError):
|
|
217
|
+
continue
|
|
218
|
+
replacement = _neutral(old)
|
|
219
|
+
if replacement == old:
|
|
220
|
+
continue
|
|
221
|
+
candidate = _candidate(result, path, replacement)
|
|
222
|
+
if candidate is not None and _passes(test, candidate):
|
|
223
|
+
result = candidate
|
|
224
|
+
return result
|
|
225
|
+
|
|
226
|
+
apply = reduce
|
|
227
|
+
|
|
228
|
+
def __call__(self, value: Any, test: ContentTest) -> Any:
|
|
229
|
+
return self.reduce(value, test)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
_ID_TOKEN = re.compile(
|
|
233
|
+
r"\b(?P<prefix>email|user|account|order|ticket|message|document|file)"
|
|
234
|
+
r"[-_][A-Za-z0-9]+\b",
|
|
235
|
+
re.IGNORECASE,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _id_tokens(value: Any) -> list[str]:
|
|
240
|
+
tokens: list[str] = []
|
|
241
|
+
for path in _paths(value):
|
|
242
|
+
try:
|
|
243
|
+
child = _get_path(value, path)
|
|
244
|
+
except (AttributeError, KeyError, IndexError, TypeError):
|
|
245
|
+
continue
|
|
246
|
+
if not isinstance(child, str):
|
|
247
|
+
continue
|
|
248
|
+
for match in _ID_TOKEN.finditer(child):
|
|
249
|
+
token = match.group(0)
|
|
250
|
+
if token not in tokens:
|
|
251
|
+
tokens.append(token)
|
|
252
|
+
return tokens
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _replace_ids(value: Any, replacements: Mapping[str, str]) -> Any | None:
|
|
256
|
+
result = copy.deepcopy(value)
|
|
257
|
+
for path in list(_paths(result)):
|
|
258
|
+
try:
|
|
259
|
+
child = _get_path(result, path)
|
|
260
|
+
except (AttributeError, KeyError, IndexError, TypeError):
|
|
261
|
+
continue
|
|
262
|
+
if not isinstance(child, str):
|
|
263
|
+
continue
|
|
264
|
+
replacement = child
|
|
265
|
+
for old, new in replacements.items():
|
|
266
|
+
replacement = re.sub(
|
|
267
|
+
rf"(?<![A-Za-z0-9_-]){re.escape(old)}(?![A-Za-z0-9_-])",
|
|
268
|
+
new,
|
|
269
|
+
replacement,
|
|
270
|
+
)
|
|
271
|
+
if replacement != child:
|
|
272
|
+
try:
|
|
273
|
+
_set_path(result, path, replacement)
|
|
274
|
+
except (AttributeError, KeyError, IndexError, TypeError, ValueError):
|
|
275
|
+
return None
|
|
276
|
+
return result
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class NormalizeIds:
|
|
280
|
+
"""Canonicalize stable entity IDs only when the reproduction test passes."""
|
|
281
|
+
|
|
282
|
+
def reduce(self, value: Any, test: ContentTest) -> Any:
|
|
283
|
+
tokens = _id_tokens(value)
|
|
284
|
+
if not tokens:
|
|
285
|
+
return copy.deepcopy(value)
|
|
286
|
+
|
|
287
|
+
counters: dict[str, int] = {}
|
|
288
|
+
replacements: dict[str, str] = {}
|
|
289
|
+
for token in tokens:
|
|
290
|
+
prefix = token.split("-", 1)[0].split("_", 1)[0].lower()
|
|
291
|
+
counters[prefix] = counters.get(prefix, 0) + 1
|
|
292
|
+
replacements[token] = f"{prefix}_{counters[prefix]}"
|
|
293
|
+
|
|
294
|
+
all_ids = _replace_ids(value, replacements)
|
|
295
|
+
if all_ids is not None and _passes(test, all_ids):
|
|
296
|
+
return all_ids
|
|
297
|
+
|
|
298
|
+
result = copy.deepcopy(value)
|
|
299
|
+
for token, replacement in replacements.items():
|
|
300
|
+
candidate = _replace_ids(result, {token: replacement})
|
|
301
|
+
if candidate is not None and _passes(test, candidate):
|
|
302
|
+
result = candidate
|
|
303
|
+
return result
|
|
304
|
+
|
|
305
|
+
apply = reduce
|
|
306
|
+
|
|
307
|
+
def __call__(self, value: Any, test: ContentTest) -> Any:
|
|
308
|
+
return self.reduce(value, test)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def reduce_content(
|
|
312
|
+
value: Any,
|
|
313
|
+
test: ContentTest,
|
|
314
|
+
*,
|
|
315
|
+
reducers: Sequence[ContentReducer] | None = None,
|
|
316
|
+
) -> Any:
|
|
317
|
+
"""Apply the content-reducer pipeline in deterministic order."""
|
|
318
|
+
|
|
319
|
+
current = value
|
|
320
|
+
selected = reducers or (TrimDocument(), BlankField(), NormalizeIds())
|
|
321
|
+
for reducer in selected:
|
|
322
|
+
current = reducer.reduce(current, test)
|
|
323
|
+
return current
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
__all__ = [
|
|
327
|
+
"BlankField",
|
|
328
|
+
"NormalizeIds",
|
|
329
|
+
"TrimDocument",
|
|
330
|
+
"reduce_content",
|
|
331
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Grudge contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|