diagnostics: add synthetic rct uvc probe

This commit is contained in:
Brad Stein 2026-05-16 06:48:01 -03:00
parent 46538c4f44
commit 260cb263f6
9 changed files with 1364 additions and 6 deletions

6
Cargo.lock generated
View File

@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lesavka_client"
version = "0.22.46"
version = "0.22.47"
dependencies = [
"anyhow",
"async-stream",
@ -1686,7 +1686,7 @@ dependencies = [
[[package]]
name = "lesavka_common"
version = "0.22.46"
version = "0.22.47"
dependencies = [
"anyhow",
"base64",
@ -1698,7 +1698,7 @@ dependencies = [
[[package]]
name = "lesavka_server"
version = "0.22.46"
version = "0.22.47"
dependencies = [
"anyhow",
"base64",

View File

@ -472,6 +472,10 @@ path = "tests/manual/server/rct/server_rct_mode_matrix_manual_contract.rs"
name = "rct_uvc_artifact_probe_manual_contract"
path = "tests/manual/server/rct/rct_uvc_artifact_probe_manual_contract.rs"
[[test]]
name = "synthetic_rct_uvc_probe_manual_contract"
path = "tests/manual/server/rct/synthetic_rct_uvc_probe_manual_contract.rs"
[[test]]
name = "google_meet_observer_manual_contract"
path = "tests/manual/google_meet/google_meet_observer_manual_contract.rs"

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package]
name = "lesavka_client"
version = "0.22.46"
version = "0.22.47"
edition = "2024"
[dependencies]

View File

@ -1,6 +1,6 @@
[package]
name = "lesavka_common"
version = "0.22.46"
version = "0.22.47"
edition = "2024"
build = "build.rs"

View File

@ -1524,6 +1524,7 @@ run_as_user env TMPDIR="$TMPDIR" bash -c "cd '$SRC_DIR/server' && cargo clean &&
echo "==> 5. Install binaries"
install_verified_executable "$SRC_DIR/target/release/lesavka-server" /usr/local/bin/lesavka-server "lesavka-server"
install_verified_executable "$SRC_DIR/target/release/lesavka-uvc" /usr/local/bin/lesavka-uvc "lesavka-uvc"
install_verified_executable "$SRC_DIR/target/release/lesavka-synthetic-uplink" /usr/local/bin/lesavka-synthetic-uplink "lesavka-synthetic-uplink"
install_verified_executable "$SRC_DIR/scripts/daemon/lesavka-core.sh" /usr/local/bin/lesavka-core.sh "lesavka-core.sh"
install_verified_executable "$SRC_DIR/scripts/daemon/lesavka-uvc.sh" /usr/local/bin/lesavka-uvc.sh "lesavka-uvc.sh"
install_verified_executable "$SRC_DIR/scripts/daemon/lesavka-recovery-ladder.sh" /usr/local/bin/lesavka-recovery-ladder "lesavka-recovery-ladder"

View File

@ -0,0 +1,710 @@
#!/usr/bin/env python3
"""Run synthetic Lesavka uplink media and compare what the RCT receives."""
from __future__ import annotations
import argparse
import collections
import json
import os
import pathlib
import shlex
import shutil
import subprocess
import sys
import time
from typing import Any
DEFAULT_DEVICE_LABEL = "Lesavka Composite"
DEFAULT_MODES = "1280x720@20,1280x720@30,1920x1080@20,1920x1080@30"
MARKER_BITS = 32
MARKER_COLUMNS = 16
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Manual synthetic end-to-end probe: Theia sends sequence-coded media "
"through StreamWebcamMedia while Tethys captures the received UVC/X11 "
"frames and compares them to the generated source."
)
)
parser.add_argument("--inject-host", default="", help="Theia SSH host, e.g. titan-jh")
parser.add_argument("--rct-host", default="", help="RCT SSH host, e.g. tethys")
parser.add_argument("--server", default="http://127.0.0.1:50051")
parser.add_argument("--inject-binary", default="/usr/local/bin/lesavka-synthetic-uplink")
parser.add_argument("--mode", default="1280x720@30", help=f"one mode; baseline set is {DEFAULT_MODES}")
parser.add_argument("--width", type=int, default=0, help="override capture width")
parser.add_argument("--height", type=int, default=0, help="override capture height")
parser.add_argument("--fps", type=int, default=0, help="override capture fps")
parser.add_argument("--duration", type=float, default=300.0)
parser.add_argument("--source", choices=["device", "x11"], default="device")
parser.add_argument("--device", default="auto")
parser.add_argument("--device-label", default=DEFAULT_DEVICE_LABEL)
parser.add_argument("--display", default=":0")
parser.add_argument("--crop", default="", help="x,y,width,height for --source x11")
parser.add_argument("--artifact-dir", default="")
parser.add_argument("--remote-rct-dir", default="")
parser.add_argument("--remote-inject-dir", default="")
parser.add_argument("--x-step", type=int, default=8)
parser.add_argument("--y-step", type=int, default=4)
parser.add_argument("--bands", type=int, default=24)
parser.add_argument("--mae-threshold", type=float, default=18.0)
parser.add_argument("--lower-mae-threshold", type=float, default=28.0)
parser.add_argument("--lower-skew-ratio", type=float, default=1.8)
parser.add_argument("--slab-var", type=float, default=20.0)
parser.add_argument("--shift-threshold", type=float, default=16.0)
parser.add_argument("--shift-improvement", type=float, default=1.25)
parser.add_argument("--max-suspicious-artifacts", type=int, default=80)
parser.add_argument("--max-reference-artifacts", type=int, default=12)
parser.add_argument("--reference-every", type=int, default=900)
parser.add_argument("--progress-every", type=int, default=150)
parser.add_argument("--capture-only", action="store_true", help=argparse.SUPPRESS)
parser.add_argument("--self-test", action="store_true")
return parser.parse_args()
def timestamp() -> str:
return time.strftime("%Y%m%d-%H%M%S", time.gmtime())
def parse_mode(value: str) -> tuple[int, int, int]:
try:
size, fps = value.lower().split("@", 1)
width, height = size.split("x", 1)
return int(width), int(height), int(fps)
except ValueError as exc:
raise SystemExit(f"--mode must look like WIDTHxHEIGHT@FPS, got {value!r}") from exc
def mode_dimensions(args: argparse.Namespace) -> tuple[int, int, int]:
width, height, fps = parse_mode(args.mode)
if args.width:
width = args.width
if args.height:
height = args.height
if args.fps:
fps = args.fps
if width <= 0 or height <= 0 or fps <= 0:
raise SystemExit("width, height, and fps must be positive")
return width, height, fps
def default_artifact_dir(mode: str) -> pathlib.Path:
safe_mode = mode.replace("@", "-").replace("x", "x")
return pathlib.Path("artifacts/synthetic-rct") / f"{safe_mode}-{timestamp()}"
def run_remote_orchestrated(args: argparse.Namespace) -> int:
if not args.inject_host or not args.rct_host:
raise SystemExit("--inject-host and --rct-host are required unless --capture-only or --self-test is used")
if not shutil.which("ssh") or not shutil.which("scp"):
raise SystemExit("ssh and scp are required for the remote synthetic probe")
width, height, fps = mode_dimensions(args)
artifact_dir = pathlib.Path(args.artifact_dir) if args.artifact_dir else default_artifact_dir(args.mode)
artifact_dir.mkdir(parents=True, exist_ok=True)
remote_rct_dir = args.remote_rct_dir or f"/tmp/lesavka-synthetic-rct-capture-{timestamp()}"
remote_inject_dir = args.remote_inject_dir or f"/tmp/lesavka-synthetic-uplink-{timestamp()}"
remote_script = f"/tmp/lesavka-synthetic-rct-probe-{os.getpid()}.py"
script_text = pathlib.Path(__file__).read_text()
subprocess.run(
["ssh", args.rct_host, f"cat > {shlex.quote(remote_script)} && chmod +x {shlex.quote(remote_script)}"],
input=script_text,
text=True,
check=True,
)
capture_cmd = [
"python3",
remote_script,
"--capture-only",
"--mode",
args.mode,
"--width",
str(width),
"--height",
str(height),
"--fps",
str(fps),
"--duration",
str(args.duration),
"--source",
args.source,
"--device",
args.device,
"--device-label",
args.device_label,
"--display",
args.display,
"--crop",
args.crop,
"--artifact-dir",
remote_rct_dir,
"--x-step",
str(args.x_step),
"--y-step",
str(args.y_step),
"--bands",
str(args.bands),
"--mae-threshold",
str(args.mae_threshold),
"--lower-mae-threshold",
str(args.lower_mae_threshold),
"--lower-skew-ratio",
str(args.lower_skew_ratio),
"--slab-var",
str(args.slab_var),
"--shift-threshold",
str(args.shift_threshold),
"--shift-improvement",
str(args.shift_improvement),
"--max-suspicious-artifacts",
str(args.max_suspicious_artifacts),
"--max-reference-artifacts",
str(args.max_reference_artifacts),
"--reference-every",
str(args.reference_every),
"--progress-every",
str(args.progress_every),
]
inject_cmd = [
args.inject_binary,
"--server",
args.server,
"--mode",
args.mode,
"--duration",
str(args.duration + 2.0),
"--artifact-dir",
remote_inject_dir,
"--print-every",
str(args.progress_every),
]
(artifact_dir / "orchestrator-command.txt").write_text(" ".join(sys.argv) + "\n")
(artifact_dir / "mode.json").write_text(
json.dumps(
{
"schema": "lesavka.synthetic-rct-probe.run.v1",
"mode": args.mode,
"width": width,
"height": height,
"fps": fps,
"source": args.source,
"duration_s": args.duration,
"inject_host": args.inject_host,
"rct_host": args.rct_host,
},
indent=2,
sort_keys=True,
)
+ "\n"
)
print(f"starting RCT capture on {args.rct_host}: {remote_rct_dir}", file=sys.stderr)
capture = subprocess.Popen(["ssh", args.rct_host, " ".join(shlex.quote(part) for part in capture_cmd)])
time.sleep(1.0)
print(f"starting synthetic uplink on {args.inject_host}: {remote_inject_dir}", file=sys.stderr)
inject = subprocess.Popen(["ssh", args.inject_host, " ".join(shlex.quote(part) for part in inject_cmd)])
inject_rc = inject.wait()
capture_rc = capture.wait()
local_capture = artifact_dir / "capture"
local_inject = artifact_dir / "inject"
subprocess.run(["scp", "-r", f"{args.rct_host}:{remote_rct_dir}", str(local_capture)], check=False)
subprocess.run(["scp", "-r", f"{args.inject_host}:{remote_inject_dir}", str(local_inject)], check=False)
summary = {
"schema": "lesavka.synthetic-rct-probe.orchestrator.v1",
"mode": args.mode,
"capture_rc": capture_rc,
"inject_rc": inject_rc,
"artifact_dir": str(artifact_dir),
"capture_artifacts": str(local_capture),
"inject_artifacts": str(local_inject),
}
(artifact_dir / "run-summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n")
print(json.dumps(summary, indent=2, sort_keys=True))
print(f"artifact_dir: {artifact_dir}")
return 0 if capture_rc == 0 and inject_rc == 0 else 1
def detect_video_device(label: str) -> str:
explicit = os.environ.get("LESAVKA_RCT_UVC_DEVICE")
if explicit:
return explicit
try:
listing = subprocess.check_output(["v4l2-ctl", "--list-devices"], text=True)
except Exception:
return "/dev/video2"
current_matches = False
for line in listing.splitlines():
if not line.startswith(("\t", " ")):
current_matches = label.lower() in line.lower()
continue
value = line.strip()
if current_matches and value.startswith("/dev/video"):
return value
return "/dev/video2"
def parse_crop(args: argparse.Namespace, width: int, height: int) -> tuple[int, int, int, int]:
if not args.crop:
return 0, 0, width, height
parts = [part.strip() for part in args.crop.split(",")]
if len(parts) != 4:
raise SystemExit("--crop must be x,y,width,height")
x, y, crop_width, crop_height = [int(part) for part in parts]
if crop_width <= 0 or crop_height <= 0:
raise SystemExit("--crop width and height must be positive")
return x, y, crop_width, crop_height
def ffmpeg_cmd(args: argparse.Namespace, width: int, height: int) -> tuple[list[str], int, int, str]:
if args.source == "x11":
x, y, capture_width, capture_height = parse_crop(args, width, height)
display = f"{args.display}+{x},{y}"
return (
[
"ffmpeg",
"-hide_banner",
"-nostdin",
"-loglevel",
"warning",
"-f",
"x11grab",
"-video_size",
f"{capture_width}x{capture_height}",
"-framerate",
str(args.fps or parse_mode(args.mode)[2]),
"-i",
display,
"-an",
"-pix_fmt",
"gray",
"-f",
"rawvideo",
"-",
],
capture_width,
capture_height,
display,
)
device = detect_video_device(args.device_label) if args.device == "auto" else args.device
return (
[
"ffmpeg",
"-hide_banner",
"-nostdin",
"-loglevel",
"warning",
"-f",
"v4l2",
"-input_format",
"mjpeg",
"-video_size",
f"{width}x{height}",
"-framerate",
str(args.fps or parse_mode(args.mode)[2]),
"-i",
device,
"-an",
"-pix_fmt",
"gray",
"-f",
"rawvideo",
"-",
],
width,
height,
device,
)
def marker_cell(width: int, height: int) -> int:
return max(6, min(16, min(width, height) // 80))
def fill_rect(frame: bytearray, width: int, height: int, x0: int, y0: int, w: int, h: int, value: int) -> None:
for y in range(max(0, y0), min(height, y0 + h)):
row = y * width
for x in range(max(0, x0), min(width, x0 + w)):
frame[row + x] = value
def synthetic_gray(width: int, height: int, sequence: int) -> bytes:
data = bytearray(width * height)
moving_period = max(width // 3, 64)
moving_width = max(width // 18, 12)
moving_offset = (sequence * 17) % moving_period
center_x = width // 2
center_y = height // 2
for y in range(height):
row = y * width
for x in range(width):
value = (x * 3 + y * 5 + sequence * 11) & 0xFF
if (x + moving_offset) % moving_period < moving_width:
value = min(255, value + 70)
if abs(x - center_x) < width // 9 and abs(y - center_y) < height // 12:
value = 255 - value // 2
if y >= height // 2 and (((x // 32) + (y // 24) + sequence) & 1) == 0:
value //= 3
data[row + x] = value
draw_marker(data, width, height, sequence)
return bytes(data)
def draw_marker(frame: bytearray, width: int, height: int, sequence: int) -> None:
cell = marker_cell(width, height)
rows = (MARKER_BITS + MARKER_COLUMNS - 1) // MARKER_COLUMNS
if width < (MARKER_COLUMNS + 4) * cell or height < (rows + 4) * cell:
return
x0 = 2 * cell
y0 = 2 * cell
fill_rect(frame, width, height, cell, cell, (MARKER_COLUMNS + 2) * cell, (rows + 2) * cell, 32)
fill_rect(frame, width, height, x0 - cell, y0 - cell, cell, cell, 255)
fill_rect(frame, width, height, x0 + MARKER_COLUMNS * cell, y0 - cell, cell, cell, 0)
for bit in range(MARKER_BITS):
col = bit % MARKER_COLUMNS
row = bit // MARKER_COLUMNS
value = 255 if ((sequence >> bit) & 1) else 0
fill_rect(frame, width, height, x0 + col * cell, y0 + row * cell, cell, cell, value)
def cell_mean(frame: bytes, width: int, x0: int, y0: int, cell: int) -> float:
total = 0
count = 0
inset = max(1, cell // 4)
for y in range(y0 + inset, y0 + cell - inset):
row = y * width
for x in range(x0 + inset, x0 + cell - inset):
total += frame[row + x]
count += 1
return total / max(1, count)
def decode_sequence(frame: bytes, width: int, height: int) -> tuple[int | None, int]:
cell = marker_cell(width, height)
rows = (MARKER_BITS + MARKER_COLUMNS - 1) // MARKER_COLUMNS
if width < (MARKER_COLUMNS + 4) * cell or height < (rows + 4) * cell:
return None, MARKER_BITS
x0 = 2 * cell
y0 = 2 * cell
value = 0
uncertain = 0
for bit in range(MARKER_BITS):
col = bit % MARKER_COLUMNS
row = bit // MARKER_COLUMNS
mean = cell_mean(frame, width, x0 + col * cell, y0 + row * cell, cell)
if mean > 165:
value |= 1 << bit
elif mean >= 90:
uncertain += 1
if uncertain > 6:
return None, uncertain
return value, uncertain
def sampled_abs_delta(a: bytes, b: bytes, width: int, y0: int, y1: int, x_step: int, y_step: int) -> float:
total = 0
count = 0
for y in range(y0, y1, y_step):
row = y * width
for x in range(0, width, x_step):
total += abs(a[row + x] - b[row + x])
count += 1
return total / max(1, count)
def band_stats(frame: bytes, width: int, y0: int, y1: int, x_step: int, y_step: int) -> tuple[float, float]:
total = 0
total2 = 0
count = 0
for y in range(y0, y1, y_step):
row = y * width
for x in range(0, width, x_step):
value = frame[row + x]
total += value
total2 += value * value
count += 1
mean = total / max(1, count)
return mean, max(0.0, total2 / max(1, count) - mean * mean)
def shifted_expected_delta(frame: bytes, expected: bytes, width: int, height: int, shift: int, args: argparse.Namespace) -> float:
x0 = max(0, -shift)
x1 = min(width, width - shift)
if x0 >= x1:
return 0.0
y0 = height // 4
total = 0
count = 0
for y in range(y0, height, args.y_step):
row = y * width
for x in range(x0, x1, args.x_step):
total += abs(frame[row + x] - expected[row + x + shift])
count += 1
return total / max(1, count)
def best_expected_shift(frame: bytes, expected: bytes, width: int, height: int, args: argparse.Namespace) -> tuple[int, float, float, float]:
zero = shifted_expected_delta(frame, expected, width, height, 0, args)
best = zero
best_shift = 0
for shift in [-128, -96, -80, -64, -48, -32, -24, -16, -12, -8, 8, 12, 16, 24, 32, 48, 64, 80, 96, 128]:
candidate = shifted_expected_delta(frame, expected, width, height, shift, args)
if candidate < best:
best = candidate
best_shift = shift
improvement = zero / max(best, 0.001) if best_shift else 1.0
return best_shift, zero, best, improvement
def max_run(flags: list[bool]) -> int:
best = 0
current = 0
for flag in flags:
current = current + 1 if flag else 0
best = max(best, current)
return best
def analyze_frame(
frame: bytes,
width: int,
height: int,
args: argparse.Namespace,
previous_seq: int | None,
) -> dict[str, Any]:
sequence, uncertain_bits = decode_sequence(frame, width, height)
expected = synthetic_gray(width, height, sequence or 0) if sequence is not None else None
upper_mae = lower_mae = total_mae = 0.0
shift_pixels = 0
shift_zero_delta = shift_best_delta = shift_improvement = 0.0
if expected is not None:
upper_mae = sampled_abs_delta(frame, expected, width, 0, height // 2, args.x_step, args.y_step)
lower_mae = sampled_abs_delta(frame, expected, width, height // 2, height, args.x_step, args.y_step)
total_mae = sampled_abs_delta(frame, expected, width, 0, height, args.x_step, args.y_step)
shift_pixels, shift_zero_delta, shift_best_delta, shift_improvement = best_expected_shift(frame, expected, width, height, args)
band_count = max(8, args.bands)
band_h = max(1, height // band_count)
means: list[float] = []
variances: list[float] = []
for band in range(band_count):
y0 = band * band_h
y1 = height if band == band_count - 1 else min(height, y0 + band_h)
mean, variance = band_stats(frame, width, y0, y1, args.x_step, args.y_step)
means.append(mean)
variances.append(variance)
lower = band_count // 2
lower_flags = [var < args.slab_var for var in variances[lower:]]
low_var_run = max_run(lower_flags) / max(1, len(lower_flags))
mean_jumps = [abs(means[idx] - means[idx - 1]) for idx in range(1, band_count)]
max_lower_jump = max(mean_jumps[lower:] or [0.0])
reasons: list[str] = []
if sequence is None:
reasons.append("marker_decode_failed")
elif previous_seq is not None:
if sequence == previous_seq:
reasons.append("frame_repeat")
elif sequence > previous_seq + 1:
reasons.append("frame_gap")
elif sequence < previous_seq:
reasons.append("frame_backwards")
if expected is not None:
if lower_mae > args.lower_mae_threshold and lower_mae > max(upper_mae * args.lower_skew_ratio, args.lower_mae_threshold):
reasons.append("lower_half_tear")
if total_mae > args.mae_threshold and lower_mae <= max(upper_mae * args.lower_skew_ratio, args.lower_mae_threshold):
reasons.append("high_mae")
if low_var_run >= 0.25 and lower_mae > args.lower_mae_threshold:
reasons.append("black_or_gray_slab")
if shift_pixels and shift_zero_delta > args.shift_threshold and shift_improvement > args.shift_improvement:
reasons.append("horizontal_shift")
return {
"suspicious": bool(reasons),
"reasons": reasons,
"decoded_sequence": sequence,
"marker_uncertain_bits": uncertain_bits,
"upper_mae": round(upper_mae, 3),
"lower_mae": round(lower_mae, 3),
"total_mae": round(total_mae, 3),
"lower_low_variance_run_pct": round(low_var_run, 3),
"max_lower_jump": round(max_lower_jump, 3),
"shift_pixels": shift_pixels,
"shift_zero_delta": round(shift_zero_delta, 3),
"shift_best_delta": round(shift_best_delta, 3),
"shift_improvement": round(shift_improvement, 3),
}
def write_pgm(path: pathlib.Path, frame: bytes, width: int, height: int) -> None:
path.write_bytes(f"P5\n{width} {height}\n255\n".encode() + frame)
def run_capture(args: argparse.Namespace) -> int:
width, height, fps = mode_dimensions(args)
command, capture_width, capture_height, device = ffmpeg_cmd(args, width, height)
artifact_dir = pathlib.Path(args.artifact_dir) if args.artifact_dir else pathlib.Path("/tmp") / f"lesavka-synthetic-rct-capture-{timestamp()}"
artifact_dir.mkdir(parents=True, exist_ok=True)
frame_size = capture_width * capture_height
(artifact_dir / "command.txt").write_text(" ".join(shlex.quote(part) for part in command) + "\n")
stderr_path = artifact_dir / "ffmpeg.stderr"
metrics_path = artifact_dir / "frame-metrics.jsonl"
started = time.monotonic()
frame_index = 0
suspicious_count = 0
reference_artifacts = 0
suspicious_artifacts = 0
previous_seq: int | None = None
decoded_frames = 0
reason_counts: collections.Counter[str] = collections.Counter()
max_total_mae = max_upper_mae = max_lower_mae = 0.0
worst: list[dict[str, Any]] = []
with stderr_path.open("wb") as err, metrics_path.open("w") as metrics:
proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=err)
assert proc.stdout is not None
try:
while time.monotonic() - started < args.duration:
frame = proc.stdout.read(frame_size)
if len(frame) != frame_size:
break
frame_index += 1
result = analyze_frame(frame, capture_width, capture_height, args, previous_seq)
decoded_seq = result["decoded_sequence"]
if decoded_seq is not None:
decoded_frames += 1
previous_seq = int(decoded_seq)
result.update({"frame": frame_index, "elapsed_s": round(time.monotonic() - started, 3)})
max_total_mae = max(max_total_mae, float(result["total_mae"]))
max_upper_mae = max(max_upper_mae, float(result["upper_mae"]))
max_lower_mae = max(max_lower_mae, float(result["lower_mae"]))
if result["suspicious"]:
suspicious_count += 1
reason_counts.update(result["reasons"])
worst.append(result)
worst = sorted(worst, key=lambda item: (item["lower_mae"], item["total_mae"]), reverse=True)[:30]
if suspicious_artifacts < args.max_suspicious_artifacts:
seq_label = "unknown" if decoded_seq is None else f"seq{decoded_seq:08d}"
write_pgm(artifact_dir / f"suspicious_{frame_index:06d}_{seq_label}.pgm", frame, capture_width, capture_height)
if decoded_seq is not None:
write_pgm(
artifact_dir / f"expected_{frame_index:06d}_{seq_label}.pgm",
synthetic_gray(capture_width, capture_height, int(decoded_seq)),
capture_width,
capture_height,
)
suspicious_artifacts += 1
should_reference = frame_index == 1 or (args.reference_every > 0 and frame_index % args.reference_every == 0)
if should_reference and reference_artifacts < args.max_reference_artifacts:
write_pgm(artifact_dir / f"reference_{frame_index:06d}.pgm", frame, capture_width, capture_height)
reference_artifacts += 1
metrics.write(json.dumps(result, sort_keys=True) + "\n")
if frame_index % args.progress_every == 0:
print(f"frames={frame_index} suspicious={suspicious_count} latest={result}", file=sys.stderr)
finally:
proc.terminate()
try:
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
proc.kill()
elapsed = max(0.001, time.monotonic() - started)
summary = {
"schema": "lesavka.synthetic-rct-capture.v1",
"source": args.source,
"device": device,
"mode": args.mode,
"width": capture_width,
"height": capture_height,
"fps_requested": fps,
"duration_requested_s": args.duration,
"duration_observed_s": round(elapsed, 3),
"frames": frame_index,
"fps_observed": round(frame_index / elapsed, 3),
"decoded_frames": decoded_frames,
"decoded_pct": round(decoded_frames / frame_index * 100.0, 3) if frame_index else 0.0,
"suspicious_frames": suspicious_count,
"suspicious_pct": round(suspicious_count / frame_index * 100.0, 3) if frame_index else 0.0,
"reason_counts": dict(reason_counts),
"max_total_mae": round(max_total_mae, 3),
"max_upper_mae": round(max_upper_mae, 3),
"max_lower_mae": round(max_lower_mae, 3),
"worst_frames": worst,
"reference_artifacts": reference_artifacts,
"suspicious_artifacts": suspicious_artifacts,
"artifact_dir": str(artifact_dir),
"ffmpeg_stderr": str(stderr_path),
}
(artifact_dir / "summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n")
(artifact_dir / "summary.txt").write_text(format_summary(summary))
print(format_summary(summary), end="")
print(f"artifact_dir: {artifact_dir}")
return 0 if frame_index > 0 else 2
def format_summary(summary: dict[str, Any]) -> str:
return "\n".join(
[
"Lesavka synthetic RCT UVC comparison probe",
f"source: {summary['source']}",
f"device: {summary['device']}",
f"mode: {summary['mode']} capture={summary['width']}x{summary['height']}@{summary['fps_requested']}",
f"frames: {summary['frames']} ({summary['fps_observed']} fps observed)",
f"decoded markers: {summary['decoded_frames']} ({summary['decoded_pct']}%)",
f"suspicious: {summary['suspicious_frames']} ({summary['suspicious_pct']}%)",
f"reasons: {summary['reason_counts']}",
f"max mae: total={summary['max_total_mae']} upper={summary['max_upper_mae']} lower={summary['max_lower_mae']}",
f"artifacts: {summary['artifact_dir']}",
"",
]
)
def run_self_test(args: argparse.Namespace) -> int:
width = 320
height = 180
frames = [synthetic_gray(width, height, idx) for idx in range(6)]
corrupt = bytearray(synthetic_gray(width, height, 6))
fill_rect(corrupt, width, height, 0, height // 2, width, height // 4, 128)
frames.append(bytes(corrupt))
shifted = bytearray(width * height)
expected = synthetic_gray(width, height, 7)
for y in range(height):
row = y * width
for x in range(width):
src = min(width - 1, x + 24)
shifted[row + x] = expected[row + src]
frames.append(bytes(shifted))
previous_seq: int | None = None
records: list[dict[str, Any]] = []
suspicious = 0
for idx, frame in enumerate(frames):
result = analyze_frame(frame, width, height, args, previous_seq)
if result["decoded_sequence"] is not None:
previous_seq = int(result["decoded_sequence"])
result["frame"] = idx
records.append(result)
suspicious += int(bool(result["suspicious"]))
artifact_dir = pathlib.Path(args.artifact_dir) if args.artifact_dir else pathlib.Path("/tmp") / f"lesavka-synthetic-rct-self-test-{timestamp()}"
artifact_dir.mkdir(parents=True, exist_ok=True)
write_pgm(artifact_dir / "reference_000001.pgm", frames[0], width, height)
summary = {
"schema": "lesavka.synthetic-rct-probe.self-test.v1",
"frames": len(frames),
"suspicious_frames": suspicious,
"records": records,
"artifact_dir": str(artifact_dir),
}
(artifact_dir / "summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n")
print(json.dumps(summary, indent=2, sort_keys=True))
return 0 if suspicious >= 2 else 1
def main() -> int:
args = parse_args()
if args.self_test:
return run_self_test(args)
if args.capture_only:
return run_capture(args)
return run_remote_orchestrated(args)
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -8,9 +8,15 @@ path = "src/bin/lesavka-uvc.rs"
test = false
bench = false
[[bin]]
name = "lesavka-synthetic-uplink"
path = "src/bin/lesavka-synthetic-uplink.rs"
test = false
bench = false
[package]
name = "lesavka_server"
version = "0.22.46"
version = "0.22.47"
edition = "2024"
autobins = false

View File

@ -0,0 +1,501 @@
#![forbid(unsafe_code)]
use std::{path::PathBuf, time::Duration};
use anyhow::{Context, Result, bail};
use gstreamer as gst;
use gstreamer::prelude::*;
use gstreamer_app as gst_app;
use lesavka_common::lesavka::{
AudioEncoding, AudioPacket, UpstreamMediaBundle, VideoPacket, relay_client::RelayClient,
};
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
use tonic::Request;
const DEFAULT_SERVER: &str = "http://127.0.0.1:50051";
const DEFAULT_SAMPLE_RATE: u32 = 48_000;
const DEFAULT_CHANNELS: u32 = 2;
const DEFAULT_JPEG_QUALITY: i32 = 90;
const MARKER_BITS: usize = 32;
const MARKER_COLUMNS: usize = 16;
#[derive(Clone, Debug)]
struct Args {
server: String,
width: usize,
height: usize,
fps: u32,
duration: Duration,
jpeg_quality: i32,
session_id: u64,
artifact_dir: Option<PathBuf>,
print_every: u64,
}
impl Args {
fn parse() -> Result<Self> {
let mut args = Self {
server: DEFAULT_SERVER.to_string(),
width: 1280,
height: 720,
fps: 30,
duration: Duration::from_secs(300),
jpeg_quality: DEFAULT_JPEG_QUALITY,
session_id: unix_millis(),
artifact_dir: None,
print_every: 150,
};
let mut it = std::env::args().skip(1);
while let Some(flag) = it.next() {
match flag.as_str() {
"--server" => args.server = next_value(&mut it, &flag)?,
"--width" => args.width = parse_next(&mut it, &flag)?,
"--height" => args.height = parse_next(&mut it, &flag)?,
"--fps" => args.fps = parse_next(&mut it, &flag)?,
"--duration" => {
let seconds: f64 = parse_next(&mut it, &flag)?;
args.duration = Duration::from_secs_f64(seconds.max(0.0));
}
"--jpeg-quality" => args.jpeg_quality = parse_next(&mut it, &flag)?,
"--session-id" => args.session_id = parse_next(&mut it, &flag)?,
"--artifact-dir" => {
args.artifact_dir = Some(PathBuf::from(next_value(&mut it, &flag)?))
}
"--print-every" => args.print_every = parse_next(&mut it, &flag)?,
"--mode" => {
let value = next_value(&mut it, &flag)?;
let (width, height, fps) = parse_mode(&value)?;
args.width = width;
args.height = height;
args.fps = fps;
}
"--help" | "-h" => {
print_help();
std::process::exit(0);
}
other => bail!("unknown argument {other:?}; pass --help for usage"),
}
}
if args.width == 0 || args.height == 0 || args.fps == 0 {
bail!("width, height, and fps must be positive");
}
args.jpeg_quality = args.jpeg_quality.clamp(1, 100);
Ok(args)
}
fn frame_step_us(&self) -> u64 {
(1_000_000_u64 / u64::from(self.fps)).max(1)
}
fn total_frames(&self) -> u64 {
let frames = self.duration.as_secs_f64() * f64::from(self.fps);
frames.ceil().max(1.0) as u64
}
}
struct MjpegEncoder {
src: gst_app::AppSrc,
sink: gst_app::AppSink,
pipeline: gst::Pipeline,
width: usize,
height: usize,
frame_step_us: u64,
}
impl MjpegEncoder {
fn new(args: &Args) -> Result<Self> {
gst::init().context("gst init")?;
let width = args.width as i32;
let height = args.height as i32;
let fps = args.fps as i32;
let raw_caps = gst::Caps::builder("video/x-raw")
.field("format", "RGB")
.field("width", width)
.field("height", height)
.field("framerate", gst::Fraction::new(fps, 1))
.build();
let jpeg_caps = gst::Caps::builder("image/jpeg")
.field("parsed", true)
.field("width", width)
.field("height", height)
.field("framerate", gst::Fraction::new(fps, 1))
.build();
let pipeline = gst::Pipeline::new();
let src = gst::ElementFactory::make("appsrc")
.name("lesavka_synthetic_uplink_src")
.build()?
.downcast::<gst_app::AppSrc>()
.expect("appsrc");
src.set_is_live(false);
src.set_format(gst::Format::Time);
src.set_property("do-timestamp", false);
src.set_caps(Some(&raw_caps));
let convert = gst::ElementFactory::make("videoconvert").build()?;
let encoder = gst::ElementFactory::make("jpegenc")
.property("quality", args.jpeg_quality)
.build()?;
let capsfilter = gst::ElementFactory::make("capsfilter")
.property("caps", &jpeg_caps)
.build()?;
let sink = gst::ElementFactory::make("appsink")
.name("lesavka_synthetic_uplink_sink")
.property("sync", false)
.property("emit-signals", false)
.property("max-buffers", 8u32)
.build()?
.downcast::<gst_app::AppSink>()
.expect("appsink");
pipeline.add_many([
src.upcast_ref(),
&convert,
&encoder,
&capsfilter,
sink.upcast_ref(),
])?;
gst::Element::link_many([
src.upcast_ref(),
&convert,
&encoder,
&capsfilter,
sink.upcast_ref(),
])?;
pipeline
.set_state(gst::State::Playing)
.context("starting synthetic MJPEG encoder")?;
Ok(Self {
src,
sink,
pipeline,
width: args.width,
height: args.height,
frame_step_us: args.frame_step_us(),
})
}
fn encode(&mut self, sequence: u64) -> Result<Vec<u8>> {
let pts_us = sequence.saturating_mul(self.frame_step_us);
let mut buffer =
gst::Buffer::from_slice(synthetic_rgb_frame(self.width, self.height, sequence));
if let Some(meta) = buffer.get_mut() {
let pts = gst::ClockTime::from_useconds(pts_us);
meta.set_pts(Some(pts));
meta.set_dts(Some(pts));
meta.set_duration(Some(gst::ClockTime::from_useconds(self.frame_step_us)));
}
self.src
.push_buffer(buffer)
.context("encoding synthetic frame")?;
let sample = self
.sink
.pull_sample()
.context("pulling encoded synthetic frame")?;
let buffer = sample
.buffer()
.context("encoded synthetic frame had no buffer")?;
let map = buffer
.map_readable()
.context("mapping encoded synthetic frame")?;
Ok(map.as_slice().to_vec())
}
}
impl Drop for MjpegEncoder {
fn drop(&mut self) {
let _ = self.src.end_of_stream();
let _ = self.pipeline.set_state(gst::State::Null);
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
let args = Args::parse()?;
if let Some(dir) = &args.artifact_dir {
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
std::fs::write(
dir.join("command.txt"),
std::env::args().collect::<Vec<_>>().join(" ") + "\n",
)?;
std::fs::write(dir.join("summary.json"), args_summary_json(&args) + "\n")?;
}
let channel = tonic::transport::Channel::from_shared(args.server.clone())?
.tcp_nodelay(true)
.connect()
.await
.with_context(|| format!("connecting to {}", args.server))?;
let mut client = RelayClient::new(channel);
let (tx, rx) = mpsc::channel::<UpstreamMediaBundle>(8);
let response_task = tokio::spawn(async move {
let response = client
.stream_webcam_media(Request::new(ReceiverStream::new(rx)))
.await
.context("opening StreamWebcamMedia")?;
let mut inbound = response.into_inner();
while inbound
.message()
.await
.context("reading StreamWebcamMedia response")?
.is_some()
{}
Ok::<(), anyhow::Error>(())
});
let mut encoder = MjpegEncoder::new(&args)?;
let frame_step = Duration::from_micros(args.frame_step_us());
let started = tokio::time::Instant::now() + Duration::from_millis(250);
let total_frames = args.total_frames();
eprintln!(
"lesavka synthetic uplink: mode={}x{}@{} frames={} server={} session={}",
args.width, args.height, args.fps, total_frames, args.server, args.session_id
);
for sequence in 0..total_frames {
tokio::time::sleep_until(started + duration_mul(frame_step, sequence)).await;
let pts_us = sequence.saturating_mul(args.frame_step_us());
let encoded = encoder.encode(sequence)?;
let bundle = synthetic_bundle(&args, sequence, pts_us, encoded);
tx.send(bundle).await.context("sending synthetic bundle")?;
if args.print_every > 0 && sequence > 0 && sequence % args.print_every == 0 {
eprintln!("sent synthetic frame {sequence}/{total_frames}");
}
}
drop(tx);
response_task
.await
.context("joining StreamWebcamMedia task")??;
eprintln!("lesavka synthetic uplink complete: frames={total_frames}");
Ok(())
}
fn synthetic_bundle(args: &Args, sequence: u64, pts_us: u64, data: Vec<u8>) -> UpstreamMediaBundle {
let video = VideoPacket {
id: 0,
pts: pts_us,
data,
seq: sequence,
effective_fps: args.fps,
client_capture_pts_us: pts_us,
client_send_pts_us: pts_us,
..Default::default()
};
let audio = AudioPacket {
id: 0,
pts: pts_us,
data: silence_pcm(args.frame_step_us()),
seq: sequence,
client_capture_pts_us: pts_us,
client_send_pts_us: pts_us,
encoding: AudioEncoding::PcmS16le as i32,
sample_rate: DEFAULT_SAMPLE_RATE,
channels: DEFAULT_CHANNELS,
frame_duration_us: args.frame_step_us().min(u64::from(u32::MAX)) as u32,
..Default::default()
};
UpstreamMediaBundle {
session_id: args.session_id,
seq: sequence,
capture_start_us: pts_us,
capture_end_us: pts_us,
video: Some(video),
audio: vec![audio],
audio_sample_rate: DEFAULT_SAMPLE_RATE,
audio_channels: DEFAULT_CHANNELS,
video_width: args.width as u32,
video_height: args.height as u32,
video_fps: args.fps,
audio_encoding: AudioEncoding::PcmS16le as i32,
}
}
fn silence_pcm(duration_us: u64) -> Vec<u8> {
let samples = (u64::from(DEFAULT_SAMPLE_RATE).saturating_mul(duration_us) / 1_000_000).max(1);
let bytes = samples
.saturating_mul(u64::from(DEFAULT_CHANNELS))
.saturating_mul(std::mem::size_of::<i16>() as u64)
.min(usize::MAX as u64) as usize;
vec![0; bytes]
}
fn synthetic_rgb_frame(width: usize, height: usize, sequence: u64) -> Vec<u8> {
let mut frame = vec![0u8; width.saturating_mul(height).saturating_mul(3)];
let moving_period = (width / 3).max(64);
let moving_width = (width / 18).max(12);
let moving_offset = (sequence as usize).wrapping_mul(17) % moving_period;
for y in 0..height {
for x in 0..width {
let mut value = synthetic_luma(
width,
height,
x,
y,
sequence,
moving_period,
moving_width,
moving_offset,
);
if y >= height / 2 && (((x / 32) + (y / 24) + sequence as usize) & 1) == 0 {
value /= 3;
}
let offset = (y * width + x) * 3;
frame[offset] = value;
frame[offset + 1] = value;
frame[offset + 2] = value;
}
}
draw_sequence_marker(&mut frame, width, height, sequence);
frame
}
fn synthetic_luma(
width: usize,
height: usize,
x: usize,
y: usize,
sequence: u64,
moving_period: usize,
moving_width: usize,
moving_offset: usize,
) -> u8 {
let mut value = ((x as u64 * 3 + y as u64 * 5 + sequence.saturating_mul(11)) & 0xff) as u8;
let moving = (x + moving_offset) % moving_period;
if moving < moving_width {
value = value.saturating_add(70);
}
let center_x = width / 2;
let center_y = height / 2;
if x.abs_diff(center_x) < width / 9 && y.abs_diff(center_y) < height / 12 {
value = 255u8.saturating_sub(value / 2);
}
value
}
fn marker_cell(width: usize, height: usize) -> usize {
(width.min(height) / 80).clamp(6, 16)
}
fn draw_sequence_marker(frame: &mut [u8], width: usize, height: usize, sequence: u64) {
let cell = marker_cell(width, height);
let rows = MARKER_BITS.div_ceil(MARKER_COLUMNS);
if width < (MARKER_COLUMNS + 4) * cell || height < (rows + 4) * cell {
return;
}
let x0 = 2 * cell;
let y0 = 2 * cell;
fill_rect(
frame,
width,
cell,
cell,
(MARKER_COLUMNS + 2) * cell,
(rows + 2) * cell,
32,
);
fill_rect(frame, width, x0 - cell, y0 - cell, cell, cell, 255);
fill_rect(
frame,
width,
x0 + MARKER_COLUMNS * cell,
y0 - cell,
cell,
cell,
0,
);
for bit in 0..MARKER_BITS {
let col = bit % MARKER_COLUMNS;
let row = bit / MARKER_COLUMNS;
let value = if ((sequence >> bit) & 1) != 0 { 255 } else { 0 };
fill_rect(
frame,
width,
x0 + col * cell,
y0 + row * cell,
cell,
cell,
value,
);
}
}
fn fill_rect(frame: &mut [u8], width: usize, x0: usize, y0: usize, w: usize, h: usize, value: u8) {
let pixels = frame.len() / 3;
let height = pixels / width.max(1);
let x1 = (x0 + w).min(width);
let y1 = (y0 + h).min(height);
for y in y0..y1 {
for x in x0..x1 {
let offset = (y * width + x) * 3;
if let Some(pixel) = frame.get_mut(offset..offset + 3) {
pixel[0] = value;
pixel[1] = value;
pixel[2] = value;
}
}
}
}
fn parse_mode(value: &str) -> Result<(usize, usize, u32)> {
let (size, fps) = value
.split_once('@')
.with_context(|| format!("mode must look like WIDTHxHEIGHT@FPS, got {value:?}"))?;
let (width, height) = size
.split_once('x')
.with_context(|| format!("mode must look like WIDTHxHEIGHT@FPS, got {value:?}"))?;
Ok((width.parse()?, height.parse()?, fps.parse()?))
}
fn next_value(it: &mut impl Iterator<Item = String>, flag: &str) -> Result<String> {
it.next()
.with_context(|| format!("{flag} requires a value"))
}
fn parse_next<T>(it: &mut impl Iterator<Item = String>, flag: &str) -> Result<T>
where
T: std::str::FromStr,
T::Err: std::error::Error + Send + Sync + 'static,
{
Ok(next_value(it, flag)?.parse()?)
}
fn duration_mul(duration: Duration, count: u64) -> Duration {
Duration::from_nanos(
duration
.as_nanos()
.saturating_mul(u128::from(count))
.min(u128::from(u64::MAX)) as u64,
)
}
fn unix_millis() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.min(u128::from(u64::MAX)) as u64
}
fn args_summary_json(args: &Args) -> String {
format!(
"{{\"schema\":\"lesavka.synthetic-uplink.v1\",\"server\":{server:?},\"width\":{width},\"height\":{height},\"fps\":{fps},\"duration_s\":{duration:.3},\"session_id\":{session}}}",
server = args.server,
width = args.width,
height = args.height,
fps = args.fps,
duration = args.duration.as_secs_f64(),
session = args.session_id,
)
}
fn print_help() {
println!(
"lesavka-synthetic-uplink\n\n\
Sends sequence-coded synthetic MJPEG plus silent PCM through StreamWebcamMedia.\n\n\
Options:\n\
--server URL gRPC endpoint, default {DEFAULT_SERVER}\n\
--mode WIDTHxHEIGHT@FPS shorthand for width/height/fps\n\
--width N --height N --fps N\n\
--duration SECONDS default 300\n\
--jpeg-quality N default {DEFAULT_JPEG_QUALITY}\n\
--artifact-dir PATH write command/summary metadata\n\
--print-every N progress interval in frames"
);
}

View File

@ -0,0 +1,136 @@
// Contract coverage for the synthetic Theia-to-RCT UVC comparison probe.
//
// Scope: inspect the StreamWebcamMedia synthetic injector and the manual RCT
// capture/comparison harness. Why: the remaining UVC corruption is rare and
// stateful, so we need repeatable source-to-received frame evidence.
use std::{fs, path::PathBuf, process::Command};
use serde_json::Value;
const PROBE_SRC: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/scripts/manual/run_synthetic_rct_uvc_probe.py"
));
const INJECTOR_SRC: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/server/src/bin/lesavka-synthetic-uplink.rs"
));
const SERVER_CARGO: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/server/Cargo.toml"));
const SERVER_INSTALL: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/scripts/install/server.sh"
));
fn repo_script_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("scripts/manual/run_synthetic_rct_uvc_probe.py")
}
#[test]
fn synthetic_probe_keeps_bundled_network_ingress_and_rct_comparison_markers() {
for expected in [
"lesavka.synthetic-rct-capture.v1",
"lesavka.synthetic-rct-probe.orchestrator.v1",
"StreamWebcamMedia",
"lesavka-synthetic-uplink",
"--inject-host",
"--rct-host",
"--capture-only",
"--source",
"--mode",
"1280x720@20,1280x720@30,1920x1080@20,1920x1080@30",
"marker_decode_failed",
"frame_repeat",
"frame_gap",
"frame_backwards",
"lower_half_tear",
"black_or_gray_slab",
"horizontal_shift",
"expected_",
"suspicious_",
"decoded_pct",
"max_lower_mae",
"ffmpeg",
"v4l2",
"x11grab",
] {
assert!(
PROBE_SRC.contains(expected),
"synthetic RCT probe should preserve marker {expected}"
);
}
}
#[test]
fn synthetic_injector_enters_the_public_bundled_media_rpc() {
for expected in [
"name = \"lesavka-synthetic-uplink\"",
"src/bin/lesavka-synthetic-uplink.rs",
"stream_webcam_media(Request::new(ReceiverStream::new(rx)))",
"UpstreamMediaBundle",
"AudioEncoding::PcmS16le",
"silence_pcm(args.frame_step_us())",
"synthetic_rgb_frame",
"draw_sequence_marker",
"image/jpeg",
"jpegenc",
"client_capture_pts_us: pts_us",
"client_send_pts_us: pts_us",
] {
assert!(
SERVER_CARGO.contains(expected) || INJECTOR_SRC.contains(expected),
"synthetic injector should preserve marker {expected}"
);
}
assert!(
SERVER_INSTALL.contains(
"install_verified_executable \"$SRC_DIR/target/release/lesavka-synthetic-uplink\" /usr/local/bin/lesavka-synthetic-uplink"
),
"server installer should install the synthetic uplink binary for Theia lab runs"
);
}
#[test]
fn synthetic_probe_self_test_detects_slab_and_shift_categories() {
let dir = tempfile::tempdir().expect("tempdir");
let output = Command::new("python3")
.arg(repo_script_path())
.arg("--self-test")
.arg("--artifact-dir")
.arg(dir.path())
.output()
.expect("run synthetic RCT probe self-test");
assert!(
output.status.success(),
"synthetic probe self-test should succeed: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let summary: Value = serde_json::from_str(
&fs::read_to_string(dir.path().join("summary.json")).expect("summary json"),
)
.expect("parse summary json");
assert_eq!(
summary["schema"],
"lesavka.synthetic-rct-probe.self-test.v1"
);
assert!(
summary["suspicious_frames"].as_u64().unwrap_or_default() >= 2,
"self-test should detect at least the slab and shifted synthetic frames: {summary}"
);
let records = summary["records"].as_array().expect("records array");
assert!(
records.iter().any(|record| record["reasons"]
.as_array()
.is_some_and(|reasons| reasons.iter().any(|reason| reason == "black_or_gray_slab"))),
"self-test should include a slab category: {summary}"
);
assert!(
records.iter().any(|record| record["reasons"]
.as_array()
.is_some_and(|reasons| reasons.iter().any(|reason| reason == "horizontal_shift"))),
"self-test should include a horizontal shift category: {summary}"
);
}