rosotacom-dev 2.2__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 (195) hide show
  1. rosotacom/__init__.py +32 -0
  2. rosotacom/__main__.py +8 -0
  3. rosotacom/anonymize.py +360 -0
  4. rosotacom/bag_ground_truth.py +395 -0
  5. rosotacom/benched_set.py +492 -0
  6. rosotacom/benchmark.py +1842 -0
  7. rosotacom/bundle_check.py +517 -0
  8. rosotacom/cli.py +8637 -0
  9. rosotacom/cli_benchmark.py +8211 -0
  10. rosotacom/deployment.py +196 -0
  11. rosotacom/ffmpeg_packet.py +123 -0
  12. rosotacom/forensics.py +1172 -0
  13. rosotacom/geomap.py +848 -0
  14. rosotacom/network_profiles.py +563 -0
  15. rosotacom/network_shaper.py +127 -0
  16. rosotacom/plots.py +609 -0
  17. rosotacom/py.typed +0 -0
  18. rosotacom/resources/benched-set.yaml +317 -0
  19. rosotacom/resources/examples/.gitignore +1 -0
  20. rosotacom/resources/examples/README.md +163 -0
  21. rosotacom/resources/examples/__init__.py +1 -0
  22. rosotacom/resources/examples/bags/8_drop_reference/metadata.yaml +17 -0
  23. rosotacom/resources/examples/deployment.example.yaml +14 -0
  24. rosotacom/resources/examples/profiles.yaml +85 -0
  25. rosotacom/resources/examples/ros2docker.json +33 -0
  26. rosotacom/resources/examples/rosotacom.yaml +12 -0
  27. rosotacom/resources/examples/scenarios/2_native_chatter/scenario-definition.yaml +10 -0
  28. rosotacom/resources/examples/scripts/1_heartbeat/run_machine_a.sh +9 -0
  29. rosotacom/resources/examples/scripts/1_heartbeat/run_machine_b.sh +9 -0
  30. rosotacom/resources/examples/scripts/2_native_chatter/machine_a/external.ros2docker.json +21 -0
  31. rosotacom/resources/examples/scripts/2_native_chatter/machine_a/run_communication.sh +9 -0
  32. rosotacom/resources/examples/scripts/2_native_chatter/machine_a/run_external.py +15 -0
  33. rosotacom/resources/examples/scripts/2_native_chatter/machine_a/wait_for_echo_topic.sh +10 -0
  34. rosotacom/resources/examples/scripts/2_native_chatter/machine_b/external.ros2docker.json +19 -0
  35. rosotacom/resources/examples/scripts/2_native_chatter/machine_b/run_communication.sh +9 -0
  36. rosotacom/resources/examples/scripts/2_native_chatter/machine_b/run_external.py +15 -0
  37. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/external.ros2docker.json +12 -0
  38. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/external_session.yaml +9 -0
  39. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/foxglove.json +89 -0
  40. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/run_communication.sh +9 -0
  41. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/run_external.py +15 -0
  42. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/costmap_1second/costmap_1second_0.mcap +0 -0
  43. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/costmap_1second/metadata.yaml +44 -0
  44. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/external.ros2docker.json +13 -0
  45. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/run_communication.sh +9 -0
  46. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/run_external.py +15 -0
  47. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/external.ros2docker.json +12 -0
  48. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/external_session.yaml +9 -0
  49. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/foxglove.json +89 -0
  50. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/run_communication.sh +9 -0
  51. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/run_external.py +15 -0
  52. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/costmap_1second/costmap_1second_0.mcap +0 -0
  53. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/costmap_1second/metadata.yaml +44 -0
  54. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/external.ros2docker.json +13 -0
  55. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/run_communication.sh +9 -0
  56. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/run_external.py +15 -0
  57. rosotacom/resources/examples/scripts/5_sized_payload/machine_a/external.ros2docker.json +20 -0
  58. rosotacom/resources/examples/scripts/5_sized_payload/machine_a/external_session.yaml +22 -0
  59. rosotacom/resources/examples/scripts/5_sized_payload/machine_a/run_communication.sh +9 -0
  60. rosotacom/resources/examples/scripts/5_sized_payload/machine_a/run_external.py +15 -0
  61. rosotacom/resources/examples/scripts/5_sized_payload/machine_b/external.ros2docker.json +20 -0
  62. rosotacom/resources/examples/scripts/5_sized_payload/machine_b/external_session.yaml +22 -0
  63. rosotacom/resources/examples/scripts/5_sized_payload/machine_b/run_communication.sh +9 -0
  64. rosotacom/resources/examples/scripts/5_sized_payload/machine_b/run_external.py +15 -0
  65. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/external.ros2docker.json +18 -0
  66. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/external_session.yaml +22 -0
  67. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/run_communication.sh +9 -0
  68. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/run_external.py +15 -0
  69. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/external.ros2docker.json +18 -0
  70. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/external_session.yaml +22 -0
  71. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/run_communication.sh +9 -0
  72. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/run_external.py +15 -0
  73. rosotacom/resources/examples/sessions/10_restamp/session-definition.yaml +39 -0
  74. rosotacom/resources/examples/sessions/11_trickle/session-definition.yaml +42 -0
  75. rosotacom/resources/examples/sessions/12_content_integrity/session-definition.yaml +33 -0
  76. rosotacom/resources/examples/sessions/13_link_latency/session-definition.yaml +42 -0
  77. rosotacom/resources/examples/sessions/14_remote_assist_anonymized/session-definition.yaml +211 -0
  78. rosotacom/resources/examples/sessions/15_remote_assist_anonymized_costmap/session-definition.yaml +59 -0
  79. rosotacom/resources/examples/sessions/16_remote_assist_anonymized_camera/session-definition.yaml +61 -0
  80. rosotacom/resources/examples/sessions/17_synthetic_camera_quality/session-definition.yaml +57 -0
  81. rosotacom/resources/examples/sessions/1_heartbeat/session-definition.yaml +26 -0
  82. rosotacom/resources/examples/sessions/1_heartbeat_status/session-definition.yaml +37 -0
  83. rosotacom/resources/examples/sessions/2_native_chatter/session-definition.yaml +34 -0
  84. rosotacom/resources/examples/sessions/3_comp_occ_grid/session-definition.yaml +43 -0
  85. rosotacom/resources/examples/sessions/4_comp_occ_grid_zen/session-definition.yaml +43 -0
  86. rosotacom/resources/examples/sessions/5_sized_payload/session-definition.yaml +39 -0
  87. rosotacom/resources/examples/sessions/6_sized_payload_zen/session-definition.yaml +50 -0
  88. rosotacom/resources/examples/sessions/7_latched_static/session-definition.yaml +49 -0
  89. rosotacom/resources/examples/sessions/8_drop/session-definition.yaml +37 -0
  90. rosotacom/resources/examples/sessions/9_throttle/session-definition.yaml +37 -0
  91. rosotacom/resources/examples/sessions/bench_1_1_capacity/session-definition.yaml +42 -0
  92. rosotacom/resources/examples/sessions/bench_1_2_load_sweep/session-definition.yaml +51 -0
  93. rosotacom/resources/examples/sessions/bench_1_3_ramp/session-definition.yaml +41 -0
  94. rosotacom/resources/examples/sessions/bench_1_4_recovery/session-definition.yaml +47 -0
  95. rosotacom/resources/examples/sessions/bench_1_5_camera/session-definition.yaml +45 -0
  96. rosotacom/resources/ros2docker.json.example +33 -0
  97. rosotacom/resources/ws/.vscode/c_cpp_properties.json +20 -0
  98. rosotacom/resources/ws/.vscode/settings.json +8 -0
  99. rosotacom/resources/ws/ota_configs/archive/fastdds_easy_mode.xml.template +32 -0
  100. rosotacom/resources/ws/ota_configs/archive/fastdds_initial_peer_list.xml.template +21 -0
  101. rosotacom/resources/ws/ota_configs/archive/fastdds_v2.xml.template +17 -0
  102. rosotacom/resources/ws/ota_configs/archive/fastdds_v3.xml.template +48 -0
  103. rosotacom/resources/ws/ota_configs/archive/fastdds_v4.xml.template +95 -0
  104. rosotacom/resources/ws/ota_configs/archive/fastdds_v5.xml.template +64 -0
  105. rosotacom/resources/ws/ota_configs/create_zenoh_json5.py +56 -0
  106. rosotacom/resources/ws/ota_configs/cyclonedds_minimal.xml.template +9 -0
  107. rosotacom/resources/ws/ota_configs/cyclonedds_tuned.xml.template +33 -0
  108. rosotacom/resources/ws/ota_configs/fastdds_unicast.xml.template +71 -0
  109. rosotacom/resources/ws/ota_configs/get_ota_xml.py +214 -0
  110. rosotacom/resources/ws/ota_configs/utils.py +74 -0
  111. rosotacom/resources/ws/ota_configs/zenoh.json5.template +32 -0
  112. rosotacom/resources/ws/ros2src/.gitkeep +0 -0
  113. rosotacom/resources/ws/ros2src/com_cpp/CMakeLists.txt +67 -0
  114. rosotacom/resources/ws/ros2src/com_cpp/COLCON_IGNORE +0 -0
  115. rosotacom/resources/ws/ros2src/com_cpp/LICENSE.txt +25 -0
  116. rosotacom/resources/ws/ros2src/com_cpp/package.xml +37 -0
  117. rosotacom/resources/ws/ros2src/com_cpp/src/hz_monitor.cpp +192 -0
  118. rosotacom/resources/ws/ros2src/com_cpp/src/universal_compressor.cpp +283 -0
  119. rosotacom/resources/ws/ros2src/com_cpp/src/universal_decompressor.cpp +306 -0
  120. rosotacom/resources/ws/ros2src/com_msgs/CMakeLists.txt +22 -0
  121. rosotacom/resources/ws/ros2src/com_msgs/LICENSE.txt +25 -0
  122. rosotacom/resources/ws/ros2src/com_msgs/msg/CompressedData.msg +3 -0
  123. rosotacom/resources/ws/ros2src/com_msgs/msg/EchoHeartbeat.msg +10 -0
  124. rosotacom/resources/ws/ros2src/com_msgs/msg/OtaStamped.msg +4 -0
  125. rosotacom/resources/ws/ros2src/com_msgs/msg/SizedPayload.msg +4 -0
  126. rosotacom/resources/ws/ros2src/com_msgs/package.xml +25 -0
  127. rosotacom/resources/ws/ros2src/com_py/LICENSE.txt +25 -0
  128. rosotacom/resources/ws/ros2src/com_py/com_py/__init__.py +0 -0
  129. rosotacom/resources/ws/ros2src/com_py/com_py/base_bridge_relay.py +106 -0
  130. rosotacom/resources/ws/ros2src/com_py/com_py/bridge_in.py +76 -0
  131. rosotacom/resources/ws/ros2src/com_py/com_py/bridge_out.py +70 -0
  132. rosotacom/resources/ws/ros2src/com_py/com_py/heartbeat_echo.py +221 -0
  133. rosotacom/resources/ws/ros2src/com_py/com_py/hz_monitor.py +178 -0
  134. rosotacom/resources/ws/ros2src/com_py/com_py/image_bandwidth_reducer.py +218 -0
  135. rosotacom/resources/ws/ros2src/com_py/com_py/image_pixel_capper.py +370 -0
  136. rosotacom/resources/ws/ros2src/com_py/com_py/lanelet_saver.py +80 -0
  137. rosotacom/resources/ws/ros2src/com_py/com_py/latch_relay.py +205 -0
  138. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/costmap_remapper.py +73 -0
  139. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/marker_remapper.py +93 -0
  140. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/speed_test.py +107 -0
  141. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/tf_remapper.py +156 -0
  142. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/traffic_monitor.py +91 -0
  143. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/wifi_network_analysis_1.py +213 -0
  144. rosotacom/resources/ws/ros2src/com_py/com_py/link_bytes.py +177 -0
  145. rosotacom/resources/ws/ros2src/com_py/com_py/link_trace.py +186 -0
  146. rosotacom/resources/ws/ros2src/com_py/com_py/local_global_frame_bridge.py +465 -0
  147. rosotacom/resources/ws/ros2src/com_py/com_py/localization_map_split_reduce_saver.py +258 -0
  148. rosotacom/resources/ws/ros2src/com_py/com_py/msg_transform.py +277 -0
  149. rosotacom/resources/ws/ros2src/com_py/com_py/pair_management.py +87 -0
  150. rosotacom/resources/ws/ros2src/com_py/com_py/pub_sub_pair.py +145 -0
  151. rosotacom/resources/ws/ros2src/com_py/com_py/qos.py +254 -0
  152. rosotacom/resources/ws/ros2src/com_py/com_py/relay_in.py +78 -0
  153. rosotacom/resources/ws/ros2src/com_py/com_py/relay_out.py +75 -0
  154. rosotacom/resources/ws/ros2src/com_py/com_py/restamp.py +191 -0
  155. rosotacom/resources/ws/ros2src/com_py/com_py/sized_publisher.py +244 -0
  156. rosotacom/resources/ws/ros2src/com_py/com_py/stage_latency.py +64 -0
  157. rosotacom/resources/ws/ros2src/com_py/com_py/stage_latency_core.py +51 -0
  158. rosotacom/resources/ws/ros2src/com_py/com_py/status_overview.py +476 -0
  159. rosotacom/resources/ws/ros2src/com_py/com_py/status_overview_core.py +989 -0
  160. rosotacom/resources/ws/ros2src/com_py/com_py/tf_map_to_cart_saver.py +99 -0
  161. rosotacom/resources/ws/ros2src/com_py/com_py/topic_monitor.py +327 -0
  162. rosotacom/resources/ws/ros2src/com_py/com_py/topic_resolution.py +171 -0
  163. rosotacom/resources/ws/ros2src/com_py/com_py/trickle.py +183 -0
  164. rosotacom/resources/ws/ros2src/com_py/com_py/universal_compressor.py +653 -0
  165. rosotacom/resources/ws/ros2src/com_py/com_py/universal_decompressor.py +372 -0
  166. rosotacom/resources/ws/ros2src/com_py/com_py/universal_ota_unwrapper.py +191 -0
  167. rosotacom/resources/ws/ros2src/com_py/com_py/universal_ota_wrapper.py +154 -0
  168. rosotacom/resources/ws/ros2src/com_py/package.xml +34 -0
  169. rosotacom/resources/ws/ros2src/com_py/resource/com_py +0 -0
  170. rosotacom/resources/ws/ros2src/com_py/setup.cfg +6 -0
  171. rosotacom/resources/ws/ros2src/com_py/setup.py +55 -0
  172. rosotacom/resources/ws/ros2src/com_py/test/test_copyright.py +25 -0
  173. rosotacom/resources/ws/ros2src/com_py/test/test_flake8.py +25 -0
  174. rosotacom/resources/ws/ros2src/com_py/test/test_pep257.py +23 -0
  175. rosotacom/resources/ws/session/content/base/session_plugin_base.yaml +633 -0
  176. rosotacom/resources/ws/session/content/network/up_connectivity.py +364 -0
  177. rosotacom/resources/ws/session/content/network/wifi_network_analysis_2.py +215 -0
  178. rosotacom/resources/ws/session/creation/anonymize_bag.py +156 -0
  179. rosotacom/resources/ws/session/creation/catmux_log_setup.sh +65 -0
  180. rosotacom/resources/ws/session/creation/create_session_yaml.py +145 -0
  181. rosotacom/resources/ws/session/creation/generate_session_files.py +3217 -0
  182. rosotacom/resources/ws/session/creation/run_session.py +447 -0
  183. rosotacom/resources/ws/session/creation/run_session_in_external_terminal.py +54 -0
  184. rosotacom/resources/ws/session/creation/strip_ansi.py +42 -0
  185. rosotacom/status_eval.py +475 -0
  186. rosotacom/stream_stats.py +650 -0
  187. rosotacom/trace_profiles.py +561 -0
  188. rosotacom/transit.py +249 -0
  189. rosotacom/video_quality.py +906 -0
  190. rosotacom_dev-2.2.dist-info/METADATA +874 -0
  191. rosotacom_dev-2.2.dist-info/RECORD +195 -0
  192. rosotacom_dev-2.2.dist-info/WHEEL +5 -0
  193. rosotacom_dev-2.2.dist-info/entry_points.txt +4 -0
  194. rosotacom_dev-2.2.dist-info/licenses/LICENSE.txt +25 -0
  195. rosotacom_dev-2.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,492 @@
