x4d-devkit 0.16.0__tar.gz → 0.17.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 (105) hide show
  1. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/PKG-INFO +7 -6
  2. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/README.md +5 -5
  3. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/pyproject.toml +2 -2
  4. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/tests/test_boundary.py +2 -0
  5. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/tests/test_box.py +17 -10
  6. x4d_devkit-0.17.0/tests/test_contract_validation.py +413 -0
  7. x4d_devkit-0.17.0/tests/test_coordinate_frames.py +81 -0
  8. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/tests/test_current_state.py +34 -6
  9. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/tests/test_current_state_identity_consumers_example.py +1 -1
  10. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/tests/test_dataset_identity.py +84 -85
  11. x4d_devkit-0.17.0/tests/test_detection_eval.py +23 -0
  12. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/tests/test_evaluate.py +4 -4
  13. x4d_devkit-0.17.0/tests/test_label_schemas.py +453 -0
  14. x4d_devkit-0.17.0/tests/test_loader.py +115 -0
  15. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/tests/test_matching.py +1 -1
  16. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/tests/test_metrics.py +5 -5
  17. x4d_devkit-0.17.0/tests/test_models.py +167 -0
  18. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/tests/test_nuscenes_converter.py +160 -44
  19. x4d_devkit-0.17.0/tests/test_sdk_api.py +47 -0
  20. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/tests/test_training_identity.py +32 -19
  21. x4d_devkit-0.17.0/tests/test_training_manifest.py +455 -0
  22. x4d_devkit-0.17.0/tests/test_transform_chain.py +51 -0
  23. x4d_devkit-0.17.0/tests/test_validation.py +92 -0
  24. x4d_devkit-0.17.0/tests/test_work_session.py +409 -0
  25. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/__init__.py +4 -0
  26. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/cli.py +2 -28
  27. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/client/clip_work_sessions.py +4 -8
  28. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/client/label_schemas.py +11 -6
  29. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/converters/nuscenes.py +232 -145
  30. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/core/__init__.py +6 -1
  31. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/core/loader.py +141 -154
  32. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/core/models.py +170 -51
  33. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/core/transform.py +35 -90
  34. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/current_state.py +83 -25
  35. x4d_devkit-0.17.0/x4d_devkit/dataset_contract.py +408 -0
  36. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/dataset_identity.py +57 -14
  37. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/eval/api.py +2 -2
  38. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/eval/box.py +23 -14
  39. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/eval/detection.py +0 -5
  40. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/eval/metrics.py +6 -4
  41. x4d_devkit-0.17.0/x4d_devkit/label_schema.py +2093 -0
  42. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/manifest.py +251 -223
  43. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/materialize.py +9 -9
  44. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/training_identity.py +79 -18
  45. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/validation/__init__.py +2 -1
  46. x4d_devkit-0.17.0/x4d_devkit/validation/contract.py +1544 -0
  47. x4d_devkit-0.17.0/x4d_devkit/validation/validator.py +817 -0
  48. x4d_devkit-0.17.0/x4d_devkit/work_session.py +568 -0
  49. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit.egg-info/PKG-INFO +7 -6
  50. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit.egg-info/SOURCES.txt +2 -3
  51. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit.egg-info/requires.txt +1 -0
  52. x4d_devkit-0.16.0/tests/test_contract_validation.py +0 -537
  53. x4d_devkit-0.16.0/tests/test_coordinate_frames.py +0 -501
  54. x4d_devkit-0.16.0/tests/test_detection_eval.py +0 -195
  55. x4d_devkit-0.16.0/tests/test_label_schemas.py +0 -565
  56. x4d_devkit-0.16.0/tests/test_loader.py +0 -254
  57. x4d_devkit-0.16.0/tests/test_models.py +0 -232
  58. x4d_devkit-0.16.0/tests/test_training_manifest.py +0 -824
  59. x4d_devkit-0.16.0/tests/test_transform_chain.py +0 -88
  60. x4d_devkit-0.16.0/tests/test_v06_api_additions.py +0 -161
  61. x4d_devkit-0.16.0/tests/test_v06_schema.py +0 -414
  62. x4d_devkit-0.16.0/tests/test_v10_schema.py +0 -379
  63. x4d_devkit-0.16.0/tests/test_validation.py +0 -421
  64. x4d_devkit-0.16.0/tests/test_work_session.py +0 -601
  65. x4d_devkit-0.16.0/x4d_devkit/label_schema.py +0 -1451
  66. x4d_devkit-0.16.0/x4d_devkit/validation/contract.py +0 -954
  67. x4d_devkit-0.16.0/x4d_devkit/validation/validator.py +0 -587
  68. x4d_devkit-0.16.0/x4d_devkit/work_session.py +0 -388
  69. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/LICENSE +0 -0
  70. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/setup.cfg +0 -0
  71. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/tests/test_build_info.py +0 -0
  72. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/tests/test_cli_auth.py +0 -0
  73. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/tests/test_client_projects.py +0 -0
  74. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/tests/test_clip_archives.py +0 -0
  75. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/tests/test_inference_builders.py +0 -0
  76. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/tests/test_inference_schemas.py +0 -0
  77. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/tests/test_inference_service.py +0 -0
  78. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/tests/test_token.py +0 -0
  79. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/tests/test_transform.py +0 -0
  80. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/build_info.py +0 -0
  81. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/client/__init__.py +0 -0
  82. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/client/bags.py +0 -0
  83. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/client/capabilities.py +0 -0
  84. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/client/checkpoints.py +0 -0
  85. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/client/client.py +0 -0
  86. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/client/clips.py +0 -0
  87. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/client/dataset_identity.py +0 -0
  88. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/client/projects.py +0 -0
  89. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/client/services.py +0 -0
  90. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/client/test_sets.py +0 -0
  91. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/converters/__init__.py +0 -0
  92. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/coordinate_contract.py +0 -0
  93. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/core/token.py +0 -0
  94. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/eval/__init__.py +0 -0
  95. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/eval/matching.py +0 -0
  96. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/inference/__init__.py +0 -0
  97. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/inference/builders.py +0 -0
  98. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/inference/models.py +0 -0
  99. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/inference/schemas.py +0 -0
  100. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/inference/service.py +0 -0
  101. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/inference/tasks.py +0 -0
  102. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit/validation/report.py +0 -0
  103. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit.egg-info/dependency_links.txt +0 -0
  104. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit.egg-info/entry_points.txt +0 -0
  105. {x4d_devkit-0.16.0 → x4d_devkit-0.17.0}/x4d_devkit.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: x4d-devkit
