Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 1 addition & 15 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,7 @@ uv sync --dev
uv run prek install

# Run tests
uv run pytest -q
uv run pytest -s # verbose with stdout
uv run pytest --cov=. --cov-report=term-missing # with coverage
uv run pytest tests/test_foo.py # single test file
uv run pytest

# Run linting/formatting (all pre-commit hooks)
uv run prek run -a
Expand Down Expand Up @@ -182,17 +179,6 @@ def set_configuration(self) -> None:

Omitting either step means a `persist:` block in the YAML is silently ignored with no error.

## Code Quality

Pre-commit hooks enforce:
- **mypy** strict mode
- **flake8** linting, **autopep8** formatting (max line 110)
- **bandit** security checks, **vulture** dead-code detection (70% threshold)
- **docformatter** docstring style

Python 3.12 is required (see `.python-version`).


# Git
NEVER include "Co-Authored-By ..." in your commit or PR messages.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class EventDataStructure(ABC):
template: str = ""

@abstractmethod
def add_data(self, data_object: Any) -> None: ...
def add_data(self, data_object: Any, timestamp: float | None = None) -> None: ...
Comment thread
viktorbeck98 marked this conversation as resolved.
Dismissed

@abstractmethod
def get_data(self) -> Any: ...
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class ChunkedEventDataFrame(EventDataStructure):
chunks: list[pl.DataFrame] = field(default_factory=list)
_rows: int = 0

def add_data(self, data: pl.DataFrame) -> None:
def add_data(self, data: pl.DataFrame, timestamp: float | None = None) -> None:
if data.height == 0:
return
self.chunks.append(data)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class EventDataFrame(EventDataStructure):
"""
data: pd.DataFrame = field(default_factory=pd.DataFrame)

def add_data(self, data: pd.DataFrame) -> None:
def add_data(self, data: pd.DataFrame, timestamp: float | None = None) -> None:
if len(self.data) > 0:
self.data = pd.concat([self.data, data], ignore_index=True)
else:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ def __init__(
self.converter_function = converter_function
self.multi_tracker = self.multi_tracker_type(single_tracker_type=self.single_tracker_type)

def add_data(self, data_object: Any) -> None:
def add_data(self, data_object: Any, timestamp: float | None = None) -> None:
"""Add data to the variable trackers."""
self.multi_tracker.add_data(data_object)
self.multi_tracker.add_data(data_object, timestamp=timestamp)

def get_data(self) -> Dict[str, SingleTracker]:
"""Retrieve the tracker's stored data."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ def __init__(self, single_tracker_type: Callable[[], SingleTracker] = SingleTrac
self.single_trackers: Dict[str, SingleTracker] = {}
self.single_tracker_type: Callable[[], SingleTracker] = single_tracker_type

def add_data(self, data_object: Dict[str, Any]) -> None:
def add_data(self, data_object: Dict[str, Any], timestamp: float | None = None) -> None:
"""Add data to the appropriate feature trackers."""
for name, value in data_object.items():
if name not in self.single_trackers:
self.single_trackers[name] = self.single_tracker_type()
self.single_trackers[name].add_value(value)
self.single_trackers[name].add_value(value, timestamp=timestamp)

def get_trackers(self) -> Dict[str, SingleTracker]:
"""Get the current feature trackers."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class SingleTracker(ABC):
"""Tracks whether a single variable is converging to a constant value."""

@abstractmethod
def add_value(self, value: Any) -> None:
def add_value(self, value: Any, timestamp: float | None = None) -> None:
"""Add a new value to the tracker."""

@abstractmethod
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,60 @@ def __init__(self, segment_thresholds: List[float], min_samples: int = 10):
# for lists
self.segment_means: List[float] = []

def is_stable(self, change_series: RLEList[bool] | List[bool]) -> bool:
def _segment_boundaries(self, total_len: int, timestamps: List[float] | None = None) -> List[int]:
"""Index boundaries of n_segments segments over total_len items.

