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

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:
2026-07-31 11:18:30 +02:00
co-authored by Claude Fable 5
parent fe552a50bb
commit 4457356ee4
4 changed files with 268 additions and 25 deletions
+3 -25
View File
@@ -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
+1
View File
@@ -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;
+92
View File
@@ -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());
}
}
+172
View File
@@ -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}"
);
}
}