3
- Version: 0.16.0
3
+ Version: 0.17.0
4
4
  Summary: Official X-4D external SDK and CLI for datasets, platform APIs, training bridges, and automation
5
5
  Author: windzu
6
6
  License-Expression: Apache-2.0
@@ -21,6 +21,7 @@ Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
22
  Requires-Dist: numpy>=1.24
23
23
  Requires-Dist: httpx>=0.27
24
+ Requires-Dist: rfc8785==0.1.4
24
25
  Provides-Extra: converters
25
26
  Requires-Dist: nuscenes-devkit>=1.1.0; extra == "converters"
26
27
  Provides-Extra: inference
@@ -294,12 +295,12 @@ label_schema.projects.<project>.contract # binding + full registry record
294
295
  label_schema.projects.<project>.class_catalog # training/observed class catalog
295
296
  ```
296
297
 
297
- The deprecated per-project `classes`, `source`, and `schema_version` aliases
298
- remain in manifest v0.1 for existing consumers. New consumers must parse
299
- `contract` with `parse_project_label_schema_snapshot()` and treat
300
- `class_catalog` as non-authoritative observation metadata.
298
+ Consumers must parse `contract` with
299
+ `parse_project_label_schema_snapshot()`. `class_catalog` is
300
+ non-authoritative observation metadata; there are no per-project
301
+ `classes`, `source`, or `schema_version` aliases.
301
302
 
302
- This strict parser/view contract is introduced in x4d-devkit `0.16.0`.
303
+ This strict parser/view contract is introduced in x4d-devkit `0.17.0`.
303
304
  Consumers tracking `main` should still record `get_build_info()["source"]` in
304
305
  training identity so an untagged development run remains reproducible by exact
305
306
  commit. Deployments that copy a source tree without its Git directory must set
@@ -260,12 +260,12 @@ label_schema.projects.<project>.contract # binding + full registry record
260
260
  label_schema.projects.<project>.class_catalog # training/observed class catalog
261
261
  ```
262
262
 
263
- The deprecated per-project `classes`, `source`, and `schema_version` aliases
264
- remain in manifest v0.1 for existing consumers. New consumers must parse
265
- `contract` with `parse_project_label_schema_snapshot()` and treat
266
- `class_catalog` as non-authoritative observation metadata.
263
+ Consumers must parse `contract` with
264
+ `parse_project_label_schema_snapshot()`. `class_catalog` is
265
+ non-authoritative observation metadata; there are no per-project
266
+ `classes`, `source`, or `schema_version` aliases.
267
267
 
