Design: Source-Identity Entities (correlate same source, not co-occurrence)¶
Status: Implemented (pairs with ADR 0013)
Date: 2026-06-04
Supersedes the Layer-2 clustering described in: cross-classifier-identity.md §4
Shipped in the correlator: Observation, intervals_overlap,
observations_same_source and union-find source grouping in
cluster_manager.py, with also_detected on the entity event and "Also
detected at this time" in the dashboard.
The problem this fixed¶
Entities used to cluster by emission wall-clock time regardless of species, so the page filled with "Insect", and opening one showed a Loon and a Nuthatch listed as evidence for the insect. The case that prompted the change:
Entity Insect (51%), "5 sensors", 90ms time span. Evidence: Insect, Music, Cricket, White-breasted Nuthatch (6–9s), Common Loon (13.5–16.5s) — all from mic-2.
Root cause¶
- Clips are 30 s. An outdoor 30 s recording is a whole soundscape — a continuous cricket, a nuthatch at 0:06, a loon at 0:13 — not one animal event.
- Layer-2 clustered by emission wall-clock time (
window_seconds, 3 s) regardless of species, and BirdNET + PANNs both finish a clip at ~the same instant, so everything from one clip collapsed into one cluster. - Winner-takes-label: the loudest, most continuous source (crickets) won, so the entity was labeled "Insect" and every other sound was dragged in as "evidence."
A second bug went with it: "# Sensors" counted observations rather than distinct sensors, which is why the example claims five sensors for observations that all came from mic-2. Both are fixed — the correlator now appends a sensor only if it is not already present, and the dashboard renders the distinct list.
Co-occurrence is not identity. A bird and a cricket genuinely overlap in time and are two animals. Pure time-clustering can never separate them.
Goals¶
- Correlate what is genuinely the same source across classifiers and
sensors (BirdNET
Crow+ PANNsCrowheard on 3 mics → one Crow). - Show co-occurring-but-different sounds as context, not as evidence ("also detected at this time"). The operator sees both.
- Distinct sources become distinct entities — the 30 s clip above yields Insect, White-breasted Nuthatch, Common Loon, each with its own evidence. Birds stop being buried.
- Sensor- and modality-agnostic. The model must already support the
coming
video.motion → high-level classification → bird classificationchain, so a bird seen on a camera and heard on a mic at the same instant collapses into one multi-modal entity. Nothing may be audio-specific. - "# Sensors" becomes the count of distinct sensors that observed the source (e.g. "3 mics + 1 camera").
Key enabler: absolute-time intervals¶
Every Observation already carries TemporalIntervals (ADR 0011), but they
are relative to the clip start. The whole design hinges on mapping them
to absolute wall-clock time:
Once intervals are absolute, "loon at 13.5–16.5 s of mic-2's clip" becomes
"loon at 12:30:44.8–12:30:47.8", which is directly comparable to an
observation from any other sensor or modality (another mic, or later a
camera). This is what makes overlap meaningful across the system and what
future-proofs it for video. The chain root timestamp is reachable now that
root_event_id is correct (ADR 0012).
The model¶
Observation (normalized, modality-agnostic)¶
Observation {
label: TaxonomyRef | call-type # what
confidence: float
abs_intervals: [(start: datetime, end: datetime)] # when (absolute)
sensor: str # mic-2, camera-1, …
modality: "audio" | "video"
root_event_id: str # the sensory-motion chain root
classifier: str # birdnet | panns | crow-tools | video-* …
}
Both audio.motion → audio.classified → species.detected and the future
video.motion → … → bird produce Observations of exactly this shape.
Entity = same-source group¶
Build an undirected graph over the Observations in a spatiotemporal neighborhood. Add an edge between two Observations iff both:
- Temporal: their
abs_intervalsoverlap (within a small tolerance, e.g. ±0.5 s to absorb classifier window jitter), and - Identity: their labels are compatible —
- same classifier + same species, or
- cross-classifier via an accepted/learned equivalence
(
equivalent_taxa()— BirdNETCrow≡ PANNs/m/04s8yn), or - same coarse taxon (configurable; e.g. both map to family/order), or
- cross-modal compatible (a camera "Bird" ↔ a mic bird species, via the same equivalence machinery).
Each connected component = one Entity. Its species is the
highest-confidence label in the component; its sensors are the distinct
sensor values; its modalities are the distinct modality values.
Observations that are temporally near the entity but not edge-connected (incompatible label) are not members — they belong to their own entities, and are surfaced as "also detected at this time."
"Also detected at this time/place" (context)¶
For an entity, the context list = observations from other entities whose
abs_intervals overlap the entity's span (optionally same/nearby sensors).
This is presentation only — it's a query over neighboring entities, not
membership. The operator sees the full picture without the label lying.
Decoupling identity from equivalence-learning (the chicken-and-egg)¶
Layer-3 auto-discovery learns equivalences from co-occurrence. If we only ever merged already-equivalent labels, nothing new would be learned. Resolution: keep co-occurrence tracking exactly as is for learning (observe which labels co-fire on shared roots → propose equivalences at Jaccard thresholds), but base the Entity on identity (overlap ∧ compatibility). The two are separate concerns:
- Learning asks "do these labels tend to co-occur?" → co-occurrence.
- Entity asks "is this the same source right now?" → identity.
Bootstrapping: before any cross-classifier equivalence is learned, BirdNET-Crow and PANNs-Crow form two entities for the same bird (slight over-count). Same-classifier-same-species across sensors still merges immediately (no equivalence needed). As equivalences accumulate, the cross-classifier merges kick in. This degradation is graceful and visible.
UI changes¶
- Entity detail: split the current "Evidence" into Evidence (same-source observations, across classifiers/sensors/modalities) and Also detected at this time (overlapping neighbors).
- # Sensors = count of distinct
sensorvalues in the entity (and show modality breakdown when video lands). - Entities list naturally diversifies (real species surface instead of a wall of "Insect").
Open questions (decide during implementation)¶
- Overlap tolerance (±0.5 s?) and whether sensors at different locations should require spatial proximity once GPS/site differs.
- Coarse-taxon merging: do we merge two different warbler species that overlap, or keep them distinct? Start strict (exact species / accepted equivalence only) and loosen if it fragments.
- Clip length: 30 s makes each clip a soundscape. Independently of this redesign, shorter motion clips (or event sub-segmentation) would reduce how much lands in one neighborhood. Tracked separately.
- Cross-modal correlation window: audio and video of the same animal won't have identical intervals; define the tolerance when video lands.
Migration / compatibility¶
- Additive: existing Entities stay; new clustering applies going forward.
- The correlator already receives intervals + correct
root_event_id(ADR 0012), so no new data is required — only absolute-time mapping + the graph build. - No schema change strictly required for the core;
EntityEvidencemay gain arelation: "evidence" | "co_occurring"flag for the UI split.