From 4457356ee4b98b2dd63af6e34a91a0916889dda1 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 31 Jul 2026 11:17:25 +0200 Subject: [PATCH] =?UTF-8?q?test(phase-lock):=20a=20closed-loop=20simulatio?= =?UTF-8?q?n=20harness=20=E2=80=94=20both=20on-glass=20failures=20become?= =?UTF-8?q?=20CI=20regressions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The controller iterated twice on the user's gaming PC before this existed; never again. punktfunk_core::phase::circular_latch is extracted from the Android presenter (shared: iOS reports next, and the harness generates its synthetic reports through the IDENTICAL statistic the clients ship), with unit tests including the wrap-straddling cluster an arithmetic mean gets maximally wrong. The stream.rs harness models the plant — latch = (base − hold + noise) mod P, deterministic LCG noise — and drives PhaseController::adjust through five regimes: - tight jitter locks into the deadband within ~5 adjusts and stops moving; - a wrap-side start takes the SHORTEST way (travel < P/2 asserted); - an incoherent phase never steps (zero hold, ever); - a v1 pinned-median report trips the travel budget and DECAYS TO ZERO — the 2026-07-31 orbit and the parked-hold e2e tax, both as asserts; - a post-decay regime tightening re-locks. Gates: host bin suite 344 passed / 1 failed = the documented environmental qemu UDP-loopback test (clean main fails identically in this container); core phase tests 4/4; clippy --all-targets -D warnings clean. Co-Authored-By: Claude Fable 5 --- .../android/native/src/decode/presenter.rs | 28 +-- crates/punktfunk-core/src/lib.rs | 1 + crates/punktfunk-core/src/phase.rs | 92 ++++++++++ crates/punktfunk-host/src/native/stream.rs | 172 ++++++++++++++++++ 4 files changed, 268 insertions(+), 25 deletions(-) create mode 100644 crates/punktfunk-core/src/phase.rs diff --git a/clients/android/native/src/decode/presenter.rs b/clients/android/native/src/decode/presenter.rs index 1a159c46..2607b5a9 100644 --- a/clients/android/native/src/decode/presenter.rs +++ b/clients/android/native/src/decode/presenter.rs @@ -138,29 +138,6 @@ impl PresentMeter { } } -/// Circular (vector-mean) statistics of latch samples against the panel period: the mean latch -/// mod the period (ns) and the coherence (‰). The mean is what a phase controller can actually -/// steer under jitter — a median of a period-spanning distribution is immovable (the v1 lesson); -/// the coherence says whether ANY phase exists to steer (0 = uniformly smeared, 1000 = locked). -/// `None` under 8 samples — too little evidence to report a phase at all. -fn circular_latch(samples_us: &[u64], period_ns: i64) -> Option<(u64, u16)> { - if samples_us.len() < 8 || period_ns <= 0 { - return None; - } - let period_us = period_ns as f64 / 1000.0; - let (mut x, mut y) = (0.0f64, 0.0f64); - for &s in samples_us { - let theta = (s as f64 % period_us) / period_us * std::f64::consts::TAU; - x += theta.cos(); - y += theta.sin(); - } - let n = samples_us.len() as f64; - let r = (x * x + y * y).sqrt() / n; - let mean_theta = y.atan2(x).rem_euclid(std::f64::consts::TAU); - let mean_ns = (mean_theta / std::f64::consts::TAU * period_ns as f64) as u64; - Some((mean_ns, (r * 1000.0) as u16)) -} - /// p50/max of an unsorted µs sample vec, in ms. (0, 0) when empty. fn p50_max_ms(mut v: Vec) -> (f64, f64) { if v.is_empty() { @@ -380,8 +357,9 @@ impl Presenter { return None; // idle stream — nothing worth a line } let (pace_p50, pace_max) = p50_max_ms(std::mem::take(&mut self.pace_us)); - let circ = - clock.and_then(|c| circular_latch(&latch, c.panel_period_ns().max(c.period_ns()))); + let circ = clock.and_then(|c| { + punktfunk_core::phase::circular_latch(&latch, c.panel_period_ns().max(c.period_ns())) + }); let (latch_p50, latch_max) = p50_max_ms(latch); let period_ms = clock.map(|c| c.period_ns() as f64 / 1e6).unwrap_or(0.0); let panel_ms = clock diff --git a/crates/punktfunk-core/src/lib.rs b/crates/punktfunk-core/src/lib.rs index 3beea300..ae25c961 100644 --- a/crates/punktfunk-core/src/lib.rs +++ b/crates/punktfunk-core/src/lib.rs @@ -56,6 +56,7 @@ pub mod error; pub mod fec; pub mod input; pub mod packet; +pub mod phase; #[cfg(feature = "quic")] pub mod quic; pub mod reanchor; diff --git a/crates/punktfunk-core/src/phase.rs b/crates/punktfunk-core/src/phase.rs new file mode 100644 index 00000000..4a08ede9 --- /dev/null +++ b/crates/punktfunk-core/src/phase.rs @@ -0,0 +1,92 @@ +//! Circular (directional) statistics for phase-locked capture (design/phase-locked-capture.md): +//! the client-side half of the controller's v2 error signal. Pure math, no features — shared so +//! every vsync-aware presenter (Android today, iOS next) computes the SAME statistic the host +//! controller was tuned against, and so the controller's simulation tests can generate their +//! synthetic reports through the identical code path. + +/// Circular (vector-mean) statistics of latch samples against a display period: the mean latch +/// mod the period (ns) and the coherence (‰). +/// +/// The mean is what a phase controller can actually steer under jitter — the MEDIAN of a +/// period-spanning distribution is immovable (shifting a uniform-mod-P distribution's mean +/// leaves its median untouched; the controller-v1 on-glass lesson, 2026-07-31). The coherence +/// (the resultant length `R` of the unit phasors, scaled to ‰) says whether ANY phase exists to +/// steer: 0 = arrivals uniformly smeared over the period (alignment is physically pointless), +/// 1000 = perfectly phase-locked. +/// +/// `None` under 8 samples or a non-positive period — too little evidence to report a phase. +pub fn circular_latch(samples_us: &[u64], period_ns: i64) -> Option<(u64, u16)> { + if samples_us.len() < 8 || period_ns <= 0 { + return None; + } + let period_us = period_ns as f64 / 1000.0; + let (mut x, mut y) = (0.0f64, 0.0f64); + for &s in samples_us { + let theta = (s as f64 % period_us) / period_us * std::f64::consts::TAU; + x += theta.cos(); + y += theta.sin(); + } + let n = samples_us.len() as f64; + let r = (x * x + y * y).sqrt() / n; + let mean_theta = y.atan2(x).rem_euclid(std::f64::consts::TAU); + let mean_ns = (mean_theta / std::f64::consts::TAU * period_ns as f64) as u64; + Some((mean_ns, (r * 1000.0) as u16)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const P: i64 = 8_333_333; // 120 Hz in ns + const P_US: u64 = 8_333; // …and in µs, the sample unit + + #[test] + fn identical_samples_are_fully_coherent() { + let (mean, coh) = circular_latch(&[4_000; 16], P).unwrap(); + assert!(coh >= 995, "identical phases must read ~1000‰, got {coh}"); + assert!( + (mean as i64 - 4_000_000).abs() < 20_000, + "mean {mean} ≉ 4.0 ms" + ); + } + + #[test] + fn uniform_grid_over_the_period_is_incoherent() { + // 16 samples evenly spanning one period — the resultant vector cancels. + let samples: Vec = (0..16).map(|i| i * P_US / 16).collect(); + let (_, coh) = circular_latch(&samples, P).unwrap(); + assert!(coh < 100, "a uniform phase smear must read ~0‰, got {coh}"); + } + + #[test] + fn cluster_straddling_the_wrap_averages_at_the_boundary() { + // Half the samples just below the period boundary, half just above 0: an ARITHMETIC + // mean would report ~P/2 (maximally wrong); the circular mean must sit at the boundary. + let samples = [ + P_US - 200, + P_US - 100, + P_US - 50, + P_US - 150, + 100, + 50, + 150, + 200, + ]; + let (mean, coh) = circular_latch(&samples, P).unwrap(); + let dist_to_boundary = (mean as i64).min((P - mean as i64).abs()); + assert!( + dist_to_boundary < 500_000, + "circular mean {mean} must hug the wrap boundary" + ); + assert!( + coh > 900, + "a tight straddling cluster is still coherent, got {coh}" + ); + } + + #[test] + fn too_few_samples_report_nothing() { + assert!(circular_latch(&[1_000; 7], P).is_none()); + assert!(circular_latch(&[1_000; 16], 0).is_none()); + } +} diff --git a/crates/punktfunk-host/src/native/stream.rs b/crates/punktfunk-host/src/native/stream.rs index ba74ed3a..3943850e 100644 --- a/crates/punktfunk-host/src/native/stream.rs +++ b/crates/punktfunk-host/src/native/stream.rs @@ -4280,4 +4280,176 @@ mod tests { )); assert!(!is_permanent_build_error("open NVENC: device busy")); } + + // ---- Phase-controller closed-loop simulation (design/phase-locked-capture.md §3, v2) ---- + // + // The plant models what the on-glass loop actually is: the client's per-frame latch is + // `(base_latch − hold + noise) mod P` — a bigger host hold makes frames arrive later, so + // the wait-for-latch SHRINKS — and each 1 Hz report carries the CIRCULAR statistics of a + // window of those samples, generated through the exact shared `punktfunk_core::phase` + // code the real clients use. Both v1 failure modes live here as regression tests: the + // 2026-07-31 orbit (a dead statistic chased forever) and the parked-hold e2e tax. + + const SIM_P: i64 = 8_333_333; // 120 Hz + const SIM_TARGET: i64 = 3_500_000; // TARGET_LEAD_FLOOR ∨ (uncertainty 1ms + 1ms) → 3.5 ms + + /// Deterministic LCG in ±spread_ns around zero (no OS randomness in tests). + struct Lcg(u64); + impl Lcg { + fn next_noise(&mut self, spread_ns: i64) -> i64 { + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + if spread_ns == 0 { + return 0; + } + ((self.0 >> 33) as i64 % (2 * spread_ns)) - spread_ns + } + } + + /// One simulated 1 Hz report: 120 latch samples from the plant, folded through the SHARED + /// circular statistic — the same bytes-in-bytes-out path the Android reporter takes. + fn plant_report( + base_latch_ns: i64, + hold_ns: i64, + noise_spread_ns: i64, + rng: &mut Lcg, + ) -> punktfunk_core::quic::PhaseReport { + let samples_us: Vec = (0..120) + .map(|_| { + let latch = + (base_latch_ns - hold_ns + rng.next_noise(noise_spread_ns)).rem_euclid(SIM_P); + (latch / 1000) as u64 + }) + .collect(); + let (mean_ns, coherence) = + punktfunk_core::phase::circular_latch(&samples_us, SIM_P).expect("120 samples"); + punktfunk_core::quic::PhaseReport { + next_latch_host_ns: 0, + latch_period_ns: SIM_P as u32, + uncertainty_ns: 1_000_000, + arrival_lead_ns: mean_ns as u32, + coherence_milli: coherence, + } + } + + /// The steady-state latch the controller produced: one noise-free plant readout. + fn settled_latch(base_latch_ns: i64, hold_ns: i64) -> i64 { + (base_latch_ns - hold_ns).rem_euclid(SIM_P) + } + + #[test] + fn tight_jitter_locks_into_the_deadband_and_stays() { + let mut c = PhaseController::new(); + let mut rng = Lcg(7); + for _ in 0..12 { + let r = plant_report(7_500_000, c.hold_ns, 500_000, &mut rng); + c.adjust(&r, SIM_P); + } + let err = settled_latch(7_500_000, c.hold_ns) - SIM_TARGET; + assert!( + err.abs() < 1_000_000, + "tight jitter must converge near the target lead, residual {err} ns" + ); + assert!(!c.decaying, "a converged lock never decays"); + // Locked: further reports keep it in the deadband without dithering away. + let before = c.hold_ns; + for _ in 0..10 { + let r = plant_report(7_500_000, c.hold_ns, 500_000, &mut rng); + c.adjust(&r, SIM_P); + } + assert!( + (c.hold_ns - before).abs() <= 2 * PhaseController::MAX_STEP_NS, + "a locked loop must not wander" + ); + } + + #[test] + fn wrap_side_error_takes_the_shortest_way() { + // base 1.0 ms < target 3.5 ms: the SHORT way is −2.5 ms (through the wrap), the long + // way is +5.8. v1 walked long ways; v2 must spend well under a period of travel. + let mut c = PhaseController::new(); + let mut rng = Lcg(11); + for _ in 0..12 { + let r = plant_report(1_000_000, c.hold_ns, 300_000, &mut rng); + c.adjust(&r, SIM_P); + } + let err = settled_latch(1_000_000, c.hold_ns) - SIM_TARGET; + assert!( + err.abs() < 1_000_000, + "wrap-side start must still lock, residual {err}" + ); + assert!( + c.cum_travel_ns <= SIM_P / 2, + "shortest-way stepping spent {} ns of travel — walked the long way", + c.cum_travel_ns + ); + } + + #[test] + fn incoherent_phase_never_steps_and_holds_nothing() { + // Uniform jitter over the whole period: coherence ~0 — the v1 orbit's regime. The + // controller must refuse to step at all: a hold here is a pure e2e tax. + let mut c = PhaseController::new(); + let mut rng = Lcg(13); + for _ in 0..20 { + let r = plant_report(7_500_000, c.hold_ns, SIM_P, &mut rng); + c.adjust(&r, SIM_P); + } + assert_eq!( + c.hold_ns, 0, + "an incoherent phase must never accumulate a hold" + ); + } + + #[test] + fn v1_median_report_trips_the_travel_budget_and_decays_to_zero() { + // A v1 sender (coherence sentinel) whose statistic does not respond to the hold — the + // exact on-glass orbit. The budget must trip and the hold must DECAY TO ZERO (not + // freeze at a random value: a parked hold taxes e2e by up to a period). + let mut c = PhaseController::new(); + let mut report = punktfunk_core::quic::PhaseReport { + next_latch_host_ns: 0, + latch_period_ns: SIM_P as u32, + uncertainty_ns: 1_000_000, + arrival_lead_ns: 7_500_000, // pinned — the dead median + coherence_milli: u16::MAX, // v1: bypasses the coherence gate + }; + let mut max_hold_seen = 0; + for _ in 0..40 { + c.adjust(&report, SIM_P); + report.arrival_lead_ns = 7_500_000; // never responds + max_hold_seen = max_hold_seen.max(c.hold_ns); + } + assert!( + max_hold_seen > 0, + "the chase must have started before the budget tripped" + ); + assert_eq!( + c.hold_ns, 0, + "a tripped budget must end at ZERO hold (decay), not parked" + ); + } + + #[test] + fn regime_change_relocks_after_decay() { + // Incoherent → hold 0; the network tightens → the same controller must lock. + let mut c = PhaseController::new(); + let mut rng = Lcg(17); + for _ in 0..10 { + let r = plant_report(7_500_000, c.hold_ns, SIM_P, &mut rng); + c.adjust(&r, SIM_P); + } + assert_eq!(c.hold_ns, 0); + for _ in 0..12 { + let r = plant_report(7_500_000, c.hold_ns, 400_000, &mut rng); + c.adjust(&r, SIM_P); + } + let err = settled_latch(7_500_000, c.hold_ns) - SIM_TARGET; + assert!( + err.abs() < 1_000_000, + "post-decay tightening must re-lock, residual {err}" + ); + } }