Equal-count by default. When timestamps are given (one per item,
chronological, non-zero span), boundaries are equal-DURATION
cuts of the observed time span, mapped back to indices. Falls
back to equal-count on missing/mismatched/non-finite timestamps
or zero span.
"""
try:
use_time = (
timestamps is not None
and len(timestamps) == total_len
and total_len > 0
and bool(np.all(np.isfinite(timestamps)))
and timestamps[-1] > timestamps[0]
)
except TypeError:
# e.g. a None entry: not comparable/convertible -> equal-count
use_time = False
if use_time and timestamps is not None: # 2nd clause narrows for mypy
t_first, t_last = timestamps[0], timestamps[-1]
cuts = [
t_first + k * (t_last - t_first) / self.n_segments
for k in range(self.n_segments + 1)
]
boundaries = [int(np.searchsorted(timestamps, t, side="left")) for t in cuts]
boundaries[0] = 0
boundaries[-1] = total_len
return boundaries
segment_size = total_len / self.n_segments
boundaries = [int(i * segment_size) for i in range(self.n_segments + 1)]
boundaries[-1] = total_len
return boundaries

def is_stable(
self,
change_series: RLEList[bool] | List[bool],
timestamps: List[float] | None = None,
) -> bool:
"""Determine if a list of segment means is stable.

