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.
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