Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 144 additions & 32 deletions docs/adrs/ADR-151-presence-field.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
# ADR-151: Multi-node field-model presence cog

**Status**: Proposed (WIP — still-person detection pending, see Limitations)
**Status**: Accepted — shipped as `presence-field` v0.1.1 (public registry,
difficulty `medium`). The breathing-band detector this ADR named as the exit
criterion **has landed, but ships advisory-only and does not gate presence**, so
still-person presence is still unproven. See Limitations.
**Date**: 2026-06-15
**Updated**: 2026-08-03
**Cog**: `presence-field`

## Context
Expand Down Expand Up @@ -32,56 +36,164 @@ residual** across ≥2 CSI nodes:
3. **Residual = body perturbation** — what remains after projection is the
occupant's effect on the field; presence energy is the residual maxed
across nodes.
4. **Threshold + hold** — a person is present when the residual exceeds
`thresh` × the empty-room floor; presence latches for `hold` seconds.

The cog **owns UDP 5006** (the ESP32 ingest port) and **relays** the
vitals/feature packets to a loopback port, so a vitals cog (Health
Monitor) can coexist on the same node feed without contending for :5006.
Health Monitor consumes `presence_detected` from this cog and keeps its
vitals gated behind it.
4. **Threshold + quorum + hold** — a node counts when its residual exceeds
`thresh` × its empty-room floor; presence fires when at least `quorum`
nodes count, and latches for `hold` seconds. `quorum` defaults to 1
(equivalent to max-over-nodes); `quorum 2` is the N-of-M gate that rejects
a single through-wall node.
5. **Calibration-quality guard** — after computing a baseline, the same
breathing-band scan is run over the *calibration* frames. A stable in-band
respiration peak means the room was not empty, and the operator is warned
to re-calibrate. See Limitations: this is a warning, not a refusal.

**Port ownership changed after this ADR was written.** As drafted, the cog
owned UDP 5006 and relayed vitals/feature packets to a loopback port itself
(`--relay`, still present in the code). Under ADR-104 P2 the
`cognitum-csi-relay` is the single binder on :5006 and performs packet-type
fan-out, and the agent injects `COG_CSI_BIND=127.0.0.1:<per-cog port>` so this
cog is a relay *consumer*. Bind precedence is `--bind` > `COG_CSI_BIND` >
`0.0.0.0:5006`; the default is retained for standalone/manual runs only. The
per-cog port is a deterministic hash of the cog id — `presence-field` is always
5259. The cog's own `--relay` fan-out is therefore redundant in the deployed
path, kept for standalone use.

Health Monitor consumes `presence_detected` from this cog (via
`--presence-file`) and keeps its vitals gated behind it.

## CLI

```
cog-presence-field [--bind 0.0.0.0:5006] [--relay 127.0.0.1:5007]
[--calibrate] [--baseline <path>] [--presence-file <path>]
[--thresh 4.0] [--modes 8] [--hold 5] [--interval 1]
[--window <ms>]
cog-presence-field [--bind 0.0.0.0:5006] [--relay 127.0.0.1:5106]
[--calibrate <secs>] [--baseline <path>] [--presence-file <path>]
[--thresh 4.0] [--quorum 1] [--modes 8] [--hold 5] [--interval 1]
[--window 20]
[--breath-secs 45] [--breath-snr 4.0] [--breath-stable 3]
```

`--calibrate` runs the learning window and then **falls through into
detection** in the same process; operators are expected to stop it once
`baseline saved` prints.

`--window` is a count of **frames** to average per node, not milliseconds as
this ADR originally documented.

The `--breath-*` knobs and `--window` are **not** exposed in `cog.toml`, so
they are unreachable through the seed API — CLI only.

## Output

```json
{
"presence_detected": false,
"presence_score": 1.7,
"node_count": 2,
"thresh": 4.0,
"source": "field-model-residual"
"score": 1.7,
"threshold": 4.0,
"nodes_over_thresh": 0,
"quorum": 1,
"per_node_ratio": {"node1": 1.7},
"breathing_advisory": false,
"breathing_bpm": 0.0,
"breathing_snr": 0.0,
"nodes": 1,
"method": "none",
"timestamp": 1785460879
}
```

## Limitations (why this is still WIP / draft)

- **Still-person detection is not solved yet.** The residual as computed is
a *motion* measure; a motionless body still fades toward the empty
baseline, so a person holding still reads below threshold (device-verified
on cognitum-8b40: seated person ~1.2-1.9×, below the 4.0 default; 14/14
reads `present=false` after ~15 s of stillness). Robust still-person
presence needs a **breathing-band detector** (isolate the ~0.1-0.5 Hz
respiration modulation as the liveness signal) — tracked as the remaining
work before this cog leaves draft.
`method` is `field-residual` when the residual/quorum test fired this cycle,
`hold` while latched, `none` otherwise. `score` is the best per-node residual
ratio. The `breathing_*` fields are **advisory** — see Limitations.

## Limitations

