From f6a85fe7db79d7317af6b7c4e48aa2f75a61838e Mon Sep 17 00:00:00 2001 From: ruv Date: Sat, 23 May 2026 14:54:21 -0400 Subject: [PATCH] =?UTF-8?q?feat(adr-110):=20NodeSyncSnapshot.staleness=5Fm?= =?UTF-8?q?s=20=E2=80=94=20sync=20age=20in=20milliseconds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iter 34 — adds an optional `staleness_ms` field to the iter-23 NodeSyncSnapshot that exposes (Instant::now() - latest_sync_at). Dashboards / Prometheus exporters / UI badges can now decay sync freshness without re-deriving it from latest_sync_at on the host. Wire compatibility: new field is `#[serde(skip_serializing_if = "Option::is_none")]` so pre-iter-34 clients that strict-parse via serde + deny_unknown_fields are unaffected (default serde_json strategy is to ignore unknown fields anyway). Sensing-server changes: + NodeSyncSnapshot.staleness_ms: Option + sync_snapshot() populates it via latest_sync_at.elapsed().as_millis() + iter-24 serialization tests now check 8 contract fields, not 7 + new test `snapshot_staleness_ms_tracks_apply_time` pins latest_sync_at to a past Instant and asserts the snapshot reports ~750 ms staleness with ±500 ms tolerance for scheduler delay User-guide updates: + REST/WebSocket field table grows a `staleness_ms` row with the UI-rendering thresholds (fade at 5 s, drop at 9 s to match the firmware's VALID_WINDOW_MS-derived gate). Tests passing: sync_snapshot_helper_tests: 7/7 node_sync_snapshot_serialization_tests: 3/3 (8-field assertion green) Co-Authored-By: claude-flow --- docs/user-guide.md | 1 + .../wifi-densepose-sensing-server/src/main.rs | 35 +++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index 08750a87..b717e1bf 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -669,6 +669,7 @@ Field meanings: | `sequence` | u32 | High-water CSI sequence number stamped when this sync packet was emitted. Pair with the per-frame `sequence` field on incoming CSI to interpolate a mesh-aligned timestamp for any frame. | | `csi_fps_ema` | f64 | Per-node EMA of the observed CSI frame rate. Bench typical ≈ 10 Hz. | | `csi_fps_samples` | u32 | How many inter-frame deltas the EMA has seen. Treat values < 5 as "not yet trustworthy" and fall back to 20 Hz. | +| `staleness_ms` | u64 (optional) | Milliseconds since the host last received a sync packet from this node ([iter 34](adr/ADR-110-esp32-c6-firmware-extension.md)). Fade UI badges after 5 000 ms; treat ≥ 9 000 ms as the same condition that the firmware's `c6_sync_espnow_is_valid()` reports as `false`. | **When `sync` is omitted entirely**: the node isn't on the mesh (or hasn't heard a peer yet). Non-ESP32 paths — multi-BSSID router scan, diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index b6b4dd0a..dc88d0cb 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -325,6 +325,14 @@ struct NodeSyncSnapshot { /// How many CSI frames have contributed to `csi_fps_ema`. Clients can /// treat <5 as "not yet trustworthy" and fall back to 20 Hz. csi_fps_samples: u32, + /// ADR-110 iter 34 — milliseconds since the host last received a sync + /// packet from this node. Lets UI dashboards render sync-age decay + /// (badge fades after 5 s, drops off after the 9 s mesh_aligned_us + /// staleness gate). `None` only when the host never had Instant data + /// for this node, which shouldn't happen in normal flow but is + /// modeled defensively. + #[serde(skip_serializing_if = "Option::is_none")] + staleness_ms: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -584,6 +592,7 @@ impl NodeState { sequence: sync.sequence, csi_fps_ema: self.csi_fps_ema, csi_fps_samples: self.csi_fps_samples, + staleness_ms: self.latest_sync_at.map(|t| t.elapsed().as_millis() as u64), }) } @@ -5789,6 +5798,7 @@ mod node_sync_snapshot_serialization_tests { sequence: 20, csi_fps_ema: 10.0, csi_fps_samples: 47, + staleness_ms: Some(120), } } @@ -5807,9 +5817,10 @@ mod node_sync_snapshot_serialization_tests { fn sync_present_serializes_all_seven_fields() { let v = serde_json::to_value(sample_node(Some(sample_sync()))).unwrap(); let s = v.get("sync").expect("sync key must be present"); - // All seven contract fields named exactly as iter 23 documented. + // All eight contract fields named exactly as iter 23/34 documented. for key in ["offset_us", "is_leader", "is_valid", "smoothed", - "sequence", "csi_fps_ema", "csi_fps_samples"] { + "sequence", "csi_fps_ema", "csi_fps_samples", + "staleness_ms"] { assert!(s.get(key).is_some(), "sync object missing field `{}` — UI contract broken", key); } @@ -5942,6 +5953,26 @@ mod sync_snapshot_helper_tests { assert_eq!(ns.latest_sync_at, Some(t1)); // staleness clock reset } + #[test] + fn snapshot_staleness_ms_tracks_apply_time() { + // Iter 34: staleness_ms = (Instant::now() - latest_sync_at).as_millis(). + // We can't pass a synthetic "now" through sync_snapshot, but we can + // pin latest_sync_at to a past instant and assert the value lands + // in a plausible window. + let mut ns = NodeState::new(); + ns.latest_sync = Some(populated_sync(9)); + ns.latest_sync_at = std::time::Instant::now() + .checked_sub(std::time::Duration::from_millis(750)); + + let snap = ns.sync_snapshot().unwrap(); + let st = snap.staleness_ms.expect("staleness_ms must be present"); + // Should be approximately 750 ms — give a generous ±500 ms tolerance + // for any test-runner scheduling delay between checked_sub() and + // elapsed() within sync_snapshot. + assert!(st >= 740 && st < 1250, + "expected ~750 ms staleness, got {} ms", st); + } + #[test] fn mesh_aligned_us_honors_9s_staleness_gate() { // The receive helper stores latest_sync_at = Instant::now() each