Skip to content

perf(nodes): make anomaly detector z-score O(1) incremental#1626

Open
Ansh-Karnwal wants to merge 2 commits into
rocketride-org:developfrom
Ansh-Karnwal:perf/anomaly-detector-incremental-zscore
Open

perf(nodes): make anomaly detector z-score O(1) incremental#1626
Ansh-Karnwal wants to merge 2 commits into
rocketride-org:developfrom
Ansh-Karnwal:perf/anomaly-detector-incremental-zscore

Conversation

@Ansh-Karnwal

@Ansh-Karnwal Ansh-Karnwal commented Jul 18, 2026

Copy link
Copy Markdown

Problem

The anomaly_detector node scores every value flowing through a pipeline against a sliding window. On the default z_score method (also the fallback for any unrecognized method), each detect() call:

  1. copies the whole window under the lock (window = list(self._window)), and
  2. runs a two-pass scan over that copy: mean = sum(window)/n, then variance = sum((x-mean)**2 for x in window)/n.

So processing M values with window size W is O(M·W). The window is configurable up to 10000, and the detector is a per-pipeline shared singleton whose threading.Lock also serializes that O(W) snapshot across every instance sharing it. There is no functional reason for it to be O(W): mean and variance over a sliding window can be maintained in O(1) amortized.

Change

Keep assumed-mean shifted running sums over the current window and adjust them in O(1) as values enter and leave:

  • _z_sx = sum(x - K) and _z_sxx = sum((x - K)**2) for a shift constant K.
  • On append below capacity: add the new term. At capacity: add the new term and subtract the evicted one (captured before the deque(maxlen) drops it).
  • mean = K + sx/n, variance = sxx/n - (sx/n)**2.

The scoring arithmetic now runs outside the lock (it is a pure function of scalars captured under the lock), so the z_score critical section drops from O(W) to O(1) as well.

Numerical stability

The naive sum_sq/n - mean**2 identity suffers catastrophic cancellation on large-magnitude, low-spread data (file sizes, epoch-second timestamps ~1e9), where it can return garbage or a negative variance. Two things prevent that here:

  • Shifting by K keeps the summed quantities small, and K is re-centered on the current mean at each rebuild so it stays near the data even on drifting streams.
  • Every window_size values the sums are rebuilt from scratch over the current window (one O(W) pass per window turnover, so still O(1) amortized). This also clears any per-op rounding residue. A max(variance, 0.0) clamp keeps the tiny negative residue on a zero-variance window from reaching sqrt.

Output is unchanged

score, severity, is_anomalous, and the details string all reproduce the previous two-pass result. IQR and rolling_avg keep their original snapshot-and-scan path untouched, as does the non-finite-input early return.

Benchmark

Pure-Python, single machine, median of repeated runs, with the two implementations asserted to produce byte-identical rounded output across the whole 200k/30k-value workload before any timing:

window size baseline ns/op incremental ns/op speedup
100 8,563 1,769 4.8x
10000 576,994 1,712 337x

The incremental version holds ~1.7 us/op regardless of window size; the baseline grows linearly with the window.

Tests

New file nodes/test/test_anomaly_detector_zscore.py pins the incremental output to an independent from-scratch two-pass oracle, asserting exact 4-decimal result-dict equality (no epsilon) across:

  • baseline z_score cases that had no coverage before (insufficient data, a hand-checked golden window, severity boundaries),
  • zero-variance detection on identical windows including 1e9 magnitude (the cancellation canary) and after eviction,
  • equivalence corpora at window sizes 10 / 100 / 10000 with evictions, spikes, negatives, and a 1e6 cancellation trap,
  • eviction correctness (window holds exactly the last W values),
  • non-finite inputs as a complete no-op on window and aggregates,
  • the recompute boundary crossed many times,
  • 8 threads x 2000 calls on one shared detector, with a final consistency check against the oracle.

At 1e9 magnitude, four decimals on the mean is below float64 resolution for any algorithm, so those streams assert the numeric invariants (finite, non-negative variance, no sqrt domain error) rather than a byte-identical string; this is called out in the test docstring.

The existing test_anomaly_detector.py is unchanged and still passes.

Docs

No change. services.json (fields, defaults, ranges) and the README behavior description are preserved exactly, and the generated params block is unaffected, so there is no observable-contract change to document.

Verified locally vs CI

Ran the full suite (including the slow marker), ruff check, and ruff format in a clean venv; all green. The benchmark and its correctness gate were run locally. The engine build and the full CI matrix are left to CI.

Summary by CodeRabbit

  • Bug Fixes
    • Reworked Z-score anomaly detection to use incremental, thread-safe window aggregates for improved numerical stability across large magnitudes and zero-variance scenarios.
    • Ensured consistent output formatting and correct severity behavior on warning/critical boundaries.
    • Added explicit handling for non-finite inputs so they do not affect rolling-window state.
  • Tests
    • Added extensive Z-score coverage, including exact result validation, multiple window patterns, eviction correctness, recomputation-boundary scenarios, and concurrent detection.

The z_score method recomputed mean and standard deviation from scratch on
every value: it copied the whole window under the lock and ran a two-pass
scan over it. That made scoring O(window_size) per value, and with a window
configurable up to 10000 on a per-pipeline shared detector it was a real
throughput hotspot.