1
+ """RFC 0007 §4 — the benched set: the curated registry of gated benchmark rows.
2
+
3
+ The registry is a whitelist, not a crawler: every row is one deliberate
4
+ ``(rmw × profile × load × metric set)`` combination with an operator-visible
5
+ reason to exist. Workflows and the ``benchmark`` CLI *read* this registry —
6
+ adding an RMW variant or profile extends the matrix without editing CI, and a
7
+ matrix nobody can afford to keep green is a monitor with extra steps.
8
+
9
+ This module is pure and host-testable: loading, structural validation, and the
10
+ machine-readable per-run verdict document. Cross-file checks (profiles exist,
11
+ bands committed, workflows consume the registry) live in the contract tests.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from dataclasses import dataclass, field
18
+ from importlib import resources
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ import yaml
23
+
24
+ REGISTRY_SCHEMA = 2
25
+ REGISTRY_RESOURCE = "resources/benched-set.yaml"
26
+
27
+ LANES = ("merge-gate", "nightly")
28
+ ROW_KINDS = ("performance", "boundary")
29
+ GENRES = ("probe", "capacity")
30
+
31
+ # The RFC 0003 metrics each genre can put under a band today. The metric policy
32
+ # (RFC 0007 §3) prefers bottleneck-dominated metrics for tight public bands;
33
+ # latency percentiles on shared runners are monitor-only unless calibration
34
+ # proves otherwise.
35
+ GENRE_METRICS: dict[str, tuple[str, ...]] = {
36
+ "probe": ("completeness_pct", "loss_pct", "latency_p50_ms", "latency_p95_ms", "payload_bandwidth_bps"),
37
+ "capacity": ("capacity_size", "capacity_rate", "capacity_bandwidth"),
38
+ }
39
+
40
+ PROBE_LOAD_KEYS = frozenset(
41
+ {"size", "size_pattern", "rate_hz", "streams", "interval_jitter_ms", "interval_jitter_seed"}
42
+ )
43
+ CAPACITY_KNOBS = ("size", "rate", "bandwidth")
44
+
45
+ # A gated window must span >=2 CycloneDDS SPDP discovery periods (2 x 30 s,
46
+ # RFC 0005 "discovery traffic is real load") or explicitly annotate why not.
47
+ MIN_GATED_WINDOW_S = 60.0
48
+
49
+ VERDICT_SCHEMA = 1
50
+
51
+
52
+ class RegistryError(ValueError):
53
+ """The registry refused to load; the message names the offending row/field."""
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class GateRow:
58
+ """One benched row: what runs, under what, and which metrics gate."""
59
+
60
+ id: str
61
+ lane: str
62
+ reason: str
63
+ rmw: str
64
+ genre: str
65
+ profile: str
66
+ duration_s: float
67
+ metrics: tuple[str, ...] # banded — these gate
68
+ monitor: tuple[str, ...] = () # recorded in the verdict, never gated
69
+ load: dict[str, Any] = field(default_factory=dict) # probe load parameters
70
+ search: dict[str, Any] = field(default_factory=dict) # capacity binary-search bounds
71
+ oracle: dict[str, Any] = field(default_factory=dict) # capacity pass/fail thresholds
72
+ replay: dict[str, Any] = field(default_factory=dict) # replay provenance/expect fragment for replay-derived rows
73
+ floors: dict[str, float] = field(default_factory=dict) # committed minimum half-widths per gated metric
74
+ repeats: int = 1 # in-run repeats (median vote / attempts)
75
+ window_note: str = "" # why a sub-60 s window is still a valid gate
76
+ kind: str = "performance" # performance = normal band row; boundary = good/bad envelope assertion
77
+ boundary: dict[str, Any] = field(default_factory=dict)
78
+
79
+
80
+ def packaged_registry_path() -> Path:
81
+ """The committed registry (packaged with the wheel, same file as in-repo)."""
82
+ return Path(str(resources.files("rosotacom").joinpath(REGISTRY_RESOURCE)))
83
+
84
+
85
+ def _require_str(row_id: str, raw: dict[str, Any], key: str) -> str:
86
+ value = raw.get(key)
87
+ if not isinstance(value, str) or not value.strip():
88
+ raise RegistryError(f"row {row_id!r}: {key!r} must be a non-empty string")
89
+ return value.strip()
90
+
91
+
92
+ def _string_tuple(row_id: str, raw: dict[str, Any], key: str) -> tuple[str, ...]:
93
+ value = raw.get(key, [])
94
+ if not isinstance(value, list) or not all(isinstance(item, str) and item.strip() for item in value):
95
+ raise RegistryError(f"row {row_id!r}: {key!r} must be a list of metric names")
96
+ return tuple(item.strip() for item in value)
97
+
98
+
99
+ _ROW_KEYS = frozenset(
100
+ {
101
+ "id",
102
+ "kind",
103
+ "lane",
104
+ "reason",
105
+ "rmw",
106
+ "genre",
107
+ "profile",
108
+ "duration_s",
109
+ "metrics",
110
+ "monitor",
111
+ "load",
112
+ "search",
113
+ "oracle",
114
+ "replay",
115
+ "floors",
116
+ "repeats",
117
+ "window_note",
118
+ "boundary",
119
+ }
120
+ )
121
+
122
+
123
+ def _threshold_rules(
124
+ row_id: str,
125
+ boundary: dict[str, Any],
126
+ key: str,
127
+ metrics: tuple[str, ...],
128
+ ) -> dict[str, dict[str, float]]:
129
+ raw = boundary.get(key)
130
+ if not isinstance(raw, dict) or not raw:
131
+ raise RegistryError(f"row {row_id!r}: boundary.{key!r} must be a non-empty metric threshold mapping")
132
+ parsed: dict[str, dict[str, float]] = {}
133
+ for metric, rule in raw.items():
134
+ metric_name = str(metric)
135
+ if metric_name not in metrics:
136
+ raise RegistryError(f"row {row_id!r}: boundary.{key!r} references unbanded metric {metric_name!r}")
137
+ if not isinstance(rule, dict) or not rule:
138
+ raise RegistryError(f"row {row_id!r}: boundary.{key!r}.{metric_name!r} must be a threshold mapping")
139
+ unknown = sorted(set(rule) - {"min", "max"})
140
+ if unknown:
141
+ raise RegistryError(f"row {row_id!r}: boundary.{key!r}.{metric_name!r} has unknown keys {unknown}")
142
+ parsed_rule: dict[str, float] = {}
143
+ for side in ("min", "max"):
144
+ if side not in rule:
145
+ continue
146
+ value = rule[side]
147
+ if not isinstance(value, int | float):
148
+ raise RegistryError(f"row {row_id!r}: boundary.{key!r}.{metric_name!r}.{side} must be numeric")
149
+ parsed_rule[side] = float(value)
150
+ if not parsed_rule:
151
+ raise RegistryError(f"row {row_id!r}: boundary.{key!r}.{metric_name!r} needs min and/or max")
152
+ if "min" in parsed_rule and "max" in parsed_rule and parsed_rule["min"] > parsed_rule["max"]:
153
+ raise RegistryError(f"row {row_id!r}: boundary.{key!r}.{metric_name!r} has min > max")
154
+ parsed[metric_name] = parsed_rule
155
+ return parsed
156
+
157
+
158
+ def _parse_boundary(row_id: str, raw: dict[str, Any], metrics: tuple[str, ...]) -> dict[str, Any]:
159
+ boundary = raw.get("boundary") or {}
160
+ if not isinstance(boundary, dict):
161
+ raise RegistryError(f"row {row_id!r}: 'boundary' must be a mapping")
162
+ expected = {"finding", "bad_profile", "good_oracle", "failure_signature", "next_steps"}
163
+ unknown = sorted(set(boundary) - expected)
164
+ if unknown:
165
+ raise RegistryError(f"row {row_id!r}: boundary has unknown keys {unknown}")
166
+ missing = sorted(key for key in expected if key not in boundary)
167
+ if missing:
168
+ raise RegistryError(f"row {row_id!r}: boundary is missing {missing}")
169
+ finding = str(boundary["finding"]).strip()
170
+ bad_profile = str(boundary["bad_profile"]).strip()
171
+ next_steps = str(boundary["next_steps"]).strip()
172
+ if not finding:
173
+ raise RegistryError(f"row {row_id!r}: boundary.finding must be a non-empty string")
174
+ if not bad_profile:
175
+ raise RegistryError(f"row {row_id!r}: boundary.bad_profile must be a non-empty string")
176
+ if not next_steps:
177
+ raise RegistryError(f"row {row_id!r}: boundary.next_steps must be a non-empty string")
178
+ return {
179
+ "finding": finding,
180
+ "bad_profile": bad_profile,
181
+ "good_oracle": _threshold_rules(row_id, boundary, "good_oracle", metrics),
182
+ "failure_signature": _threshold_rules(row_id, boundary, "failure_signature", metrics),
183
+ "next_steps": next_steps,
184
+ }
185
+
186
+
187
+ def _parse_row(raw: Any) -> GateRow:
188
+ if not isinstance(raw, dict):
189
+ raise RegistryError(f"registry rows must be mappings, got {type(raw).__name__}")
190
+ row_id = str(raw.get("id") or "").strip()
191
+ if not row_id:
192
+ raise RegistryError("every registry row needs an 'id'")
193
+ unknown = sorted(set(raw) - _ROW_KEYS)
194
+ if unknown:
195
+ raise RegistryError(f"row {row_id!r}: unknown keys {unknown} — the registry is a whitelist, extend it in code")
196
+
197
+ kind = _require_str(row_id, raw, "kind")
198
+ if kind not in ROW_KINDS:
199
+ raise RegistryError(f"row {row_id!r}: kind {kind!r} is not one of {list(ROW_KINDS)}")
200
+ lane = _require_str(row_id, raw, "lane")
201
+ if lane not in LANES:
202
+ raise RegistryError(f"row {row_id!r}: lane {lane!r} is not one of {list(LANES)}")
203
+ genre = _require_str(row_id, raw, "genre")
204
+ if genre not in GENRES:
205
+ raise RegistryError(f"row {row_id!r}: genre {genre!r} is not one of {list(GENRES)}")
206
+ reason = _require_str(row_id, raw, "reason")
207
+
208
+ duration_raw = raw.get("duration_s")
209
+ if not isinstance(duration_raw, int | float) or duration_raw <= 0:
210
+ raise RegistryError(f"row {row_id!r}: 'duration_s' must be a positive number")
211
+ duration_s = float(duration_raw)
212
+
213
+ metrics = _string_tuple(row_id, raw, "metrics")
214
+ if not metrics:
215
+ raise RegistryError(f"row {row_id!r}: a gated row needs at least one banded metric")
216
+ monitor = _string_tuple(row_id, raw, "monitor")
217
+ producible = set(GENRE_METRICS[genre])
218
+ for name, values in (("metrics", metrics), ("monitor", monitor)):
219
+ unknown_metrics = sorted(set(values) - producible)
220
+ if unknown_metrics:
221
+ raise RegistryError(
222
+ f"row {row_id!r}: {name} {unknown_metrics} are not produced by genre {genre!r} "
223
+ f"(known: {sorted(producible)})"
224
+ )
225
+ overlap = sorted(set(metrics) & set(monitor))
226
+ if overlap:
227
+ raise RegistryError(f"row {row_id!r}: {overlap} cannot be both gated and monitor-only")
228
+
229
+ load = raw.get("load") or {}
230
+ search = raw.get("search") or {}
231
+ oracle = raw.get("oracle") or {}
232
+ replay = raw.get("replay") or {}
233
+ floors = raw.get("floors") or {}
234
+ for name, value in (
235
+ ("load", load),
236
+ ("search", search),
237
+ ("oracle", oracle),
238
+ ("replay", replay),
239
+ ("floors", floors),
240
+ ):
241
+ if not isinstance(value, dict):
242
+ raise RegistryError(f"row {row_id!r}: {name!r} must be a mapping")
243
+ for metric, floor in floors.items():
244
+ if metric not in metrics:
245
+ raise RegistryError(f"row {row_id!r}: floor for {metric!r} but only gated metrics take committed floors")
246
+ if not isinstance(floor, int | float) or floor <= 0:
247
+ raise RegistryError(f"row {row_id!r}: floor for {metric!r} must be a positive number")
248
+
249
+ if kind == "performance":
250
+ if raw.get("boundary"):
251
+ raise RegistryError(f"row {row_id!r}: performance rows do not carry boundary metadata")
252
+ boundary: dict[str, Any] = {}
253
+ else:
254
+ boundary = _parse_boundary(row_id, raw, metrics)
255
+
256
+ repeats_raw = raw.get("repeats", 1)
257
+ if not isinstance(repeats_raw, int) or repeats_raw < 1:
258
+ raise RegistryError(f"row {row_id!r}: 'repeats' must be an integer >= 1")
259
+
260
+ window_note = str(raw.get("window_note") or "").strip()
261
+ if duration_s < MIN_GATED_WINDOW_S and not window_note:
262
+ raise RegistryError(
263
+ f"row {row_id!r}: window {duration_s:g}s is shorter than {MIN_GATED_WINDOW_S:g}s "
264
+ "(2 discovery periods, RFC 0007 §3) — lengthen it or add a 'window_note' saying why it still gates"
265
+ )
266
+
267
+ row = GateRow(
268
+ id=row_id,
269
+ lane=lane,
270
+ reason=reason,
271
+ rmw=_require_str(row_id, raw, "rmw"),
272
+ genre=genre,
273
+ profile=_require_str(row_id, raw, "profile"),
274
+ duration_s=duration_s,
275
+ metrics=metrics,
276
+ monitor=monitor,
277
+ load=dict(load),
278
+ search=dict(search),
279
+ oracle=dict(oracle),
280
+ replay=dict(replay),
281
+ floors={str(metric): float(floor) for metric, floor in floors.items()},
282
+ repeats=repeats_raw,
283
+ window_note=window_note,
284
+ kind=kind,
285
+ boundary=boundary,
286
+ )
287
+ _validate_genre_parameters(row)
288
+ return row
289
+
290
+
291
+ def _validate_genre_parameters(row: GateRow) -> None:
292
+ if row.genre == "probe":
293
+ if row.search or row.oracle:
294
+ raise RegistryError(f"row {row.id!r}: 'search'/'oracle' belong to capacity rows")
295
+ unknown = sorted(set(row.load) - PROBE_LOAD_KEYS)
296
+ if unknown:
297
+ raise RegistryError(f"row {row.id!r}: unknown probe load keys {unknown} (known: {sorted(PROBE_LOAD_KEYS)})")
298
+ if ("size" in row.load) == ("size_pattern" in row.load):
299
+ raise RegistryError(f"row {row.id!r}: a probe load needs exactly one of 'size' or 'size_pattern'")
300
+ if float(row.load.get("rate_hz") or 0.0) <= 0.0:
301
+ raise RegistryError(f"row {row.id!r}: probe load needs a positive 'rate_hz'")
302
+ # Seed policy is explicit (vision): a seeded-random load must commit its seed.
303
+ if float(row.load.get("interval_jitter_ms") or 0.0) > 0.0 and "interval_jitter_seed" not in row.load:
304
+ raise RegistryError(
305
+ f"row {row.id!r}: interval jitter without 'interval_jitter_seed' — gated rows commit their seeds"
306
+ )
307
+ _validate_replay_metadata(row)
308
+ return
309
+
310
+ # capacity
311
+ if row.replay:
312
+ raise RegistryError(f"row {row.id!r}: replay metadata belongs to probe rows")
313
+ if row.load:
314
+ raise RegistryError(f"row {row.id!r}: capacity rows configure 'search'/'oracle', not 'load'")
315
+ knob = str(row.search.get("knob") or "")
316
+ if knob not in CAPACITY_KNOBS:
317
+ raise RegistryError(f"row {row.id!r}: search knob {knob!r} is not one of {list(CAPACITY_KNOBS)}")
318
+ low, high = row.search.get("low"), row.search.get("high")
319
+ if not isinstance(low, int) or not isinstance(high, int) or not 0 < low <= high:
320
+ raise RegistryError(f"row {row.id!r}: search needs integers 0 < low <= high")
321
+ if float(row.search.get("rate_hz") or 0.0) <= 0.0:
322
+ raise RegistryError(f"row {row.id!r}: search needs a positive 'rate_hz'")
323
+ for key in ("max_loss_pct", "max_latency_ms"):
324
+ if not isinstance(row.oracle.get(key), int | float):
325
+ raise RegistryError(f"row {row.id!r}: oracle needs numeric {key!r}")
326
+ expected_metric = f"capacity_{knob}"
327
+ if set(row.metrics) != {expected_metric}:
328
+ raise RegistryError(
329
+ f"row {row.id!r}: a capacity row bands exactly [{expected_metric!r}] (the breakpoint of its knob)"
330
+ )
331
+
332
+
333
+ def _validate_replay_metadata(row: GateRow) -> None:
334
+ if not row.replay:
335
+ return
336
+ required = {
337
+ "source",
338
+ "public_example",
339
+ "bag_topic",
340
+ "native_count",
341
+ "native_duration_s",
342
+ "native_hz",
343
+ "size_basis",
344
+ "expect",
345
+ }
346
+ missing = sorted(required - set(row.replay))
347
+ if missing:
348
+ raise RegistryError(f"row {row.id!r}: replay metadata missing {missing}")
349
+ expect = row.replay.get("expect")
350
+ if not isinstance(expect, dict):
351
+ raise RegistryError(f"row {row.id!r}: replay.expect must be a mapping")
352
+ for key in ("min_count", "completeness_min_ratio", "vs_bag_ratio"):
353
+ if key not in expect:
354
+ raise RegistryError(f"row {row.id!r}: replay.expect missing {key!r}")
355
+ if int(expect["min_count"]) < 1:
356
+ raise RegistryError(f"row {row.id!r}: replay.expect.min_count must be positive")
357
+ for key in ("completeness_min_ratio", "vs_bag_ratio"):
358
+ value = float(expect[key])
359
+ if not 0.0 < value <= 1.0:
360
+ raise RegistryError(f"row {row.id!r}: replay.expect.{key} must be in (0, 1]")
361
+
362
+
363
+ def load_registry(path: Path | None = None) -> list[GateRow]:
364
+ """Load and structurally validate the benched set. Refusals name the row."""
365
+ registry_path = path if path is not None else packaged_registry_path()
366
+ doc = yaml.safe_load(registry_path.read_text(encoding="utf-8"))
367
+ if not isinstance(doc, dict):
368
+ raise RegistryError(f"{registry_path}: registry must be a mapping")
369
+ if doc.get("version") != REGISTRY_SCHEMA:
370
+ raise RegistryError(f"{registry_path}: registry version {doc.get('version')!r} != {REGISTRY_SCHEMA}")
371
+ raw_rows = doc.get("rows")
372
+ if not isinstance(raw_rows, list) or not raw_rows:
373
+ raise RegistryError(f"{registry_path}: registry needs a non-empty 'rows' list")
374
+ rows = [_parse_row(raw) for raw in raw_rows]
375
+ seen: set[str] = set()
376
+ for row in rows:
377
+ if row.id in seen:
378
+ raise RegistryError(f"duplicate row id {row.id!r}; row ids key committed bands")
379
+ seen.add(row.id)
380
+ return rows
381
+
382
+
383
+ def rows_for_lane(rows: list[GateRow], lane: str | None) -> list[GateRow]:
384
+ if lane is None:
385
+ return list(rows)
386
+ if lane not in LANES:
387
+ raise RegistryError(f"lane {lane!r} is not one of {list(LANES)}")
388
+ return [row for row in rows if row.lane == lane]
389
+
390
+
391
+ def rows_for_calibration(rows: list[GateRow]) -> list[GateRow]:
392
+ """Rows that mint committed two-sided bands through the calibration workflow."""
393
+ return [row for row in rows if row.kind == "performance"]
394
+
395
+
396
+ def profiles_for_row(row: GateRow) -> tuple[str, ...]:
397
+ profiles = [row.profile]
398
+ if row.kind == "boundary":
399
+ profiles.append(str(row.boundary["bad_profile"]))
400
+ return tuple(profiles)
401
+
402
+
403
+ def find_row(rows: list[GateRow], row_id: str) -> GateRow:
404
+ for row in rows:
405
+ if row.id == row_id:
406
+ return row
407
+ known = ", ".join(row.id for row in rows)
408
+ raise RegistryError(f"no benched row {row_id!r} in the registry (rows: {known})")
409
+
410
+
411
+ def verdict_document(
412
+ row: GateRow,
413
+ *,
414
+ verdict: str,
415
+ exit_code: int,
416
+ gate: bool,
417
+ sha: str,
418
+ fingerprint: str,
419
+ created_at: str,
420
+ metrics: dict[str, float],
421
+ monitor_metrics: dict[str, float],
422
+ bands: dict[str, dict[str, Any]],
423
+ result_path: str,
424
+ refusal: str | None = None,
425
+ ratchet_command: str | None = None,
426
+ boundary: dict[str, Any] | None = None,
427
+ ) -> dict[str, Any]:
428
+ """The machine-readable per-run verdict a downstream gate can consume."""
429
+ doc = {
430
+ "schema": VERDICT_SCHEMA,
431
+ "kind": "benchmark-gate-verdict",
432
+ "row": row.id,
433
+ "row_kind": row.kind,
434
+ "lane": row.lane,
435
+ "rmw": row.rmw,
436
+ "genre": row.genre,
437
+ "profile": row.profile,
438
+ "verdict": verdict,
439
+ "exit_code": exit_code,
440
+ "gate": gate,
441
+ "sha": sha,
442
+ "fingerprint": fingerprint,
443
+ "created_at": created_at,
444
+ "metrics": metrics,
445
+ "monitor_metrics": monitor_metrics,
446
+ "bands": bands,
447
+ "result": result_path,
448
+ "refusal": refusal,
449
+ "ratchet_command": ratchet_command,
450
+ }
451
+ if boundary is not None:
452
+ doc["boundary"] = boundary
453
+ return doc
454
+
455
+
456
+ def write_verdict(path: Path, doc: dict[str, Any]) -> None:
457
+ path.parent.mkdir(parents=True, exist_ok=True)
458
+ path.write_text(json.dumps(doc, indent=2, sort_keys=True) + "\n", encoding="utf-8")
459
+
460
+
461
+ def summarize_verdicts(
462
+ expected_rows: list[GateRow],
463
+ verdicts: dict[str, dict[str, Any]],
464
+ *,
465
+ run: dict[str, Any],
466
+ ) -> dict[str, Any]:
467
+ """Aggregate per-row verdicts into the one document a promotion gate reads.
468
+
469
+ Failure semantics (RFC 0007 §4): a missing verdict is a setup failure and
470
+ red; ``REGRESSED`` is red; ``IMPROVED`` is red too — the fix is the ratchet
471
+ command recorded in the row's verdict, not a revert.
472
+ """
473
+ rows_out: list[dict[str, Any]] = []
474
+ red: list[str] = []
475
+ for row in expected_rows:
476
+ doc = verdicts.get(row.id)
477
+ if doc is None:
478
+ rows_out.append({"row": row.id, "verdict": "MISSING", "exit_code": None, "gate": True})
479
+ red.append(row.id)
480
+ continue
481
+ rows_out.append(doc)
482
+ gate = bool(doc.get("gate", True))
483
+ if gate and (doc.get("verdict") != "WITHIN" or doc.get("exit_code") != 0):
484
+ red.append(row.id)
485
+ return {
486
+ "schema": VERDICT_SCHEMA,
487
+ "kind": "benchmark-gate-summary",
488
+ "run": run,
489
+ "overall": "red" if red else "green",
490
+ "red_rows": red,
491
+ "rows": rows_out,
492
+ }