baglint 0.1.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.
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.egg-info/
5
+ dist/
6
+ .pytest_cache/
baglint-0.1.0/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Puja
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
baglint-0.1.0/Makefile ADDED
@@ -0,0 +1,18 @@
1
+ # ROS installs put /opt/ros/*/site-packages on PYTHONPATH, which leaks into the
2
+ # venv and drags ROS's pytest plugins into our test run. baglint is deliberately
3
+ # ROS-free, so every target runs with PYTHONPATH cleared.
4
+ PY := .venv/bin/python
5
+ RUN := env -u PYTHONPATH
6
+
7
+ .PHONY: install test lint clean
8
+
9
+ install:
10
+ python3 -m venv .venv
11
+ $(RUN) $(PY) -m pip install -q --upgrade pip
12
+ $(RUN) $(PY) -m pip install -q -e ".[dev]"
13
+
14
+ test:
15
+ $(RUN) $(PY) -m pytest -q
16
+
17
+ clean:
18
+ rm -rf .venv .pytest_cache **/__pycache__ *.egg-info
baglint-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,198 @@
1
+ Metadata-Version: 2.5
2
+ Name: baglint
3
+ Version: 0.1.0
4
+ Summary: Validates the contents of MCAP recordings against a declarative specification
5
+ Project-URL: Homepage, https://github.com/catplotlib/baglint
6
+ Project-URL: Issues, https://github.com/catplotlib/baglint/issues
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: linter,mcap,robotics,ros2,rosbag,validation
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Scientific/Engineering
15
+ Classifier: Topic :: Software Development :: Quality Assurance
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: mcap-ros2-support>=0.5
18
+ Requires-Dist: mcap>=1.2
19
+ Requires-Dist: pyyaml>=6.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=8.0; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # baglint
25
+
26
+ Validates the contents of MCAP recordings against a declarative specification.
27
+ Checks for missing topics, recording gaps and rate violations, and returns a
28
+ non-zero exit status when a recording does not satisfy the specification.
29
+
30
+ Validation of the MCAP container itself, such as chunk CRCs and index
31
+ integrity, is out of scope. Use `mcap doctor` for that.
32
+
33
+ ## Requirements
34
+
35
+ * Python 3.10 or later
36
+
37
+ ## Installation
38
+
39
+ ```console
40
+ $ python3 -m venv .venv
41
+ $ .venv/bin/pip install -e .
42
+ ```
43
+
44
+ When ROS 2 is sourced, `/opt/ros/$ROS_DISTRO/lib/python3.*/site-packages` is on
45
+ `PYTHONPATH` and is inherited by the virtual environment, which causes ROS
46
+ pytest plugins to load during test runs. Clear it when invoking the tool:
47
+
48
+ ```console
49
+ $ env -u PYTHONPATH .venv/bin/baglint --help
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ ```console
55
+ $ baglint BAG [-s SPEC] [-f {text,json}] [--strict]
56
+ $ baglint BAG --init [--margin FRACTION]
57
+ ```
58
+
59
+ | Option | Description |
60
+ | --- | --- |
61
+ | `-s`, `--spec` | Specification file to validate against |
62
+ | `-f`, `--format` | Output format, `text` (default) or `json` |
63
+ | `--strict` | Exit non-zero on `WARN` findings as well as `FAIL` |
64
+ | `--init` | Print a specification generated from the recording instead of validating it |
65
+ | `--margin` | With `--init`, the fraction below the observed rate at which to set `min_rate`. Default `0.1` |
66
+ | `--version` | Print version and exit |
67
+
68
+ Without `-s`, no checks run and nothing is validated.
69
+
70
+ ## Generating a specification
71
+
72
+ `--init` writes a specification describing a recording, which is the practical
73
+ way to produce a first one:
74
+
75
+ ```console
76
+ $ baglint good_run.mcap --init > spec.yaml
77
+ $ baglint experiment_042.mcap --spec spec.yaml
78
+ ```
79
+
80
+ `min_rate` is set below each topic's observed mean rate by `--margin`, and
81
+ `max_gap_ms` to twice the worst interval observed. Topics with fewer than ten
82
+ messages are generated as presence-only, since a mean rate over so few samples
83
+ describes the recording length rather than the publisher.
84
+
85
+ The bounds describe the recording they came from, defects included. Generate
86
+ from a run that is known to be good, and treat the result as a starting point.
87
+
88
+ ## Specification
89
+
90
+ Topic keys accept glob patterns. The first matching entry applies, so list
91
+ specific topics before wildcards.
92
+
93
+ ```yaml
94
+ topics:
95
+ /joint_states:
96
+ min_rate: 490
97
+ max_gap_ms: 10
98
+
99
+ /camera/*:
100
+ min_rate: 25
101
+
102
+ /diagnostics:
103
+ required: false
104
+ ```
105
+
106
+ | Key | Type | Description |
107
+ | --- | --- | --- |
108
+ | `min_rate` | float | Minimum mean publication rate in Hz, measured over the topic's own span |
109
+ | `max_gap_ms` | float | Maximum permitted interval between consecutive messages |
110
+ | `required` | bool | Whether a topic named literally must be present. Default `true` |
111
+ | `check_stamps` | bool | Validate `header.stamp` ordering. Default `false`, as it deserializes payloads |
112
+
113
+ A `transforms.required` list of `[parent, child]` frame pairs is parsed and
114
+ validated, but no check consumes it yet.
115
+
116
+ ## Output
117
+
118
+ ```console
119
+ $ baglint experiment_042.mcap --spec examples/demo_spec.yaml
120
+ experiment_042.mcap
121
+ 3 topics · 680,884 messages · 18m32s
122
+
123
+ FAIL /camera/image
124
+ rate 12.4 Hz below minimum 25 Hz [log_time]
125
+
126
+ FAIL /joint_states
127
+ 3 missing interval(s) >10 ms (worst 122.0 ms at 904.00 s) [log_time]
128
+
129
+ FAIL /tf
130
+ required by spec but no messages in bag
131
+
132
+ 3 findings: 3 FAIL
133
+ ```
134
+
135
+ To regenerate the recording used above:
136
+
137
+ ```console
138
+ $ python examples/make_demo_bag.py experiment_042.mcap
139
+ ```
140
+
141
+ `--format json` emits the same findings for machine consumption. Each carries a
142
+ stable `code`, intended for filtering and baselining in CI:
143
+
144
+ | Code | Level | Meaning |
145
+ | --- | --- | --- |
146
+ | `gap` | FAIL | Consecutive messages exceeded `max_gap_ms` |
147
+ | `rate_below_min` | FAIL | Mean rate fell below `min_rate` |
148
+ | `rate_unmeasurable` | FAIL | Fewer than two messages, so no rate can be computed |
149
+ | `missing_topic` | FAIL | A required topic carried no messages |
150
+ | `stamp_backwards` | FAIL | A `header.stamp` preceded the stamp before it |
151
+ | `stamp_duplicate` | WARN | A message repeated the previous `header.stamp` |
152
+ | `stamp_unset` | WARN | A message carried a zero `header.stamp` |
153
+ | `stamp_unavailable` | WARN | `check_stamps` was set on a message type without a header |
154
+
155
+ ## Message timestamps
156
+
157
+ Each message carries two timestamps, and findings state which one was used:
158
+
159
+ | Timestamp | Set by | A defect indicates |
160
+ | --- | --- | --- |
161
+ | `log_time` | the recorder, on write | the recorder stalled: disk I/O, CPU starvation, a terminated node |
162
+ | `header.stamp` | the publisher, at sample time | the sensor or its driver misbehaved |
163
+
164
+ Gap and rate checks read `log_time`. Stamp checks read `header.stamp` and are
165
+ enabled per topic with `check_stamps`, which is off by default because it
166
+ requires deserializing every message on that topic:
167
+
168
+ ```yaml
169
+ topics:
170
+ /imu:
171
+ check_stamps: true
172
+ ```
173
+
174
+ A stamp that moves backwards is reported as a failure. Downstream consumers
175
+ such as tf2, `message_filters` and the SLAM backends assume a non-decreasing
176
+ stamp sequence per topic, and do not report the violation themselves. Duplicate and zero stamps
177
+ are reported as warnings.
178
+
179
+ A zero stamp is treated as never populated rather than as a timestamp, so it
180
+ does not become the baseline for the messages that follow it.
181
+
182
+ ## Exit codes
183
+
184
+ | Code | Condition |
185
+ | --- | --- |
186
+ | 0 | No `FAIL` findings. With `--strict`, no `WARN` findings either |
187
+ | 1 | At least one finding at or above the failing level |
188
+ | 2 | Invalid arguments, unreadable recording, or malformed specification |
189
+
190
+ ## Development
191
+
192
+ ```console
193
+ $ make install
194
+ $ make test
195
+ ```
196
+
197
+ Tests construct MCAP recordings with injected defects at known offsets. See
198
+ `tests/fixtures.py`.
@@ -0,0 +1,175 @@
1
+ # baglint
2
+
3
+ Validates the contents of MCAP recordings against a declarative specification.
4
+ Checks for missing topics, recording gaps and rate violations, and returns a
5
+ non-zero exit status when a recording does not satisfy the specification.
6
+
7
+ Validation of the MCAP container itself, such as chunk CRCs and index
8
+ integrity, is out of scope. Use `mcap doctor` for that.
9
+
10
+ ## Requirements
11
+
12
+ * Python 3.10 or later
13
+
14
+ ## Installation
15
+
16
+ ```console
17
+ $ python3 -m venv .venv
18
+ $ .venv/bin/pip install -e .
19
+ ```
20
+
21
+ When ROS 2 is sourced, `/opt/ros/$ROS_DISTRO/lib/python3.*/site-packages` is on
22
+ `PYTHONPATH` and is inherited by the virtual environment, which causes ROS
23
+ pytest plugins to load during test runs. Clear it when invoking the tool:
24
+
25
+ ```console
26
+ $ env -u PYTHONPATH .venv/bin/baglint --help
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```console
32
+ $ baglint BAG [-s SPEC] [-f {text,json}] [--strict]
33
+ $ baglint BAG --init [--margin FRACTION]
34
+ ```
35
+
36
+ | Option | Description |
37
+ | --- | --- |
38
+ | `-s`, `--spec` | Specification file to validate against |
39
+ | `-f`, `--format` | Output format, `text` (default) or `json` |
40
+ | `--strict` | Exit non-zero on `WARN` findings as well as `FAIL` |
41
+ | `--init` | Print a specification generated from the recording instead of validating it |
42
+ | `--margin` | With `--init`, the fraction below the observed rate at which to set `min_rate`. Default `0.1` |
43
+ | `--version` | Print version and exit |
44
+
45
+ Without `-s`, no checks run and nothing is validated.
46
+
47
+ ## Generating a specification
48
+
49
+ `--init` writes a specification describing a recording, which is the practical
50
+ way to produce a first one:
51
+
52
+ ```console
53
+ $ baglint good_run.mcap --init > spec.yaml
54
+ $ baglint experiment_042.mcap --spec spec.yaml
55
+ ```
56
+
57
+ `min_rate` is set below each topic's observed mean rate by `--margin`, and
58
+ `max_gap_ms` to twice the worst interval observed. Topics with fewer than ten
59
+ messages are generated as presence-only, since a mean rate over so few samples
60
+ describes the recording length rather than the publisher.
61
+
62
+ The bounds describe the recording they came from, defects included. Generate
63
+ from a run that is known to be good, and treat the result as a starting point.
64
+
65
+ ## Specification
66
+
67
+ Topic keys accept glob patterns. The first matching entry applies, so list
68
+ specific topics before wildcards.
69
+
70
+ ```yaml
71
+ topics:
72
+ /joint_states:
73
+ min_rate: 490
74
+ max_gap_ms: 10
75
+
76
+ /camera/*:
77
+ min_rate: 25
78
+
79
+ /diagnostics:
80
+ required: false
81
+ ```
82
+
83
+ | Key | Type | Description |
84
+ | --- | --- | --- |
85
+ | `min_rate` | float | Minimum mean publication rate in Hz, measured over the topic's own span |
86
+ | `max_gap_ms` | float | Maximum permitted interval between consecutive messages |
87
+ | `required` | bool | Whether a topic named literally must be present. Default `true` |
88
+ | `check_stamps` | bool | Validate `header.stamp` ordering. Default `false`, as it deserializes payloads |
89
+
90
+ A `transforms.required` list of `[parent, child]` frame pairs is parsed and
91
+ validated, but no check consumes it yet.
92
+
93
+ ## Output
94
+
95
+ ```console
96
+ $ baglint experiment_042.mcap --spec examples/demo_spec.yaml
97
+ experiment_042.mcap
98
+ 3 topics · 680,884 messages · 18m32s
99
+
100
+ FAIL /camera/image
101
+ rate 12.4 Hz below minimum 25 Hz [log_time]
102
+
103
+ FAIL /joint_states
104
+ 3 missing interval(s) >10 ms (worst 122.0 ms at 904.00 s) [log_time]
105
+
106
+ FAIL /tf
107
+ required by spec but no messages in bag
108
+
109
+ 3 findings: 3 FAIL
110
+ ```
111
+
112
+ To regenerate the recording used above:
113
+
114
+ ```console
115
+ $ python examples/make_demo_bag.py experiment_042.mcap
116
+ ```
117
+
118
+ `--format json` emits the same findings for machine consumption. Each carries a
119
+ stable `code`, intended for filtering and baselining in CI:
120
+
121
+ | Code | Level | Meaning |
122
+ | --- | --- | --- |
123
+ | `gap` | FAIL | Consecutive messages exceeded `max_gap_ms` |
124
+ | `rate_below_min` | FAIL | Mean rate fell below `min_rate` |
125
+ | `rate_unmeasurable` | FAIL | Fewer than two messages, so no rate can be computed |
126
+ | `missing_topic` | FAIL | A required topic carried no messages |
127
+ | `stamp_backwards` | FAIL | A `header.stamp` preceded the stamp before it |
128
+ | `stamp_duplicate` | WARN | A message repeated the previous `header.stamp` |
129
+ | `stamp_unset` | WARN | A message carried a zero `header.stamp` |
130
+ | `stamp_unavailable` | WARN | `check_stamps` was set on a message type without a header |
131
+
132
+ ## Message timestamps
133
+
134
+ Each message carries two timestamps, and findings state which one was used:
135
+
136
+ | Timestamp | Set by | A defect indicates |
137
+ | --- | --- | --- |
138
+ | `log_time` | the recorder, on write | the recorder stalled: disk I/O, CPU starvation, a terminated node |
139
+ | `header.stamp` | the publisher, at sample time | the sensor or its driver misbehaved |
140
+
141
+ Gap and rate checks read `log_time`. Stamp checks read `header.stamp` and are
142
+ enabled per topic with `check_stamps`, which is off by default because it
143
+ requires deserializing every message on that topic:
144
+
145
+ ```yaml
146
+ topics:
147
+ /imu:
148
+ check_stamps: true
149
+ ```
150
+
151
+ A stamp that moves backwards is reported as a failure. Downstream consumers
152
+ such as tf2, `message_filters` and the SLAM backends assume a non-decreasing
153
+ stamp sequence per topic, and do not report the violation themselves. Duplicate and zero stamps
154
+ are reported as warnings.
155
+
156
+ A zero stamp is treated as never populated rather than as a timestamp, so it
157
+ does not become the baseline for the messages that follow it.
158
+
159
+ ## Exit codes
160
+
161
+ | Code | Condition |
162
+ | --- | --- |
163
+ | 0 | No `FAIL` findings. With `--strict`, no `WARN` findings either |
164
+ | 1 | At least one finding at or above the failing level |
165
+ | 2 | Invalid arguments, unreadable recording, or malformed specification |
166
+
167
+ ## Development
168
+
169
+ ```console
170
+ $ make install
171
+ $ make test
172
+ ```
173
+
174
+ Tests construct MCAP recordings with injected defects at known offsets. See
175
+ `tests/fixtures.py`.
@@ -0,0 +1,14 @@
1
+ # Spec used for the README's example output, against examples/make_demo_bag.py.
2
+ topics:
3
+ /joint_states:
4
+ min_rate: 490 # tolerance below the nominal 500 Hz loop rate
5
+ max_gap_ms: 10
6
+
7
+ /camera/image:
8
+ min_rate: 25
9
+
10
+ /imu:
11
+ min_rate: 100
12
+
13
+ /tf:
14
+ min_rate: 10
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env python3
2
+ """Generate the demo bag used for the README's example output.
3
+
4
+ The synthetic-bag helper lives in tests/ rather than in the package: it is a
5
+ test utility, not shipped API, so this script puts it on the path explicitly.
6
+
7
+ python examples/make_demo_bag.py /tmp/experiment_042.mcap
8
+ baglint /tmp/experiment_042.mcap --spec examples/demo_spec.yaml
9
+ """
10
+
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tests"))
15
+
16
+ from fixtures import SynthBag # noqa: E402
17
+
18
+
19
+ def build(path: Path) -> Path:
20
+ bag = SynthBag(path, duration=1112.0)
21
+
22
+ # A 500 Hz control loop that stalls three times.
23
+ bag.topic("/joint_states", rate=500).gap(at=180.0, ms=52).gap(at=312.4, ms=38).gap(at=904.0, ms=120)
24
+
25
+ # A camera that never reaches its configured frame rate.
26
+ bag.topic("/camera/image", rate=12.4)
27
+
28
+ bag.topic("/imu", rate=100)
29
+
30
+ # /tf is absent entirely, which the spec requires.
31
+ return bag.write()
32
+
33
+
34
+ if __name__ == "__main__":
35
+ out = Path(sys.argv[1] if len(sys.argv) > 1 else "experiment_042.mcap")
36
+ print(f"wrote {build(out)}")
@@ -0,0 +1,19 @@
1
+ # Example baglint spec. Topic keys may be glob patterns; the first entry that
2
+ # matches a topic wins, so list specific topics before wildcards.
3
+
4
+ topics:
5
+ /joint_states:
6
+ min_rate: 500
7
+ max_gap_ms: 10
8
+
9
+ /camera/image:
10
+ min_rate: 25
11
+ max_gap_ms: 100
12
+
13
+ /imu:
14
+ min_rate: 100
15
+
16
+ # Not yet implemented -- parsed and validated, but no check consumes it in v0.1.
17
+ transforms:
18
+ required:
19
+ - [camera_link, base_link]
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "baglint"
7
+ version = "0.1.0"
8
+ description = "Validates the contents of MCAP recordings against a declarative specification"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ keywords = ["mcap", "rosbag", "ros2", "robotics", "validation", "linter"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Intended Audience :: Science/Research",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Scientific/Engineering",
20
+ "Topic :: Software Development :: Quality Assurance",
21
+ ]
22
+ dependencies = [
23
+ "mcap>=1.2",
24
+ "mcap-ros2-support>=0.5",
25
+ "pyyaml>=6.0",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/catplotlib/baglint"
30
+ Issues = "https://github.com/catplotlib/baglint/issues"
31
+
32
+ [project.optional-dependencies]
33
+ dev = ["pytest>=8.0"]
34
+
35
+ [project.scripts]
36
+ baglint = "baglint.cli:main"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/baglint"]
40
+
41
+ [tool.pytest.ini_options]
42
+ testpaths = ["tests"]
@@ -0,0 +1,7 @@
1
+ __version__ = "0.1.0"
2
+
3
+ from baglint.findings import Clock, Finding, Level
4
+ from baglint.runner import run
5
+ from baglint.spec import Spec
6
+
7
+ __all__ = ["Clock", "Finding", "Level", "Spec", "run", "__version__"]
@@ -0,0 +1,18 @@
1
+ from baglint.checks.base import Check, RunContext, TopicStat
2
+ from baglint.checks.gap import GapCheck
3
+ from baglint.checks.presence import PresenceCheck
4
+ from baglint.checks.rate import RateCheck
5
+ from baglint.checks.stamp import StampCheck
6
+
7
+ ALL_CHECKS = [PresenceCheck, GapCheck, RateCheck, StampCheck]
8
+
9
+ __all__ = [
10
+ "Check",
11
+ "RunContext",
12
+ "TopicStat",
13
+ "GapCheck",
14
+ "PresenceCheck",
15
+ "RateCheck",
16
+ "StampCheck",
17
+ "ALL_CHECKS",
18
+ ]
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Iterable, Protocol
5
+
6
+ from baglint.findings import Finding
7
+ from baglint.reader import Message
8
+ from baglint.spec import Spec
9
+
10
+
11
+ @dataclass
12
+ class TopicStat:
13
+ """Per-topic timing, accumulated by the runner for every topic in the bag."""
14
+
15
+ topic: str
16
+ count: int = 0
17
+ first_ns: int | None = None
18
+ last_ns: int | None = None
19
+ max_interval_ns: int = 0
20
+
21
+ def observe(self, log_time_ns: int) -> None:
22
+ if self.first_ns is None:
23
+ self.first_ns = log_time_ns
24
+ else:
25
+ self.max_interval_ns = max(self.max_interval_ns, log_time_ns - self.last_ns)
26
+ self.last_ns = log_time_ns
27
+ self.count += 1
28
+
29
+ @property
30
+ def max_interval_ms(self) -> float:
31
+ return self.max_interval_ns / 1e6
32
+
33
+ @property
34
+ def span_s(self) -> float:
35
+ if self.first_ns is None or self.last_ns is None:
36
+ return 0.0
37
+ return (self.last_ns - self.first_ns) / 1e9
38
+
39
+ @property
40
+ def rate_hz(self) -> float | None:
41
+ """Mean rate over the topic's own span.
42
+
43
+ Uses count-1 intervals rather than count messages: for N evenly spaced
44
+ messages there are N-1 gaps, and dividing by N biases the rate low on
45
+ short recordings.
46
+ """
47
+ if self.count < 2 or self.span_s <= 0:
48
+ return None
49
+ return (self.count - 1) / self.span_s
50
+
51
+
52
+ @dataclass
53
+ class RunContext:
54
+ spec: Spec
55
+ stats: dict[str, TopicStat] = field(default_factory=dict)
56
+ start_ns: int = 0
57
+ end_ns: int = 0
58
+
59
+ @property
60
+ def duration_s(self) -> float:
61
+ return max(0.0, (self.end_ns - self.start_ns) / 1e9)
62
+
63
+ def rel_s(self, ns: int) -> float:
64
+ """Seconds since the first message in the bag."""
65
+ return (ns - self.start_ns) / 1e9
66
+
67
+
68
+ class Check(Protocol):
69
+ """A streaming accumulator.
70
+
71
+ Checks see every message once, in log_time order, then report at the end.
72
+ Payload deserialization is opt-in per check, via decode_topics().
73
+ """
74
+
75
+ def decode_topics(self, topics: Iterable[str]) -> set[str]:
76
+ """Topics whose payloads this check needs deserialized.
77
+
78
+ Resolved against the bag's actual channel list so spec globs can be
79
+ expanded. Return an empty set unless message fields are actually read.
80
+ """
81
+ ...
82
+
83
+ def on_message(self, msg: Message) -> None: ...
84
+
85
+ def finalize(self, ctx: RunContext) -> list[Finding]: ...