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.
- mayhem/agent/__init__.py +1 -0
- mayhem/agent/cli.py +36 -0
- mayhem/agents/__init__.py +1 -0
- mayhem/agents/capabilities.py +106 -0
- mayhem/agents/executors.py +430 -0
- mayhem/agents/impact.py +729 -0
- mayhem/agents/lease_client.py +141 -0
- mayhem/agents/probes.py +284 -0
- mayhem/agents/protocol.py +134 -0
- mayhem/agents/server.py +281 -0
- mayhem/agents/sinks.py +60 -0
- mayhem/agents/transports.py +134 -0
- mayhem/agents/watchdog.py +140 -0
- mayhem/cli/__init__.py +11 -0
- mayhem/cli/app.py +154 -0
- mayhem/cli/campaign.py +496 -0
- mayhem/cli/config_cmd.py +47 -0
- mayhem/cli/context.py +23 -0
- mayhem/cli/dependency.py +429 -0
- mayhem/cli/exit_codes.py +24 -0
- mayhem/cli/experiment.py +24 -0
- mayhem/cli/lifecycle.py +805 -0
- mayhem/cli/resolver.py +72 -0
- mayhem/cli/services.py +459 -0
- mayhem/cli/style.py +101 -0
- mayhem/cli/toolkit.py +41 -0
- mayhem/cli/topology.py +127 -0
- mayhem/config.py +208 -0
- mayhem/controller/__init__.py +1 -0
- mayhem/controller/compensation.py +2156 -0
- mayhem/controller/executor.py +1719 -0
- mayhem/controller/janitor.py +196 -0
- mayhem/controller/observability_collector.py +382 -0
- mayhem/controller/observations.py +102 -0
- mayhem/controller/planner.py +715 -0
- mayhem/controller/recovery.py +245 -0
- mayhem/controller/resilience_report.py +585 -0
- mayhem/controller/resource_manager.py +457 -0
- mayhem/controller/safety.py +392 -0
- mayhem/domain/__init__.py +6 -0
- mayhem/domain/campaigns.py +118 -0
- mayhem/domain/cancellation.py +110 -0
- mayhem/domain/candidates.py +101 -0
- mayhem/domain/capabilities.py +86 -0
- mayhem/domain/catalog.py +727 -0
- mayhem/domain/checks.py +173 -0
- mayhem/domain/common.py +104 -0
- mayhem/domain/coverage.py +106 -0
- mayhem/domain/decisions.py +57 -0
- mayhem/domain/errors.py +87 -0
- mayhem/domain/events.py +61 -0
- mayhem/domain/execution_context.py +120 -0
- mayhem/domain/execution_loci.py +94 -0
- mayhem/domain/experiments.py +370 -0
- mayhem/domain/faults.py +239 -0
- mayhem/domain/identity.py +200 -0
- mayhem/domain/k8s_adapter.py +132 -0
- mayhem/domain/leases.py +186 -0
- mayhem/domain/load_strategy.py +98 -0
- mayhem/domain/m5_campaign.py +120 -0
- mayhem/domain/maniac.py +93 -0
- mayhem/domain/observability.py +146 -0
- mayhem/domain/outcomes.py +92 -0
- mayhem/domain/remote_agent_interface.py +70 -0
- mayhem/domain/resources.py +245 -0
- mayhem/domain/risks.py +61 -0
- mayhem/domain/run_outcome.py +146 -0
- mayhem/domain/runtime_adapter.py +256 -0
- mayhem/domain/success.py +329 -0
- mayhem/domain/topology.py +452 -0
- mayhem/infra/__init__.py +1 -0
- mayhem/infra/campaign_engine.py +205 -0
- mayhem/infra/candidate_gates.py +124 -0
- mayhem/infra/candidate_generator.py +110 -0
- mayhem/infra/coverage_repository.py +101 -0
- mayhem/infra/lease_repository.py +129 -0
- mayhem/infra/maniac.py +103 -0
- mayhem/infra/migrations.py +596 -0
- mayhem/infra/migrator.py +149 -0
- mayhem/infra/report.py +227 -0
- mayhem/infra/store.py +200 -0
- mayhem/py.typed +0 -0
- mayhem/spec.py +52 -0
- mayhem/toolkit/__init__.py +1 -0
- mayhem/toolkit/fingerprint.py +69 -0
- mayhem/toolkit/hashing.py +32 -0
- mayhem/toolkit/manifests/docker.yaml +11 -0
- mayhem/toolkit/manifests/podman.yaml +11 -0
- mayhem/toolkit/manifests/stress-ng.yaml +11 -0
- mayhem/toolkit/manifests/tc-netem.yaml +11 -0
- mayhem/toolkit/manifests/toxiproxy.yaml +10 -0
- mayhem/toolkit/registry.py +185 -0
- mayhem/toolkit/tool_runner.py +129 -0
- mayhem/topology/__init__.py +10 -0
- mayhem/topology/providers/__init__.py +0 -0
- mayhem/topology/providers/adapter_registry.py +60 -0
- mayhem/topology/providers/base.py +31 -0
- mayhem/topology/providers/compose.py +207 -0
- mayhem/topology/providers/docker_adapter.py +277 -0
- mayhem/topology/providers/docker_runtime.py +461 -0
- mayhem/topology/providers/podman_adapter.py +328 -0
- mayhem/topology/resolve.py +196 -0
- mayhem/topology/service.py +158 -0
- mayhem_cli-0.5.1.dist-info/METADATA +555 -0
- mayhem_cli-0.5.1.dist-info/RECORD +107 -0
- mayhem_cli-0.5.1.dist-info/WHEEL +4 -0
- mayhem_cli-0.5.1.dist-info/entry_points.txt +3 -0
mayhem/infra/migrator.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Forward migration runner with down-migration support (ADR-M4-5).
|
|
2
|
+
|
|
3
|
+
Every migration is an ordered, immutable Python module exposing ``version``,
|
|
4
|
+
``name``, ``statements``, and optionally ``down_statements``. Applied versions
|
|
5
|
+
are recorded in ``_schema_migrations``; ``run_migrations`` applies pending ones
|
|
6
|
+
in a single transaction each and refuses out-of-order application
|
|
7
|
+
(testing-strategy §5). ``run_down_migrations`` reverses applied migrations
|
|
8
|
+
back to an older version in descending order — the mechanism ADR-M4-5's
|
|
9
|
+
"down-migration restores the baseline" acceptance relies on.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import sqlite3
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
from mayhem.domain.errors import DomainError
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MigrationError(DomainError):
|
|
25
|
+
"""Migration machinery failure."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Migration:
|
|
30
|
+
version: int
|
|
31
|
+
name: str
|
|
32
|
+
statements: tuple[str, ...]
|
|
33
|
+
down_statements: tuple[str, ...] = ()
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def migration_id(self) -> str:
|
|
37
|
+
return f"{self.version:04d}_{self.name}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run_migrations(conn: sqlite3.Connection, migrations: Sequence[Migration]) -> list[str]:
|
|
41
|
+
"""Apply all pending migrations; returns ids applied this call."""
|
|
42
|
+
conn.execute("""
|
|
43
|
+
CREATE TABLE IF NOT EXISTS _schema_migrations (
|
|
44
|
+
version INTEGER PRIMARY KEY,
|
|
45
|
+
name TEXT NOT NULL,
|
|
46
|
+
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
47
|
+
)
|
|
48
|
+
""")
|
|
49
|
+
applied = {int(row[0]) for row in conn.execute("SELECT version FROM _schema_migrations")}
|
|
50
|
+
applied_now: list[str] = []
|
|
51
|
+
previous = -1
|
|
52
|
+
for migration in migrations:
|
|
53
|
+
if migration.version <= previous:
|
|
54
|
+
raise MigrationError(
|
|
55
|
+
f"migrations must be strictly increasing; got {migration.version} after {previous}"
|
|
56
|
+
)
|
|
57
|
+
previous = migration.version
|
|
58
|
+
if migration.version in applied:
|
|
59
|
+
continue
|
|
60
|
+
try:
|
|
61
|
+
# Schema changes may rebuild parent tables (e.g. extending a CHECK
|
|
62
|
+
# constraint); FK enforcement blocks those DROP/RENAME steps, so it
|
|
63
|
+
# is disabled per-migration and re-enabled afterwards. A migration
|
|
64
|
+
# runs as the single writer with no concurrent readers, so this is
|
|
65
|
+
# safe and matches the SQLite table-rebuild procedure.
|
|
66
|
+
conn.execute("PRAGMA foreign_keys=OFF")
|
|
67
|
+
with conn:
|
|
68
|
+
for statement in migration.statements:
|
|
69
|
+
conn.execute(statement)
|
|
70
|
+
conn.execute(
|
|
71
|
+
"INSERT INTO _schema_migrations (version, name) VALUES (?, ?)",
|
|
72
|
+
(migration.version, migration.name),
|
|
73
|
+
)
|
|
74
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
75
|
+
except sqlite3.Error as exc:
|
|
76
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
77
|
+
raise MigrationError(f"migration {migration.migration_id} failed: {exc}") from exc
|
|
78
|
+
applied_now.append(migration.migration_id)
|
|
79
|
+
return applied_now
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def current_version(conn: sqlite3.Connection) -> int | None:
|
|
83
|
+
"""Latest applied schema version; None when database is fresh."""
|
|
84
|
+
exists = conn.execute(
|
|
85
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='_schema_migrations'"
|
|
86
|
+
).fetchone()
|
|
87
|
+
if exists is None:
|
|
88
|
+
return None
|
|
89
|
+
row = conn.execute("SELECT MAX(version) FROM _schema_migrations").fetchone()
|
|
90
|
+
return int(row[0]) if row and row[0] is not None else None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def run_down_migrations(
|
|
94
|
+
conn: sqlite3.Connection,
|
|
95
|
+
migrations: Sequence[Migration],
|
|
96
|
+
target_version: int,
|
|
97
|
+
) -> list[str]:
|
|
98
|
+
"""Roll an upgraded schema back to ``target_version`` (ADR-M4-5).
|
|
99
|
+
|
|
100
|
+
Applied migrations newer than ``target_version`` are reversed in descending
|
|
101
|
+
order using their ``down_statements``; each reversal runs in its own
|
|
102
|
+
transaction and the ``_schema_migrations`` row is deleted with it. A
|
|
103
|
+
migration without a down path refuses to roll back rather than truncate
|
|
104
|
+
history. Returns the migration ids reversed.
|
|
105
|
+
"""
|
|
106
|
+
by_version = {m.version: m for m in migrations}
|
|
107
|
+
applied = {
|
|
108
|
+
int(row[0]): str(row[1])
|
|
109
|
+
for row in conn.execute("SELECT version, name FROM _schema_migrations")
|
|
110
|
+
}
|
|
111
|
+
current = max(applied, default=-1)
|
|
112
|
+
if current == -1:
|
|
113
|
+
return [] # fresh database has nothing to roll back
|
|
114
|
+
if target_version >= current:
|
|
115
|
+
raise MigrationError(
|
|
116
|
+
f"target version {target_version} is not below current schema {current}; "
|
|
117
|
+
"nothing to roll back"
|
|
118
|
+
)
|
|
119
|
+
missing = set(applied) - set(by_version)
|
|
120
|
+
if missing:
|
|
121
|
+
raise MigrationError(
|
|
122
|
+
f"database has applied migrations unknown to this store: {sorted(missing)}"
|
|
123
|
+
)
|
|
124
|
+
reversed_now: list[str] = []
|
|
125
|
+
ordered = sorted(applied, reverse=True)
|
|
126
|
+
for version in ordered:
|
|
127
|
+
if version <= target_version:
|
|
128
|
+
continue
|
|
129
|
+
migration = by_version[version]
|
|
130
|
+
if not migration.down_statements:
|
|
131
|
+
raise MigrationError(
|
|
132
|
+
f"migration {migration.migration_id} has no down path; "
|
|
133
|
+
"refusing to truncate schema history"
|
|
134
|
+
)
|
|
135
|
+
try:
|
|
136
|
+
conn.execute("PRAGMA foreign_keys=OFF")
|
|
137
|
+
with conn:
|
|
138
|
+
for statement in migration.down_statements:
|
|
139
|
+
conn.execute(statement)
|
|
140
|
+
conn.execute(
|
|
141
|
+
"DELETE FROM _schema_migrations WHERE version = ?",
|
|
142
|
+
(version,),
|
|
143
|
+
)
|
|
144
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
145
|
+
except sqlite3.Error as exc:
|
|
146
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
147
|
+
raise MigrationError(f"down-migration {migration.migration_id} failed: {exc}") from exc
|
|
148
|
+
reversed_now.append(migration.migration_id)
|
|
149
|
+
return reversed_now
|
mayhem/infra/report.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""M5 report + experiment guidance builder (M5 Phase 5.7).
|
|
2
|
+
|
|
3
|
+
Consumes Run/Outcome history, coverage, and the Maniac candidate backlog to
|
|
4
|
+
produce a rich, model-only report:
|
|
5
|
+
- a coverage heatmap (rendered ASCII grid),
|
|
6
|
+
- per-cell verdicts with supported-by evidence (run links),
|
|
7
|
+
- the candidate backlog ranked by Maniac score,
|
|
8
|
+
- the guided "what to run next" list (untouched/``UNKNOWN`` cells).
|
|
9
|
+
|
|
10
|
+
The builder is deterministic and pure: given the same history it always
|
|
11
|
+
renders the same report. Assertions in tests are model/string level.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from mayhem.infra.candidate_gates import CandidateGatePipeline
|
|
20
|
+
from mayhem.infra.maniac import SelectionInputs, select_next
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from mayhem.domain.candidates import ExperimentCandidate
|
|
24
|
+
from mayhem.domain.coverage import CoverageCell, CoverageRecord
|
|
25
|
+
from mayhem.domain.run_outcome import Outcome, RunRecord
|
|
26
|
+
|
|
27
|
+
# Verdict / heatmap symbols.
|
|
28
|
+
_CELL_COVERED = "█"
|
|
29
|
+
_CELL_UNKNOWN = "·"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _PermissiveGate:
|
|
33
|
+
"""A gate that passes every candidate (used when no gates are given)."""
|
|
34
|
+
|
|
35
|
+
def check(self, candidate: object) -> str | None:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _permissive_gates() -> CandidateGatePipeline:
|
|
40
|
+
return CandidateGatePipeline(safety=_PermissiveGate(), feasibility=_PermissiveGate())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class CellVerdictReport:
|
|
45
|
+
"""One cell's verdict plus the runs that support/evidence it."""
|
|
46
|
+
|
|
47
|
+
cell: CoverageCell
|
|
48
|
+
covered: bool
|
|
49
|
+
run_ids: tuple[str, ...] = ()
|
|
50
|
+
verdict: str = "UNKNOWN"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class M5Report:
|
|
55
|
+
"""Rendered report over a landscape + recorded history."""
|
|
56
|
+
|
|
57
|
+
landscape: tuple[CoverageCell, ...] = ()
|
|
58
|
+
heatmap: str = ""
|
|
59
|
+
cell_verdicts: tuple[CellVerdictReport, ...] = ()
|
|
60
|
+
coverage_fraction: float = 0.0
|
|
61
|
+
next_to_run: tuple[CoverageCell, ...] = ()
|
|
62
|
+
ranked_backlog: tuple[ExperimentCandidate, ...] = ()
|
|
63
|
+
|
|
64
|
+
def render_markdown(self) -> str:
|
|
65
|
+
"""Render the whole report as a markdown string."""
|
|
66
|
+
lines = [
|
|
67
|
+
"# M5 Campaign Report",
|
|
68
|
+
"",
|
|
69
|
+
"## Coverage",
|
|
70
|
+
"",
|
|
71
|
+
self.heatmap,
|
|
72
|
+
"",
|
|
73
|
+
f"**coverage**: {self.coverage_fraction:.1%} of the landscape covered",
|
|
74
|
+
"",
|
|
75
|
+
"## Per-cell verdicts",
|
|
76
|
+
"",
|
|
77
|
+
]
|
|
78
|
+
if not self.cell_verdicts:
|
|
79
|
+
lines.append("_no cells recorded_")
|
|
80
|
+
else:
|
|
81
|
+
lines.append("| cell | verdict | evidence runs |")
|
|
82
|
+
lines.append("| --- | --- | --- |")
|
|
83
|
+
for v in self.cell_verdicts:
|
|
84
|
+
ev = ", ".join(v.run_ids) if v.run_ids else "—"
|
|
85
|
+
lines.append(f"| `{v.cell.key}` | {v.verdict} | {ev} |")
|
|
86
|
+
lines.append("")
|
|
87
|
+
lines.append("## What to run next")
|
|
88
|
+
lines.append("")
|
|
89
|
+
if self.next_to_run:
|
|
90
|
+
for cell in self.next_to_run:
|
|
91
|
+
lines.append(f"- `{cell.key}` (UNKNOWN)")
|
|
92
|
+
else:
|
|
93
|
+
lines.append("_no gap remains_")
|
|
94
|
+
lines.append("")
|
|
95
|
+
lines.append("## Candidate backlog (Maniac-ranked)")
|
|
96
|
+
lines.append("")
|
|
97
|
+
if self.ranked_backlog:
|
|
98
|
+
for i, cand in enumerate(self.ranked_backlog, 1):
|
|
99
|
+
lines.append(f"{i}. `{cand.id}` -> {cand.target} [{cand.primary_fault}]")
|
|
100
|
+
else:
|
|
101
|
+
lines.append("_backlog empty_")
|
|
102
|
+
return "\n".join(lines) + "\n"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _cell_verdict(
|
|
106
|
+
covered: bool,
|
|
107
|
+
run_ids: tuple[str, ...],
|
|
108
|
+
runs: dict[str, RunRecord],
|
|
109
|
+
outcomes: dict[str, Outcome],
|
|
110
|
+
) -> str:
|
|
111
|
+
"""Derive a verdict for a cell from its evidence (Runs/Outcomes)."""
|
|
112
|
+
if not covered or not run_ids:
|
|
113
|
+
return "UNKNOWN"
|
|
114
|
+
# Use the most recent run's outcome as the cell verdict.
|
|
115
|
+
for run_id in reversed(run_ids):
|
|
116
|
+
outcome = outcomes.get(run_id)
|
|
117
|
+
if outcome is not None and outcome.total_checks > 0:
|
|
118
|
+
return "PASS" if outcome.all_checks_passed else "FAIL"
|
|
119
|
+
# No outcome with checks; fall back to the run verdict if it FAILED.
|
|
120
|
+
for run_id in reversed(run_ids):
|
|
121
|
+
run = runs.get(run_id)
|
|
122
|
+
if run is not None and run.verdict.value == "fail":
|
|
123
|
+
return "FAIL"
|
|
124
|
+
return "PASS"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def render_heatmap(
|
|
128
|
+
landscape: tuple[CoverageCell, ...],
|
|
129
|
+
covered_keys: frozenset[str],
|
|
130
|
+
) -> str:
|
|
131
|
+
"""Render a compact ASCII heatmap: targets (rows) x faults (cols).
|
|
132
|
+
|
|
133
|
+
A cell is marked covered when any landscape cell in that (target, fault)
|
|
134
|
+
group is covered. Unknown cells use ``·``, covered cells use ``█``.
|
|
135
|
+
"""
|
|
136
|
+
targets: list[str] = []
|
|
137
|
+
faults: list[str] = []
|
|
138
|
+
seen_cells: dict[tuple[str, str], bool] = {}
|
|
139
|
+
for cell in landscape:
|
|
140
|
+
group = (cell.target, cell.fault_kind)
|
|
141
|
+
covered = cell.key in covered_keys
|
|
142
|
+
if group not in seen_cells or (covered and not seen_cells[group]):
|
|
143
|
+
seen_cells[group] = covered
|
|
144
|
+
if cell.target not in targets:
|
|
145
|
+
targets.append(cell.target)
|
|
146
|
+
if cell.fault_kind not in faults:
|
|
147
|
+
faults.append(cell.fault_kind)
|
|
148
|
+
|
|
149
|
+
header = " " + " ".join(f"{f:<6}" for f in faults)
|
|
150
|
+
lines = [header]
|
|
151
|
+
for t in targets:
|
|
152
|
+
row_parts = []
|
|
153
|
+
for f in faults:
|
|
154
|
+
covered = seen_cells.get((t, f), False)
|
|
155
|
+
row_parts.append(f"{_CELL_COVERED if covered else _CELL_UNKNOWN:<6}")
|
|
156
|
+
lines.append(f"{t:<8} " + " ".join(row_parts))
|
|
157
|
+
lines.append("")
|
|
158
|
+
lines.append(f"legend: {_CELL_COVERED}=covered {_CELL_UNKNOWN}=unknown")
|
|
159
|
+
return "\n".join(lines)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def build_m5_report(
|
|
163
|
+
*,
|
|
164
|
+
landscape: tuple[CoverageCell, ...],
|
|
165
|
+
covered_records: tuple[CoverageRecord, ...] = (),
|
|
166
|
+
runs: dict[str, RunRecord] | None = None,
|
|
167
|
+
outcomes: dict[str, Outcome] | None = None,
|
|
168
|
+
candidates: tuple[ExperimentCandidate, ...] = (),
|
|
169
|
+
gates: CandidateGatePipeline | None = None,
|
|
170
|
+
seed: int = 0,
|
|
171
|
+
max_runs: int = 50,
|
|
172
|
+
) -> M5Report:
|
|
173
|
+
"""Assemble the report from recorded history (pure/deterministic)."""
|
|
174
|
+
runs = runs or {}
|
|
175
|
+
outcomes = outcomes or {}
|
|
176
|
+
|
|
177
|
+
covered_keys: set[str] = set()
|
|
178
|
+
run_ids_by_cell: dict[str, list[str]] = {}
|
|
179
|
+
for rec in covered_records:
|
|
180
|
+
covered_keys.add(rec.cell.key)
|
|
181
|
+
run_ids_by_cell.setdefault(rec.cell.key, []).append(rec.run_id)
|
|
182
|
+
|
|
183
|
+
covered_count = len(covered_keys)
|
|
184
|
+
fraction = covered_count / len(landscape) if landscape else 0.0
|
|
185
|
+
|
|
186
|
+
verdicts = tuple(
|
|
187
|
+
CellVerdictReport(
|
|
188
|
+
cell=cell,
|
|
189
|
+
covered=cell.key in covered_keys,
|
|
190
|
+
run_ids=tuple(run_ids_by_cell.get(cell.key, ())),
|
|
191
|
+
verdict=_cell_verdict(
|
|
192
|
+
cell.key in covered_keys,
|
|
193
|
+
tuple(run_ids_by_cell.get(cell.key, ())),
|
|
194
|
+
runs,
|
|
195
|
+
outcomes,
|
|
196
|
+
),
|
|
197
|
+
)
|
|
198
|
+
for cell in landscape
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
unknown = [cell for cell in landscape if cell.key not in covered_keys]
|
|
202
|
+
|
|
203
|
+
# Candidate backlog ranked by Maniac (deterministic greedy coverage).
|
|
204
|
+
ranked: tuple[ExperimentCandidate, ...] = ()
|
|
205
|
+
if candidates:
|
|
206
|
+
result = select_next(
|
|
207
|
+
SelectionInputs(
|
|
208
|
+
candidates=candidates,
|
|
209
|
+
covered_keys=frozenset(covered_keys),
|
|
210
|
+
gates=gates if gates is not None else _permissive_gates(),
|
|
211
|
+
max_runs=max_runs,
|
|
212
|
+
coverage_target=max(len(landscape), 1),
|
|
213
|
+
seed=seed,
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
ranked = result.selected
|
|
217
|
+
|
|
218
|
+
heatmap = render_heatmap(landscape, frozenset(covered_keys))
|
|
219
|
+
|
|
220
|
+
return M5Report(
|
|
221
|
+
landscape=landscape,
|
|
222
|
+
heatmap=heatmap,
|
|
223
|
+
cell_verdicts=verdicts,
|
|
224
|
+
coverage_fraction=fraction,
|
|
225
|
+
next_to_run=tuple(unknown),
|
|
226
|
+
ranked_backlog=ranked,
|
|
227
|
+
)
|
mayhem/infra/store.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""SQLite store: one connection, WAL, disciplined pragmas (ADR-0007).
|
|
2
|
+
|
|
3
|
+
The controller is the single writer. Agents never open this file.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import sqlite3
|
|
10
|
+
import threading
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from mayhem.domain.common import utc_now
|
|
16
|
+
from mayhem.domain.run_outcome import Outcome, RunRecord, RunStatus, RunVerdict
|
|
17
|
+
from mayhem.infra.migrations import ALL_MIGRATIONS
|
|
18
|
+
from mayhem.infra.migrator import Migration, current_version, run_down_migrations, run_migrations
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Iterator
|
|
22
|
+
|
|
23
|
+
_BUSY_TIMEOUT_MS = 5_000
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Store:
|
|
27
|
+
"""Owns the SQLite connection and migration lifecycle."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, path: Path | str) -> None:
|
|
30
|
+
self._path = Path(path)
|
|
31
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
# One connection shared across threads (parallel fault execution, ADR-0022).
|
|
33
|
+
# Access is serialized by ``_lock``; WAL + busy_timeout handle cross-process writers.
|
|
34
|
+
self._conn = sqlite3.connect(
|
|
35
|
+
self._path, timeout=_BUSY_TIMEOUT_MS / 1000, check_same_thread=False
|
|
36
|
+
)
|
|
37
|
+
self._lock = threading.RLock()
|
|
38
|
+
self._conn.row_factory = sqlite3.Row
|
|
39
|
+
for pragma in (
|
|
40
|
+
"PRAGMA journal_mode=WAL",
|
|
41
|
+
f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}",
|
|
42
|
+
"PRAGMA foreign_keys=ON",
|
|
43
|
+
"PRAGMA synchronous=NORMAL",
|
|
44
|
+
):
|
|
45
|
+
self._conn.execute(pragma)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def open_migrated(
|
|
49
|
+
cls, path: Path | str, migrations: tuple[Migration, ...] = ALL_MIGRATIONS
|
|
50
|
+
) -> Store:
|
|
51
|
+
store = cls(path)
|
|
52
|
+
store.migrate(migrations)
|
|
53
|
+
return store
|
|
54
|
+
|
|
55
|
+
def migrate(self, migrations: tuple[Migration, ...] = ALL_MIGRATIONS) -> list[str]:
|
|
56
|
+
with self._lock:
|
|
57
|
+
return run_migrations(self._conn, migrations)
|
|
58
|
+
|
|
59
|
+
def migrate_down(
|
|
60
|
+
self, target_version: int, migrations: tuple[Migration, ...] = ALL_MIGRATIONS
|
|
61
|
+
) -> list[str]:
|
|
62
|
+
"""Roll the schema back to ``target_version`` (ADR-M4-5)."""
|
|
63
|
+
with self._lock:
|
|
64
|
+
return run_down_migrations(self._conn, migrations, target_version)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def schema_version(self) -> int | None:
|
|
68
|
+
with self._lock:
|
|
69
|
+
return current_version(self._conn)
|
|
70
|
+
|
|
71
|
+
@contextmanager
|
|
72
|
+
def write(self) -> Iterator[sqlite3.Connection]:
|
|
73
|
+
"""Single-writer transaction boundary; commits or rolls back atomically."""
|
|
74
|
+
try:
|
|
75
|
+
with self._lock, self._conn:
|
|
76
|
+
yield self._conn
|
|
77
|
+
except sqlite3.Error:
|
|
78
|
+
raise
|
|
79
|
+
|
|
80
|
+
def query(self, sql: str, params: tuple[object, ...] = ()) -> list[sqlite3.Row]:
|
|
81
|
+
with self._lock:
|
|
82
|
+
return list(self._conn.execute(sql, params))
|
|
83
|
+
|
|
84
|
+
def close(self) -> None:
|
|
85
|
+
self._conn.close()
|
|
86
|
+
|
|
87
|
+
# ── Run / Outcome persistence (ADR-M5-1) ──────────────────────────
|
|
88
|
+
|
|
89
|
+
def save_run_record(self, run: RunRecord) -> None:
|
|
90
|
+
"""Persist a RunRecord to the m5_runs table."""
|
|
91
|
+
with self.write() as conn:
|
|
92
|
+
conn.execute(
|
|
93
|
+
"""INSERT OR REPLACE INTO m5_runs
|
|
94
|
+
(id, experiment_name, spec_json, plan_json, seed,
|
|
95
|
+
status, environment_fingerprint, config_snapshot_id,
|
|
96
|
+
started_at, ended_at, description, verdict,
|
|
97
|
+
tags_json, extra_json)
|
|
98
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
99
|
+
(
|
|
100
|
+
run.run_id,
|
|
101
|
+
run.experiment_name,
|
|
102
|
+
run.spec_json,
|
|
103
|
+
run.plan_json,
|
|
104
|
+
run.seed,
|
|
105
|
+
run.status.value,
|
|
106
|
+
run.environment_fingerprint,
|
|
107
|
+
run.config_snapshot_id,
|
|
108
|
+
run.started_at,
|
|
109
|
+
run.ended_at,
|
|
110
|
+
run.description,
|
|
111
|
+
run.verdict.value,
|
|
112
|
+
json.dumps(list(run.tags)),
|
|
113
|
+
json.dumps(run.extra),
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def load_run_record(self, run_id: str) -> RunRecord | None:
|
|
118
|
+
"""Load a RunRecord by id, or None if absent."""
|
|
119
|
+
rows = self.query("SELECT * FROM m5_runs WHERE id = ?", (run_id,))
|
|
120
|
+
if not rows:
|
|
121
|
+
return None
|
|
122
|
+
row = rows[0]
|
|
123
|
+
return RunRecord(
|
|
124
|
+
run_id=row["id"],
|
|
125
|
+
experiment_name=row["experiment_name"],
|
|
126
|
+
spec_json=row["spec_json"],
|
|
127
|
+
plan_json=row["plan_json"],
|
|
128
|
+
seed=row["seed"],
|
|
129
|
+
status=RunStatus(row["status"]),
|
|
130
|
+
environment_fingerprint=row["environment_fingerprint"],
|
|
131
|
+
config_snapshot_id=row["config_snapshot_id"],
|
|
132
|
+
started_at=row["started_at"],
|
|
133
|
+
ended_at=row["ended_at"],
|
|
134
|
+
description=row["description"],
|
|
135
|
+
verdict=RunVerdict(row["verdict"]),
|
|
136
|
+
tags=tuple(json.loads(row["tags_json"])),
|
|
137
|
+
extra=json.loads(row["extra_json"]),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def save_outcome(self, outcome: Outcome) -> None:
|
|
141
|
+
"""Persist an Outcome to the m5_outcomes table."""
|
|
142
|
+
with self.write() as conn:
|
|
143
|
+
conn.execute(
|
|
144
|
+
"""INSERT OR REPLACE INTO m5_outcomes
|
|
145
|
+
(run_id, body_json, body_hash, checks_passed, checks_failed,
|
|
146
|
+
metric_deltas_json, residual_effect, stability_signal,
|
|
147
|
+
extra_json)
|
|
148
|
+
VALUES (?,?,?,?,?,?,?,?,?)""",
|
|
149
|
+
(
|
|
150
|
+
outcome.run_id,
|
|
151
|
+
outcome.body_json,
|
|
152
|
+
outcome.body_hash,
|
|
153
|
+
outcome.checks_passed,
|
|
154
|
+
outcome.checks_failed,
|
|
155
|
+
json.dumps(outcome.metric_deltas),
|
|
156
|
+
outcome.residual_effect,
|
|
157
|
+
outcome.stability_signal,
|
|
158
|
+
json.dumps(outcome.extra),
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def save_observation(
|
|
163
|
+
self,
|
|
164
|
+
kind: str,
|
|
165
|
+
*,
|
|
166
|
+
run_id: str = "",
|
|
167
|
+
source: str = "",
|
|
168
|
+
data: dict[str, object] | None = None,
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Persist one row to the observations table (ADR-M5 phase-gated)."""
|
|
171
|
+
with self.write() as conn:
|
|
172
|
+
conn.execute(
|
|
173
|
+
"""INSERT INTO observations (kind, run_id, source, data_json, timestamp)
|
|
174
|
+
VALUES (?,?,?,?,?)""",
|
|
175
|
+
(
|
|
176
|
+
kind,
|
|
177
|
+
run_id,
|
|
178
|
+
source,
|
|
179
|
+
json.dumps(data or {}),
|
|
180
|
+
utc_now().isoformat(),
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def load_outcome(self, run_id: str) -> Outcome | None:
|
|
185
|
+
"""Load an Outcome by run_id, or None if absent."""
|
|
186
|
+
rows = self.query("SELECT * FROM m5_outcomes WHERE run_id = ?", (run_id,))
|
|
187
|
+
if not rows:
|
|
188
|
+
return None
|
|
189
|
+
row = rows[0]
|
|
190
|
+
return Outcome(
|
|
191
|
+
run_id=row["run_id"],
|
|
192
|
+
body_json=row["body_json"],
|
|
193
|
+
body_hash=row["body_hash"],
|
|
194
|
+
checks_passed=row["checks_passed"],
|
|
195
|
+
checks_failed=row["checks_failed"],
|
|
196
|
+
metric_deltas=json.loads(row["metric_deltas_json"]),
|
|
197
|
+
residual_effect=row["residual_effect"],
|
|
198
|
+
stability_signal=row["stability_signal"],
|
|
199
|
+
extra=json.loads(row["extra_json"]),
|
|
200
|
+
)
|
mayhem/py.typed
ADDED
|
File without changes
|
mayhem/spec.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""YAML drill-spec loading — the authored entry point into the domain.
|
|
2
|
+
|
|
3
|
+
Spec files stay close to the domain models: keys map 1:1 onto pydantic
|
|
4
|
+
fields (durations as ``10s`` strings, enums as lowercase values), so the
|
|
5
|
+
loader is a thin validate-and-discriminate layer, not a second language.
|
|
6
|
+
|
|
7
|
+
``load_drill`` / ``parse_drill`` handle the ``kind: drill`` format (ADR-0019)
|
|
8
|
+
— the only supported spec kind since the clean break (ADR-0021).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
from pydantic import ValidationError
|
|
18
|
+
|
|
19
|
+
from mayhem.domain.errors import SchemaValidationError
|
|
20
|
+
from mayhem.domain.experiments import DrillSpec
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _flatten(exc: ValidationError) -> str:
|
|
24
|
+
parts = []
|
|
25
|
+
for err in exc.errors()[:8]:
|
|
26
|
+
loc = ".".join(str(part) for part in err["loc"]) or "<root>"
|
|
27
|
+
parts.append(f"{loc}: {err['msg']}")
|
|
28
|
+
return "; ".join(parts)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_drill(path: str | Path) -> DrillSpec:
|
|
32
|
+
"""Load a drill spec from YAML; refuse anything the domain refuses."""
|
|
33
|
+
raw_path = Path(path)
|
|
34
|
+
if not raw_path.is_file():
|
|
35
|
+
raise FileNotFoundError(f"spec file not found: {raw_path}")
|
|
36
|
+
try:
|
|
37
|
+
data = yaml.safe_load(raw_path.read_text(encoding="utf-8"))
|
|
38
|
+
except yaml.YAMLError as exc:
|
|
39
|
+
raise SchemaValidationError("drill", f"invalid YAML: {exc}") from None
|
|
40
|
+
return parse_drill(data)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_drill(data: Any) -> DrillSpec:
|
|
44
|
+
"""Parse raw YAML data into a DrillSpec."""
|
|
45
|
+
if not isinstance(data, dict):
|
|
46
|
+
raise SchemaValidationError("drill", "top level must be a mapping")
|
|
47
|
+
if data.get("kind") != "drill":
|
|
48
|
+
raise SchemaValidationError("drill", f"expected kind: drill, got: {data.get('kind')}")
|
|
49
|
+
try:
|
|
50
|
+
return DrillSpec.model_validate(data)
|
|
51
|
+
except ValidationError as exc:
|
|
52
|
+
raise SchemaValidationError("drill", _flatten(exc)) from None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared toolkit primitives (Phase 2): tool_runner, snapshots, fingerprinting."""
|