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>
93 lines
3.7 KiB
Rust
93 lines
3.7 KiB
Rust
//! 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());
|
|
}
|
|
}
|