feat(adr-110): NodeSyncSnapshot.staleness_ms — sync age in milliseconds

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<u64>
  + 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 <ruv@ruv.net>
This commit is contained in:
ruv 2026-05-23 14:54:21 -04:00
parent bea7edee1f
commit f6a85fe7db
2 changed files with 34 additions and 2 deletions

View File

@ -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,

View File

@ -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<u64>,
}
#[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