Skip to content

feat: add adaptive zone anomaly baselines using Welford's algorithm#91

Merged
Devnil434 merged 1 commit into
Devnil434:mainfrom
dilshadalikhan2004:feat/adaptive-zone-baseline
May 19, 2026
Merged

feat: add adaptive zone anomaly baselines using Welford's algorithm#91
Devnil434 merged 1 commit into
Devnil434:mainfrom
dilshadalikhan2004:feat/adaptive-zone-baseline

Conversation

@dilshadalikhan2004
Copy link
Copy Markdown
Contributor

@dilshadalikhan2004 dilshadalikhan2004 commented May 19, 2026

Summary

Implements adaptive per-zone dwell anomaly baselines using Welford’s online algorithm with Redis-backed persistence.

Features Added

  • Online running statistics for dwell times:

    • mean
    • variance
    • standard deviation
  • Adaptive anomaly detection:

    • dwell > mean + 2.5 * std
  • Redis persistence under:

    • zone:{name}:stats
  • Zone statistics API endpoint:

    • GET /zones/{name}/stats
  • Anomaly flag propagation into tracking events

  • Unit tests validating statistical correctness and persistence

Technical Notes

  • Uses numerically stable Welford updates
  • No batch recomputation
  • Statistics persist across restarts
  • Cold-start protection prevents early false positives

Test Status

  • ✅ All 15 new tests pass
  • ✅ 58 existing tests remain green

Known Pre-existing Failures (Unrelated to This PR)

These failures already exist on the base branch and are unrelated to the adaptive baseline implementation:

  • test_tracker.py

    • 11 failures caused by:

      • SyntaxError: 'return' outside function
      • services/tracking/tracker.py line 234
  • test_scene_graph.py

    • missing ultralytics dependency/environment setup

Fixes #72

Summary by CodeRabbit

Release Notes

  • New Features

    • Added new endpoint to retrieve per-zone baseline statistics including count, mean, variance, and standard deviation.
    • Implemented anomaly detection for tracked objects: objects exceeding adaptive zone dwell-time thresholds are now flagged as anomalous.
    • Enhanced health check endpoint to report Redis connectivity status.
  • Tests

    • Added comprehensive test suite for zone baseline statistics and anomaly detection functionality.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 19, 2026

Warning

Rate limit exceeded

@dilshadalikhan2004 has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 24 minutes and 26 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: dc9ea539-29b8-43d7-b2d7-61de851fefab

📥 Commits

Reviewing files that changed from the base of the PR and between dbf8e21 and 69fc14d.

📒 Files selected for processing (8)
  • apps/backend/main.py
  • apps/backend/routes/zones.py
  • libs/config/settings.py
  • libs/schemas/tracking.py
  • services/memory/baseline.py
  • services/memory/memory.py
  • services/memory/requirements.txt
  • tests/test_baseline.py
📝 Walkthrough

Walkthrough

This PR implements adaptive per-zone anomaly detection using Welford's online algorithm. The ZoneBaseline service learns dwell time distributions per zone from Redis-persisted statistics, flags outliers when dwell exceeds mean + 2.5 × std, integrates into the MemoryService LOST event handler, and exposes zone statistics via a new GET /zones/{name}/stats API endpoint. The tracking schema now carries an is_anomalous flag end-to-end.

Changes

Adaptive Per-Zone Anomaly Baseline

