diff --git a/AGENTS.md b/AGENTS.md index 6b74f0d..c25d61e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -409,3 +409,23 @@ not degrade into camera-only evidence. - [x] Run shell syntax checks, focused contract tests, and package checks. - [x] Push clean semver `0.17.19` for installed client/server testing. - [ ] Re-run only after `LESAVKA_MIC_SOURCE` is listed by the local audio stack. + +## 0.17.20 Provisional Adaptive Calibration Checklist + +Context: the 0.17.19 adaptive run reached live media and produced browser-visible sync evidence, +but the probe still did not calibrate. Each segment either had only 3 coded pairs or an analyzer +failure, so the existing `calibration.ready=true` gate refused every adjustment. That was safe for +saved defaults, but wrong for the user-directed adaptive probe goal: live segments should be allowed +to make bounded provisional corrections from imperfect-but-usable evidence, then let the next segment +judge whether the correction helped. + +- [x] Treat the 0.17.19 run as a measurement-only run, not a calibration run. +- [x] Keep permanent saved calibration gated by analyzer-ready evidence. +- [x] Add provisional adaptive calibration mode enabled by adaptive runs by default. +- [x] Allow provisional corrections from low-but-usable paired-pulse reports when p95 and drift are bounded. +- [x] Use median skew as the provisional correction source and default to video offset adjustment. +- [x] Clamp provisional correction gain and max step so one noisy segment cannot swing the site offset wildly. +- [x] Record decision mode and provisional recommendations in segment metrics. +- [x] Update manual probe contract tests for provisional calibration controls and output. +- [x] Run shell syntax checks, focused contract tests, and package checks. +- [ ] Push clean semver `0.17.20` for installed client/server testing. diff --git a/Cargo.lock b/Cargo.lock index 1b6a39c..8f003ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.17.19" +version = "0.17.20" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.17.19" +version = "0.17.20" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.17.19" +version = "0.17.20" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index ec76e19..af99b2f 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.17.19" +version = "0.17.20" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index bb188a0..a57bfaa 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.17.19" +version = "0.17.20" edition = "2024" build = "build.rs" diff --git a/scripts/manual/run_upstream_mirrored_av_sync.sh b/scripts/manual/run_upstream_mirrored_av_sync.sh index f6fe215..6381594 100755 --- a/scripts/manual/run_upstream_mirrored_av_sync.sh +++ b/scripts/manual/run_upstream_mirrored_av_sync.sh @@ -32,6 +32,12 @@ LESAVKA_SYNC_CALIBRATION_SEGMENTS=${LESAVKA_SYNC_CALIBRATION_SEGMENTS:-1} LESAVKA_SYNC_CONTINUOUS_BROWSER=${LESAVKA_SYNC_CONTINUOUS_BROWSER:-${LESAVKA_SYNC_ADAPTIVE_CALIBRATION}} LESAVKA_SYNC_CONTINUE_ON_ANALYSIS_FAILURE=${LESAVKA_SYNC_CONTINUE_ON_ANALYSIS_FAILURE:-${LESAVKA_SYNC_ADAPTIVE_CALIBRATION}} LESAVKA_SYNC_SEGMENT_SETTLE_SECONDS=${LESAVKA_SYNC_SEGMENT_SETTLE_SECONDS:-3} +LESAVKA_SYNC_PROVISIONAL_CALIBRATION=${LESAVKA_SYNC_PROVISIONAL_CALIBRATION:-${LESAVKA_SYNC_ADAPTIVE_CALIBRATION}} +LESAVKA_SYNC_PROVISIONAL_MIN_PAIRS=${LESAVKA_SYNC_PROVISIONAL_MIN_PAIRS:-3} +LESAVKA_SYNC_PROVISIONAL_MAX_P95_MS=${LESAVKA_SYNC_PROVISIONAL_MAX_P95_MS:-350} +LESAVKA_SYNC_PROVISIONAL_MAX_DRIFT_MS=${LESAVKA_SYNC_PROVISIONAL_MAX_DRIFT_MS:-250} +LESAVKA_SYNC_PROVISIONAL_GAIN=${LESAVKA_SYNC_PROVISIONAL_GAIN:-0.5} +LESAVKA_SYNC_PROVISIONAL_MAX_STEP_US=${LESAVKA_SYNC_PROVISIONAL_MAX_STEP_US:-150000} STIMULUS_PORT=${STIMULUS_PORT:-18444} STIMULUS_SETTLE_SECONDS=${STIMULUS_SETTLE_SECONDS:-10} LOCAL_OUTPUT_DIR=${LOCAL_OUTPUT_DIR:-"${REPO_ROOT}/tmp"} @@ -289,6 +295,8 @@ maybe_apply_probe_calibration() { local summary if ! summary="$(python3 - "${report_json}" "${LESAVKA_SYNC_CALIBRATION_TARGET}" <<'PY' import json +import math +import os import shlex import sys @@ -302,25 +310,115 @@ verdict = report.get("verdict", {}) if target not in {"audio", "video"}: target = "video" -audio_recommendation = int(cal.get("recommended_audio_offset_adjust_us") or 0) -video_recommendation = int(cal.get("recommended_video_offset_adjust_us") or 0) +def env_bool(name, default=False): + raw = os.environ.get(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + +def env_int(name, default): + try: + return int(os.environ.get(name, str(default))) + except ValueError: + return default + +def env_float(name, default): + try: + return float(os.environ.get(name, str(default))) + except ValueError: + return default + +def as_float(value, default=0.0): + try: + parsed = float(value) + except (TypeError, ValueError): + return default + return parsed if math.isfinite(parsed) else default + +def clamp(value, limit): + limit = abs(int(limit)) + return max(-limit, min(limit, int(round(value)))) + +ready = bool(cal.get("ready")) +paired_pulses = int(report.get("paired_event_count", 0) or 0) +median_skew_ms = as_float(report.get("median_skew_ms", 0.0)) +p95_abs_skew_ms = as_float(verdict.get("p95_abs_skew_ms", 0.0)) +drift_ms = as_float(report.get("drift_ms", 0.0)) + +provisional_enabled = env_bool("LESAVKA_SYNC_PROVISIONAL_CALIBRATION", False) +provisional_min_pairs = env_int("LESAVKA_SYNC_PROVISIONAL_MIN_PAIRS", 3) +provisional_max_p95_ms = env_float("LESAVKA_SYNC_PROVISIONAL_MAX_P95_MS", 350.0) +provisional_max_drift_ms = env_float("LESAVKA_SYNC_PROVISIONAL_MAX_DRIFT_MS", 250.0) +provisional_gain = env_float("LESAVKA_SYNC_PROVISIONAL_GAIN", 0.5) +provisional_max_step_us = env_int("LESAVKA_SYNC_PROVISIONAL_MAX_STEP_US", 150000) + +ready_audio_recommendation = int(cal.get("recommended_audio_offset_adjust_us") or 0) +ready_video_recommendation = int(cal.get("recommended_video_offset_adjust_us") or 0) +provisional_audio_recommendation = int(round(-median_skew_ms * 1000.0)) +provisional_video_recommendation = int(round(median_skew_ms * 1000.0)) + +audio_recommendation = ready_audio_recommendation +video_recommendation = ready_video_recommendation audio_delta = audio_recommendation if target == "audio" else 0 video_delta = video_recommendation if target == "video" else 0 +decision_mode = "ready" if ready else "refused" +decision_note = "analyzer marked this report calibration-ready" if ready else "analyzer did not mark this report calibration-ready" + +if not ready and provisional_enabled: + refusal_reasons = [] + if paired_pulses < provisional_min_pairs: + refusal_reasons.append(f"paired_pulses {paired_pulses} < {provisional_min_pairs}") + if p95_abs_skew_ms > provisional_max_p95_ms: + refusal_reasons.append(f"p95_abs_skew_ms {p95_abs_skew_ms:.1f} > {provisional_max_p95_ms:.1f}") + if abs(drift_ms) > provisional_max_drift_ms: + refusal_reasons.append(f"abs(drift_ms) {abs(drift_ms):.1f} > {provisional_max_drift_ms:.1f}") + + if refusal_reasons: + decision_note = "provisional calibration refused: " + "; ".join(refusal_reasons) + else: + audio_recommendation = provisional_audio_recommendation + video_recommendation = provisional_video_recommendation + if target == "audio": + audio_delta = clamp(audio_recommendation * provisional_gain, provisional_max_step_us) + video_delta = 0 + else: + audio_delta = 0 + video_delta = clamp(video_recommendation * provisional_gain, provisional_max_step_us) + if audio_delta == 0 and video_delta == 0: + decision_note = "provisional calibration skipped: rounded correction was zero" + else: + decision_mode = "provisional" + decision_note = ( + "bounded provisional correction from median skew; " + "not safe to save until a confirming ready report" + ) fields = { "report_json": report_path, - "calibration_ready": str(bool(cal.get("ready"))).lower(), + "calibration_ready": str(ready).lower(), "calibration_target": target, + "calibration_decision_mode": decision_mode, + "calibration_decision_note": decision_note, "calibration_audio_recommendation_us": audio_recommendation, "calibration_video_recommendation_us": video_recommendation, + "calibration_ready_audio_recommendation_us": ready_audio_recommendation, + "calibration_ready_video_recommendation_us": ready_video_recommendation, + "calibration_provisional_audio_recommendation_us": provisional_audio_recommendation, + "calibration_provisional_video_recommendation_us": provisional_video_recommendation, "calibration_apply_audio_delta_us": audio_delta, "calibration_apply_video_delta_us": video_delta, "calibration_note": cal.get("note", ""), + "provisional_calibration_enabled": str(provisional_enabled).lower(), + "provisional_min_pairs": provisional_min_pairs, + "provisional_max_p95_ms": f"{provisional_max_p95_ms:.1f}", + "provisional_max_drift_ms": f"{provisional_max_drift_ms:.1f}", + "provisional_gain": f"{provisional_gain:.3f}", + "provisional_max_step_us": provisional_max_step_us, "verdict_status": verdict.get("status", ""), - "paired_pulses": report.get("paired_event_count", 0), - "median_skew_ms": f"{float(report.get('median_skew_ms', 0.0)):+.1f}", - "p95_abs_skew_ms": f"{float(verdict.get('p95_abs_skew_ms', 0.0)):.1f}", - "drift_ms": f"{float(report.get('drift_ms', 0.0)):+.1f}", + "paired_pulses": paired_pulses, + "median_skew_ms": f"{median_skew_ms:+.1f}", + "p95_abs_skew_ms": f"{p95_abs_skew_ms:.1f}", + "drift_ms": f"{drift_ms:+.1f}", } for key, value in fields.items(): print(f"{key}={shlex.quote(str(value))}") @@ -340,16 +438,28 @@ PY echo " ↪ drift_ms=${drift_ms}" echo " ↪ calibration_ready=${calibration_ready}" echo " ↪ calibration_target=${calibration_target}" + echo " ↪ calibration_decision_mode=${calibration_decision_mode}" echo " ↪ recommended_audio_offset_adjust_us=${calibration_audio_recommendation_us}" echo " ↪ recommended_video_offset_adjust_us=${calibration_video_recommendation_us}" + echo " ↪ ready_audio_offset_adjust_us=${calibration_ready_audio_recommendation_us}" + echo " ↪ ready_video_offset_adjust_us=${calibration_ready_video_recommendation_us}" + echo " ↪ provisional_audio_offset_adjust_us=${calibration_provisional_audio_recommendation_us}" + echo " ↪ provisional_video_offset_adjust_us=${calibration_provisional_video_recommendation_us}" + echo " ↪ provisional_calibration_enabled=${provisional_calibration_enabled}" + echo " ↪ provisional_min_pairs=${provisional_min_pairs}" + echo " ↪ provisional_max_p95_ms=${provisional_max_p95_ms}" + echo " ↪ provisional_max_drift_ms=${provisional_max_drift_ms}" + echo " ↪ provisional_gain=${provisional_gain}" + echo " ↪ provisional_max_step_us=${provisional_max_step_us}" echo " ↪ calibration_note=${calibration_note}" + echo " ↪ calibration_decision_note=${calibration_decision_note}" if [[ "${LESAVKA_SYNC_APPLY_CALIBRATION}" != "1" ]]; then - echo " ↪ calibration apply disabled; set LESAVKA_SYNC_APPLY_CALIBRATION=1 to apply a ready recommendation" + echo " ↪ calibration apply disabled; set LESAVKA_SYNC_APPLY_CALIBRATION=1 to apply ready or provisional recommendations" return 0 fi - if [[ "${calibration_ready}" != "true" ]]; then - echo " ↪ calibration apply refused: analyzer did not mark this report calibration-ready" + if [[ "${calibration_decision_mode}" == "refused" ]]; then + echo " ↪ calibration apply refused: ${calibration_decision_note}" return 0 fi if [[ "${calibration_apply_audio_delta_us}" == "0" && "${calibration_apply_video_delta_us}" == "0" ]]; then @@ -357,7 +467,7 @@ PY return 0 fi - local note="mirrored probe ${STAMP} ${label}: target=${calibration_target}, median=${median_skew_ms}ms, p95=${p95_abs_skew_ms}ms, pairs=${paired_pulses}" + local note="mirrored probe ${STAMP} ${label}: mode=${calibration_decision_mode}, target=${calibration_target}, median=${median_skew_ms}ms, p95=${p95_abs_skew_ms}ms, pairs=${paired_pulses}" echo " ↪ applying calibration: audio_delta_us=${calibration_apply_audio_delta_us}, video_delta_us=${calibration_apply_video_delta_us}" LESAVKA_TLS_DOMAIN="${LESAVKA_TLS_DOMAIN}" \ "${REPO_ROOT}/target/debug/lesavka-relayctl" \ @@ -365,13 +475,15 @@ PY calibrate "${calibration_apply_audio_delta_us}" "${calibration_apply_video_delta_us}" "${note}" \ | sed 's/^/ ↪ /' - if [[ "${LESAVKA_SYNC_SAVE_CALIBRATION}" == "1" ]]; then + if [[ "${LESAVKA_SYNC_SAVE_CALIBRATION}" == "1" && "${calibration_decision_mode}" == "ready" ]]; then echo " ↪ saving active calibration as site default" LESAVKA_TLS_DOMAIN="${LESAVKA_TLS_DOMAIN}" \ "${REPO_ROOT}/target/debug/lesavka-relayctl" \ --server "${RESOLVED_LESAVKA_SERVER_ADDR}" \ calibration-save-default \ | sed 's/^/ ↪ /' + elif [[ "${LESAVKA_SYNC_SAVE_CALIBRATION}" == "1" ]]; then + echo " ↪ provisional calibration not saved; require a confirming ready rerun before saving defaults" else echo " ↪ active calibration not saved; set LESAVKA_SYNC_SAVE_CALIBRATION=1 after a confirming rerun" fi @@ -595,8 +707,12 @@ for segment in range(1, segment_count + 1): "analysis_raw_first_audio_activity_s": as_float(str(failure.get("raw_first_audio_activity_s", ""))), "calibration_ready": bool(calibration.get("ready", False)), "calibration_note": calibration.get("note", ""), + "decision_mode": decision.get("calibration_decision_mode", ""), + "decision_note": decision.get("calibration_decision_note", ""), "decision_video_delta_us": as_float(decision.get("calibration_apply_video_delta_us")), "decision_audio_delta_us": as_float(decision.get("calibration_apply_audio_delta_us")), + "decision_provisional_video_recommendation_us": as_float(decision.get("calibration_provisional_video_recommendation_us")), + "decision_provisional_audio_recommendation_us": as_float(decision.get("calibration_provisional_audio_recommendation_us")), "planner_phase_before": planner_before.get("planner_phase", ""), "planner_phase_after": planner_after.get("planner_phase", ""), "planner_live_lag_ms_before": as_float(planner_before.get("planner_live_lag_ms")), diff --git a/server/Cargo.toml b/server/Cargo.toml index 6c2ceea..6c6ae5a 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.17.19" +version = "0.17.20" edition = "2024" autobins = false diff --git a/testing/tests/client_manual_sync_script_contract.rs b/testing/tests/client_manual_sync_script_contract.rs index c92dcd0..6a6b1a7 100644 --- a/testing/tests/client_manual_sync_script_contract.rs +++ b/testing/tests/client_manual_sync_script_contract.rs @@ -127,6 +127,12 @@ fn mirrored_sync_script_uses_real_client_capture_path() { "LESAVKA_SYNC_CONTINUOUS_BROWSER=${LESAVKA_SYNC_CONTINUOUS_BROWSER:-${LESAVKA_SYNC_ADAPTIVE_CALIBRATION}}", "LESAVKA_SYNC_CONTINUE_ON_ANALYSIS_FAILURE=${LESAVKA_SYNC_CONTINUE_ON_ANALYSIS_FAILURE:-${LESAVKA_SYNC_ADAPTIVE_CALIBRATION}}", "LESAVKA_SYNC_SEGMENT_SETTLE_SECONDS=${LESAVKA_SYNC_SEGMENT_SETTLE_SECONDS:-3}", + "LESAVKA_SYNC_PROVISIONAL_CALIBRATION=${LESAVKA_SYNC_PROVISIONAL_CALIBRATION:-${LESAVKA_SYNC_ADAPTIVE_CALIBRATION}}", + "LESAVKA_SYNC_PROVISIONAL_MIN_PAIRS=${LESAVKA_SYNC_PROVISIONAL_MIN_PAIRS:-3}", + "LESAVKA_SYNC_PROVISIONAL_MAX_P95_MS=${LESAVKA_SYNC_PROVISIONAL_MAX_P95_MS:-350}", + "LESAVKA_SYNC_PROVISIONAL_MAX_DRIFT_MS=${LESAVKA_SYNC_PROVISIONAL_MAX_DRIFT_MS:-250}", + "LESAVKA_SYNC_PROVISIONAL_GAIN=${LESAVKA_SYNC_PROVISIONAL_GAIN:-0.5}", + "LESAVKA_SYNC_PROVISIONAL_MAX_STEP_US=${LESAVKA_SYNC_PROVISIONAL_MAX_STEP_US:-150000}", "LESAVKA_SYNC_ADAPTIVE_CALIBRATION", "LESAVKA_SYNC_CALIBRATION_SEGMENTS=4", "browser_consumer_reuse_session=${reuse_browser_session}", @@ -147,13 +153,18 @@ fn mirrored_sync_script_uses_real_client_capture_path() { "probe_activity_start_delta_ms", "blind-targets.json", "no segment produced a passing probe verdict; refusing to invent blind targets", + "decision_mode", + "decision_provisional_video_recommendation_us", "planner_live_lag_ms_after", "probe_p95_abs_skew_ms", "settling ${LESAVKA_SYNC_SEGMENT_SETTLE_SECONDS}s before next calibration segment", "print_upstream_calibration_state \"before mirrored run\"", "maybe_apply_probe_calibration", "calibration_ready=${calibration_ready}", - "calibration apply refused: analyzer did not mark this report calibration-ready", + "calibration_decision_mode=${calibration_decision_mode}", + "bounded provisional correction from median skew", + "provisional calibration not saved", + "calibration apply refused: ${calibration_decision_note}", "calibrate \"${calibration_apply_audio_delta_us}\" \"${calibration_apply_video_delta_us}\"", "calibration-save-default", "print_upstream_sync_state \"after mirrored run\"",