ruvnet / ruvnet/RuView

Multi-node calibration silently produces false occupancy

Open
#1,938 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug
Dominant language
Rust
Stars
94.5k
Forks
12.5k
Avg merge
21h 27m
Merged PRs (30d)
43

Description

Multi-node calibration silently produces false occupancy

Repo: ruvnet/RuView (sensing-server) · Severity: High · Measured: 2026-09-14/15, 3–4 ESP32-C6 nodes

Symptom

A room calibrated with four nodes reports an occupant in an empty room, in every frame.

capture bound nodes eigenvalues residual threshold empty room occupied
1 4 0 19.96 present 12/12
2 4 0 18.90 present 1162/1162
3 1 (node 13) 5 3.65 absent 604/604 present 616/616

Root cause

maybe_feed_calibration_frame admits every bound radio into the field model:

// The field model remains single-link, but every radio in the room
// binding may contribute bounded observations on the frozen grid.

The model is single-link: one baseline, one set of amplitude offsets. Averaging several
radios' offsets into it flattens the eigenstructure — hence baseline_eigenvalue_count: 0
— leaving only a scalar energy threshold, inflated by the cross-radio spread (19.96 vs 3.65).

person_count_at (main.rs:2232) already documents the scoring half:

// A single-link model may score only the source node it was calibrated
// against. Applying one node's baseline to a different radio creates
// deterministic false occupancy from hardware-specific amplitude offsets.
let bound_source_node_id = (self.calibration_source_node_ids.len() == 1) …

…but it only engages when exactly one node is bound. With more, it falls through to the
shared mixed history — so the guard never fires in the case its own comment describes.

calibration_start accepts a multi-node binding the model cannot honour.

Fix

Restrict the baseline write, not the admission: only the grid-bound radio calls
maybe_feed_calibration; every bound radio still registers as a contributor and is scored
from the bound radio's history. This mirrors the narrowing bootstrap_baseline::store
already applies (vec![binding.source_node_id] as the frozen model source).

Caution for reviewers: gating the whole feed path instead deadlocks the capture —
calibration_source_nodes_missing requires every bound node to contribute before finalize.
Measured: 43,120 frames over 51 minutes stuck in collecting, missing=[12,13,14], no
error surfaced. The accompanying PR carries a regression test for exactly this.


Fixed in #1936.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with calibration_start and maybe_feed_calibration_frame, then read person_count_at in main.rs around line 2232 and compare the source narrowing in bootstrap_baseline::store. The accompanying PR contains the regression test; done means multi-node calibration keeps all bound radios as contributors while writing the single-link baseline only from the grid-bound radio and avoids false occupancy.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
backend, embedded-iot
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Clearly specified
Newbie friendliness
20/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.