268
- This strict parser/view contract is introduced in x4d-devkit `0.16.0`.
268
+ This strict parser/view contract is introduced in x4d-devkit `0.17.0`.
269
269
  Consumers tracking `main` should still record `get_build_info()["source"]` in
270
270
  training identity so an untagged development run remains reproducible by exact
271
271
  commit. Deployments that copy a source tree without its Git directory must set
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "x4d-devkit"
7
- version = "0.16.0"
7
+ version = "0.17.0"
8
8
  description = "Official X-4D external SDK and CLI for datasets, platform APIs, training bridges, and automation"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -24,7 +24,7 @@ classifiers = [
24
24
  "Programming Language :: Python :: 3.12",
25
25
  "Topic :: Scientific/Engineering :: Artificial Intelligence",
26
26
  ]
27
- dependencies = ["numpy>=1.24", "httpx>=0.27"]
27
+ dependencies = ["numpy>=1.24", "httpx>=0.27", "rfc8785==0.1.4"]
28
28
 
29
29
  [project.optional-dependencies]
30
30
  converters = ["nuscenes-devkit>=1.1.0"]
@@ -65,6 +65,7 @@ def test_top_level_exports_only_stable_sdk_surface():
65
65
  "Annotation",
66
66
  "AssetRef",
67
67
  "CalibratedSensor",
68
+ "ClipContractValidationError",
68
69
  "ClipLoader",
69
70
  "ClipMeta",
70
71
  "ClipWorkSessionManifest",
@@ -87,6 +88,7 @@ def test_top_level_exports_only_stable_sdk_surface():
87
88
  "get_build_info",
88
89
  "get_training_coordinate_contract",
89
90
  "materialize_work_session_clip",
91
+ "parse_clip_work_session",
90
92
  "validate_clip",
91
93
  }
92
94
  for name in (
@@ -21,7 +21,7 @@ class TestBoxConstruction:
21
21
  assert b.category == "car"
22
22
  assert b.score is None
23
23
  assert b.velocity is None
24
- assert b.attributes == []
24
+ assert b.attributes == {}
25
25
  assert b.id is None
26
26
  assert b.geometry_type == "normal"
27
27
 
@@ -33,12 +33,12 @@ class TestBoxConstruction:
33
33
  category="car",
34
34
  score=0.85,
35
35
  velocity=[1.0, 0.5, 0.0],
36
- attributes=["vehicle.moving"],
36
+ attributes={"vehicle.moving": True},
37
37
  )
38
38
  assert b.score == 0.85
39
39
  assert b.velocity.dtype == np.float64
40
40
  assert b.velocity.tolist() == [1.0, 0.5, 0.0]
41
- assert b.attributes == ["vehicle.moving"]
41
+ assert b.attributes == {"vehicle.moving": True}
42
42
 
43
43
  def test_identity_and_geometry_type(self):
