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 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")