diff --git a/scripts/manual/run_server_to_rc_mode_matrix.sh b/scripts/manual/run_server_to_rc_mode_matrix.sh index 4cbc3ec..ec61d41 100755 --- a/scripts/manual/run_server_to_rc_mode_matrix.sh +++ b/scripts/manual/run_server_to_rc_mode_matrix.sh @@ -69,6 +69,7 @@ LESAVKA_SERVER_RC_TUNE_MIN_CHANGE_US=${LESAVKA_SERVER_RC_TUNE_MIN_CHANGE_US:-500 LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS=${LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS:-350} LESAVKA_SERVER_RC_FRESHNESS_MAX_DRIFT_MS=${LESAVKA_SERVER_RC_FRESHNESS_MAX_DRIFT_MS:-100} LESAVKA_SERVER_RC_FRESHNESS_MAX_CLOCK_UNCERTAINTY_MS=${LESAVKA_SERVER_RC_FRESHNESS_MAX_CLOCK_UNCERTAINTY_MS:-250} +LESAVKA_SERVER_RC_FRESHNESS_MIN_PAIRS=${LESAVKA_SERVER_RC_FRESHNESS_MIN_PAIRS:-${LESAVKA_SERVER_RC_TUNE_MIN_PAIRS}} LESAVKA_SERVER_RC_REQUIRE_FRESHNESS_PASS=${LESAVKA_SERVER_RC_REQUIRE_FRESHNESS_PASS:-1} LESAVKA_SERVER_RC_REQUIRE_SYNC_PASS=${LESAVKA_SERVER_RC_REQUIRE_SYNC_PASS:-1} @@ -196,6 +197,7 @@ run_mode_probe() { LESAVKA_OUTPUT_FRESHNESS_MAX_AGE_MS="${LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS}" \ LESAVKA_OUTPUT_FRESHNESS_MAX_DRIFT_MS="${LESAVKA_SERVER_RC_FRESHNESS_MAX_DRIFT_MS}" \ LESAVKA_OUTPUT_FRESHNESS_MAX_CLOCK_UNCERTAINTY_MS="${LESAVKA_SERVER_RC_FRESHNESS_MAX_CLOCK_UNCERTAINTY_MS}" \ + LESAVKA_OUTPUT_FRESHNESS_MIN_PAIRS="${LESAVKA_SERVER_RC_FRESHNESS_MIN_PAIRS}" \ PROBE_EVENT_WIDTH_CODES="${PROBE_EVENT_WIDTH_CODES}" \ PROBE_DURATION_SECONDS="${PROBE_DURATION_SECONDS}" \ PROBE_WARMUP_SECONDS="${PROBE_WARMUP_SECONDS}" \ @@ -1352,7 +1354,7 @@ echo " ↪ video_delays=${LESAVKA_SERVER_RC_MODE_DELAYS_US}" echo " ↪ audio_delays=${LESAVKA_SERVER_RC_MODE_AUDIO_DELAYS_US}" echo " ↪ capture_stack=${REMOTE_CAPTURE_STACK} audio_source=${REMOTE_AUDIO_SOURCE} pulse_tool=${REMOTE_PULSE_CAPTURE_TOOL} video_mode=${REMOTE_PULSE_VIDEO_MODE}" echo " ↪ tune_delays=${LESAVKA_SERVER_RC_TUNE_DELAYS} confirm=${LESAVKA_SERVER_RC_TUNE_CONFIRM} min_pairs=${LESAVKA_SERVER_RC_TUNE_MIN_PAIRS} max_abs_skew_ms=${LESAVKA_SERVER_RC_TUNE_MAX_ABS_SKEW_MS} max_step_us=${LESAVKA_SERVER_RC_TUNE_MAX_STEP_US} min_change_us=${LESAVKA_SERVER_RC_TUNE_MIN_CHANGE_US}" -echo " ↪ freshness_limit_ms=${LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS}" +echo " ↪ freshness_limit_ms=${LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS} min_pairs=${LESAVKA_SERVER_RC_FRESHNESS_MIN_PAIRS}" echo " ↪ reconfigure=${LESAVKA_SERVER_RC_RECONFIGURE} strategy=${LESAVKA_SERVER_RC_RECONFIGURE_STRATEGY} allow_gadget_reset=${LESAVKA_SERVER_RC_ALLOW_GADGET_RESET}" echo " ↪ tethys_ready=${LESAVKA_SERVER_RC_WAIT_TETHYS_READY} settle=${LESAVKA_SERVER_RC_TETHYS_SETTLE_SECONDS}s timeout=${LESAVKA_SERVER_RC_TETHYS_READY_TIMEOUT_SECONDS}s preroll_discard=${LESAVKA_SERVER_RC_PREROLL_DISCARD_SECONDS}s" echo " ↪ artifact_dir=${MATRIX_REPORT_DIR}" diff --git a/scripts/manual/run_upstream_av_sync.sh b/scripts/manual/run_upstream_av_sync.sh index 856e80c..472f49c 100755 --- a/scripts/manual/run_upstream_av_sync.sh +++ b/scripts/manual/run_upstream_av_sync.sh @@ -31,7 +31,8 @@ LESAVKA_OUTPUT_DELAY_PROBE_VIDEO_DELAY_US=${LESAVKA_OUTPUT_DELAY_PROBE_VIDEO_DEL # VIDIOC_STREAMON if the camera is starved during pre-roll. LEAD_IN_SECONDS=${LEAD_IN_SECONDS:-0} TAIL_SECONDS=${TAIL_SECONDS:-2} -CAPTURE_SECONDS=${CAPTURE_SECONDS:-$((PROBE_DURATION_SECONDS + PROBE_WARMUP_SECONDS + PROBE_START_GRACE_SECONDS + LEAD_IN_SECONDS + TAIL_SECONDS))} +CAPTURE_START_GRACE_SECONDS=${CAPTURE_START_GRACE_SECONDS:-12} +CAPTURE_SECONDS=${CAPTURE_SECONDS:-$((PROBE_DURATION_SECONDS + PROBE_WARMUP_SECONDS + CAPTURE_START_GRACE_SECONDS + LEAD_IN_SECONDS + TAIL_SECONDS))} LOCAL_OUTPUT_DIR=${LOCAL_OUTPUT_DIR:-/tmp} REMOTE_VIDEO_DEVICE=${REMOTE_VIDEO_DEVICE:-auto} VIDEO_SIZE=${VIDEO_SIZE:-auto} @@ -78,6 +79,7 @@ LESAVKA_OUTPUT_DELAY_MAX_STEP_US=${LESAVKA_OUTPUT_DELAY_MAX_STEP_US:-1500000} LESAVKA_OUTPUT_FRESHNESS_MAX_AGE_MS=${LESAVKA_OUTPUT_FRESHNESS_MAX_AGE_MS:-1000} LESAVKA_OUTPUT_FRESHNESS_MAX_DRIFT_MS=${LESAVKA_OUTPUT_FRESHNESS_MAX_DRIFT_MS:-100} LESAVKA_OUTPUT_FRESHNESS_MAX_CLOCK_UNCERTAINTY_MS=${LESAVKA_OUTPUT_FRESHNESS_MAX_CLOCK_UNCERTAINTY_MS:-250} +LESAVKA_OUTPUT_FRESHNESS_MIN_PAIRS=${LESAVKA_OUTPUT_FRESHNESS_MIN_PAIRS:-${LESAVKA_OUTPUT_DELAY_MIN_PAIRS}} LESAVKA_CLOCK_ALIGNMENT_SAMPLES=${LESAVKA_CLOCK_ALIGNMENT_SAMPLES:-5} CAPTURE_READY_MARKER="__LESAVKA_CAPTURE_READY__" @@ -988,7 +990,10 @@ write_output_delay_correlation() { "${LOCAL_CLOCK_ALIGNMENT_JSON}" \ "${LESAVKA_OUTPUT_FRESHNESS_MAX_AGE_MS}" \ "${LESAVKA_OUTPUT_FRESHNESS_MAX_DRIFT_MS}" \ - "${LESAVKA_OUTPUT_FRESHNESS_MAX_CLOCK_UNCERTAINTY_MS}" + "${LESAVKA_OUTPUT_FRESHNESS_MAX_CLOCK_UNCERTAINTY_MS}" \ + "${LESAVKA_OUTPUT_FRESHNESS_MIN_PAIRS}" \ + "${CAPTURE_SECONDS}" \ + "${CAPTURE_START_GRACE_SECONDS}" import csv import json import math @@ -1010,6 +1015,9 @@ import sys max_freshness_age_raw, max_freshness_drift_raw, max_clock_uncertainty_raw, + min_freshness_pairs_raw, + capture_seconds_raw, + capture_start_grace_seconds_raw, ) = sys.argv[1:] report = json.loads(pathlib.Path(report_path).read_text()) timeline = json.loads(pathlib.Path(timeline_path).read_text()) @@ -1777,6 +1785,9 @@ correction_mode = ( max_freshness_age_ms = max(1.0, as_float(max_freshness_age_raw, 1000.0)) max_freshness_drift_ms = max(0.0, as_float(max_freshness_drift_raw, 100.0)) max_clock_uncertainty_ms = max(0.0, as_float(max_clock_uncertainty_raw, 100.0)) +min_freshness_pairs = max(1, as_int_or_none(min_freshness_pairs_raw) or 1) +configured_capture_duration_s = as_float(capture_seconds_raw, 0.0) +capture_start_grace_s = as_float(capture_start_grace_seconds_raw, 0.0) freshness_p95_values = [ value for value in [ @@ -1820,6 +1831,17 @@ event_age_min_values = [ freshness_min_event_age_ms = min(event_age_min_values) if event_age_min_values else None smoothness = analyze_smoothness(capture_path, report, timeline) media_timeline = smoothness.get("media_timeline") or {} +observed_capture_duration_values = [ + value + for value in [ + finite(media_timeline.get("video_last_s")), + finite(media_timeline.get("audio_last_s")), + ] + if value is not None +] +observed_capture_duration_s = ( + max(observed_capture_duration_values) if observed_capture_duration_values else None +) def negative_rows(rows, key, limit_ms): @@ -1832,11 +1854,36 @@ def negative_rows(rows, key, limit_ms): ] +def non_monotonic_rows(rows, key): + bad = [] + previous = None + for row in rows: + value = finite(row.get(key)) + if value is None: + continue + if previous is not None and value + 1e-9 < previous: + bad.append(row) + previous = value + return bad + + video_negative_rows = negative_rows(joined, "video_freshness_ms", clock_uncertainty_ms) audio_negative_rows = negative_rows(joined, "audio_freshness_ms", clock_uncertainty_ms) +monotonic_checks = { + "server video pipeline": non_monotonic_rows(joined, "server_video_pipeline_unix_ns"), + "server audio pipeline": non_monotonic_rows(joined, "server_audio_pipeline_unix_ns"), + "Tethys video observation": non_monotonic_rows(joined, "tethys_video_time_s"), + "Tethys audio observation": non_monotonic_rows(joined, "tethys_audio_time_s"), +} +non_monotonic_labels = [ + f"{label} ({len(rows)} reversal{'s' if len(rows) != 1 else ''})" + for label, rows in monotonic_checks.items() + if rows +] capture_timebase_status = "valid" capture_timebase_reason = "Tethys observation timestamps are monotonic against server pipeline references" capture_timebase_reasons = [] +capture_timebase_warnings = [] if not joined: capture_timebase_status = "unknown" capture_timebase_reason = "no joined coded events are available" @@ -1853,14 +1900,18 @@ elif video_negative_rows or audio_negative_rows: capture_timebase_reasons.append( f"audio observed before server pipeline injection {len(audio_negative_rows)} time(s)" ) +if non_monotonic_labels: + capture_timebase_status = "invalid" + capture_timebase_reasons.append( + "non-monotonic event timestamps: " + ", ".join(non_monotonic_labels) + ) span_gap_s = finite(media_timeline.get("audio_video_span_gap_s")) if span_gap_s is not None and abs(span_gap_s) > 1.0: relation = "shorter" if span_gap_s > 0 else "longer" - capture_timebase_status = "invalid" - capture_timebase_reasons.append( + capture_timebase_warnings.append( f"captured audio stream is {abs(span_gap_s):.3f}s {relation} than video; " - "the recording cannot place audio events on the video capture timeline" + "treating whole-file media span as recorder coverage, not event freshness" ) if capture_timebase_reasons: capture_timebase_reason = "; ".join(capture_timebase_reasons) @@ -1911,18 +1962,30 @@ timing_map = { ), "probe_warmup_ms": as_float(timeline.get("warmup_us"), 0.0) / 1000.0, "probe_duration_ms": as_float(timeline.get("duration_us"), 0.0) / 1000.0, + "active_probe_media_ms": ( + as_float(timeline.get("warmup_us"), 0.0) + as_float(timeline.get("duration_us"), 0.0) + ) / 1000.0, + "configured_capture_duration_s": configured_capture_duration_s, + "observed_capture_duration_s": observed_capture_duration_s, + "capture_start_grace_s": capture_start_grace_s, "intentional_audio_delay_ms": audio_delay_ms, "intentional_video_delay_ms": video_delay_ms, "first_event": first_event_timing, } if not event_age_p95_values: - freshness_status = "unknown" - freshness_reason = "clock-aligned server feed and Tethys capture timestamps were not available" -elif not clock_alignment_available or clock_uncertainty_ms > max_clock_uncertainty_ms: - freshness_status = "unknown" + freshness_status = "invalid" + freshness_reason = "clock-aligned server pipeline references and Tethys observations were not available" +elif len(joined) < min_freshness_pairs: + freshness_status = "invalid" freshness_reason = ( - f"clock uncertainty {clock_uncertainty_ms:.1f} ms exceeds " - f"{max_clock_uncertainty_ms:.1f} ms freshness measurement limit" + f"paired coded events {len(joined)} < {min_freshness_pairs}; " + "freshness requires enough matched injected/observed events" + ) +elif not clock_alignment_available or clock_uncertainty_ms > max_clock_uncertainty_ms: + freshness_status = "invalid" + freshness_reason = ( + "server/capture clock alignment unavailable or too uncertain: " + f"{clock_uncertainty_ms:.1f} ms exceeds {max_clock_uncertainty_ms:.1f} ms limit" ) elif capture_timebase_status == "invalid": freshness_status = "invalid" @@ -1987,14 +2050,18 @@ artifact = { "status": capture_timebase_status, "reason": capture_timebase_reason, "valid": capture_timebase_status == "valid", + "warnings": capture_timebase_warnings, "video_observed_before_injection_count": len(video_negative_rows), "audio_observed_before_injection_count": len(audio_negative_rows), + "non_monotonic_event_timestamps": non_monotonic_labels, }, "clock_alignment": clock_alignment, "clock_uncertainty_ms": clock_uncertainty_ms, "max_age_limit_ms": max_freshness_age_ms, "max_drift_limit_ms": max_freshness_drift_ms, "max_clock_uncertainty_ms": max_clock_uncertainty_ms, + "min_paired_events": min_freshness_pairs, + "paired_event_count": len(joined), "intentional_audio_delay_ms": audio_delay_ms, "intentional_video_delay_ms": video_delay_ms, "worst_p95_sink_handoff_overhead_ms": max( @@ -2124,10 +2191,18 @@ lines = [ f"- first event Tethys observation: audio {fmt_number(first_event_timing.get('tethys_audio_observed_s'), 's', 3)}, video {fmt_number(first_event_timing.get('tethys_video_observed_s'), 's', 3)}", f"- first event pipeline freshness: audio {fmt_number(first_event_timing.get('audio_pipeline_freshness_ms'), ' ms')}, video {fmt_number(first_event_timing.get('video_pipeline_freshness_ms'), ' ms')}", "", + "Freshness timeline", + f"- capture duration: {fmt_number(observed_capture_duration_s if observed_capture_duration_s is not None else configured_capture_duration_s, 's', 3)} (recorder coverage, not media latency; configured {fmt_number(configured_capture_duration_s, 's', 3)})", + f"- active probe media: {fmt_number(timing_map.get('active_probe_media_ms'), ' ms')} (warmup + coded events)", + f"- setup before probe: {fmt_number(timing_map.get('capture_to_server_start_ms'), ' ms')} (capture readiness/tunnel/probe startup, not media latency)", + f"- measured event freshness: video p95 {fmt_number(video_freshness_stats.get('p95_ms'), ' ms')}, audio p95 {fmt_number(audio_freshness_stats.get('p95_ms'), ' ms')}", + f"- freshness verdict: {freshness_status} ({freshness_reason})", + "", f"Output freshness for {report_path}", "- scope: clock-corrected server pipeline reference to Tethys observed playback from the same paired signatures", f"- freshness status: {freshness_status} ({freshness_reason})", f"- capture timebase: {capture_timebase_status} ({capture_timebase_reason})", + *[f"- capture timebase warning: {warning}" for warning in capture_timebase_warnings], f"- clock uncertainty: +/-{clock_uncertainty_ms:.1f} ms", f"- intentional sync delays: audio {audio_delay_ms:+.1f} ms, video {video_delay_ms:+.1f} ms", f"- sink handoff overhead: video median {fmt_number(video_sink_handoff_stats.get('median_ms'), ' ms')} / p95 {fmt_number(video_sink_handoff_stats.get('p95_ms'), ' ms')} / max {fmt_number(video_sink_handoff_stats.get('max_ms'), ' ms')}; audio median {fmt_number(audio_sink_handoff_stats.get('median_ms'), ' ms')} / p95 {fmt_number(audio_sink_handoff_stats.get('p95_ms'), ' ms')} / max {fmt_number(audio_sink_handoff_stats.get('max_ms'), ' ms')}", diff --git a/testing/tests/client_manual_sync_script_contract.rs b/testing/tests/client_manual_sync_script_contract.rs index bc27ac8..41e8857 100644 --- a/testing/tests/client_manual_sync_script_contract.rs +++ b/testing/tests/client_manual_sync_script_contract.rs @@ -56,6 +56,7 @@ fn upstream_sync_script_tunnels_auto_server_addr_through_ssh() { "LESAVKA_OUTPUT_FRESHNESS_MAX_AGE_MS=${LESAVKA_OUTPUT_FRESHNESS_MAX_AGE_MS:-1000}", "LESAVKA_OUTPUT_FRESHNESS_MAX_DRIFT_MS=${LESAVKA_OUTPUT_FRESHNESS_MAX_DRIFT_MS:-100}", "LESAVKA_OUTPUT_FRESHNESS_MAX_CLOCK_UNCERTAINTY_MS=${LESAVKA_OUTPUT_FRESHNESS_MAX_CLOCK_UNCERTAINTY_MS:-250}", + "LESAVKA_OUTPUT_FRESHNESS_MIN_PAIRS=${LESAVKA_OUTPUT_FRESHNESS_MIN_PAIRS:-${LESAVKA_OUTPUT_DELAY_MIN_PAIRS}}", "REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS=${REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS:-0}", "REMOTE_CAPTURE_READY_SETTLE_SECONDS=${REMOTE_CAPTURE_READY_SETTLE_SECONDS:-1}", "remote_capture_ready_settle_seconds=${15}", @@ -111,9 +112,21 @@ fn upstream_sync_script_tunnels_auto_server_addr_through_ssh() { "clock-corrected server pipeline reference to Tethys observed playback", "capture timebase", "capture timebase invalid", + "capture_timebase_warnings = []", + "capture timebase warning", + "treating whole-file media span as recorder coverage, not event freshness", "intentional sync delays", "sink handoff overhead", "pipeline freshness", + "Freshness timeline", + "capture duration", + "observed_capture_duration_s", + "configured_capture_duration_s", + "recorder coverage, not media latency", + "active probe media", + "setup before probe", + "measured event freshness", + "freshness verdict", "freshness budget", "Output smoothness", "capture media timeline", @@ -131,6 +144,9 @@ fn upstream_sync_script_tunnels_auto_server_addr_through_ssh() { "minimum_pipeline_freshness_ms", "worst_event_age_with_uncertainty_ms", "minimum_event_age_ms", + "min_paired_events", + "paired_event_count", + "non_monotonic_event_timestamps", "sync_passed", "sync did not pass, so freshness from paired signatures is not trustworthy", "impossible negative pipeline freshness", @@ -178,7 +194,8 @@ fn upstream_sync_script_tunnels_auto_server_addr_through_ssh() { "LEAD_IN_SECONDS=${LEAD_IN_SECONDS:-0}", "PROBE_START_GRACE_SECONDS=${PROBE_START_GRACE_SECONDS:-20}", "PROBE_TIMEOUT_SECONDS=${PROBE_TIMEOUT_SECONDS:-$((PROBE_DURATION_SECONDS + PROBE_WARMUP_SECONDS + PROBE_START_GRACE_SECONDS))}", - "CAPTURE_SECONDS=${CAPTURE_SECONDS:-$((PROBE_DURATION_SECONDS + PROBE_WARMUP_SECONDS + PROBE_START_GRACE_SECONDS + LEAD_IN_SECONDS + TAIL_SECONDS))}", + "CAPTURE_START_GRACE_SECONDS=${CAPTURE_START_GRACE_SECONDS:-12}", + "CAPTURE_SECONDS=${CAPTURE_SECONDS:-$((PROBE_DURATION_SECONDS + PROBE_WARMUP_SECONDS + CAPTURE_START_GRACE_SECONDS + LEAD_IN_SECONDS + TAIL_SECONDS))}", "ANALYSIS_TIMELINE_WINDOW=${ANALYSIS_TIMELINE_WINDOW:-0}", "compute_analysis_window_arg", "analyzer timeline window:", @@ -237,6 +254,94 @@ fn upstream_sync_script_tunnels_auto_server_addr_through_ssh() { } } +#[test] +fn output_freshness_is_event_level_not_capture_container_duration() { + for expected in [ + "\"freshness_clock_starts_at\": \"server pipeline reference before intentional sync delay\"", + "video_freshness_ms = (", + "audio_freshness_ms = (", + "\"video_freshness_stats\": video_freshness_stats", + "\"audio_freshness_stats\": audio_freshness_stats", + "\"worst_p95_pipeline_freshness_ms\": freshness_worst_p95_ms", + "\"worst_event_age_with_uncertainty_ms\": freshness_worst_event_with_uncertainty_ms", + "\"configured_capture_duration_s\": configured_capture_duration_s", + "\"observed_capture_duration_s\": observed_capture_duration_s", + "\"min_paired_events\": min_freshness_pairs", + "\"paired_event_count\": len(joined)", + "\"warnings\": capture_timebase_warnings", + "treating whole-file media span as recorder coverage, not event freshness", + "Freshness timeline", + "measured event freshness", + ] { + assert!( + SYNC_SCRIPT.contains(expected), + "freshness contract should contain event-level timing marker {expected}" + ); + } + + let span_gap_start = SYNC_SCRIPT + .find("span_gap_s = finite(media_timeline.get(\"audio_video_span_gap_s\"))") + .expect("manual sync script should compute media span gap diagnostics"); + let span_gap_end = SYNC_SCRIPT[span_gap_start..] + .find("if capture_timebase_reasons:") + .map(|offset| span_gap_start + offset) + .expect("span gap diagnostic block should end before reason folding"); + let span_gap_block = &SYNC_SCRIPT[span_gap_start..span_gap_end]; + + assert!( + span_gap_block.contains("capture_timebase_warnings.append("), + "whole-file media span mismatch should be recorded as a warning" + ); + assert!( + !span_gap_block.contains("capture_timebase_status = \"invalid\""), + "whole-file media span mismatch must not invalidate freshness; freshness is event-level" + ); + assert!( + !span_gap_block.contains("capture_timebase_reasons.append("), + "whole-file media span mismatch must not become a hard invalidation reason" + ); +} + +#[test] +fn output_freshness_still_invalidates_real_event_timing_contradictions() { + let negative_rows_start = SYNC_SCRIPT + .find("elif video_negative_rows or audio_negative_rows:") + .expect("manual sync script should detect observed-before-injection rows"); + let negative_rows_end = SYNC_SCRIPT[negative_rows_start..] + .find("span_gap_s = finite") + .map(|offset| negative_rows_start + offset) + .expect("negative-row invalidation should precede span diagnostics"); + let negative_rows_block = &SYNC_SCRIPT[negative_rows_start..negative_rows_end]; + + for expected in [ + "capture_timebase_status = \"invalid\"", + "video observed before server pipeline injection", + "audio observed before server pipeline injection", + "capture_timebase_reasons.append(", + ] { + assert!( + negative_rows_block.contains(expected), + "real event timing contradictions must remain hard failures: {expected}" + ); + } + + for expected in [ + "elif capture_timebase_status == \"invalid\":", + "freshness_reason = f\"capture timebase invalid: {capture_timebase_reason}\"", + "paired coded events {len(joined)} < {min_freshness_pairs}", + "freshness requires enough matched injected/observed events", + "server/capture clock alignment unavailable or too uncertain", + "non-monotonic event timestamps", + "minimum freshness", + "freshness was negative beyond clock uncertainty", + ] { + assert!( + SYNC_SCRIPT.contains(expected), + "freshness should still fail on impossible event timing: {expected}" + ); + } +} + #[test] fn server_rc_mode_matrix_validates_advertised_uvc_profiles() { for expected in [ @@ -289,6 +394,7 @@ fn server_rc_mode_matrix_validates_advertised_uvc_profiles() { "Tethys media ready: video=%s mode=%s audio_stack=%s", "timed out waiting for Tethys Lesavka media endpoints", "LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS=${LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS:-350}", + "LESAVKA_SERVER_RC_FRESHNESS_MIN_PAIRS=${LESAVKA_SERVER_RC_FRESHNESS_MIN_PAIRS:-${LESAVKA_SERVER_RC_TUNE_MIN_PAIRS}}", "LESAVKA_SERVER_RC_MAX_VIDEO_HICCUPS=${LESAVKA_SERVER_RC_MAX_VIDEO_HICCUPS:-0}", "LESAVKA_SERVER_RC_MAX_AUDIO_HICCUPS=${LESAVKA_SERVER_RC_MAX_AUDIO_HICCUPS:-0}", "LESAVKA_SERVER_RC_MAX_VIDEO_MISSING_FRAMES=${LESAVKA_SERVER_RC_MAX_VIDEO_MISSING_FRAMES:-12}", @@ -333,6 +439,7 @@ fn server_rc_mode_matrix_validates_advertised_uvc_profiles() { "LESAVKA_OUTPUT_DELAY_SAVE=0", "LESAVKA_OUTPUT_FRESHNESS_MAX_AGE_MS=\"${LESAVKA_SERVER_RC_FRESHNESS_MAX_AGE_MS}\"", "LESAVKA_OUTPUT_FRESHNESS_MAX_CLOCK_UNCERTAINTY_MS=\"${LESAVKA_SERVER_RC_FRESHNESS_MAX_CLOCK_UNCERTAINTY_MS}\"", + "LESAVKA_OUTPUT_FRESHNESS_MIN_PAIRS=\"${LESAVKA_SERVER_RC_FRESHNESS_MIN_PAIRS}\"", "sync did not pass", "freshness did not pass", "video hiccups",