test(phase-lock): a closed-loop simulation harness — both on-glass failures become CI regressions
ci / web (push) Successful in 1m7s
ci / docs-site (push) Successful in 1m14s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 23s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 27s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
ci / rust-arm64 (push) Successful in 2m48s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 22s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 25s
deb / build-publish-client-arm64 (push) Successful in 2m48s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m1s
android / android (push) Successful in 5m31s
deb / build-publish-host (push) Successful in 6m5s
docker / builders-arm64cross (push) Successful in 18s
apple / swift (push) Successful in 4m41s
docker / deploy-docs (push) Successful in 46s
ci / rust (push) Successful in 6m45s
deb / build-publish (push) Successful in 5m9s
arch / build-publish (push) Successful in 9m4s
flatpak / build-publish (push) Successful in 6m54s
windows-host / package (push) Successful in 11m3s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 25s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m10s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m35s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m27s
release / apple (push) Successful in 25m17s
apple / screenshots (push) Successful in 21m0s
ci / web (push) Successful in 1m7s
ci / docs-site (push) Successful in 1m14s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 23s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 27s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
ci / rust-arm64 (push) Successful in 2m48s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 22s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 25s
deb / build-publish-client-arm64 (push) Successful in 2m48s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m1s
android / android (push) Successful in 5m31s
deb / build-publish-host (push) Successful in 6m5s
docker / builders-arm64cross (push) Successful in 18s
apple / swift (push) Successful in 4m41s
docker / deploy-docs (push) Successful in 46s
ci / rust (push) Successful in 6m45s
deb / build-publish (push) Successful in 5m9s
arch / build-publish (push) Successful in 9m4s
flatpak / build-publish (push) Successful in 6m54s
windows-host / package (push) Successful in 11m3s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 25s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m10s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m35s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m27s
release / apple (push) Successful in 25m17s
apple / screenshots (push) Successful in 21m0s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<u64>) -> (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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<u64> = (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());
|
||||
}
|
||||
}
|
||||
@@ -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<u64> = (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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user