- **The breathing-band detector landed, but it does not gate presence.** The
work this ADR named as its exit criterion is implemented (`breathing_band()`,
a Goertzel scan of the 0.10-0.50 Hz band over the per-node residual history,
gated on SNR over the band's median floor plus `--breath-stable` consecutive
consistent estimates). It is wired as **advisory only** and deliberately
never sets `presence_detected`. Reason, recorded in the implementation: on
real data the *empty-room* respiration SNR can equal or exceed the
still-person SNR, because WiFi-CSI passes through walls and picks up family
or pets elsewhere in the home. An in-band peak alone therefore cannot
establish an occupant **in this room**, only corroborate a residual lock.
So the exit criterion as originally written is satisfied by the code but not
by the outcome, and this ADR's question is still open.
- **Still-person detection remains unproven, and the evidence disagrees with
itself.** Two measurements are on record from 2026-06-15 and they have never
been reconciled:
- this ADR's own device run on cognitum-8b40 — seated person ~1.2-1.9×,
below the 4.0 default; 14/14 `present=false` after ~15 s of stillness;
- the implementation's note — still person 10-68× the empty floor on every
node, with **3 nodes and a clean empty calibration**.

The most likely reconciliation is baseline quality and node count, not the
algorithm, but that is a hypothesis and has not been tested. Treat
still-person presence as unvalidated until a controlled run settles it.
The design-partner proof this ADR asked for (cognitum-4e61, 3 nodes) has
**not** been recorded anywhere.
- **A contaminated baseline is the dominant field failure mode.** `floor` is
the mean *in-sample* residual of the calibration frames, and the projected-out
eigenmodes are computed from those same frames. An occupant present during
calibration is therefore absorbed into both the divisor and the subtracted
subspace, and every later reading pins near 1.0 whether the room is occupied
or not — bench-verified 2026-06-15 (still-person ratio collapsed ~6× → ~2×).
Observed in the field 2026-07-31: a design partner's single-node run warned
`empty baseline shows a respiration band (16 bpm, snr 8.2)`, the baseline was
saved anyway, and a person *walking into the room* then peaked at score 4.6
against a 4.0 threshold. The calibration-quality guard warns and continues;
it should refuse, or the saved baseline should carry the contamination
verdict so a consumer can detect it.
- **Single-node `hd_distance` is unusable** — the single-node report carries
no real distance field (reads 0 always); the multi-node baseline is the
intended path, not `hd_distance`.
- **Calibration is launch-time `--calibrate` only.** A runtime
trigger + drift recalibration is a follow-up (see ADR-030).
- **Calibration is API-driven from seed v0.24.1; CLI-only below it.**
`POST /api/v1/apps/{id}/calibrate` shipped in seed **v0.24.1**
(2026-07-31, `e7e7489` / seed#294, closing seed#290) and is live on hardware
— verified on cognitum-8b40 running 0.24.2. Body `{"secs": N}`, 1-600; it
appends `--calibrate N` to the schema-driven argv and answers 202 echoing the
argv actually passed. Note it **kills any running instance first** rather than
refusing, and because `--calibrate` falls through into detection, the single
call both calibrates and leaves the cog running. Seeds below v0.24.1 — which
includes design-partner units still on 0.22.x — have no such endpoint and must
use the CLI flag. A runtime drift-recalibration trigger is still a follow-up
(see ADR-030).
- **`thresh` was unsettable through the seed API below v0.24.1.** It is declared
`type = "number"` in `cog.toml`, and the agent's argv builder matched only
`boolean | integer | float | string`, dropping anything else silently — so
`PUT /apps/presence-field/config` returned 200 and changed nothing. Fixed in
seed v0.24.1 (seed#289): both argv builders now match
`"integer" | "float" | "number"` and `"string" | "select"`. Still true on any
firmware, and worth knowing: a value **equal to the declared default** emits no
argv at all, so a config write can look like a no-op even for the keys that
work. The `args` array in the `/start` response is the reliable check.

## Consequences

- Health Monitor's safety gate is preserved end-to-end: no vitals are shown
while `presence_detected` is false.
- Unblocks the Vitality Call deployment **once** the breathing-band
detector lands and still-person presence is proven on their unit
(cognitum-4e61, 3 nodes), not just the cognitum-8b40 test seed.
- Presence fires reliably on **motion and arrival**. That is enough for the
building/occupancy use cases, and it is what this cog should be described as
delivering today.
- The Vitality Call deployment is **not** unblocked. It gates all vitals behind
`presence_detected` for a patient who rests still, which is precisely the
case that remains unproven. Landing the breathing-band detector did not
change this, because the detector is advisory.
- Store-only deployment is closer but not confirmed. cogs#38's first blocker —
calibration unreachable from the store — is addressed at the API level by
seed v0.24.1's `/calibrate` endpoint. Its other two items (Health Monitor
source selection, racy stop→start) and whether a UI surfaces the new endpoint
are unverified here. The fail-honest source/calibration contract is cogs#75.

## Follow-ups

- Settle still-person detection with a controlled run: clean calibration
(verified no contamination warning), 1 node vs 3 nodes, seated subject,
recorded ratios. Until then neither the 1.2-1.9× nor the 10-68× figure
should be quoted as the behaviour of this cog.
- Make the calibration-quality guard fail closed, or persist the verdict into
`baseline.json` so a running cog can report `calibration_suspect`.
- `thresh` now applies via the API on seed v0.24.1+ (seed#289 shipped), but it
is still declared `type = "number"` while every other key in this manifest
uses a type the pre-0.24.1 builder understood. Re-declaring it `float` would
make the cog work correctly on older seeds still in the field.
- Expose `--window` and the `--breath-*` knobs in `cog.toml` if they are meant
to be tunable per site; today they are unreachable through the API.
Loading