backplane-tracecheck 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backplane_tracecheck-0.2.0.dist-info/METADATA +167 -0
- backplane_tracecheck-0.2.0.dist-info/RECORD +15 -0
- backplane_tracecheck-0.2.0.dist-info/WHEEL +4 -0
- backplane_tracecheck-0.2.0.dist-info/entry_points.txt +2 -0
- backplane_tracecheck-0.2.0.dist-info/licenses/LICENSE +202 -0
- backplane_tracecheck-0.2.0.dist-info/licenses/NOTICE +5 -0
- tracecheck/__init__.py +1 -0
- tracecheck/__main__.py +131 -0
- tracecheck/contract.py +92 -0
- tracecheck/engine.py +310 -0
- tracecheck/reader.py +735 -0
- tracecheck/report.py +14 -0
- tracecheck/samples.py +103 -0
- tracecheck/server.py +158 -0
- tracecheck/ui.html +21 -0
tracecheck/engine.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Contract checks over MCAP records. Message payloads are never decoded."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import platform
|
|
10
|
+
from dataclasses import asdict
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import BinaryIO, Callable
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .contract import validate
|
|
17
|
+
from .reader import (
|
|
18
|
+
META_PAIRS_LIMIT,
|
|
19
|
+
META_VALUE_LIMIT,
|
|
20
|
+
TEXT_LIMIT,
|
|
21
|
+
Limits,
|
|
22
|
+
ScanError,
|
|
23
|
+
TrackConfig,
|
|
24
|
+
scan_fast,
|
|
25
|
+
scan_full,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
REPORT_VERSION = 2
|
|
29
|
+
NAMES = [
|
|
30
|
+
"Readable bounded MCAP scan",
|
|
31
|
+
"Required topics contain messages",
|
|
32
|
+
"Profile, schema and encoding",
|
|
33
|
+
"Required file metadata",
|
|
34
|
+
"Message counts",
|
|
35
|
+
"Average recorded rate",
|
|
36
|
+
"Sorted timestamp gaps",
|
|
37
|
+
"Record-order timestamp regressions",
|
|
38
|
+
"Sequence continuity",
|
|
39
|
+
"Expected interval coverage",
|
|
40
|
+
]
|
|
41
|
+
FULL_SCAN_ONLY = "Fast mode reads only the summary section; this check needs a full scan."
|
|
42
|
+
|
|
43
|
+
__all__ = ["Limits", "ScanError", "analyze_bytes", "analyze_file", "analyze_stream", "NAMES", "REPORT_VERSION"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def limits_report(limits: Limits) -> dict:
|
|
47
|
+
out = asdict(limits)
|
|
48
|
+
out.update(
|
|
49
|
+
text_field_characters=TEXT_LIMIT,
|
|
50
|
+
metadata_value_characters=META_VALUE_LIMIT,
|
|
51
|
+
metadata_pairs_per_record=META_PAIRS_LIMIT,
|
|
52
|
+
compression="none, zstd, lz4; output capped at declared uncompressed size",
|
|
53
|
+
)
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _plan(contract: dict) -> Callable[[str], TrackConfig | None]:
|
|
58
|
+
topics = contract["topics"]
|
|
59
|
+
|
|
60
|
+
def plan(topic: str) -> TrackConfig | None:
|
|
61
|
+
t = topics.get(topic)
|
|
62
|
+
if t is None:
|
|
63
|
+
return None
|
|
64
|
+
gap = t["max_gap_ns"] if t.get("clock_confirmed") and "max_gap_ns" in t else None
|
|
65
|
+
return TrackConfig(
|
|
66
|
+
basis_log=t.get("time_basis", "log_time") == "log_time",
|
|
67
|
+
sequence=t.get("sequence") == "increment_mod32",
|
|
68
|
+
max_gap=gap,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
return plan
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _loc(a: int, b: int) -> dict:
|
|
75
|
+
return {"file_offset": a} if b < 0 else {"chunk_file_offset": a, "chunk_inner_offset": b}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _msg(cid: int, m: tuple) -> dict:
|
|
79
|
+
return dict(_loc(m[1], m[2]), message_ordinal=m[0], channel_id=cid, log_time=m[3], publish_time=m[4], sequence=m[5])
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def analyze_stream(
|
|
83
|
+
f: BinaryIO,
|
|
84
|
+
size: int,
|
|
85
|
+
contract: dict,
|
|
86
|
+
filename: str = "recording.mcap",
|
|
87
|
+
*,
|
|
88
|
+
fast: bool = False,
|
|
89
|
+
limits: Limits | None = None,
|
|
90
|
+
progress: Callable[[int, int], None] | None = None,
|
|
91
|
+
) -> dict:
|
|
92
|
+
"""Check a seekable binary stream of ``size`` bytes against ``contract``."""
|
|
93
|
+
limits = limits or Limits()
|
|
94
|
+
c = validate(contract)
|
|
95
|
+
canonical = json.dumps(c, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
|
|
96
|
+
ev_limit = limits.evidence_per_finding
|
|
97
|
+
result: dict = {
|
|
98
|
+
"report_version": REPORT_VERSION,
|
|
99
|
+
"tool": {"name": "Backplane Tracecheck", "version": __version__, "implementation": "python",
|
|
100
|
+
"python": platform.python_version()},
|
|
101
|
+
"generated_utc": datetime.now(timezone.utc).isoformat(),
|
|
102
|
+
"source": {"name": Path(filename).name, "bytes": size, "sha256": None},
|
|
103
|
+
"scan_mode": "fast" if fast else "full",
|
|
104
|
+
"contract": c,
|
|
105
|
+
"contract_sha256": hashlib.sha256(canonical).hexdigest(),
|
|
106
|
+
"limits": limits_report(limits),
|
|
107
|
+
"provenance": "Local read-only scan; user-supplied contract; no payload decoding or execution.",
|
|
108
|
+
"interpretation": "Contract observations only. No safety certification, regulatory compliance, causality, "
|
|
109
|
+
"or original-recording truthfulness is established. Clock differences are not transport "
|
|
110
|
+
"latency.",
|
|
111
|
+
"checks": [{"id": f"C{i + 1:02}", "name": n, "status": "skipped", "findings": []} for i, n in enumerate(NAMES)],
|
|
112
|
+
} # fmt: skip
|
|
113
|
+
|
|
114
|
+
def finding(i, status, detail, evidence=None, total=None, **extra):
|
|
115
|
+
row = dict(status=status, detail=detail, **extra)
|
|
116
|
+
if evidence is not None:
|
|
117
|
+
count = len(evidence) if total is None else total
|
|
118
|
+
row["evidence_count"] = count
|
|
119
|
+
row["evidence"] = evidence[:ev_limit]
|
|
120
|
+
row["evidence_omitted"] = max(0, count - len(row["evidence"]))
|
|
121
|
+
result["checks"][i - 1]["findings"].append(row)
|
|
122
|
+
|
|
123
|
+
ignore_crc = c.get("missing_crc", "warn") == "ignore"
|
|
124
|
+
try:
|
|
125
|
+
r = None
|
|
126
|
+
if fast:
|
|
127
|
+
r = scan_fast(f, size, limits)
|
|
128
|
+
if r.fast_fallback:
|
|
129
|
+
result["scan_mode"] = "full"
|
|
130
|
+
result["fast_mode_fallback"] = r.fast_fallback
|
|
131
|
+
f.seek(0)
|
|
132
|
+
r = None
|
|
133
|
+
if r is None:
|
|
134
|
+
r = scan_full(f, size, _plan(c), limits, progress)
|
|
135
|
+
result["source"]["sha256"] = r.sha256
|
|
136
|
+
crc = r.crc
|
|
137
|
+
result.update(scan_complete=True, mcap_profile=r.profile, checksum_coverage=crc,
|
|
138
|
+
compression=r.compression, scan_stats=r.stats) # fmt: skip
|
|
139
|
+
if r.mode == "full":
|
|
140
|
+
# Message bytes are CRC-protected by a verified data-section CRC, or else by per-chunk CRCs.
|
|
141
|
+
# Unchunked messages are protected only by the data-section CRC.
|
|
142
|
+
missing = crc["data_section"] == "absent" and (crc["chunk_absent"] > 0 or
|
|
143
|
+
r.stats["unchunked_messages"] > 0) # fmt: skip
|
|
144
|
+
finding(1, "warn" if r.unknown or (missing and not ignore_crc) else "pass",
|
|
145
|
+
"Bounded scan completed. Data-section, chunk and summary CRCs checked when present; attachment "
|
|
146
|
+
"CRCs unchecked. Missing CRC warns only when some message data had no CRC coverage. This is not "
|
|
147
|
+
"full MCAP conformance validation.",
|
|
148
|
+
unknown_records_skipped=r.unknown, missing_crc_policy="ignore" if ignore_crc else "warn",
|
|
149
|
+
crc_coverage_complete=not missing) # fmt: skip
|
|
150
|
+
else:
|
|
151
|
+
missing = crc["summary"] == "absent"
|
|
152
|
+
finding(1, "warn" if r.unknown or (missing and not ignore_crc) else "pass",
|
|
153
|
+
"Fast mode: header, footer and summary section read and summary CRC checked when present. The "
|
|
154
|
+
"data section was not scanned, so message-level corruption is not detected.",
|
|
155
|
+
unknown_records_skipped=r.unknown, missing_crc_policy="ignore" if ignore_crc else "warn") # fmt: skip
|
|
156
|
+
except Exception as e:
|
|
157
|
+
partial = getattr(e, "result", None)
|
|
158
|
+
if partial is not None:
|
|
159
|
+
result["source"]["sha256"] = partial.sha256
|
|
160
|
+
result["scan_complete"] = False
|
|
161
|
+
finding(1, "fail", f"{type(e).__name__}: {str(e)[:300]}")
|
|
162
|
+
for i in range(2, 11):
|
|
163
|
+
finding(i, "skipped", "Scan incomplete; no completeness or timing conclusion.")
|
|
164
|
+
return finish(result)
|
|
165
|
+
|
|
166
|
+
schemas, channels, metadata = r.schemas, r.channels, r.metadata
|
|
167
|
+
|
|
168
|
+
def count_of(cid):
|
|
169
|
+
if r.counts is not None:
|
|
170
|
+
return r.counts.get(cid, 0)
|
|
171
|
+
tr = r.tracks.get(cid)
|
|
172
|
+
return tr.count if tr else 0
|
|
173
|
+
|
|
174
|
+
result["inventory"] = [
|
|
175
|
+
{"channel_id": cid, "topic": x["value"]["topic"], "message_count": count_of(cid),
|
|
176
|
+
"message_encoding": x["value"]["message_encoding"], "schema_id": x["value"]["schema_id"],
|
|
177
|
+
"schema": schemas.get(x["value"]["schema_id"], {}).get("value"), "location": x["location"]}
|
|
178
|
+
for cid, x in channels.items()
|
|
179
|
+
] # fmt: skip
|
|
180
|
+
|
|
181
|
+
if "profile" in c:
|
|
182
|
+
finding(3, "pass" if r.profile == c["profile"] else "fail", "MCAP Header profile equality",
|
|
183
|
+
[{"file_offset": 8}], expected=c["profile"], observed=r.profile) # fmt: skip
|
|
184
|
+
for name, fields in c.get("metadata", {}).items():
|
|
185
|
+
found = [m for m in metadata if m["name"] == name]
|
|
186
|
+
for key, expected in fields.items():
|
|
187
|
+
matches = [m for m in found if key in m["fields"] and
|
|
188
|
+
(m["fields"][key] == expected if expected is not None else bool(m["fields"][key]))] # fmt: skip
|
|
189
|
+
ok = bool(found) and len(matches) == len(found)
|
|
190
|
+
finding(4, "pass" if ok else "fail",
|
|
191
|
+
f"Metadata {name}.{key}: every matching record must satisfy the contract; null means nonempty.",
|
|
192
|
+
[m["location"] for m in found], expected=expected) # fmt: skip
|
|
193
|
+
|
|
194
|
+
for topic, t in c["topics"].items():
|
|
195
|
+
ids = [cid for cid, ch in channels.items() if ch["value"]["topic"] == topic]
|
|
196
|
+
active = [cid for cid in ids if count_of(cid)]
|
|
197
|
+
if t.get("required", True):
|
|
198
|
+
finding(2, "pass" if active else "fail", "Required topic needs at least one message.",
|
|
199
|
+
[channels[cid]["location"] for cid in ids], topic=topic) # fmt: skip
|
|
200
|
+
if not ids:
|
|
201
|
+
for i in [3, 5, 6, 7, 8, 9, 10]:
|
|
202
|
+
finding(i, "skipped", "Topic absent; see required-topic check.", topic=topic)
|
|
203
|
+
for cid in ids:
|
|
204
|
+
ch = channels[cid]["value"]
|
|
205
|
+
ctx = {"topic": topic, "channel_id": cid}
|
|
206
|
+
sch = schemas.get(ch["schema_id"], {}).get("value", {})
|
|
207
|
+
for key, actual in [("message_encoding", ch["message_encoding"]), ("schema_name", sch.get("name")),
|
|
208
|
+
("schema_encoding", sch.get("encoding")), ("schema_sha256", sch.get("sha256"))]:
|
|
209
|
+
if key in t:
|
|
210
|
+
finding(3, "pass" if actual == t[key] else "fail", key + " equality (schema payload not validated)",
|
|
211
|
+
[channels[cid]["location"]], expected=t[key], observed=actual, **ctx) # fmt: skip
|
|
212
|
+
n = count_of(cid)
|
|
213
|
+
tr = r.tracks.get(cid)
|
|
214
|
+
if "min_count" in t or "max_count" in t:
|
|
215
|
+
ok = t.get("min_count", 0) <= n <= t.get("max_count", float("inf"))
|
|
216
|
+
if tr is not None:
|
|
217
|
+
ev = [_msg(cid, tr.first), _msg(cid, tr.last)] if n else []
|
|
218
|
+
else:
|
|
219
|
+
ev = [channels[cid]["location"]]
|
|
220
|
+
finding(5, "pass" if ok else "fail", "Per-channel message count", ev,
|
|
221
|
+
observed=n, minimum=t.get("min_count"), maximum=t.get("max_count"), **ctx) # fmt: skip
|
|
222
|
+
if tr is None: # fast mode
|
|
223
|
+
for i in [6, 7, 8, 9, 10]:
|
|
224
|
+
finding(i, "skipped", FULL_SCAN_ONLY, **ctx)
|
|
225
|
+
continue
|
|
226
|
+
if t.get("sequence") == "increment_mod32" and n >= 2:
|
|
227
|
+
bad = [{"previous": _msg(cid, a), "current": _msg(cid, b)} for a, b in tr.seq_bad]
|
|
228
|
+
finding(9, "fail" if tr.seq_bad_n else "pass",
|
|
229
|
+
"Physical-order unit increment modulo 2^32; one sequence source per channel is a contract "
|
|
230
|
+
"assumption.", bad, total=tr.seq_bad_n, **ctx) # fmt: skip
|
|
231
|
+
else:
|
|
232
|
+
finding(9, "skipped", "Sequence disabled or fewer than two messages.", **ctx)
|
|
233
|
+
basis = t.get("time_basis", "log_time")
|
|
234
|
+
k = 3 if basis == "log_time" else 4
|
|
235
|
+
if not t.get("clock_confirmed") or not n:
|
|
236
|
+
for i in [6, 7, 8, 10]:
|
|
237
|
+
finding(i, "skipped", "Needs clock_confirmed=true and messages; clock domain/epoch must be known "
|
|
238
|
+
"by the user.", **ctx) # fmt: skip
|
|
239
|
+
continue
|
|
240
|
+
lo, hi = tr.lo, tr.hi
|
|
241
|
+
if ("min_rate_hz" in t or "max_rate_hz" in t) and n >= 2 and hi[k] > lo[k]:
|
|
242
|
+
rate = (n - 1) * 1e9 / (hi[k] - lo[k])
|
|
243
|
+
ok = t.get("min_rate_hz", 0) <= rate <= t.get("max_rate_hz", float("inf"))
|
|
244
|
+
finding(6, "pass" if ok else "fail",
|
|
245
|
+
"(N-1) / (max timestamp - min timestamp), in seconds; not instantaneous rate.",
|
|
246
|
+
[_msg(cid, lo), _msg(cid, hi)], observed_hz=rate, time_basis=basis, **ctx) # fmt: skip
|
|
247
|
+
else:
|
|
248
|
+
finding(6, "skipped", "Rate bounds absent, fewer than two messages, or zero timestamp span.", **ctx)
|
|
249
|
+
if "max_gap_ns" in t and n >= 2:
|
|
250
|
+
sample, total = tr.sorted_gaps()
|
|
251
|
+
bad = []
|
|
252
|
+
for a, b, g in sample:
|
|
253
|
+
if tr.monotonic:
|
|
254
|
+
pa = dict(_loc(a[1], a[2]), message_ordinal=a[0], channel_id=cid, **{basis: a[k]})
|
|
255
|
+
pb = dict(_loc(b[1], b[2]), message_ordinal=b[0], channel_id=cid, **{basis: b[k]})
|
|
256
|
+
else:
|
|
257
|
+
pa = dict(_loc(tr.loc_a[a], tr.loc_b[a]), message_ordinal=tr.ords[a], channel_id=cid,
|
|
258
|
+
**{basis: tr.times[a]}) # fmt: skip
|
|
259
|
+
pb = dict(_loc(tr.loc_a[b], tr.loc_b[b]), message_ordinal=tr.ords[b], channel_id=cid,
|
|
260
|
+
**{basis: tr.times[b]}) # fmt: skip
|
|
261
|
+
bad.append({"previous": pa, "current": pb, "gap_ns": g})
|
|
262
|
+
finding(7, "fail" if total else "pass",
|
|
263
|
+
"Adjacent sorted timestamps; only interior gaps. Equal-to-threshold passes.",
|
|
264
|
+
bad, total=total, time_basis=basis, **ctx) # fmt: skip
|
|
265
|
+
else:
|
|
266
|
+
finding(7, "skipped", "Gap threshold absent or fewer than two messages.", **ctx)
|
|
267
|
+
if t.get("check_regression") and n >= 2:
|
|
268
|
+
bad = [{"previous": _msg(cid, a), "current": _msg(cid, b)} for a, b in tr.reg_bad]
|
|
269
|
+
finding(8, "fail" if tr.reg_bad_n else "pass",
|
|
270
|
+
"Strictly decreasing timestamps in physical record order; equal values allowed.",
|
|
271
|
+
bad, total=tr.reg_bad_n, time_basis=basis, **ctx) # fmt: skip
|
|
272
|
+
else:
|
|
273
|
+
finding(8, "skipped", "Regression check disabled or fewer than two messages.", **ctx)
|
|
274
|
+
if "interval_ns" in t:
|
|
275
|
+
start, end = t["interval_ns"]
|
|
276
|
+
tol = t.get("boundary_tolerance_ns", 0)
|
|
277
|
+
first, last = lo[k], hi[k]
|
|
278
|
+
ok = start <= first <= start + tol and end - tol <= last <= end
|
|
279
|
+
finding(10, "pass" if ok else "fail",
|
|
280
|
+
"All timestamps inside inclusive expected interval, with first/last within endpoint "
|
|
281
|
+
"tolerance; interior continuity is C07.",
|
|
282
|
+
[_msg(cid, lo), _msg(cid, hi)], expected_interval_ns=[start, end],
|
|
283
|
+
boundary_tolerance_ns=tol, time_basis=basis, **ctx) # fmt: skip
|
|
284
|
+
else:
|
|
285
|
+
finding(10, "skipped", "No expected interval configured.", **ctx)
|
|
286
|
+
return finish(result)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def analyze_file(path: str | os.PathLike, contract: dict, filename: str | None = None, **kwargs) -> dict:
|
|
290
|
+
"""Check a recording on disk. The file is opened read-only and streamed."""
|
|
291
|
+
p = Path(path)
|
|
292
|
+
with p.open("rb") as f:
|
|
293
|
+
size = os.fstat(f.fileno()).st_size
|
|
294
|
+
return analyze_stream(f, size, contract, filename or p.name, **kwargs)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def analyze_bytes(data: bytes, contract: dict, filename: str = "recording.mcap", **kwargs) -> dict:
|
|
298
|
+
"""Check an in-memory recording."""
|
|
299
|
+
return analyze_stream(io.BytesIO(data), len(data), contract, filename, **kwargs)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def finish(result: dict) -> dict:
|
|
303
|
+
priority = {"skipped": 0, "pass": 1, "warn": 2, "fail": 3}
|
|
304
|
+
for check in result["checks"]:
|
|
305
|
+
if not check["findings"]:
|
|
306
|
+
check["findings"] = [{"status": "skipped", "detail": "No applicable requirement configured."}]
|
|
307
|
+
check["status"] = max((f["status"] for f in check["findings"]), key=priority.get)
|
|
308
|
+
result["overall"] = max((x["status"] for x in result["checks"]), key=priority.get)
|
|
309
|
+
result["status_counts"] = {s: sum(x["status"] == s for x in result["checks"]) for s in priority}
|
|
310
|
+
return result
|