cotterbot 0.1.0__tar.gz → 0.3.0__tar.gz

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 (42) hide show
  1. {cotterbot-0.1.0 → cotterbot-0.3.0}/PKG-INFO +293 -12
  2. {cotterbot-0.1.0 → cotterbot-0.3.0}/README.md +286 -11
  3. {cotterbot-0.1.0 → cotterbot-0.3.0}/cotter/__init__.py +88 -1
  4. {cotterbot-0.1.0 → cotterbot-0.3.0}/cotter/cli.py +155 -0
  5. {cotterbot-0.1.0 → cotterbot-0.3.0}/cotter/config.py +137 -1
  6. cotterbot-0.3.0/cotter/coverage.py +127 -0
  7. cotterbot-0.3.0/cotter/diff.py +118 -0
  8. cotterbot-0.3.0/cotter/falsify.py +169 -0
  9. cotterbot-0.3.0/cotter/junit.py +70 -0
  10. cotterbot-0.3.0/cotter/manifest.py +95 -0
  11. cotterbot-0.3.0/cotter/pipeline.py +391 -0
  12. {cotterbot-0.1.0 → cotterbot-0.3.0}/cotter/policy.py +54 -3
  13. cotterbot-0.3.0/cotter/render.py +240 -0
  14. cotterbot-0.3.0/cotter/render_md.py +75 -0
  15. {cotterbot-0.1.0 → cotterbot-0.3.0}/cotter/report.py +105 -3
  16. cotterbot-0.3.0/cotter/tests/action_adversarial.py +291 -0
  17. cotterbot-0.3.0/cotter/tests/iso15066.py +289 -0
  18. cotterbot-0.3.0/cotter/tests/stl.py +159 -0
  19. cotterbot-0.3.0/cotter/tests/transfer.py +104 -0
  20. cotterbot-0.3.0/cotter/traceability.py +113 -0
  21. cotterbot-0.3.0/cotter/verify.py +93 -0
  22. cotterbot-0.3.0/cotter/zoo/pretrained.py +159 -0
  23. {cotterbot-0.1.0 → cotterbot-0.3.0}/pyproject.toml +12 -1
  24. cotterbot-0.1.0/cotter/pipeline.py +0 -214
  25. {cotterbot-0.1.0 → cotterbot-0.3.0}/LICENSE +0 -0
  26. {cotterbot-0.1.0 → cotterbot-0.3.0}/cotter/backends.py +0 -0
  27. {cotterbot-0.1.0 → cotterbot-0.3.0}/cotter/compliance/__init__.py +0 -0
  28. {cotterbot-0.1.0 → cotterbot-0.3.0}/cotter/compliance/base.py +0 -0
  29. {cotterbot-0.1.0 → cotterbot-0.3.0}/cotter/envs/__init__.py +0 -0
  30. {cotterbot-0.1.0 → cotterbot-0.3.0}/cotter/envs/factory.py +0 -0
  31. {cotterbot-0.1.0 → cotterbot-0.3.0}/cotter/envs/registry.py +0 -0
  32. {cotterbot-0.1.0 → cotterbot-0.3.0}/cotter/envs/wrapper.py +0 -0
  33. {cotterbot-0.1.0 → cotterbot-0.3.0}/cotter/runner.py +0 -0
  34. {cotterbot-0.1.0 → cotterbot-0.3.0}/cotter/stats.py +0 -0
  35. {cotterbot-0.1.0 → cotterbot-0.3.0}/cotter/success.py +0 -0
  36. {cotterbot-0.1.0 → cotterbot-0.3.0}/cotter/tests/__init__.py +0 -0
  37. {cotterbot-0.1.0 → cotterbot-0.3.0}/cotter/tests/adversarial.py +0 -0
  38. {cotterbot-0.1.0 → cotterbot-0.3.0}/cotter/tests/regression.py +0 -0
  39. {cotterbot-0.1.0 → cotterbot-0.3.0}/cotter/tests/safety.py +0 -0
  40. {cotterbot-0.1.0 → cotterbot-0.3.0}/cotter/tests/sprt.py +0 -0
  41. {cotterbot-0.1.0 → cotterbot-0.3.0}/cotter/zoo/__init__.py +0 -0
  42. {cotterbot-0.1.0 → cotterbot-0.3.0}/cotter/zoo/registry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cotterbot
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: Compliance-testing framework for AI-controlled robot policies — pytest for robot policies.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -9,11 +9,17 @@ Author-email: hyihan2007hong@gmail.com
9
9
  Requires-Python: >=3.11,<3.12
