mayhem-cli 0.5.1__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 (107) hide show
  1. mayhem/agent/__init__.py +1 -0
  2. mayhem/agent/cli.py +36 -0
  3. mayhem/agents/__init__.py +1 -0
  4. mayhem/agents/capabilities.py +106 -0
  5. mayhem/agents/executors.py +430 -0
  6. mayhem/agents/impact.py +729 -0
  7. mayhem/agents/lease_client.py +141 -0
  8. mayhem/agents/probes.py +284 -0
  9. mayhem/agents/protocol.py +134 -0
  10. mayhem/agents/server.py +281 -0
  11. mayhem/agents/sinks.py +60 -0
  12. mayhem/agents/transports.py +134 -0
  13. mayhem/agents/watchdog.py +140 -0
  14. mayhem/cli/__init__.py +11 -0
  15. mayhem/cli/app.py +154 -0
  16. mayhem/cli/campaign.py +496 -0
  17. mayhem/cli/config_cmd.py +47 -0
  18. mayhem/cli/context.py +23 -0
  19. mayhem/cli/dependency.py +429 -0
  20. mayhem/cli/exit_codes.py +24 -0
  21. mayhem/cli/experiment.py +24 -0
  22. mayhem/cli/lifecycle.py +805 -0
  23. mayhem/cli/resolver.py +72 -0
  24. mayhem/cli/services.py +459 -0
  25. mayhem/cli/style.py +101 -0
  26. mayhem/cli/toolkit.py +41 -0
  27. mayhem/cli/topology.py +127 -0
  28. mayhem/config.py +208 -0
  29. mayhem/controller/__init__.py +1 -0
  30. mayhem/controller/compensation.py +2156 -0
  31. mayhem/controller/executor.py +1719 -0
  32. mayhem/controller/janitor.py +196 -0
  33. mayhem/controller/observability_collector.py +382 -0
  34. mayhem/controller/observations.py +102 -0
  35. mayhem/controller/planner.py +715 -0
  36. mayhem/controller/recovery.py +245 -0
  37. mayhem/controller/resilience_report.py +585 -0
  38. mayhem/controller/resource_manager.py +457 -0
  39. mayhem/controller/safety.py +392 -0
  40. mayhem/domain/__init__.py +6 -0
  41. mayhem/domain/campaigns.py +118 -0
  42. mayhem/domain/cancellation.py +110 -0
  43. mayhem/domain/candidates.py +101 -0
  44. mayhem/domain/capabilities.py +86 -0
  45. mayhem/domain/catalog.py +727 -0
  46. mayhem/domain/checks.py +173 -0
  47. mayhem/domain/common.py +104 -0
  48. mayhem/domain/coverage.py +106 -0
  49. mayhem/domain/decisions.py +57 -0
  50. mayhem/domain/errors.py +87 -0
  51. mayhem/domain/events.py +61 -0
  52. mayhem/domain/execution_context.py +120 -0
  53. mayhem/domain/execution_loci.py +94 -0
  54. mayhem/domain/experiments.py +370 -0
  55. mayhem/domain/faults.py +239 -0
  56. mayhem/domain/identity.py +200 -0
  57. mayhem/domain/k8s_adapter.py +132 -0
  58. mayhem/domain/leases.py +186 -0
  59. mayhem/domain/load_strategy.py +98 -0
  60. mayhem/domain/m5_campaign.py +120 -0
  61. mayhem/domain/maniac.py +93 -0
  62. mayhem/domain/observability.py +146 -0
  63. mayhem/domain/outcomes.py +92 -0
  64. mayhem/domain/remote_agent_interface.py +70 -0
  65. mayhem/domain/resources.py +245 -0
  66. mayhem/domain/risks.py +61 -0
  67. mayhem/domain/run_outcome.py +146 -0
  68. mayhem/domain/runtime_adapter.py +256 -0
  69. mayhem/domain/success.py +329 -0
  70. mayhem/domain/topology.py +452 -0
  71. mayhem/infra/__init__.py +1 -0
  72. mayhem/infra/campaign_engine.py +205 -0
  73. mayhem/infra/candidate_gates.py +124 -0
  74. mayhem/infra/candidate_generator.py +110 -0
  75. mayhem/infra/coverage_repository.py +101 -0
  76. mayhem/infra/lease_repository.py +129 -0
  77. mayhem/infra/maniac.py +103 -0
  78. mayhem/infra/migrations.py +596 -0
  79. mayhem/infra/migrator.py +149 -0
  80. mayhem/infra/report.py +227 -0
  81. mayhem/infra/store.py +200 -0
  82. mayhem/py.typed +0 -0
  83. mayhem/spec.py +52 -0
  84. mayhem/toolkit/__init__.py +1 -0
  85. mayhem/toolkit/fingerprint.py +69 -0
  86. mayhem/toolkit/hashing.py +32 -0
  87. mayhem/toolkit/manifests/docker.yaml +11 -0
  88. mayhem/toolkit/manifests/podman.yaml +11 -0
  89. mayhem/toolkit/manifests/stress-ng.yaml +11 -0
  90. mayhem/toolkit/manifests/tc-netem.yaml +11 -0
  91. mayhem/toolkit/manifests/toxiproxy.yaml +10 -0
  92. mayhem/toolkit/registry.py +185 -0
  93. mayhem/toolkit/tool_runner.py +129 -0
  94. mayhem/topology/__init__.py +10 -0
  95. mayhem/topology/providers/__init__.py +0 -0
  96. mayhem/topology/providers/adapter_registry.py +60 -0
  97. mayhem/topology/providers/base.py +31 -0
  98. mayhem/topology/providers/compose.py +207 -0
  99. mayhem/topology/providers/docker_adapter.py +277 -0
  100. mayhem/topology/providers/docker_runtime.py +461 -0
  101. mayhem/topology/providers/podman_adapter.py +328 -0
  102. mayhem/topology/resolve.py +196 -0
  103. mayhem/topology/service.py +158 -0
  104. mayhem_cli-0.5.1.dist-info/METADATA +555 -0
  105. mayhem_cli-0.5.1.dist-info/RECORD +107 -0
  106. mayhem_cli-0.5.1.dist-info/WHEEL +4 -0
  107. mayhem_cli-0.5.1.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,452 @@
