gauntlet-robotics 0.2.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 (129) hide show
  1. gauntlet/__init__.py +60 -0
  2. gauntlet/aggregate/__init__.py +51 -0
  3. gauntlet/aggregate/analyze.py +394 -0
  4. gauntlet/aggregate/cli.py +125 -0
  5. gauntlet/aggregate/fleet_clustering.py +793 -0
  6. gauntlet/aggregate/html.py +102 -0
  7. gauntlet/aggregate/schema.py +104 -0
  8. gauntlet/aggregate/sim_real.py +358 -0
  9. gauntlet/aggregate/templates/__init__.py +1 -0
  10. gauntlet/aggregate/templates/fleet_report.html.jinja +346 -0
  11. gauntlet/bisect/__init__.py +64 -0
  12. gauntlet/bisect/bisect.py +464 -0
  13. gauntlet/bisect/cli.py +330 -0
  14. gauntlet/cli.py +3689 -0
  15. gauntlet/compare/__init__.py +43 -0
  16. gauntlet/compare/drift_map.py +270 -0
  17. gauntlet/compare/github_summary.py +139 -0
  18. gauntlet/dashboard/__init__.py +30 -0
  19. gauntlet/dashboard/build.py +322 -0
  20. gauntlet/dashboard/static/__init__.py +1 -0
  21. gauntlet/dashboard/static/dashboard.css +187 -0
  22. gauntlet/dashboard/static/dashboard.js +199 -0
  23. gauntlet/dashboard/templates/__init__.py +1 -0
  24. gauntlet/dashboard/templates/dashboard.html.jinja +158 -0
  25. gauntlet/diff/__init__.py +61 -0
  26. gauntlet/diff/diff.py +457 -0
  27. gauntlet/diff/paired.py +542 -0
  28. gauntlet/diff/render.py +229 -0
  29. gauntlet/env/__init__.py +57 -0
  30. gauntlet/env/assets/objects/banana.xml +8 -0
  31. gauntlet/env/assets/objects/bottle.xml +7 -0
  32. gauntlet/env/assets/objects/mug.xml +9 -0
  33. gauntlet/env/assets/objects/screwdriver.xml +7 -0
  34. gauntlet/env/assets/tabletop.xml +122 -0
  35. gauntlet/env/assets/tabletop_stack.xml +66 -0
  36. gauntlet/env/base.py +269 -0
  37. gauntlet/env/color_attack.py +409 -0
  38. gauntlet/env/genesis/__init__.py +80 -0
  39. gauntlet/env/genesis/tabletop_genesis.py +853 -0
  40. gauntlet/env/gym_registration.py +126 -0
  41. gauntlet/env/image_attack.py +397 -0
  42. gauntlet/env/instruction.py +262 -0
  43. gauntlet/env/isaac/__init__.py +62 -0
  44. gauntlet/env/isaac/tabletop_isaac.py +709 -0
  45. gauntlet/env/mobile.py +294 -0
  46. gauntlet/env/perturbation/__init__.py +151 -0
  47. gauntlet/env/perturbation/axes.py +697 -0
  48. gauntlet/env/perturbation/base.py +152 -0
  49. gauntlet/env/pybullet/__init__.py +56 -0
  50. gauntlet/env/pybullet/assets/cube_alt.png +0 -0
  51. gauntlet/env/pybullet/assets/cube_default.png +0 -0
  52. gauntlet/env/pybullet/tabletop_pybullet.py +1235 -0
  53. gauntlet/env/registry.py +127 -0
  54. gauntlet/env/tabletop.py +1533 -0
  55. gauntlet/env/tabletop_stack.py +613 -0
  56. gauntlet/monitor/__init__.py +82 -0
  57. gauntlet/monitor/ae.py +275 -0
  58. gauntlet/monitor/conformal.py +231 -0
  59. gauntlet/monitor/entropy.py +85 -0
  60. gauntlet/monitor/schema.py +102 -0
  61. gauntlet/monitor/score.py +198 -0
  62. gauntlet/monitor/train.py +229 -0
  63. gauntlet/plugins.py +312 -0
  64. gauntlet/policy/__init__.py +72 -0
  65. gauntlet/policy/base.py +120 -0
  66. gauntlet/policy/dt.py +239 -0
  67. gauntlet/policy/groot.py +203 -0
  68. gauntlet/policy/huggingface.py +278 -0
  69. gauntlet/policy/lerobot.py +466 -0
  70. gauntlet/policy/pi0.py +250 -0
  71. gauntlet/policy/random.py +103 -0
  72. gauntlet/policy/rdt.py +177 -0
  73. gauntlet/policy/registry.py +219 -0
  74. gauntlet/policy/scripted.py +99 -0
  75. gauntlet/py.typed +0 -0
  76. gauntlet/realsim/__init__.py +112 -0
  77. gauntlet/realsim/io.py +189 -0
  78. gauntlet/realsim/pipeline.py +337 -0
  79. gauntlet/realsim/renderer.py +187 -0
  80. gauntlet/realsim/renderers/__init__.py +40 -0
  81. gauntlet/realsim/renderers/gsplat.py +181 -0
  82. gauntlet/realsim/renderers/nearest_frame.py +160 -0
  83. gauntlet/realsim/scene_input.py +509 -0
  84. gauntlet/realsim/scene_to_axis.py +210 -0
  85. gauntlet/realsim/schema.py +287 -0
  86. gauntlet/replay/__init__.py +32 -0
  87. gauntlet/replay/overrides.py +170 -0
  88. gauntlet/replay/replay.py +262 -0
  89. gauntlet/report/__init__.py +55 -0
  90. gauntlet/report/abstention.py +134 -0
  91. gauntlet/report/analyze.py +682 -0
  92. gauntlet/report/html.py +211 -0
  93. gauntlet/report/junit.py +93 -0
  94. gauntlet/report/schema.py +384 -0
  95. gauntlet/report/sobol_indices.py +182 -0
  96. gauntlet/report/templates/__init__.py +1 -0
  97. gauntlet/report/templates/report.html.jinja +957 -0
  98. gauntlet/report/trajectory_taxonomy.py +529 -0
  99. gauntlet/report/wilson.py +293 -0
  100. gauntlet/ros2/__init__.py +78 -0
  101. gauntlet/ros2/publisher.py +175 -0
  102. gauntlet/ros2/recorder.py +235 -0
  103. gauntlet/ros2/schema.py +95 -0
  104. gauntlet/runner/__init__.py +72 -0
  105. gauntlet/runner/cache.py +373 -0
  106. gauntlet/runner/determinism.py +403 -0
  107. gauntlet/runner/episode.py +473 -0
  108. gauntlet/runner/parquet.py +189 -0
  109. gauntlet/runner/provenance.py +268 -0
  110. gauntlet/runner/runner.py +927 -0
  111. gauntlet/runner/sinks.py +248 -0
  112. gauntlet/runner/video.py +222 -0
  113. gauntlet/runner/worker.py +1128 -0
  114. gauntlet/security/__init__.py +38 -0
  115. gauntlet/security/paths.py +147 -0
  116. gauntlet/security/yaml_guard.py +81 -0
  117. gauntlet/suite/__init__.py +47 -0
  118. gauntlet/suite/adversarial.py +300 -0
  119. gauntlet/suite/lhs.py +179 -0
  120. gauntlet/suite/linter.py +389 -0
  121. gauntlet/suite/loader.py +318 -0
  122. gauntlet/suite/sampling.py +238 -0
  123. gauntlet/suite/schema.py +925 -0
  124. gauntlet/suite/sobol.py +318 -0
  125. gauntlet/suite/worst_case.py +403 -0
  126. gauntlet_robotics-0.2.0.dist-info/METADATA +628 -0
  127. gauntlet_robotics-0.2.0.dist-info/RECORD +129 -0
  128. gauntlet_robotics-0.2.0.dist-info/WHEEL +4 -0
  129. gauntlet_robotics-0.2.0.dist-info/entry_points.txt +22 -0
