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
grudge/cli/demo.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Run the deterministic, offline five-beat demonstration."""
|
|
2
|
+
|
|
3
|
+
# Typer's declarative Option defaults are intentional here.
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import webbrowser
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from threading import Event
|
|
12
|
+
from typing import Any, cast
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
from grudge.cli._common import command, finish
|
|
17
|
+
from grudge.config import DEFAULTS, SCARS_DIR
|
|
18
|
+
from grudge.demo.events import EventBus
|
|
19
|
+
from grudge.demo.orchestrator import run_demo as orchestrate_demo
|
|
20
|
+
from grudge.demo.server import DemoServer
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run_demo(
|
|
24
|
+
live: bool = typer.Option(False, "--live", help="Reserved for a live provider."),
|
|
25
|
+
port: int = typer.Option(8765, "--port", help="Local viewer server port."),
|
|
26
|
+
no_browser: bool = typer.Option(
|
|
27
|
+
False, "--no-browser", help="Do not open a browser."
|
|
28
|
+
),
|
|
29
|
+
runs: int = typer.Option(DEFAULTS.n_runs, "--runs", min=1),
|
|
30
|
+
fast: bool = typer.Option(False, "--fast", help="Disable presenter pacing."),
|
|
31
|
+
guard: bool = typer.Option(
|
|
32
|
+
False, "--guard", help="Run the offline two-session hook proof."
|
|
33
|
+
),
|
|
34
|
+
json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
|
|
35
|
+
debug: bool = typer.Option(False, "--debug", help="Show a traceback on errors."),
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Play the offline proof against the fixed and vulnerable demo agents."""
|
|
38
|
+
|
|
39
|
+
def run() -> dict[str, Any]:
|
|
40
|
+
if live:
|
|
41
|
+
raise RuntimeError(
|
|
42
|
+
"live demo mode is reserved for a future provider integration"
|
|
43
|
+
)
|
|
44
|
+
if guard:
|
|
45
|
+
node = shutil.which("node")
|
|
46
|
+
if node is None:
|
|
47
|
+
raise RuntimeError("--guard requires Node.js >= 18 on PATH")
|
|
48
|
+
script = Path(__file__).resolve().parents[2] / "scripts" / "e2e_guard.mjs"
|
|
49
|
+
completed = subprocess.run(
|
|
50
|
+
[node, str(script)],
|
|
51
|
+
cwd=script.parent.parent,
|
|
52
|
+
check=False,
|
|
53
|
+
capture_output=True,
|
|
54
|
+
text=True,
|
|
55
|
+
env={**os.environ, "GRUDGE_DISABLE": ""},
|
|
56
|
+
)
|
|
57
|
+
if completed.returncode != 0:
|
|
58
|
+
raise RuntimeError(completed.stderr.strip() or "guard demo failed")
|
|
59
|
+
payload = cast(dict[str, Any], json.loads(completed.stdout))
|
|
60
|
+
payload["command"] = "demo --guard"
|
|
61
|
+
if json_output:
|
|
62
|
+
return payload
|
|
63
|
+
typer.echo("Session 1: leak captured, proposed, and accepted")
|
|
64
|
+
typer.echo("Session 2: leak blocked before the HTTP call")
|
|
65
|
+
typer.echo(payload["reason"])
|
|
66
|
+
return payload
|
|
67
|
+
if not fast and not json_output:
|
|
68
|
+
bus = EventBus()
|
|
69
|
+
finished = Event()
|
|
70
|
+
failure: list[BaseException] = []
|
|
71
|
+
|
|
72
|
+
def run_viewer_demo() -> None:
|
|
73
|
+
try:
|
|
74
|
+
orchestrate_demo(bus=bus, runs=runs)
|
|
75
|
+
except BaseException as exc:
|
|
76
|
+
failure.append(exc)
|
|
77
|
+
finally:
|
|
78
|
+
finished.set()
|
|
79
|
+
|
|
80
|
+
server = DemoServer(
|
|
81
|
+
port=port,
|
|
82
|
+
bus=bus,
|
|
83
|
+
runner=run_viewer_demo,
|
|
84
|
+
).start(run_demo=True)
|
|
85
|
+
url = f"http://{server.address[0]}:{server.address[1]}/"
|
|
86
|
+
if not no_browser:
|
|
87
|
+
webbrowser.open(url)
|
|
88
|
+
typer.echo(f"Grudge viewer: {url}")
|
|
89
|
+
finished.wait()
|
|
90
|
+
server.close()
|
|
91
|
+
if failure:
|
|
92
|
+
raise RuntimeError("viewer demo failed") from failure[0]
|
|
93
|
+
return {"command": "demo", "mode": "viewer", "url": url}
|
|
94
|
+
events = orchestrate_demo(fast=fast, runs=runs)
|
|
95
|
+
event_payloads = [event.model_dump(mode="json") for event in events]
|
|
96
|
+
gates = [event for event in event_payloads if event["type"] == "gate.result"]
|
|
97
|
+
vulnerable = next(
|
|
98
|
+
event
|
|
99
|
+
for event in gates
|
|
100
|
+
if event["agent"] == "vulnerable" and not event["passed"]
|
|
101
|
+
)
|
|
102
|
+
fixed = next(event for event in gates if event["agent"] == "fixed")
|
|
103
|
+
return {
|
|
104
|
+
"command": "demo",
|
|
105
|
+
"mode": "offline/deterministic",
|
|
106
|
+
"scar": str(SCARS_DIR / "sample_leak.scar.json"),
|
|
107
|
+
"events": event_payloads,
|
|
108
|
+
"fixed": {
|
|
109
|
+
"passed": fixed["passed"],
|
|
110
|
+
"successes": fixed["passes"],
|
|
111
|
+
"runs": fixed["n"],
|
|
112
|
+
"lower_bound": fixed["wilson"],
|
|
113
|
+
},
|
|
114
|
+
"vulnerable": {
|
|
115
|
+
"passed": vulnerable["passed"],
|
|
116
|
+
"successes": vulnerable["passes"],
|
|
117
|
+
"runs": vulnerable["n"],
|
|
118
|
+
"lower_bound": vulnerable["wilson"],
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
result = command(run, json_output=json_output, debug=debug)
|
|
123
|
+
if result.get("mode") == "viewer":
|
|
124
|
+
return
|
|
125
|
+
if guard:
|
|
126
|
+
if json_output:
|
|
127
|
+
finish(result, json_output=True)
|
|
128
|
+
return
|
|
129
|
+
if json_output:
|
|
130
|
+
finish(result, json_output=True)
|
|
131
|
+
else:
|
|
132
|
+
for event in result["events"]:
|
|
133
|
+
typer.echo(
|
|
134
|
+
f"{event['type']} "
|
|
135
|
+
f"{json.dumps(event, ensure_ascii=False, sort_keys=True)}"
|
|
136
|
+
)
|
grudge/cli/main.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Typer command surface for Grudge's deterministic pipeline."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, NoReturn
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from grudge._stubs import roadmap
|
|
8
|
+
from grudge.cli._common import command
|
|
9
|
+
from grudge.cli.admit import admit_cassette
|
|
10
|
+
from grudge.cli.capture import capture_agent
|
|
11
|
+
from grudge.cli.compile import compile_command
|
|
12
|
+
from grudge.cli.demo import run_demo
|
|
13
|
+
from grudge.cli.run import run_scars
|
|
14
|
+
from grudge.cli.scars import scars_app
|
|
15
|
+
from grudge.cli.shrink import shrink_trace
|
|
16
|
+
from grudge.errors import GrudgeError
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(
|
|
19
|
+
name="grudge",
|
|
20
|
+
help="Compile agent failures into deterministic regression scars.",
|
|
21
|
+
no_args_is_help=True,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _future(command: str) -> NoReturn:
|
|
26
|
+
roadmap(f"cli.{command}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
app.command("capture")(capture_agent)
|
|
30
|
+
app.command("compile")(compile_command)
|
|
31
|
+
app.command("shrink")(shrink_trace)
|
|
32
|
+
app.command("admit")(admit_cassette)
|
|
33
|
+
app.command("run")(run_scars)
|
|
34
|
+
app.command("demo")(run_demo)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.command()
|
|
38
|
+
def viewer(
|
|
39
|
+
json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
|
|
40
|
+
debug: bool = typer.Option(False, "--debug", help="Show a traceback on errors."),
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Start the local viewer server."""
|
|
43
|
+
|
|
44
|
+
def run() -> dict[str, Any]:
|
|
45
|
+
try:
|
|
46
|
+
_future("viewer")
|
|
47
|
+
except NotImplementedError as exc:
|
|
48
|
+
raise GrudgeError(str(exc)) from exc
|
|
49
|
+
raise AssertionError("unreachable")
|
|
50
|
+
|
|
51
|
+
command(run, json_output=json_output, debug=debug)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
app.add_typer(scars_app, name="scars")
|
grudge/cli/run.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Run admitted Scars through the deterministic statistical gate."""
|
|
2
|
+
|
|
3
|
+
# Typer's declarative Option defaults are intentional here.
|
|
4
|
+
# ruff: noqa: B008
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from grudge.cli._common import command, finish, load_callable, read_text
|
|
12
|
+
from grudge.config import DEFAULTS
|
|
13
|
+
from grudge.gate.report import render_terminal
|
|
14
|
+
from grudge.gate.runner import run_gate
|
|
15
|
+
from grudge.schema.scar import Scar
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run_scars(
|
|
19
|
+
scars: Path = typer.Option(..., "--scars", help="Scar file or directory."),
|
|
20
|
+
agent: str = typer.Option(..., "--agent", help="Agent module to gate."),
|
|
21
|
+
runs: int = typer.Option(DEFAULTS.n_runs, "--runs", min=1),
|
|
22
|
+
threshold: float = typer.Option(DEFAULTS.gate_threshold, "--threshold"),
|
|
23
|
+
json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
|
|
24
|
+
debug: bool = typer.Option(False, "--debug", help="Show a traceback on errors."),
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Run all admitted Scars in a directory, or one Scar file."""
|
|
27
|
+
|
|
28
|
+
def run() -> dict[str, Any]:
|
|
29
|
+
paths = [scars] if scars.is_file() else sorted(scars.glob("*.scar.json"))
|
|
30
|
+
if not paths:
|
|
31
|
+
raise FileNotFoundError(f"no Scar files found at {scars}")
|
|
32
|
+
agent_fn = load_callable(agent)
|
|
33
|
+
results: list[dict[str, Any]] = []
|
|
34
|
+
for path in paths:
|
|
35
|
+
scar = Scar.model_validate_json(read_text(path, label="Scar"))
|
|
36
|
+
result = run_gate(scar, agent_fn, runs=runs, threshold=threshold)
|
|
37
|
+
if not json_output:
|
|
38
|
+
typer.echo(render_terminal(result))
|
|
39
|
+
results.append(
|
|
40
|
+
{
|
|
41
|
+
"scar_id": result.scar_id,
|
|
42
|
+
"passed": result.passed,
|
|
43
|
+
"exit_code": result.exit_code,
|
|
44
|
+
"successes": result.successes,
|
|
45
|
+
"runs": result.runs,
|
|
46
|
+
"pass_rate": result.pass_rate,
|
|
47
|
+
"lower_bound": result.lower_bound,
|
|
48
|
+
"threshold": result.threshold,
|
|
49
|
+
"reasons": result.reasons,
|
|
50
|
+
"observations": result.observations,
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
payload = {"command": "run", "results": results}
|
|
54
|
+
return payload
|
|
55
|
+
|
|
56
|
+
payload = command(run, json_output=json_output, debug=debug)
|
|
57
|
+
if json_output:
|
|
58
|
+
finish(payload, json_output=True)
|
|
59
|
+
if any(not item["passed"] for item in payload["results"]):
|
|
60
|
+
raise typer.Exit(code=1)
|
grudge/cli/scars.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""List and inspect saved Scars; retirement remains a roadmap stub."""
|
|
2
|
+
|
|
3
|
+
# Typer's declarative Option defaults are intentional here.
|
|
4
|
+
# ruff: noqa: B008
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from grudge._stubs import roadmap
|
|
12
|
+
from grudge.cli._common import command, finish, read_text
|
|
13
|
+
from grudge.config import SCARS_DIR
|
|
14
|
+
from grudge.errors import GrudgeError
|
|
15
|
+
from grudge.schema.scar import Scar
|
|
16
|
+
|
|
17
|
+
scars_app = typer.Typer(help="Browse saved regression Scars.")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@scars_app.command("list")
|
|
21
|
+
def list_scars(
|
|
22
|
+
scars_dir: Path = typer.Option(SCARS_DIR, "--scars", "--scars-dir"),
|
|
23
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
24
|
+
debug: bool = typer.Option(False, "--debug"),
|
|
25
|
+
) -> None:
|
|
26
|
+
"""List saved Scar summaries."""
|
|
27
|
+
|
|
28
|
+
def run() -> dict[str, Any]:
|
|
29
|
+
paths = sorted(scars_dir.glob("*.scar.json"))
|
|
30
|
+
entries = []
|
|
31
|
+
for path in paths:
|
|
32
|
+
scar = Scar.model_validate_json(read_text(path, label="Scar"))
|
|
33
|
+
entries.append(
|
|
34
|
+
{
|
|
35
|
+
"id": scar.id,
|
|
36
|
+
"path": str(path),
|
|
37
|
+
"admitted": bool(scar.admission and scar.admission.admitted),
|
|
38
|
+
"fingerprint": scar.fingerprint,
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
payload = {"command": "scars list", "scars": entries}
|
|
42
|
+
if not json_output:
|
|
43
|
+
for item in entries:
|
|
44
|
+
status = "admitted" if item["admitted"] else "unadmitted"
|
|
45
|
+
typer.echo(f"{item['id']} {status}")
|
|
46
|
+
return payload
|
|
47
|
+
|
|
48
|
+
finish(command(run, json_output=json_output, debug=debug), json_output=json_output)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@scars_app.command("show")
|
|
52
|
+
def show_scar(
|
|
53
|
+
scar: str,
|
|
54
|
+
scars_dir: Path = typer.Option(SCARS_DIR, "--scars", "--scars-dir"),
|
|
55
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
56
|
+
debug: bool = typer.Option(False, "--debug"),
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Show one Scar by path or id."""
|
|
59
|
+
|
|
60
|
+
def run() -> dict[str, Any]:
|
|
61
|
+
path = Path(scar)
|
|
62
|
+
if not path.is_file():
|
|
63
|
+
filename = scar if scar.endswith(".scar.json") else f"{scar}.scar.json"
|
|
64
|
+
path = scars_dir / filename
|
|
65
|
+
loaded = Scar.model_validate_json(read_text(path, label="Scar"))
|
|
66
|
+
return {"command": "scars show", "scar": loaded.model_dump(mode="json")}
|
|
67
|
+
|
|
68
|
+
result = command(run, json_output=json_output, debug=debug)
|
|
69
|
+
if not json_output:
|
|
70
|
+
typer.echo(Scar.model_validate(result["scar"]).model_dump_json(indent=2))
|
|
71
|
+
else:
|
|
72
|
+
finish(result, json_output=True)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@scars_app.command("retire")
|
|
76
|
+
def retire_scar(
|
|
77
|
+
scar: str,
|
|
78
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
79
|
+
debug: bool = typer.Option(False, "--debug"),
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Retire a Scar (registry support is a later roadmap item)."""
|
|
82
|
+
|
|
83
|
+
del scar
|
|
84
|
+
|
|
85
|
+
def run() -> None:
|
|
86
|
+
try:
|
|
87
|
+
roadmap("cli.scars.retire")
|
|
88
|
+
except NotImplementedError as exc:
|
|
89
|
+
raise GrudgeError(str(exc)) from exc
|
|
90
|
+
|
|
91
|
+
command(run, json_output=json_output, debug=debug)
|
grudge/cli/shrink.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Shrink a captured trace into a minimal replay cassette."""
|
|
2
|
+
|
|
3
|
+
# Typer's declarative Option defaults are intentional here.
|
|
4
|
+
# ruff: noqa: B008
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from grudge.cli._common import command, finish, load_callable, read_text, write_text
|
|
12
|
+
from grudge.predicates.compiler import compile_predicate
|
|
13
|
+
from grudge.predicates.dsl import parse
|
|
14
|
+
from grudge.predicates.fallback_rules import demo_rule
|
|
15
|
+
from grudge.replay.sandbox import world_from_trace
|
|
16
|
+
from grudge.replay.scripted_llm import ScriptedLLM
|
|
17
|
+
from grudge.schema.trace import ToolResult, Trace
|
|
18
|
+
from grudge.shrinker.cassette_builder import build_minimal_cassette
|
|
19
|
+
from grudge.shrinker.oracle import OracleResult, PredicateOracle
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _ReplayCompleteOracle:
|
|
23
|
+
"""Keep CLI cassettes admissible by rejecting incomplete replays."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, inner: PredicateOracle, seed: Any) -> None:
|
|
26
|
+
self.inner = inner
|
|
27
|
+
self.checker = compile_predicate(seed)
|
|
28
|
+
self.calls = 0
|
|
29
|
+
self.cache_hits = 0
|
|
30
|
+
|
|
31
|
+
def reproduces(self, cassette: Any) -> OracleResult:
|
|
32
|
+
self.calls = self.inner.calls
|
|
33
|
+
self.cache_hits = self.inner.cache_hits
|
|
34
|
+
result = self.inner.reproduces(cassette)
|
|
35
|
+
self.calls = self.inner.calls
|
|
36
|
+
self.cache_hits = self.inner.cache_hits
|
|
37
|
+
if not result.reproduced or result.trace is None:
|
|
38
|
+
return result
|
|
39
|
+
errors = [
|
|
40
|
+
step.error
|
|
41
|
+
for step in result.trace.steps
|
|
42
|
+
if isinstance(step, ToolResult) and step.error is not None
|
|
43
|
+
]
|
|
44
|
+
if result.trace.outcome != "ok" or errors:
|
|
45
|
+
reason = errors[0] if errors else "failed or truncated trace"
|
|
46
|
+
return OracleResult(False, result.trace, f"replay failed: {reason}")
|
|
47
|
+
checked = self.checker.check(result.trace)
|
|
48
|
+
if not checked.violated:
|
|
49
|
+
return OracleResult(False, result.trace, "seed predicate did not reproduce")
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def shrink_trace(
|
|
54
|
+
trace: Path,
|
|
55
|
+
failure: str = typer.Option(
|
|
56
|
+
..., "--failure", help="Seed failure rule in Grudge DSL."
|
|
57
|
+
),
|
|
58
|
+
out: Path = typer.Option(..., "--out", help="Destination cassette JSON."),
|
|
59
|
+
agent: str = typer.Option(
|
|
60
|
+
"examples.leaky_agent.agent",
|
|
61
|
+
"--agent",
|
|
62
|
+
help="Agent module used to evaluate candidate cassettes.",
|
|
63
|
+
),
|
|
64
|
+
json_output: bool = typer.Option(False, "--json", help="Emit JSON output."),
|
|
65
|
+
debug: bool = typer.Option(False, "--debug", help="Show a traceback on errors."),
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Shrink a failing trace while retaining the supplied seed failure."""
|
|
68
|
+
|
|
69
|
+
def run() -> dict[str, Any]:
|
|
70
|
+
source = Trace.from_jsonl(read_text(trace, label="trace"))
|
|
71
|
+
cassette = world_from_trace(source)
|
|
72
|
+
seed = demo_rule() if failure.strip().lower() == "demo" else parse(failure)
|
|
73
|
+
result = build_minimal_cassette(
|
|
74
|
+
cassette,
|
|
75
|
+
_ReplayCompleteOracle(
|
|
76
|
+
PredicateOracle(
|
|
77
|
+
load_callable(agent),
|
|
78
|
+
seed,
|
|
79
|
+
llm=ScriptedLLM(),
|
|
80
|
+
max_oracle_calls=5000,
|
|
81
|
+
),
|
|
82
|
+
seed,
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
write_text(
|
|
86
|
+
out,
|
|
87
|
+
result.minimal.model_dump_json(indent=2) + "\n",
|
|
88
|
+
label="cassette",
|
|
89
|
+
)
|
|
90
|
+
if not json_output:
|
|
91
|
+
for event in result.history:
|
|
92
|
+
typer.echo(
|
|
93
|
+
f"shrink: {event['reducer']} "
|
|
94
|
+
f"{event['size_before']} -> {event['size_after']} "
|
|
95
|
+
f"({'kept' if event['kept'] else 'discarded'})"
|
|
96
|
+
)
|
|
97
|
+
return {
|
|
98
|
+
"command": "shrink",
|
|
99
|
+
"cassette": str(out),
|
|
100
|
+
"original_entries": result.stats["original_entries"],
|
|
101
|
+
"minimal_entries": result.stats["minimal_entries"],
|
|
102
|
+
"original_bytes": result.stats["original_bytes"],
|
|
103
|
+
"minimal_bytes": result.stats["minimal_bytes"],
|
|
104
|
+
"oracle_calls": result.stats["oracle_calls"],
|
|
105
|
+
"history_events": result.stats["history_events"],
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
finish(command(run, json_output=json_output, debug=debug), json_output=json_output)
|
grudge/config.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Bootstrap configuration shared by every pipeline stage.
|
|
2
|
+
|
|
3
|
+
Phase 0 keeps defaults deterministic and offline-first; later phases may add
|
|
4
|
+
validated environment loading without changing these stable names.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
N_RUNS: int = 20
|
|
11
|
+
WILSON_Z: float = 1.96
|
|
12
|
+
GATE_THRESHOLD: float = 0.80
|
|
13
|
+
OFFLINE: bool = True
|
|
14
|
+
DEFAULT_MODEL: str = "offline-scripted"
|
|
15
|
+
|
|
16
|
+
PACKAGE_DIR: Path = Path(__file__).resolve().parent
|
|
17
|
+
PROJECT_ROOT: Path = PACKAGE_DIR.parent
|
|
18
|
+
CACHE_DIR: Path = PROJECT_ROOT / ".grudge" / "cache"
|
|
19
|
+
SCARS_DIR: Path = PROJECT_ROOT / "scars"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Settings:
|
|
24
|
+
"""Immutable application defaults used by the Phase 0 command surface."""
|
|
25
|
+
|
|
26
|
+
n_runs: int = N_RUNS
|
|
27
|
+
wilson_z: float = WILSON_Z
|
|
28
|
+
gate_threshold: float = GATE_THRESHOLD
|
|
29
|
+
offline: bool = OFFLINE
|
|
30
|
+
model: str = DEFAULT_MODEL
|
|
31
|
+
cache_dir: Path = CACHE_DIR
|
|
32
|
+
scars_dir: Path = SCARS_DIR
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
DEFAULTS = Settings()
|
grudge/demo/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Demo package; the five-beat offline story is implemented in a later phase."""
|