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
grudge/demo/server.py ADDED
@@ -0,0 +1,222 @@
1
+ """Dependency-free local server for the Phase 10 event stream and viewer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import mimetypes
7
+ import queue
8
+ import threading
9
+ from collections.abc import Callable
10
+ from http import HTTPStatus
11
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
12
+ from pathlib import Path
13
+ from urllib.parse import unquote, urlparse
14
+
15
+ from grudge.demo.events import DemoEvent, EventBus
16
+
17
+ ROOT = Path(__file__).resolve().parents[2]
18
+ VIEWER_DIR = ROOT / "viewer"
19
+
20
+
21
+ class _DemoHTTPServer(ThreadingHTTPServer):
22
+ """HTTP server carrying the bus and orchestration callback."""
23
+
24
+ daemon_threads = True
25
+ allow_reuse_address = True
26
+
27
+ def __init__(
28
+ self,
29
+ address: tuple[str, int],
30
+ bus: EventBus,
31
+ viewer_dir: Path,
32
+ runner: Callable[[], object] | None,
33
+ ) -> None:
34
+ self.bus = bus
35
+ self.viewer_dir = viewer_dir.resolve()
36
+ self.runner = runner
37
+ self._run_thread: threading.Thread | None = None
38
+ super().__init__(address, _DemoHandler)
39
+
40
+ def restart(self) -> None:
41
+ """Clear the replay buffer and run the configured demo in the background."""
42
+
43
+ self.bus.clear()
44
+ if self.runner is None:
45
+ return
46
+ self._run_thread = threading.Thread(target=self.runner, daemon=True)
47
+ self._run_thread.start()
48
+
49
+
50
+ class _DemoHandler(BaseHTTPRequestHandler):
51
+ """Serve static viewer files and the bus's SSE/JSON endpoints."""
52
+
53
+ server: _DemoHTTPServer
54
+ protocol_version = "HTTP/1.1"
55
+
56
+ def _json(self, payload: object, status: HTTPStatus = HTTPStatus.OK) -> None:
57
+ body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode()
58
+ self.send_response(status)
59
+ self.send_header("Content-Type", "application/json; charset=utf-8")
60
+ self.send_header("Content-Length", str(len(body)))
61
+ self.send_header("Cache-Control", "no-store")
62
+ self.end_headers()
63
+ self.wfile.write(body)
64
+
65
+ def do_GET(self) -> None:
66
+ path = urlparse(self.path).path
67
+ if path == "/state":
68
+ self._json(
69
+ {"events": [_payload(event) for event in self.server.bus.snapshot()]}
70
+ )
71
+ return
72
+ if path == "/events":
73
+ self._events()
74
+ return
75
+ self._static(path)
76
+
77
+ def do_POST(self) -> None:
78
+ if urlparse(self.path).path != "/restart":
79
+ self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
80
+ return
81
+ self.server.restart()
82
+ self._json({"restarted": True}, HTTPStatus.ACCEPTED)
83
+
84
+ def _events(self) -> None:
85
+ pending: queue.Queue[DemoEvent] = queue.Queue()
86
+ unsubscribe = self.server.bus.subscribe(pending.put)
87
+ history = self.server.bus.snapshot()
88
+ sent = {event.seq for event in history}
89
+ self.send_response(HTTPStatus.OK)
90
+ self.send_header("Content-Type", "text/event-stream; charset=utf-8")
91
+ self.send_header("Cache-Control", "no-cache")
92
+ self.send_header("Connection", "close")
93
+ self.end_headers()
94
+ try:
95
+ for event in history:
96
+ self._write_event(event)
97
+ self.wfile.flush()
98
+ # A completed replay is finite and useful to curl/test clients;
99
+ # a running stream remains open for the next event.
100
+ if history and history[-1].type == "demo.finished":
101
+ return
102
+ while True:
103
+ try:
104
+ event = pending.get(timeout=0.5)
105
+ except queue.Empty:
106
+ continue
107
+ if event.seq in sent:
108
+ continue
109
+ sent.add(event.seq)
110
+ self._write_event(event)
111
+ self.wfile.flush()
112
+ if event.type == "demo.finished":
113
+ return
114
+ except (
115
+ BrokenPipeError,
116
+ ConnectionAbortedError,
117
+ ConnectionResetError,
118
+ TimeoutError,
119
+ ):
120
+ return
121
+ finally:
122
+ unsubscribe()
123
+
124
+ def _write_event(self, event: DemoEvent) -> None:
125
+ payload = json.dumps(_payload(event), ensure_ascii=False, separators=(",", ":"))
126
+ self.wfile.write(f"event: {event.type}\ndata: {payload}\n\n".encode())
127
+
128
+ def _static(self, path: str) -> None:
129
+ relative = unquote(path.lstrip("/")) or "index.html"
130
+ candidate = (self.server.viewer_dir / relative).resolve()
131
+ if not candidate.is_file() or not candidate.is_relative_to(
132
+ self.server.viewer_dir
133
+ ):
134
+ self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
135
+ return
136
+ body = candidate.read_bytes()
137
+ content_type = mimetypes.guess_type(candidate.name)[0] or (
138
+ "application/octet-stream"
139
+ )
140
+ self.send_response(HTTPStatus.OK)
141
+ self.send_header("Content-Type", content_type)
142
+ self.send_header("Content-Length", str(len(body)))
143
+ self.end_headers()
144
+ self.wfile.write(body)
145
+
146
+ def log_message(self, format: str, *args: object) -> None:
147
+ return
148
+
149
+
150
+ def _payload(event: DemoEvent) -> dict[str, object]:
151
+ return event.model_dump(mode="json")
152
+
153
+
154
+ class DemoServer:
155
+ """Lifecycle wrapper around the local HTTP server."""
156
+
157
+ def __init__(
158
+ self,
159
+ *,
160
+ host: str = "127.0.0.1",
161
+ port: int = 8765,
162
+ bus: EventBus | None = None,
163
+ viewer_dir: Path | str = VIEWER_DIR,
164
+ runner: Callable[[], object] | None = None,
165
+ ) -> None:
166
+ self.bus = bus or EventBus()
167
+ self.httpd = _DemoHTTPServer((host, port), self.bus, Path(viewer_dir), runner)
168
+ self.thread: threading.Thread | None = None
169
+
170
+ @property
171
+ def address(self) -> tuple[str, int]:
172
+ """Return the bound host and port, including an ephemeral port."""
173
+
174
+ host, port = self.httpd.server_address[:2]
175
+ return str(host), int(port)
176
+
177
+ @property
178
+ def server_address(self) -> tuple[str, int]:
179
+ """Compatibility alias matching :class:`HTTPServer`."""
180
+
181
+ return self.address
182
+
183
+ def start(self, *, run_demo: bool = False) -> DemoServer:
184
+ """Start serving in a daemon thread and optionally begin the demo."""
185
+
186
+ self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
187
+ self.thread.start()
188
+ if run_demo:
189
+ self.httpd.restart()
190
+ return self
191
+
192
+ def restart(self) -> None:
193
+ self.httpd.restart()
194
+
195
+ def close(self) -> None:
196
+ """Stop the server and release its listening socket."""
197
+
198
+ self.httpd.shutdown()
199
+ self.httpd.server_close()
200
+
201
+ shutdown = close
202
+
203
+ def __enter__(self) -> DemoServer:
204
+ return self.start()
205
+
206
+ def __exit__(self, *_args: object) -> None:
207
+ self.close()
208
+
209
+
210
+ def serve_demo(
211
+ *,
212
+ port: int = 8765,
213
+ host: str = "127.0.0.1",
214
+ bus: EventBus | None = None,
215
+ runner: Callable[[], object] | None = None,
216
+ ) -> DemoServer:
217
+ """Start a local viewer server and return its lifecycle handle."""
218
+
219
+ return DemoServer(host=host, port=port, bus=bus, runner=runner).start()
220
+
221
+
222
+ __all__ = ["DemoServer", "serve_demo"]
grudge/errors.py ADDED
@@ -0,0 +1,25 @@
1
+ """Exception hierarchy for errors crossing Grudge pipeline boundaries."""
2
+
3
+
4
+ class GrudgeError(Exception):
5
+ """Base class for expected Grudge failures."""
6
+
7
+
8
+ class SchemaError(GrudgeError):
9
+ """A trace, cassette, predicate, or scar does not match its contract."""
10
+
11
+
12
+ class OracleError(GrudgeError):
13
+ """The failure-reproduction oracle could not evaluate a candidate."""
14
+
15
+
16
+ class PredicateParseError(GrudgeError):
17
+ """A predicate source string cannot be parsed."""
18
+
19
+
20
+ class AdmissionRejected(GrudgeError):
21
+ """A candidate scar did not prove red-on-bad and green-on-good."""
22
+
23
+
24
+ class ReplayMismatch(GrudgeError):
25
+ """An agent requested a response absent from a cassette."""
@@ -0,0 +1 @@
1
+ """Variant fuzzer package; both fuzzer modules remain roadmap stubs."""
@@ -0,0 +1,10 @@
1
+ """Failure fingerprint guard; this Phase 13 stretch item is a stub."""
2
+
3
+ from typing import NoReturn
4
+
5
+ from grudge._stubs import roadmap
6
+
7
+
8
+ def preserve_fingerprint() -> NoReturn:
9
+ """Validate a variant when the fuzzer stretch stage is implemented."""
10
+ roadmap("fuzzer.fingerprint_guard")
@@ -0,0 +1,10 @@
1
+ """Cassette mutation boundary; this Phase 13 stretch item is a stub."""
2
+
3
+ from typing import NoReturn
4
+
5
+ from grudge._stubs import roadmap
6
+
7
+
8
+ def mutate() -> NoReturn:
9
+ """Generate variants when the fuzzer stretch stage is implemented."""
10
+ roadmap("fuzzer.mutators")
@@ -0,0 +1,5 @@
1
+ """CI gate execution and deterministic reporting."""
2
+
3
+ from grudge.gate.runner import GateResult, RunResult, gate, run_gate, wilson_lower_bound
4
+
5
+ __all__ = ["GateResult", "RunResult", "gate", "run_gate", "wilson_lower_bound"]
grudge/gate/report.py ADDED
@@ -0,0 +1,125 @@
1
+ """Deterministic terminal and pull-request reports for gate results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import Console
6
+ from rich.panel import Panel
7
+ from rich.table import Table
8
+
9
+ from grudge.gate.runner import GateResult
10
+
11
+
12
+ def _clean(value: object) -> str:
13
+ return " ".join(str(value).replace("|", "\\|").splitlines())
14
+
15
+
16
+ def status_text(result: GateResult) -> str:
17
+ """Return the stable status label shared by terminal and markdown output."""
18
+
19
+ return "PASS" if result.passed else "BLOCKED"
20
+
21
+
22
+ def render_terminal(result: GateResult, console: Console | None = None) -> str:
23
+ """Render a Rich report and return captured text when no console is supplied."""
24
+
25
+ owns_console = console is None
26
+ target = console or Console(record=True, width=120, color_system=None)
27
+ colour = "green" if result.passed else "red"
28
+ target.print(
29
+ Panel(
30
+ f"[bold {colour}]{status_text(result)}[/bold {colour}] "
31
+ f"Scar: {result.scar_id}",
32
+ title="Grudge gate",
33
+ )
34
+ )
35
+ summary = Table(show_header=True, header_style="bold")
36
+ for column in ("Passes", "Runs", "Pass rate", "Wilson lower", "Threshold", "Mode"):
37
+ summary.add_column(column)
38
+ summary.add_row(
39
+ f"{result.successes}",
40
+ f"{result.runs}",
41
+ f"{result.pass_rate:.2%}",
42
+ f"{result.lower_bound:.4f}",
43
+ f"{result.threshold:.4f}",
44
+ "offline/deterministic" if result.offline else "deterministic",
45
+ )
46
+ target.print(summary)
47
+ for observation in result.observations:
48
+ target.print(f"• {observation}")
49
+ if result.reasons:
50
+ target.print("Reasons:")
51
+ for reason in result.reasons:
52
+ target.print(f" • {reason}")
53
+ if owns_console:
54
+ return target.export_text()
55
+ return ""
56
+
57
+
58
+ def render_markdown(result: GateResult) -> str:
59
+ """Return a deterministic PR comment; this function performs no network I/O."""
60
+
61
+ status = status_text(result)
62
+ lines = [
63
+ f"## Grudge gate: **{status}**",
64
+ "",
65
+ f"- Scar: `{_clean(result.scar_id)}`",
66
+ f"- Gate runs: `{result.successes}/{result.runs}`",
67
+ f"- Pass rate: `{result.pass_rate:.4f}`",
68
+ f"- Wilson lower bound: `{result.lower_bound:.4f}`",
69
+ f"- Threshold: `{result.threshold:.4f}`",
70
+ f"- Mode: **{'offline/deterministic' if result.offline else 'deterministic'}**",
71
+ "",
72
+ "### Observations",
73
+ ]
74
+ lines.extend(f"- {_clean(item)}" for item in result.observations)
75
+ if result.reasons:
76
+ lines.extend(["", "### Failed runs / reasons"])
77
+ lines.extend(f"- {_clean(item)}" for item in result.reasons)
78
+ lines.extend(
79
+ [
80
+ "",
81
+ "### Per-run evidence",
82
+ "| Run | Replay | Predicate | Gate | Evidence / reason |",
83
+ "| ---: | :---: | :---: | :---: | --- |",
84
+ ]
85
+ )
86
+ for run in result.run_results:
87
+ predicate = (
88
+ "clean"
89
+ if run.predicate_result is not None and not run.predicate_result.violated
90
+ else "violation"
91
+ if run.predicate_result is not None
92
+ else "not evaluated"
93
+ )
94
+ evidence = (
95
+ f"{len(run.evidence)} evidence step(s)"
96
+ if run.evidence
97
+ else _clean(run.observation)
98
+ )
99
+ lines.append(
100
+ f"| {run.number} | "
101
+ f"{'ok' if run.replay_succeeded else 'failed'} | {predicate} | "
102
+ f"{'pass' if run.passed else 'fail'} | {evidence} |"
103
+ )
104
+ return "\n".join(lines) + "\n"
105
+
106
+
107
+ def render_pr_comment(result: GateResult) -> str:
108
+ """Alias naming the markdown format used by pull-request integrations."""
109
+
110
+ return render_markdown(result)
111
+
112
+
113
+ def render_report(result: GateResult, console: Console | None = None) -> str:
114
+ """Backward-compatible report entry point for Rich terminal output."""
115
+
116
+ return render_terminal(result, console)
117
+
118
+
119
+ __all__ = [
120
+ "render_markdown",
121
+ "render_pr_comment",
122
+ "render_report",
123
+ "render_terminal",
124
+ "status_text",
125
+ ]