From dbcbac1d43cca97e8870501588e90bc83abc6e0d Mon Sep 17 00:00:00 2001 From: ruv Date: Sat, 23 May 2026 14:15:28 -0400 Subject: [PATCH] feat(adr-110): Python SyncPacket API parity with Rust (apply_to_local + interpolation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iter 26 — closes the ABI gap between the Python and Rust SyncPacket decoders. Before this, Python could decode the wire but had no helpers to apply offsets or recover per-frame mesh time; any Python-side tooling (host scripts, replay analysers, notebooks) would have to re-implement the math from scratch and could drift from Rust silently. New methods on the Python SyncPacket dataclass: local_minus_epoch_us() -> int Signed local-vs-mesh offset. Matches Rust byte-for-byte. apply_to_local(local_at_frame_us: int) -> int offset = epoch_us - local_us return local_at_frame_us + offset Identity at local_at_frame_us == self.local_us returns epoch_us. mesh_aligned_us_for_sequence(frame_seq: int, fps_hz: float) -> int Sequence-based interpolation matching Rust's identical method. Includes u32 wraparound handling via masked-subtract — verified against Rust's iter 17 `mesh_aligned_for_sequence_handles_seq_wraparound`. 3 new Python tests (10 total in TestSyncPacketParser, all green in 0.24s): test_apply_to_local_recovers_epoch_at_sync_point Identity at the sync point. Also verifies local_minus_epoch_us() matches §A0.10's measured 1,163,565 µs bench number. test_apply_to_local_preserves_inter_frame_delta Frame arriving 5 s after the sync on the follower's local clock produces mesh time exactly 5 s after sync.epoch_us. test_mesh_aligned_us_for_sequence_matches_rust Cross-language parity with Rust's `end_to_end_sync_decode_then_frame_mesh_recovery` (iter 20): 100 frames after sync.sequence at 20 fps = sync.epoch_us + 5 s. Cross-checks via apply_to_local — both paths must agree. Test count after iter 26: Python TestSyncPacketParser: 10/10 (was 7/7) Rust sync_packet::tests: 15/15 Combined: 25 unit tests defending the SyncPacket contract across the two host language stacks. Co-Authored-By: claude-flow --- archive/v1/src/hardware/csi_extractor.py | 42 +++++++++++++++++++ .../v1/tests/unit/test_esp32_binary_parser.py | 32 ++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/archive/v1/src/hardware/csi_extractor.py b/archive/v1/src/hardware/csi_extractor.py index 97150bde..a040806c 100644 --- a/archive/v1/src/hardware/csi_extractor.py +++ b/archive/v1/src/hardware/csi_extractor.py @@ -284,6 +284,48 @@ class SyncPacket: sequence: int # u32 — high-water CSI sequence at emit time flags_raw: int + def local_minus_epoch_us(self) -> int: + """Signed local-vs-mesh clock offset in µs. + + Negative when this node's clock is behind the leader's (typical + for followers). Equal to ≈0 on the leader (modulo call-stack µs). + Matches Rust's `SyncPacket::local_minus_epoch_us` byte-for-byte. + """ + return self.local_us - self.epoch_us + + def apply_to_local(self, local_at_frame_us: int) -> int: + """Recover a mesh-aligned timestamp for any node-local µs snapshot. + + Math (see WITNESS-LOG-110 §A0.10 / §A0.12): + offset = epoch_us - local_us (signed; this packet) + mesh = local_at_frame_us + offset + + Identical contract to Rust's `SyncPacket::apply_to_local`. + Identity at `local_at_frame_us == self.local_us` returns `epoch_us`. + """ + offset = self.epoch_us - self.local_us + return local_at_frame_us + offset + + def mesh_aligned_us_for_sequence(self, frame_seq: int, fps_hz: float) -> int: + """ADR-110 §A0.12 — recover the mesh-aligned timestamp for an + in-flight CSI frame by its sequence number. + + Pairs the frame's sequence number against this sync packet's + sequence high-water + an assumed/measured CSI rate. Matches the + Rust implementation byte-for-byte at the integer level (Python + rounds via `int()` truncation; for the canonical bench values + this is exact). + """ + if fps_hz <= 0: + raise ValueError(f"fps_hz must be positive, got {fps_hz}") + # Wrap to handle u32 sequence overflow the same way Rust does. + dframes = (frame_seq - self.sequence) & 0xFFFFFFFF + if dframes >= 0x80000000: + dframes -= 0x1_0000_0000 + dus = int(dframes * 1_000_000 / fps_hz) + local_at = self.local_us + dus + return self.apply_to_local(local_at) + class SyncPacketParser: """Parser for ADR-110 §A0.12 32-byte sync packets. diff --git a/archive/v1/tests/unit/test_esp32_binary_parser.py b/archive/v1/tests/unit/test_esp32_binary_parser.py index 5a5280e9..a3c21add 100644 --- a/archive/v1/tests/unit/test_esp32_binary_parser.py +++ b/archive/v1/tests/unit/test_esp32_binary_parser.py @@ -365,6 +365,38 @@ class TestSyncPacketParser: assert sync_magic == SyncPacketParser.MAGIC assert csi_magic != sync_magic + def test_apply_to_local_recovers_epoch_at_sync_point(self): + """ADR-110 iter 26 — Python parity with Rust's `apply_to_local`. + At local_at_frame == sync.local_us, the recovered mesh time must + equal sync.epoch_us exactly.""" + pkt = SyncPacketParser.parse(build_sync_packet( + local_us=28_798_450, epoch_us=27_634_885, sequence=20, + )) + assert pkt.apply_to_local(pkt.local_us) == pkt.epoch_us + assert pkt.local_minus_epoch_us() == 1_163_565 # §A0.10's bench number + + def test_apply_to_local_preserves_inter_frame_delta(self): + """A frame arriving 5 s after the sync packet on the follower's + local clock must produce a mesh time exactly 5 s after sync.epoch_us.""" + pkt = SyncPacketParser.parse(build_sync_packet( + local_us=28_798_450, epoch_us=27_634_885, sequence=20, + )) + local_at_frame = pkt.local_us + 5_000_000 + assert pkt.apply_to_local(local_at_frame) == pkt.epoch_us + 5_000_000 + + def test_mesh_aligned_us_for_sequence_matches_rust(self): + """Cross-language parity with Rust's + `end_to_end_sync_decode_then_frame_mesh_recovery` test — + 100 frames after sync.sequence at 20 fps = sync.epoch_us + 5 s.""" + pkt = SyncPacketParser.parse(build_sync_packet( + local_us=28_798_450, epoch_us=27_634_885, sequence=20, + )) + mesh = pkt.mesh_aligned_us_for_sequence(120, 20.0) + assert mesh == pkt.epoch_us + 5_000_000 + # Both paths (apply_to_local + interpolation) must agree + local_at = pkt.local_us + 5_000_000 + assert pkt.apply_to_local(local_at) == mesh + def test_canonical_wire_bytes_match_rust_decoder(self): """ADR-110 iter 21 — cross-language wire-format conformance gate.