Works efficiently with RLEList without expanding to a full list.
When timestamps are given (one per occurrence, chronological),
segments are equal-duration cuts of the time span instead of
equal-count cuts of the series.
"""
# Handle both RLEList and regular list
if isinstance(change_series, RLEList):
total_len = len(change_series)
if total_len == 0:
return True

# Calculate segment boundaries
segment_size = total_len / self.n_segments
segment_boundaries = [int(i * segment_size) for i in range(self.n_segments + 1)]
segment_boundaries[-1] = total_len
segment_boundaries = self._segment_boundaries(total_len, timestamps)

# Compute segment means directly from RLE runs
segment_sums = [0.0] * self.n_segments
Expand Down Expand Up @@ -65,8 +104,15 @@ def is_stable(self, change_series: RLEList[bool] | List[bool]) -> bool:
for i in range(self.n_segments)
]
else:
# Original implementation for regular lists
self.segment_means = self._compute_segment_means(change_series)
if timestamps is not None and len(timestamps) == len(change_series):
b = self._segment_boundaries(len(change_series), timestamps)
self.segment_means = [
float(np.mean(change_series[b[i]:b[i + 1]])) if b[i + 1] > b[i] else np.nan
for i in range(self.n_segments)
]
else:
# Original implementation for regular lists
self.segment_means = self._compute_segment_means(change_series)
return all([not q >= thresh for q, thresh in zip(self.segment_means, self.segment_threshs)])

def _compute_segment_means(self, change_series: List[bool]) -> List[float]:
Expand All @@ -80,8 +126,10 @@ def get_last_segment_means(self) -> List[float]:
def get_segment_thresholds(self) -> List[float]:
return self.segment_threshs

def __call__(self, change_series: RLEList[bool] | List[bool]) -> bool:
return self.is_stable(change_series)
def __call__(
self, change_series: RLEList[bool] | List[bool], timestamps: List[float] | None = None
) -> bool:
return self.is_stable(change_series, timestamps=timestamps)

def __repr__(self) -> str:
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,26 @@
class SingleStabilityTracker(SingleTracker):
"""Tracks stability of a single feature."""

def __init__(self, min_samples: int = 3, add_value_fn: str = "default",
detector_config: "CoreDetectorConfig | None" = None) -> None:
def __init__(
self,
min_samples: int = 3,
expand_value: bool = False,
time_dependent: bool = False,
add_value_fn: str = "default",
detector_config: "CoreDetectorConfig | None" = None,
) -> None:
self.min_samples = min_samples
self.expand_value = expand_value
self.time_dependent = time_dependent
self.change_series: RLEList[bool] = RLEList()
self.unique_set: Set[Any] = set()
self.stability_classifier: StabilityClassifier = StabilityClassifier(
segment_thresholds=[1.1, 0.3, 0.1, 0.01],
)
self._accum = set.update if expand_value else set.add
# ponytail: O(N) timestamps; switch to fixed-width time buckets if
# this ever runs unbounded/streaming.
self.timestamps: List[float] = []
# Opaque slot for detectors to stash per-variable model state that
# must survive save/load. Schema-free; the tracker does not interpret it.
self.extra_state: Dict[str, Any] = {}
Expand All @@ -35,14 +47,21 @@ def __init__(self, min_samples: int = 3, add_value_fn: str = "default",
detector = detector()
self.add_value = detector.add_value_fn.__get__(self, type(self)) # type: ignore[method-assign]

def add_value(self, value: Any) -> None:
def add_value(self, value: Any, timestamp: float | None = None) -> None:
"""Add a new value to the tracker."""
before = len(self.unique_set)
self.unique_set.add(value)
self._accum(self.unique_set, value)
self.change_series.append(len(self.unique_set) > before)
if self.time_dependent and timestamp is not None:
self.timestamps.append(float(timestamp))

def classify(self) -> Classification:
"""Classify the variable."""
timestamps = (
self.timestamps
if self.time_dependent and len(self.timestamps) == len(self.change_series)
else None
)
if len(self.change_series) < self.min_samples:
return Classification(
type="INSUFFICIENT_DATA",
Expand All @@ -58,7 +77,7 @@ def classify(self) -> Classification:
type="RANDOM",
reason=f"Unique set size equals number of samples ({len(self.change_series)})"
)
elif self.stability_classifier.is_stable(self.change_series):
elif self.stability_classifier.is_stable(self.change_series, timestamps=timestamps):
return Classification(
type="STABLE",
reason=(
Expand All @@ -79,6 +98,9 @@ def to_state(self) -> Dict[str, Any]:
"type": self.__class__.__name__,
"module": self.__class__.__module__,
"min_samples": self.min_samples,
"expand_value": self.expand_value,
"time_dependent": self.time_dependent,
"timestamps": self.timestamps,
"add_value_fn": self.add_value_fn,
"detector_config": self.detector_config,
"runs": self.change_series.runs(),
Expand All @@ -92,6 +114,8 @@ def from_state(cls, state: Dict[str, Any]) -> "SingleStabilityTracker":
"""Restore tracker from a state dict produced by to_state()."""
tracker = cls(
min_samples=state["min_samples"],
expand_value=state.get("expand_value", False),
time_dependent=state.get("time_dependent", False),
add_value_fn=state["add_value_fn"],
detector_config=state["detector_config"]
)
Expand All @@ -104,6 +128,7 @@ def from_state(cls, state: Dict[str, Any]) -> "SingleStabilityTracker":
tracker.stability_classifier = StabilityClassifier(
segment_thresholds=state["segment_thresholds"]
)
tracker.timestamps = [float(t) for t in state.get("timestamps", [])]
tracker.extra_state = state.get("extra_state", {})
return tracker

Expand Down Expand Up @@ -143,14 +168,21 @@ class EventStabilityTracker(EventTracker):
def __init__(
self,
converter_function: Callable[[Any], Any] = lambda x: x,
expand_value: bool = False,
time_dependent: bool = False,
add_value_fn: str = "default",
detector_config: "CoreDetectorConfig | None" = None

) -> None:
self.multi_tracker: MultiStabilityTracker # for type hinting

def make_tracker() -> SingleStabilityTracker:
return SingleStabilityTracker(add_value_fn=add_value_fn, detector_config=detector_config)
return SingleStabilityTracker(
expand_value=expand_value,
time_dependent=time_dependent,
add_value_fn=add_value_fn,
detector_config=detector_config,
)

# Mirror class identity onto the closure so dump()/load() can resolve
# the underlying SingleStabilityTracker via its module + qualname.
Expand Down
5 changes: 3 additions & 2 deletions src/detectmatelibrary/utils/persistency/event_persistency.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ def ingest_event(
event_id: int | str,
event_template: str,
variables: list[Any] = [],
named_variables: Dict[str, Any] = {}
named_variables: Dict[str, Any] = {},
timestamp: float | None = None,
) -> None:
"""Ingest event data into the appropriate EventData store."""
with self._lock:
Expand All @@ -60,7 +61,7 @@ def ingest_event(
self.events_data[event_id] = data_structure

data = data_structure.to_data(all_variables)
data_structure.add_data(data)
data_structure.add_data(data, timestamp=timestamp)
# ponytail: fire callbacks outside the lock so a count-triggered save
# doesn't hold the ingest lock across serialize + file I/O.
for _cb in self._on_ingest_callbacks:
Expand Down
Loading
Loading