10
10
  Classifier: Programming Language :: Python :: 3
11
11
  Classifier: Programming Language :: Python :: 3.11
12
+ Provides-Extra: falsify
13
+ Provides-Extra: onnx
14
+ Provides-Extra: stl
15
+ Requires-Dist: cma (>=4.0,<5.0) ; extra == "falsify"
12
16
  Requires-Dist: gymnasium-robotics (>=1.3,<2.0)
13
17
  Requires-Dist: gymnasium[mujoco] (>=1.0,<2.0)
14
18
  Requires-Dist: mujoco (>=3.2,<4.0)
15
19
  Requires-Dist: numpy (>=1.26,<2.0)
20
+ Requires-Dist: onnxruntime (>=1.17,<2.0) ; extra == "onnx"
16
21
  Requires-Dist: pyyaml (>=6.0,<7.0)
22
+ Requires-Dist: rtamt (>=0.3,<0.4) ; extra == "stl"
17
23
  Requires-Dist: scipy (>=1.13,<2.0)
18
24
  Requires-Dist: stable-baselines3 (>=2.4,<3.0)
19
25
  Requires-Dist: torch (>=2.4,<3.0)
@@ -24,9 +30,11 @@ Description-Content-Type: text/markdown
24
30
  **Compliance testing for AI-controlled robot policies — pytest for robot
25
31
  policies.**
26
32
 
27
- Cotter loads a trained robot policy as a black box (observation → action),
28
- runs it through a battery of standardized tests in MuJoCo simulation, and
29
- produces structured pass/fail results with statistical guarantees. It is
33
+ Cotter loads a trained robot policy as a black box (observation → action)
34
+ — a Stable-Baselines3 `.zip`, a raw PyTorch `.pt`/`.pth` module, or an
35
+ ONNX `.onnx` model — runs it through a battery of standardized tests in
36
+ MuJoCo simulation, and produces structured pass/fail results with
37
+ statistical guarantees. It is
30
38
  aimed at the emerging regulatory need (EU Machinery Regulation, ISO 10218)
31
39
  for evidence that a learned controller actually behaves — but the core is
32
40
  just honest, reproducible testing:
@@ -34,9 +42,10 @@ just honest, reproducible testing:
34
42
  | Category | Question | Method |
35
43
  |---|---|---|
36
44
  | **Performance** | Does it succeed at the task? | Wald's sequential probability ratio test (SPRT) — stops sampling as soon as the evidence is decisive |
37
- | **Safety** | Does it ever exceed a hard physical limit? | Per-timestep checks on joint velocities / actuator forces / contacts; a single violation anywhere fails, no averaging |
45
+ | **Safety** | Does it ever exceed a hard physical limit? | Per-timestep checks on joint velocities / actuator forces / contacts; a single violation anywhere fails, no averaging. Includes an **ISO/TS 15066** power-and-force-limiting check (per-body-region contact-speed limits) |
38
46
  | **Regression** | Did the new version break behavior? | Matched pairs on a shared seed sequence, exact McNemar (binary) and Wilcoxon signed-rank (continuous) |
39
- | **Adversarial** | How bad is the worst case? | A PPO adversary trained to perturb the policy's *observations* within an L∞ budget, plus a guaranteed random-noise baseline |
47
+ | **Adversarial** | How bad is the worst case? | A PPO adversary trained to perturb the policy's *observations* (sensor attack) or *actions* (actuator attack) within an L∞ budget, plus a guaranteed random-noise baseline |
48
+ | **Specification** | Does it meet a declared behavioral requirement? | Signal Temporal Logic (STL) specs scored by *quantitative robustness* per rollout — e.g. `always (speed <= 1.5)`, `eventually (dist_to_goal <= 0.05)` |
40
49
 
41
50
  Everything runs on CPU (developed on Apple Silicon; no CUDA anywhere in
42
51
  the stack).