Layer / File(s) Summary
Welford Baseline Service Foundation
services/memory/baseline.py
Implements WelfordStats dataclass with computed variance and std properties. ZoneBaseline class validates zone names, maintains lazy-loaded in-memory statistics, updates Welford accumulators on update(), gates anomaly detection on min sample count and non-zero std in is_anomalous(), persists to Redis using zone:{name}:stats key with configurable TTL, and exposes serializable stats via get_stats().
Tracking Schema: Anomalous Flag
libs/schemas/tracking.py
Adds is_anomalous: bool field to TrackedObject to indicate when track dwell exceeds the adaptive zone baseline.
MemoryService Anomaly Detection Integration
services/memory/memory.py
Imports ZoneBaseline. Updates _handle_lost() to return (global_id, zone_anomalous) tuple; per-zone anomaly is computed by checking each zone in event.zones_present via ZoneBaseline.is_anomalous() before updating baselines with current dwell. _update_record() accepts and persists anomalous parameter to Redis track record. _append_event() accepts anomalous boolean and includes it in the event log JSON. Note: Contains unresolved merge-conflict markers in final implementation.
Zone Statistics API Endpoint
apps/backend/routes/zones.py, apps/backend/main.py
Defines ZoneStatsResponse schema (zone, count, mean, variance, std, m2). Implements _get_redis() dependency raising HTTPException(503) on missing Redis. Adds get_zone_stats(name, redis) handler with zone name validation, retrieves stats via ZoneBaseline.get_stats(). Backend main wiring includes zones router, updates Redis connectivity to explicitly ping and capture status on app.state.redis, and updates /health endpoint to report Redis-connected vs degraded/unavailable status.
Baseline Service Unit Tests
tests/test_baseline.py
Pytest fixtures for fake Redis and ZoneBaseline instance. Tests validate Welford mean/std/variance/count correctness against statistics module reference. Tests verify Redis persistence across instances, in-memory cache reuse, zone isolation. Anomaly detection tests cover min-count suppression before threshold, zero-std suppression, detection-before-update ordering, outlier flagging, normal value pass-through, empty stats behavior, and zone name validation (path traversal and special characters rejection).

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Handler as MemoryService<br/>handle_lifecycle_event
  participant Lost as MemoryService<br/>_handle_lost
  participant Baseline as ZoneBaseline
  participant Redis
  participant LogWriter as _append_event<br/>& _update_record
  
  Client->>Handler: LOST frame event
  Handler->>Lost: LOST TrackEvent
  Lost->>Lost: zone_anomalous = False
  loop each zone in event.zones_present
    Lost->>Baseline: is_anomalous(dwell)
    Baseline->>Redis: load zone:{name}:stats
    Baseline-->>Lost: anomaly boolean
    Lost->>Baseline: update(dwell)
    Baseline->>Redis: save updated stats
  end
  Lost->>Redis: query track record
  Lost->>LogWriter: _update_record with anomalous flag
  Lost->>LogWriter: _append_event with anomalous flag
  LogWriter->>Redis: persist anomalous in record & event log
  Lost-->>Handler: (global_id, zone_anomalous)
  Handler->>Client: event log updated
Loading
sequenceDiagram
  participant Client
  participant API as GET /zones/{name}/stats
  participant Dependency as _get_redis
  participant Baseline as ZoneBaseline
  participant Redis
  
  Client->>API: GET /zones/lobby/stats
  API->>Dependency: _get_redis(request)
  Dependency->>Redis: retrieve app.state.redis
  alt Redis available
    Dependency-->>API: redis_client
  else Redis unavailable
    Dependency-->>API: HTTPException(503)
  end
  API->>Baseline: ZoneBaseline(redis, "lobby")
  Baseline->>Redis: load zone:lobby:stats
  Baseline-->>API: stats loaded
  API->>Baseline: get_stats()
  Baseline-->>API: {count, mean, variance, std, m2}
  API-->>Client: ZoneStatsResponse
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

The changes span a new service module (Welford algorithm with Redis persistence), integration into an existing core service (MemoryService), new API endpoint, schema update, and comprehensive tests. The anomaly detection logic requires careful review of the Welford implementation, merge-conflict markers in memory.py, state mutation ordering (detect-before-update), and Redis persistence semantics. The variety of implementation roles across baseline service, integration, API, and tests, combined with stateful computation and persistence, demands heterogeneous reasoning per area.

Poem

🐰 A baseline learns the rhythms of each zone,
Welford's dance of mean and variance shown,
Redis keeps the stats when daylight fades,
Anomalies flag when dwell cascades,
Outliers cannot hide—the stats now trace what's true!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR contains merge conflict markers in services/memory/memory.py that indicate unresolved conflicts, preventing clean merge and introducing potential corruption to the codebase. Resolve merge conflict markers (<<<<<<< HEAD / >>>>>>> ...) in services/memory/memory.py before merging to ensure code integrity.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely describes the main change: implementing adaptive zone anomaly baselines using Welford's algorithm, which is the core feature across all modified files.
Linked Issues check ✅ Passed The PR implements all core requirements from issue #72: Welford algorithm [baseline.py], Redis persistence [baseline.py], anomaly detection [memory.py], API endpoint [zones.py], anomaly flag propagation [tracking.py], and comprehensive unit tests [test_baseline.py].
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@dilshadalikhan2004 dilshadalikhan2004 force-pushed the feat/adaptive-zone-baseline branch from dbf8e21 to 288c929 Compare May 19, 2026 05:45
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
services/memory/memory.py (1)

215-236: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Resolve both unresolved merge-conflict blocks in services/memory/memory.py.

The merge-conflict markers at lines 217–236 and 252–435 leave this module unparsable, blocking Python syntax validation. When resolving, retain the anomalous field in both _update_record() and _append_event() to preserve anomaly detection across the LOST event path.

