#!/usr/bin/env python3 """Build a T0-T5 timing trace from a client-to-RCT probe artifact.""" from __future__ import annotations import argparse import json import math import pathlib import statistics from typing import Any def load_json(path: pathlib.Path) -> dict[str, Any]: """Read a JSON object from `path`. Inputs: an artifact path produced by the client-to-RCT harness. Output: the parsed object. Why: the trace intentionally reuses the blind probe artifacts instead of adding another media generator. """ return json.loads(path.read_text()) def load_jsonl(path: pathlib.Path) -> list[dict[str, Any]]: """Read valid JSON records from an optional JSONL artifact. Inputs: a JSONL path. Output: parsed objects with malformed lines ignored. Why: operational probe logs can be truncated when a run fails, but partial timing evidence is still useful for locating the layer that drifted. """ if not path.exists(): return [] records: list[dict[str, Any]] = [] for line in path.read_text(errors="replace").splitlines(): if not line.strip(): continue try: records.append(json.loads(line)) except json.JSONDecodeError: continue return records def percentile(values: list[float], q: float) -> float | None: """Return a nearest-rank percentile for finite values. Inputs: numeric samples and a quantile. Output: the selected percentile or `None`. Why: the trace needs the same conservative p95 language as the sync and freshness gates. """ finite = sorted(value for value in values if math.isfinite(value)) if not finite: return None index = min(len(finite) - 1, max(0, math.ceil(len(finite) * q) - 1)) return finite[index] def fmt_ms(value: float | None) -> str: """Format optional millisecond evidence for human reports.""" return f"{value:.1f}ms" if value is not None else "unavailable" def capture_start_ns(capture_log: pathlib.Path) -> int | None: """Return the RCT recorder Unix start timestamp from the capture log. Inputs: the recorder log. Output: Unix nanoseconds or `None`. Why: converting capture-relative detections into client time lets us split end-to-end freshness into pre-send and post-send portions. """ if not capture_log.exists(): return None for line in capture_log.read_text(errors="replace").splitlines(): if line.startswith("capture_start_unix_ns="): return int(line.split("=", 1)[1].strip()) return None def as_float(value: Any) -> float | None: """Parse a finite float from relayctl fields.""" if value in (None, "pending"): return None try: parsed = float(str(value).replace("+", "")) except (TypeError, ValueError): return None return parsed if math.isfinite(parsed) else None def summarize_upstream_samples(records: list[dict[str, Any]]) -> dict[str, Any] | None: """Summarize sampled server receive and sink timing. Inputs: `upstream-sync-samples.jsonl` records. Output: p95 layer metrics. Why: the current server telemetry is sampled instead of per-event; this still tells us whether the failure is already visible at receive/sink time. """ buckets: dict[str, list[float]] = { "camera_client_queue_age_ms": [], "microphone_client_queue_age_ms": [], "camera_server_receive_age_ms": [], "microphone_server_receive_age_ms": [], "camera_sink_late_ms": [], "microphone_sink_late_ms": [], "server_receive_abs_skew_ms": [], "sink_handoff_abs_skew_ms": [], } live_samples = 0 for record in records: fields = record.get("fields") or {} if fields.get("planner_phase") == "live": live_samples += 1 mapping = { "planner_camera_client_queue_age_ms": "camera_client_queue_age_ms", "planner_microphone_client_queue_age_ms": "microphone_client_queue_age_ms", "planner_camera_server_receive_age_ms": "camera_server_receive_age_ms", "planner_microphone_server_receive_age_ms": "microphone_server_receive_age_ms", "planner_camera_sink_late_ms": "camera_sink_late_ms", "planner_microphone_sink_late_ms": "microphone_sink_late_ms", "planner_server_receive_abs_skew_p95_ms": "server_receive_abs_skew_ms", "planner_sink_handoff_abs_skew_p95_ms": "sink_handoff_abs_skew_ms", } for source, target in mapping.items(): parsed = as_float(fields.get(source)) if parsed is not None: buckets[target].append(parsed) if live_samples == 0 and not any(buckets.values()): return None return { "sample_count": live_samples, **{f"{name}_p95": percentile(values, 0.95) for name, values in buckets.items()}, } def event_send_records( event: dict[str, Any], send_records: list[dict[str, Any]], ) -> list[dict[str, Any]]: """Return client send records whose video PTS falls inside an event window. Inputs: a synthetic event and client bundle send records. Output: matching bundles. Why: T1 should be the bundle send containing the coded video flash, not merely the nearest arbitrary frame. """ start = int(event.get("planned_start_us") or -1) end = int(event.get("planned_end_us") or -1) if start < 0 or end <= start: return [] return [ record for record in send_records if start <= int(record.get("video_capture_pts_us") or -1) < end ] def build_trace(report_dir: pathlib.Path) -> dict[str, Any]: """Build the T0-T5 trace summary for one completed probe directory. Inputs: a client-to-RCT artifact directory. Output: structured trace data. Why: failed blind runs need enough layer evidence to decide whether to tune client generation, transport/server ingress, HEVC decode/UVC handoff, or final RCT capture before we touch production offsets. """ timeline = load_json(report_dir / "client-transport-timeline.json") report = load_json(report_dir / "report.json") transport = load_json(report_dir / "client-rct-transport-summary.json") clock = load_json(report_dir / "clock-alignment.json") send_records = load_jsonl(report_dir / "client-send-bundles.jsonl") upstream_records = load_jsonl(report_dir / "upstream-sync-samples.jsonl") capture_start = capture_start_ns(report_dir / "capture.log") offset_ns = int(clock.get("capture_clock_offset_from_client_ns") or 0) pairs = { int(pair.get("server_event_id", pair.get("event_id", -1))): pair for pair in report.get("paired_events", []) } summary_events = { int(event.get("event_id", -1)): event for event in transport.get("events", []) } rows: list[dict[str, Any]] = [] t0_t1_values: list[float] = [] t1_t5_video_values: list[float] = [] t1_t5_audio_values: list[float] = [] for event in timeline.get("events", []): event_id = int(event.get("event_id", -1)) matches = event_send_records(event, send_records) first_send = min(matches, key=lambda record: int(record["send_unix_ns"])) if matches else None pair = pairs.get(event_id) summary_event = summary_events.get(event_id) t0_ns = int(event.get("client_capture_unix_ns") or 0) t1_ns = int(first_send["send_unix_ns"]) if first_send else None t0_t1_ms = ((t1_ns - t0_ns) / 1_000_000.0) if t1_ns and t0_ns else None if t0_t1_ms is not None: t0_t1_values.append(t0_t1_ms) t1_t5_video_ms = None t1_t5_audio_ms = None if pair and t1_ns and capture_start is not None: send_capture_s = (t1_ns + offset_ns - capture_start) / 1_000_000_000.0 t1_t5_video_ms = (float(pair["video_time_s"]) - send_capture_s) * 1000.0 t1_t5_audio_ms = (float(pair["audio_time_s"]) - send_capture_s) * 1000.0 t1_t5_video_values.append(t1_t5_video_ms) t1_t5_audio_values.append(t1_t5_audio_ms) rows.append( { "event_id": event_id, "code": event.get("code"), "t0_client_capture_unix_ns": t0_ns or None, "t1_client_send_unix_ns": t1_ns, "t0_to_t1_local_send_ms": t0_t1_ms, "event_video_bundle_count": len(matches), "t5_rct_video_s": pair.get("video_time_s") if pair else None, "t5_rct_audio_s": pair.get("audio_time_s") if pair else None, "t0_to_t5_video_ms": summary_event.get("video_age_ms") if summary_event else None, "t0_to_t5_audio_ms": summary_event.get("audio_age_ms") if summary_event else None, "t1_to_t5_video_ms": t1_t5_video_ms, "t1_to_t5_audio_ms": t1_t5_audio_ms, "sync_skew_ms": pair.get("skew_ms") if pair else None, "paired": pair is not None, } ) uvc_summary_path = report_dir / "uvc-frame-meta-summary.json" uvc_summary = load_json(uvc_summary_path) if uvc_summary_path.exists() else None return { "schema": "lesavka.client-rct-timing-trace.v1", "report_dir": str(report_dir), "verdict": "pass" if transport.get("passed") else "fail", "sync_status": transport.get("sync_status"), "sync_p95_abs_skew_ms": (report.get("verdict") or {}).get("p95_abs_skew_ms"), "sync_median_skew_ms": report.get("median_skew_ms"), "sync_drift_ms": report.get("drift_ms"), "paired_event_count": transport.get("paired_event_count"), "expected_event_count": transport.get("expected_event_count"), "freshness_budget_ms": transport.get("freshness_budget_ms"), "freshness_limit_ms": transport.get("freshness_limit_ms"), "t0_to_t1_local_send_p95_ms": percentile(t0_t1_values, 0.95), "t1_to_t5_video_p95_ms": percentile(t1_t5_video_values, 0.95), "t1_to_t5_audio_p95_ms": percentile(t1_t5_audio_values, 0.95), "t0_to_t5_video_p95_ms": transport.get("video_age_p95_ms"), "t0_to_t5_audio_p95_ms": transport.get("audio_age_p95_ms"), "upstream_sampled_layers": summarize_upstream_samples(upstream_records), "uvc_spool": uvc_summary, "smoothness": transport.get("smoothness"), "events": rows, "notes": [ "T0 is synthetic capture time on the client.", "T1 is the first outgoing bundle whose video PTS lands inside the coded event window.", "T2/T3 are sampled server receive/sink telemetry, not per-event timestamps yet.", "T4 UVC spool evidence is present only when the server metadata log is enabled.", "T5 is final RCT capture detection from the flash/tone analyzer.", ], } def text_report(trace: dict[str, Any]) -> str: """Render the timing trace summary for humans.""" upstream = trace.get("upstream_sampled_layers") or {} uvc = trace.get("uvc_spool") or {} smoothness = trace.get("smoothness") or {} lines = [ f"Client-to-RCT T0-T5 timing trace for {trace['report_dir']}", f"- verdict: {trace['verdict']}", f"- sync: {trace.get('sync_status')} p95={fmt_ms(trace.get('sync_p95_abs_skew_ms'))} " f"median={fmt_ms(trace.get('sync_median_skew_ms'))} drift={fmt_ms(trace.get('sync_drift_ms'))}", f"- evidence: paired={trace.get('paired_event_count')}/{trace.get('expected_event_count')}", f"- freshness: budget={fmt_ms(trace.get('freshness_budget_ms'))} " f"limit={fmt_ms(trace.get('freshness_limit_ms'))}", "- layer p95:", f" T0->T1 local bundle send: {fmt_ms(trace.get('t0_to_t1_local_send_p95_ms'))}", f" T1->T5 RCT video detect: {fmt_ms(trace.get('t1_to_t5_video_p95_ms'))}", f" T1->T5 RCT audio detect: {fmt_ms(trace.get('t1_to_t5_audio_p95_ms'))}", f" T0->T5 RCT video detect: {fmt_ms(trace.get('t0_to_t5_video_p95_ms'))}", f" T0->T5 RCT audio detect: {fmt_ms(trace.get('t0_to_t5_audio_p95_ms'))}", "- sampled server layers:", f" T2 server receive age p95: video={fmt_ms(upstream.get('camera_server_receive_age_ms_p95'))} " f"audio={fmt_ms(upstream.get('microphone_server_receive_age_ms_p95'))}", f" T2 receive A/V skew p95: {fmt_ms(upstream.get('server_receive_abs_skew_ms_p95'))}", f" T3 sink late p95: video={fmt_ms(upstream.get('camera_sink_late_ms_p95'))} " f"audio={fmt_ms(upstream.get('microphone_sink_late_ms_p95'))}", f" T3 sink handoff skew p95: {fmt_ms(upstream.get('sink_handoff_abs_skew_ms_p95'))}", "- optional UVC spool layer:", ] if uvc: coverage = uvc.get("event_coverage") or {} lines.append( f" T4 records={uvc.get('record_count')} profiles={uvc.get('profiles')} " f"covered_events={coverage.get('covered_events')}/{coverage.get('expected_events')}" ) else: lines.append(" T4 unavailable; enable LESAVKA_UVC_FRAME_META_LOG_PATH for spool-boundary evidence") lines.extend( [ "- smoothness:", f" video_hiccups={smoothness.get('video_hiccups')} " f"audio_hiccups={smoothness.get('audio_hiccups')} " f"video_p95_jitter={fmt_ms(smoothness.get('video_p95_jitter_ms'))}", ] ) missing = [event for event in trace["events"] if not event["paired"]] if missing: lines.append( "- missing paired event codes: " + ",".join(str(event.get("code")) for event in missing if event.get("code") is not None) ) return "\n".join(lines) + "\n" def parse_args() -> argparse.Namespace: """Parse CLI arguments for the trace summarizer.""" parser = argparse.ArgumentParser() parser.add_argument("report_dir", type=pathlib.Path) parser.add_argument("json_out", type=pathlib.Path) parser.add_argument("txt_out", type=pathlib.Path) return parser.parse_args() def main() -> None: """Write the JSON and text T0-T5 trace artifacts.""" args = parse_args() trace = build_trace(args.report_dir) args.json_out.write_text(json.dumps(trace, indent=2, sort_keys=True) + "\n") args.txt_out.write_text(text_report(trace)) print(args.txt_out.read_text(), end="") if __name__ == "__main__": main()