gauntlet/__init__.py ADDED
@@ -0,0 +1,60 @@
1
+ """Gauntlet — an evaluation harness for learned robot policies.
2
+
3
+ Why this exists
4
+ ---------------
5
+ Learned manipulation policies (open-ended VLAs, behaviour-cloning baselines,
6
+ diffusion policies, RL agents) routinely advertise headline success rates
7
+ that quietly average across many easy initial conditions and a handful of
8
+ catastrophic failures. Gauntlet is the harness that takes a policy plus a
9
+ declarative perturbation grid (a *suite*) and produces the breakdown that
10
+ hides behind the mean: per-axis marginals, per-cell aggregates, failure
11
+ clusters, 2D heatmaps, and an HTML artifact you can hand to a colleague.
12
+
13
+ Top-level layout
14
+ ----------------
15
+ The public API is grouped into single-purpose subpackages:
16
+
17
+ * :mod:`gauntlet.env` — environment Protocol and built-in MuJoCo backend.
18
+ The ``tabletop-pybullet`` / ``tabletop-genesis`` / ``tabletop-isaac``
19
+ backends live in nested subpackages and register on demand.
20
+ * :mod:`gauntlet.policy` — :class:`~gauntlet.policy.Policy` Protocol plus
21
+ :class:`~gauntlet.policy.RandomPolicy`, :class:`~gauntlet.policy.ScriptedPolicy`,
22
+ and the lazy VLA adapters (:class:`~gauntlet.policy.HuggingFacePolicy`,
23
+ :class:`~gauntlet.policy.LeRobotPolicy`).
24
+ * :mod:`gauntlet.suite` — declarative YAML grid (:class:`~gauntlet.suite.Suite`)
25
+ and its loader.
26
+ * :mod:`gauntlet.runner` — parallel rollout orchestrator
27
+ (:class:`~gauntlet.runner.Runner`) producing
28
+ :class:`~gauntlet.runner.Episode` records.
29
+ * :mod:`gauntlet.report` — failure-analysis schema + HTML renderer
30
+ (:func:`~gauntlet.report.build_report`, :func:`~gauntlet.report.write_html`).
31
+ * :mod:`gauntlet.replay` — single-episode re-simulation with optional
32
+ axis overrides.
33
+ * :mod:`gauntlet.monitor` — runtime drift detector (torch-backed, opt-in
34
+ via the ``[monitor]`` extra).
35
+ * :mod:`gauntlet.ros2` — ROS 2 publisher / recorder bridges (rclpy-backed,
36
+ opt-in via the ``[ros2]`` extra).
37
+ * :mod:`gauntlet.cli` — ``gauntlet`` command-line entry point that wires
38
+ the subpackages above into ``run`` / ``report`` / ``compare`` / ``replay``
39
+ / ``monitor`` / ``ros2`` subcommands.
40
+
41
+ See ``GAUNTLET_SPEC.md`` for the canonical design vocabulary, the
42
+ seven-axis perturbation surface, and the §6 hard rules (reproducibility,
43
+ small deps, never aggregate away failures) that every subpackage is
44
+ written against.
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ from gauntlet.env.gym_registration import register_envs
50
+
51
+ __version__ = "0.2.0"
52
+
53
+ # Register the four shipped backends with gymnasium's global registry on
54
+ # package import — the standard gymnasium-ecosystem convention. Heavy
55
+ # backends use string ``entry_point``s so this call does NOT pull in
56
+ # pybullet / genesis / isaacsim. Idempotent: safe to call again from
57
+ # tests, multiprocessing workers, or user code.
58
+ register_envs()
59
+
60
+ __all__ = ["__version__", "register_envs"]
@@ -0,0 +1,51 @@
1
+ """Fleet-wide aggregation — see ``GAUNTLET_SPEC.md`` §7 and
2
+ ``docs/phase3-rfc-019-fleet-aggregate.md``.
3
+
4
+ Public surface (built up across commits — schema, then aggregation,
5
+ then HTML rendering):
6
+
7
+ * :class:`FleetRun` — one row of the fleet roll-up (per discovered
8
+ ``report.json``).
9
+ * :class:`FleetReport` — the meta-report aggregated across N runs.
10
+ * :func:`aggregate_reports` — pure function ``list[Report] -> FleetReport``.
11
+ * :func:`aggregate_directory` — discovery + aggregation in one call.
12
+ * :func:`discover_run_files` — recursive ``report.json`` glob.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from gauntlet.aggregate.analyze import aggregate_directory as aggregate_directory
18
+ from gauntlet.aggregate.analyze import aggregate_reports as aggregate_reports
19
+ from gauntlet.aggregate.analyze import discover_run_files as discover_run_files
20
+ from gauntlet.aggregate.fleet_clustering import FleetCluster as FleetCluster
21
+ from gauntlet.aggregate.fleet_clustering import (
22
+ FleetClusteringResult as FleetClusteringResult,
23
+ )
24
+ from gauntlet.aggregate.fleet_clustering import (
25
+ cluster_fleet_failures as cluster_fleet_failures,
26
+ )
27
+ from gauntlet.aggregate.html import render_fleet_html as render_fleet_html
28
+ from gauntlet.aggregate.html import write_fleet_html as write_fleet_html
29
+ from gauntlet.aggregate.schema import FleetReport as FleetReport
30
+ from gauntlet.aggregate.schema import FleetRun as FleetRun
31
+ from gauntlet.aggregate.sim_real import AxisTransfer as AxisTransfer
32
+ from gauntlet.aggregate.sim_real import SimRealReport as SimRealReport
33
+ from gauntlet.aggregate.sim_real import (
34
+ compute_sim_real_correlation as compute_sim_real_correlation,
35
+ )
36
+
37
+ __all__ = [
38
+ "AxisTransfer",
39
+ "FleetCluster",
40
+ "FleetClusteringResult",
41
+ "FleetReport",
42
+ "FleetRun",
43
+ "SimRealReport",
44
+ "aggregate_directory",
45
+ "aggregate_reports",
46
+ "cluster_fleet_failures",
47
+ "compute_sim_real_correlation",
48
+ "discover_run_files",
49
+ "render_fleet_html",
50
+ "write_fleet_html",
51
+ ]
@@ -0,0 +1,394 @@
1
+ """Pure-function aggregation across a list of :class:`Report` objects.
2
+
3
+ The single public entry points are:
4
+
5
+ * :func:`discover_run_files` — recursive ``report.json`` glob.
6
+ * :func:`aggregate_reports` — pure transform from per-run reports to
7
+ the fleet-level :class:`FleetReport`.
8
+ * :func:`aggregate_directory` — convenience: discover + load + aggregate.
9
+
10
+ Everything else is an internal helper. See
11
+ ``docs/phase3-rfc-019-fleet-aggregate.md`` for the algorithm.
12
+
13
+ This module performs *no* I/O beyond the directory glob and per-file
14
+ ``open`` in :func:`aggregate_directory`. The pure
15
+ :func:`aggregate_reports` step is testable without touching the
16
+ filesystem and is what the CLI subcommand ultimately drives.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import statistics
23
+ from collections import defaultdict
24
+ from collections.abc import Iterable
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+
28
+ from pydantic import ValidationError
29
+
30
+ from gauntlet.aggregate.schema import FleetReport, FleetRun
31
+ from gauntlet.report.analyze import _norm
32
+ from gauntlet.report.schema import AxisBreakdown, FailureCluster, Report
33
+
34
+ __all__ = [
35
+ "aggregate_directory",
36
+ "aggregate_reports",
37
+ "discover_run_files",
38
+ ]
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Discovery.
43
+ # ---------------------------------------------------------------------------
44
+
45
+
46
+ def discover_run_files(directory: Path) -> list[Path]:
47
+ """Recursively find files literally named ``report.json``.
48
+
49
+ Returns an absolute, sorted list of matches. Sort order is ``Path``-
50
+ natural (the Python default lexical sort), which makes downstream
51
+ aggregation deterministic.
52
+
53
+ Raises:
54
+ FileNotFoundError: if *directory* does not exist or is not a
55
+ directory.
56
+ """
57
+ if not directory.is_dir():
58
+ raise FileNotFoundError(f"not a directory: {directory}")
59
+ matches = list(directory.rglob("report.json"))
60
+ return sorted(matches)
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Aggregation primitives.
65
+ # ---------------------------------------------------------------------------
66
+
67
+
68
+ def _ordered_axis_names(reports: Iterable[Report]) -> tuple[str, ...]:
69
+ """Union of per-report ``per_axis`` names in first-appearance order.
70
+
71
+ Mirrors :func:`gauntlet.report.analyze._ordered_axis_names` — Python
72
+ dict preserves insertion order so ``dict.fromkeys`` yields a stable,
73
+ deterministic ordering without an explicit ``seen`` set.
74
+ """
75
+ seen: dict[str, None] = {}
76
+ for rep in reports:
77
+ for ab in rep.per_axis:
78
+ if ab.name not in seen:
79
+ seen[ab.name] = None
80
+ return tuple(seen.keys())
81
+
82
+
83
+ def _aggregate_per_axis(
84
+ reports: list[Report],
85
+ axis_names: tuple[str, ...],
86
+ ) -> dict[str, AxisBreakdown]:
87
+ """Sum per-axis ``counts`` / ``successes`` across runs and recompute rates.
88
+
89
+ Float keys are normalised through the shared ``_norm`` helper so
90
+ 1e-15 jitter doesn't split a bucket across two reports.
91
+ """
92
+ out: dict[str, AxisBreakdown] = {}
93
+ for axis in axis_names:
94
+ counts: dict[float, int] = defaultdict(int)
95
+ successes: dict[float, int] = defaultdict(int)
96
+ for rep in reports:
97
+ ab = next((a for a in rep.per_axis if a.name == axis), None)
98
+ if ab is None:
99
+ continue
100
+ for v, c in ab.counts.items():
101
+ counts[_norm(v)] += c
102
+ for v, s in ab.successes.items():
103
+ successes[_norm(v)] += s
104
+ sorted_keys = sorted(counts.keys())
105
+ rates: dict[float, float] = {}
106
+ for k in sorted_keys:
107
+ n = counts[k]
108
+ rates[k] = successes[k] / n if n > 0 else float("nan")
109
+ out[axis] = AxisBreakdown(
110
+ name=axis,
111
+ rates=rates,
112
+ counts={k: counts[k] for k in sorted_keys},
113
+ successes={k: successes[k] for k in sorted_keys},
114
+ )
115
+ return out
116
+
117
+
118
+ def _cluster_fingerprint(
119
+ cluster: FailureCluster,
120
+ ) -> tuple[tuple[str, float], ...]:
121
+ """Stable fingerprint for a :class:`FailureCluster`.
122
+
123
+ Sort by axis-name so two clusters with the same (name, value) pairs
124
+ in different insertion orders match. Float values are normalised
125
+ through ``_norm``. Returns a ``tuple`` (hashable) so it can key a
126
+ ``dict``.
127
+ """
128
+ return tuple(sorted((name, _norm(value)) for name, value in cluster.axes.items()))
129
+
130
+
131
+ @dataclass
132
+ class _ClusterAccumulator:
133
+ """Internal scratch type — pools per-run cluster stats by fingerprint.
134
+
135
+ Held as the value type of the ``pooled`` dict in
136
+ :func:`_persistent_failure_clusters`. Defined as a dataclass (not
137
+ a TypedDict) so mypy --strict can narrow the int / dict fields
138
+ without per-line ``type: ignore`` casts.
139
+ """
140
+
141
+ axes: dict[str, float]
142
+ n_episodes: int = 0
143
+ n_success: int = 0
144
+ appearances: int = 0
145
+
146
+
147
+ def _persistent_failure_clusters(
148
+ reports: list[Report],
149
+ *,
150
+ persistence_threshold: float,
151
+ fleet_baseline_failure_rate: float,
152
+ ) -> list[FailureCluster]:
153
+ """Return the fleet's persistent failure clusters.
154
+
155
+ A cluster fingerprint is "persistent" if it appears in at least
156
+ ``ceil(persistence_threshold * n_runs)`` runs (the test suite pins
157
+ that the comparison is ``>=``). For each persistent fingerprint,
158
+ ``n_episodes`` and ``n_success`` are SUMMED across the runs that
159
+ carried the cluster; ``failure_rate`` is recomputed from those
160
+ sums; ``lift`` is taken against the *fleet* baseline failure rate.
161
+
162
+ The output is sorted by ``lift`` desc then ``failure_rate`` desc,
163
+ matching :func:`gauntlet.report.analyze._failure_clusters` for
164
+ consistent presentation.
165
+
166
+ Empty result paths:
167
+
168
+ * ``n_runs == 0`` → empty list (also caught upstream).
169
+ * No cluster appears often enough → empty list.
170
+ * ``fleet_baseline_failure_rate <= 0.0`` → empty list (no failures
171
+ to lift against; mirrors the per-run "all-success" short-circuit).
172
+ """
173
+ if fleet_baseline_failure_rate <= 0.0:
174
+ return []
175
+ n_runs = len(reports)
176
+ if n_runs == 0:
177
+ return []
178
+
179
+ pooled: dict[tuple[tuple[str, float], ...], _ClusterAccumulator] = {}
180
+ for rep in reports:
181
+ # Each run's cluster set may carry the same fingerprint multiple
182
+ # times only when build_report mis-emits — in practice
183
+ # build_report dedupes; the per-run set is a defensive guard.
184
+ seen_in_run: set[tuple[tuple[str, float], ...]] = set()
185
+ for cluster in rep.failure_clusters:
186
+ fp = _cluster_fingerprint(cluster)
187
+ entry = pooled.setdefault(fp, _ClusterAccumulator(axes=dict(fp)))
188
+ entry.n_episodes += cluster.n_episodes
189
+ entry.n_success += cluster.n_success
190
+ if fp not in seen_in_run:
191
+ entry.appearances += 1
192
+ seen_in_run.add(fp)
193
+
194
+ threshold_count = persistence_threshold * n_runs
195
+ persistent: list[FailureCluster] = []
196
+ for entry in pooled.values():
197
+ if entry.appearances < threshold_count:
198
+ continue
199
+ if entry.n_episodes == 0:
200
+ continue
201
+ failure_rate = (entry.n_episodes - entry.n_success) / entry.n_episodes
202
+ lift = failure_rate / fleet_baseline_failure_rate
203
+ persistent.append(
204
+ FailureCluster(
205
+ axes=dict(entry.axes),
206
+ n_episodes=entry.n_episodes,
207
+ n_success=entry.n_success,
208
+ failure_rate=failure_rate,
209
+ lift=lift,
210
+ )
211
+ )
212
+
213
+ persistent.sort(key=lambda c: (-c.lift, -c.failure_rate))
214
+ return persistent
215
+
216
+
217
+ def _cross_run_success_distribution(
218
+ reports: list[Report],
219
+ ) -> dict[str, list[float]]:
220
+ """Group each run's overall success rate by ``suite_name``.
221
+
222
+ Order within each bucket matches the *input* order of ``reports``
223
+ (which is sorted by source path under :func:`aggregate_directory`),
224
+ so two aggregations of the same directory produce byte-identical
225
+ output.
226
+ """
227
+ out: dict[str, list[float]] = defaultdict(list)
228
+ for rep in reports:
229
+ out[rep.suite_name].append(rep.overall_success_rate)
230
+ # Convert defaultdict → dict so the schema's strict dict typing
231
+ # holds and so re-entering this dict elsewhere doesn't auto-create
232
+ # missing keys.
233
+ return dict(out)
234
+
235
+
236
+ # ---------------------------------------------------------------------------
237
+ # Public aggregation entry points.
238
+ # ---------------------------------------------------------------------------
239
+
240
+
241
+ def aggregate_reports(
242
+ reports: list[Report],
243
+ *,
244
+ persistence_threshold: float = 0.5,
245
+ runs: list[FleetRun] | None = None,
246
+ ) -> FleetReport:
247
+ """Aggregate a list of per-run :class:`Report` into a :class:`FleetReport`.
248
+
249
+ Pure function — no I/O. ``runs`` is optional and threads through
250
+ the directory loader's :class:`FleetRun` rows so the
251
+ ``fleet_report.json`` carries the source-file metadata; passing
252
+ ``None`` synthesises minimal :class:`FleetRun` rows from each
253
+ report's ``suite_name`` / counts (no ``source_file`` to point at).
254
+
255
+ Args:
256
+ reports: per-run reports to aggregate. Must be non-empty.
257
+ persistence_threshold: cluster fingerprint must appear in at
258
+ least ``persistence_threshold * n_runs`` runs to be
259
+ included. Comparison is ``>=`` (the threshold value itself
260
+ is included). Must be in ``[0.0, 1.0]``.
261
+ runs: optional :class:`FleetRun` rows already populated by the
262
+ directory loader. When ``None``, minimal rows are derived
263
+ from each report.
264
+
265
+ Returns:
266
+ A fully populated :class:`FleetReport`.
267
+
268
+ Raises:
269
+ ValueError: if ``reports`` is empty or
270
+ ``persistence_threshold`` is outside ``[0, 1]``.
271
+ """
272
+ if not 0.0 <= persistence_threshold <= 1.0:
273
+ raise ValueError(
274
+ f"persistence_threshold must be in [0.0, 1.0]; got {persistence_threshold}",
275
+ )
276
+ if len(reports) == 0:
277
+ raise ValueError("cannot aggregate zero reports")
278
+
279
+ n_runs = len(reports)
280
+ n_total_episodes = sum(r.n_episodes for r in reports)
281
+ n_total_success = sum(r.n_success for r in reports)
282
+ fleet_failure_rate = (
283
+ (n_total_episodes - n_total_success) / n_total_episodes if n_total_episodes > 0 else 0.0
284
+ )
285
+
286
+ axis_names = _ordered_axis_names(reports)
287
+ per_axis_aggregate = _aggregate_per_axis(reports, axis_names)
288
+ persistent_clusters = _persistent_failure_clusters(
289
+ reports,
290
+ persistence_threshold=persistence_threshold,
291
+ fleet_baseline_failure_rate=fleet_failure_rate,
292
+ )
293
+ distribution = _cross_run_success_distribution(reports)
294
+
295
+ rates = [r.overall_success_rate for r in reports]
296
+ mean_rate = statistics.fmean(rates)
297
+ std_rate = statistics.pstdev(rates) if n_runs > 1 else 0.0
298
+
299
+ if runs is None:
300
+ runs = [
301
+ FleetRun(
302
+ run_id=f"run-{i:04d}",
303
+ policy_label=f"run-{i:04d}",
304
+ suite_name=rep.suite_name,
305
+ suite_env=rep.suite_env,
306
+ n_episodes=rep.n_episodes,
307
+ n_success=rep.n_success,
308
+ success_rate=rep.overall_success_rate,
309
+ source_file="",
310
+ )
311
+ for i, rep in enumerate(reports)
312
+ ]
313
+
314
+ suite_names = sorted({r.suite_name for r in reports})
315
+
316
+ return FleetReport(
317
+ runs=runs,
318
+ n_runs=n_runs,
319
+ n_total_episodes=n_total_episodes,
320
+ per_axis_aggregate=per_axis_aggregate,
321
+ persistent_failure_clusters=persistent_clusters,
322
+ cross_run_success_distribution=distribution,
323
+ persistence_threshold=persistence_threshold,
324
+ fleet_baseline_failure_rate=fleet_failure_rate,
325
+ mean_success_rate=mean_rate,
326
+ std_success_rate=std_rate,
327
+ suite_names=suite_names,
328
+ )
329
+
330
+
331
+ def aggregate_directory(
332
+ directory: Path,
333
+ *,
334
+ persistence_threshold: float = 0.5,
335
+ ) -> FleetReport:
336
+ """Discover ``report.json`` files under *directory* and aggregate them.
337
+
338
+ Builds :class:`FleetRun` rows whose ``source_file`` is the path
339
+ *relative to* ``directory`` (RFC §3) so the resulting
340
+ ``fleet_report.json`` is movable.
341
+
342
+ Args:
343
+ directory: scan root.
344
+ persistence_threshold: see :func:`aggregate_reports`.
345
+
346
+ Returns:
347
+ A fully populated :class:`FleetReport`.
348
+
349
+ Raises:
350
+ FileNotFoundError: if *directory* does not exist.
351
+ ValueError: if no ``report.json`` files are found, if any
352
+ file is malformed JSON, or if any file fails Pydantic
353
+ validation as a :class:`Report`.
354
+ """
355
+ files = discover_run_files(directory)
356
+ if not files:
357
+ raise ValueError(f"no report.json files found under {directory}")
358
+
359
+ reports: list[Report] = []
360
+ runs: list[FleetRun] = []
361
+ for path in files:
362
+ rel = path.relative_to(directory).as_posix()
363
+ try:
364
+ raw = json.loads(path.read_text(encoding="utf-8"))
365
+ except json.JSONDecodeError as exc:
366
+ raise ValueError(f"{rel}: invalid JSON ({exc.msg} at line {exc.lineno})") from exc
367
+ try:
368
+ report = Report.model_validate(raw)
369
+ except ValidationError as exc:
370
+ raise ValueError(f"{rel}: not a valid report.json: {exc}") from exc
371
+ reports.append(report)
372
+ # ``policy_label`` and ``run_id`` derive from the run dir name
373
+ # (the parent of ``report.json``). For files placed directly in
374
+ # ``directory`` the parent is ``"."`` — use the file stem as
375
+ # a fallback so the label is never empty.
376
+ parent = path.parent.name or path.stem
377
+ runs.append(
378
+ FleetRun(
379
+ run_id=parent,
380
+ policy_label=parent,
381
+ suite_name=report.suite_name,
382
+ suite_env=report.suite_env,
383
+ n_episodes=report.n_episodes,
384
+ n_success=report.n_success,
385
+ success_rate=report.overall_success_rate,
386
+ source_file=rel,
387
+ )
388
+ )
389
+
390
+ return aggregate_reports(
391
+ reports,
392
+ persistence_threshold=persistence_threshold,
393
+ runs=runs,
394
+ )
@@ -0,0 +1,125 @@
1
+ """CLI helpers for ``gauntlet aggregate`` — Phase 3 Task 19.
2
+
3
+ Mirrors the layout of :func:`gauntlet.diff._build_diff` and the
4
+ ``gauntlet.compare`` helpers: the @app.command shell stays in the top-
5
+ level :mod:`gauntlet.cli`, the per-subcommand business logic lives next
6
+ to its module surface so the test layer can drive it without spinning
7
+ up a Typer ``CliRunner``.
8
+
9
+ This module owns three things:
10
+
11
+ * :func:`build_cluster_payload` — pure function from a scan root +
12
+ ``max_clusters`` cap to the JSON-friendly cluster payload that
13
+ ``gauntlet aggregate --cluster-output`` writes. Wraps
14
+ :func:`gauntlet.aggregate.cluster_fleet_failures` and the private
15
+ ``_result_to_payload`` serialiser so the CLI stays a thin wrapper.
16
+ * :func:`write_cluster_json` — write the payload to disk. Lifted out so
17
+ the test layer can pin the exact ``json.dumps`` shape (sorted keys,
18
+ ``indent=2``, trailing newline) without re-implementing it inline.
19
+ * :func:`format_cluster_summary` — the one-line stderr summary the CLI
20
+ echoes after the artefact is written. Pure / testable in isolation
21
+ rather than buried inside the typer handler.
22
+
23
+ The actual ``@app.command("aggregate")`` Typer wiring stays in
24
+ ``gauntlet/cli.py`` — that's where the ``--out`` / ``--html`` /
25
+ ``--persistence-threshold`` flags live and the new clustering flags
26
+ slot in alongside them.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ from pathlib import Path
33
+ from typing import TypeAlias
34
+
35
+ from gauntlet.aggregate.fleet_clustering import (
36
+ FleetClusteringResult,
37
+ _result_to_payload,
38
+ cluster_fleet_failures,
39
+ )
40
+
41
+ __all__ = [
42
+ "build_cluster_payload",
43
+ "format_cluster_summary",
44
+ "write_cluster_json",
45
+ ]
46
+
47
+
48
+ # JSON-shape alias — recursive type for what ``json.dumps`` accepts.
49
+ # Identical to the alias in :mod:`gauntlet.aggregate.fleet_clustering`;
50
+ # kept local rather than imported so this CLI helper has no
51
+ # cross-module type dependency the linter would flag as unused.
52
+ _JsonValue: TypeAlias = (
53
+ float
54
+ | int
55
+ | str
56
+ | bool
57
+ | None
58
+ | dict[str, "_JsonValue"]
59
+ | list["_JsonValue"]
60
+ | tuple["_JsonValue", ...]
61
+ )
62
+
63
+
64
+ def build_cluster_payload(
65
+ directory: Path,
66
+ *,
67
+ max_clusters: int,
68
+ ) -> tuple[FleetClusteringResult, dict[str, _JsonValue]]:
69
+ """Run the clustering pipeline and return both the result + JSON payload.
70
+
71
+ Two-tuple return so the CLI can echo human-friendly counts (from
72
+ the structured :class:`FleetClusteringResult`) AND serialise the
73
+ JSON-friendly payload (from the dump dict) in one call.
74
+
75
+ Args:
76
+ directory: Scan root passed straight through to
77
+ :func:`cluster_fleet_failures`. Path validation /
78
+ sandboxing is the lower layer's responsibility — see
79
+ :func:`gauntlet.security.safe_join`.
80
+ max_clusters: Hard cap on the number of clusters to return.
81
+ ``< 1`` is rejected by the lower layer with
82
+ :class:`ValueError`.
83
+
84
+ Returns:
85
+ A ``(result, payload)`` tuple. ``payload`` is suitable for
86
+ ``json.dumps`` directly; ``result`` is the full
87
+ :class:`FleetClusteringResult` for callers that want to
88
+ consume it programmatically.
89
+ """
90
+ result = cluster_fleet_failures(directory, max_clusters=max_clusters)
91
+ payload = _result_to_payload(result)
92
+ return result, payload
93
+
94
+
95
+ def write_cluster_json(payload: dict[str, _JsonValue], path: Path) -> None:
96
+ """Write the cluster payload to ``path`` as pretty-printed JSON.
97
+
98
+ Format matches the rest of the harness's ``--out`` artefact
99
+ contract: ``indent=2``, sorted keys for byte-identical re-runs, and
100
+ a trailing newline so the file plays nicely with text-mode tools
101
+ that expect a final ``\\n``.
102
+
103
+ The parent directory is created if it doesn't exist — the CLI
104
+ handler accepts a path under a fresh ``--out`` directory and we
105
+ don't want to crash because ``mkdir -p`` was implicit.
106
+ """
107
+ path.parent.mkdir(parents=True, exist_ok=True)
108
+ encoded = json.dumps(payload, indent=2, sort_keys=True, allow_nan=False)
109
+ path.write_text(encoded + "\n", encoding="utf-8")
110
+
111
+
112
+ def format_cluster_summary(result: FleetClusteringResult) -> str:
113
+ """Render the one-line summary the CLI echoes to stderr.
114
+
115
+ Pure / testable. The format follows the rest of the aggregate
116
+ subcommand's stderr style — leading two-space indent, lowercase
117
+ label, parenthetical caveat for the silhouette score (which is
118
+ informational and may legitimately be ``None``).
119
+ """
120
+ n_clusters = len(result.clusters)
121
+ sil = "n/a" if result.silhouette is None else f"{result.silhouette:+.3f}"
122
+ return (
123
+ f" failure-mode clusters: {n_clusters} "
124
+ f"({result.n_unique_failures} unique signature(s); silhouette={sil})"
125
+ )