# 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:

1. **The cycle fails outright** (`Result::Err(EngineError)`) — nothing is
   published for that tick. This increments `engine_error_count`, a
   monotonically increasing counter.
2. **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
   the `demoted` flag, 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`:

```rust
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-empty `contradiction_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`):

```json
{
  "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_count` is a single running total. There is **no breakdown by
  error type** anywhere in the API or in `EngineBridge`'s state — you cannot
  tell from `/health/ready` whether your 20,000 errors are 20,000
  `TimestampMismatch`es or 20,000 `DimensionMismatch`es.
- `demoted` is a boolean with no accompanying list of which
  `ContradictionFlag`s actually fired. The underlying `contradiction_flags`
  vector exists in `QualityScore` (`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=200000` for 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 at `main.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_model` at `main.rs:6925-6974`) converts a **pose-model
  weights file** — Hugging Face `safetensors` or a `jsonl` manifest — 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_count` and `demoted` come from `StreamingEngine::process_cycle`
  in `wifi-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, and `load_or_convert_model` /
  `run_convert_model` never call into `engine_bridge` or
  `StreamingEngine` at 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

```bash
# 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.rs`
- `v2/crates/wifi-densepose-sensing-server/src/main.rs` (search `trust`, `health_ready`, `multistatic_guard_config_from`, `convert_model`)
- `v2/crates/wifi-densepose-engine/src/lib.rs`
- `v2/crates/wifi-densepose-engine/src/mesh_guard.rs`
- `v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs`
- `v2/crates/wifi-densepose-signal/src/ruvsense/fusion_quality.rs`
