tsanomaly 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. tsanomaly/__init__.py +37 -0
  2. tsanomaly/calibrate/__init__.py +0 -0
  3. tsanomaly/calibrate/conformal.py +104 -0
  4. tsanomaly/calibrate/evt.py +119 -0
  5. tsanomaly/core/__init__.py +0 -0
  6. tsanomaly/core/clock.py +36 -0
  7. tsanomaly/core/config.py +127 -0
  8. tsanomaly/core/determinism.py +22 -0
  9. tsanomaly/core/errors.py +28 -0
  10. tsanomaly/core/result.py +91 -0
  11. tsanomaly/core/types.py +240 -0
  12. tsanomaly/detect/__init__.py +0 -0
  13. tsanomaly/detect/bocpd.py +94 -0
  14. tsanomaly/detect/cusum.py +53 -0
  15. tsanomaly/detect/drift.py +53 -0
  16. tsanomaly/detect/episodes.py +129 -0
  17. tsanomaly/detector.py +745 -0
  18. tsanomaly/eval/__init__.py +0 -0
  19. tsanomaly/eval/backtest.py +48 -0
  20. tsanomaly/eval/metrics.py +47 -0
  21. tsanomaly/eval/synth.py +77 -0
  22. tsanomaly/events/__init__.py +3 -0
  23. tsanomaly/events/store.py +43 -0
  24. tsanomaly/explain/__init__.py +0 -0
  25. tsanomaly/explain/card.py +132 -0
  26. tsanomaly/explain/provenance.py +44 -0
  27. tsanomaly/feedback/__init__.py +3 -0
  28. tsanomaly/feedback/labels.py +47 -0
  29. tsanomaly/incidents/__init__.py +3 -0
  30. tsanomaly/incidents/grouping.py +179 -0
  31. tsanomaly/io/__init__.py +0 -0
  32. tsanomaly/io/dimensions.py +23 -0
  33. tsanomaly/io/frames.py +135 -0
  34. tsanomaly/models/__init__.py +0 -0
  35. tsanomaly/models/base.py +36 -0
  36. tsanomaly/models/guard.py +78 -0
  37. tsanomaly/models/robust_baseline.py +211 -0
  38. tsanomaly/models/seasonal_naive.py +96 -0
  39. tsanomaly/models/selection.py +51 -0
  40. tsanomaly/preprocess/__init__.py +0 -0
  41. tsanomaly/preprocess/detrend.py +33 -0
  42. tsanomaly/preprocess/gaps.py +72 -0
  43. tsanomaly/preprocess/hampel.py +32 -0
  44. tsanomaly/preprocess/transform.py +45 -0
  45. tsanomaly/profile/__init__.py +0 -0
  46. tsanomaly/profile/classifier.py +78 -0
  47. tsanomaly/py.typed +0 -0
  48. tsanomaly/registry/__init__.py +76 -0
  49. tsanomaly/relate/__init__.py +0 -0
  50. tsanomaly/relate/coanomaly.py +60 -0
  51. tsanomaly/relate/efftests.py +24 -0
  52. tsanomaly/score/__init__.py +0 -0
  53. tsanomaly/score/duration.py +45 -0
  54. tsanomaly/score/scoring.py +50 -0
  55. tsanomaly/season/__init__.py +3 -0
  56. tsanomaly/season/acf_verify.py +77 -0
  57. tsanomaly/season/calendar.py +31 -0
  58. tsanomaly/season/detector.py +105 -0
  59. tsanomaly/season/indices.py +46 -0
  60. tsanomaly/season/lombscargle.py +42 -0
  61. tsanomaly/sinks.py +52 -0
  62. tsanomaly/storage/__init__.py +4 -0
  63. tsanomaly/storage/memory.py +31 -0
  64. tsanomaly/storage/sqlite.py +70 -0
  65. tsanomaly/storage/state.py +64 -0
  66. tsanomaly-0.1.0.dist-info/METADATA +155 -0
  67. tsanomaly-0.1.0.dist-info/RECORD +70 -0
  68. tsanomaly-0.1.0.dist-info/WHEEL +5 -0
  69. tsanomaly-0.1.0.dist-info/licenses/LICENSE +202 -0
  70. tsanomaly-0.1.0.dist-info/top_level.txt +1 -0
