oopsie-data-tools 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. oopsie_data_tools/__init__.py +7 -0
  2. oopsie_data_tools/annotation_tool/__init__.py +5 -0
  3. oopsie_data_tools/annotation_tool/annotation_schema.py +266 -0
  4. oopsie_data_tools/annotation_tool/annotator_server.py +850 -0
  5. oopsie_data_tools/annotation_tool/episode_recorder.py +648 -0
  6. oopsie_data_tools/annotation_tool/rollout_annotator.py +333 -0
  7. oopsie_data_tools/annotation_tool/ui/annotator.html +2240 -0
  8. oopsie_data_tools/cli.py +894 -0
  9. oopsie_data_tools/init_wizard.py +329 -0
  10. oopsie_data_tools/skill/SKILL.md +98 -0
  11. oopsie_data_tools/skill/reference/conversion.md +257 -0
  12. oopsie_data_tools/skill/reference/format.md +112 -0
  13. oopsie_data_tools/skill/reference/robot-profile.md +104 -0
  14. oopsie_data_tools/skill/reference/setup.md +216 -0
  15. oopsie_data_tools/skill/reference/troubleshooting.md +97 -0
  16. oopsie_data_tools/test/__init__.py +1 -0
  17. oopsie_data_tools/test/conftest.py +174 -0
  18. oopsie_data_tools/test/fixtures/__init__.py +0 -0
  19. oopsie_data_tools/test/fixtures/make_invalid.py +633 -0
  20. oopsie_data_tools/test/fixtures/make_valid.py +406 -0
  21. oopsie_data_tools/test/test_annotation_completeness.py +154 -0
  22. oopsie_data_tools/test/test_annotation_schema.py +190 -0
  23. oopsie_data_tools/test/test_annotator_server.py +240 -0
  24. oopsie_data_tools/test/test_annotator_server_guards.py +146 -0
  25. oopsie_data_tools/test/test_bulk_inference_end_to_end.py +112 -0
  26. oopsie_data_tools/test/test_cli_config.py +111 -0
  27. oopsie_data_tools/test/test_cli_tools.py +438 -0
  28. oopsie_data_tools/test/test_contributor_config.py +38 -0
  29. oopsie_data_tools/test/test_conversion_utils_annotations.py +75 -0
  30. oopsie_data_tools/test/test_credentials_location.py +106 -0
  31. oopsie_data_tools/test/test_diversity.py +33 -0
  32. oopsie_data_tools/test/test_episode_recorder.py +335 -0
  33. oopsie_data_tools/test/test_episode_video_paths.py +173 -0
  34. oopsie_data_tools/test/test_hf_upload.py +234 -0
  35. oopsie_data_tools/test/test_init_wizard.py +193 -0
  36. oopsie_data_tools/test/test_install_skill.py +120 -0
  37. oopsie_data_tools/test/test_migrate_taxonomy_v2.py +255 -0
  38. oopsie_data_tools/test/test_new_profile.py +84 -0
  39. oopsie_data_tools/test/test_paths.py +137 -0
  40. oopsie_data_tools/test/test_python38_compat.py +79 -0
  41. oopsie_data_tools/test/test_record_step_purity.py +127 -0
  42. oopsie_data_tools/test/test_robot_setup.py +244 -0
  43. oopsie_data_tools/test/test_rollout_annotator.py +134 -0
  44. oopsie_data_tools/test/test_rotation_utils.py +126 -0
  45. oopsie_data_tools/test/test_validate.py +494 -0
  46. oopsie_data_tools/utils/__init__.py +1 -0
  47. oopsie_data_tools/utils/claude_skill.py +96 -0
  48. oopsie_data_tools/utils/contributor_config.py +91 -0
  49. oopsie_data_tools/utils/conversion_utils.py +220 -0
  50. oopsie_data_tools/utils/h5.py +60 -0
  51. oopsie_data_tools/utils/h5_inspect.py +167 -0
  52. oopsie_data_tools/utils/hf_limits.py +22 -0
  53. oopsie_data_tools/utils/hf_upload.py +235 -0
  54. oopsie_data_tools/utils/log.py +35 -0
  55. oopsie_data_tools/utils/migrate_taxonomy_v2.py +215 -0
  56. oopsie_data_tools/utils/paths.py +162 -0
  57. oopsie_data_tools/utils/restructure.py +402 -0
  58. oopsie_data_tools/utils/robot_profile/__init__.py +1 -0
  59. oopsie_data_tools/utils/robot_profile/robot_profile.py +240 -0
  60. oopsie_data_tools/utils/robot_profile/rotation_utils.py +119 -0
  61. oopsie_data_tools/utils/robot_profile/template.py +108 -0
  62. oopsie_data_tools/utils/validation/__init__.py +5 -0
  63. oopsie_data_tools/utils/validation/annotation_completeness.py +67 -0
  64. oopsie_data_tools/utils/validation/diversity.py +93 -0
  65. oopsie_data_tools/utils/validation/episode_data.py +54 -0
  66. oopsie_data_tools/utils/validation/episode_loader.py +201 -0
  67. oopsie_data_tools/utils/validation/episode_validator.py +315 -0
  68. oopsie_data_tools/utils/validation/errors.py +17 -0
  69. oopsie_data_tools/utils/validation/validation_utils.py +88 -0
  70. oopsie_data_tools-0.2.0.dist-info/METADATA +131 -0
  71. oopsie_data_tools-0.2.0.dist-info/RECORD +74 -0
  72. oopsie_data_tools-0.2.0.dist-info/WHEEL +4 -0
  73. oopsie_data_tools-0.2.0.dist-info/entry_points.txt +2 -0
  74. oopsie_data_tools-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,406 @@