Merge-conflict markers verified present at:

  • Lines 217–236 in _update_record()
  • Lines 252–435 in _append_event()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/memory/memory.py` around lines 215 - 236, Remove the Git conflict
markers and merge the two variants so the file parses: in _update_record() keep
the compact update and include the "anomalous" key (set from the anomalous
parameter) along with state, last_seen_frame, last_seen_ms, dwell_time_seconds,
and zones_present; likewise resolve the conflict in _append_event() to ensure
the event-record update path for LOST events preserves and writes the
"anomalous" field into the stored record (use the same key name "anomalous"),
and remove any leftover <<<<<<<, =======, and >>>>>>> markers so the module is
syntactically valid.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/backend/main.py`:
- Around line 26-35: Resolve the merge conflict by removing the Git conflict
markers and creating a single FastAPI app instance: import zones_router from
apps.backend.routes.zones, keep the FastAPI() instantiation as the single `app`,
and ensure `app.state.redis` is initialized (using the redis client currently
aliased as `redis_sync` or whichever Redis client you prefer) before including
`zones_router` so routes that use `request.app.state.redis` work; register the
router (`app.include_router(zones_router)`) and remove the
duplicate/contradictory blocks so there is only one `app` definition and no
leftover conflict markers.

In `@services/memory/memory.py`:
- Around line 186-190: The loop over event.zones_present can process the same
zone multiple times per event, skewing Redis baselines; before creating
ZoneBaseline and calling baseline.is_anomalous / baseline.update, deduplicate
the zones in event.zones_present (while preserving order if needed) and iterate
over the unique list instead (e.g., use an order-preserving dedupe like
dict.fromkeys or a set if order doesn't matter) so each zone's baseline is
learned/checked exactly once and zone_anomalous is set appropriately.
- Around line 197-198: _handle_dead currently calls _update_record(event,
TrackState.DEAD.value) which uses the default anomalous=False and clears any
prior anomaly flag; change the call to preserve the anomaly bit by passing the
existing anomalous state (e.g., _update_record(event, TrackState.DEAD.value,
anomalous=event.anomalous)) or, if the TrackLifecycleEvent lacks that field,
fetch the stored record via _get_record(track_id) and pass its anomalous value
into _update_record so DEAD transitions do not clear the anomalous flag.

---

Outside diff comments:
In `@services/memory/memory.py`:
- Around line 215-236: Remove the Git conflict markers and merge the two
variants so the file parses: in _update_record() keep the compact update and
include the "anomalous" key (set from the anomalous parameter) along with state,
last_seen_frame, last_seen_ms, dwell_time_seconds, and zones_present; likewise
resolve the conflict in _append_event() to ensure the event-record update path
for LOST events preserves and writes the "anomalous" field into the stored
record (use the same key name "anomalous"), and remove any leftover <<<<<<<,
=======, and >>>>>>> markers so the module is syntactically valid.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c7a95ee3-4ae6-4471-a8e2-ed962e33d512

📥 Commits

Reviewing files that changed from the base of the PR and between 07bb02a and dbf8e21.

📒 Files selected for processing (6)
  • apps/backend/main.py
  • apps/backend/routes/zones.py
  • libs/schemas/tracking.py
  • services/memory/baseline.py
  • services/memory/memory.py
  • tests/test_baseline.py

Comment thread apps/backend/main.py
Comment on lines +26 to +35
<<<<<<< HEAD
import redis as redis_sync
from fastapi import FastAPI, Query
from fastapi.responses import Response
from prometheus_client import generate_latest
=======
from apps.backend.routes.zones import router as zones_router

app = FastAPI()
>>>>>>> 4d99088 (feat: adaptive anomaly baseline per zone using Welford's algorithm)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

cd apps/backend && wc -l main.py && head -100 main.py

Repository: Devnil434/Eagle

Length of output: 3019


🏁 Script executed:

cd apps/backend && sed -n '35,90p' main.py | cat -n

Repository: Devnil434/Eagle

Length of output: 2270


🏁 Script executed:

cd apps/backend && find . -name "zones.py" -type f && cat -n apps/backend/routes/zones.py

Repository: Devnil434/Eagle

Length of output: 156


🏁 Script executed:

cd apps/backend && cat -n routes/zones.py

Repository: Devnil434/Eagle

Length of output: 1679


Resolve this merge conflict and keep a single live app instance.

These conflict markers at lines 26–35 and 41–83 make the file invalid Python. The unresolved split also decides whether zones_router is included and whether app.state.redis is initialized—both required because zones_router depends on request.app.state.redis (see apps/backend/routes/zones.py:26).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/main.py` around lines 26 - 35, Resolve the merge conflict by
removing the Git conflict markers and creating a single FastAPI app instance:
import zones_router from apps.backend.routes.zones, keep the FastAPI()
instantiation as the single `app`, and ensure `app.state.redis` is initialized
(using the redis client currently aliased as `redis_sync` or whichever Redis
client you prefer) before including `zones_router` so routes that use
`request.app.state.redis` work; register the router
(`app.include_router(zones_router)`) and remove the duplicate/contradictory
blocks so there is only one `app` definition and no leftover conflict markers.

Comment thread services/memory/memory.py
Comment on lines +186 to +190
for zone in event.zones_present:
baseline = ZoneBaseline(self._r, zone)
if baseline.is_anomalous(event.dwell_time_seconds):
zone_anomalous = True
baseline.update(event.dwell_time_seconds)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Deduplicate zones_present before learning.

If the same zone appears twice in one LOST event, this loop records the same dwell twice and permanently skews that zone's Redis baseline.

♻️ Proposed fix
-        for zone in event.zones_present:
+        for zone in dict.fromkeys(event.zones_present):
             baseline = ZoneBaseline(self._r, zone)
             if baseline.is_anomalous(event.dwell_time_seconds):
                 zone_anomalous = True
             baseline.update(event.dwell_time_seconds)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/memory/memory.py` around lines 186 - 190, The loop over
event.zones_present can process the same zone multiple times per event, skewing
Redis baselines; before creating ZoneBaseline and calling baseline.is_anomalous
/ baseline.update, deduplicate the zones in event.zones_present (while
preserving order if needed) and iterate over the unique list instead (e.g., use
an order-preserving dedupe like dict.fromkeys or a set if order doesn't matter)
so each zone's baseline is learned/checked exactly once and zone_anomalous is
set appropriately.

Comment thread services/memory/memory.py
Comment on lines 197 to 198
def _handle_dead(self, event: TrackLifecycleEvent) -> None:
self._update_record(event, TrackState.DEAD.value)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve the anomaly bit on DEAD transitions.

_handle_dead() uses the default anomalous=False, so a track flagged on LOST is rewritten as non-anomalous when it later expires.

💡 Suggested shape after resolving the conflict
-    def _update_record(self, event: TrackLifecycleEvent, state: str, anomalous: bool = False) -> None:
+    def _update_record(
+        self,
+        event: TrackLifecycleEvent,
+        state: str,
+        anomalous: bool | None = None,
+    ) -> None:
         record = self._load_record(event.camera_id, event.track_id) or {}
         record.update({
             "state":              state,
             "last_seen_frame":    event.frame_id,
             "last_seen_ms":       event.timestamp_ms,
             "dwell_time_seconds": event.dwell_time_seconds,
             "zones_present":      event.zones_present,
-            "anomalous":          anomalous,
+            "anomalous":          record.get("anomalous", False) if anomalous is None else anomalous,
         })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/memory/memory.py` around lines 197 - 198, _handle_dead currently
calls _update_record(event, TrackState.DEAD.value) which uses the default
anomalous=False and clears any prior anomaly flag; change the call to preserve
the anomaly bit by passing the existing anomalous state (e.g.,
_update_record(event, TrackState.DEAD.value, anomalous=event.anomalous)) or, if
the TrackLifecycleEvent lacks that field, fetch the stored record via
_get_record(track_id) and pass its anomalous value into _update_record so DEAD
transitions do not clear the anomalous flag.

@dilshadalikhan2004 dilshadalikhan2004 force-pushed the feat/adaptive-zone-baseline branch 4 times, most recently from 8b54c59 to 3ad8991 Compare May 19, 2026 06:01
@dilshadalikhan2004
Copy link
Copy Markdown
Contributor Author

Note on CI failures

The test_memory.py failures are pre-existing in upstream main and are
unrelated to this PR. The import chain:

services/memory/init.py → pipeline.py → action_classifier.py
→ settings.lingering_threshold_sec

...references settings fields that don't exist in libs/config/settings.py
on upstream main. This was broken before this PR was opened.

This PR's own tests (tests/test_baseline.py) — 15/15 pass.
All other existing tests that were passing before remain green.

@dilshadalikhan2004 dilshadalikhan2004 force-pushed the feat/adaptive-zone-baseline branch from 3ad8991 to 69fc14d Compare May 19, 2026 06:16
@dilshadalikhan2004
Copy link
Copy Markdown
Contributor Author

Update: All CI checks now passing

Previously flagged test_memory.py failures have been resolved as part of this PR:

  • Added missing action classifier settings fields to libs/config/settings.py
    (lingering_threshold_sec, movement_threshold_px, near_keypad_dist_px,
    keypad_center_x, keypad_center_y)
  • Added camera_id optional parameter to MemoryStore.get_sequence(),
    get_zone_entry_count(), and expire_track() to match test expectations
  • Added missing dependencies to services/memory/requirements.txt
    (numpy, pydantic-settings, confluent-kafka)

Final Test Results

  • 15/15 tests/test_baseline.py — new tests, all pass
  • 15/15 tests/test_memory.py — previously failing, now all pass
  • All other passing tests remain green

Closes #72

@Devnil434 Devnil434 merged commit e3f57d0 into Devnil434:main May 19, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Issue] — Implement anomaly baseline per zone

2 participants