44
44
  b = Box(
@@ -135,14 +135,14 @@ class TestFromXyzlwhyawVxvy:
135
135
  b = Box.from_xyzlwhyaw_vxvy(
136
136
  [0, 0, 0, 4, 2, 1.5, 0, 0, 0], category="car",
137
137
  )
138
- assert b.attributes == []
138
+ assert b.attributes == {}
139
139
 
140
140
  def test_attributes_passthrough(self):
141
141
  b = Box.from_xyzlwhyaw_vxvy(
142
142
  [0, 0, 0, 4, 2, 1.5, 0, 0, 0], category="car",
143
- attributes=["vehicle.moving"],
143
+ attributes={"vehicle.moving": True},
144
144
  )
145
- assert b.attributes == ["vehicle.moving"]
145
+ assert b.attributes == {"vehicle.moving": True}
146
146
 
147
147
  def test_identity_and_geometry_type_passthrough(self):
148
148
  b = Box.from_xyzlwhyaw_vxvy(
@@ -176,14 +176,21 @@ class TestFromXyzlwhyaw:
176
176
 
177
177
  class TestGeometryTypeFromAttributes:
178
178
  def test_normal_when_geometry_attribute_absent(self):
179
- assert geometry_type_from_attributes(["vehicle.moving"]) == "normal"
179
+ assert geometry_type_from_attributes({"vehicle.moving": True}) == "normal"
180
180
 
181
181
  def test_front_only(self):
182
- assert geometry_type_from_attributes(["geometry.front_only"]) == "front_only"
182
+ assert geometry_type_from_attributes({"geometry.front_only": True}) == "front_only"
183
183
 
184
184
  def test_rear_only(self):
185
- assert geometry_type_from_attributes(["geometry.rear_only"]) == "rear_only"
185
+ assert geometry_type_from_attributes({"geometry.rear_only": True}) == "rear_only"
186
186
 
187
187
  def test_rejects_mutually_exclusive_geometry_attributes(self):
188
188
  with pytest.raises(ValueError, match="mutually exclusive"):
189
- geometry_type_from_attributes(["geometry.front_only", "geometry.rear_only"])
189
+ geometry_type_from_attributes({
190
+ "geometry.front_only": True,
191
+ "geometry.rear_only": True,
192
+ })
193
+
194
+ def test_rejects_false_presence_flag(self):
195
+ with pytest.raises(ValueError, match="literal true"):
196
+ geometry_type_from_attributes({"geometry.front_only": False})
@@ -0,0 +1,413 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import json
5
+
6
+ import pytest
7
+
8
+ from tests.schema_contract_fixtures import schema_identity, schema_record
9
+ from x4d_devkit.label_schema import parse_label_schema_record
10
+ from x4d_devkit.validation.contract import (
11
+ ClipValidationContext,
12
+ validate_annotation_payload,
13
+ validate_clip_contract,
14
+ )
15
+
16
+
17
+ def _schema():
18
+ return parse_label_schema_record(schema_record())
19
+
20
+
21
+ def _meta() -> dict:
22
+ return {
23
+ "clip_id": "clip-a",
24
+ "schema_version": "0.11",
25
+ "session_id": "session-a",
26
+ "vehicle_id": "vehicle-a",
27
+ "capture_time_utc": "2026-07-10T00:00:00Z",
28
+ "duration_s": 0.0,
29
+ "keyframe_count": 1,
30
+ "sweep_count": 0,
31
+ "annotation_source_channel": "lidar",
32
+ "ego_pose_frame_id": "base_link",
33
+ "sensors": {
34
+ "lidar": {
35
+ "modality": "lidar",
36
+ "frame_id": "base_link",
37
+ "point_format": {
38
+ "fields": ["x", "y", "z", "intensity"],
39
+ "types": ["float32", "float32", "float32", "float32"],
40
+ "bytes_per_point": 16,
41
+ },
42
+ }
43
+ },
44
+ "label_schema": schema_identity(),
45
+ }
46
+
47
+
48
+ def _sample() -> dict:
49
+ return {
50
+ "sample_token": "s0",
51
+ "clip_id": "clip-a",
52
+ "timestamp_us": 1,
53
+ "is_keyframe": True,
54
+ "prev": None,
55
+ "next": None,
56
+ }
57
+
58
+
59
+ def _sample_data() -> dict:
60
+ return {
61
+ "sample_data_token": "sd0",
62
+ "sample_token": "s0",
63
+ "ego_pose_token": "ep0",
64
+ "calibrated_sensor_token": "cs0",
65
+ "channel": "lidar",
66
+ "file_path": "samples/lidar/1.bin",
67
+ "timestamp_us": 1,
68
+ "is_keyframe": True,
69
+ "width": None,
70
+ "height": None,
71
+ "prev": None,
72
+ "next": None,
73
+ }
74
+
75
+
76
+ def _annotation(
77
+ *,
78
+ token: str = "a0",
79
+ sample_token: str = "s0",
80
+ instance_token: str = "i0",
81
+ category: str = "car",
82
+ attributes: dict | None = None,
83
+ prev: str | None = None,
84
+ next: str | None = None,
85
+ ) -> dict:
86
+ return {
87
+ "annotation_token": token,
88
+ "sample_token": sample_token,
89
+ "instance_token": instance_token,
90
+ "category": category,
91
+ "bbox_3d": {
92
+ "translation": {"x": 1.0, "y": 2.0, "z": 0.5},
93
+ "size": {"length": 4.0, "width": 2.0, "height": 1.5},
94
+ "rotation": {"qx": 0.0, "qy": 0.0, "qz": 0.0, "qw": 1.0},
95
+ },
96
+ "num_lidar_pts": 5,
97
+ "velocity": None,
98
+ "attributes": attributes if attributes is not None else {"visibility": "1"},
99
+ "prev": prev,
100
+ "next": next,
101
+ }
102
+
103
+
104
+ def _instance(
105
+ *,
106
+ token: str = "i0",
107
+ category: str = "car",
108
+ count: int = 1,
109
+ first: str = "a0",
110
+ last: str = "a0",
111
+ ) -> dict:
112
+ return {
113
+ "instance_token": token,
114
+ "category": category,
115
+ "num_annotations": count,
116
+ "first_annotation_token": first,
117
+ "last_annotation_token": last,
118
+ }
119
+
120
+
121
+ def _context(
122
+ *,
123
+ annotations: list[dict] | None = None,
124
+ instances: list[dict] | None = None,
125
+ meta: dict | None = None,
126
+ sample_data: list[dict] | None = None,
127
+ ) -> ClipValidationContext:
128
+ anns = [_annotation()] if annotations is None else annotations
129
+ insts = [_instance()] if instances is None else instances
130
+ return ClipValidationContext(
131
+ clip_id="clip-a",
132
+ clip_schema_version="0.11",
133
+ label_schema=_schema(),
134
+ samples=[_sample()],
135
+ sample_data=[_sample_data()] if sample_data is None else sample_data,
136
+ annotations=anns,
137
+ instances=insts,
138
+ meta=_meta() if meta is None else meta,
139
+ annotation_frame_id="base_link",
140
+ )
141
+
142
+
143
+ def _codes(report) -> set[str]:
144
+ return {issue.code for issue in report.issues}
145
+
146
+
147
+ def test_valid_current_clip_contract_passes_and_serializes():
148
+ report = validate_clip_contract(_context())
149
+ assert report.ok
150
+ assert report.to_dict()["issue_count"] == 0
151
+ json.dumps(report.to_dict())
152
+
153
+
154
+ @pytest.mark.parametrize(
155
+ ("mutation", "code"),
156
+ [
157
+ (lambda ann: ann.update(category="unknown"), "unknown_category"),
158
+ (lambda ann: ann.update(attributes=["vehicle.moving"]), "invalid_attributes_shape"),
159
+ (lambda ann: ann.update(attributes={"visibility": "1", "unknown": True}), "unknown_attribute"),
160
+ (lambda ann: ann.update(attributes={"visibility": "1", "truck.loaded": True}), "invalid_attribute_for_category"),
161
+ (lambda ann: ann.update(attributes={"visibility": "1", "vehicle.moving": False}), "invalid_attribute_value"),
162
+ (lambda ann: ann.update(attributes={}), "missing_required_attribute"),
163
+ (lambda ann: ann["bbox_3d"]["size"].update(length=-1.0), "invalid_bbox_size"),
164
+ (lambda ann: ann["bbox_3d"]["translation"].update(x=float("nan")), "non_finite_bbox_value"),
165
+ (lambda ann: ann["bbox_3d"]["rotation"].update(qw=2.0), "invalid_bbox_rotation"),
166
+ (lambda ann: ann.update(num_lidar_pts=True), "invalid_num_lidar_pts"),
167
+ (lambda ann: ann.update(velocity={"vx": 1.0, "vy": 0.0}), "invalid_velocity"),
168
+ (lambda ann: ann.update(sample_token="missing"), "sample_token_not_in_clip"),
169
+ (lambda ann: ann.update(instance_token=""), "missing_instance_token"),
170
+ (lambda ann: ann.update(prev=""), "invalid_annotation_shape"),
171
+ (lambda ann: ann.update(score=0.9), "invalid_annotation_shape"),
172
+ ],
173
+ )
174
+ def test_annotation_contract_reports_strict_issue_codes(mutation, code):
175
+ annotation = _annotation()
176
+ mutation(annotation)
177
+ report = validate_annotation_payload(
178
+ _context(),
179
+ annotations=[annotation],
180
+ instances=[_instance()],
181
+ )
182
+ assert code in _codes(report)
183
+
184
+
185
+ @pytest.mark.parametrize(
186
+ "mutation",
187
+ [
188
+ lambda ann: ann["bbox_3d"]["translation"].update(extra=0.0),
189
+ lambda ann: ann["bbox_3d"]["size"].pop("height"),
190
+ lambda ann: ann["bbox_3d"]["rotation"].update(qx="0"),
191
+ lambda ann: ann.update(velocity={"vx": 0.0, "vy": 0.0, "vz": 0.0, "extra": 0.0}),
192
+ ],
193
+ )
194
+ def test_nested_geometry_and_velocity_require_exact_numeric_objects(mutation):
195
+ annotation = _annotation()
196
+ mutation(annotation)
197
+ assert not validate_annotation_payload(
198
+ _context(), annotations=[annotation], instances=[_instance()]
199
+ ).ok
200
+
201
+
202
+ def test_multiple_enum_requires_sorted_unique_nonempty_values():
203
+ annotation = _annotation(
204
+ attributes={"visibility": "1", "vehicle.state_tags": ["turning", "stopped"]}
205
+ )
206
+ assert "invalid_attribute_value" in _codes(
207
+ validate_annotation_payload(_context(), annotations=[annotation], instances=[_instance()])
208
+ )
209
+
210
+
211
+ def test_boolean_mutual_exclusion_uses_literal_true_presence():
212
+ annotation = _annotation(
213
+ category="truck",
214
+ attributes={"visibility": "1", "truck.empty": True, "truck.loaded": True},
215
+ )
216
+ instance = _instance(category="truck")
217
+ assert "mutually_exclusive_attributes" in _codes(
218
+ validate_annotation_payload(_context(), annotations=[annotation], instances=[instance])
219
+ )
220
+
221
+
222
+ def test_instance_shape_tokens_counts_and_chain_are_exact():
223
+ first = _annotation(token="a0", next="a1")
224
+ second = _annotation(token="a1", sample_token="s1", prev="a0")
225
+ valid_instance = _instance(count=2, first="a0", last="a1")
226
+ context = _context()
227
+ context.samples[0]["next"] = "s1"
228
+ context.samples.append({
229
+ "sample_token": "s1",
230
+ "clip_id": "clip-a",
231
+ "timestamp_us": 2,
232
+ "is_keyframe": True,
233
+ "prev": "s0",
234
+ "next": None,
235
+ })
236
+ second_sample_data = _sample_data()
237
+ context.sample_data[0]["next"] = "sd1"
238
+ second_sample_data.update(
239
+ sample_data_token="sd1",
240
+ sample_token="s1",
241
+ timestamp_us=2,
242
+ file_path="samples/lidar/2.bin",
243
+ prev="sd0",
244
+ )
245
+ context.sample_data.append(second_sample_data)
246
+ assert validate_annotation_payload(
247
+ context, annotations=[first, second], instances=[valid_instance]
248
+ ).ok
249
+
250
+ broken = copy.deepcopy(valid_instance)
251
+ broken["extra"] = True
252
+ broken["last_annotation_token"] = "a0"
253
+ report = validate_annotation_payload(
254
+ context, annotations=[first, second], instances=[broken]
255
+ )
256
+ assert {"invalid_instance_shape", "broken_instance_chain"}.issubset(_codes(report))
257
+
258
+
259
+ def test_instance_links_must_be_reciprocal_and_stay_in_one_instance():
260
+ first = _annotation(token="a0", next="a1")
261
+ second = _annotation(token="a1", instance_token="i1", prev=None)
262
+ report = validate_annotation_payload(
263
+ _context(),
264
+ annotations=[first, second],
265
+ instances=[_instance(), _instance(token="i1", first="a1", last="a1")],
266
+ )
267
+ assert "broken_instance_chain" in _codes(report)
268
+
269
+
270
+ def test_draft_frame_scope_skips_final_clip_closure_only():
271
+ report = validate_annotation_payload(
272
+ _context(),
273
+ annotations=[_annotation()],
274
+ instances=[],
275
+ scope="frame",
276
+ phase="draft",
277
+ )
278
+ assert report.ok
279
+
280
+ bad = _annotation(category="unknown")
281
+ report = validate_annotation_payload(
282
+ _context(), annotations=[bad], instances=[], scope="frame", phase="draft"
283
+ )
284
+ assert "unknown_category" in _codes(report)
285
+
286
+
287
+ def test_final_sensor_coverage_requires_every_declared_channel():
288
+ meta = _meta()
289
+ meta["sensors"]["cam_front"] = {"modality": "camera", "frame_id": "cam_front"}
290
+ report = validate_clip_contract(_context(meta=meta))
291
+ assert "incomplete_sensor_coverage" in _codes(report)
292
+
293
+
294
+ def test_context_rejects_non_list_payloads_and_invalid_meta():
295
+ with pytest.raises(TypeError):
296
+ validate_annotation_payload(_context(), annotations=(), instances=[])
297
+ with pytest.raises(TypeError):
298
+ ClipValidationContext(
299
+ clip_id="clip-a",
300
+ clip_schema_version="0.11",
301
+ label_schema=_schema(),
302
+ samples=(),
303
+ meta=_meta(),
304
+ )
305
+
306
+ meta = _meta()
307
+ meta["topic_config_id"] = 1
308
+ assert "invalid_clip_meta" in _codes(validate_clip_contract(_context(meta=meta)))
309
+
310
+
311
+ def test_context_identity_and_derived_frame_must_match():
312
+ meta = _meta()
313
+ meta["label_schema"]["fingerprint"] = "sha256:" + "0" * 64
314
+ report = validate_clip_contract(_context(meta=meta))
315
+ assert "label_schema_fingerprint_mismatch" in _codes(report)
316
+
317
+ context = _context()
318
+ object.__setattr__(context, "annotation_frame_id", "other")
319
+ assert "annotation_frame_mismatch" in _codes(validate_clip_contract(context))
320
+
321
+
322
+ @pytest.mark.parametrize("status", ["draft", "deprecated"])
323
+ def test_shared_contract_requires_active_label_schema_in_every_phase(status):
324
+ context = _context()
325
+ object.__setattr__(context, "label_schema", parse_label_schema_record(schema_record(status=status)))
326
+ for phase in ("draft", "final"):
327
+ assert "inactive_label_schema" in _codes(
328
+ validate_clip_contract(context, phase=phase)
329
+ )
330
+
331
+
332
+ def test_instance_declared_chain_must_cover_disconnected_cycle():
333
+ context = _context()
334
+ samples = []
335
+ sample_data = []
336
+ annotations = []
337
+ for index in range(4):
338
+ samples.append({
339
+ "sample_token": f"s{index}",
340
+ "clip_id": "clip-a",
341
+ "timestamp_us": index + 1,
342
+ "is_keyframe": True,
343
+ "prev": f"s{index - 1}" if index else None,
344
+ "next": f"s{index + 1}" if index < 3 else None,
345
+ })
346
+ row = _sample_data()
347
+ row.update(
348
+ sample_data_token=f"sd{index}",
349
+ sample_token=f"s{index}",
350
+ timestamp_us=index + 1,
351
+ file_path=f"samples/lidar/{index + 1}.bin",
352
+ prev=f"sd{index - 1}" if index else None,
353
+ next=f"sd{index + 1}" if index < 3 else None,
354
+ )
355
+ sample_data.append(row)
356
+ annotations.append(
357
+ _annotation(
358
+ token=f"a{index}",
359
+ sample_token=f"s{index}",
360
+ prev=(None if index == 0 else "a0" if index == 1 else f"a{5 - index}"),
361
+ next=("a1" if index == 0 else None if index == 1 else f"a{5 - index}"),
362
+ )
363
+ )
364
+ object.__setattr__(context, "samples", samples)
365
+ object.__setattr__(context, "sample_data", sample_data)
366
+ report = validate_annotation_payload(
367
+ context,
368
+ annotations=annotations,
369
+ instances=[_instance(count=4, first="a0", last="a1")],
370
+ )
371
+ assert "broken_instance_chain" in _codes(report)
372
+
373
+
374
+ def test_shared_contract_rejects_nonreciprocal_sample_data_chain():
375
+ context = _context()
376
+ context.samples[0]["next"] = "s1"
377
+ context.samples.append({
378
+ "sample_token": "s1",
379
+ "clip_id": "clip-a",
380
+ "timestamp_us": 2,
381
+ "is_keyframe": True,
382
+ "prev": "s0",
383
+ "next": None,
384
+ })
385
+ context.sample_data[0]["next"] = "sd1"
386
+ second = _sample_data()
387
+ second.update(
388
+ sample_data_token="sd1",
389
+ sample_token="s1",
390
+ timestamp_us=2,
391
+ file_path="samples/lidar/2.bin",
392
+ prev=None,
393
+ )
394
+ context.sample_data.append(second)
395
+ assert "sample_data_chain" in _codes(validate_clip_contract(context))
396
+
397
+
398
+ def test_sample_chain_rejects_disconnected_cycle():
399
+ context = _context()
400
+ object.__setattr__(context, "samples", [
401
+ {"sample_token": "s0", "clip_id": "clip-a", "timestamp_us": 1, "is_keyframe": True, "prev": None, "next": "s1"},
402
+ {"sample_token": "s1", "clip_id": "clip-a", "timestamp_us": 2, "is_keyframe": True, "prev": "s0", "next": None},
403
+ {"sample_token": "s2", "clip_id": "clip-a", "timestamp_us": 3, "is_keyframe": True, "prev": "s3", "next": "s3"},
404
+ {"sample_token": "s3", "clip_id": "clip-a", "timestamp_us": 4, "is_keyframe": True, "prev": "s2", "next": "s2"},
405
+ ])
406
+ assert "broken_sample_chain" in _codes(validate_clip_contract(context))
407
+
408
+
409
+ def test_invalid_scope_and_phase_are_rejected():
410
+ with pytest.raises(ValueError):
411
+ validate_annotation_payload(_context(), annotations=[], instances=[], scope="dataset")
412
+ with pytest.raises(ValueError):
413
+ validate_annotation_payload(_context(), annotations=[], instances=[], phase="publish")
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ import pytest
5
+
6
+ from tests.current_clip_fixtures import write_current_clip
7
+ from x4d_devkit.core.loader import CLIP_WORLD_FRAME_ID, ClipLoader
8
+
9
+
10
+ @pytest.fixture()
11
+ def loader(tmp_path):
12
+ clip = write_current_clip(
13
+ tmp_path / "coordinate-clip",
14
+ sample_count=2,
15
+ include_camera=True,
16
+ include_fused=True,
17
+ )
18
+ return ClipLoader(clip)
19
+
20
+
21
+ def test_current_frame_ids_are_explicit_and_annotation_frame_is_derived(loader):
22
+ sd = loader.sample_data_for_channel("lidar_top")[0]
23
+ assert loader.ego_pose_frame_id == "base_link"
24
+ assert loader.annotation_source_channel == "fused"
25
+ assert loader.annotation_frame_id == "base_link"
26
+ assert loader.sensor_frame_id(sd) == "lidar_top"
27
+ assert CLIP_WORLD_FRAME_ID == "clip_world"
28
+
29
+
30
+ def test_point_cloud_defaults_to_sensor_frame_and_preserves_payload(loader):
31
+ sd = loader.sample_data_for_channel("lidar_top")[0]
32
+ raw = loader.load_point_cloud(sd)
33
+ explicit = loader.load_point_cloud(sd, frame="lidar_top")
34
+ np.testing.assert_array_equal(raw, explicit)
35
+ np.testing.assert_allclose(raw[0], [1.0, 0.0, 0.0, 0.5])
36
+
37
+
38
+ def test_point_cloud_transforms_to_ego_and_clip_world(loader):
39
+ first, second = loader.sample_data_for_channel("lidar_top")
40
+ first_ego = loader.load_point_cloud(first, frame="base_link")
41
+ first_world = loader.load_point_cloud(first, frame=CLIP_WORLD_FRAME_ID)
42
+ second_world = loader.load_point_cloud(second, frame=CLIP_WORLD_FRAME_ID)
43
+ np.testing.assert_allclose(first_ego[0, :3], [1.0, 0.0, 1.0])
44
+ np.testing.assert_allclose(first_world[0, :3], [1.0, 0.0, 1.0])
45
+ np.testing.assert_allclose(second_world[0, :3], [11.0, 0.0, 1.0])
46
+
47
+
48
+ def test_annotations_transform_from_native_frame_to_world(loader):
49
+ first = loader.annotations_for_sample("sample-0")
50
+ second = loader.annotations_for_sample("sample-1")
51
+ first_world = loader.annotations_for_sample("sample-0", frame=CLIP_WORLD_FRAME_ID)
52
+ second_world = loader.annotations_for_sample("sample-1", frame=CLIP_WORLD_FRAME_ID)
53
+ assert first_world[0] is not first[0]
54
+ np.testing.assert_allclose(first_world[0].translation, [5.0, 3.0, 0.0])
55
+ np.testing.assert_allclose(second_world[0].translation, [10.0, 3.0, 0.0])
56
+ np.testing.assert_allclose(second[0].velocity, second_world[0].velocity)
57
+
58
+
59
+ def test_static_and_dynamic_transform_round_trip(loader):
60
+ sd = loader.sample_data_for_channel("lidar_top")[1]
61
+ sensor_to_world = loader.get_transform("lidar_top", CLIP_WORLD_FRAME_ID, sd=sd)
62
+ world_to_sensor = loader.get_transform(CLIP_WORLD_FRAME_ID, "lidar_top", sd=sd)
63
+ points = np.array([[1.0, 2.0, 3.0]])
64
+ np.testing.assert_allclose(world_to_sensor.apply(sensor_to_world.apply(points)), points)
65
+
66
+
67
+ @pytest.mark.parametrize("alias", ["sensor", "ego", "world"])
68
+ def test_legacy_magic_frame_aliases_are_rejected(loader, alias):
69
+ sd = loader.sample_data_for_channel("lidar_top")[0]
70
+ with pytest.raises(ValueError, match="legacy magic aliases"):
71
+ loader.load_point_cloud(sd, frame=alias)
72
+
73
+
74
+ def test_unknown_real_frame_is_rejected(loader):
75
+ with pytest.raises(KeyError):
76
+ loader.get_transform("lidar_top", "unknown_frame")
77
+
78
+
79
+ def test_clip_world_requires_sample_data_for_dynamic_transform(loader):
80
+ with pytest.raises(ValueError, match="pass sd"):
81
+ loader.get_transform("lidar_top", CLIP_WORLD_FRAME_ID)