@@ -50,13 +59,27 @@ Requires Python 3.11. From PyPI (the distribution is published as
50
59
  pip install cotterbot
51
60
  ```
52
61
 
62
+ To load **ONNX** policies, install the optional extra (pulls in `onnxruntime`):
63
+
64
+ ```sh
65
+ pip install "cotterbot[onnx]"
66
+ ```
67
+
68
+ > **Note:** on some systems the `cotter` binary lands outside your PATH (pip
69
+ > will warn you with the exact directory). Either add that directory to PATH,
70
+ > or invoke via `python -m cotter` instead:
71
+ >
72
+ > ```sh
73
+ > python -m cotter run --policy policy.zip --config run.yaml
74
+ > ```
75
+
53
76
  Or from source with [Poetry](https://python-poetry.org/):
54
77
 
55
78
  ```sh
56
79
  git clone https://github.com/yih0nk/cotter.git
57
80
  cd cotter
58
81
  poetry install
59
- poetry run pytest # 213 tests, unit + real-MuJoCo integration
82
+ poetry run pytest # 435 tests, unit + real-MuJoCo integration
60
83
  ```
61
84
 
62
85
  ## Quickstart (CLI)
@@ -103,6 +126,224 @@ confidence interval (SPRT and adversarial results), and regression
103
126
  results carry an effect size — the discordant-pair odds ratio for
104
127
  McNemar, the rank-biserial correlation for Wilcoxon.
105
128
 
129
+ #### Adversarial attack surface
130
+
131
+ The adversarial category attacks either surface, set by
132
+ `adversarial.attack`:
133
+
134
+ - `observation` (default) — perturb what the victim *sees* (sensor noise,
135
+ calibration drift).
136
+ - `action` — perturb what the victim *does* (actuator noise, control-channel
137
+ corruption), clipped back into the action space.
138
+ - `both` — run both; the report carries distinct `action_*` entries.
139
+
140
+ Each surface runs a guaranteed random baseline and, when `train: true`, a
141
+ PPO adversary trained against the frozen victim.
142
+
143
+ ### Specifications (Signal Temporal Logic)
144
+
145
+ Beyond the fixed categories, declare behavioral requirements in **Signal
146
+ Temporal Logic** and Cotter scores each rollout by *quantitative
147
+ robustness* — a signed margin (positive = satisfied, negative = violated,
148
+ and by how much), not just pass/fail. Requires the `stl` extra
149
+ (`pip install cotterbot[stl]`):
150
+
151
+ ```yaml
152
+ stl:
153
+ n_episodes: 20
154
+ variables:
155
+ speed: cotter/tcp_speed # scalar info key
156
+ jv0: {key: cotter/joint_velocities, index: 0} # a vector component
157
+ threshold: 0.0 # min robustness required to pass (0 = the STL boundary)
158
+ specs:
159
+ - name: speed_cap
160
+ formula: "always (speed <= 1.5)"
161
+ - name: settles
162
+ formula: "eventually (speed <= 0.1)"
163
+ ```
164
+
165
+ Each spec becomes a `specification`-category result reporting the worst
166
+ (minimum) episode robustness. Signals are pulled from the per-step `info`
167
+ dict by the `variables` map. STL operators (`always`, `eventually`,
168
+ `until`, bounded `[a,b]`) are evaluated offline with
169
+ [`rtamt`](https://github.com/nickovic/rtamt).
170
+
171
+ ### Falsification (active worst-case search)
172
+
173
+ The categories above *sample* scenarios; falsification *searches* for the
174
+ worst one. Point CMA-ES at an STL-robustness objective and it hunts the
175
+ initial condition / disturbance / parameter vector that most violates a
176
+ spec — returning the concrete counterexample. Requires the `falsify`
177
+ extra (`pip install cotterbot[falsify]`); a Python-API feature, since the
178
+ scenario→env mapping is code:
179
+
180
+ ```python
181
+ import cotter
182
+
183
+ # how a scenario vector configures the env (masses, frictions, disturbance...)
184
+ def apply_scenario(env, params):
185
+ env.unwrapped.model.body_mass[1] = params[0] # e.g. sweep a link mass
186
+
187
+ objective = cotter.stl_scenario_objective(
188
+ policy, env_factory, apply_scenario,
189
+ formula="always (tcp_speed <= 1.5)",
190
+ variables={"tcp_speed": {"key": "cotter/tcp_speed"}},
191
+ )
192
+ result = cotter.falsify(objective, lower=[0.5], upper=[5.0], max_evaluations=200)
193
+ if result.falsified:
194
+ print("counterexample scenario:", result.best_params)
195
+ ```
196
+
197
+ `falsify` minimizes the objective and reports whether it dropped below the
198
+ threshold (0 = a spec violation). Derivative-free and CPU-only — each
199
+ evaluation is one rollout; 1-D spaces use a deterministic sweep, higher
200
+ dimensions use CMA-ES.
201
+
202
+ ### Coverage sweeps (operating-envelope coverage)
203
+
204
+ A green report shouldn't be able to hide untested regions. Coverage
205
+ sweeps the scenario space with a low-discrepancy **Sobol** sequence and
206
+ reports the metric *as a function of the scenario* — the failure fraction
207
+ and worst region, not one aggregate number. Same `objective` as
208
+ falsification (sweep it here, search it there), CPU-only, no extra:
209
+
210
+ ```python
211
+ import cotter
212
+
213
+ result = cotter.sweep(
214
+ objective, # e.g. cotter.stl_scenario_objective(...)
215
+ lower=[0.5, 0.0], upper=[5.0, 1.0], # the operating envelope
216
+ n_scenarios=64,
217
+ )
218
+ print(result.summary()) # "covered 64 scenarios, 7 below threshold (11% failure region) ..."
219
+ for o in result.outcomes: # every (scenario, value, pass/fail) — a coverage map
220
+ ...
221
+ ```
222
+
223
+ Coverage-of-operating-envelope reporting is something regulators ask for
224
+ (UNECE-style ODD coverage) but no open policy-eval tool provides.
225
+
226
+ For the common case — coverage across the **observation-disturbance**
227
+ envelope — a `coverage` config section runs it as part of `cotter run`
228
+ (no code), sweeping a constant observation offset in the L∞ box and
229
+ reporting a `coverage`-category result:
230
+
231
+ ```yaml
232
+ coverage:
233
+ epsilon: 0.1 # L∞ budget on the observation offset
234
+ n_scenarios: 32
235
+ n_episodes: 10
236
+ min_success_rate: 0.8 # a scenario fails below this
237
+ ```
238
+
239
+ ### ISO/TS 15066 power-and-force-limiting (PFL)
240
+
241
+ Collaborative-robot safety comes down to a concrete numeric question: at
242
+ its speed, would a contact exceed the human injury threshold for the body
243
+ region it might hit? Cotter checks it directly. Declare an
244
+ `iso_ts_15066` section:
245
+
246
+ ```yaml
247
+ iso_ts_15066:
248
+ m_robot: 5.0 # effective robot mass at the TCP (kg), incl. payload
249
+ speed_key: cotter/tcp_speed # per-step info key holding TCP linear velocity
250
+ contact_type: transient # transient (dynamic) or quasi_static (clamping)
251
+ regions: [chest, hand_finger, skull_forehead] # omit for the full library
252
+ ```
253
+
254
+ For each region, Cotter back-solves the maximum permissible contact speed
255
+ from the ISO/TS 15066 collision model — `F = v·√(k·μ)`, reduced mass
256
+ `1/μ = 1/m_robot + 1/m_human` — and flags any timestep whose TCP speed
257
+ would produce a force over that region's limit, reporting the binding
258
+ (most restrictive) region and the implied contact force.
259
+
260
+ > The bundled per-region limits (`cotter.ISO_TS_15066_LIMITS`) are the
261
+ > widely-published approximations and are **user-editable** — confirm them
262
+ > against the purchased ISO/TS 15066 before relying on them for
263
+ > certification.
264
+
265
+ ### Clause traceability
266
+
267
+ Conformity assessment is clause-by-clause. Declare which checks evidence
268
+ each regulatory clause and Cotter reports every clause as verified,
269
+ failed, or **unverified** — the coverage-gap view an auditor asks for:
270
+
271
+ ```yaml
272
+ traceability:
273
+ EHSR-1.3.7:
274
+ description: "moving parts of machinery"
275
+ checks: [hard_limits, iso_ts_15066_pfl]
276
+ AI-Act-15:
277
+ description: "robustness & accuracy"
278
+ checks: [learned_ppo, speed_cap]
279
+ ```
280
+
281
+ A clause is *failed* if any of its checks failed, *verified* if it has a
282
+ passing check and none failed, else *unverified* (no evidence). The result
283
+ is a `traceability`-category entry that fails the report if any clause is
284
+ failed or unverified. (The certified clause *thresholds* are the paid
285
+ layer; this open mapping ties your checks to the clauses you declare.)
286
+
287
+ ### Reports (JSON + HTML + JUnit + Markdown)
288
+
289
+ Every run prints a console summary and can write four artifacts:
290
+
291
+ - **JSON** (`report:` in the config, or `--report`) — the machine-readable
292
+ evidence, one object per category with the full statistics.
293
+ - **HTML** (`report_html:` in the config, or `--report-html`) — a single
294
+ self-contained page you can open in a browser, drop into a pull request,
295
+ or attach to a demo. No external assets, no JavaScript, adapts to the
296
+ viewer's light/dark theme.
297
+ - **JUnit XML** (`report_junit:` in the config, or `--report-junit`) — each
298
+ category as a `<testcase>`, so a Cotter run shows up as native test
299
+ results in GitHub Actions, GitLab, Jenkins, CircleCI, etc.
300
+ - **Markdown** (`report_md:` in the config, or `--report-md`) — a shareable
301
+ plain-text report to paste into a PR, an issue, or a wiki.
302
+
303
+ ```sh
304
+ poetry run cotter run \
305
+ --policy artifacts/victim_ppo_inverted_pendulum.zip \
306
+ --config examples/inverted_pendulum.yaml \
307
+ --report report.json --report-html report.html
308
+ ```
309
+
310
+ The HTML page is the *free* counterpart to the paid compliance layer: the
311
+ open engine renders the pass/fail evidence into something shareable; the
312
+ licensed layer renders it into a signed, regulator-ready technical file.
313
+
314
+ ### Reproducibility manifest
315
+
316
+ Every report (schema v2+) carries a `manifest` recording the full
317
+ provenance of the run — so a result can be reproduced and audited, not
318
+ just read:
319
+
320
+ ```json
321
+ "manifest": {
322
+ "cotter_version": "0.2.0",
323
+ "python_version": "3.11.9",
324
+ "platform": "Linux-6.5.0-x86_64",
325
+ "dependencies": { "gymnasium": "1.0.0", "mujoco": "3.2.3", "torch": "2.4.1", ... },
326
+ "env_id": "InvertedPendulum-v5",
327
+ "base_seed": 0,
328
+ "policy_path": "artifacts/victim.zip",
329
+ "policy_sha256": "sha256:1f3a…"
330
+ }
331
+ ```
332
+
333
+ The report also includes `content_sha256`, a digest over the report body
334
+ (excluding the timestamp and the digest itself). It is a stable id and a
335
+ tamper-evidence check.
336
+
337
+ Verify a report's integrity with `cotter verify`:
338
+
339
+ ```sh
340
+ cotter verify report.json # recompute + check content_sha256
341
+ cotter verify report.json --policy p.zip # also re-hash the policy vs the manifest
342
+ ```
343
+
344
+ Exit 0 means verified, 1 means a check failed (the report or policy was
345
+ modified), 2 means the report is missing or invalid.
346
+
106
347
  ### Comparing two policy versions
107
348
 
108
349
  `cotter compare` runs only the regression test between a baseline and a
@@ -115,6 +356,24 @@ poetry run cotter compare \
115
356
  --config examples/inverted_pendulum.yaml
116
357
  ```
117
358
 
359
+ Like `run`, it accepts `--report` (JSON) and `--report-html` to write the
360
+ comparison result to disk — handy for archiving the artifact or feeding a
361
+ PR comment.
362
+
363
+ ### Diffing reports (safety budget)
364
+
365
+ Where `compare` re-runs the regression test, `cotter diff` compares two
366
+ existing **reports** and fails if any previously-passing check now fails —
367
+ a safety-budget gate across the *whole* battery, not just regression:
368
+
369
+ ```sh
370
+ cotter diff baseline_report.json candidate_report.json
371
+ ```
372
+
373
+ Each check is classified regressed / improved / added / removed; exit 1
374
+ means a check that used to pass now fails (0 otherwise, 2 on a bad
375
+ report). Store a baseline report and diff every retrain against it.
376
+
118
377
  ### Parallel rollouts
119
378
 
120
379
  Safety, regression, and fixed-N adversarial evaluation parallelize across
@@ -162,7 +421,7 @@ from cotter import (
162
421
  # magnitudes in every step's info dict.
163
422
  env = CotterWrapper(gym.make("InvertedPendulum-v5"))
164
423
 
165
- # 2. Load the policy under test (SB3 .zip or a raw torch .pt module).
424
+ # 2. Load the policy under test (SB3 .zip, raw torch .pt, or .onnx).
166
425
  # Observation/action spaces are validated and mismatches fail loudly.
167
426
  policy = load_policy("artifacts/victim_ppo_inverted_pendulum.zip", env, algo=PPO)
168
427
 
@@ -184,13 +443,14 @@ safe = evaluate_safety(rollouts.episode_infos, [
184
443
 
185
444
  adv = run_adversarial_test(policy, env, success, epsilon=0.07, n_episodes=20)
186
445
 
187
- # 4. Aggregate into a report (console summary + JSON artifact).
446
+ # 4. Aggregate into a report (console summary + JSON + HTML artifacts).
188
447
  report = TestReport(policy_name="my_policy", env_id="InvertedPendulum-v5")
189
448
  report.add_sprt(perf)
190
449
  report.add_safety(safe)
191
450
  report.add_adversarial(adv)
192
451
  print(report.summary())
193
452
  report.to_json("report.json")
453
+ report.to_html("report.html") # self-contained, shareable page
194
454
  ```
195
455
 
196
456
  ## Demo
@@ -347,8 +607,28 @@ Trained adversaries are expensive and specific to what they attack, so
347
607
  adversarial section and the first run trains and stores the attacker;
348
608
  later runs on the same victim reuse it instead of retraining. The victim
349
609
  hash is taken over the policy artifact (or its parameter tensors), so a
350
- retrained victim never silently reuses a stale adversary. A hosted zoo
351
- of pretrained expert adversaries is the planned paid tier.
610
+ retrained victim never silently reuses a stale adversary.
611
+
612
+ ### Pretrained adversaries (transfer attacks)
613
+
614
+ `cotter.zoo.PretrainedZoo` is a different registry: keyed by **robot
615
+ class** — `(robot_class, env_id, epsilon, attack)` — not by victim. An
616
+ adversary trained per class transfers to *any* compatible victim on that
617
+ env (adversarial policies exploit the environment, not victim-specific
618
+ weights), so you can attack your own policy without training one:
619
+
620
+ ```yaml
621
+ adversarial:
622
+ epsilon: 0.05
623
+ pretrained: franka-panda # transfer-attack from the pretrained zoo
624
+ ```
625
+
626
+ The run loads the class adversary, checks it matches the env, and reports
627
+ a `pretrained_<class>_<surface>` category (a missing class or incompatible
628
+ adversary is skipped, never fatal). Inspect the registry with
629
+ `cotter pretrained list`. The open engine ships the mechanism
630
+ (`register` / `transfer_attack`); a hosted library of GPU-trained experts
631
+ per robot class is the paid tier.
352
632
 
353
633
  ## Compliance layer (paid tier)
354
634
 
@@ -380,7 +660,8 @@ cotter/
380
660
  │ ├── registry.py # resolves gymnasium-robotics (and other) extension envs
381
661
  │ └── factory.py # picklable env factory for parallel rollouts
382
662
  ├── report.py # TestReport: console summary + JSON (results container only)
383
- ├── zoo/ # adversary registry keyed by (env, victim, epsilon)
663
+ ├── render.py # self-contained HTML rendering of a TestReport (free tier)
664
+ ├── zoo/ # adversary registries: victim-keyed cache + class-keyed pretrained
384
665
  ├── compliance/ # paid-tier regulatory document stub (license required)
385
666
  └── tests/
386
667
  ├── sprt.py # Wald's sequential probability ratio test (+ CI)