cotterbot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cotter/__init__.py +125 -0
- cotter/backends.py +125 -0
- cotter/cli.py +221 -0
- cotter/compliance/__init__.py +33 -0
- cotter/compliance/base.py +74 -0
- cotter/config.py +201 -0
- cotter/envs/__init__.py +0 -0
- cotter/envs/factory.py +31 -0
- cotter/envs/registry.py +37 -0
- cotter/envs/wrapper.py +73 -0
- cotter/pipeline.py +214 -0
- cotter/policy.py +170 -0
- cotter/report.py +168 -0
- cotter/runner.py +282 -0
- cotter/stats.py +35 -0
- cotter/success.py +92 -0
- cotter/tests/__init__.py +0 -0
- cotter/tests/adversarial.py +398 -0
- cotter/tests/regression.py +166 -0
- cotter/tests/safety.py +155 -0
- cotter/tests/sprt.py +177 -0
- cotter/zoo/__init__.py +13 -0
- cotter/zoo/registry.py +173 -0
- cotterbot-0.1.0.dist-info/METADATA +409 -0
- cotterbot-0.1.0.dist-info/RECORD +28 -0
- cotterbot-0.1.0.dist-info/WHEEL +4 -0
- cotterbot-0.1.0.dist-info/entry_points.txt +3 -0
- cotterbot-0.1.0.dist-info/licenses/LICENSE +21 -0
cotter/__init__.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Cotter — compliance testing for AI-controlled robot policies.
|
|
2
|
+
|
|
3
|
+
Load a trained policy as a black box, run it through standardized test
|
|
4
|
+
categories (performance, safety, regression, adversarial) in a MuJoCo
|
|
5
|
+
simulation, and get structured pass/fail results with statistical
|
|
6
|
+
guarantees.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from cotter.envs.wrapper import (
|
|
10
|
+
ACTUATOR_FORCES,
|
|
11
|
+
CONTACT_COUNT,
|
|
12
|
+
CONTACT_FORCES,
|
|
13
|
+
INSTRUMENTED_KEYS,
|
|
14
|
+
JOINT_VELOCITIES,
|
|
15
|
+
CotterWrapper,
|
|
16
|
+
)
|
|
17
|
+
from cotter.policy import (
|
|
18
|
+
Policy,
|
|
19
|
+
SB3Policy,
|
|
20
|
+
SpaceMismatchError,
|
|
21
|
+
TorchPolicy,
|
|
22
|
+
load_policy,
|
|
23
|
+
validate_spaces,
|
|
24
|
+
)
|
|
25
|
+
from cotter.report import TestReport
|
|
26
|
+
from cotter.runner import (
|
|
27
|
+
EpisodeRecord,
|
|
28
|
+
RolloutSet,
|
|
29
|
+
make_seed_sequence,
|
|
30
|
+
rollout_one,
|
|
31
|
+
run_rollouts,
|
|
32
|
+
run_rollouts_parallel,
|
|
33
|
+
)
|
|
34
|
+
from cotter.tests.adversarial import (
|
|
35
|
+
AdversarialResult,
|
|
36
|
+
NullAdversary,
|
|
37
|
+
ObservationPerturbationEnv,
|
|
38
|
+
RandomAdversary,
|
|
39
|
+
get_adversary,
|
|
40
|
+
run_adversarial_test,
|
|
41
|
+
train_adversary,
|
|
42
|
+
)
|
|
43
|
+
from cotter.tests.regression import (
|
|
44
|
+
RegressionDecision,
|
|
45
|
+
RegressionResult,
|
|
46
|
+
mcnemar_exact,
|
|
47
|
+
wilcoxon_regression,
|
|
48
|
+
)
|
|
49
|
+
from cotter.tests.safety import (
|
|
50
|
+
SafetyDecision,
|
|
51
|
+
SafetyLimit,
|
|
52
|
+
SafetyResult,
|
|
53
|
+
SafetyViolation,
|
|
54
|
+
check_step,
|
|
55
|
+
evaluate_safety,
|
|
56
|
+
)
|
|
57
|
+
from cotter.backends import (
|
|
58
|
+
BackendFactory,
|
|
59
|
+
BackendNotAvailableError,
|
|
60
|
+
GymnasiumBackend,
|
|
61
|
+
IsaacSimBackend,
|
|
62
|
+
)
|
|
63
|
+
from cotter.stats import clopper_pearson
|
|
64
|
+
from cotter.tests.sprt import SPRT, SPRTDecision, SPRTResult, run_sprt
|
|
65
|
+
|
|
66
|
+
__version__ = "0.1.0"
|
|
67
|
+
|
|
68
|
+
__all__ = [
|
|
69
|
+
# envs
|
|
70
|
+
"CotterWrapper",
|
|
71
|
+
"JOINT_VELOCITIES",
|
|
72
|
+
"ACTUATOR_FORCES",
|
|
73
|
+
"CONTACT_COUNT",
|
|
74
|
+
"CONTACT_FORCES",
|
|
75
|
+
"INSTRUMENTED_KEYS",
|
|
76
|
+
# policy
|
|
77
|
+
"Policy",
|
|
78
|
+
"SB3Policy",
|
|
79
|
+
"TorchPolicy",
|
|
80
|
+
"load_policy",
|
|
81
|
+
"validate_spaces",
|
|
82
|
+
"SpaceMismatchError",
|
|
83
|
+
# runner
|
|
84
|
+
"EpisodeRecord",
|
|
85
|
+
"RolloutSet",
|
|
86
|
+
"rollout_one",
|
|
87
|
+
"run_rollouts",
|
|
88
|
+
"run_rollouts_parallel",
|
|
89
|
+
"make_seed_sequence",
|
|
90
|
+
# performance
|
|
91
|
+
"SPRT",
|
|
92
|
+
"SPRTDecision",
|
|
93
|
+
"SPRTResult",
|
|
94
|
+
"run_sprt",
|
|
95
|
+
# stats
|
|
96
|
+
"clopper_pearson",
|
|
97
|
+
# backends
|
|
98
|
+
"BackendFactory",
|
|
99
|
+
"BackendNotAvailableError",
|
|
100
|
+
"GymnasiumBackend",
|
|
101
|
+
"IsaacSimBackend",
|
|
102
|
+
# safety
|
|
103
|
+
"SafetyLimit",
|
|
104
|
+
"SafetyViolation",
|
|
105
|
+
"SafetyResult",
|
|
106
|
+
"SafetyDecision",
|
|
107
|
+
"check_step",
|
|
108
|
+
"evaluate_safety",
|
|
109
|
+
# regression
|
|
110
|
+
"RegressionDecision",
|
|
111
|
+
"RegressionResult",
|
|
112
|
+
"mcnemar_exact",
|
|
113
|
+
"wilcoxon_regression",
|
|
114
|
+
# adversarial
|
|
115
|
+
"AdversarialResult",
|
|
116
|
+
"RandomAdversary",
|
|
117
|
+
"NullAdversary",
|
|
118
|
+
"ObservationPerturbationEnv",
|
|
119
|
+
"run_adversarial_test",
|
|
120
|
+
"train_adversary",
|
|
121
|
+
"get_adversary",
|
|
122
|
+
# report
|
|
123
|
+
"TestReport",
|
|
124
|
+
"__version__",
|
|
125
|
+
]
|
cotter/backends.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Simulator backend abstraction.
|
|
2
|
+
|
|
3
|
+
A backend knows how to turn an env id into a ready-to-test Gymnasium
|
|
4
|
+
environment (instrumented with :class:`~cotter.envs.wrapper.CotterWrapper`
|
|
5
|
+
when possible). This isolates the rest of the framework from *which*
|
|
6
|
+
simulator is behind the Gymnasium API, so the same test battery can run
|
|
7
|
+
against MuJoCo today and other Gymnasium-compatible simulators later.
|
|
8
|
+
|
|
9
|
+
Backends are looked up by name via :meth:`BackendFactory.from_name`;
|
|
10
|
+
``RunConfig.backend`` selects one (default ``"gymnasium"``). A backend
|
|
11
|
+
that needs an optional dependency raises :class:`BackendNotAvailableError`
|
|
12
|
+
at construction, so an unavailable backend fails immediately with a clear
|
|
13
|
+
message rather than deep inside a run.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import abc
|
|
19
|
+
|
|
20
|
+
import gymnasium as gym
|
|
21
|
+
|
|
22
|
+
from cotter.envs.registry import make_env_by_id
|
|
23
|
+
from cotter.envs.wrapper import CotterWrapper
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BackendNotAvailableError(RuntimeError):
|
|
27
|
+
"""A backend's required simulator/dependency is not importable."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BackendFactory(abc.ABC):
|
|
31
|
+
"""Interface for simulator backends.
|
|
32
|
+
|
|
33
|
+
Concrete backends set a class-level ``backend_name`` (used for
|
|
34
|
+
registration and lookup) and implement :meth:`make_env`.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
backend_name: str = ""
|
|
38
|
+
_registry: dict[str, type["BackendFactory"]] = {}
|
|
39
|
+
|
|
40
|
+
def __init_subclass__(cls, **kwargs) -> None:
|
|
41
|
+
super().__init_subclass__(**kwargs)
|
|
42
|
+
if cls.backend_name:
|
|
43
|
+
BackendFactory._registry[cls.backend_name] = cls
|
|
44
|
+
|
|
45
|
+
@abc.abstractmethod
|
|
46
|
+
def make_env(self, env_id: str) -> gym.Env:
|
|
47
|
+
"""Create an instrumented environment for ``env_id``."""
|
|
48
|
+
|
|
49
|
+
def name(self) -> str:
|
|
50
|
+
return self.backend_name
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_name(cls, name: str) -> "BackendFactory":
|
|
54
|
+
"""Construct the backend registered under ``name``.
|
|
55
|
+
|
|
56
|
+
Raises ``ValueError`` for an unknown name and
|
|
57
|
+
:class:`BackendNotAvailableError` if the named backend's
|
|
58
|
+
dependencies are missing (surfaced from its constructor).
|
|
59
|
+
"""
|
|
60
|
+
if name not in cls._registry:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f"unknown backend '{name}' (available: {sorted(cls._registry)})"
|
|
63
|
+
)
|
|
64
|
+
return cls._registry[name]()
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def available(cls) -> list[str]:
|
|
68
|
+
return sorted(cls._registry)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class GymnasiumBackend(BackendFactory):
|
|
72
|
+
"""Default backend: standard Gymnasium/MuJoCo via ``gym.make``."""
|
|
73
|
+
|
|
74
|
+
backend_name = "gymnasium"
|
|
75
|
+
|
|
76
|
+
def make_env(self, env_id: str) -> gym.Env:
|
|
77
|
+
env = make_env_by_id(env_id)
|
|
78
|
+
try:
|
|
79
|
+
return CotterWrapper(env)
|
|
80
|
+
except TypeError:
|
|
81
|
+
# not MuJoCo-backed: safety instrumentation is unavailable, but
|
|
82
|
+
# performance/regression/adversarial still work on the raw env
|
|
83
|
+
return env
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class IsaacSimBackend(BackendFactory):
|
|
87
|
+
"""NVIDIA Isaac Sim backend (optional, GPU/cluster only).
|
|
88
|
+
|
|
89
|
+
Isaac Sim exposes a Gymnasium-compatible API but requires the
|
|
90
|
+
``omni.isaac.gym`` package, which is not importable on CPU-only
|
|
91
|
+
machines. Construction fails fast when it is absent; when present,
|
|
92
|
+
envs are built through Isaac's Gymnasium interface and instrumented
|
|
93
|
+
with :class:`CotterWrapper` like any other MuJoCo/Gymnasium env.
|
|
94
|
+
|
|
95
|
+
This path is not exercised on the CPU development machine; it is
|
|
96
|
+
validated on a GPU cluster separately.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
backend_name = "isaac-sim"
|
|
100
|
+
|
|
101
|
+
def __init__(self) -> None:
|
|
102
|
+
import importlib.util
|
|
103
|
+
|
|
104
|
+
# find_spec returns None for a missing leaf, but raises
|
|
105
|
+
# ModuleNotFoundError when an intermediate package (omni) is absent;
|
|
106
|
+
# both mean the backend is unavailable.
|
|
107
|
+
try:
|
|
108
|
+
spec = importlib.util.find_spec("omni.isaac.gym")
|
|
109
|
+
except ModuleNotFoundError:
|
|
110
|
+
spec = None
|
|
111
|
+
if spec is None:
|
|
112
|
+
raise BackendNotAvailableError(
|
|
113
|
+
"the isaac-sim backend requires the 'omni.isaac.gym' package "
|
|
114
|
+
"from NVIDIA Isaac Sim, which is not installed. Install Isaac "
|
|
115
|
+
"Sim on a CUDA machine, or use the default 'gymnasium' backend."
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def make_env(self, env_id: str) -> gym.Env: # pragma: no cover - no Isaac on CI
|
|
119
|
+
# Isaac registers Gymnasium-compatible envs; build and instrument
|
|
120
|
+
# them through the same path as any MuJoCo env.
|
|
121
|
+
env = make_env_by_id(env_id)
|
|
122
|
+
try:
|
|
123
|
+
return CotterWrapper(env)
|
|
124
|
+
except TypeError:
|
|
125
|
+
return env
|
cotter/cli.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""`cotter` command-line interface.
|
|
2
|
+
|
|
3
|
+
cotter run --policy artifacts/victim.zip --config run.yaml
|
|
4
|
+
cotter compare --baseline a.zip --candidate b.zip --config run.yaml
|
|
5
|
+
|
|
6
|
+
Exit codes: 0 = all executed categories passed (compare: no regression),
|
|
7
|
+
1 = at least one category failed (compare: regression detected),
|
|
8
|
+
2 = configuration/usage error.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from cotter import __version__
|
|
18
|
+
from cotter.config import ConfigError, RegressionConfig, RunConfig, load_config
|
|
19
|
+
from cotter.pipeline import run_from_config
|
|
20
|
+
from cotter.policy import SpaceMismatchError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
24
|
+
parser = argparse.ArgumentParser(
|
|
25
|
+
prog="cotter",
|
|
26
|
+
description="Compliance testing for AI-controlled robot policies.",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument("--version", action="version", version=f"cotter {__version__}")
|
|
29
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
30
|
+
|
|
31
|
+
run = subparsers.add_parser(
|
|
32
|
+
"run", help="run the test categories declared in a YAML config"
|
|
33
|
+
)
|
|
34
|
+
run.add_argument(
|
|
35
|
+
"--policy", type=Path, required=True,
|
|
36
|
+
help="policy under test (SB3 .zip or torch .pt)",
|
|
37
|
+
)
|
|
38
|
+
run.add_argument(
|
|
39
|
+
"--config", type=Path, required=True,
|
|
40
|
+
help="YAML config declaring test categories and parameters",
|
|
41
|
+
)
|
|
42
|
+
run.add_argument(
|
|
43
|
+
"--env", default=None,
|
|
44
|
+
help="Gymnasium env id (overrides the config's 'env')",
|
|
45
|
+
)
|
|
46
|
+
run.add_argument(
|
|
47
|
+
"--report", type=Path, default=None,
|
|
48
|
+
help="JSON report path (overrides the config's 'report')",
|
|
49
|
+
)
|
|
50
|
+
run.add_argument(
|
|
51
|
+
"--quiet", action="store_true", help="suppress progress output"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
compare = subparsers.add_parser(
|
|
55
|
+
"compare",
|
|
56
|
+
help="run only the regression test between a baseline and a candidate",
|
|
57
|
+
)
|
|
58
|
+
compare.add_argument("--baseline", type=Path, required=True, help="baseline policy")
|
|
59
|
+
compare.add_argument("--candidate", type=Path, required=True, help="candidate policy")
|
|
60
|
+
compare.add_argument(
|
|
61
|
+
"--config", type=Path, required=True,
|
|
62
|
+
help="YAML config supplying env, success, base_seed, and regression params",
|
|
63
|
+
)
|
|
64
|
+
compare.add_argument("--env", default=None, help="Gymnasium env id (overrides config)")
|
|
65
|
+
compare.add_argument("--quiet", action="store_true", help="suppress progress output")
|
|
66
|
+
|
|
67
|
+
list_envs = subparsers.add_parser(
|
|
68
|
+
"list-envs", help="list available Gymnasium env ids grouped by package"
|
|
69
|
+
)
|
|
70
|
+
list_envs.add_argument(
|
|
71
|
+
"--filter", default=None, help="only show env ids containing this substring"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
zoo = subparsers.add_parser("zoo", help="inspect the cached adversary zoo")
|
|
75
|
+
zoo.add_argument("--root", type=Path, default=None, help="zoo root (default ~/.cotter/zoo)")
|
|
76
|
+
zoo_sub = zoo.add_subparsers(dest="zoo_command", required=True)
|
|
77
|
+
zoo_list = zoo_sub.add_parser("list", help="list cached adversaries")
|
|
78
|
+
zoo_list.add_argument("--env", default=None, help="filter by env id")
|
|
79
|
+
zoo_sub.add_parser("prune", help="remove entries whose artifact files are missing")
|
|
80
|
+
return parser
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
84
|
+
log = (lambda msg: None) if args.quiet else print
|
|
85
|
+
try:
|
|
86
|
+
cfg = load_config(args.config)
|
|
87
|
+
except (ConfigError, FileNotFoundError) as exc:
|
|
88
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
89
|
+
return 2
|
|
90
|
+
if args.env is not None:
|
|
91
|
+
cfg.env = args.env
|
|
92
|
+
if args.report is not None:
|
|
93
|
+
cfg.report = args.report
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
report = run_from_config(args.policy, cfg, log=log)
|
|
97
|
+
except (FileNotFoundError, SpaceMismatchError, ValueError) as exc:
|
|
98
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
99
|
+
return 2
|
|
100
|
+
|
|
101
|
+
print(report.summary())
|
|
102
|
+
return 0 if report.overall_passed else 1
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def cmd_compare(args: argparse.Namespace) -> int:
|
|
106
|
+
log = (lambda msg: None) if args.quiet else print
|
|
107
|
+
try:
|
|
108
|
+
cfg = load_config(args.config)
|
|
109
|
+
except (ConfigError, FileNotFoundError) as exc:
|
|
110
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
111
|
+
return 2
|
|
112
|
+
|
|
113
|
+
# Regression-only run: keep the loaded regression params (n_pairs,
|
|
114
|
+
# alpha, n_workers) but override the baseline with the CLI argument and
|
|
115
|
+
# drop every other category so only the comparison executes.
|
|
116
|
+
reg = cfg.regression or RegressionConfig()
|
|
117
|
+
reg.baseline = args.baseline
|
|
118
|
+
reg_only = RunConfig(
|
|
119
|
+
env=args.env or cfg.env,
|
|
120
|
+
success=cfg.success,
|
|
121
|
+
algo=cfg.algo,
|
|
122
|
+
base_seed=cfg.base_seed,
|
|
123
|
+
regression=reg,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
report = run_from_config(args.candidate, reg_only, log=log)
|
|
128
|
+
except (FileNotFoundError, SpaceMismatchError, ValueError) as exc:
|
|
129
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
130
|
+
return 2
|
|
131
|
+
|
|
132
|
+
print(report.summary())
|
|
133
|
+
regressed = any(r.passed is False for r in report.results)
|
|
134
|
+
return 1 if regressed else 0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def cmd_list_envs(args: argparse.Namespace) -> int:
|
|
138
|
+
from collections import defaultdict
|
|
139
|
+
|
|
140
|
+
import gymnasium as gym
|
|
141
|
+
|
|
142
|
+
from cotter.envs.registry import register_extension_envs
|
|
143
|
+
|
|
144
|
+
register_extension_envs() # make gymnasium-robotics ids visible
|
|
145
|
+
|
|
146
|
+
groups: dict[str, set[str]] = defaultdict(set)
|
|
147
|
+
for env_id, spec in gym.registry.items():
|
|
148
|
+
if args.filter and args.filter.lower() not in env_id.lower():
|
|
149
|
+
continue
|
|
150
|
+
entry = spec.entry_point
|
|
151
|
+
if isinstance(entry, str):
|
|
152
|
+
package = entry.split(":", 1)[0].split(".", 1)[0]
|
|
153
|
+
elif entry is not None:
|
|
154
|
+
package = getattr(entry, "__module__", "unknown").split(".", 1)[0]
|
|
155
|
+
else:
|
|
156
|
+
package = "unknown"
|
|
157
|
+
groups[package].add(env_id)
|
|
158
|
+
|
|
159
|
+
total = sum(len(ids) for ids in groups.values())
|
|
160
|
+
scope = f" matching '{args.filter}'" if args.filter else ""
|
|
161
|
+
if total == 0:
|
|
162
|
+
print(f"no envs{scope}")
|
|
163
|
+
return 0
|
|
164
|
+
print(f"{total} env id(s){scope}:")
|
|
165
|
+
for package in sorted(groups):
|
|
166
|
+
ids = sorted(groups[package])
|
|
167
|
+
print(f"\n{package} ({len(ids)}):")
|
|
168
|
+
for env_id in ids:
|
|
169
|
+
print(f" {env_id}")
|
|
170
|
+
return 0
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def cmd_zoo(args: argparse.Namespace) -> int:
|
|
174
|
+
from cotter.zoo import AdversaryZoo
|
|
175
|
+
|
|
176
|
+
zoo = AdversaryZoo(args.root) if args.root else AdversaryZoo()
|
|
177
|
+
|
|
178
|
+
if args.zoo_command == "list":
|
|
179
|
+
entries = zoo.entries(args.env)
|
|
180
|
+
scope = f" for env '{args.env}'" if args.env else ""
|
|
181
|
+
if not entries:
|
|
182
|
+
print(f"no cached adversaries{scope} (zoo root: {zoo.root})")
|
|
183
|
+
return 0
|
|
184
|
+
print(f"{len(entries)} cached adversar{'y' if len(entries) == 1 else 'ies'}"
|
|
185
|
+
f"{scope} (zoo root: {zoo.root}):")
|
|
186
|
+
for e in entries:
|
|
187
|
+
missing = "" if (zoo.root / e.path).exists() else " [MISSING ARTIFACT]"
|
|
188
|
+
print(f" {e.env_id} eps={e.epsilon:g} victim={e.victim_hash} "
|
|
189
|
+
f"{e.algo} {e.created_at}{missing}")
|
|
190
|
+
print(f" {e.path}")
|
|
191
|
+
return 0
|
|
192
|
+
|
|
193
|
+
if args.zoo_command == "prune":
|
|
194
|
+
removed = zoo.prune()
|
|
195
|
+
if not removed:
|
|
196
|
+
print(f"nothing to prune (zoo root: {zoo.root})")
|
|
197
|
+
return 0
|
|
198
|
+
print(f"pruned {len(removed)} entr{'y' if len(removed) == 1 else 'ies'} "
|
|
199
|
+
f"with missing artifacts:")
|
|
200
|
+
for e in removed:
|
|
201
|
+
print(f" {e.env_id} eps={e.epsilon:g} victim={e.victim_hash}")
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
raise AssertionError(f"unhandled zoo command {args.zoo_command}") # pragma: no cover
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def main(argv: list[str] | None = None) -> int:
|
|
208
|
+
args = build_parser().parse_args(argv)
|
|
209
|
+
if args.command == "run":
|
|
210
|
+
return cmd_run(args)
|
|
211
|
+
if args.command == "compare":
|
|
212
|
+
return cmd_compare(args)
|
|
213
|
+
if args.command == "zoo":
|
|
214
|
+
return cmd_zoo(args)
|
|
215
|
+
if args.command == "list-envs":
|
|
216
|
+
return cmd_list_envs(args)
|
|
217
|
+
raise AssertionError(f"unhandled command {args.command}") # pragma: no cover
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
if __name__ == "__main__":
|
|
221
|
+
sys.exit(main())
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Regulatory compliance document generation — PAID TIER (license required).
|
|
2
|
+
|
|
3
|
+
This module is a deliberate stub. The open-source Cotter engine produces
|
|
4
|
+
the *evidence* (structured TestReport pass/fail results with statistical
|
|
5
|
+
guarantees); turning that evidence into signed regulatory documentation
|
|
6
|
+
— EU Machinery Regulation 2027 technical files, ISO 10218 conformity
|
|
7
|
+
records — is the licensed commercial layer.
|
|
8
|
+
|
|
9
|
+
The public import path is stable so downstream code and docs can be
|
|
10
|
+
written against it today::
|
|
11
|
+
|
|
12
|
+
from cotter.compliance import EUMachineryReg2027
|
|
13
|
+
|
|
14
|
+
generator = EUMachineryReg2027(report) # raises LicenseRequiredError
|
|
15
|
+
|
|
16
|
+
Every constructor here raises :class:`LicenseRequiredError` with
|
|
17
|
+
activation instructions. Nothing in this package performs real document
|
|
18
|
+
generation in the open-source distribution.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from cotter.compliance.base import (
|
|
22
|
+
ComplianceGenerator,
|
|
23
|
+
LicenseRequiredError,
|
|
24
|
+
EUMachineryReg2027,
|
|
25
|
+
ISO10218,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"ComplianceGenerator",
|
|
30
|
+
"LicenseRequiredError",
|
|
31
|
+
"EUMachineryReg2027",
|
|
32
|
+
"ISO10218",
|
|
33
|
+
]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Paid-tier compliance generators — stub implementations.
|
|
2
|
+
|
|
3
|
+
# paid tier — license required
|
|
4
|
+
#
|
|
5
|
+
# These classes define the commercial API surface but perform no real
|
|
6
|
+
# work in the open-source distribution: constructing any generator
|
|
7
|
+
# raises LicenseRequiredError. The shapes here (standard name, version,
|
|
8
|
+
# required category coverage) are intentionally concrete so the boundary
|
|
9
|
+
# between the free engine and the paid layer is legible.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from cotter.report import TestReport
|
|
18
|
+
|
|
19
|
+
_ACTIVATION_HINT = (
|
|
20
|
+
"Regulatory document generation is a licensed feature and is not "
|
|
21
|
+
"included in the open-source Cotter distribution. The open engine "
|
|
22
|
+
"produces the pass/fail evidence (TestReport); the compliance layer "
|
|
23
|
+
"renders it into a regulator-ready technical file. Request access at "
|
|
24
|
+
"https://github.com/yih0nk/cotter (see the commercial tier) or set a "
|
|
25
|
+
"valid COTTER_LICENSE_KEY."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class LicenseRequiredError(RuntimeError):
|
|
30
|
+
"""Raised when a paid-tier compliance feature is used without a license."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, standard: str) -> None:
|
|
33
|
+
super().__init__(
|
|
34
|
+
f"'{standard}' compliance generation requires a Cotter license. "
|
|
35
|
+
f"{_ACTIVATION_HINT}"
|
|
36
|
+
)
|
|
37
|
+
self.standard = standard
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ComplianceGenerator:
|
|
41
|
+
"""Base class for regulatory document generators (paid tier).
|
|
42
|
+
|
|
43
|
+
Subclasses declare the standard they cover and the test categories
|
|
44
|
+
that standard's evidence requires. The open-source stub validates
|
|
45
|
+
nothing and generates nothing — it refuses at construction.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
standard: str = "unknown"
|
|
49
|
+
version: str = "0"
|
|
50
|
+
required_categories: tuple[str, ...] = ()
|
|
51
|
+
|
|
52
|
+
def __init__(self, report: "TestReport") -> None:
|
|
53
|
+
# The license check happens before any work, so downstream code
|
|
54
|
+
# gets a clear, immediate signal rather than a late failure.
|
|
55
|
+
raise LicenseRequiredError(self.standard)
|
|
56
|
+
|
|
57
|
+
def generate(self, output_path) -> None: # pragma: no cover - unreachable in OSS
|
|
58
|
+
raise LicenseRequiredError(self.standard)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class EUMachineryReg2027(ComplianceGenerator):
|
|
62
|
+
"""EU Machinery Regulation (2023/1230) technical file generator (paid)."""
|
|
63
|
+
|
|
64
|
+
standard = "EU Machinery Regulation 2027"
|
|
65
|
+
version = "2023/1230"
|
|
66
|
+
required_categories = ("performance", "safety", "adversarial")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ISO10218(ComplianceGenerator):
|
|
70
|
+
"""ISO 10218 industrial-robot safety conformity generator (paid)."""
|
|
71
|
+
|
|
72
|
+
standard = "ISO 10218"
|
|
73
|
+
version = "2011"
|
|
74
|
+
required_categories = ("safety", "performance")
|