1
+ #!/usr/bin/env python3
2
+ """Generate a set of valid test HDF5 episodes with synthetic MP4 videos.
3
+
4
+ Writes files into OUTPUT_DIR (default: oopsie_data_tools/test/fixtures/samples/).
5
+ Each fixture covers a distinct annotation scenario so that the annotation tool,
6
+ annotator server, and any downstream readers can be exercised without real data.
7
+
8
+ Scenarios created
9
+ -----------------
10
+ episode_unannotated – minimally annotated success; the general-purpose "valid episode"
11
+ episode_no_annotations – no annotation group at all: passes lenient validation, fails strict
12
+ episode_success – annotated as success by "test_annotator", with notes
13
+ episode_failure – annotated as failure with taxonomy by "test_annotator"
14
+ episode_multi_camera – two cameras (left + wrist), annotated as success
15
+ episode_legacy_v1 – a taxonomy v1 annotation, prose values and all, for back-compat
16
+
17
+ Usage
18
+ -----
19
+ uv run python -m oopsie_data_tools.test.fixtures.make_valid [OUTPUT_DIR]
20
+
21
+ The videos are solid-color 64×64 MP4s (10 frames each). They are tiny
22
+ (< 20 KB) and require only imageio + libx264.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import json
29
+ import time
30
+ from pathlib import Path
31
+
32
+ import h5py
33
+ import imageio
34
+ import numpy as np
35
+
36
+ from oopsie_data_tools.annotation_tool.annotation_schema import write_annotation_attrs
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Helpers
40
+ # ---------------------------------------------------------------------------
41
+
42
+ _STR_DTYPE = h5py.string_dtype(encoding="utf-8")
43
+
44
+ _ROBOT_PROFILE = json.dumps(
45
+ {
46
+ "policy_name": "test_policy",
47
+ "robot_name": "test_robot",
48
+ "is_biarm": False,
49
+ "uses_mobile_base": False,
50
+ "gripper_name": "test_gripper",
51
+ "control_freq": 10,
52
+ "camera_names": ["front"],
53
+ "robot_state_keys": ["joint_position", "gripper_position"],
54
+ "robot_state_joint_names": ["j1", "j2", "j3", "j4", "j5", "j6", "j7"],
55
+ "action_space": ["joint_velocity", "gripper_position"],
56
+ "action_joint_names": ["j1", "j2", "j3", "j4", "j5", "j6", "j7"],
57
+ "orientation_representation": None,
58
+ "controller": None,
59
+ "gains": None,
60
+ "intrinsic_calibration_matrix": None,
61
+ "extrinsic_calibration_matrix": None,
62
+ }
63
+ )
64
+
65
+ _ROBOT_PROFILE_MULTI_CAM = json.dumps(
66
+ {
67
+ **json.loads(_ROBOT_PROFILE),
68
+ "camera_names": ["left", "wrist"],
69
+ }
70
+ )
71
+
72
+
73
+ def _write_video(path: Path, color: tuple[int, int, int], n_frames: int = 25, size: int = 224) -> None:
74
+ frames = np.full((n_frames, size, size, 3), color, dtype=np.uint8)
75
+ with imageio.get_writer(
76
+ str(path),
77
+ format="FFMPEG",
78
+ mode="I",
79
+ fps=10,
80
+ codec="libx264",
81
+ output_params=["-crf", "28"],
82
+ ) as writer:
83
+ for frame in frames:
84
+ writer.append_data(frame)
85
+
86
+
87
+ def _robot_states(n: int = 20) -> dict[str, np.ndarray]:
88
+ return {
89
+ "joint_position": np.random.randn(n, 7).astype(np.float64),
90
+ "gripper_position": np.random.randn(n, 1).astype(np.float64),
91
+ }
92
+
93
+
94
+ def _actions(n: int = 20) -> dict[str, np.ndarray | None]:
95
+ return {
96
+ "joint_velocity": np.random.randn(n, 7).astype(np.float64),
97
+ "gripper_position": np.random.randn(n, 1).astype(np.float64),
98
+ "cartesian_position": None,
99
+ "cartesian_velocity": None,
100
+ "joint_position": None,
101
+ "base_position": None,
102
+ "base_velocity": None,
103
+ "gripper_velocity": None,
104
+ "gripper_binary": None,
105
+ }
106
+
107
+
108
+ def _write_base_h5(
109
+ f: h5py.File,
110
+ *,
111
+ episode_id: str,
112
+ language_instruction: str,
113
+ camera_video_paths: dict[str, str],
114
+ robot_profile_json: str = _ROBOT_PROFILE,
115
+ n_steps: int = 20,
116
+ ) -> None:
117
+ f.attrs["schema"] = "oopsiedata_format_v1"
118
+ f.attrs["episode_id"] = episode_id
119
+ f.attrs["robot_profile"] = robot_profile_json
120
+ f.attrs["language_instruction"] = language_instruction
121
+ f.attrs["operator_name"] = "test_operator"
122
+ f.attrs["lab_id"] = "test_lab"
123
+ f.attrs["timestamp"] = time.time()
124
+
125
+ obs_group = f.create_group("observations")
126
+ vp_group = obs_group.create_group("video_paths")
127
+ for cam, rel_path in camera_video_paths.items():
128
+ vp_group.create_dataset(cam, data=rel_path, dtype=_STR_DTYPE)
129
+ rs = obs_group.create_group("robot_states")
130
+ for key, arr in _robot_states(n_steps).items():
131
+ rs.create_dataset(key, data=arr, dtype=np.float64)
132
+
133
+ actions_group = f.create_group("actions")
134
+ for key, val in _actions(n_steps).items():
135
+ if val is None:
136
+ actions_group.create_dataset(key, data=h5py.Empty(dtype=np.float64))
137
+ else:
138
+ actions_group.create_dataset(key, data=val, dtype=np.float64)
139
+
140
+
141
+ def _write_annotation(
142
+ f: h5py.File,
143
+ *,
144
+ annotator: str,
145
+ outcome: str,
146
+ episode_description: str = "",
147
+ side_effect_category: list[str] | None = None,
148
+ severity: str = "",
149
+ additional_notes: str = "",
150
+ ) -> None:
151
+ """Write a v2 annotation through the real writer.
152
+
153
+ Going through ``write_annotation_attrs`` rather than hand-rolling the attrs is the
154
+ point: a fixture that spells the attr set out itself keeps passing after a schema
155
+ change, which is exactly when it should fail.
156
+ """
157
+ ea = f.require_group("episode_annotations")
158
+ ag = ea.require_group(annotator)
159
+ write_annotation_attrs(
160
+ ag,
161
+ {
162
+ "outcome": outcome,
163
+ "timestamp": "2026-04-21T10:00:00+00:00",
164
+ "episode_description": episode_description,
165
+ "side_effect_category": list(side_effect_category or []),
166
+ "severity": severity,
167
+ "additional_notes": additional_notes,
168
+ },
169
+ )
170
+
171
+
172
+ def _write_legacy_v1_annotation(
173
+ f: h5py.File,
174
+ *,
175
+ annotator: str,
176
+ success: float,
177
+ failure_description: str = "",
178
+ failure_category: list[str] | None = None,
179
+ severity: str = "",
180
+ success_category: str = "",
181
+ ) -> None:
182
+ """Write the pre-v2 attr set verbatim, prose values and all.
183
+
184
+ Deliberately hand-rolled: this fixture's job is to pin what v1 files actually look
185
+ like on disk, so it must not follow the writer as it moves on.
186
+ """
187
+ ea = f.require_group("episode_annotations")
188
+ ag = ea.require_group(annotator)
189
+ taxonomy: dict = {
190
+ "failure_category": list(failure_category or []),
191
+ "severity": severity,
192
+ }
193
+ if success_category:
194
+ taxonomy["success_category"] = success_category
195
+ ag.attrs["schema"] = "oopsie_failure_taxonomy_v1"
196
+ ag.attrs["source"] = "human"
197
+ ag.attrs["timestamp"] = "2026-04-21T10:00:00+00:00"
198
+ ag.attrs["success"] = success
199
+ ag.attrs["failure_description"] = failure_description
200
+ ag.attrs["taxonomy_schema"] = "oopsiedata_taxonomy_schema_v1"
201
+ ag.attrs["taxonomy"] = json.dumps(taxonomy, ensure_ascii=False)
202
+ ag.attrs["additional_notes"] = ""
203
+
204
+
205
+ # ---------------------------------------------------------------------------
206
+ # Public helper for per-test fixture generation
207
+ # ---------------------------------------------------------------------------
208
+
209
+
210
+ def write_valid_episode(
211
+ out_dir: Path,
212
+ stem: str = "episode",
213
+ *,
214
+ cam_color: tuple[int, int, int] = (120, 160, 200),
215
+ n: int = 20,
216
+ ) -> Path:
217
+ """Write a single valid episode (HDF5 + MP4) into *out_dir* and return the HDF5 path.
218
+
219
+ Intended for use in pytest fixtures that need an isolated valid episode,
220
+ e.g. ``tmp_path``-based per-test fixtures.
221
+ """
222
+ mp4_name = f"{stem}_front.mp4"
223
+ _write_video(out_dir / mp4_name, color=cam_color)
224
+ h5_path = out_dir / f"{stem}.h5"
225
+ with h5py.File(h5_path, "w") as f:
226
+ _write_base_h5(
227
+ f,
228
+ episode_id=stem,
229
+ language_instruction="pick up the block",
230
+ camera_video_paths={"front": mp4_name},
231
+ n_steps=n,
232
+ )
233
+ _write_annotation(
234
+ f,
235
+ annotator="test_annotator",
236
+ outcome="success",
237
+ )
238
+ return h5_path
239
+
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # Fixture writers
243
+ # ---------------------------------------------------------------------------
244
+
245
+
246
+ def make_unannotated(out_dir: Path) -> None:
247
+ # Despite the name, this *is* annotated — a minimal success. It is the general-purpose
248
+ # "valid episode" behind the `valid_episode` fixture, and validation through the CLI
249
+ # checks annotations unconditionally, so it has to carry one to be usable as such.
250
+ # For an episode with no annotation group, use make_no_annotations below.
251
+ _write_video(out_dir / "episode_unannotated_front.mp4", color=(80, 120, 200))
252
+ with h5py.File(out_dir / "episode_unannotated.h5", "w") as f:
253
+ _write_base_h5(
254
+ f,
255
+ episode_id="episode_unannotated",
256
+ language_instruction="pick up the red block",
257
+ camera_video_paths={"front": "episode_unannotated_front.mp4"},
258
+ )
259
+ _write_annotation(
260
+ f,
261
+ annotator="test_annotator",
262
+ outcome="success",
263
+ )
264
+
265
+
266
+ def make_no_annotations(out_dir: Path) -> None:
267
+ """A structurally valid episode carrying no ``episode_annotations`` group.
268
+
269
+ This is what a freshly recorded, not-yet-annotated episode looks like: it satisfies
270
+ every structural rule, so it passes lenient validation, and fails the strict check
271
+ that ``oopsie-data validate`` and ``upload`` actually run.
272
+ """
273
+ _write_video(out_dir / "episode_no_annotations_front.mp4", color=(80, 120, 200))
274
+ with h5py.File(out_dir / "episode_no_annotations.h5", "w") as f:
275
+ _write_base_h5(
276
+ f,
277
+ episode_id="episode_no_annotations",
278
+ language_instruction="pick up the red block",
279
+ camera_video_paths={"front": "episode_no_annotations_front.mp4"},
280
+ )
281
+
282
+
283
+ def make_success(out_dir: Path) -> None:
284
+ _write_video(out_dir / "episode_success_front.mp4", color=(60, 180, 80))
285
+ with h5py.File(out_dir / "episode_success.h5", "w") as f:
286
+ _write_base_h5(
287
+ f,
288
+ episode_id="episode_success",
289
+ language_instruction="place the cup on the tray",
290
+ camera_video_paths={"front": "episode_success_front.mp4"},
291
+ )
292
+ _write_annotation(
293
+ f,
294
+ annotator="test_annotator",
295
+ outcome="success",
296
+ additional_notes="Clean success, no issues.",
297
+ )
298
+
299
+
300
+ def make_failure(out_dir: Path) -> None:
301
+ _write_video(out_dir / "episode_failure_front.mp4", color=(200, 60, 60))
302
+ with h5py.File(out_dir / "episode_failure.h5", "w") as f:
303
+ _write_base_h5(
304
+ f,
305
+ episode_id="episode_failure",
306
+ language_instruction="stack the two blocks",
307
+ camera_video_paths={"front": "episode_failure_front.mp4"},
308
+ )
309
+ _write_annotation(
310
+ f,
311
+ annotator="test_annotator",
312
+ outcome="failure",
313
+ episode_description="Robot grasped the block but dropped it during transport.",
314
+ side_effect_category=["grasp", "manipulation"],
315
+ severity="medium",
316
+ additional_notes="Happens consistently at the same waypoint.",
317
+ )
318
+
319
+
320
+ def make_multi_camera(out_dir: Path) -> None:
321
+ _write_video(out_dir / "episode_multi_camera_left.mp4", color=(160, 80, 200))
322
+ _write_video(out_dir / "episode_multi_camera_wrist.mp4", color=(200, 160, 40))
323
+ with h5py.File(out_dir / "episode_multi_camera.h5", "w") as f:
324
+ _write_base_h5(
325
+ f,
326
+ episode_id="episode_multi_camera",
327
+ language_instruction="open the drawer",
328
+ camera_video_paths={
329
+ "left": "episode_multi_camera_left.mp4",
330
+ "wrist": "episode_multi_camera_wrist.mp4",
331
+ },
332
+ robot_profile_json=_ROBOT_PROFILE_MULTI_CAM,
333
+ )
334
+ _write_annotation(
335
+ f,
336
+ annotator="test_annotator",
337
+ outcome="success",
338
+ )
339
+
340
+
341
+ def make_legacy_v1(out_dir: Path) -> None:
342
+ """A file still carrying the v1 annotation schema.
343
+
344
+ Readers upcast v1 on the fly and never rewrite it, so this pins that path: prose
345
+ category and severity values, ``failure_description`` rather than
346
+ ``episode_description``, and a partial taxonomy that v1 validation used to reject.
347
+ """
348
+ _write_video(out_dir / "episode_legacy_v1_front.mp4", color=(90, 140, 90))
349
+ with h5py.File(out_dir / "episode_legacy_v1.h5", "w") as f:
350
+ _write_base_h5(
351
+ f,
352
+ episode_id="episode_legacy_v1",
353
+ language_instruction="stack the red block on the blue block",
354
+ camera_video_paths={"front": "episode_legacy_v1_front.mp4"},
355
+ )
356
+ _write_legacy_v1_annotation(
357
+ f,
358
+ annotator="test_annotator",
359
+ success=0.0,
360
+ failure_description="Gripper closed early and knocked the block over.",
361
+ failure_category=["Grasp failure (at contact)", "Collision failure"],
362
+ severity="Medium severity - some damage or risk of damage or significant reset required, but can be reattempted",
363
+ )
364
+
365
+
366
+ # ---------------------------------------------------------------------------
367
+ # Entry point
368
+ # ---------------------------------------------------------------------------
369
+
370
+ _DEFAULT_OUT = Path(__file__).resolve().parent / "samples"
371
+
372
+
373
+ def main() -> None:
374
+ parser = argparse.ArgumentParser(description=__doc__)
375
+ parser.add_argument(
376
+ "output_dir",
377
+ nargs="?",
378
+ type=Path,
379
+ default=_DEFAULT_OUT,
380
+ help=f"Destination directory (default: {_DEFAULT_OUT})",
381
+ )
382
+ args = parser.parse_args()
383
+ out: Path = args.output_dir
384
+ out.mkdir(parents=True, exist_ok=True)
385
+
386
+ for maker in [
387
+ make_unannotated,
388
+ make_no_annotations,
389
+ make_success,
390
+ make_failure,
391
+ make_multi_camera,
392
+ make_legacy_v1,
393
+ ]:
394
+ name = maker.__name__.replace("make_", "")
395
+ print(f" writing {name}...", end=" ", flush=True)
396
+ maker(out)
397
+ print("done")
398
+
399
+ h5_files = list(out.glob("*.h5"))
400
+ mp4_files = list(out.glob("*.mp4"))
401
+ print(f"\nFixtures written to: {out}")
402
+ print(f" {len(h5_files)} HDF5 files, {len(mp4_files)} MP4 files")
403
+
404
+
405
+ if __name__ == "__main__":
406
+ main()
@@ -0,0 +1,154 @@
1
+ """What is stored on disk and what the form hands over must mean the same thing.
2
+
3
+ Validation no longer has an opinion here — only ``outcome`` is required, and every taxonomy
4
+ field is optional. What remains is the UI's tick, which tells an annotator whether the
5
+ outcome they picked still has blank fields. That answer must not depend on whether the
6
+ annotation came out of an HDF5 file or straight off the form, so both shapes are run through
7
+ :func:`read_annotation_attrs` and compared.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+
14
+ import pytest
15
+
16
+ from oopsie_data_tools.annotation_tool.annotation_schema import (
17
+ OUTCOMES,
18
+ read_annotation_attrs,
19
+ write_annotation_attrs,
20
+ )
21
+ from oopsie_data_tools.annotation_tool.annotator_server import _annotation_tick_level
22
+ from oopsie_data_tools.utils.validation.annotation_completeness import (
23
+ OUTCOME_EXPECTED_FIELDS,
24
+ completeness_flags,
25
+ is_complete,
26
+ )
27
+
28
+ NONE, PARTIAL, COMPLETE = 0, 1, 2
29
+
30
+
31
+ def _h5_attrs(outcome, category, description, severity) -> dict:
32
+ """Annotation as the HDF5 stores it: category and severity inside a taxonomy blob."""
33
+ return {
34
+ "episode_description": description,
35
+ "taxonomy": json.dumps(
36
+ {
37
+ "outcome": outcome,
38
+ "side_effect_category": category,
39
+ "severity": severity,
40
+ }
41
+ ),
42
+ }
43
+
44
+
45
+ def _form_dict(outcome, category, description, severity) -> dict:
46
+ """The same annotation as the form hands it over: flat keys."""
47
+ return {
48
+ "outcome": outcome,
49
+ "side_effect_category": category,
50
+ "episode_description": description,
51
+ "severity": severity,
52
+ }
53
+
54
+
55
+ @pytest.mark.parametrize(
56
+ "category,description,severity",
57
+ [
58
+ ([], "", ""),
59
+ (["grasp"], "slipped", "low"),
60
+ ([], "slipped", ""),
61
+ (["grasp"], "", "low"),
62
+ ([""], " ", " "), # whitespace and an empty string are not "filled"
63
+ ("grasp", "slipped", "low"), # category as a scalar, from older records
64
+ ],
65
+ )
66
+ def test_both_shapes_agree_on_which_fields_are_filled(category, description, severity):
67
+ """The stored and in-flight representations must resolve to the same flags."""
68
+ stored = read_annotation_attrs(_h5_attrs("failure", category, description, severity))
69
+ direct = _form_dict("failure", category, description, severity)
70
+
71
+ assert completeness_flags(stored) == completeness_flags(direct)
72
+
73
+
74
+ def test_whitespace_does_not_count_as_filled():
75
+ flags = completeness_flags(_form_dict("failure", [], " ", "\t"))
76
+ assert flags == {
77
+ "episode_description": False,
78
+ "side_effect_category": False,
79
+ "severity": False,
80
+ }
81
+
82
+
83
+ def test_an_empty_category_list_is_not_filled():
84
+ flags = completeness_flags(_form_dict("failure", [], "x", "x"))
85
+ assert flags["side_effect_category"] is False
86
+ assert flags["episode_description"] is True
87
+
88
+
89
+ # ── The tick, per outcome ─────────────────────────────────────────────────────
90
+
91
+
92
+ @pytest.mark.parametrize(
93
+ "label,outcome,category,description,severity,tick",
94
+ [
95
+ # A clean success asks for nothing else, so it is complete the moment it is picked.
96
+ ("clean success", "success", [], "", "", COMPLETE),
97
+ # Anything set on a clean success is ignored: those fields are never shown for it.
98
+ ("clean success ignores extras", "success", ["grasp"], "x", "low", COMPLETE),
99
+ ("suboptimal without a description", "success_suboptimal", [], "", "", PARTIAL),
100
+ ("suboptimal with a description", "success_suboptimal", [], "clumsy", "", COMPLETE),
101
+ # Category and severity are not shown for suboptimal, so they cannot hold it back.
102
+ ("suboptimal ignores category", "success_suboptimal", [], "clumsy", "", COMPLETE),
103
+ ("side-effect, nothing filled", "success_side_effect", [], "", "", PARTIAL),
104
+ ("side-effect, partly filled", "success_side_effect", ["collision"], "", "", PARTIAL),
105
+ (
106
+ "side-effect, fully filled",
107
+ "success_side_effect",
108
+ ["collision"],
109
+ "clipped a cup",
110
+ "low",
111
+ COMPLETE,
112
+ ),
113
+ # Previously this combination was rejected outright; now it is simply unfinished.
114
+ ("failure, nothing filled", "failure", [], "", "", PARTIAL),
115
+ ("failure, only a description", "failure", [], "slipped", "", PARTIAL),
116
+ ("failure, fully filled", "failure", ["grasp"], "slipped", "low", COMPLETE),
117
+ ],
118
+ )
119
+ def test_tick_level_per_outcome(label, outcome, category, description, severity, tick):
120
+ form = _form_dict(outcome, category, description, severity)
121
+ assert _annotation_tick_level(form) == tick, label
122
+
123
+ stored = read_annotation_attrs(_h5_attrs(outcome, category, description, severity))
124
+ assert _annotation_tick_level(stored) == tick, f"{label} (via stored attrs)"
125
+
126
+
127
+ def test_an_unannotated_episode_ticks_none():
128
+ assert _annotation_tick_level({"outcome": ""}) == NONE
129
+ assert _annotation_tick_level({}) == NONE
130
+
131
+
132
+ def test_an_unrecognized_outcome_ticks_none():
133
+ """A slug the vocabulary does not know is not a partial annotation — it is no signal."""
134
+ assert _annotation_tick_level({"outcome": "sort_of_worked"}) == NONE
135
+
136
+
137
+ def test_expected_fields_cover_every_outcome():
138
+ """The tick's field table and the schema's outcome list must not drift apart."""
139
+ assert set(OUTCOME_EXPECTED_FIELDS) == set(OUTCOMES)
140
+
141
+
142
+ def test_a_round_trip_through_hdf5_preserves_completeness(tmp_path):
143
+ """Whatever the writer stores must read back with the same tick it went in with."""
144
+ import h5py
145
+
146
+ form = _form_dict("success_side_effect", ["collision"], "clipped a cup", "low")
147
+ path = tmp_path / "round_trip.h5"
148
+ with h5py.File(path, "w") as f:
149
+ write_annotation_attrs(f.require_group("episode_annotations/a"), form)
150
+ with h5py.File(path, "r") as f:
151
+ stored = read_annotation_attrs(f["episode_annotations/a"].attrs)
152
+
153
+ assert is_complete(stored) is True
154
+ assert _annotation_tick_level(stored) == COMPLETE