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.
- gauntlet/__init__.py +60 -0
- gauntlet/aggregate/__init__.py +51 -0
- gauntlet/aggregate/analyze.py +394 -0
- gauntlet/aggregate/cli.py +125 -0
- gauntlet/aggregate/fleet_clustering.py +793 -0
- gauntlet/aggregate/html.py +102 -0
- gauntlet/aggregate/schema.py +104 -0
- gauntlet/aggregate/sim_real.py +358 -0
- gauntlet/aggregate/templates/__init__.py +1 -0
- gauntlet/aggregate/templates/fleet_report.html.jinja +346 -0
- gauntlet/bisect/__init__.py +64 -0
- gauntlet/bisect/bisect.py +464 -0
- gauntlet/bisect/cli.py +330 -0
- gauntlet/cli.py +3689 -0
- gauntlet/compare/__init__.py +43 -0
- gauntlet/compare/drift_map.py +270 -0
- gauntlet/compare/github_summary.py +139 -0
- gauntlet/dashboard/__init__.py +30 -0
- gauntlet/dashboard/build.py +322 -0
- gauntlet/dashboard/static/__init__.py +1 -0
- gauntlet/dashboard/static/dashboard.css +187 -0
- gauntlet/dashboard/static/dashboard.js +199 -0
- gauntlet/dashboard/templates/__init__.py +1 -0
- gauntlet/dashboard/templates/dashboard.html.jinja +158 -0
- gauntlet/diff/__init__.py +61 -0
- gauntlet/diff/diff.py +457 -0
- gauntlet/diff/paired.py +542 -0
- gauntlet/diff/render.py +229 -0
- gauntlet/env/__init__.py +57 -0
- gauntlet/env/assets/objects/banana.xml +8 -0
- gauntlet/env/assets/objects/bottle.xml +7 -0
- gauntlet/env/assets/objects/mug.xml +9 -0
- gauntlet/env/assets/objects/screwdriver.xml +7 -0
- gauntlet/env/assets/tabletop.xml +122 -0
- gauntlet/env/assets/tabletop_stack.xml +66 -0
- gauntlet/env/base.py +269 -0
- gauntlet/env/color_attack.py +409 -0
- gauntlet/env/genesis/__init__.py +80 -0
- gauntlet/env/genesis/tabletop_genesis.py +853 -0
- gauntlet/env/gym_registration.py +126 -0
- gauntlet/env/image_attack.py +397 -0
- gauntlet/env/instruction.py +262 -0
- gauntlet/env/isaac/__init__.py +62 -0
- gauntlet/env/isaac/tabletop_isaac.py +709 -0
- gauntlet/env/mobile.py +294 -0
- gauntlet/env/perturbation/__init__.py +151 -0
- gauntlet/env/perturbation/axes.py +697 -0
- gauntlet/env/perturbation/base.py +152 -0
- gauntlet/env/pybullet/__init__.py +56 -0
- gauntlet/env/pybullet/assets/cube_alt.png +0 -0
- gauntlet/env/pybullet/assets/cube_default.png +0 -0
- gauntlet/env/pybullet/tabletop_pybullet.py +1235 -0
- gauntlet/env/registry.py +127 -0
- gauntlet/env/tabletop.py +1533 -0
- gauntlet/env/tabletop_stack.py +613 -0
- gauntlet/monitor/__init__.py +82 -0
- gauntlet/monitor/ae.py +275 -0
- gauntlet/monitor/conformal.py +231 -0
- gauntlet/monitor/entropy.py +85 -0
- gauntlet/monitor/schema.py +102 -0
- gauntlet/monitor/score.py +198 -0
- gauntlet/monitor/train.py +229 -0
- gauntlet/plugins.py +312 -0
- gauntlet/policy/__init__.py +72 -0
- gauntlet/policy/base.py +120 -0
- gauntlet/policy/dt.py +239 -0
- gauntlet/policy/groot.py +203 -0
- gauntlet/policy/huggingface.py +278 -0
- gauntlet/policy/lerobot.py +466 -0
- gauntlet/policy/pi0.py +250 -0
- gauntlet/policy/random.py +103 -0
- gauntlet/policy/rdt.py +177 -0
- gauntlet/policy/registry.py +219 -0
- gauntlet/policy/scripted.py +99 -0
- gauntlet/py.typed +0 -0
- gauntlet/realsim/__init__.py +112 -0
- gauntlet/realsim/io.py +189 -0
- gauntlet/realsim/pipeline.py +337 -0
- gauntlet/realsim/renderer.py +187 -0
- gauntlet/realsim/renderers/__init__.py +40 -0
- gauntlet/realsim/renderers/gsplat.py +181 -0
- gauntlet/realsim/renderers/nearest_frame.py +160 -0
- gauntlet/realsim/scene_input.py +509 -0
- gauntlet/realsim/scene_to_axis.py +210 -0
- gauntlet/realsim/schema.py +287 -0
- gauntlet/replay/__init__.py +32 -0
- gauntlet/replay/overrides.py +170 -0
- gauntlet/replay/replay.py +262 -0
- gauntlet/report/__init__.py +55 -0
- gauntlet/report/abstention.py +134 -0
- gauntlet/report/analyze.py +682 -0
- gauntlet/report/html.py +211 -0
- gauntlet/report/junit.py +93 -0
- gauntlet/report/schema.py +384 -0
- gauntlet/report/sobol_indices.py +182 -0
- gauntlet/report/templates/__init__.py +1 -0
- gauntlet/report/templates/report.html.jinja +957 -0
- gauntlet/report/trajectory_taxonomy.py +529 -0
- gauntlet/report/wilson.py +293 -0
- gauntlet/ros2/__init__.py +78 -0
- gauntlet/ros2/publisher.py +175 -0
- gauntlet/ros2/recorder.py +235 -0
- gauntlet/ros2/schema.py +95 -0
- gauntlet/runner/__init__.py +72 -0
- gauntlet/runner/cache.py +373 -0
- gauntlet/runner/determinism.py +403 -0
- gauntlet/runner/episode.py +473 -0
- gauntlet/runner/parquet.py +189 -0
- gauntlet/runner/provenance.py +268 -0
- gauntlet/runner/runner.py +927 -0
- gauntlet/runner/sinks.py +248 -0
- gauntlet/runner/video.py +222 -0
- gauntlet/runner/worker.py +1128 -0
- gauntlet/security/__init__.py +38 -0
- gauntlet/security/paths.py +147 -0
- gauntlet/security/yaml_guard.py +81 -0
- gauntlet/suite/__init__.py +47 -0
- gauntlet/suite/adversarial.py +300 -0
- gauntlet/suite/lhs.py +179 -0
- gauntlet/suite/linter.py +389 -0
- gauntlet/suite/loader.py +318 -0
- gauntlet/suite/sampling.py +238 -0
- gauntlet/suite/schema.py +925 -0
- gauntlet/suite/sobol.py +318 -0
- gauntlet/suite/worst_case.py +403 -0
- gauntlet_robotics-0.2.0.dist-info/METADATA +628 -0
- gauntlet_robotics-0.2.0.dist-info/RECORD +129 -0
- gauntlet_robotics-0.2.0.dist-info/WHEEL +4 -0
- 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
|
+
)
|