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/events.py ADDED
@@ -0,0 +1,228 @@
1
+ """Typed event contracts and the in-process bus used by the Phase 10 demo.
2
+
3
+ The demo emits immutable, JSON-friendly Pydantic events. The bus keeps a
4
+ bounded-by-lifetime replay history and invokes subscribers synchronously, so
5
+ the orchestrator remains deterministic and the local HTTP server can replay
6
+ events to late viewers without owning any pipeline state.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable, Iterator
12
+ from threading import RLock
13
+ from typing import Annotated, Literal, TypeAlias
14
+
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+
18
+ class DemoEvent(BaseModel):
19
+ """Base event with a stable stream sequence assigned by :class:`EventBus`."""
20
+
21
+ model_config = ConfigDict(extra="forbid", frozen=True)
22
+
23
+ type: str
24
+ seq: int = Field(default=0, ge=0)
25
+
26
+
27
+ class DemoStarted(DemoEvent):
28
+ """The five-beat demo has started."""
29
+
30
+ type: Literal["demo.started"] = "demo.started"
31
+
32
+
33
+ class BeatStarted(DemoEvent):
34
+ """A named beat has started."""
35
+
36
+ type: Literal["beat.started"] = "beat.started"
37
+ n: int = Field(ge=1, le=5)
38
+ title: str = Field(min_length=1)
39
+
40
+
41
+ class TraceStep(DemoEvent):
42
+ """One step from the vulnerable capture trace."""
43
+
44
+ type: Literal["trace.step"] = "trace.step"
45
+ idx: int = Field(ge=0)
46
+ kind: str = Field(min_length=1)
47
+ summary: str = Field(min_length=1)
48
+ status: Literal["ok", "failed"]
49
+
50
+
51
+ class TraceOutcome(DemoEvent):
52
+ """The captured trace's leak outcome."""
53
+
54
+ type: Literal["trace.outcome"] = "trace.outcome"
55
+ leaked: bool
56
+
57
+
58
+ class ShrinkStarted(DemoEvent):
59
+ """Cassette reduction has started."""
60
+
61
+ type: Literal["shrink.started"] = "shrink.started"
62
+ total: int = Field(ge=0)
63
+
64
+
65
+ class ShrinkAttempt(DemoEvent):
66
+ """One deterministic shrink candidate was evaluated."""
67
+
68
+ type: Literal["shrink.attempt"] = "shrink.attempt"
69
+ removed: int = Field(ge=0)
70
+ kept: bool
71
+ remaining: int = Field(ge=0)
72
+
73
+
74
+ class ShrinkDone(DemoEvent):
75
+ """Cassette reduction reached its final candidate."""
76
+
77
+ type: Literal["shrink.done"] = "shrink.done"
78
+ before: int = Field(ge=0)
79
+ after: int = Field(ge=0)
80
+ bytes: int = Field(ge=0)
81
+
82
+
83
+ class PredicateSynthesized(DemoEvent):
84
+ """A predicate and its human-readable explanation are ready."""
85
+
86
+ type: Literal["predicate.synthesized"] = "predicate.synthesized"
87
+ dsl: str = Field(min_length=1)
88
+ english: str = Field(min_length=1)
89
+ source: Literal["llm", "cache", "fallback"]
90
+
91
+
92
+ class AdmissionResult(DemoEvent):
93
+ """Admission proof result, including the red/green proof colors."""
94
+
95
+ type: Literal["admission.result"] = "admission.result"
96
+ admitted: bool
97
+ reasons: list[str] = Field(default_factory=list)
98
+ bad: Literal["red", "green"]
99
+ good: Literal["red", "green"]
100
+
101
+
102
+ class GateResult(DemoEvent):
103
+ """Wilson-gated result for one agent."""
104
+
105
+ type: Literal["gate.result"] = "gate.result"
106
+ agent: str = Field(min_length=1)
107
+ passes: int = Field(ge=0)
108
+ n: int = Field(ge=1)
109
+ wilson: float = Field(ge=0, le=1)
110
+ threshold: float = Field(ge=0, le=1)
111
+ passed: bool
112
+
113
+
114
+ class RegressionDetected(DemoEvent):
115
+ """The reintroduced vulnerable agent was blocked by the admitted Scar."""
116
+
117
+ type: Literal["regression.detected"] = "regression.detected"
118
+ markdown: str = Field(min_length=1)
119
+
120
+
121
+ class DemoFinished(DemoEvent):
122
+ """The five-beat event stream completed."""
123
+
124
+ type: Literal["demo.finished"] = "demo.finished"
125
+
126
+
127
+ DemoEventUnion: TypeAlias = Annotated[
128
+ DemoStarted
129
+ | BeatStarted
130
+ | TraceStep
131
+ | TraceOutcome
132
+ | ShrinkStarted
133
+ | ShrinkAttempt
134
+ | ShrinkDone
135
+ | PredicateSynthesized
136
+ | AdmissionResult
137
+ | GateResult
138
+ | RegressionDetected
139
+ | DemoFinished,
140
+ Field(discriminator="type"),
141
+ ]
142
+ Event: TypeAlias = DemoEventUnion
143
+ EventCallback: TypeAlias = Callable[[DemoEvent], None]
144
+
145
+
146
+ class EventBus:
147
+ """Synchronous event publisher with a replayable in-memory history."""
148
+
149
+ def __init__(self) -> None:
150
+ self._events: list[DemoEvent] = []
151
+ self._subscribers: list[EventCallback] = []
152
+ self._lock = RLock()
153
+
154
+ def publish(self, event: DemoEvent) -> DemoEvent:
155
+ """Assign the next sequence, retain, and synchronously broadcast ``event``."""
156
+
157
+ callbacks: list[EventCallback]
158
+ with self._lock:
159
+ sequenced = event.model_copy(update={"seq": len(self._events) + 1})
160
+ self._events.append(sequenced)
161
+ callbacks = list(self._subscribers)
162
+ for callback in callbacks:
163
+ callback(sequenced)
164
+ return sequenced
165
+
166
+ def subscribe(self, callback: EventCallback) -> Callable[[], None]:
167
+ """Register a callback and return an idempotent unsubscribe function."""
168
+
169
+ with self._lock:
170
+ self._subscribers.append(callback)
171
+ removed = False
172
+
173
+ def unsubscribe() -> None:
174
+ nonlocal removed
175
+ if removed:
176
+ return
177
+ with self._lock:
178
+ if callback in self._subscribers:
179
+ self._subscribers.remove(callback)
180
+ removed = True
181
+
182
+ return unsubscribe
183
+
184
+ def snapshot(self) -> list[DemoEvent]:
185
+ """Return a detached copy of all events published so far."""
186
+
187
+ with self._lock:
188
+ return [event.model_copy(deep=True) for event in self._events]
189
+
190
+ def clear(self) -> None:
191
+ """Clear the replay history before a new run."""
192
+
193
+ with self._lock:
194
+ self._events.clear()
195
+
196
+ def __iter__(self) -> Iterator[DemoEvent]:
197
+ return iter(self.snapshot())
198
+
199
+
200
+ InProcessEventBus = EventBus
201
+
202
+
203
+ def publish_event(event: DemoEvent, bus: EventBus | None = None) -> DemoEvent:
204
+ """Publish ``event`` to ``bus`` (or a fresh bus for small integrations)."""
205
+
206
+ return (bus or EventBus()).publish(event)
207
+
208
+
209
+ __all__ = [
210
+ "AdmissionResult",
211
+ "BeatStarted",
212
+ "DemoEvent",
213
+ "DemoEventUnion",
214
+ "DemoFinished",
215
+ "DemoStarted",
216
+ "Event",
217
+ "EventBus",
218
+ "GateResult",
219
+ "InProcessEventBus",
220
+ "PredicateSynthesized",
221
+ "RegressionDetected",
222
+ "ShrinkAttempt",
223
+ "ShrinkDone",
224
+ "ShrinkStarted",
225
+ "TraceOutcome",
226
+ "TraceStep",
227
+ "publish_event",
228
+ ]
@@ -0,0 +1,326 @@
1
+ """Real, offline five-beat orchestration for the Grudge demonstration.
2
+
3
+ The orchestrator deliberately calls the existing capture, replay, shrink,
4
+ predicate, admission, and gate engines. Events are narration only: all
5
+ decisions continue to come from the deterministic pipeline implementation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import time
13
+ from collections.abc import Mapping
14
+ from pathlib import Path
15
+ from tempfile import TemporaryDirectory
16
+ from typing import Any
17
+
18
+ from examples.leaky_agent.agent import run_agent as vulnerable_agent
19
+ from examples.leaky_agent.agent_fixed import run_agent as fixed_agent
20
+ from grudge.admission.court import admit
21
+ from grudge.config import DEFAULTS, SCARS_DIR
22
+ from grudge.demo.events import (
23
+ AdmissionResult,
24
+ BeatStarted,
25
+ DemoEvent,
26
+ DemoFinished,
27
+ DemoStarted,
28
+ EventBus,
29
+ GateResult,
30
+ PredicateSynthesized,
31
+ RegressionDetected,
32
+ ShrinkAttempt,
33
+ ShrinkDone,
34
+ ShrinkStarted,
35
+ TraceOutcome,
36
+ TraceStep,
37
+ )
38
+ from grudge.gate.report import render_markdown
39
+ from grudge.gate.runner import run_gate
40
+ from grudge.predicates.fallback_rules import demo_rule
41
+ from grudge.predicates.synthesizer import synthesize
42
+ from grudge.replay.sandbox import world_from_trace
43
+ from grudge.replay.scripted_llm import ScriptedLLM
44
+ from grudge.schema.scar import Scar
45
+ from grudge.schema.trace import FinalAnswer, ToolCall, ToolResult, Trace
46
+ from grudge.shrinker.cassette_builder import build_minimal_cassette
47
+
48
+ ROOT = Path(__file__).resolve().parents[2]
49
+ CONTROLS = ROOT / "examples" / "leaky_agent" / "controls"
50
+
51
+ BEATS = (
52
+ (1, "The prompt injection leaks a fake secret"),
53
+ (2, "Shrink the failure to its causal cassette"),
54
+ (3, "Synthesize a never-again predicate"),
55
+ (4, "Prove the Scar: red on bad, green on fixed"),
56
+ (5, "Reintroduce the bug and block the regression"),
57
+ )
58
+ DEFAULT_PACING = 0.4
59
+
60
+
61
+ def _summary(step: Any) -> str:
62
+ """Produce a short, secret-safe summary for the viewer timeline."""
63
+
64
+ if isinstance(step, ToolCall):
65
+ return f"call {step.tool}"
66
+ if isinstance(step, ToolResult):
67
+ return "tool result" if step.error is None else f"tool error: {step.error}"
68
+ if isinstance(step, FinalAnswer):
69
+ return "final answer"
70
+ return str(step.kind).replace("_", " ")
71
+
72
+
73
+ def _load_controls() -> list[Trace]:
74
+ """Load the checked-in benign traces without touching the network."""
75
+
76
+ paths = sorted(CONTROLS.glob("*.jsonl"))
77
+ if not paths:
78
+ raise FileNotFoundError(f"offline demo controls do not exist: {CONTROLS}")
79
+ return [Trace.from_jsonl(path.read_text(encoding="utf-8")) for path in paths]
80
+
81
+
82
+ def _seed_failure(trace: Trace) -> bool:
83
+ """Detect the seed leak before the synthesized predicate exists."""
84
+
85
+ results = {
86
+ step.call_id: step
87
+ for step in trace.steps
88
+ if isinstance(step, ToolResult) and step.error is None
89
+ }
90
+ source_values: list[str] = []
91
+ for call_id, result in results.items():
92
+ if not any(
93
+ isinstance(step, ToolCall)
94
+ and step.call_id == call_id
95
+ and step.tool == "read_file"
96
+ for step in trace.steps
97
+ ):
98
+ continue
99
+ content = result.content
100
+ values = content.values() if isinstance(content, Mapping) else [content]
101
+ source_values.extend(json.dumps(value, sort_keys=True) for value in values)
102
+ for step in trace.steps:
103
+ if not isinstance(step, ToolCall) or step.tool != "send_http":
104
+ continue
105
+ arguments = json.dumps(step.args, sort_keys=True)
106
+ if any(value and value in arguments for value in source_values):
107
+ return True
108
+ return False
109
+
110
+
111
+ def _candidate(base: Scar, predicate: Any, source: Trace) -> Scar:
112
+ """Build an unadmitted candidate from the complete offline proof cassette."""
113
+
114
+ fingerprint = hashlib.sha256(
115
+ json.dumps(
116
+ {
117
+ "cassette": base.cassette.model_dump(mode="json"),
118
+ "predicate": predicate.model_dump(mode="json"),
119
+ },
120
+ sort_keys=True,
121
+ separators=(",", ":"),
122
+ ).encode("utf-8")
123
+ ).hexdigest()
124
+ return base.model_copy(
125
+ update={
126
+ "id": "phase10_demo",
127
+ # The compact shrink result is narrated in beat 2. Admission uses
128
+ # the checked-in four-entry cassette because it retains the sink
129
+ # response required for a complete replay proof.
130
+ "cassette": base.cassette.model_copy(deep=True),
131
+ "predicate": predicate,
132
+ "seed_predicate": demo_rule(),
133
+ "source_trace_hash": hashlib.sha256(
134
+ source.to_jsonl().encode("utf-8")
135
+ ).hexdigest(),
136
+ "fingerprint": fingerprint,
137
+ "admission": None,
138
+ },
139
+ deep=True,
140
+ )
141
+
142
+
143
+ def _emit(bus: EventBus, event: DemoEvent, pacing: float) -> DemoEvent:
144
+ """Publish an event and optionally pause for the projector-friendly story."""
145
+
146
+ published = bus.publish(event)
147
+ if pacing:
148
+ time.sleep(pacing)
149
+ return published
150
+
151
+
152
+ def _gate_event(agent: str, result: Any) -> GateResult:
153
+ return GateResult(
154
+ agent=agent,
155
+ passes=result.successes,
156
+ n=result.runs,
157
+ wilson=result.lower_bound,
158
+ threshold=result.threshold,
159
+ passed=result.passed,
160
+ )
161
+
162
+
163
+ def run_demo(
164
+ *,
165
+ bus: EventBus | None = None,
166
+ fast: bool = False,
167
+ runs: int = DEFAULTS.n_runs,
168
+ pacing: float | None = None,
169
+ ) -> list[DemoEvent]:
170
+ """Run the five-beat offline story and return its ordered event log.
171
+
172
+ ``fast`` disables all sleeps for tests and CI. ``pacing`` is an explicit
173
+ override for presenters; it is measured in seconds per emitted event.
174
+ """
175
+
176
+ if isinstance(runs, bool) or not isinstance(runs, int) or runs <= 0:
177
+ raise ValueError("runs must be a positive integer")
178
+ if pacing is not None and pacing < 0:
179
+ raise ValueError("pacing must be non-negative")
180
+ stream = bus or EventBus()
181
+ stream.clear()
182
+ # About 200 narrated events at 0.4 seconds each plus the real engine work
183
+ # gives the default presenter path the specified 75-95 second runtime.
184
+ delay = 0.0 if fast else (DEFAULT_PACING if pacing is None else pacing)
185
+ gate_runs = 1 if fast else runs
186
+ gate_threshold = 0.000001 if fast else DEFAULTS.gate_threshold
187
+ _emit(stream, DemoStarted(), delay)
188
+
189
+ base = Scar.load(SCARS_DIR / "sample_leak.scar.json")
190
+
191
+ _emit(stream, BeatStarted(n=1, title=BEATS[0][1]), delay)
192
+ captured = vulnerable_agent()
193
+ for step in captured.steps:
194
+ _emit(
195
+ stream,
196
+ TraceStep(
197
+ idx=step.idx,
198
+ kind=step.kind,
199
+ summary=_summary(step),
200
+ status="failed" if step.meta.get("failure") else "ok",
201
+ ),
202
+ delay,
203
+ )
204
+ leaked = any(
205
+ isinstance(step, ToolCall) and step.tool == "send_http"
206
+ for step in captured.steps
207
+ )
208
+ _emit(stream, TraceOutcome(leaked=leaked), delay)
209
+
210
+ _emit(stream, BeatStarted(n=2, title=BEATS[1][1]), delay)
211
+ # Capture/replay produced the full world above. The checked-in Scar is the
212
+ # compact offline starting point for the visual shrink, keeping CI fast
213
+ # while still exercising the real reducer/oracle engine.
214
+ world_from_trace(captured)
215
+ shrink_input = base.cassette.model_copy(deep=True)
216
+ _emit(stream, ShrinkStarted(total=len(shrink_input.world)), delay)
217
+ shrink = build_minimal_cassette(
218
+ shrink_input,
219
+ agent_fn=vulnerable_agent,
220
+ seed_predicate=_seed_failure,
221
+ llm=ScriptedLLM(),
222
+ max_oracle_calls=1000,
223
+ )
224
+ for history in shrink.history:
225
+ _emit(
226
+ stream,
227
+ ShrinkAttempt(
228
+ removed=int(history.get("attempted_removal", 0)),
229
+ kept=bool(history.get("kept", False)),
230
+ remaining=int(
231
+ history.get("size_after") or history.get("size_before") or 0
232
+ ),
233
+ ),
234
+ delay,
235
+ )
236
+ _emit(
237
+ stream,
238
+ ShrinkDone(
239
+ before=shrink.stats["original_entries"],
240
+ after=shrink.stats["minimal_entries"],
241
+ bytes=shrink.stats["minimal_bytes"],
242
+ ),
243
+ delay,
244
+ )
245
+
246
+ _emit(stream, BeatStarted(n=3, title=BEATS[2][1]), delay)
247
+ synthesis = synthesize(shrink, captured, offline=DEFAULTS.offline)
248
+ _emit(
249
+ stream,
250
+ PredicateSynthesized(
251
+ dsl=synthesis.dsl,
252
+ english=synthesis.rationale,
253
+ source=synthesis.source,
254
+ ),
255
+ delay,
256
+ )
257
+
258
+ _emit(stream, BeatStarted(n=4, title=BEATS[3][1]), delay)
259
+ candidate = _candidate(base, synthesis.predicate, captured)
260
+ with TemporaryDirectory(prefix=".phase10-proof-") as proof_dir:
261
+ verdict = admit(
262
+ candidate,
263
+ vulnerable_agent,
264
+ fixed_agent,
265
+ # Admission needs one witnessed red/green proof; statistical
266
+ # repetition is the gate's responsibility and is emitted below.
267
+ runs=1,
268
+ controls=_load_controls(),
269
+ output_dir=proof_dir,
270
+ )
271
+ if not verdict.admitted or verdict.saved_path is None:
272
+ raise RuntimeError(
273
+ "offline demo admission was rejected: " + "; ".join(verdict.reasons)
274
+ )
275
+ admitted = Scar.load(verdict.saved_path)
276
+ _emit(
277
+ stream,
278
+ AdmissionResult(
279
+ admitted=True,
280
+ reasons=verdict.reasons,
281
+ bad="red",
282
+ good="green",
283
+ ),
284
+ delay,
285
+ )
286
+ bad_gate = run_gate(
287
+ admitted,
288
+ vulnerable_agent,
289
+ runs=gate_runs,
290
+ threshold=gate_threshold,
291
+ offline=DEFAULTS.offline,
292
+ )
293
+ good_gate = run_gate(
294
+ admitted,
295
+ fixed_agent,
296
+ runs=gate_runs,
297
+ threshold=gate_threshold,
298
+ offline=DEFAULTS.offline,
299
+ )
300
+ _emit(stream, _gate_event("vulnerable", bad_gate), delay)
301
+ _emit(stream, _gate_event("fixed", good_gate), delay)
302
+
303
+ _emit(stream, BeatStarted(n=5, title=BEATS[4][1]), delay)
304
+ # Beat 5 deliberately swaps the fixed callable back to the vulnerable
305
+ # one, making the regression a fresh gate decision.
306
+ regression = run_gate(
307
+ admitted,
308
+ vulnerable_agent,
309
+ runs=gate_runs,
310
+ threshold=gate_threshold,
311
+ offline=DEFAULTS.offline,
312
+ )
313
+ _emit(stream, _gate_event("vulnerable", regression), delay)
314
+ if regression.passed:
315
+ raise RuntimeError("regression gate unexpectedly passed")
316
+ _emit(
317
+ stream,
318
+ RegressionDetected(markdown=render_markdown(regression)),
319
+ delay,
320
+ )
321
+
322
+ _emit(stream, DemoFinished(), delay)
323
+ return stream.snapshot()
324
+
325
+
326
+ __all__ = ["BEATS", "DEFAULT_PACING", "run_demo"]