Maintain the mean and variance incrementally instead. The detector now keeps
assumed-mean shifted running sums over the window and adjusts them in O(1) as
values enter and leave, so scoring is O(1) amortized. A shift constant plus a
periodic full rebuild (once per window turnover) keeps the running sums
accurate on large-magnitude, low-spread streams where the naive
sum_sq/n - mean**2 identity loses precision to cancellation.

Output is unchanged: score, severity, is_anomalous and the details string all
match the previous two-pass result. The z_score path also no longer copies the
window under the lock, which shortens the critical section. IQR and rolling_avg
are untouched.

A new test file pins the incremental output to an independent two-pass oracle
(exact 4-decimal equality) across evictions, spikes, large magnitudes, the
recompute boundary, non-finite no-ops, and concurrent access. The existing
tests are unchanged and still pass.
@github-actions github-actions Bot added the module:nodes Python pipeline nodes label Jul 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor
🤖 Internal: Discord sync marker

Auto-managed by the Discord notification workflow. Stores the linked Discord message ID and forum thread ID. Do not edit or delete.

@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e5bfbc5e-985f-4f20-b248-a4eab53b27d8

📥 Commits

Reviewing files that changed from the base of the PR and between 8c7dd37 and 4ce4ce5.

📒 Files selected for processing (1)
  • nodes/test/test_anomaly_detector_zscore.py

📝 Walkthrough

Walkthrough

AnomalyDetector now computes Z-scores using thread-safe incremental aggregates with periodic rebuilding. Existing IQR and rolling-average snapshot handling remains, while non-finite inputs are ignored and comprehensive tests cover accuracy, eviction, stability, recomputation, and concurrency.

Changes

Z-Score detection

Layer / File(s) Summary
Incremental Z-score aggregation
nodes/src/nodes/anomaly_detector/detector.py
Adds shifted running aggregates, eviction-aware updates, periodic recomputation, variance clamping, and formatted Z-score results.
Detection flow integration
nodes/src/nodes/anomaly_detector/detector.py
Routes Z-score and unrecognized methods through locked incremental updates while preserving snapshot-based IQR and rolling-average processing.
Z-score behavioral validation
nodes/test/test_anomaly_detector_zscore.py
Adds oracle comparisons and tests for thresholds, zero variance, evictions, large magnitudes, non-finite inputs, recomputation boundaries, and concurrent detection.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant AnomalyDetector
  participant ZScoreAggregates
  participant ZScoreFormatter
  Caller->>AnomalyDetector: detect(value, method)
  AnomalyDetector->>ZScoreAggregates: update aggregates under lock
  ZScoreAggregates-->>AnomalyDetector: aggregate snapshot
  AnomalyDetector->>ZScoreFormatter: format score outside lock
  ZScoreFormatter-->>Caller: anomaly result
Loading

Possibly related PRs

Suggested reviewers: jmaionchi, stepmikhaylov, rod-christensen

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ 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 clearly and concisely summarizes the main change: making anomaly detector z-score detection incremental and O(1).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 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 `@nodes/test/test_anomaly_detector_zscore.py`:
- Around line 124-147: Add concise PEP 257-compliant docstrings to every newly
added public test_* method in the affected anomaly-detector test class,
including test_insufficient_data_below_two, test_golden_window_mean5_std2, and
test_severity_boundaries and the additionally referenced test ranges. Keep the
existing test logic unchanged.
- Around line 227-254: Strengthen TestLargeMagnitudeStability._assert_sane to
validate parsed mean, deviation, and z-score against a two-pass oracle, using
explicit tolerances appropriate for ~1e9 values. Retain the existing finite and
non-negative standard-deviation checks, and ensure both
test_large_base_small_spread and test_monotonic_timestamp_like exercise the
accuracy comparisons.
- Around line 349-356: Remove the explicit det._recompute_z_aggregates() call
and its lock wrapper from the post-concurrency verification in the anomaly
detector test. Compare det.detect(123.4) directly with the _ref_z_score result
derived from snapshot, preserving the existing assertion so corrupted
incremental aggregate state remains observable.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1492dce9-1eba-413a-ad60-9dea13c02f9d

📥 Commits

Reviewing files that changed from the base of the PR and between 4a09a43 and 8c7dd37.

📒 Files selected for processing (2)
  • nodes/src/nodes/anomaly_detector/detector.py
  • nodes/test/test_anomaly_detector_zscore.py

Comment thread nodes/test/test_anomaly_detector_zscore.py
Comment thread nodes/test/test_anomaly_detector_zscore.py Outdated
Comment thread nodes/test/test_anomaly_detector_zscore.py Outdated
…7 docstrings

Address review on rocketride-org#1626:

- Large-magnitude streams (~1e9) now compare parsed mean, std, and z against
  the two-pass oracle within explicit tolerances instead of only asserting
  finiteness. At 1e9 four decimals are well within float64 resolution, so the
  previous sanity-only checks would have accepted arbitrarily inaccurate values.
- Concurrency consistency check no longer rebuilds the aggregates before the
  final comparison, so an update lost to a race stays observable.
- Add docstrings to every public test method and the module helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

module:nodes Python pipeline nodes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant