feat(phase-lock): controller v3 — grid-locked submits, arrival sensing, antipode damping
ci / docs-site (push) Successful in 1m17s
ci / web (push) Successful in 2m14s
ci / rust-arm64 (push) Successful in 2m54s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 19s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 25s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
deb / build-publish-client-arm64 (push) Successful in 2m59s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
apple / swift (push) Successful in 4m48s
deb / build-publish-host (push) Successful in 4m51s
android / android (push) Successful in 5m31s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 32s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 31s
docker / builders-arm64cross (push) Successful in 12s
docker / deploy-docs (push) Successful in 35s
deb / build-publish (push) Successful in 6m0s
ci / rust (push) Successful in 9m53s
arch / build-publish (push) Successful in 10m32s
windows-host / package (push) Successful in 19m10s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 15s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m2s
apple / screenshots (push) Successful in 21m3s

Midday on-glass falsified v2's remaining assumptions: a per-frame additive
hold SATURATES the arrival-slaved loop once hold + work >= interval — submits
free-run at their own cadence and the commanded phase dissolves (measured:
+-2 ms hold steps, zero client response) — and the latch statistic hovers at
the target's ANTIPODE, where shortest-way errors sign-flip on noise (the
0<->2<->4 ms offset chatter).

v3, one change per falsified assumption:
- ACTUATOR: submits lock to an absolute grid (epoch + k x period + offset);
  the controller walks only the offset. A periodic grid cannot free-run —
  occupancy is one frame per period whatever the offset — so phase actuation
  is linear BY CONSTRUCTION. Disengaged = no grid sleeps = zero cost; every
  failure path DISENGAGES (never parks: v2's e2e-tax lesson).
- SENSOR: the client reports the circular mean + coherence of the ARRIVAL
  lead (per-AU reassembly stamps vs the panel latch grid) — the phase the
  host actually controls; latch measured downstream of the decoder pipeline,
  which absorbed the actuation. pf.phase logs "arrival lead circ= coh=".
- ANTIPODE DAMPING: errors within 1 ms of +-period/2 take half-steps until
  they commit to a side.

Harness rewritten around plants glass validated: a GRID plant (linear by
construction), the DECOUPLED plant (v2's saturation — must disengage, not
orbit or park), an antipode start (must converge within one period of
travel), incoherence (never engages), a regime change (re-engages after
backoff), and the actuator's own periodicity/offset-linearity. Also fixes
the harness's SIM_TARGET (a mis-derived max asserted 3.5 ms where the
controller's actual target is 2.5 — the controller was right, the ruler
was wrong; the Python cross-check inherited the same constant, a lesson in
replicating the CODE, not the author's belief about it).

Gates: v3 simulations 6/6; host bin suite 345/1 (the documented
environmental qemu UDP test); clippy --all-targets -D warnings clean;
cargo ndk arm64 check clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 12:20:39 +02:00
co-authored by Claude Fable 5
parent 4457356ee4
commit 759aac255b
2 changed files with 293 additions and 157 deletions
+55 -17
View File
@@ -269,6 +269,8 @@ pub(super) fn run_async(
let mut free_inputs: VecDeque<usize> = VecDeque::new();
let mut pending_aus: VecDeque<Frame> = VecDeque::new();
// Phase-lock v3: per-AU arrival stamps for the circular arrival-lead report (drained 1 Hz).
let mut arrival_stamps: Vec<i128> = Vec::new();
let mut ready: Vec<OutputReady> = Vec::new();
let mut applied_ds: Option<DataSpace> = None;
let mut fed: u64 = 0;
@@ -321,6 +323,7 @@ pub(super) fn run_async(
&mut fatal,
&mut gate,
&mut recovery_flags,
&mut arrival_stamps,
));
}
// Coalesce every other event already queued into this one work pass — correct newest-only
@@ -336,6 +339,7 @@ pub(super) fn run_async(
&mut fatal,
&mut gate,
&mut recovery_flags,
&mut arrival_stamps,
));
}
if vsync_tick {
@@ -380,28 +384,50 @@ pub(super) fn run_async(
if p.pump(&codec, clock, &tracker, &stats, now_monotonic_ns()) {
rendered += 1;
}
// The 1 Hz window flush doubles as the phase-lock report tick: the CIRCULAR
// (vector-mean) latch phase + coherence are the host capture controller's v2 error
// signal (design/phase-locked-capture.md §6; a median is immovable under jitter).
// Timestamps convert monotonic→realtime→host — the skew offset lives client-side.
if let (Some((circ_latch_ns, coherence)), Some(c)) = (p.flush_log(&meter, clock), clock)
{
// The 1 Hz window flush doubles as the phase-lock report tick. v3 sensor: the
// CIRCULAR mean + coherence of the ARRIVAL lead — each AU's reassembly stamp
// against the panel's latch grid — because arrival is the phase the host actually
// controls; the v2 latch statistic measured downstream of the decoder pipeline,
// which absorbed the actuation (on-glass 2026-07-31). Timestamps convert
// monotonic→realtime→host — the skew offset lives client-side.
if let (Some(_), Some(c)) = (p.flush_log(&meter, clock), clock) {
let period = c.panel_period_ns().max(c.period_ns());
if period > 0 {
if let Some(t) = c.next_target(now_monotonic_ns(), 0) {
let mono_now = now_monotonic_ns();
let real_now = now_realtime_ns();
let latch_real_ns = real_now + (t.expected_present_ns - mono_now) as i128;
let latch_host_ns = (latch_real_ns
+ clock_offset.load(Ordering::Relaxed) as i128)
.max(0) as u64;
client.report_phase(
latch_host_ns,
period.clamp(0, u32::MAX as i64) as u32,
1_000_000, // skew residual + latch jitter — conservative 1 ms
circ_latch_ns.min(u32::MAX as u64) as u32,
coherence,
);
let leads_us: Vec<u64> = arrival_stamps
.iter()
.map(|&r_ns| {
let arrival_mono = mono_now as i128 - (real_now - r_ns);
((t.expected_present_ns as i128 - arrival_mono)
.rem_euclid(period as i128)
/ 1000) as u64
})
.collect();
arrival_stamps.clear();
if let Some((lead_mean_ns, coherence)) =
punktfunk_core::phase::circular_latch(&leads_us, period)
{
log::info!(
target: "pf.phase",
"arrival lead circ={:.2}ms coh={}",
lead_mean_ns as f64 / 1e6,
coherence
);
let latch_real_ns =
real_now + (t.expected_present_ns - mono_now) as i128;
let latch_host_ns = (latch_real_ns
+ clock_offset.load(Ordering::Relaxed) as i128)
.max(0) as u64;
client.report_phase(
latch_host_ns,
period.clamp(0, u32::MAX as i64) as u32,
1_000_000, // skew residual — conservative 1 ms
lead_mean_ns.min(u32::MAX as u64) as u32,
coherence,
);
}
}
}
}
@@ -634,6 +660,7 @@ fn dispatch_event(
fatal: &mut bool,
gate: &mut ReanchorGate,
recovery_flags: &mut VecDeque<(u64, u32)>,
arrival_stamps: &mut Vec<i128>,
) -> bool {
match ev {
DecodeEvent::Au(f, gap) => {
@@ -646,6 +673,17 @@ fn dispatch_event(
if recovery_flags.len() > IN_FLIGHT_CAP {
recovery_flags.pop_front();
}
// Phase-lock v3 sensor: the ARRIVAL stamp (reassembly completion, realtime) — the
// phase the host actually controls. The latch-based v2 sensor measured downstream
// of the decoder pipeline, which absorbed the host's actuation (on-glass 07-31).
arrival_stamps.push(if f.received_ns > 0 {
f.received_ns as i128
} else {
now_realtime_ns()
});
if arrival_stamps.len() > 256 {
arrival_stamps.remove(0);
}
pending_aus.push_back(f);
if pending_aus.len() > FRAME_PARK_CAP {
pending_aus.pop_front(); // sustained overflow — drop oldest, signal a keyframe request