tsanomaly/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ """A Python library for autonomous, explainable, real-time anomaly detection on time-series metrics.
2
+
3
+ Usage guide: docs/usage.md. Architecture: docs/architecture.md.
4
+ """
5
+
6
+ from tsanomaly.core.config import DetectorConfig
7
+ from tsanomaly.core.result import DetectionResult
8
+ from tsanomaly.core.types import (
9
+ Anomaly,
10
+ Envelope,
11
+ Episode,
12
+ EpisodeKind,
13
+ MetricId,
14
+ MetricType,
15
+ Profile,
16
+ ScoreBreakdown,
17
+ SeasonalComponent,
18
+ )
19
+ from tsanomaly.detector import Detector
20
+
21
+ __version__ = "0.1.0"
22
+
23
+ __all__ = [
24
+ "Detector",
25
+ "DetectorConfig",
26
+ "DetectionResult",
27
+ "MetricId",
28
+ "MetricType",
29
+ "Profile",
30
+ "SeasonalComponent",
31
+ "Envelope",
32
+ "Episode",
33
+ "EpisodeKind",
34
+ "ScoreBreakdown",
35
+ "Anomaly",
36
+ "__version__",
37
+ ]
File without changes
@@ -0,0 +1,104 @@
1
+ """Adaptive Conformal Inference calibrator.
2
+
3
+ Wraps any BaselineModel's (expected, scale) into an interval whose realized
4
+ coverage tracks the target regardless of model misspecification:
5
+ ``alpha_{t+1} = alpha_t + gamma * (alpha - err_t)`` (Gibbs & Candes 2021).
6
+ Distribution-free: the interval half-width is a quantile of recent
7
+ standardized nonconformity scores, not a Gaussian z-value.
8
+
9
+ Freezes during open episodes so an ongoing anomaly cannot widen its own envelope.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections import deque
15
+ from typing import Any
16
+
17
+ import numpy as np
18
+
19
+
20
+ class ACICalibrator:
21
+ def __init__(
22
+ self,
23
+ target_coverage: float = 0.995,
24
+ gamma: float = 0.005,
25
+ reservoir: int = 512,
26
+ ) -> None:
27
+ self.alpha_target = 1.0 - target_coverage
28
+ self.gamma = gamma
29
+ self.alpha_t = self.alpha_target
30
+ self.scores: deque[float] = deque(maxlen=reservoir)
31
+ self.frozen = False
32
+ self.n_observed = 0
33
+ self.n_outside = 0
34
+
35
+ def _quantile(self) -> float:
36
+ n = len(self.scores)
37
+ if n < 30:
38
+ return 4.0 # conservative cold-start half-width (in scale units)
39
+ level = min(0.9999, (1.0 - self.alpha_t) * (n + 1) / n)
40
+ return float(np.quantile(np.array(self.scores), level))
41
+
42
+ def halfwidth(self, scale: float) -> float:
43
+ return self._quantile() * scale
44
+
45
+ def observe(self, nonconformity: float, outside: bool, learn_score: bool = True) -> None:
46
+ """Feed one realized coverage outcome; no-op while frozen.
47
+
48
+ The alpha_t coverage-feedback update runs on EVERY sample -- including
49
+ ones inside an open episode. Freezing it there silences the very misses
50
+ ACI must react to (an episode's opening points are exactly its coverage
51
+ errors) and alpha_t drifts upward on pure successes, silently narrowing
52
+ the envelope. Only the nonconformity reservoir is gated by
53
+ ``learn_score`` so anomalous magnitudes never widen future envelopes.
54
+ """
55
+ if self.frozen:
56
+ return
57
+ err = 1.0 if outside else 0.0
58
+ self.alpha_t = float(
59
+ np.clip(
60
+ self.alpha_t + self.gamma * (self.alpha_target - err),
61
+ self.alpha_target / 20,
62
+ 0.5,
63
+ )
64
+ )
65
+ if learn_score:
66
+ # Reservoir AND the coverage diagnostic count only non-episode
67
+ # points: the diagnostic answers "is the envelope honest on normal
68
+ # data" -- a true incident's points are anomalies, not calibration
69
+ # misses. (alpha_t above still reacts to every sample.)
70
+ self.scores.append(float(nonconformity))
71
+ self.n_observed += 1
72
+ self.n_outside += int(outside)
73
+
74
+ def warm(self, nonconformities: np.ndarray) -> None:
75
+ for s in nonconformities[np.isfinite(nonconformities)]:
76
+ self.scores.append(float(s))
77
+ self.alpha_t = self.alpha_target
78
+
79
+ def coverage_error(self) -> float | None:
80
+ """Realized-minus-target coverage over everything observed (diagnostics)."""
81
+ if self.n_observed < 50:
82
+ return None
83
+ realized = 1.0 - self.n_outside / self.n_observed
84
+ return realized - (1.0 - self.alpha_target)
85
+
86
+ def state_dict(self) -> dict[str, Any]:
87
+ return {
88
+ "alpha_target": self.alpha_target,
89
+ "gamma": self.gamma,
90
+ "alpha_t": self.alpha_t,
91
+ "scores": [round(s, 6) for s in self.scores],
92
+ "reservoir": self.scores.maxlen,
93
+ "n_observed": self.n_observed,
94
+ "n_outside": self.n_outside,
95
+ }
96
+
97
+ @classmethod
98
+ def from_state(cls, d: dict[str, Any]) -> "ACICalibrator":
99
+ c = cls(1.0 - d["alpha_target"], d["gamma"], d["reservoir"])
100
+ c.alpha_t = d["alpha_t"]
101
+ c.scores.extend(d["scores"])
102
+ c.n_observed = d["n_observed"]
103
+ c.n_outside = d["n_outside"]
104
+ return c
@@ -0,0 +1,119 @@
1
+ """Streaming extreme-value tail model.
2
+
3
+ Generalized Pareto fit over exceedances of a high quantile of standardized
4
+ residual magnitudes -> calibrated tail probabilities for "how far outside the
5
+ envelope" (heavy tails honest, unlike Gaussian). Empirical fallback below the
6
+ threshold or before enough exceedances accumulate, so p_mag works from the
7
+ first episode.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections import deque
13
+ from typing import Any
14
+
15
+ import numpy as np
16
+ from scipy import stats
17
+
18
+ _REFIT_EVERY = 100
19
+ _MIN_EXCEEDANCES = 30
20
+ _THRESHOLD_Q = 0.95
21
+
22
+
23
+ class TailModel:
24
+ def __init__(self, buf_size: int = 2048, exc_size: int = 256) -> None:
25
+ self.all_s: deque[float] = deque(maxlen=buf_size)
26
+ self.exceedances: deque[float] = deque(maxlen=exc_size)
27
+ self.u: float = float("inf") # POT threshold
28
+ self.n_total = 0
29
+ self.n_exc = 0
30
+ self.gpd: tuple[float, float] | None = None # (shape c, scale)
31
+ self._since_refit = 0
32
+ self.frozen = False
33
+
34
+ def warm(self, magnitudes: np.ndarray) -> None:
35
+ for s in magnitudes[np.isfinite(magnitudes)]:
36
+ self.all_s.append(float(s))
37
+ self.n_total = len(self.all_s)
38
+ self._refit_threshold()
39
+ for s in self.all_s:
40
+ if s > self.u:
41
+ self.exceedances.append(s)
42
+ self.n_exc = len(self.exceedances)
43
+ self._fit_gpd()
44
+
45
+ def observe(self, magnitude: float) -> None:
46
+ if self.frozen or not np.isfinite(magnitude):
47
+ return
48
+ self.all_s.append(float(magnitude))
49
+ self.n_total += 1
50
+ if magnitude > self.u:
51
+ self.exceedances.append(float(magnitude))
52
+ self.n_exc += 1
53
+ self._since_refit += 1
54
+ if self._since_refit >= _REFIT_EVERY:
55
+ self._refit_threshold()
56
+ self._fit_gpd()
57
+ self._since_refit = 0
58
+
59
+ def _refit_threshold(self) -> None:
60
+ if len(self.all_s) >= 50:
61
+ self.u = float(np.quantile(np.array(self.all_s), _THRESHOLD_Q))
62
+
63
+ def _fit_gpd(self) -> None:
64
+ exc = np.array([e - self.u for e in self.exceedances if e > self.u])
65
+ if len(exc) < _MIN_EXCEEDANCES:
66
+ # too few exceedances to estimate a shape: exponential tail (shape 0)
67
+ # still decays beyond the observed record
68
+ if len(exc) >= 5:
69
+ self.gpd = (0.0, float(max(np.mean(exc), 1e-9)))
70
+ else:
71
+ self.gpd = None
72
+ return
73
+ try:
74
+ c, loc, scale = stats.genpareto.fit(exc, floc=0.0)
75
+ # shape capped at 0.5: heavier tails are infinite-variance
76
+ self.gpd = (float(np.clip(c, -0.5, 0.5)), float(max(scale, 1e-9)))
77
+ except Exception:
78
+ self.gpd = None
79
+
80
+ def tail_p(self, magnitude: float) -> float:
81
+ """P(|z| >= magnitude) under the learned residual-magnitude distribution."""
82
+ if not np.isfinite(magnitude) or magnitude <= 0:
83
+ return 1.0
84
+ n = max(1, self.n_total)
85
+ p_u = self.n_exc / n if self.n_exc else 1.0 / (n + 1)
86
+ if magnitude <= self.u or not np.isfinite(self.u):
87
+ if len(self.all_s) >= 30:
88
+ arr = np.array(self.all_s)
89
+ return float(
90
+ min(1.0, (np.sum(arr >= magnitude) + 1) / (len(arr) + 1))
91
+ )
92
+ # cold start: normal tail as a stopgap
93
+ return float(min(1.0, 2 * stats.norm.sf(magnitude)))
94
+ if self.gpd is not None:
95
+ c, scale = self.gpd
96
+ return float(max(1e-12, p_u * stats.genpareto.sf(magnitude - self.u, c, scale=scale)))
97
+ exc = np.array(self.exceedances)
98
+ return float(max(1e-12, p_u * (np.sum(exc >= magnitude) + 1) / (len(exc) + 1)))
99
+
100
+ def state_dict(self) -> dict[str, Any]:
101
+ return {
102
+ "all_s": [round(s, 5) for s in self.all_s],
103
+ "exceedances": [round(s, 5) for s in self.exceedances],
104
+ "u": self.u if np.isfinite(self.u) else None,
105
+ "n_total": self.n_total,
106
+ "n_exc": self.n_exc,
107
+ "gpd": list(self.gpd) if self.gpd else None,
108
+ }
109
+
110
+ @classmethod
111
+ def from_state(cls, d: dict[str, Any]) -> "TailModel":
112
+ t = cls()
113
+ t.all_s.extend(d["all_s"])
114
+ t.exceedances.extend(d["exceedances"])
115
+ t.u = d["u"] if d["u"] is not None else float("inf")
116
+ t.n_total = d["n_total"]
117
+ t.n_exc = d["n_exc"]
118
+ t.gpd = tuple(d["gpd"]) if d["gpd"] else None
119
+ return t
File without changes
@@ -0,0 +1,36 @@
1
+ """Injectable clock.
2
+
3
+ Statistical code never reads the wall clock; time comes from data. The clock
4
+ exists only for operational concerns (stall detection, schedules) and for
5
+ deterministic tests.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from typing import Protocol
12
+
13
+
14
+ class Clock(Protocol):
15
+ def now_ns(self) -> int: ...
16
+
17
+
18
+ class SystemClock:
19
+ def now_ns(self) -> int:
20
+ return time.time_ns()
21
+
22
+
23
+ class ManualClock:
24
+ """Test clock advanced explicitly."""
25
+
26
+ def __init__(self, start_ns: int = 0) -> None:
27
+ self._now = start_ns
28
+
29
+ def now_ns(self) -> int:
30
+ return self._now
31
+
32
+ def advance(self, delta_ns: int) -> None:
33
+ self._now += delta_ns
34
+
35
+ def set(self, ts_ns: int) -> None:
36
+ self._now = ts_ns
@@ -0,0 +1,127 @@
1
+ """Configuration tree.
2
+
3
+ Three layers: built-in defaults -> tsanomaly.toml -> per-call overrides.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import json
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field
14
+
15
+ from tsanomaly.core.errors import ConfigError
16
+
17
+
18
+ class _Section(BaseModel):
19
+ model_config = ConfigDict(extra="forbid", frozen=True)
20
+
21
+
22
+ class EnvelopeConfig(_Section):
23
+ target_coverage: float = Field(0.995, gt=0.5, lt=1.0) # fraction of normal points inside the envelope
24
+ aci_gamma: float = Field(0.02, gt=0.0, lt=0.5) # ACI step size
25
+ reservoir: int = Field(512, ge=32) # conformal residual reservoir
26
+
27
+
28
+ class EpisodeConfig(_Section):
29
+ close_after: int = Field(3, ge=1) # consecutive in-envelope samples to close
30
+ merge_gap: int = Field(3, ge=0) # samples between episodes that merge
31
+ max_points: int = Field(5000, ge=10) # bounded episode point buffer
32
+
33
+
34
+ class SeasonConfig(_Section):
35
+ max_components: int = Field(2, ge=0) # max simultaneous seasonal components
36
+ strength_floor: float = Field(0.3, ge=0.0, le=1.0) # Hyndman F_s floor
37
+ n_candidates: int = Field(8, ge=1) # top-K periodogram peaks
38
+ null_shuffles: int = Field(100, ge=10) # block-permutation null size
39
+ null_quantile: float = Field(0.99, gt=0.5, lt=1.0)
40
+ min_cycles: float = Field(2.0, ge=1.5) # data must span >= this many periods
41
+
42
+
43
+ class PreprocessConfig(_Section):
44
+ hampel_k: float = Field(6.0, gt=0.0) # rolling median +/- k*MAD outlier threshold
45
+ hampel_window: int = Field(25, ge=5)
46
+ large_gap_intervals: int = Field(10, ge=2) # gap > this*interval splits segments
47
+
48
+
49
+ class ScoreConfig(_Section):
50
+ min_alert_score: float = Field(70.0, ge=0.0, le=100.0)
51
+ log10_full_scale: float = Field(6.0, gt=0.0) # p=1e-6 -> score 100
52
+ noise_halfsat: float = Field(5.0, gt=0.0) # episodes/week at which shrink lambda=1
53
+ # Episodes below this score are the envelope's *expected* exceedances (a
54
+ # calibrated 99.5% envelope emits ~0.5% of points by design); they are
55
+ # tallied in diagnostics, never emitted as anomalies. Not a silent cap:
56
+ # the count is always reported.
57
+ report_floor: float = Field(25.0, ge=0.0, le=100.0)
58
+
59
+
60
+ class RegimeConfig(_Section):
61
+ min_samples: int = Field(24, ge=4) # stationary samples before a shift becomes a re-anchor
62
+ cooldown_samples: int = Field(64, ge=0) # post-re-anchor samples with warm-up-damped scores
63
+
64
+
65
+ class WarmupConfig(_Section):
66
+ min_points: int = Field(64, ge=8) # minimum points before scores are undamped
67
+ damp: float = Field(0.5, gt=0.0, le=1.0)
68
+
69
+
70
+ class StreamConfig(_Section):
71
+ grace_intervals: int = Field(2, ge=0) # out-of-order tolerance, in sampling intervals
72
+
73
+
74
+ class DetectorConfig(_Section):
75
+ budget: str = "cheap" # cheap | balanced | thorough (gates model registry)
76
+ envelope: EnvelopeConfig = EnvelopeConfig()
77
+ episodes: EpisodeConfig = EpisodeConfig()
78
+ season: SeasonConfig = SeasonConfig()
79
+ preprocess: PreprocessConfig = PreprocessConfig()
80
+ score: ScoreConfig = ScoreConfig()
81
+ regime: RegimeConfig = RegimeConfig()
82
+ warmup: WarmupConfig = WarmupConfig()
83
+ stream: StreamConfig = StreamConfig()
84
+
85
+ def config_hash(self) -> str:
86
+ blob = json.dumps(self.model_dump(), sort_keys=True, separators=(",", ":"))
87
+ return hashlib.sha256(blob.encode()).hexdigest()[:16]
88
+
89
+ @classmethod
90
+ def load(
91
+ cls,
92
+ toml_path: str | Path | None = None,
93
+ overrides: dict[str, Any] | None = None,
94
+ ) -> "DetectorConfig":
95
+ """Resolve defaults -> toml file -> overrides."""
96
+ data: dict[str, Any] = {}
97
+ if toml_path is not None:
98
+ try:
99
+ import tomllib # Python 3.11+
100
+ except ImportError: # pragma: no cover - Python 3.10
101
+ try:
102
+ import tomli as tomllib # type: ignore[no-redef]
103
+ except ImportError as exc:
104
+ raise ConfigError(
105
+ "reading TOML config on Python 3.10 requires `pip install tomli`"
106
+ ) from exc
107
+
108
+ p = Path(toml_path)
109
+ if not p.exists():
110
+ raise ConfigError(f"config file not found: {p}")
111
+ data = tomllib.loads(p.read_text())
112
+ if overrides:
113
+ data = _deep_merge(data, overrides)
114
+ try:
115
+ return cls.model_validate(data)
116
+ except Exception as exc: # pydantic ValidationError -> our error type
117
+ raise ConfigError(str(exc)) from exc
118
+
119
+
120
+ def _deep_merge(base: dict[str, Any], extra: dict[str, Any]) -> dict[str, Any]:
121
+ out = dict(base)
122
+ for k, v in extra.items():
123
+ if isinstance(v, dict) and isinstance(out.get(k), dict):
124
+ out[k] = _deep_merge(out[k], v)
125
+ else:
126
+ out[k] = v
127
+ return out
@@ -0,0 +1,22 @@
1
+ """Deterministic seed derivation.
2
+
3
+ Same inputs -> same seed across processes and platforms. All stochastic steps
4
+ (bootstrap nulls, backtest splits) derive their RNG from here; nothing seeds
5
+ from wall clock or global state.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+
12
+ import numpy as np
13
+
14
+
15
+ def derive_seed(*parts: str | int) -> int:
16
+ """Stable 64-bit seed from string/int parts."""
17
+ h = hashlib.sha256("\x1f".join(str(p) for p in parts).encode("utf-8")).digest()
18
+ return int.from_bytes(h[:8], "big")
19
+
20
+
21
+ def rng_for(*parts: str | int) -> np.random.Generator:
22
+ return np.random.default_rng(derive_seed(*parts))
@@ -0,0 +1,28 @@
1
+ """Exception hierarchy."""
2
+
3
+
4
+ class TSAnomalyError(Exception):
5
+ """Root of all TSAnomaly exceptions."""
6
+
7
+
8
+ class ConfigError(TSAnomalyError):
9
+ """Invalid or inconsistent configuration."""
10
+
11
+
12
+ class IngestionError(TSAnomalyError):
13
+ """Input data could not be interpreted (schema, timestamps, dimensions)."""
14
+
15
+
16
+ class StateError(TSAnomalyError):
17
+ """Metric state is missing, corrupt, or from an incompatible schema version."""
18
+
19
+
20
+ class MissingExtraError(TSAnomalyError):
21
+ """A plugin needs an optional dependency that is not installed."""
22
+
23
+ def __init__(self, plugin: str, extra: str) -> None:
24
+ super().__init__(
25
+ f"Plugin {plugin!r} requires the {extra!r} extra: pip install 'tsanomaly[{extra}]'"
26
+ )
27
+ self.plugin = plugin
28
+ self.extra = extra
@@ -0,0 +1,91 @@
1
+ """DetectionResult container."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Iterable
8
+
9
+ import pandas as pd
10
+
11
+ from tsanomaly.core.types import Anomaly, Incident
12
+
13
+
14
+ @dataclass(slots=True)
15
+ class DetectionResult:
16
+ anomalies: list[Anomaly] = field(default_factory=list)
17
+ incidents: list[Incident] = field(default_factory=list)
18
+ diagnostics: dict[str, Any] = field(default_factory=dict)
19
+ config_hash: str = ""
20
+ # Opt-in per-point envelope series: metric key ->
21
+ # list of {ts, observed, expected, lo, hi, z, outside} dicts.
22
+ envelopes: dict[str, list[dict[str, Any]]] | None = None
23
+
24
+ def __post_init__(self) -> None:
25
+ self.anomalies.sort(key=lambda a: (-a.score, a.episode.start_ts, a.metric.key()))
26
+
27
+ def alerts(self, min_score: float = 70.0) -> list[Anomaly]:
28
+ return [a for a in self.anomalies if a.score >= min_score]
29
+
30
+ def summary(self) -> str:
31
+ n = len(self.anomalies)
32
+ n_alert = len(self.alerts())
33
+ metrics = {a.metric.key() for a in self.anomalies}
34
+ inc = f", {len(self.incidents)} incidents" if self.incidents else ""
35
+ return (
36
+ f"{n} anomalies ({n_alert} above alert threshold) "
37
+ f"across {len(metrics)} metrics{inc}"
38
+ )
39
+
40
+ def to_frame(self) -> pd.DataFrame:
41
+ rows = []
42
+ for a in self.anomalies:
43
+ e = a.episode
44
+ rows.append(
45
+ {
46
+ "metric": e.metric.key(),
47
+ "start_ts": pd.Timestamp(e.start_ts, unit="ns", tz="UTC"),
48
+ "end_ts": (
49
+ pd.Timestamp(e.end_ts, unit="ns", tz="UTC") if e.end_ts else pd.NaT
50
+ ),
51
+ "direction": e.direction,
52
+ "kind": e.kind.value,
53
+ "score": a.score,
54
+ "peak_z": e.peak_z,
55
+ "p_mag": a.breakdown.p_mag,
56
+ "p_dur": a.breakdown.p_dur,
57
+ "p_combined": a.breakdown.p_combined,
58
+ }
59
+ )
60
+ return pd.DataFrame(rows)
61
+
62
+ def to_json(self) -> str:
63
+ """Deterministic JSON: identical data and config always serialize identically."""
64
+ payload = {
65
+ "config_hash": self.config_hash,
66
+ "anomalies": [a.to_dict() for a in self.anomalies],
67
+ "incidents": [
68
+ {
69
+ "id": i.id,
70
+ "narration": i.narration,
71
+ "started_ts": i.started_ts,
72
+ "ended_ts": i.ended_ts,
73
+ "aggregate": i.aggregate.to_dict(),
74
+ }
75
+ for i in self.incidents
76
+ ],
77
+ "diagnostics": self.diagnostics,
78
+ }
79
+ return json.dumps(payload, sort_keys=True, separators=(",", ":"), default=_json_default)
80
+
81
+ def extend(self, other: Iterable[Anomaly]) -> None:
82
+ self.anomalies.extend(other)
83
+ self.__post_init__()
84
+
85
+
86
+ def _json_default(o: Any) -> Any:
87
+ if hasattr(o, "to_dict"):
88
+ return o.to_dict()
89
+ if hasattr(o, "item"): # numpy scalars
90
+ return o.item()
91
+ return str(o)