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.
- rosotacom/__init__.py +32 -0
- rosotacom/__main__.py +8 -0
- rosotacom/anonymize.py +360 -0
- rosotacom/bag_ground_truth.py +395 -0
- rosotacom/benched_set.py +492 -0
- rosotacom/benchmark.py +1842 -0
- rosotacom/bundle_check.py +517 -0
- rosotacom/cli.py +8637 -0
- rosotacom/cli_benchmark.py +8211 -0
- rosotacom/deployment.py +196 -0
- rosotacom/ffmpeg_packet.py +123 -0
- rosotacom/forensics.py +1172 -0
- rosotacom/geomap.py +848 -0
- rosotacom/network_profiles.py +563 -0
- rosotacom/network_shaper.py +127 -0
- rosotacom/plots.py +609 -0
- rosotacom/py.typed +0 -0
- rosotacom/resources/benched-set.yaml +317 -0
- rosotacom/resources/examples/.gitignore +1 -0
- rosotacom/resources/examples/README.md +163 -0
- rosotacom/resources/examples/__init__.py +1 -0
- rosotacom/resources/examples/bags/8_drop_reference/metadata.yaml +17 -0
- rosotacom/resources/examples/deployment.example.yaml +14 -0
- rosotacom/resources/examples/profiles.yaml +85 -0
- rosotacom/resources/examples/ros2docker.json +33 -0
- rosotacom/resources/examples/rosotacom.yaml +12 -0
- rosotacom/resources/examples/scenarios/2_native_chatter/scenario-definition.yaml +10 -0
- rosotacom/resources/examples/scripts/1_heartbeat/run_machine_a.sh +9 -0
- rosotacom/resources/examples/scripts/1_heartbeat/run_machine_b.sh +9 -0
- rosotacom/resources/examples/scripts/2_native_chatter/machine_a/external.ros2docker.json +21 -0
- rosotacom/resources/examples/scripts/2_native_chatter/machine_a/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/2_native_chatter/machine_a/run_external.py +15 -0
- rosotacom/resources/examples/scripts/2_native_chatter/machine_a/wait_for_echo_topic.sh +10 -0
- rosotacom/resources/examples/scripts/2_native_chatter/machine_b/external.ros2docker.json +19 -0
- rosotacom/resources/examples/scripts/2_native_chatter/machine_b/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/2_native_chatter/machine_b/run_external.py +15 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/external.ros2docker.json +12 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/external_session.yaml +9 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/foxglove.json +89 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/run_external.py +15 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/costmap_1second/costmap_1second_0.mcap +0 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/costmap_1second/metadata.yaml +44 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/external.ros2docker.json +13 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/run_external.py +15 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/external.ros2docker.json +12 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/external_session.yaml +9 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/foxglove.json +89 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/run_external.py +15 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/costmap_1second/costmap_1second_0.mcap +0 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/costmap_1second/metadata.yaml +44 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/external.ros2docker.json +13 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/run_external.py +15 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_a/external.ros2docker.json +20 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_a/external_session.yaml +22 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_a/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_a/run_external.py +15 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_b/external.ros2docker.json +20 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_b/external_session.yaml +22 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_b/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_b/run_external.py +15 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/external.ros2docker.json +18 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/external_session.yaml +22 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/run_external.py +15 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/external.ros2docker.json +18 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/external_session.yaml +22 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/run_external.py +15 -0
- rosotacom/resources/examples/sessions/10_restamp/session-definition.yaml +39 -0
- rosotacom/resources/examples/sessions/11_trickle/session-definition.yaml +42 -0
- rosotacom/resources/examples/sessions/12_content_integrity/session-definition.yaml +33 -0
- rosotacom/resources/examples/sessions/13_link_latency/session-definition.yaml +42 -0
- rosotacom/resources/examples/sessions/14_remote_assist_anonymized/session-definition.yaml +211 -0
- rosotacom/resources/examples/sessions/15_remote_assist_anonymized_costmap/session-definition.yaml +59 -0
- rosotacom/resources/examples/sessions/16_remote_assist_anonymized_camera/session-definition.yaml +61 -0
- rosotacom/resources/examples/sessions/17_synthetic_camera_quality/session-definition.yaml +57 -0
- rosotacom/resources/examples/sessions/1_heartbeat/session-definition.yaml +26 -0
- rosotacom/resources/examples/sessions/1_heartbeat_status/session-definition.yaml +37 -0
- rosotacom/resources/examples/sessions/2_native_chatter/session-definition.yaml +34 -0
- rosotacom/resources/examples/sessions/3_comp_occ_grid/session-definition.yaml +43 -0
- rosotacom/resources/examples/sessions/4_comp_occ_grid_zen/session-definition.yaml +43 -0
- rosotacom/resources/examples/sessions/5_sized_payload/session-definition.yaml +39 -0
- rosotacom/resources/examples/sessions/6_sized_payload_zen/session-definition.yaml +50 -0
- rosotacom/resources/examples/sessions/7_latched_static/session-definition.yaml +49 -0
- rosotacom/resources/examples/sessions/8_drop/session-definition.yaml +37 -0
- rosotacom/resources/examples/sessions/9_throttle/session-definition.yaml +37 -0
- rosotacom/resources/examples/sessions/bench_1_1_capacity/session-definition.yaml +42 -0
- rosotacom/resources/examples/sessions/bench_1_2_load_sweep/session-definition.yaml +51 -0
- rosotacom/resources/examples/sessions/bench_1_3_ramp/session-definition.yaml +41 -0
- rosotacom/resources/examples/sessions/bench_1_4_recovery/session-definition.yaml +47 -0
- rosotacom/resources/examples/sessions/bench_1_5_camera/session-definition.yaml +45 -0
- rosotacom/resources/ros2docker.json.example +33 -0
- rosotacom/resources/ws/.vscode/c_cpp_properties.json +20 -0
- rosotacom/resources/ws/.vscode/settings.json +8 -0
- rosotacom/resources/ws/ota_configs/archive/fastdds_easy_mode.xml.template +32 -0
- rosotacom/resources/ws/ota_configs/archive/fastdds_initial_peer_list.xml.template +21 -0
- rosotacom/resources/ws/ota_configs/archive/fastdds_v2.xml.template +17 -0
- rosotacom/resources/ws/ota_configs/archive/fastdds_v3.xml.template +48 -0
- rosotacom/resources/ws/ota_configs/archive/fastdds_v4.xml.template +95 -0
- rosotacom/resources/ws/ota_configs/archive/fastdds_v5.xml.template +64 -0
- rosotacom/resources/ws/ota_configs/create_zenoh_json5.py +56 -0
- rosotacom/resources/ws/ota_configs/cyclonedds_minimal.xml.template +9 -0
- rosotacom/resources/ws/ota_configs/cyclonedds_tuned.xml.template +33 -0
- rosotacom/resources/ws/ota_configs/fastdds_unicast.xml.template +71 -0
- rosotacom/resources/ws/ota_configs/get_ota_xml.py +214 -0
- rosotacom/resources/ws/ota_configs/utils.py +74 -0
- rosotacom/resources/ws/ota_configs/zenoh.json5.template +32 -0
- rosotacom/resources/ws/ros2src/.gitkeep +0 -0
- rosotacom/resources/ws/ros2src/com_cpp/CMakeLists.txt +67 -0
- rosotacom/resources/ws/ros2src/com_cpp/COLCON_IGNORE +0 -0
- rosotacom/resources/ws/ros2src/com_cpp/LICENSE.txt +25 -0
- rosotacom/resources/ws/ros2src/com_cpp/package.xml +37 -0
- rosotacom/resources/ws/ros2src/com_cpp/src/hz_monitor.cpp +192 -0
- rosotacom/resources/ws/ros2src/com_cpp/src/universal_compressor.cpp +283 -0
- rosotacom/resources/ws/ros2src/com_cpp/src/universal_decompressor.cpp +306 -0
- rosotacom/resources/ws/ros2src/com_msgs/CMakeLists.txt +22 -0
- rosotacom/resources/ws/ros2src/com_msgs/LICENSE.txt +25 -0
- rosotacom/resources/ws/ros2src/com_msgs/msg/CompressedData.msg +3 -0
- rosotacom/resources/ws/ros2src/com_msgs/msg/EchoHeartbeat.msg +10 -0
- rosotacom/resources/ws/ros2src/com_msgs/msg/OtaStamped.msg +4 -0
- rosotacom/resources/ws/ros2src/com_msgs/msg/SizedPayload.msg +4 -0
- rosotacom/resources/ws/ros2src/com_msgs/package.xml +25 -0
- rosotacom/resources/ws/ros2src/com_py/LICENSE.txt +25 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/__init__.py +0 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/base_bridge_relay.py +106 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/bridge_in.py +76 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/bridge_out.py +70 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/heartbeat_echo.py +221 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/hz_monitor.py +178 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/image_bandwidth_reducer.py +218 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/image_pixel_capper.py +370 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/lanelet_saver.py +80 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/latch_relay.py +205 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/legacy/costmap_remapper.py +73 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/legacy/marker_remapper.py +93 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/legacy/speed_test.py +107 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/legacy/tf_remapper.py +156 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/legacy/traffic_monitor.py +91 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/legacy/wifi_network_analysis_1.py +213 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/link_bytes.py +177 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/link_trace.py +186 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/local_global_frame_bridge.py +465 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/localization_map_split_reduce_saver.py +258 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/msg_transform.py +277 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/pair_management.py +87 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/pub_sub_pair.py +145 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/qos.py +254 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/relay_in.py +78 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/relay_out.py +75 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/restamp.py +191 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/sized_publisher.py +244 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/stage_latency.py +64 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/stage_latency_core.py +51 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/status_overview.py +476 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/status_overview_core.py +989 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/tf_map_to_cart_saver.py +99 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/topic_monitor.py +327 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/topic_resolution.py +171 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/trickle.py +183 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/universal_compressor.py +653 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/universal_decompressor.py +372 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/universal_ota_unwrapper.py +191 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/universal_ota_wrapper.py +154 -0
- rosotacom/resources/ws/ros2src/com_py/package.xml +34 -0
- rosotacom/resources/ws/ros2src/com_py/resource/com_py +0 -0
- rosotacom/resources/ws/ros2src/com_py/setup.cfg +6 -0
- rosotacom/resources/ws/ros2src/com_py/setup.py +55 -0
- rosotacom/resources/ws/ros2src/com_py/test/test_copyright.py +25 -0
- rosotacom/resources/ws/ros2src/com_py/test/test_flake8.py +25 -0
- rosotacom/resources/ws/ros2src/com_py/test/test_pep257.py +23 -0
- rosotacom/resources/ws/session/content/base/session_plugin_base.yaml +633 -0
- rosotacom/resources/ws/session/content/network/up_connectivity.py +364 -0
- rosotacom/resources/ws/session/content/network/wifi_network_analysis_2.py +215 -0
- rosotacom/resources/ws/session/creation/anonymize_bag.py +156 -0
- rosotacom/resources/ws/session/creation/catmux_log_setup.sh +65 -0
- rosotacom/resources/ws/session/creation/create_session_yaml.py +145 -0
- rosotacom/resources/ws/session/creation/generate_session_files.py +3217 -0
- rosotacom/resources/ws/session/creation/run_session.py +447 -0
- rosotacom/resources/ws/session/creation/run_session_in_external_terminal.py +54 -0
- rosotacom/resources/ws/session/creation/strip_ansi.py +42 -0
- rosotacom/status_eval.py +475 -0
- rosotacom/stream_stats.py +650 -0
- rosotacom/trace_profiles.py +561 -0
- rosotacom/transit.py +249 -0
- rosotacom/video_quality.py +906 -0
- rosotacom_dev-2.2.dist-info/METADATA +874 -0
- rosotacom_dev-2.2.dist-info/RECORD +195 -0
- rosotacom_dev-2.2.dist-info/WHEEL +5 -0
- rosotacom_dev-2.2.dist-info/entry_points.txt +4 -0
- rosotacom_dev-2.2.dist-info/licenses/LICENSE.txt +25 -0
- rosotacom_dev-2.2.dist-info/top_level.txt +1 -0
rosotacom/benched_set.py
ADDED
|
@@ -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
|
+
}
|