1
+ """Topology model: nodes, edges, graph (ADR-0006).
2
+
3
+ Node kinds form a *closed union* — adding Kubernetes later means extending this
4
+ union deliberately (ADR-0013), never duck-typing through it. Container-runtime
5
+ specifics stay out: ``ContainerNode`` is engine-agnostic. Identity lives in
6
+ ``RuntimeIdentity``/``RuntimeMetadata`` (ADR-M1-1/M1-2); ``container_name`` and
7
+ ``service_name`` are authoring resolver keys, never identity.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import re
14
+ from enum import StrEnum
15
+ from typing import Annotated, Literal
16
+
17
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
18
+ from pydantic.networks import IPvAnyAddress
19
+
20
+ from mayhem.domain.errors import InvariantViolationError, TargetResolutionError
21
+ from mayhem.domain.identity import RuntimeIdentity, RuntimeMetadata
22
+
23
+
24
+ class NodeKind(StrEnum):
25
+ SERVICE = "service"
26
+ CONTAINER = "container"
27
+ HOST = "host"
28
+ PROCESS = "process"
29
+ EXTERNAL_DEPENDENCY = "external_dependency"
30
+ POD = "pod"
31
+ K8S_NODE = "k8s_node"
32
+
33
+
34
+ class _NodeBase(BaseModel):
35
+ """Common node shape; all nodes are immutable value objects."""
36
+
37
+ model_config = ConfigDict(frozen=True)
38
+
39
+ id: str
40
+ name: str
41
+
42
+
43
+ class PortBinding(BaseModel):
44
+ """A single port binding — host side and container side."""
45
+
46
+ model_config = ConfigDict(frozen=True)
47
+
48
+ host_port: int
49
+ container_port: int
50
+ host_address: str = "0.0.0.0"
51
+ protocol: str = "tcp" # tcp | udp
52
+
53
+
54
+ class ServiceNode(_NodeBase):
55
+ kind: Literal[NodeKind.SERVICE] = NodeKind.SERVICE
56
+ image: str | None = None
57
+ exposed_ports: tuple[PortBinding, ...] = ()
58
+ container_name: str | None = None # compose ``name:`` field (ADR-0020)
59
+
60
+
61
+ class ContainerNode(_NodeBase):
62
+ kind: Literal[NodeKind.CONTAINER] = NodeKind.CONTAINER
63
+ engine: str # "docker" | "podman" — label only, no runtime types here (ADR-0013)
64
+ runtime_identity: RuntimeIdentity # equality key (ADR-M1-1)
65
+ runtime_metadata: RuntimeMetadata | None = None # descriptive, never identity (ADR-M1-2)
66
+ ip_address: IPvAnyAddress | None = None
67
+ # Resolver key only — authoring/DSL lookup by a stable name (ADR-M1-1, ADR-M1-4).
68
+ # Deprecated in identity contexts: equality goes through runtime_identity.
69
+ container_name: str | None = None
70
+ ports: tuple[PortBinding, ...] = ()
71
+ state: str = "unknown" # engine-reported lifecycle state
72
+ image: str | None = None
73
+ networks: tuple[str, ...] = ()
74
+
75
+ @property
76
+ def identity_key(self) -> str:
77
+ """Canonical identity key for persisted records."""
78
+ return self.runtime_identity.key()
79
+
80
+
81
+ class HostNode(_NodeBase):
82
+ kind: Literal[NodeKind.HOST] = NodeKind.HOST
83
+ address: IPvAnyAddress | None = None
84
+ transport: Literal["local", "ssh"] = "local"
85
+ ssh_user: str | None = None
86
+
87
+
88
+ class ProcessNode(_NodeBase):
89
+ kind: Literal[NodeKind.PROCESS] = NodeKind.PROCESS
90
+ pid: int | None = None # resolved lazily at execution time (ADR-0020)
91
+ host_id: str
92
+ cmdline: str = ""
93
+ container_id: str | None = None # short container ID if running inside a container
94
+ container_name: str | None = None # stable identity for PID resolution (ADR-0020)
95
+ exe: str = "" # executable path where discoverable
96
+ user: str = "" # process user where discoverable
97
+ ppid: int | None = None # parent PID where discoverable
98
+
99
+
100
+ class ExternalDependencyNode(_NodeBase):
101
+ kind: Literal[NodeKind.EXTERNAL_DEPENDENCY] = NodeKind.EXTERNAL_DEPENDENCY
102
+ endpoint: str
103
+ protocol: str = "http" # http | tcp | dns | ...
104
+ inferred: bool = False # inferred nodes are never targetable until allowlisted
105
+
106
+
107
+ class PodNode(_NodeBase):
108
+ """Kubernetes pod — planning-only model (ADR-M7-2).
109
+
110
+ Represents a pod discovered via the k8s adapter. Execution is gated
111
+ behind ``KubernetesAdapter`` and defaults to ``UNSUPPORTED`` until a
112
+ live-cluster driver is implemented (M8).
113
+ """
114
+
115
+ kind: Literal[NodeKind.POD] = NodeKind.POD
116
+ namespace: str = "default"
117
+ node_name: str | None = None # k8s node hosting the pod
118
+ pod_ip: IPvAnyAddress | None = None
119
+ image: str | None = None
120
+ labels: dict[str, str] = Field(default_factory=dict)
121
+ state: str = "unknown"
122
+
123
+
124
+ class K8sNode(_NodeBase):
125
+ """Kubernetes cluster node — planning-only model (ADR-M7-2).
126
+
127
+ Represents a worker or control-plane node discovered via the k8s adapter.
128
+ Execution is gated behind ``KubernetesAdapter`` and defaults to
129
+ ``UNSUPPORTED`` until a live-cluster driver is implemented (M8).
130
+ """
131
+
132
+ kind: Literal[NodeKind.K8S_NODE] = NodeKind.K8S_NODE
133
+ cluster: str = ""
134
+ roles: tuple[str, ...] = () # e.g. ("control-plane",), ("worker",)
135
+ ip_address: IPvAnyAddress | None = None
136
+ capacity_cpu: str = ""
137
+ capacity_memory: str = ""
138
+ state: str = "unknown"
139
+
140
+
141
+ TopologyNode = (
142
+ ServiceNode
143
+ | ContainerNode
144
+ | HostNode
145
+ | ProcessNode
146
+ | ExternalDependencyNode
147
+ | PodNode
148
+ | K8sNode
149
+ )
150
+ """Public union of all node kinds (ADR-0013 closed union, extended ADR-M7-2)."""
151
+
152
+ _discriminated_nodes = Annotated[TopologyNode, Field(discriminator="kind")]
153
+
154
+
155
+ # --- Network path model (ADR-0019) ---
156
+
157
+
158
+ class NetworkSegment(BaseModel):
159
+ """A named segment in the network topology — a subnet, VLAN, or namespace."""
160
+
161
+ model_config = ConfigDict(frozen=True)
162
+
163
+ id: str
164
+ name: str
165
+ cidr: str | None = None # e.g. "10.0.1.0/24"
166
+ node_ids: tuple[str, ...] = () # nodes in this segment
167
+
168
+
169
+ class NetworkPath(BaseModel):
170
+ """A directed path between two nodes through the network.
171
+
172
+ Captures hops, expected latency, and any intermediate infrastructure
173
+ (load balancers, firewalls, service meshes) that could be fault targets.
174
+ """
175
+
176
+ model_config = ConfigDict(frozen=True)
177
+
178
+ src_node_id: str
179
+ dst_node_id: str
180
+ hop_count: int = 1
181
+ expected_latency_ms: float = 0.0
182
+ intermediaries: tuple[str, ...] = () # node IDs of LBs, firewalls, etc.
183
+ segments: tuple[str, ...] = () # segment IDs this path crosses
184
+ bidirectional: bool = True
185
+ # ADR-M3-7: fault-targetable path detail + fingerprint
186
+ namespace: str | None = None
187
+ interface: str | None = None
188
+ protocol: str = "tcp"
189
+ ports: tuple[int, ...] = ()
190
+ direction: str = "both"
191
+ fingerprint: str = ""
192
+
193
+
194
+ def compute_network_fingerprint(
195
+ *,
196
+ src: str,
197
+ dst: str,
198
+ namespace: str | None = None,
199
+ protocol: str = "tcp",
200
+ fault_type: str = "network",
201
+ ) -> str:
202
+ """Deterministic 16-hex fingerprint for a network fault path (ADR-M3-7).
203
+
204
+ The fingerprint is stable across runs and engine choices, so the planner
205
+ and the resource ledger can correlate a planned fault with the tracked
206
+ resource it mutates.
207
+ """
208
+ key = f"{src}:{dst}:{namespace or ''}:{protocol}:{fault_type}"
209
+ return hashlib.sha256(key.encode("utf-8")).hexdigest()[:16]
210
+
211
+
212
+ class NetworkTopology(BaseModel):
213
+ """Collection of network segments and paths — the "network view" of the topology.
214
+
215
+ Used by the planner to determine which network faults are applicable
216
+ to a given source→destination path and which intermediaries can be targeted.
217
+ """
218
+
219
+ model_config = ConfigDict(frozen=True)
220
+
221
+ segments: tuple[NetworkSegment, ...] = ()
222
+ paths: tuple[NetworkPath, ...] = ()
223
+
224
+ def paths_for_node(self, node_id: str) -> tuple[NetworkPath, ...]:
225
+ """All paths originating from or terminating at a node."""
226
+ return tuple(p for p in self.paths if node_id in (p.src_node_id, p.dst_node_id))
227
+
228
+ def segment_for_node(self, node_id: str) -> NetworkSegment | None:
229
+ """Which segment contains this node, if any."""
230
+ for seg in self.segments:
231
+ if node_id in seg.node_ids:
232
+ return seg
233
+ return None
234
+
235
+ def find_path(self, src_node_id: str, dst_node_id: str) -> NetworkPath | None:
236
+ """Find a path between two nodes (directional)."""
237
+ for p in self.paths:
238
+ if p.src_node_id == src_node_id and p.dst_node_id == dst_node_id:
239
+ return p
240
+ return None
241
+
242
+ def cross_segment_paths(self) -> tuple[NetworkPath, ...]:
243
+ """Paths that cross segment boundaries — higher fault surface."""
244
+ return tuple(p for p in self.paths if len(p.segments) > 1)
245
+
246
+
247
+ class EdgeKind(StrEnum):
248
+ RUNS_ON = "runs_on"
249
+ CONNECTS_VIA = "connects_via"
250
+ DEPENDS_ON = "depends_on"
251
+ EXPOSES = "exposes"
252
+ CONTAINED_IN = "contained_in"
253
+ LISTENS_ON = "listens_on"
254
+ ATTACHED_TO = "attached_to"
255
+
256
+
257
+ class Edge(BaseModel):
258
+ model_config = ConfigDict(frozen=True)
259
+
260
+ src: str
261
+ dst: str
262
+ kind: EdgeKind
263
+ weight: float = 1.0 # depends_on condition strength; >=1.0 means health-gated
264
+
265
+
266
+ class TargetSelector(BaseModel):
267
+ """Declarative selector resolved against a :class:`TopologyGraph`.
268
+
269
+ Grammar of ``expr``:
270
+ * ``name`` — exact node name match
271
+ * ``key=value`` — exact field match
272
+ * ``key~=regex`` — regex field match
273
+ """
274
+
275
+ model_config = ConfigDict(frozen=True)
276
+
277
+ kind: NodeKind
278
+ expr: str
279
+
280
+ def matches(self, node: TopologyNode) -> bool:
281
+ if node.kind != self.kind:
282
+ return False
283
+ expr = self.expr.strip()
284
+ if "~=" in expr:
285
+ key, pattern = (part.strip() for part in expr.split("~=", maxsplit=1))
286
+ value = _field_str(node, key)
287
+ return value is not None and re.search(pattern, value) is not None
288
+ if "=" in expr:
289
+ key, expected = (part.strip() for part in expr.split("=", maxsplit=1))
290
+ return _field_str(node, key) == expected
291
+ return node.name == expr
292
+
293
+ def __str__(self) -> str:
294
+ return f"{self.kind.value}:{self.expr}"
295
+
296
+
297
+ def _field_str(node: BaseModel, key: str) -> str | None:
298
+ # Authoring resolver keys (ADR-M1-1, ADR-M1-4). `service_name=` and
299
+ # `container_name=` selectors resolve through RuntimeMetadata; they are
300
+ # names of user intent, not node attributes after the identity refactor.
301
+ if key == "service_name" and isinstance(node, ContainerNode):
302
+ meta = node.runtime_metadata
303
+ return meta.service if meta is not None else None
304
+ value = getattr(node, key, None)
305
+ return None if value is None else str(value)
306
+
307
+
308
+ class TopologyGraph(BaseModel):
309
+ model_config = ConfigDict(frozen=True)
310
+
311
+ nodes: tuple[_discriminated_nodes, ...] = ()
312
+ edges: tuple[Edge, ...] = ()
313
+
314
+ @model_validator(mode="after")
315
+ def _check_integrity(self) -> TopologyGraph:
316
+ ids = [n.id for n in self.nodes]
317
+ duplicates = {nid for nid in ids if ids.count(nid) > 1}
318
+ if duplicates:
319
+ raise InvariantViolationError(
320
+ "topology_unique_node_ids", f"duplicate node ids: {sorted(duplicates)}"
321
+ )
322
+ known = set(ids)
323
+ dangling = [e for e in self.edges if e.src not in known or e.dst not in known]
324
+ if dangling:
325
+ bad = ", ".join(f"{e.kind.value}:{e.src}->{e.dst}" for e in dangling[:5])
326
+ raise InvariantViolationError(
327
+ "topology_edges_reference_known_nodes", f"dangling edges: {bad}"
328
+ )
329
+ return self
330
+
331
+ # -- queries ------------------------------------------------------------------
332
+ def by_id(self, node_id: str) -> TopologyNode | None:
333
+ for node in self.nodes:
334
+ if node.id == node_id:
335
+ return node
336
+ return None
337
+
338
+ def of_kind(self, kind: NodeKind) -> list[TopologyNode]:
339
+ return [n for n in self.nodes if n.kind == kind]
340
+
341
+ def resolve(self, selector: TargetSelector) -> tuple[TopologyNode, ...]:
342
+ found = tuple(n for n in self.nodes if selector.matches(n))
343
+ if not found:
344
+ raise TargetResolutionError(str(selector), "no nodes matched")
345
+ return found
346
+
347
+ def connected_processes(self, node_id: str) -> tuple[ProcessNode, ...]:
348
+ """Walk RUNS_ON edges from *node_id* to find connected ProcessNodes.
349
+
350
+ Follows: service → container → process. Returns every ProcessNode
351
+ reachable within two hops of *node_id* via RUNS_ON edges.
352
+ """
353
+ by_id = {n.id: n for n in self.nodes}
354
+ child_ids = [e.dst for e in self.edges if e.src == node_id and e.kind is EdgeKind.RUNS_ON]
355
+ # One more hop: process nodes that RUNS_ON the children.
356
+ grandchild_ids = [
357
+ e.dst
358
+ for cid in child_ids
359
+ for e in self.edges
360
+ if e.src == cid and e.kind is EdgeKind.RUNS_ON
361
+ ]
362
+ result: list[ProcessNode] = []
363
+ for cid in (*child_ids, *grandchild_ids):
364
+ node = by_id.get(cid)
365
+ if isinstance(node, ProcessNode):
366
+ result.append(node)
367
+ return tuple(result)
368
+
369
+ def container_names(self) -> tuple[str, ...]:
370
+ """Distinct container identifiers usable with ``--ctr``, sorted.
371
+
372
+ Every ``container_name`` authoring key in the graph is a candidate,
373
+ plus the runtime container names of :class:`ContainerNode` nodes, so
374
+ ``--ctr`` accepts either the compose ``container_name:`` value or the
375
+ container's own name as reported by the runtime.
376
+ """
377
+ keys: set[str] = set()
378
+ for node in self.nodes:
379
+ container_name = getattr(node, "container_name", None)
380
+ if container_name is not None:
381
+ keys.add(str(container_name))
382
+ if node.kind is NodeKind.CONTAINER:
383
+ keys.add(node.name)
384
+ return tuple(sorted(keys))
385
+
386
+ def node_ids_for_container(self, container_name: str) -> frozenset[str]:
387
+ """Node ids in the subtree named by *container_name* (``--ctr``).
388
+
389
+ A compose service expands to a subtree (service → container →
390
+ process); all members share the ``container_name`` authoring key. A
391
+ ``container_name`` match always wins; as a fallback the name of a
392
+ :class:`ContainerNode` is resolved to its authoring key so the whole
393
+ subtree is returned either way.
394
+ """
395
+ by_key = frozenset(
396
+ node.id
397
+ for node in self.nodes
398
+ if getattr(node, "container_name", None) == container_name
399
+ )
400
+ if by_key:
401
+ return by_key
402
+ for node in self.nodes:
403
+ if node.kind is NodeKind.CONTAINER and node.name == container_name:
404
+ canonical = getattr(node, "container_name", None)
405
+ if canonical is not None:
406
+ return frozenset(
407
+ n.id for n in self.nodes if getattr(n, "container_name", None) == canonical
408
+ )
409
+ return frozenset({node.id})
410
+ return frozenset()
411
+
412
+ def restrict_to(self, container_name: str) -> TopologyGraph:
413
+ """Return the subgraph scoped to one container subtree (``--ctr``).
414
+
415
+ Keeps the matched service/container/process subtree plus its host
416
+ parents, and only the edges among the kept nodes, so downstream
417
+ planning and synthesis see exactly one container. A miss raises
418
+ :class:`TargetResolutionError` listing the available containers —
419
+ ``--ctr`` is a loud guard, never a silent no-op.
420
+ """
421
+ keep: set[str] = set(self.node_ids_for_container(container_name))
422
+ if not keep:
423
+ available = ", ".join(self.container_names()) or "<none>"
424
+ raise TargetResolutionError(
425
+ container_name,
426
+ f"no container named {container_name!r} in topology (available: {available})",
427
+ )
428
+ by_id = {node.id: node for node in self.nodes}
429
+ for edge in self.edges:
430
+ if edge.src in keep and edge.kind in (EdgeKind.RUNS_ON, EdgeKind.CONTAINED_IN):
431
+ neighbor = by_id.get(edge.dst)
432
+ if neighbor is not None and neighbor.kind is NodeKind.HOST:
433
+ keep.add(edge.dst)
434
+ nodes = tuple(node for node in self.nodes if node.id in keep)
435
+ edges = tuple(edge for edge in self.edges if edge.src in keep and edge.dst in keep)
436
+ return TopologyGraph(nodes=nodes, edges=edges)
437
+
438
+ def dependents_closure(self, node_id: str) -> frozenset[str]:
439
+ """All node ids that transitively depend on ``node_id`` — blast-radius input."""
440
+ reverse: dict[str, list[str]] = {}
441
+ for edge in self.edges:
442
+ if edge.kind in (EdgeKind.DEPENDS_ON, EdgeKind.CONNECTS_VIA):
443
+ reverse.setdefault(edge.dst, []).append(edge.src)
444
+ seen: set[str] = set()
445
+ frontier = [node_id]
446
+ while frontier:
447
+ current = frontier.pop()
448
+ for upstream in reverse.get(current, []):
449
+ if upstream != node_id and upstream not in seen:
450
+ seen.add(upstream)
451
+ frontier.append(upstream)
452
+ return frozenset(seen)
@@ -0,0 +1 @@
1
+ """Persistence infrastructure (ADR-0007): SQLite WAL, single writer, migrations."""
@@ -0,0 +1,205 @@
1
+ """M5 campaign execution engine (ADR-M5-4, M5 Phase 5.4).
2
+
3
+ Lifecycle: init → iterate (generate/select → gate → run) → stop.
4
+
5
+ Stop on the first of: coverage target reached, budget (max_runs) exhausted,
6
+ deadline passed, or an explicit stop-condition. A ``supervised`` campaign
7
+ never runs a drill until the injected approver approves it; denied candidates
8
+ are skipped and counted, never executed.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import time
14
+ from dataclasses import dataclass
15
+ from typing import TYPE_CHECKING, Protocol
16
+
17
+ from mayhem.domain.candidates import CandidateGate
18
+ from mayhem.domain.coverage import CoverageCell
19
+ from mayhem.domain.m5_campaign import (
20
+ ApproveDecision,
21
+ CampaignMode,
22
+ CampaignProgress,
23
+ M5Campaign,
24
+ StopReason,
25
+ )
26
+
27
+ if TYPE_CHECKING:
28
+ from collections.abc import Callable
29
+
30
+ from mayhem.domain.candidates import CandidateDecision, ExperimentCandidate
31
+ from mayhem.infra.candidate_gates import CandidateGatePipeline
32
+
33
+ # A single executed drill result: the Run that ran, its Outcome, and the
34
+ # coverage cell it explored.
35
+ DrillResult = tuple[str, str, CoverageCell] # (run_id, outcome_id, cell)
36
+
37
+
38
+ class CandidateSource(Protocol):
39
+ """Yields candidates for the campaign to consider (in-order, may exhaust)."""
40
+
41
+ def next_candidate(self) -> ExperimentCandidate | None: ...
42
+
43
+
44
+ class Approver(Protocol):
45
+ """Gate for ``supervised`` mode: must explicitly approve before running."""
46
+
47
+ def approve(self, candidate: ExperimentCandidate) -> bool: ...
48
+
49
+
50
+ class Runner(Protocol):
51
+ """Executes a candidate to a Run/Outcome pair + the cell it covered."""
52
+
53
+ def run(self, candidate: ExperimentCandidate) -> DrillResult: ...
54
+
55
+
56
+ @dataclass
57
+ class CampaignStopError(Exception):
58
+ """Raised when the loop hits a stop condition."""
59
+
60
+ reason: StopReason
61
+
62
+
63
+ class M5CampaignEngine:
64
+ """Deterministic iterate-until-stop engine for one ADR-M5-4 campaign."""
65
+
66
+ def __init__(
67
+ self,
68
+ campaign: M5Campaign,
69
+ *,
70
+ source: CandidateSource,
71
+ gates: CandidateGatePipeline,
72
+ runner: Runner,
73
+ approver: Approver | None = None,
74
+ coverage_target_fn: Callable[[M5Campaign, CampaignProgress], int] | None = None,
75
+ blast_check: Callable[[ExperimentCandidate, CampaignProgress], str | None] | None = None,
76
+ rationale_fn: Callable[[ExperimentCandidate], str] | None = None,
77
+ now_epoch_s: Callable[[], float] = time.time,
78
+ ) -> None:
79
+ self.campaign = campaign
80
+ self._source = source
81
+ self._gates = gates
82
+ self._runner = runner
83
+ self._approver = approver
84
+ self._coverage_target_fn = coverage_target_fn or (lambda c, p: c.coverage_target)
85
+ self._blast_check = blast_check
86
+ self._rationale_fn = rationale_fn
87
+ self._now = now_epoch_s
88
+ self.progress = CampaignProgress()
89
+ self.state_stack: list[StopReason] = []
90
+ self.covered_cells: set[str] = set()
91
+ self._stop_requested = False
92
+
93
+ def request_stop(self) -> None:
94
+ """Explicitly request a manual stop (surfaces STOP_CONDITION)."""
95
+ self._stop_requested = True
96
+
97
+ # ── Stop-condition primitives ─────────────────────────────────────
98
+ def _budget_exhausted(self) -> bool:
99
+ return self.progress.runs_executed >= self.campaign.max_runs
100
+
101
+ def _deadline_passed(self) -> bool:
102
+ return (
103
+ self.campaign.deadline_epoch_s is not None
104
+ and self._now() > self.campaign.deadline_epoch_s
105
+ )
106
+
107
+ def _coverage_reached(self) -> bool:
108
+ target = self._coverage_target_fn(self.campaign, self.progress)
109
+ return len(self.covered_cells) >= target
110
+
111
+ def _check_stop(self, *, raise_on_stop: bool = True) -> StopReason | None:
112
+ if self._budget_exhausted():
113
+ reason = StopReason.BUDGET_EXHAUSTED
114
+ elif self._deadline_passed():
115
+ reason = StopReason.DEADLINE_PASSED
116
+ elif self._explicit_stop():
117
+ reason = StopReason.STOP_CONDITION
118
+ elif self._coverage_reached():
119
+ reason = StopReason.COVERAGE_REACHED
120
+ else:
121
+ return None
122
+ if raise_on_stop:
123
+ raise CampaignStopError(reason)
124
+ return reason
125
+
126
+ def _explicit_stop(self) -> bool:
127
+ # The caller triggers an explicit stop by raising the flag through the
128
+ # ``stop_condition`` handle; here a non-empty free-form stop condition
129
+ # with no remaining candidates is surfaced as a manual stop signal.
130
+ return bool(self._stop_requested)
131
+
132
+ # ── Iteration ─────────────────────────────────────────────────────
133
+ def _remember_cell(self, cell: CoverageCell) -> None:
134
+ self.covered_cells.add(cell.key)
135
+
136
+ def _approval_required(self) -> bool:
137
+ return self.campaign.mode is CampaignMode.SUPERVISED
138
+
139
+ def iterate(self) -> CampaignProgress:
140
+ """Advance one step (one candidate considered).
141
+
142
+ Returns the progress snapshot after the step. Raises ``CampaignStopError``
143
+ when a stop condition is reached.
144
+ """
145
+ self._check_stop()
146
+ candidate = self._source.next_candidate()
147
+ if candidate is None:
148
+ # No more candidates to try — treat as budget-exhausted equivalent.
149
+ self.state_stack.append(StopReason.BUDGET_EXHAUSTED)
150
+ raise CampaignStopError(StopReason.BUDGET_EXHAUSTED)
151
+
152
+ decision: CandidateDecision = self._gates.gate(candidate)
153
+ if decision.rejected:
154
+ if decision.gate is CandidateGate.RESOURCE_CONFLICT:
155
+ # A violating candidate (in-flight conflict, over blast budget)
156
+ # is a hard bound-abort: never executed out of bounds.
157
+ self.progress.candidates_aborted += 1
158
+ self.progress.record(
159
+ candidate.id,
160
+ ApproveDecision.DENIED,
161
+ rationale=decision.reason or "resource conflict",
162
+ )
163
+ raise CampaignStopError(StopReason.RESOURCE_CONFLICT)
164
+ self.progress.candidates_rejected += 1
165
+ # A rejected candidate is not executed and does not consume budget.
166
+ return self.progress
167
+
168
+ if self._approval_required():
169
+ self.progress.candidates_pending_approval += 1
170
+ rationale = self._rationale_fn(candidate) if self._rationale_fn else ""
171
+ self.progress.record(candidate.id, ApproveDecision.PENDING, rationale=rationale)
172
+ approved = self._approver.approve(candidate) if self._approver is not None else False
173
+ if not approved:
174
+ # denied candidates are skipped and recorded, never run.
175
+ self.progress.candidates_denied += 1
176
+ self.progress.record(candidate.id, ApproveDecision.DENIED, rationale=rationale)
177
+ return self.progress
178
+ self.progress.record(candidate.id, ApproveDecision.APPROVED, rationale=rationale)
179
+
180
+ # Protective bound-check (autonomous): blast-radius / resource cap.
181
+ if self._blast_check is not None:
182
+ blast_reason = self._blast_check(candidate, self.progress)
183
+ if blast_reason is not None:
184
+ self.progress.candidates_aborted += 1
185
+ self.progress.record(
186
+ candidate.id, ApproveDecision.DENIED, rationale=f"bound-abort: {blast_reason}"
187
+ )
188
+ raise CampaignStopError(StopReason.RESOURCE_CONFLICT)
189
+
190
+ _run_id, _outcome_id, cell = self._runner.run(candidate)
191
+ self.progress.runs_executed += 1
192
+ self._remember_cell(cell)
193
+
194
+ # Re-check stop after this run consumed budget and covered a cell.
195
+ self._check_stop()
196
+ return self.progress
197
+
198
+ def run_until_stop(self) -> StopReason:
199
+ """Drive the loop until a stop condition is met; return it."""
200
+ while True:
201
+ try:
202
+ self.iterate()
203
+ except CampaignStopError as stop:
204
+ self.state_stack.append(stop.reason)
205
+ return stop.reason