Skip to content

One platform_alert_id can group temporally disjoint detection episodes (up to 119 min apart) #276

Description

@Chouffe

Context

While working on the false-positive context view of /localize/:sequenceId (#PR), an alert surfaced where the smoke object spanned 12 frames and the false-positive object spanned 13 — reading, at a glance, like one continuous event whose smoke might have persisted into the later frames. It isn't. The two lanes are 119 minutes apart.

That prompted an audit of how lanes under one platform_alert_id relate temporally. This is distinct from #266 (below-threshold sibling drops) and #262 (cross-alert sibling lanes): those are about boxes that should have become lanes. This is about lanes that are correctly formed but grouped into one alert despite belonging to different detection episodes.

Audit run 2026-08-04 against the local stack.

Finding

Of 39 alerts with more than one lane (47 lane pairs), 12 pairs across 9 alerts share zero timestamps.

Ten of those pairs have genuinely disjoint time windows:

platform_alert_id lane A (frames) lane B (frames) gap
55664 57 (12) 58 (13) 119 min
55664 57 (12) 59 (11) 119 min
54283 200 (14) 202 (8) 44 min
54283 201 (8) 202 (8) 36 min
53770 249 (23) 250 (7) 26 min
53084 178 (24) 179 (6) 13 min
54666 100 (19) 101 (11) 12 min
54283 200 (14) 201 (8) 6 min
55077 111 (19) 112 (10) 4 min
53797 243 (6) 244 (6) 0 min (adjacent)

The remaining two (53387 lanes 224/225, 53758 lanes 253/254) have overlapping windows but still zero shared timestamps — interleaved frames rather than a shared frame set.

Worked example, alert 55664:

Lane Classification Window Frames
57 smoke 06:58:30 → 07:01:31 12
58 false positive (cloud) 09:01:00 → 09:08:01 13
59 unsure 09:01:00 → 09:08:30 11

Lanes 58 and 59 share 10 timestamps with each other — a normal object split of one episode. Lane 57 shares none with either. It is a separate detection event, two hours earlier, wearing the same platform_alert_id.

Why it matters

  1. The collocated screens are built around "one alert = one moment." /classify/:id and /localize/:id both render an alert's lanes side by side against a shared frame union. When lanes are two hours apart, that union is a timeline with a two-hour hole, and the object rows describe things that were never on screen together.

  2. It invites a wrong annotation. The false-positive context view exists to answer "is that plume already accounted for?" before someone adds a duplicate object. If the FP object is from a different episode, that question has no meaningful answer — and the reasonable-looking inference ("the smoke object probably continues into those later frames") is false.

  3. It distorts alert-level state. The missed-smoke flag, the submit gate, and the "N of M objects localized" badge are all alert-scoped. Two episodes in one alert means one submit decision covering two unrelated events.

What I could not determine

Whether the grouping originates upstream (the alert API assigning one platform_alert_id across a 2-hour relaxation window — note SEQUENCE_RELAXATION_SECONDS = 7200 in object_clustering.py, which is exactly the 119-minute case) or in our import. object_split.py sets platform_alert_id to the parent alert sequence id, so if two episodes arrive under one alert sequence, our split faithfully preserves that. Worth checking against the alert API before deciding where a fix belongs.

Reproduction

WITH lane AS (
  SELECT s.platform_alert_id, s.id AS seq_id,
         min(d.recorded_at) AS t0, max(d.recorded_at) AS t1, count(*) AS n
  FROM sequences s JOIN detections d ON d.sequence_id = s.id GROUP BY 1, 2)
SELECT a.platform_alert_id, a.seq_id AS lane_a, a.n AS n_a, b.seq_id AS lane_b, b.n AS n_b,
  CASE WHEN a.t1 < b.t0 OR b.t1 < a.t0 THEN 'disjoint' ELSE 'overlapping' END AS window_rel,
  CASE WHEN a.t1 < b.t0 THEN round(extract(epoch FROM (b.t0 - a.t1)) / 60)
       WHEN b.t1 < a.t0 THEN round(extract(epoch FROM (a.t0 - b.t1)) / 60) END AS gap_min
FROM lane a
JOIN lane b ON a.platform_alert_id = b.platform_alert_id AND a.seq_id < b.seq_id
WHERE (SELECT count(*) FROM detections d1 JOIN detections d2 ON d1.recorded_at = d2.recorded_at
       WHERE d1.sequence_id = a.seq_id AND d2.sequence_id = b.seq_id) = 0
ORDER BY gap_min DESC NULLS LAST;

Possible directions

Not proposing one — the upstream question above should settle it first.

  1. Split on the temporal gap at import. If lanes within one alert sequence are separated by more than some threshold, emit them under distinct platform_alert_ids. Cleanest for the UI, but invents alert identity we don't own.
  2. Leave grouping alone; make the screens honest about it. Show an explicit break in the frame union / timeline when consecutive frames are far apart, so a two-hour hole reads as a hole rather than as adjacency. Cheaper, no data changes, and useful regardless of what upstream does.
  3. Confirm it's an alert-API artifact and do nothing here. Viable if the platform genuinely intends these as one alert.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions