13 KiB
Trust State & Engine Errors
If you've seen the sensing-server log a growing engine_error_count, or
noticed your deployment reads as "demoted": true on the status endpoint,
this page explains — from the actual code, not the design docs — what those
two things mean, what triggers them, where you can see them, and what your
real options are.
Everything below is grounded in
v2/crates/wifi-densepose-sensing-server/src/engine_bridge.rs,
v2/crates/wifi-densepose-sensing-server/src/main.rs,
v2/crates/wifi-densepose-engine/src/lib.rs, and
v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs.
Two different things, easy to conflate
The sensing-server runs a "governed trust cycle" every sensing tick
(StreamingEngine::process_cycle, driven by EngineBridge::observe_cycle in
engine_bridge.rs:193-223). Each cycle produces one of two outcomes, and
they are tracked completely separately:
- The cycle fails outright (
Result::Err(EngineError)) — nothing is published for that tick. This incrementsengine_error_count, a monotonically increasing counter. - The cycle succeeds but under a demoted privacy class
(
Result::Ok(TrustedOutput { demoted: true, .. })) — a belief is published, just at a more restricted privacy class than normal. This sets thedemotedflag, which is recomputed fresh on every successful cycle.
A deployment can have a high engine_error_count with demoted: false (lots
of failed cycles, but the ones that succeed are clean), or demoted: true
with engine_error_count: 0 (every cycle succeeds, but under a downgraded
privacy class), or both at once — which is what the issue reporter saw.
1. Exact conditions for each
Engine errors (engine_error_count)
engine_error_count increments only when
StreamingEngine::process_cycle returns Err(EngineError::Fusion(..))
(engine_bridge.rs:198-222). EngineError wraps
wifi_densepose_signal::ruvsense::multistatic::MultistaticError
(wifi-densepose-engine/src/lib.rs:54-69), which has exactly four variants
(multistatic.rs:36-56):
| Variant | Condition |
|---|---|
NoFrames |
No node frames were passed to fusion. In practice not reachable through the bridge: process_cycle_from_states returns None (not an error, not counted) before calling the engine at all if there are no frames (engine_bridge.rs:168-171). |
InsufficientNodes(n) |
Fewer than 2 nodes contributing in multistatic mode. |
TimestampMismatch { spread_us, guard_us } |
The spread between contributing nodes' frame timestamps exceeds the hard guard interval, default 60,000 µs (60 ms) (MultistaticConfig::default(), multistatic.rs:133). |
DimensionMismatch { node_idx, expected, got } |
A node's subcarrier count doesn't match the others. As of #1170 the live bridge canonicalizes every node onto a common 56-tone grid before fusion, so this is now rare on real hardware — see the comment on observe_cycle_counts_engine_errors in engine_bridge.rs. |
Regardless of cause, an error is rate-limited in the log to one
tracing::warn! line per 10 seconds (ENGINE_ERROR_WARN_INTERVAL,
engine_bridge.rs:50, 206-219) — errors are still counted every cycle, only
the log line is throttled, so a 20 Hz loop failing continuously won't flood
your log with 20 lines/second.
Trust demotion (demoted)
This is a separate mechanism from engine errors: it happens on cycles
that succeed, and it downgrades the privacy class the output is emitted
under, one step, rather than failing the cycle. From
wifi-densepose-engine/src/lib.rs:514-515:
let demoted = quality.forces_privacy_demotion() || array_contradiction || mesh_at_risk;
let effective_class = if demoted { demote_one(base_class) } else { base_class };
Three independent conditions can trigger it:
quality.forces_privacy_demotion()— true whenever the fusion quality record carries any non-emptycontradiction_flags(fusion_quality.rs:111-118). These are tolerated disagreements, distinct from a hard fusion failure:TimestampMismatch— spread within the hard guard but beyond the soft guard (default 20,000 µs / 20 ms,soft_guard_us,multistatic.rs:104-113) — i.e. loose-but-tolerable timing alignment.CalibrationIdMismatch— contributing frames disagree on which calibration epoch (baseline) they were captured under.PhaseAlignmentFailed,DriftProfileConflict,CoherenceDrop,GeometryInsufficient— raised upstream by the array coordinator / baseline drift checks.
array_contradiction— a separate array-level directional-fusion contradiction check.mesh_at_risk— the mesh is close to partitioning (mesh_guard.rs).
demote_one() (lib.rs:688-690) steps the privacy class exactly one notch
toward Restricted (it never jumps more than one step, and never relaxes a
class in the same cycle — proven by the forced_contradiction_never_relaxes_class
test). At PrivacyClass::Restricted, EngineBridge::suppress_raw_outputs()
becomes true and main.rs strips per-node raw amplitude vectors from the
published SensingUpdate (engine_bridge.rs:251-260).
Crucially, demoted is not sticky. It is overwritten on every
successful cycle to reflect that cycle's outcome
(self.demoted = trust.demoted;, engine_bridge.rs:203). If the
contradiction that caused demotion was transient, the very next clean cycle
reports demoted: false again with no action from you. If you see
demoted: true persistently, that means the underlying condition (usually
clock drift beyond the guard, or a geometry/calibration disagreement) is
itself persistent, not that something got "stuck."
2. Where this is exposed
Both GET /health/ready and GET /api/v1/status are wired to the same
handler (health_ready, main.rs:8128,8133) and return a trust block
(main.rs:4589-4606):
{
"status": "ready",
"trust": {
"last_witness": "…64 hex chars or null…",
"effective_class": "Anonymous | Restricted | …",
"demoted": false,
"recalibration_recommended": false,
"engine_error_count": 0,
"raw_outputs_suppressed": false
}
}
This is a real, currently-shipped diagnostic surface — but it is honestly limited. It tells you that errors are occurring and that the current class is demoted, and the total count, but not why for your specific run:
engine_error_countis a single running total. There is no breakdown by error type anywhere in the API or inEngineBridge's state — you cannot tell from/health/readywhether your 20,000 errors are 20,000TimestampMismatches or 20,000DimensionMismatches.demotedis a boolean with no accompanying list of whichContradictionFlags actually fired. The underlyingcontradiction_flagsvector exists inQualityScore(fusion_quality.rs:106) but is not surfaced over the wire anywhere we found.- There's no error history/timeline, and no per-node breakdown (which node is the one whose clock is drifting, for instance).
The closest thing to a real diagnostic today is the rate-limited log
line itself. Unlike the API, the log message includes the Display text
of the actual EngineError, which for TimestampMismatch and
DimensionMismatch includes the concrete numbers (e.g. "Timestamp spread 87000 us exceeds guard interval 60000 us", "Dimension mismatch: node 2 has 114 subcarriers, expected 56"). If you're trying to diagnose a specific
demotion/error episode today, grepping the sensing-server log for
"governed trust cycle failed" is the most concrete answer available — the
status endpoint alone will not tell you the underlying cause. Treat this as
the honest state of the diagnostics, not a missing feature we're pretending
exists.
3. Is a demoted / errored state permanent? Is there a reset?
demoted never needs resetting — as described above, it's recomputed
every successful cycle from that cycle's own contradiction/mesh state. There
is no persistence, no counter, no cooldown timer for it in the code.
engine_error_count has no reset mechanism at all. It is a plain u64
field on EngineBridge, initialized to 0 in EngineBridge::new
(engine_bridge.rs:111) and only ever incremented
(self.engine_error_count += 1;, line 207) — there is no method, admin
endpoint, or timer anywhere in the crate that decrements or clears it. The
only way to bring it back to zero is to restart the sensing-server
process, which constructs a brand-new EngineBridge. If your count is
growing and you want to confirm whether a fix actually worked, restart the
server and watch whether the count starts climbing again — there is
currently no lighter-weight way to "clear the counter" without a restart.
If demotion (or errors) are persistent rather than one-off, the documented, real fix for the most common cause — clock drift between nodes exceeding the fixed 60 ms hard guard — is an environment-variable override, not a restart or a wait:
WDP_GUARD_INTERVAL_US— directly overrides the hard guard (e.g.WDP_GUARD_INTERVAL_US=200000for a 200 ms guard). This is the escape hatch a real deployment (issue #1049) needed: WiFi/ESP-NOW-synced ESP32 nodes were measured drifting 10–150 ms, which the published 60 ms default could not absorb, causing every cycle to demote with "no escape hatch" (see the comment atmain.rs:8336-8339).WDP_SOFT_GUARD_US— optionally overrides the soft (tolerated-contradiction) guard, always clamped below the hard guard.WDP_TDM_SLOTS+WDP_TDM_SLOT_US— derive the guard from your actual TDM schedule instead of setting it directly.
See multistatic_guard_config_from_env / multistatic_guard_config_from
(main.rs:6791-6856) for the exact precedence rules (a direct
WDP_GUARD_INTERVAL_US always wins over the TDM-derived value).
4. Does a converted Hugging Face model explain this?
We could not find a code path connecting --convert-model to engine
errors or trust demotion — they appear to be entirely separate subsystems.
Saying this plainly rather than speculating:
--convert-model(main.rs:6976-7028,run_convert_model/load_or_convert_modelatmain.rs:6925-6974) converts a pose-model weights file — Hugging Facesafetensorsor ajsonlmanifest — into this project's own RVF binary container format, so it can be loaded via--model. This is entirely about which neural-network weights the pose estimator uses.engine_error_countanddemotedcome fromStreamingEngine::process_cycleinwifi-densepose-engine, which performs multistatic CSI sensor fusion — checking node count, per-node timestamp spread, and per-node subcarrier dimensions across your ESP32 nodes. This code path has no dependency on which pose model is loaded, andload_or_convert_model/run_convert_modelnever call intoengine_bridgeorStreamingEngineat all.
Because the code shows no coupling between the two, we are not going to invent one. Two possibilities that the code doesn't rule out, but also doesn't confirm, if you hit both symptoms together:
- Coincidence — the deployment that had trouble loading/using a converted model separately had a fusion-timing or node-count problem (e.g. the #1049-style clock-drift issue, or fewer than 2 active nodes), unrelated to the model conversion itself.
- A configuration change made alongside the model swap — e.g. changing node count, geometry, or guard settings at the same time as switching models — could produce both symptoms together without the model itself being the cause.
If you're hitting this, the actionable step from the code is to check
engine_error_count and the log line's error text (per §2 above)
independently of whatever model you have loaded — if the errors are
TimestampMismatch/DimensionMismatch/InsufficientNodes, the fix is on
the sensor-fusion side (§3), not the model side, regardless of which model
produced the report.
Quick reference
# Check current trust state
curl -s http://localhost:3000/api/v1/status | jq .trust
# Watch for the rate-limited error log line (most specific diagnostic today)
# — look for "governed trust cycle failed" in the sensing-server's stderr/log.
# If demotion/errors are persistent due to node clock drift, raise the guard:
WDP_GUARD_INTERVAL_US=200000 wifi-densepose-sensing-server ...
# The only way to reset engine_error_count is a process restart.
Source references for everything above:
v2/crates/wifi-densepose-sensing-server/src/engine_bridge.rsv2/crates/wifi-densepose-sensing-server/src/main.rs(searchtrust,health_ready,multistatic_guard_config_from,convert_model)v2/crates/wifi-densepose-engine/src/lib.rsv2/crates/wifi-densepose-engine/src/mesh_guard.rsv2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rsv2/crates/wifi-densepose-signal/src/ruvsense/fusion_quality.rs