343 lines
14 KiB
Python
Executable File
343 lines
14 KiB
Python
Executable File
#!/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()
|