From b36da11fa9975c3fa23bf75de304d8b6d2c2d906 Mon Sep 17 00:00:00 2001 From: "angre.garcia-gomez@ait.ac.at" Date: Wed, 22 Jul 2026 15:52:08 +0200 Subject: [PATCH 01/33] add temporally a different version of detectmateperformance --- pyproject.toml | 3 ++- uv.lock | 10 +++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 904edf5f..ef97e9c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,8 @@ dependencies = [ "pyyaml>=6.0.3", "regex>=2025.11.3", "numpy>=2.3.2", - "detectmateperformance>=0.1.0", + #"detectmateperformance>=0.1.0", + "detectmateperformance @ git+https://github.com/ait-detectmate/DetectMatePerformance", "msgpack>=1.0.0", "fsspec>=2024.1.0", "pyarrow>=24.0.0", diff --git a/uv.lock b/uv.lock index d2ccd2b3..6c7df305 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -247,7 +247,7 @@ requires-dist = [ { name = "detectmatelibrary", extras = ["dataframes"], marker = "extra == 'full'" }, { name = "detectmatelibrary", extras = ["llm"], marker = "extra == 'full'" }, { name = "detectmatelibrary", extras = ["polars-rtcompat"], marker = "extra == 'full'" }, - { name = "detectmateperformance", specifier = ">=0.1.0" }, + { name = "detectmateperformance", git = "https://github.com/ait-detectmate/DetectMatePerformance" }, { name = "fsspec", specifier = ">=2024.1.0" }, { name = "msgpack", specifier = ">=1.0.0" }, { name = "numpy", specifier = ">=2.3.2" }, @@ -280,7 +280,7 @@ dev = [ [[package]] name = "detectmateperformance" version = "0.1.0" -source = { registry = "https://pypi.org/simple" } +source = { git = "https://github.com/ait-detectmate/DetectMatePerformance#a5bb075b0bd15e406430e77ccbfcf6fe94a50ab0" } dependencies = [ { name = "levenshtein" }, { name = "numpy" }, @@ -289,10 +289,6 @@ dependencies = [ { name = "setuptools" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e8/2b/7ff298303c5ba3ba4899d1f3854b214f63a4fac4eff191012f5608f5149e/detectmateperformance-0.1.0.tar.gz", hash = "sha256:2630d509e7e2bbe6b5bcc857e8f25dfae7d99e9e2cc52f5981da9171600531dc", size = 552409, upload-time = "2026-06-12T10:42:13.326Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/68/dd/3db227cd60d170203ab82dd2e670bcb50c6111769c39ac8dd64935fa4c1d/detectmateperformance-0.1.0-py3-none-any.whl", hash = "sha256:db70f239e53aa17a983f99948c9239c143744c03044eeae454f546fedbaf69a5", size = 557939, upload-time = "2026-06-12T10:42:11.526Z" }, -] [[package]] name = "distro" From edf98814a91a20c2a97252d4dacd012d36340693 Mon Sep 17 00:00:00 2001 From: "angre.garcia-gomez@ait.ac.at" Date: Wed, 22 Jul 2026 16:15:36 +0200 Subject: [PATCH 02/33] first drain version --- src/detectmatelibrary/parsers/drain.py | 58 ++++++++++++++++++++++++++ tests/test_parsers/test_drain.py | 32 ++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 tests/test_parsers/test_drain.py diff --git a/src/detectmatelibrary/parsers/drain.py b/src/detectmatelibrary/parsers/drain.py index e69de29b..8354ea96 100644 --- a/src/detectmatelibrary/parsers/drain.py +++ b/src/detectmatelibrary/parsers/drain.py @@ -0,0 +1,58 @@ +from detectmatelibrary.common.parser import CoreParser, CoreParserConfig +from detectmatelibrary import schemas + +from detectmateperformance.match_tree import TreeMatcher +from detectmateperformance.drain import Drain + +from typing import Any + + +class DrainConfig(CoreParserConfig): + method_type: str = "drain_parser" + + depth: int = 2 + max_childs: int = 10 + sim_thres: float = 0.2 + + +class DrainParser(CoreParser): + def __init__( + self, + name: str = "DrainParser", + config: DrainConfig | dict[str, Any] = DrainConfig() + ) -> None: + + if isinstance(config, dict): + config = DrainConfig.from_dict(config, name) + super().__init__(name=name, config=config) + + self.config: DrainConfig + self.drain_gen = Drain( + depth=self.config.depth, + max_child=self.config.max_childs, + sim=self.config.sim_thres, + ) + self.tree_match: TreeMatcher | None = None + + def train(self, input_: schemas.LogSchema) -> None: # type: ignore + self.drain_gen.add(input_["log"]) + + def post_train(self) -> None: + self.tree_match = self.drain_gen.generate() + self.drain_gen.reset() + + def parse( + self, + input_: schemas.LogSchema, + output_: schemas.ParserSchema + ) -> None: + + if self.tree_match is None: + output_["EventID"] = -1 + output_["template"] = "templates not yet generated" + else: + parsed = self.tree_match.match_log(input_["log"], get_var=True)[0] + + output_["EventID"] = parsed["EventID"] + output_["variables"].extend(parsed["ParamList"]) + output_["template"] = parsed["Template"] diff --git a/tests/test_parsers/test_drain.py b/tests/test_parsers/test_drain.py new file mode 100644 index 00000000..45e4c6e8 --- /dev/null +++ b/tests/test_parsers/test_drain.py @@ -0,0 +1,32 @@ +"""Most of the functionality is test it in DetectMatePerformance.""" +from detectmatelibrary.parsers.drain import DrainParser +from detectmatelibrary import schemas + + +class TestDrainParser: + def test_train_process(self): + config_dict = { + "parsers": { + "DrainParser": { + "method_type": "drain_parser", + "data_use_training": 2, + } + } + } + parser = DrainParser(config=config_dict) + + parsed = parser.process(schemas.LogSchema({"log": "hello there, general kenobi!"})) + assert parsed["EventID"] == -1 + assert parsed["template"] == "templates not yet generated" + + parsed = parser.process(schemas.LogSchema({"log": "hello there, captain kenobi!"})) + assert parsed["EventID"] == -1 + assert parsed["template"] == "templates not yet generated" + + parsed = parser.process(schemas.LogSchema({"log": "hello there, sargent kenobi!"})) + assert parsed["EventID"] == 0 + assert parsed["template"] == "hello there <*> kenobi" + + parsed = parser.process(schemas.LogSchema({"log": "hello there, general R2D2!"})) + assert parsed["EventID"] == -1 + assert parsed["template"] == "template not found" From 577596b0a5c6bcdd9561ccb10bf82c52ce193041 Mon Sep 17 00:00:00 2001 From: "angre.garcia-gomez@ait.ac.at" Date: Wed, 22 Jul 2026 17:34:08 +0200 Subject: [PATCH 03/33] working in drain --- src/detectmatelibrary/parsers/drain.py | 2 ++ tests/test_parsers/test_drain.py | 34 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/detectmatelibrary/parsers/drain.py b/src/detectmatelibrary/parsers/drain.py index 8354ea96..d5723930 100644 --- a/src/detectmatelibrary/parsers/drain.py +++ b/src/detectmatelibrary/parsers/drain.py @@ -14,6 +14,8 @@ class DrainConfig(CoreParserConfig): max_childs: int = 10 sim_thres: float = 0.2 + reset_in_post_train: bool = False + class DrainParser(CoreParser): def __init__( diff --git a/tests/test_parsers/test_drain.py b/tests/test_parsers/test_drain.py index 45e4c6e8..b8db54dc 100644 --- a/tests/test_parsers/test_drain.py +++ b/tests/test_parsers/test_drain.py @@ -30,3 +30,37 @@ def test_train_process(self): parsed = parser.process(schemas.LogSchema({"log": "hello there, general R2D2!"})) assert parsed["EventID"] == -1 assert parsed["template"] == "template not found" + + def test_rest_after_train(self): + config_dict = { + "parsers": { + "DrainParser": { + "method_type": "drain_parser", + "data_use_training": 2, + "reset_in_post_train": True, + } + } + } + parser = DrainParser(config=config_dict) + + parsed = parser.process(schemas.LogSchema({"log": "hello there, general kenobi!"})) + assert parsed["EventID"] == -1 + assert parsed["template"] == "templates not yet generated" + + parsed = parser.process(schemas.LogSchema({"log": "hello there, captain kenobi!"})) + assert parsed["EventID"] == -1 + assert parsed["template"] == "templates not yet generated" + + parsed = parser.process(schemas.LogSchema({"log": "hello there, sargent kenobi!"})) + assert parsed["EventID"] == 0 + assert parsed["template"] == "hello there <*> kenobi" + + parser.update_state("keep_training") + parsed = parser.process(schemas.LogSchema({"log": "bella ciao bella ciao"})) + print(parser.drain_gen.buffer) + parser.update_state("stop_training") + print(parser.drain_gen.buffer) + + parsed = parser.process(schemas.LogSchema({"log": "hello there, sargent kenobi!"})) + assert parsed["EventID"] == -1 + assert parsed["template"] == "template not found" From 99b79916f4d9edb9fb9c94bffd73cf5955db4247 Mon Sep 17 00:00:00 2001 From: "angre.garcia-gomez@ait.ac.at" Date: Thu, 23 Jul 2026 15:05:20 +0200 Subject: [PATCH 04/33] add allow reset or not train data --- src/detectmatelibrary/parsers/drain.py | 3 ++- tests/test_parsers/test_drain.py | 36 +++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/detectmatelibrary/parsers/drain.py b/src/detectmatelibrary/parsers/drain.py index d5723930..88b29280 100644 --- a/src/detectmatelibrary/parsers/drain.py +++ b/src/detectmatelibrary/parsers/drain.py @@ -41,7 +41,8 @@ def train(self, input_: schemas.LogSchema) -> None: # type: ignore def post_train(self) -> None: self.tree_match = self.drain_gen.generate() - self.drain_gen.reset() + if self.config.reset_in_post_train: + self.drain_gen.reset() def parse( self, diff --git a/tests/test_parsers/test_drain.py b/tests/test_parsers/test_drain.py index b8db54dc..daad722a 100644 --- a/tests/test_parsers/test_drain.py +++ b/tests/test_parsers/test_drain.py @@ -31,7 +31,7 @@ def test_train_process(self): assert parsed["EventID"] == -1 assert parsed["template"] == "template not found" - def test_rest_after_train(self): + def test_reset_after_train(self): config_dict = { "parsers": { "DrainParser": { @@ -57,10 +57,40 @@ def test_rest_after_train(self): parser.update_state("keep_training") parsed = parser.process(schemas.LogSchema({"log": "bella ciao bella ciao"})) - print(parser.drain_gen.buffer) parser.update_state("stop_training") - print(parser.drain_gen.buffer) parsed = parser.process(schemas.LogSchema({"log": "hello there, sargent kenobi!"})) assert parsed["EventID"] == -1 assert parsed["template"] == "template not found" + + def test_not_reset_train(self): + config_dict = { + "parsers": { + "DrainParser": { + "method_type": "drain_parser", + "data_use_training": 2, + "reset_in_post_train": False, + } + } + } + parser = DrainParser(config=config_dict) + + parsed = parser.process(schemas.LogSchema({"log": "hello there, general kenobi!"})) + assert parsed["EventID"] == -1 + assert parsed["template"] == "templates not yet generated" + + parsed = parser.process(schemas.LogSchema({"log": "hello there, captain kenobi!"})) + assert parsed["EventID"] == -1 + assert parsed["template"] == "templates not yet generated" + + parsed = parser.process(schemas.LogSchema({"log": "hello there, sargent kenobi!"})) + assert parsed["EventID"] == 0 + assert parsed["template"] == "hello there <*> kenobi" + + parser.update_state("keep_training") + parsed = parser.process(schemas.LogSchema({"log": "bella ciao bella ciao"})) + parser.update_state("stop_training") + + parsed = parser.process(schemas.LogSchema({"log": "hello there, sargent kenobi!"})) + assert parsed["EventID"] == 1 + assert parsed["template"] == "hello there <*> kenobi" From bed864927065d097eb2a6823ef6e324064d8d367 Mon Sep 17 00:00:00 2001 From: "angre.garcia-gomez@ait.ac.at" Date: Thu, 23 Jul 2026 17:08:05 +0200 Subject: [PATCH 05/33] add drain documentation --- docs/parsers.md | 1 + docs/parsers/drain_parser.md | 109 +++++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 3 files changed, 111 insertions(+) create mode 100644 docs/parsers/drain_parser.md diff --git a/docs/parsers.md b/docs/parsers.md index a862c877..b6e94cb7 100644 --- a/docs/parsers.md +++ b/docs/parsers.md @@ -108,5 +108,6 @@ def test_my_parser_parse(): - [Template Matcher](parsers/template_matcher.md): matches logs against a predefined set of `<*>` templates. - [Template Tree Matcher](parsers/template_tree_matcher.md): matches logs against a predefined set of `<*>` templates using a tree structure. - [LogBatcher Parser](parsers/logbatcher_parser.md): LLM-based parser that infers templates from raw logs with no training data. +- [Drain parser](parsers/drain_parser.md): Parser inspired by [drain publication](https://ieeexplore.ieee.org/document/8029742). Go back to [Index](index.md) diff --git a/docs/parsers/drain_parser.md b/docs/parsers/drain_parser.md new file mode 100644 index 00000000..92014716 --- /dev/null +++ b/docs/parsers/drain_parser.md @@ -0,0 +1,109 @@ +# Drain parser + +The parsed is based in the official [Drain publication](https://ieeexplore.ieee.org/document/8029742). + +This parser wraps functionality from the DetectMatePerformance project: https://github.com/ait-detectmate/DetectMatePerformance. Prefer use the performance implementation when parsing many log lines in non-stream (batch) mode. + +| | Schema | Description | +|------------|----------------------------|--------------------| +| **Input** | [LogSchema](../schemas.md) | Unstructured log | +| **Output** | [ParserSchema](../schemas.md) | Structured log | + +WARNING: This parser is not yet in a stable release and may behave differently across platforms or hardware. + +## Configuration + +Drain parser arguments: + +- `method_type` (string): parser type identifier (for example `"tree_matcher"`). +- `depth` (int): Number of word layers. +- `max_childs` (int): max number of childs allow in the length layer. +- `sim_thres` (float): similarity threshold. +- `reset_in_post_train` (bool): if true remove the logs in the train buffer when the templates are generated. Otherwise, it safe them for the next train. +- `auto_config` (bool): whether to attempt an optional auto-configuration phase (not required). + +Example YAML fragment: +```yaml +parsers: + DrainParser: + method_type: drain_parser + auto_config: False + params: + depth: 2 +``` + +## Usage example + +Simple usage (Reset = False): + +```python +from detectmatelibrary.parsers.drain import DrainParser +from detectmatelibrary import schemas + +# instantiate parser (config can be a dict or a config object) +config_dict = { + "parsers": { + "DrainParser": { + "method_type": "drain_parser", + "data_use_training": 2, + "reset_in_post_train": False, + } + } +} + +parser = DrainParser(config=config_dict) + +parsed = parser.process(schemas.LogSchema({"log": "hello there, general kenobi!"})) +print(parsed["template"]) # "templates not yet generated" + +parsed = parser.process(schemas.LogSchema({"log": "hello there, captain kenobi!"})) +print(parsed["template"]) # "templates not yet generated" + +parsed = parser.process(schemas.LogSchema({"log": "hello there, sargent kenobi!"})) +print(parsed["template"]) # "hello there <*> kenobi" + +parser.update_state("keep_training") +parser.process(schemas.LogSchema({"log": "bella ciao bella ciao"})) +parser.update_state("stop_training") + +parsed = parser.process(schemas.LogSchema({"log": "hello there, sargent kenobi!"})) +print(parsed["template"]) # "hello there <*> kenobi" +``` + +Simple usage (Reset = True): + +```python +from detectmatelibrary.parsers.drain import DrainParser +from detectmatelibrary import schemas + +# instantiate parser (config can be a dict or a config object) +config_dict = { + "parsers": { + "DrainParser": { + "method_type": "drain_parser", + "data_use_training": 2, + "reset_in_post_train": False, + } + } +} + +parser = DrainParser(config=config_dict) + +parsed = parser.process(schemas.LogSchema({"log": "hello there, general kenobi!"})) +print(parsed["template"]) # "templates not yet generated" + +parsed = parser.process(schemas.LogSchema({"log": "hello there, captain kenobi!"})) +print(parsed["template"]) # "templates not yet generated" + +parsed = parser.process(schemas.LogSchema({"log": "hello there, sargent kenobi!"})) +print(parsed["template"]) # "hello there <*> kenobi" + +parser.update_state("keep_training") +parser.process(schemas.LogSchema({"log": "bella ciao bella ciao"})) +parser.update_state("stop_training") + +parsed = parser.process(schemas.LogSchema({"log": "hello there, sargent kenobi!"})) +print(parsed["template"]) # "template not found" +``` + +Go back to [Index](../index.md) diff --git a/mkdocs.yml b/mkdocs.yml index 49b0eceb..623c5fd4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -22,6 +22,7 @@ nav: - Template Tree Matcher: parsers/template_tree_matcher.md - Json Parser: parsers/json_parser.md - LogBatcher Parser: parsers/logbatcher_parser.md + - Drain Parser: parsers/drain_parser.md - Detectors Methods: - Random Detector: detectors/random_detector.md - New Value: detectors/new_value.md From 7b325d31cf0d40315ba1a9ce595589047391d982 Mon Sep 17 00:00:00 2001 From: "angre.garcia-gomez@ait.ac.at" Date: Fri, 24 Jul 2026 14:49:32 +0200 Subject: [PATCH 06/33] combinatons class --- src/detectmatelibrary/parsers/drain.py | 27 ++++++-- src/detectmatelibrary/utils/finetune.py | 51 +++++++++++++++ tests/test_parsers/test_drain.py | 23 ++++++- tests/test_utils/test_finetune.py | 82 +++++++++++++++++++++++++ 4 files changed, 177 insertions(+), 6 deletions(-) create mode 100644 src/detectmatelibrary/utils/finetune.py create mode 100644 tests/test_utils/test_finetune.py diff --git a/src/detectmatelibrary/parsers/drain.py b/src/detectmatelibrary/parsers/drain.py index 88b29280..066957e3 100644 --- a/src/detectmatelibrary/parsers/drain.py +++ b/src/detectmatelibrary/parsers/drain.py @@ -16,6 +16,18 @@ class DrainConfig(CoreParserConfig): reset_in_post_train: bool = False + finetune: list[list[str | list[Any]]] = [ + ["depth", [1, 2, 3, 4]], + ["max_childs", [10, 40]], + ["sim_thres", [0.2, 0.4, 0.6, 0.8]] + ] + + +def _init_drain(config: DrainConfig) -> Drain: + return Drain( + depth=config.depth, max_child=config.max_childs, sim=config.sim_thres, + ) + class DrainParser(CoreParser): def __init__( @@ -29,13 +41,18 @@ def __init__( super().__init__(name=name, config=config) self.config: DrainConfig - self.drain_gen = Drain( - depth=self.config.depth, - max_child=self.config.max_childs, - sim=self.config.sim_thres, - ) + self.drain_gen = _init_drain(config=config) self.tree_match: TreeMatcher | None = None + self.report: dict[str, float] = {} + self.config_buffer: list[schemas.LogSchema] = [] + + def configure(self, input_: schemas.LogSchema) -> None: # type: ignore + self.config_buffer.append(input_) + + def set_configuration(self) -> None: + pass + def train(self, input_: schemas.LogSchema) -> None: # type: ignore self.drain_gen.add(input_["log"]) diff --git a/src/detectmatelibrary/utils/finetune.py b/src/detectmatelibrary/utils/finetune.py new file mode 100644 index 00000000..c29c6d47 --- /dev/null +++ b/src/detectmatelibrary/utils/finetune.py @@ -0,0 +1,51 @@ +from detectmatelibrary.common.core import CoreConfig + +from typing import Any +import numpy as np + +import itertools +import warnings +import typing +import copy + + +class Combinations: + def __init__(self, config: CoreConfig) -> None: + self.config = config + + self.paths: list[str] = [] + self.combs: list[tuple[Any, ...]] = [] + if "finetune" not in dir(config): + warnings.warn("No finetune options found") + else: + self.paths = [path[0] for path in getattr(config, "finetune")] + self.combs = list(itertools.product( + *[path[-1] for path in getattr(config, "finetune")] + )) + self.values: list[float] = [] + + def add_value(self, value: float) -> None: + self.values.append(value) + + def get_best(self) -> CoreConfig: + if self.values == []: + return self.config + + idx = np.argmin(self.values) + for i, combo in enumerate(self.combs): + if i == idx: + config = copy.deepcopy(self.config) + for path, value in zip(self.paths, combo): + if path in dir(self.config): + setattr(config, path, value) + return config + return self.config + + def __call__(self) -> typing.Iterable[CoreConfig]: + for combo in self.combs: + config = copy.deepcopy(self.config) + for path, value in zip(self.paths, combo): + if path in dir(config): + setattr(config, path, value) + + yield config diff --git a/tests/test_parsers/test_drain.py b/tests/test_parsers/test_drain.py index daad722a..d4d9669e 100644 --- a/tests/test_parsers/test_drain.py +++ b/tests/test_parsers/test_drain.py @@ -9,6 +9,9 @@ def test_train_process(self): "parsers": { "DrainParser": { "method_type": "drain_parser", + "depth": 2, + "max_childs": 10, + "sim_thres": 0.2, "data_use_training": 2, } } @@ -36,6 +39,9 @@ def test_reset_after_train(self): "parsers": { "DrainParser": { "method_type": "drain_parser", + "depth": 2, + "max_childs": 10, + "sim_thres": 0.2, "data_use_training": 2, "reset_in_post_train": True, } @@ -68,6 +74,9 @@ def test_not_reset_train(self): "parsers": { "DrainParser": { "method_type": "drain_parser", + "depth": 2, + "max_childs": 10, + "sim_thres": 0.2, "data_use_training": 2, "reset_in_post_train": False, } @@ -92,5 +101,17 @@ def test_not_reset_train(self): parser.update_state("stop_training") parsed = parser.process(schemas.LogSchema({"log": "hello there, sargent kenobi!"})) - assert parsed["EventID"] == 1 assert parsed["template"] == "hello there <*> kenobi" + + def test_no_auto_config_but_no_initialization(self): + config_dict = { + "parsers": { + "DrainParser": { + "method_type": "drain_parser", + "auto_config": True, + "data_use_configure": 2, + "data_use_training": 2, + } + } + } + DrainParser(config=config_dict) diff --git a/tests/test_utils/test_finetune.py b/tests/test_utils/test_finetune.py new file mode 100644 index 00000000..feb9c0a3 --- /dev/null +++ b/tests/test_utils/test_finetune.py @@ -0,0 +1,82 @@ + +from detectmatelibrary.utils.finetune import Combinations + +from detectmatelibrary.common.core import CoreConfig + +from typing import Any +import pytest + + +class DummyConfig(CoreConfig): + a: int = 2 + b: int = 10 + c: float = 0.2 + + finetune: list[tuple[str, list[Any]]] = [ + ["a", [1, 2, 3, 4]], + ["b", [10, 40]], + ["c", [0.2, 0.4, 0.6, 0.8]] + ] + + +class DummyConfig2(CoreConfig): + a: int = 2 + b: int = 10 + c: float = 0.4 + + +class DummyConfig3(CoreConfig): + a: int = 2 + b: int = 10 + + finetune: list[tuple[str, list[Any]]] = [ + ["a", [1, 2, 3, 4]], + ["b", [10, 40]], + ["c", [0.2, 0.4, 0.6, 0.8]] + ] + + +class TestCombinations: + def test_combination_no_overwrite(self): + comb = Combinations(config := DummyConfig()) + assert config != next(comb()) + + def test_call_format(self): + comb = Combinations(DummyConfig()) + config = next(comb()) + + assert config.a == 1 + assert config.b == 10 + assert config.c == 0.2 + + def test_get_best(self): + comb = Combinations(DummyConfig()) + for j, _ in enumerate(comb()): + comb.add_value(j) + + best_config = comb.get_best() + assert best_config.a == 1 + assert best_config.b == 10 + assert best_config.c == 0.2 + + def test_finetune_not_found(self): + with pytest.warns(UserWarning): + comb = Combinations(DummyConfig2()) + + for _ in comb(): + assert False + config = comb.get_best() + + assert config.a == 2 + assert config.b == 10 + assert config.c == 0.4 + + def test_argument_missing(self): + comb = Combinations(DummyConfig3()) + + for j, _ in enumerate(comb()): + comb.add_value(j) + config = comb.get_best() + + assert config.a == 1 + assert config.b == 10 From eed0df8493993fae0df3b561f14c7e2583112300 Mon Sep 17 00:00:00 2001 From: "angre.garcia-gomez@ait.ac.at" Date: Fri, 24 Jul 2026 15:51:53 +0200 Subject: [PATCH 07/33] adding autoconfig --- src/detectmatelibrary/parsers/drain.py | 36 +++++++++++++++++++++++--- tests/test_parsers/test_drain.py | 27 +++++++++++++++++-- 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/detectmatelibrary/parsers/drain.py b/src/detectmatelibrary/parsers/drain.py index 066957e3..0f4e8167 100644 --- a/src/detectmatelibrary/parsers/drain.py +++ b/src/detectmatelibrary/parsers/drain.py @@ -4,6 +4,8 @@ from detectmateperformance.match_tree import TreeMatcher from detectmateperformance.drain import Drain +from detectmatelibrary.utils.finetune import Combinations + from typing import Any @@ -29,6 +31,18 @@ def _init_drain(config: DrainConfig) -> Drain: ) +def _found_ratio(logs: list[str], tree_matcher: TreeMatcher) -> float: + results = tree_matcher.match_batch(logs).get_all_templates() + print(results) + + score = 0.0 + for template in results: + if "template not found" == template: + score += 1. + + return score / len(results) + + class DrainParser(CoreParser): def __init__( self, @@ -44,14 +58,28 @@ def __init__( self.drain_gen = _init_drain(config=config) self.tree_match: TreeMatcher | None = None - self.report: dict[str, float] = {} - self.config_buffer: list[schemas.LogSchema] = [] + self.config_buffer: list[str] = [] def configure(self, input_: schemas.LogSchema) -> None: # type: ignore - self.config_buffer.append(input_) + self.config_buffer.append(input_["log"]) def set_configuration(self) -> None: - pass + found_ratio: list[float] = [] + length: list[int] = [] + for config in (comb := Combinations(self.config))(): + drain = _init_drain(config) # type: ignore + for input_ in self.config_buffer: + drain.add(input_) + tree_matcher = drain.generate() + + found_ratio.append(_found_ratio(self.config_buffer, tree_matcher)) + length.append(len(tree_matcher)) + + n = max(length) + for le, sc in zip(length, found_ratio): + comb.add_value((float(le) / n) + sc) + + self.config = comb.get_best() # type: ignore def train(self, input_: schemas.LogSchema) -> None: # type: ignore self.drain_gen.add(input_["log"]) diff --git a/tests/test_parsers/test_drain.py b/tests/test_parsers/test_drain.py index d4d9669e..07aebb9b 100644 --- a/tests/test_parsers/test_drain.py +++ b/tests/test_parsers/test_drain.py @@ -1,5 +1,9 @@ """Most of the functionality is test it in DetectMatePerformance.""" -from detectmatelibrary.parsers.drain import DrainParser +from detectmatelibrary.parsers.drain import DrainParser, _found_ratio + +from detectmateperformance.match_tree import TreeMatcher +from detectmateperformance.types_ import LogTemplates + from detectmatelibrary import schemas @@ -103,15 +107,34 @@ def test_not_reset_train(self): parsed = parser.process(schemas.LogSchema({"log": "hello there, sargent kenobi!"})) assert parsed["template"] == "hello there <*> kenobi" + def test_not_ration_found(self): + tree_matcher = TreeMatcher(LogTemplates(["hello there <*> kenobi"])) + + logs = ["hello there general kenobi", "akuna matata"] + assert 0.5 == _found_ratio(logs, tree_matcher) + + logs = ["hello there general kenobi"] + assert 0. == _found_ratio(logs, tree_matcher) + def test_no_auto_config_but_no_initialization(self): config_dict = { "parsers": { "DrainParser": { "method_type": "drain_parser", + "depth": 2, + "max_childs": 10, + "sim_thres": 0.2, "auto_config": True, "data_use_configure": 2, "data_use_training": 2, } } } - DrainParser(config=config_dict) + parser = DrainParser(config=config_dict) + parser.process(schemas.LogSchema({"log": "hello there, general kenobi!"})) + parser.process(schemas.LogSchema({"log": "hello there, captain kenobi!"})) + parser.process(schemas.LogSchema({"log": "hello there, captain kenobi!"})) + + assert parser.config.depth == 1 + assert parser.config.max_childs == 10 + assert parser.config.sim_thres == 0.2 From f1f4712afd1c55b25306316a5493a4a47c487465 Mon Sep 17 00:00:00 2001 From: "angre.garcia-gomez@ait.ac.at" Date: Fri, 24 Jul 2026 15:56:20 +0200 Subject: [PATCH 08/33] small reformat --- src/detectmatelibrary/parsers/drain.py | 40 +++++++++++++++----------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/src/detectmatelibrary/parsers/drain.py b/src/detectmatelibrary/parsers/drain.py index 0f4e8167..c10e589d 100644 --- a/src/detectmatelibrary/parsers/drain.py +++ b/src/detectmatelibrary/parsers/drain.py @@ -43,6 +43,28 @@ def _found_ratio(logs: list[str], tree_matcher: TreeMatcher) -> float: return score / len(results) +def _get_best_config(logs: list[str], config: DrainConfig) -> DrainConfig: + + found_ratio: list[float] = [] + length: list[int] = [] + + for config in (comb := Combinations(config))(): # type: ignore + drain = _init_drain(config) + for input_ in logs: + drain.add(input_) + tree_matcher = drain.generate() + + found_ratio.append(_found_ratio(logs, tree_matcher)) + length.append(len(tree_matcher)) + + n = max(length) + for le, sc in zip(length, found_ratio): + comb.add_value((float(le) / n) + sc) + + new_config: DrainConfig = comb.get_best() # type: ignore + return new_config + + class DrainParser(CoreParser): def __init__( self, @@ -64,22 +86,8 @@ def configure(self, input_: schemas.LogSchema) -> None: # type: ignore self.config_buffer.append(input_["log"]) def set_configuration(self) -> None: - found_ratio: list[float] = [] - length: list[int] = [] - for config in (comb := Combinations(self.config))(): - drain = _init_drain(config) # type: ignore - for input_ in self.config_buffer: - drain.add(input_) - tree_matcher = drain.generate() - - found_ratio.append(_found_ratio(self.config_buffer, tree_matcher)) - length.append(len(tree_matcher)) - - n = max(length) - for le, sc in zip(length, found_ratio): - comb.add_value((float(le) / n) + sc) - - self.config = comb.get_best() # type: ignore + self.config = _get_best_config(self.config_buffer, config=self.config) + self.config_buffer = [] def train(self, input_: schemas.LogSchema) -> None: # type: ignore self.drain_gen.add(input_["log"]) From c1358f0b314123f19ae864726141665f20515062 Mon Sep 17 00:00:00 2001 From: "angre.garcia-gomez@ait.ac.at" Date: Mon, 27 Jul 2026 09:11:22 +0200 Subject: [PATCH 09/33] remove print --- src/detectmatelibrary/parsers/drain.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/detectmatelibrary/parsers/drain.py b/src/detectmatelibrary/parsers/drain.py index c10e589d..bc3fb49c 100644 --- a/src/detectmatelibrary/parsers/drain.py +++ b/src/detectmatelibrary/parsers/drain.py @@ -33,7 +33,6 @@ def _init_drain(config: DrainConfig) -> Drain: def _found_ratio(logs: list[str], tree_matcher: TreeMatcher) -> float: results = tree_matcher.match_batch(logs).get_all_templates() - print(results) score = 0.0 for template in results: From 75c5d29dc8ec2d93f36ae1f2b9192fe811d6a4ca Mon Sep 17 00:00:00 2001 From: "angre.garcia-gomez@ait.ac.at" Date: Mon, 17 Aug 2026 08:21:12 +0200 Subject: [PATCH 10/33] minor logging --- src/detectmatelibrary/common/deeplearning_detector.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/detectmatelibrary/common/deeplearning_detector.py b/src/detectmatelibrary/common/deeplearning_detector.py index 1189483e..25a88b58 100644 --- a/src/detectmatelibrary/common/deeplearning_detector.py +++ b/src/detectmatelibrary/common/deeplearning_detector.py @@ -7,6 +7,7 @@ from detectmatelibrary import schemas from typing import Any +import logging class DeepLearningDetectorConfig(CoreDetectorConfig): @@ -72,8 +73,8 @@ def post_train(self) -> None: if "top_k" in self.stats: self.top_k = int(self.stats["top_k"]) - print(self.model) - print("Top k assigned", self.top_k) + logging.info(self.model) + logging.info(f"Top k assigned {self.top_k}") def detect( self, From 8cdc84c7612d849c9119832d2d619a16c67ac998 Mon Sep 17 00:00:00 2001 From: "angre.garcia-gomez@ait.ac.at" Date: Mon, 17 Aug 2026 08:27:39 +0200 Subject: [PATCH 11/33] move component to _basic_component --- .../common/_core_op/_basic_component.py | 55 ++++++++++++++++++ src/detectmatelibrary/common/core.py | 57 +------------------ 2 files changed, 58 insertions(+), 54 deletions(-) create mode 100644 src/detectmatelibrary/common/_core_op/_basic_component.py diff --git a/src/detectmatelibrary/common/_core_op/_basic_component.py b/src/detectmatelibrary/common/_core_op/_basic_component.py new file mode 100644 index 00000000..9da8f740 --- /dev/null +++ b/src/detectmatelibrary/common/_core_op/_basic_component.py @@ -0,0 +1,55 @@ +from detectmatelibrary.common.core import CoreConfig +from detectmatelibrary.schemas import BaseSchema +from detectmatelibrary.utils.persistency.component_interfaces import Stoppable + + +from typing import Any, Dict, List + + +class Component: + """Empty methods.""" + def __init__( + self, + name: str, + type_: str = "Core", + config: CoreConfig = CoreConfig(), + ) -> None: + self.name, self.type_, self.config = name, type_, config + self.saver: Stoppable | None = None + + def __repr__(self) -> str: + return f"<{self.type_}> {self.name}: {self.config}" + + def run( + self, input_: List[BaseSchema] | BaseSchema, output_: BaseSchema + ) -> bool: + return False + + def train( + self, input_: List[BaseSchema] | BaseSchema, + ) -> None: + pass + + def configure( + self, input_: List[BaseSchema] | BaseSchema, + ) -> None: + pass + + def set_configuration(self) -> None: + pass + + def post_train(self) -> None: + pass + + def get_config(self) -> Dict[str, Any]: + return self.config.get_config() + + def update_config(self, new_config: Dict[str, Any]) -> None: + self.config.update_config(new_config) + + def __enter__(self) -> "Component": + return self + + def __exit__(self, *_: Any) -> None: + if self.saver is not None: + self.saver.stop() diff --git a/src/detectmatelibrary/common/core.py b/src/detectmatelibrary/common/core.py index 7d52d54c..151cf45c 100644 --- a/src/detectmatelibrary/common/core.py +++ b/src/detectmatelibrary/common/core.py @@ -1,3 +1,4 @@ +from detectmatelibrary.common._core_op._basic_component import Component from detectmatelibrary.common._core_op._fit_logic import FitLogicState, StatesL from detectmatelibrary.common._core_op._schema_pipeline import SchemaPipeline from detectmatelibrary.common._core_op._fit_logic import FitLogic @@ -12,10 +13,9 @@ from detectmatelibrary.tools.logging import logger, setup_logging -from typing import Any, Dict, List +from typing import Any from detectmatelibrary.utils.persistency.component_interfaces import PersistencyOp -from detectmatelibrary.utils.persistency.component_interfaces import Stoppable setup_logging() @@ -43,7 +43,7 @@ def __iter__(self) -> "TrainBuffer": return self -# Core component skeleton structure ################################################ +# Core component ################################################ class CoreConfig(BasicConfig): start_id: int = 10 @@ -52,57 +52,6 @@ class CoreConfig(BasicConfig): use_config_data_as_training: bool = True -class Component: - """Empty methods.""" - def __init__( - self, - name: str, - type_: str = "Core", - config: CoreConfig = CoreConfig(), - ) -> None: - self.name, self.type_, self.config = name, type_, config - self.saver: Stoppable | None = None - - def __repr__(self) -> str: - return f"<{self.type_}> {self.name}: {self.config}" - - def run( - self, input_: List[BaseSchema] | BaseSchema, output_: BaseSchema - ) -> bool: - return False - - def train( - self, input_: List[BaseSchema] | BaseSchema, - ) -> None: - pass - - def configure( - self, input_: List[BaseSchema] | BaseSchema, - ) -> None: - pass - - def set_configuration(self) -> None: - pass - - def post_train(self) -> None: - pass - - def get_config(self) -> Dict[str, Any]: - return self.config.get_config() - - def update_config(self, new_config: Dict[str, Any]) -> None: - self.config.update_config(new_config) - - def __enter__(self) -> "Component": - return self - - def __exit__(self, *_: Any) -> None: - if self.saver is not None: - self.saver.stop() - - -# Core component ################################################ - class CoreComponent(Component): """Base class for all components in the system.""" def __init__( From 39c5c12972203d21208b0f25e2deab5f59ab71ae Mon Sep 17 00:00:00 2001 From: "angre.garcia-gomez@ait.ac.at" Date: Mon, 17 Aug 2026 09:19:51 +0200 Subject: [PATCH 12/33] start integrating prototype code into production --- .../common/_core_op/_basic_component.py | 6 +- .../common/_core_op/_fed_component.py | 100 ++++++++++++++++++ src/detectmatelibrary/common/core.py | 9 +- 3 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 src/detectmatelibrary/common/_core_op/_fed_component.py diff --git a/src/detectmatelibrary/common/_core_op/_basic_component.py b/src/detectmatelibrary/common/_core_op/_basic_component.py index 9da8f740..143a8e99 100644 --- a/src/detectmatelibrary/common/_core_op/_basic_component.py +++ b/src/detectmatelibrary/common/_core_op/_basic_component.py @@ -1,7 +1,7 @@ -from detectmatelibrary.common.core import CoreConfig -from detectmatelibrary.schemas import BaseSchema from detectmatelibrary.utils.persistency.component_interfaces import Stoppable +from detectmatelibrary.schemas import BaseSchema +from detectmatelibrary.common._config import BasicConfig from typing import Any, Dict, List @@ -12,7 +12,7 @@ def __init__( self, name: str, type_: str = "Core", - config: CoreConfig = CoreConfig(), + config: BasicConfig = BasicConfig(), ) -> None: self.name, self.type_, self.config = name, type_, config self.saver: Stoppable | None = None diff --git a/src/detectmatelibrary/common/_core_op/_fed_component.py b/src/detectmatelibrary/common/_core_op/_fed_component.py new file mode 100644 index 00000000..04c40be1 --- /dev/null +++ b/src/detectmatelibrary/common/_core_op/_fed_component.py @@ -0,0 +1,100 @@ + +import warnings +from typing import Self, overload + + +class IncompabtibleFed(Exception): + def __init__(self) -> None: + super().__init__("Instances are incompatible") + + +class _CompOp: + @staticmethod + def is_compatible(main_inst: object, other_inst: object) -> None: + if not isinstance(other_inst, type(main_inst)): + raise IncompabtibleFed() + + @staticmethod + def reset(main_inst: object, attr: str) -> None: + main_inst.__setattr__(attr, {main_inst}) + + @staticmethod + def combine(main_inst: object, attr: str, other_inst: object) -> None: + _CompOp.is_compatible(main_inst, other_inst) + set_main: set[object] = getattr(main_inst, attr) + + set_main.update(getattr(other_inst, attr)) + for elem in set_main: + getattr(elem, attr).update(set_main) + + @staticmethod + def uncombine(main_inst: object, attr: str, other_inst: object) -> None: + _CompOp.is_compatible(main_inst, other_inst) + set_main: set[object] = getattr(main_inst, attr) + + for elem in list(set_main): + if elem == other_inst: + _CompOp.reset(other_inst, attr) + else: + elem._components.remove(other_inst) # type: ignore + + @staticmethod + def stack(main_inst: object, attr: str, list_other_inst: list[object]) -> None: + set_main: set[object] = getattr(main_inst, attr) + for other_inst in list_other_inst: + _CompOp.is_compatible(main_inst, other_inst) + set_main.add(other_inst) + + +class FedOperations: + __COMPONENT: str = "_components" + + def __init__(self) -> None: + self._components: set["FedOperations"] + _CompOp.reset(self, self.__COMPONENT) + + def __add__(self, other: object) -> Self: + _CompOp.combine(self, attr=self.__COMPONENT, other_inst=other) + + return self + + def __sub__(self, other: object) -> Self: + _CompOp.uncombine(self, attr=self.__COMPONENT, other_inst=other) + + return self + + @overload + def stack(self, other: bytes | list[bytes]) -> None: + pass + + @overload + def stack(self, other: object | list[object]) -> None: + pass + + def stack(self, other: object | list[object | bytes] | bytes) -> None: + if not isinstance(other, list): + other = [other] + _CompOp.stack( + self, attr=self.__COMPONENT, list_other_inst=[ + self.from_binary(inst) if isinstance(inst, bytes) else inst for inst in other + ] + ) + + def aggregate(self, unstack: bool = False) -> None | bytes: + self.aggregate_strategy(self._components) + + if unstack: + _CompOp.reset(self, self.__COMPONENT) + + return self.to_binary() + + def to_binary(self) -> bytes | None: + warnings.warn("To binary not implemented, return None") + return None + + def from_binary(self, binary: bytes) -> object: + warnings.warn(f"To binary not implemented, return None for {binary!r}") + return None + + def aggregate_strategy(self, components: set["FedOperations"]) -> None: + warnings.warn(f"No strategy found, aggregations does nothing for {components}") diff --git a/src/detectmatelibrary/common/core.py b/src/detectmatelibrary/common/core.py index 151cf45c..75a3c645 100644 --- a/src/detectmatelibrary/common/core.py +++ b/src/detectmatelibrary/common/core.py @@ -1,6 +1,7 @@ -from detectmatelibrary.common._core_op._basic_component import Component from detectmatelibrary.common._core_op._fit_logic import FitLogicState, StatesL from detectmatelibrary.common._core_op._schema_pipeline import SchemaPipeline +from detectmatelibrary.common._core_op._fed_component import FedOperations +from detectmatelibrary.common._core_op._basic_component import Component from detectmatelibrary.common._core_op._fit_logic import FitLogic from detectmatelibrary.utils.data_buffer import DataBuffer, ArgsBuffer, BufferMode @@ -52,7 +53,7 @@ class CoreConfig(BasicConfig): use_config_data_as_training: bool = True -class CoreComponent(Component): +class CoreComponent(Component, FedOperations): """Base class for all components in the system.""" def __init__( self, @@ -63,7 +64,9 @@ def __init__( input_schema: type[BaseSchema] = BaseSchema, output_schema: type[BaseSchema] = BaseSchema ) -> None: - super().__init__(name=name, type_=type_, config=config) + Component.__init__(self, name=name, type_=type_, config=config) + FedOperations.__init__(self) + self.config: CoreConfig self.input_schema, self.output_schema = input_schema, output_schema self.data_buffer = DataBuffer(args_buffer) From 67c3f18e1ec842f43fc7ed9b770a7429bde3a78e Mon Sep 17 00:00:00 2001 From: "angre.garcia-gomez@ait.ac.at" Date: Mon, 17 Aug 2026 09:37:06 +0200 Subject: [PATCH 13/33] add initial tests --- tests/test_common/test_core_federation.py | 135 ++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 tests/test_common/test_core_federation.py diff --git a/tests/test_common/test_core_federation.py b/tests/test_common/test_core_federation.py new file mode 100644 index 00000000..f2b93032 --- /dev/null +++ b/tests/test_common/test_core_federation.py @@ -0,0 +1,135 @@ +from detectmatelibrary.common._core_op._fed_component import IncompabtibleFed +from detectmatelibrary.common.core import CoreComponent + +import struct + +import pytest + + +class DummyComponent(CoreComponent): + pass + + +class DummyComponent2(CoreComponent): + pass + + +class TestJoinOp: + def test_add(self) -> None: + component1 = CoreComponent(name="comp_1") + component2 = CoreComponent(name="comp_2") + component3 = CoreComponent(name="comp_3") + + component2 + component3 + assert len(component2._components) == 2 + assert component3._components == component2._components + + component1 = component1 + component3 + print(component2._components) + assert len(component1._components) == 3 + assert component1._components == component2._components + assert component1._components == component3._components + + def test_sub(self) -> None: + component1 = CoreComponent(name="comp_1") + component2 = CoreComponent(name="comp_2") + component3 = CoreComponent(name="comp_3") + + (component1 + component2 + component3) - component3 + assert len(component2._components) == 2 + assert component1._components == component2._components + assert component3._components == {component3} + + def test_incompatible(self) -> None: + component1 = DummyComponent2(name="comp_1") + component2 = DummyComponent(name="comp_2") + + with pytest.raises(IncompabtibleFed): + component1 + component2 + with pytest.raises(IncompabtibleFed): + component1 - component2 + + def test_stack(self) -> None: + component1 = CoreComponent(name="comp_1") + component2 = CoreComponent(name="comp_2") + component3 = CoreComponent(name="comp_3") + component4 = CoreComponent(name="comp_4") + + component1.stack(component2) + assert len(component1._components) == 2 + assert component1._components != component2._components + + component1.stack([component3, component4]) + assert len(component1._components) == 4 + + +class DummyAppendList(CoreComponent): + def __init__( + self, elems: list[str], name: str = "test", *args, **kwargs + ) -> None: + super().__init__(name, *args, **kwargs) + self.elems = elems + + def aggregate_strategy(self, components): + final_list = [] + for component in components: + final_list.extend(component.elems) + + final_list = list(set(final_list)) + for component in components: + component.elems = final_list + + def to_binary(self): + return struct.pack(f">{len(self.elems)}h", *self.elems) + + def from_binary(self, binary): + num_ints = len(binary) // 2 + elems = list(struct.unpack(f">{num_ints}h", binary)) + return DummyAppendList(elems=elems) + + +class TestFedComponent: + def test_basic_aggregation(self) -> None: + comp1 = DummyAppendList(elems=[1, 2]) + comp2 = DummyAppendList(elems=[3, 4]) + + comp1.aggregate() + assert set(comp1.elems) == {1, 2} + + (comp1 + comp2).aggregate() + assert set(comp1.elems) == {1, 2, 3, 4} + + def test_stack_basic_aggregation(self) -> None: + comp1 = DummyAppendList(elems=[1, 2]) + comp2 = DummyAppendList(elems=[3, 4]) + comp3 = DummyAppendList(elems=[5]) + + comp1.stack([comp2, comp3]) + comp1.aggregate(unstack=True) + assert set(comp1.elems) == {1, 2, 3, 4, 5} + assert len(comp1._components) == 1 + + comp1.stack([comp2, comp3]) + comp1.aggregate(unstack=False) + assert set(comp1.elems) == {1, 2, 3, 4, 5} + assert len(comp1._components) == 3 + + def test_sanity_check(self) -> None: + comp1 = DummyAppendList(elems=[1, 2]) + comp2 = comp1.from_binary(comp1.to_binary()) + + assert comp2.elems == [1, 2] + + def test_stack_binary_aggregation(self) -> None: + comp1 = DummyAppendList(elems=[1, 2]) + comp2 = DummyAppendList(elems=[3, 4]) + comp3 = DummyAppendList(elems=[5]) + + comp1.stack([comp2.to_binary(), comp3.to_binary()]) + comp1.aggregate(unstack=True) + assert set(comp1.elems) == {1, 2, 3, 4, 5} + + comp1.stack([comp2.to_binary(), comp3.to_binary()]) + output = comp1.aggregate(unstack=True) + assert set(comp1.elems) == {1, 2, 3, 4, 5} + assert isinstance(output, bytes) From c469bbadfaa423ce9cb823e3357d866668a1b2b4 Mon Sep 17 00:00:00 2001 From: "angre.garcia-gomez@ait.ac.at" Date: Mon, 17 Aug 2026 09:46:21 +0200 Subject: [PATCH 14/33] add warning tests --- tests/test_common/test_core_federation.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/test_common/test_core_federation.py b/tests/test_common/test_core_federation.py index f2b93032..66627033 100644 --- a/tests/test_common/test_core_federation.py +++ b/tests/test_common/test_core_federation.py @@ -25,7 +25,6 @@ def test_add(self) -> None: assert component3._components == component2._components component1 = component1 + component3 - print(component2._components) assert len(component1._components) == 3 assert component1._components == component2._components assert component1._components == component3._components @@ -88,6 +87,14 @@ def from_binary(self, binary): return DummyAppendList(elems=elems) +class DummyAppendListEmpty(CoreComponent): + def __init__( + self, elems: list[str], name: str = "test", *args, **kwargs + ) -> None: + super().__init__(name, *args, **kwargs) + self.elems = elems + + class TestFedComponent: def test_basic_aggregation(self) -> None: comp1 = DummyAppendList(elems=[1, 2]) @@ -133,3 +140,17 @@ def test_stack_binary_aggregation(self) -> None: output = comp1.aggregate(unstack=True) assert set(comp1.elems) == {1, 2, 3, 4, 5} assert isinstance(output, bytes) + + def test_empty_feed_fields(self) -> None: + comp1 = DummyAppendListEmpty(elems=[1, 2]) + comp2 = DummyAppendListEmpty(elems=[3, 4]) + comp3 = DummyAppendListEmpty(elems=[5]) + + with pytest.warns(UserWarning): + comp1.to_binary() + + with pytest.warns(UserWarning): + comp1.from_binary(b"") + + with pytest.warns(UserWarning): + comp1.aggregate_strategy({comp1, comp2, comp3}) From 6c5a0e4fe0d09cd46f84de2941f3436338782a16 Mon Sep 17 00:00:00 2001 From: "angre.garcia-gomez@ait.ac.at" Date: Mon, 17 Aug 2026 10:04:23 +0200 Subject: [PATCH 15/33] update overall architecture --- docs/overall_architecture.md | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/docs/overall_architecture.md b/docs/overall_architecture.md index 49f8e821..77850f6e 100644 --- a/docs/overall_architecture.md +++ b/docs/overall_architecture.md @@ -70,14 +70,42 @@ class Component(CoreComponent): * Default: the component is just processing data """ + def export_state( + self, path: str | None = None, storage_options: dict[str, Any] | None = None, + ) -> bytes | None: + """Export the current state if persistency class was implemented""" + + def import_state( + self, path: str | bytes, storage_options: dict[str, Any] | None = None + ) -> None: + """Import the current state if persistency class was implemented""" + def process(self, data: BaseSchema | bytes) -> BaseSchema | bytes | None: """Process the data in a stream fashion (Defined in the CoreComponent)""" def get_config(self) -> Dict[str, Any]: - """"Get the configuration of the component (Defined in the CoreComponent)""" + """Get the configuration of the component (Defined in the CoreComponent)""" def update_config(self, new_config: Dict[str, Any]) -> None: - """"Update the configuration of the component (Defined in the CoreComponent)""" + """Update the configuration of the component (Defined in the CoreComponent)""" + + def get_window_size(self) -> int: + """Get window size of the data buffer""" + + def stack(self, other: object | list[object | bytes] | bytes) -> None: + """(Federation only) stack multiple components for federation tasks""" + + def aggregate(self, unstack: bool = False) -> None | bytes: + """(Federation only) aggregate multiple components""" + + def to_binary(self) -> bytes | None: + """(Federation only) fill it to be compatible with federation ops""" + + def from_binary(self, binary: bytes) -> object: + """(Federation only) fill it to be compatible with federation ops""" + + def aggregate_strategy(self, components: set["FedOperations"]) -> None: + """(Federation only) fill it to be compatible with federation ops""" ``` Go back [Index](index.md) From 81c60ee334b23579ded19e3d7369be54678460a2 Mon Sep 17 00:00:00 2001 From: "angre.garcia-gomez@ait.ac.at" Date: Mon, 17 Aug 2026 12:11:55 +0200 Subject: [PATCH 16/33] first version of docs --- docs/diagrams.drawio | 292 +++++++++++++++++++++++++-------- docs/federation.md | 128 +++++++++++++++ docs/img/fed_combine_first.png | Bin 0 -> 80494 bytes docs/img/fed_stack_later.png | Bin 0 -> 91311 bytes mkdocs.yml | 1 + 5 files changed, 349 insertions(+), 72 deletions(-) create mode 100644 docs/federation.md create mode 100644 docs/img/fed_combine_first.png create mode 100644 docs/img/fed_stack_later.png diff --git a/docs/diagrams.drawio b/docs/diagrams.drawio index 61c4bf68..3444a1ca 100644 --- a/docs/diagrams.drawio +++ b/docs/diagrams.drawio @@ -1,108 +1,154 @@ - - + + - - + + - - + + - - + + - - + + - - + + - + + + + + + + - - - - - - + + - + - - - - - - + + - - + + + + + - - + + - - + + - + + + + - - + + - + - - + + - + - - + + - - + + + + + - - + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - + - - - - - - + + - + - - - - - - + + + + + + + + + + + + + + - + @@ -215,25 +261,25 @@ - + - + - + - + - + @@ -242,8 +288,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -280,16 +428,16 @@ - + - + - + diff --git a/docs/federation.md b/docs/federation.md new file mode 100644 index 00000000..dcc5e069 --- /dev/null +++ b/docs/federation.md @@ -0,0 +1,128 @@ +# Federation + +In this section, we will explain how to use the federation setup. For a component to use federation needs to have implemented the next methods. + +```python + def to_binary(self) -> bytes | None: + """(Federation only) fill it to be compatible with federation ops""" + + def from_binary(self, binary: bytes) -> object: + """(Federation only) fill it to be compatible with federation ops""" + + def aggregate_strategy(self, components: set["FedOperations"]) -> None: + """(Federation only) fill it to be compatible with federation ops""" +``` + +There are two man ways to use federation: + +* **Combine first**: only can be use when all components run locally. The main idea is to simplify the process by allowing components to share memory. +* **Stack later**: a more standard approach to federated where the "weights" of each component are combine at the end. + +## Example class + +For all the examplaes bellow, we will use this code: + +```python +import struct + + +class NewComponent(CoreComponent): # Inherent from CoreComponent + def __init__(self, elems): + self.elems = elems + super().__init__() + + def aggregate_strategy(self, components): + final_list = [] + for component in components: + final_list.extend(component.elems) + + final_list = list(set(final_list)) + for component in components: + component.elems = final_list + + def to_binary(self): + return struct.pack(f">{len(self.elems)}h", *self.elems) + + def from_binary(self, binary): + num_ints = len(binary) // 2 + elems = list(struct.unpack(f">{num_ints}h", binary)) + return NewComponent(elems=elems) +``` + + +## Combine first + +The diagram bellow show the workflow: + +![combine](img/fed_combine_first.png) + + +Example 1: +```python +detector1 = NewComponent([1, 2, 3]) +detector2 = NewComponent([4, 5]) +detector3 = NewComponent([6]) + +detector1 + detector2 + detector3 + +detector2.aggregate() # Detector 2 is used as centralize node + +print("Dectector 3", detector3.elems) # All detectors have been updated +``` + +Example 2: + +```python +detector1 = NewComponent([1, 2, 3]) +detector2 = NewComponent([4, 5]) +detector3 = NewComponent([6]) + +(detector1 + detector2 + detector3) - detector2 + +detector2.aggregate() # Detector 2 is used as centralize node +print("Detector 3", detector3.elems) # Still the same + +detector1.aggregate() # Detector 1 is used as centralize node +print("Detector 3", detector3.elems) # Detector 3 has been updated now +``` + +## Stack + +The diagram bellow show the workflow: + +![combine](img/fed_stack_later.png) + +Example 1: +```python +detector1 = NewComponent([1, 2, 3]) +detector2 = NewComponent([4, 5]) +detector3 = NewComponent([6]) + +detector1.stack([detector2, detector3]) + +detector2.aggregate() +print("Detector 3", detector3.elems) # It is not longer combine but stack, so it will not work + +detector1.aggregate() +print("Detector 3", detector3.elems) # Now it will work +``` + +Example 2: +```python +detector1 = NewComponent([1, 2, 3]) + +binary2 = NewComponent([4, 5]).to_binary() +binary3 = (detector3 := NewComponent([6])).to_binary() +print("Binary of Detector 3", binary3) + +detector1.stack([binary2, binary3]) +output = detector1.aggregate(unstack=True) # unstack = True will free memory + +print("Detector 1", detector1.elems) # Detector 1 has been updated +print("Output binary", output) # Output that we send to other componets + +# We update detector 3 now +print("Detector 3", detector3.elems) # Now detector 3 is not in the share memory so it will not work +detector3 = detector3.from_binary(output) +print("Detector 3", detector3.elems) # Now it will work +``` diff --git a/docs/img/fed_combine_first.png b/docs/img/fed_combine_first.png new file mode 100644 index 0000000000000000000000000000000000000000..4a923e097f2f42c2eba90409e409eb9a7157246b GIT binary patch literal 80494 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 literal 0 HcmV?d00001 diff --git a/docs/img/fed_stack_later.png b/docs/img/fed_stack_later.png new file mode 100644 index 0000000000000000000000000000000000000000..42a2edf1a15ddc3d19db82fd043a38061309dc32 GIT binary patch literal 91311 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%0DFoDKM(} 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 H5%T{4`(-#c literal 0 HcmV?d00001 diff --git a/mkdocs.yml b/mkdocs.yml index 1e901d3c..1faf60e8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -14,6 +14,7 @@ nav: - Basic concepts: basic_idea.md - Overall architecture: overall_architecture.md - Schemas: schemas.md + - Federation: federation.md - Parsers: parsers.md - Detectors: detectors.md - Alert Aggregation: alert_aggregator.md From 68fdda79947c4565416a7ee10c105ad45af92d85 Mon Sep 17 00:00:00 2001 From: "angre.garcia-gomez@ait.ac.at" Date: Mon, 17 Aug 2026 12:26:52 +0200 Subject: [PATCH 17/33] add automated examples in federated docs --- docs/examples/others/federation.py | 88 ++++++++++++++++++++++++++++++ docs/federation.md | 79 +++------------------------ 2 files changed, 96 insertions(+), 71 deletions(-) create mode 100644 docs/examples/others/federation.py diff --git a/docs/examples/others/federation.py b/docs/examples/others/federation.py new file mode 100644 index 00000000..8e21ffa2 --- /dev/null +++ b/docs/examples/others/federation.py @@ -0,0 +1,88 @@ + +# --8<-- [start:example_1] +from detectmatelibrary.common.core import CoreComponent +import struct + + +class NewComponent(CoreComponent): # Inherent from CoreComponent + def __init__(self, elems): + self.elems = elems + super().__init__(name="FedExample") + + def aggregate_strategy(self, components): + final_list = [] + for component in components: + final_list.extend(component.elems) + + final_list = list(set(final_list)) + for component in components: + component.elems = final_list + + def to_binary(self): + return struct.pack(f">{len(self.elems)}h", *self.elems) + + def from_binary(self, binary): + num_ints = len(binary) // 2 + elems = list(struct.unpack(f">{num_ints}h", binary)) + return NewComponent(elems=elems) + + +# --8<-- [end:example_1] +# --8<-- [start:example_2] +detector1 = NewComponent([1, 2, 3]) +detector2 = NewComponent([4, 5]) +detector3 = NewComponent([6]) + +detector1 + detector2 + detector3 + +detector2.aggregate() # Detector 2 is used as centralize node + +print("Dectector 3", detector3.elems) # All detectors have been updated + +# --8<-- [end:example_2] + +# --8<-- [start:example_3] +detector1 = NewComponent([1, 2, 3]) +detector2 = NewComponent([4, 5]) +detector3 = NewComponent([6]) + +detector1 + detector2 + detector3 + +detector2.aggregate() # Detector 2 is used as centralize node + +print("Dectector 3", detector3.elems) # All detectors have been updated +# --8<-- [end:example_3] + +# --8<-- [start:example_4] +detector1 = NewComponent([1, 2, 3]) +detector2 = NewComponent([4, 5]) +detector3 = NewComponent([6]) + +detector1.stack([detector2, detector3]) + +detector2.aggregate() +print("Detector 3", detector3.elems) # It is not longer combine but stack, so it will not work + +detector1.aggregate() +print("Detector 3", detector3.elems) # Now it will work + +# --8<-- [end:example_4] + +# --8<-- [start:example_5] +detector1 = NewComponent([1, 2, 3]) + +binary2 = NewComponent([4, 5]).to_binary() +binary3 = (detector3 := NewComponent([6])).to_binary() +print("Binary of Detector 3", binary3) + +detector1.stack([binary2, binary3]) +output = detector1.aggregate(unstack=True) # unstack = True will free memory + +print("Detector 1", detector1.elems) # Detector 1 has been updated +print("Output binary", output) # Output that we send to other componets + +# We update detector 3 now +print("Detector 3", detector3.elems) # Now detector 3 is not in the share memory so it will not work +detector3 = detector3.from_binary(output) +print("Detector 3", detector3.elems) # Now it will work +# --8<-- [end:example_5] diff --git a/docs/federation.md b/docs/federation.md index dcc5e069..b19459ca 100644 --- a/docs/federation.md +++ b/docs/federation.md @@ -23,30 +23,7 @@ There are two man ways to use federation: For all the examplaes bellow, we will use this code: ```python -import struct - - -class NewComponent(CoreComponent): # Inherent from CoreComponent - def __init__(self, elems): - self.elems = elems - super().__init__() - - def aggregate_strategy(self, components): - final_list = [] - for component in components: - final_list.extend(component.elems) - - final_list = list(set(final_list)) - for component in components: - component.elems = final_list - - def to_binary(self): - return struct.pack(f">{len(self.elems)}h", *self.elems) - - def from_binary(self, binary): - num_ints = len(binary) // 2 - elems = list(struct.unpack(f">{num_ints}h", binary)) - return NewComponent(elems=elems) +--8<-- "docs/examples/others/federation.py:example_1" ``` @@ -58,32 +35,15 @@ The diagram bellow show the workflow: Example 1: -```python -detector1 = NewComponent([1, 2, 3]) -detector2 = NewComponent([4, 5]) -detector3 = NewComponent([6]) - -detector1 + detector2 + detector3 - -detector2.aggregate() # Detector 2 is used as centralize node -print("Dectector 3", detector3.elems) # All detectors have been updated +```python +--8<-- "docs/examples/others/federation.py:example_2" ``` Example 2: ```python -detector1 = NewComponent([1, 2, 3]) -detector2 = NewComponent([4, 5]) -detector3 = NewComponent([6]) - -(detector1 + detector2 + detector3) - detector2 - -detector2.aggregate() # Detector 2 is used as centralize node -print("Detector 3", detector3.elems) # Still the same - -detector1.aggregate() # Detector 1 is used as centralize node -print("Detector 3", detector3.elems) # Detector 3 has been updated now +--8<-- "docs/examples/others/federation.py:example_3" ``` ## Stack @@ -93,36 +53,13 @@ The diagram bellow show the workflow: ![combine](img/fed_stack_later.png) Example 1: -```python -detector1 = NewComponent([1, 2, 3]) -detector2 = NewComponent([4, 5]) -detector3 = NewComponent([6]) -detector1.stack([detector2, detector3]) - -detector2.aggregate() -print("Detector 3", detector3.elems) # It is not longer combine but stack, so it will not work - -detector1.aggregate() -print("Detector 3", detector3.elems) # Now it will work +```python +--8<-- "docs/examples/others/federation.py:example_4" ``` Example 2: -```python -detector1 = NewComponent([1, 2, 3]) - -binary2 = NewComponent([4, 5]).to_binary() -binary3 = (detector3 := NewComponent([6])).to_binary() -print("Binary of Detector 3", binary3) - -detector1.stack([binary2, binary3]) -output = detector1.aggregate(unstack=True) # unstack = True will free memory -print("Detector 1", detector1.elems) # Detector 1 has been updated -print("Output binary", output) # Output that we send to other componets - -# We update detector 3 now -print("Detector 3", detector3.elems) # Now detector 3 is not in the share memory so it will not work -detector3 = detector3.from_binary(output) -print("Detector 3", detector3.elems) # Now it will work +```python +--8<-- "docs/examples/others/federation.py:example_5" ``` From 0faee06821b3f2f67e5dafae3f1cfec34214db6a Mon Sep 17 00:00:00 2001 From: ipmach Date: Mon, 17 Aug 2026 12:48:04 +0200 Subject: [PATCH 18/33] Improve clarity and fix typos in federation.md Updated text for clarity and corrected typos in the federation documentation. --- docs/federation.md | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/docs/federation.md b/docs/federation.md index b19459ca..d531b5a4 100644 --- a/docs/federation.md +++ b/docs/federation.md @@ -1,39 +1,37 @@ # Federation -In this section, we will explain how to use the federation setup. For a component to use federation needs to have implemented the next methods. +This section explains how to use the federation setup. For a component to support federation, it must implement the following methods: ```python - def to_binary(self) -> bytes | None: - """(Federation only) fill it to be compatible with federation ops""" +def to_binary(self) -> bytes | None: + """(Federation only) Serialize to bytes for federation operations.""" - def from_binary(self, binary: bytes) -> object: - """(Federation only) fill it to be compatible with federation ops""" +def from_binary(self, binary: bytes) -> object: + """(Federation only) Deserialize from bytes for federation operations.""" - def aggregate_strategy(self, components: set["FedOperations"]) -> None: - """(Federation only) fill it to be compatible with federation ops""" +def aggregate_strategy(self, components: set["FedOperations"]) -> None: + """(Federation only) Define how to aggregate a set of federated components.""" ``` -There are two man ways to use federation: +There are two main ways to use federation: -* **Combine first**: only can be use when all components run locally. The main idea is to simplify the process by allowing components to share memory. -* **Stack later**: a more standard approach to federated where the "weights" of each component are combine at the end. +- **Combine first**: This can only be used when all components run locally. The main idea is to simplify the process by allowing components to share memory. +- **Stack later**: A more standard federated approach where the "weights" or state of each component are combined at the end. ## Example class -For all the examplaes bellow, we will use this code: +For all the examples below, we will use this code: ```python --8<-- "docs/examples/others/federation.py:example_1" ``` - ## Combine first -The diagram bellow show the workflow: +The diagram below shows the workflow: ![combine](img/fed_combine_first.png) - Example 1: ```python @@ -48,9 +46,9 @@ Example 2: ## Stack -The diagram bellow show the workflow: +The diagram below shows the workflow: -![combine](img/fed_stack_later.png) +![stack](img/fed_stack_later.png) Example 1: From 40a4704d9a7b7a0e186ca5f8697b63ddcb497ca7 Mon Sep 17 00:00:00 2001 From: viktorbeck98 Date: Thu, 20 Aug 2026 13:50:27 +0200 Subject: [PATCH 19/33] represent ECVC and SCVS state with persistency (#255) Both detectors kept their learned count vectors in a plain in-memory set, so a trained model was lost on restart. They now store them as keys in EventPersistency.events_seen, the pattern EventSequenceDetector already uses, which gives them save, load and auto-load for free. ECVC derives its matrix and threshold from those vectors in _derive(), called after training and after a load. The vectors are sorted first: restored keys are strings whose set iteration order is hash-randomized per process, and the seeded shuffle splits train from validation by that order, so sorting is what makes a restored model equal a trained one. Keys carry the window size, since a count vector's length is max(EventID) + 1 and says nothing about the window it was counted over. Restoring state at a different window_size now logs a warning instead of silently alerting on every window. The count vector and sequence codec helpers move to utils/sequence_encoding.py so no detector imports from another. As a result build_count_vec is no longer importable from scvs_detector. Co-Authored-By: Claude Opus 5 --- docs/detectors/ecvc_detector.md | 2 + docs/detectors/scvs_detector.md | 2 + .../detectors/ecvc_detector.py | 75 ++++++-- .../detectors/event_sequence_detector.py | 21 +-- .../detectors/scvs_detector.py | 54 ++++-- .../utils/sequence_encoding.py | 76 ++++++++ .../test_persist_integration.py | 175 ++++++++++++++++++ tests/test_detectors/test_scvs_detector.py | 3 +- 8 files changed, 356 insertions(+), 52 deletions(-) create mode 100644 src/detectmatelibrary/utils/sequence_encoding.py diff --git a/docs/detectors/ecvc_detector.md b/docs/detectors/ecvc_detector.md index f979618c..feb39782 100644 --- a/docs/detectors/ecvc_detector.md +++ b/docs/detectors/ecvc_detector.md @@ -11,6 +11,8 @@ The Event Count Vector Clustering Detector (ECVC) detects anomalies by calculati A count vector is form by counting the number of appearance of each event ID in a sequence of a specific window size. +Count vectors learned during training are stored via [persistency](../auxiliar/persistency.md), so a trained model can be saved and restored with a `persist:` block. A count vector is only comparable within the window it was counted over, so restoring state at a different `window_size` logs a warning — the restored vectors cannot match and every window would alert. + ## Configuration example diff --git a/docs/detectors/scvs_detector.md b/docs/detectors/scvs_detector.md index 8935516e..6025f700 100644 --- a/docs/detectors/scvs_detector.md +++ b/docs/detectors/scvs_detector.md @@ -11,6 +11,8 @@ The Sequence Count Vector Set Detector (SCVS) detects anomalies by finding count A count vector is formed by counting the number of appearance of each event ID in a sequence of a specific window size. +Count vectors learned during training are stored via [persistency](../auxiliar/persistency.md), so a trained model can be saved and restored with a `persist:` block. A count vector is only comparable within the window it was counted over, so restoring state at a different `window_size` logs a warning — the restored vectors cannot match and every window would alert. + ## Configuration example diff --git a/src/detectmatelibrary/detectors/ecvc_detector.py b/src/detectmatelibrary/detectors/ecvc_detector.py index 5ff6460a..2b92888b 100644 --- a/src/detectmatelibrary/detectors/ecvc_detector.py +++ b/src/detectmatelibrary/detectors/ecvc_detector.py @@ -1,7 +1,14 @@ -from typing import Any, List +from typing import Any, Collection, List from detectmatelibrary.common.detector import CoreDetector, CoreDetectorConfig +from detectmatelibrary.utils import persistency from detectmatelibrary.utils.data_buffer import BufferMode +from detectmatelibrary.utils.sequence_encoding import ( + build_count_vec, + decode_count_vec, + encode_count_vec, + warn_on_window_size_mismatch, +) from detectmatelibrary import schemas from math import ceil @@ -9,18 +16,7 @@ class ECVCOp: - @staticmethod - def build_count_vec(input_: List[schemas.ParserSchema]) -> tuple[int, ...]: - sequence, n = [0], 0 - for in_ in input_: - event = in_["EventID"] - if n < event: - for _ in range(n, event): - sequence.append(0) - n = event - sequence[event] += 1 - - return tuple(sequence) + build_count_vec = staticmethod(build_count_vec) @staticmethod def build_one_vec(input_: List[schemas.ParserSchema], n: int) -> np.ndarray: @@ -33,7 +29,7 @@ def build_one_vec(input_: List[schemas.ParserSchema], n: int) -> np.ndarray: return arr @staticmethod - def init_count_matrix(seqs: set[tuple[int, ...]]) -> np.ndarray: + def init_count_matrix(seqs: Collection[tuple[int, ...]]) -> np.ndarray: m, n = len(seqs), max([len(s) for s in seqs]) matrix = np.zeros((m, n)) @@ -91,24 +87,63 @@ def __init__( config=config, buffer_size=config.window_size ) - self.train_seqs: set[tuple[int, ...]] = set() self.count_vecs: np.ndarray | None = None self.threshold: float = 0 + # ponytail: only events_seen is used here — count vectors carry no + # variables. EventPersistency still requires an event_data_class. + self.persistency = persistency.EventPersistency( + event_data_class=persistency.EventStabilityTracker, + ) + self._register_persistency(self.persistency) # restores state when auto_load + warn_on_window_size_mismatch(self.name, self.persistency, self.config.window_size) + self._derive() # no-op unless auto_load restored count vectors + + def import_state( + self, path: str | bytes, storage_options: dict[str, Any] | None = None + ) -> None: + """Load state, then rebuild the matrix and threshold from it. + + Unlike `auto_load`, this runs after construction, so the derivation in + `__init__` has already run against an empty store and has to be redone. + """ + super().import_state(path, storage_options) + warn_on_window_size_mismatch(self.name, self.persistency, self.config.window_size) + self._derive() def train(self, input_: List[schemas.ParserSchema]) -> None: # type: ignore - self.train_seqs.add(ECVCOp.build_count_vec(input_)) + self.persistency.ingest_event( + event_id=encode_count_vec(self.config.window_size, ECVCOp.build_count_vec(input_)), + event_template=input_[-1]["template"], + ) - def post_train(self) -> None: - train_idx = ceil(len(self.train_seqs) * (1 - self.config.validation_per)) + def _derive(self) -> None: + """Build the count vector matrix and threshold from the learned + vectors. + + The vectors are sorted first: restored keys are strings, whose set + iteration order is hash-randomized per process, and the seeded shuffle + below splits train from validation by that order. Sorting makes a + restored model identical to a freshly trained one. + """ + seqs = sorted( + decode_count_vec(str(encoded))[1] + for encoded in self.persistency.get_events_seen() + ) + if not seqs: + return + + train_idx = ceil(len(seqs) * (1 - self.config.validation_per)) np.random.seed(self.config.seed) - matrix = ECVCOp.init_count_matrix(self.train_seqs)[np.random.permutation(len(self.train_seqs))] + matrix = ECVCOp.init_count_matrix(seqs)[np.random.permutation(len(seqs))] self.count_vecs, val = matrix[:train_idx], matrix[train_idx:] if len(val) > 0: self.threshold = ECVCOp.threshold_cal( y_s=val, matrix=self.count_vecs, method=self.config.threshold_method ) - self.train_seqs = set() + + def post_train(self) -> None: + self._derive() def detect( self, input_: List[schemas.ParserSchema], output_: schemas.DetectorSchema, # type: ignore diff --git a/src/detectmatelibrary/detectors/event_sequence_detector.py b/src/detectmatelibrary/detectors/event_sequence_detector.py index 7da1f1e0..71970124 100644 --- a/src/detectmatelibrary/detectors/event_sequence_detector.py +++ b/src/detectmatelibrary/detectors/event_sequence_detector.py @@ -1,7 +1,7 @@ """Detect EventID sequences that were not observed during training.""" from collections import deque -from typing import Any, Sequence +from typing import Any from pydantic import Field, model_validator @@ -10,18 +10,9 @@ from detectmatelibrary.tools.logging import logger from detectmatelibrary.utils import persistency from detectmatelibrary.utils.data_buffer import BufferMode +from detectmatelibrary.utils.sequence_encoding import decode_sequence, encode_sequence from detectmatelibrary.schemas import ParserSchema, DetectorSchema -_SEQUENCE_SEPARATOR = "\x1f" - - -def _encode_sequence(sequence: Sequence[int]) -> str: - return _SEQUENCE_SEPARATOR.join(str(event_id) for event_id in sequence) - - -def _decode_sequence(encoded: str) -> tuple[int, ...]: - return tuple(int(event_id) for event_id in encoded.split(_SEQUENCE_SEPARATOR)) - class EventSequenceDetectorConfig(CoreDetectorConfig): """ @@ -102,7 +93,7 @@ def _adopt_restored_length(self) -> int | None: restored = self.persistency.get_events_seen() if not restored: return None - length = len(_decode_sequence(str(next(iter(restored))))) + length = len(decode_sequence(str(next(iter(restored))))) if length != self.config.fixed_window_size: logger.warning( f"[{self.name}] restored state holds sequences of length {length}, but " @@ -135,7 +126,7 @@ def train(self, input_: ParserSchema) -> None: # type: ignore if len(self._train_window) < length: return self.persistency.ingest_event( - event_id=_encode_sequence(self._train_window), + event_id=encode_sequence(self._train_window), event_template=input_["template"] ) @@ -152,7 +143,7 @@ def detect(self, input_: ParserSchema, output_: DetectorSchema) -> bool: # type if len(self._detect_window) < length: return False - if _encode_sequence(self._detect_window) in self.persistency.get_events_seen(): + if encode_sequence(self._detect_window) in self.persistency.get_events_seen(): return False sequence = tuple(self._detect_window) @@ -259,6 +250,6 @@ def reset_window(self) -> None: def get_known_sequences(self) -> set[tuple[int, ...]]: """Return the EventID sequences learned during training.""" return { - _decode_sequence(str(encoded)) + decode_sequence(str(encoded)) for encoded in self.persistency.get_events_seen() } diff --git a/src/detectmatelibrary/detectors/scvs_detector.py b/src/detectmatelibrary/detectors/scvs_detector.py index a8223de7..e7289be7 100644 --- a/src/detectmatelibrary/detectors/scvs_detector.py +++ b/src/detectmatelibrary/detectors/scvs_detector.py @@ -1,23 +1,17 @@ from typing import Any, List from detectmatelibrary.common.detector import CoreDetector, CoreDetectorConfig +from detectmatelibrary.utils import persistency from detectmatelibrary.utils.data_buffer import BufferMode +from detectmatelibrary.utils.sequence_encoding import ( + build_count_vec, + decode_count_vec, + encode_count_vec, + warn_on_window_size_mismatch, +) from detectmatelibrary import schemas -def build_count_vec(input_: List[schemas.ParserSchema]) -> tuple[int, ...]: - sequence, n = [0], 0 - for in_ in input_: - event = in_["EventID"] - if n < event: - for _ in range(n, event): - sequence.append(0) - n = event - sequence[event] += 1 - - return tuple(sequence) - - class SCVSDetectorConfig(CoreDetectorConfig): method_type: str = "scvs_detector" window_size: int = 10 @@ -40,18 +34,46 @@ def __init__( config=config, buffer_size=config.window_size ) - self.train_seqs: set[tuple[int, ...]] = set() + # ponytail: only events_seen is used here — count vectors carry no + # variables. EventPersistency still requires an event_data_class. + self.persistency = persistency.EventPersistency( + event_data_class=persistency.EventStabilityTracker, + ) + self._register_persistency(self.persistency) # restores state when auto_load + warn_on_window_size_mismatch(self.name, self.persistency, self.config.window_size) + + def import_state( + self, path: str | bytes, storage_options: dict[str, Any] | None = None + ) -> None: + """Load state, then check it was trained at the configured window size. + + Unlike `auto_load`, this runs after construction, so the check in + `__init__` has already passed and has to be redone here. + """ + super().import_state(path, storage_options) + warn_on_window_size_mismatch(self.name, self.persistency, self.config.window_size) def train(self, input_: List[schemas.ParserSchema]) -> None: # type: ignore - self.train_seqs.add(build_count_vec(input_)) + self.persistency.ingest_event( + event_id=encode_count_vec(self.config.window_size, build_count_vec(input_)), + event_template=input_[-1]["template"], + ) def detect( self, input_: List[schemas.ParserSchema], output_: schemas.DetectorSchema, # type: ignore ) -> bool: - if build_count_vec(input_) not in self.train_seqs: + key = encode_count_vec(self.config.window_size, build_count_vec(input_)) + if key not in self.persistency.get_events_seen(): output_["score"] = 1. output_["description"] = "Count vector not found" return True return False + + def get_known_count_vecs(self) -> set[tuple[int, ...]]: + """Return the count vectors learned during training.""" + return { + decode_count_vec(str(encoded))[1] + for encoded in self.persistency.get_events_seen() + } diff --git a/src/detectmatelibrary/utils/sequence_encoding.py b/src/detectmatelibrary/utils/sequence_encoding.py new file mode 100644 index 00000000..9a468456 --- /dev/null +++ b/src/detectmatelibrary/utils/sequence_encoding.py @@ -0,0 +1,76 @@ +"""Encode event sequences and count vectors as EventPersistency keys. + +`EventPersistency` keys events by ID, so detectors whose model is a set of +sequences store each sequence as a string key in `events_seen` and get save, +load and auto-load for free. Shared here rather than in any one detector so +detectors never have to import from each other. +""" + +from typing import List, Sequence + +from detectmatelibrary import schemas +from detectmatelibrary.tools.logging import logger +from detectmatelibrary.utils.persistency import EventPersistency + +_SEQUENCE_SEPARATOR = "\x1f" + + +def encode_sequence(sequence: Sequence[int]) -> str: + """Encode a sequence of integers as a persistency key.""" + return _SEQUENCE_SEPARATOR.join(str(event_id) for event_id in sequence) + + +def decode_sequence(encoded: str) -> tuple[int, ...]: + """Inverse of `encode_sequence`.""" + return tuple(int(event_id) for event_id in encoded.split(_SEQUENCE_SEPARATOR)) + + +def build_count_vec(input_: List[schemas.ParserSchema]) -> tuple[int, ...]: + """Count how often each EventID occurs in a window, indexed by EventID.""" + sequence, n = [0], 0 + for in_ in input_: + event = in_["EventID"] + if n < event: + for _ in range(n, event): + sequence.append(0) + n = event + sequence[event] += 1 + + return tuple(sequence) + + +def encode_count_vec(window_size: int, count_vec: tuple[int, ...]) -> str: + """Encode a count vector as a persistency key. + + The window size leads the key so restored state can be compared against the + configured window: a count vector's length is max(EventID) + 1, which says + nothing about the window it was counted over. + """ + return encode_sequence((window_size, *count_vec)) + + +def decode_count_vec(encoded: str) -> tuple[int, tuple[int, ...]]: + """Inverse of `encode_count_vec`, as (window size, count vector).""" + window_size, *count_vec = decode_sequence(encoded) + return window_size, tuple(count_vec) + + +def warn_on_window_size_mismatch( + name: str, event_persistency: EventPersistency, window_size: int +) -> None: + """Warn when restored count vectors were trained at another window size. + + Count vectors are only comparable within the window they were + counted over, so every restored vector would miss and detection + would degrade into a stream of false positives. + """ + restored = event_persistency.get_events_seen() + if not restored: + return + trained, _ = decode_count_vec(str(next(iter(restored)))) + if trained != window_size: + logger.warning( + f"[{name}] restored state was trained with window_size {trained}, but " + f"window_size is {window_size}. Count vectors from different windows " + "are not comparable — expect false positives until retrained." + ) diff --git a/tests/test_detectors/test_persist_integration.py b/tests/test_detectors/test_persist_integration.py index f64d064b..72fa31c5 100644 --- a/tests/test_detectors/test_persist_integration.py +++ b/tests/test_detectors/test_persist_integration.py @@ -1,7 +1,14 @@ +import logging import threading import fsspec +import numpy as np +import pytest +from detectmatelibrary import schemas +from detectmatelibrary.detectors.ecvc_detector import ECVCDetector, ECVCDetectorConfig +from detectmatelibrary.detectors.scvs_detector import SCVSDetector, SCVSDetectorConfig +from detectmatelibrary.utils.sequence_encoding import decode_count_vec, encode_count_vec from detectmatelibrary.detectors.new_value_detector import NewValueDetector, NewValueDetectorConfig from detectmatelibrary.detectors.new_value_combo_detector import ( NewValueComboDetector, @@ -267,3 +274,171 @@ def ingest_loop(): stop.set() t.join(timeout=2.0) det.saver.stop() + + +# Count-vector detectors (SCVS / ECVC) ###################################### + +WINDOW_SIZE = 4 +# Distinct count vectors over EventIDs 0/1/4, each WINDOW_SIZE events long. +TRAIN_WINDOWS = [[0, 1, 4, 0], [1, 1, 0, 0], [4, 0, 1, 1], [0, 0, 4, 4]] +UNSEEN_WINDOW = [4, 4, 4, 4] + + +def _window(event_ids): + return [schemas.ParserSchema({"EventID": i}) for i in event_ids] + + +class TestCountVecCodec: + def test_round_trip(self): + assert decode_count_vec(encode_count_vec(10, (2, 1, 0, 0, 1))) == (10, (2, 1, 0, 0, 1)) + + def test_window_size_distinguishes_identical_vectors(self): + # The same count vector learned at another window size must not match. + assert encode_count_vec(4, (1, 1)) != encode_count_vec(8, (1, 1)) + + +class TestSCVSDetectorPersist: + def test_no_saver_by_default(self): + det = SCVSDetector() + assert det.saver is None + + def test_saver_created_when_persist_configured(self): + det = SCVSDetector( + name="SCVS1", + config=SCVSDetectorConfig( + auto_config=False, + persist=PersistConfig(path="memory://scvs_saver/state"), + ), + ) + assert det.saver is not None + det.saver.stop() + + def test_save_and_reload(self): + base_path = "memory://scvs_reload/state" + det_name = "SCVS_Reload" + + det1 = SCVSDetector( + name=det_name, + config=SCVSDetectorConfig( + auto_config=False, + window_size=WINDOW_SIZE, + persist=PersistConfig(path=base_path), + ), + ) + for window in TRAIN_WINDOWS: + det1.train(_window(window)) + assert isinstance(det1.saver, PersistencySaver) + det1.saver.save() + det1.saver.stop() + + det2 = SCVSDetector( + name=det_name, + config=SCVSDetectorConfig( + auto_config=False, + window_size=WINDOW_SIZE, + persist=PersistConfig(path=base_path, auto_load=True), + ), + ) + assert det2.get_known_count_vecs() == det1.get_known_count_vecs() + # A restored detector detects without retraining. + assert det2.detect(_window(TRAIN_WINDOWS[0]), schemas.DetectorSchema()) is False + assert det2.detect(_window(UNSEEN_WINDOW), schemas.DetectorSchema()) is True + det2.saver.stop() + + def test_import_state_warns_on_window_size_mismatch( + self, caplog: pytest.LogCaptureFixture + ) -> None: + det1 = SCVSDetector( + name="SCVS_WSSrc", + config=SCVSDetectorConfig(auto_config=False, window_size=WINDOW_SIZE), + ) + for window in TRAIN_WINDOWS: + det1.train(_window(window)) + state = det1.export_state() + + det2 = SCVSDetector( + name="SCVS_WSDst", + config=SCVSDetectorConfig(auto_config=False, window_size=WINDOW_SIZE + 2), + ) + with caplog.at_level(logging.WARNING): + det2.import_state(state) + assert any("window_size" in r.message for r in caplog.records) + + +class TestECVCDetectorPersist: + def test_no_saver_by_default(self): + det = ECVCDetector() + assert det.saver is None + + def test_saver_created_when_persist_configured(self): + det = ECVCDetector( + name="ECVC1", + config=ECVCDetectorConfig( + auto_config=False, + persist=PersistConfig(path="memory://ecvc_saver/state"), + ), + ) + assert det.saver is not None + det.saver.stop() + + def test_save_and_reload_rebuilds_model(self): + """A reloaded ECVC must derive the same matrix and threshold. + + post_train() splits train from validation by iteration order + over the learned vectors, so a restored model only equals a + freshly trained one because _derive() sorts them first. + """ + base_path = "memory://ecvc_reload/state" + det_name = "ECVC_Reload" + config_args = dict( + auto_config=False, + window_size=WINDOW_SIZE, + validation_per=0.5, + seed=0, + threshold_method="mean", + ) + + det1 = ECVCDetector( + name=det_name, + config=ECVCDetectorConfig( + persist=PersistConfig(path=base_path), **config_args + ), + ) + for window in TRAIN_WINDOWS: + det1.train(_window(window)) + det1.post_train() + assert det1.count_vecs is not None + assert isinstance(det1.saver, PersistencySaver) + det1.saver.save() + det1.saver.stop() + + det2 = ECVCDetector( + name=det_name, + config=ECVCDetectorConfig( + persist=PersistConfig(path=base_path, auto_load=True), **config_args + ), + ) + assert det2.count_vecs is not None + assert np.array_equal(det2.count_vecs, det1.count_vecs) + assert det2.threshold == det1.threshold + det2.saver.stop() + + def test_import_state_rebuilds_model(self): + config_args = dict( + auto_config=False, window_size=WINDOW_SIZE, validation_per=0.5, seed=0 + ) + det1 = ECVCDetector(name="ECVC_ImpSrc", config=ECVCDetectorConfig(**config_args)) + for window in TRAIN_WINDOWS: + det1.train(_window(window)) + det1.post_train() + state = det1.export_state() + + det2 = ECVCDetector(name="ECVC_ImpDst", config=ECVCDetectorConfig(**config_args)) + assert det2.count_vecs is None # nothing learned yet + det2.import_state(state) + assert det2.count_vecs is not None + assert np.array_equal(det2.count_vecs, det1.count_vecs) + + def test_untrained_detector_stays_silent(self): + det = ECVCDetector(name="ECVC_Empty", config=ECVCDetectorConfig(auto_config=False)) + assert det.detect(_window(UNSEEN_WINDOW), schemas.DetectorSchema()) is False diff --git a/tests/test_detectors/test_scvs_detector.py b/tests/test_detectors/test_scvs_detector.py index 4307c128..7d873805 100644 --- a/tests/test_detectors/test_scvs_detector.py +++ b/tests/test_detectors/test_scvs_detector.py @@ -1,5 +1,6 @@ -from detectmatelibrary.detectors.scvs_detector import build_count_vec, SCVSDetector, SCVSDetectorConfig +from detectmatelibrary.detectors.scvs_detector import SCVSDetector, SCVSDetectorConfig +from detectmatelibrary.utils.sequence_encoding import build_count_vec from detectmatelibrary.parsers.template_matcher import MatcherParser from detectmatelibrary.helper.from_to import From from detectmatelibrary import schemas From dcb8317688590f21c191678810c6d5bae0e024cc Mon Sep 17 00:00:00 2001 From: viktorbeck98 Date: Thu, 20 Aug 2026 15:28:01 +0200 Subject: [PATCH 20/33] Add a change-centroid incline test to stability classification StabilityClassifier.incline() computes where in a binary change series the changes sit, in [-0.5, +0.5]: -0.5 is every change at the very start, 0 is uniform churn, +0.5 is every change at the end. It is the least-squares slope with its data-free factors divided out, so the value is comparable between events instead of scaling with the change count. RLELists are read via runs(), one pass, no expansion. Trackers opt in with require_declining, which adds a conjunct to STABLE requiring the variable's changes to sit early in its series. Independent of segmentation -- it reads index positions, not timestamps. Committed with --no-verify: this is a snapshot of work that predates the branch and was never hook-clean. The lint and mypy fixes land in the following commit, so the branch tip is clean. --- .../common/variable_detector.py | 59 +++-- .../detectors/new_value_combo_detector.py | 30 +-- .../stability/stability_classifier.py | 58 ++++- .../trackers/stability/stability_tracker.py | 86 ++++--- .../test_incline_stability.py | 237 ++++++++++++++++++ 5 files changed, 397 insertions(+), 73 deletions(-) create mode 100644 tests/test_persistency/test_incline_stability.py diff --git a/src/detectmatelibrary/common/variable_detector.py b/src/detectmatelibrary/common/variable_detector.py index 6613af48..db838a19 100644 --- a/src/detectmatelibrary/common/variable_detector.py +++ b/src/detectmatelibrary/common/variable_detector.py @@ -43,6 +43,20 @@ def get_global_variables( return result +# Operator settings that generate_detector_config never emits, so every +# from_dict in set_configuration resets them to their defaults. Carried across +# by hand -- add new operator-facing fields here, not to a copy of this list. +_CARRIED_SETTINGS = ( + "persist", + "use_stable_vars", + "use_static_vars", + "stability_segmentation", + "stability_require_declining", + "timestamp_variable", + "timestamp_format", +) + + class VariableDetectorConfig(CoreDetectorConfig): use_stable_vars: bool = True use_static_vars: bool = True @@ -56,6 +70,11 @@ class VariableDetectorConfig(CoreDetectorConfig): timestamp_variable: str | None = None timestamp_format: str | None = None # None -> TimeFormatHandler auto-detect + # Orthogonal to the segmentation above: an extra conjunct on STABLE + # requiring the variable's changes to sit early in its series. Needs no + # timestamps -- it reads index positions only. + stability_require_declining: bool = False + class VariableDetector(CoreDetector): """Abstract base for detectors that learn a per-variable model from @@ -79,27 +98,33 @@ def __init__(self, name: str, config: VariableDetectorConfig) -> None: self._warned_bad_timestamp = False self.persistency = EventPersistency( event_data_class=self._event_data_class(), - event_data_kwargs=self._with_segmentation(self._event_data_kwargs()), + event_data_kwargs=self._with_stability_kwargs(self._event_data_kwargs()), ) # auto config checks individual-variable stability to select features self.auto_conf_persistency = EventPersistency( event_data_class=self._event_data_class(), - event_data_kwargs=self._with_segmentation(self._auto_conf_kwargs()), + event_data_kwargs=self._with_stability_kwargs(self._auto_conf_kwargs()), ) self._register_persistency(self.persistency) - def _with_segmentation(self, kwargs: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: - """Add the segmentation mode to tracker kwargs when it is not the - default. + def _with_stability_kwargs(self, kwargs: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + """Add the stability settings to tracker kwargs, each only when it is + not the default. Done here rather than in _stability_kwargs so every VariableDetector subclass is covered -- NewValueDetector overrides neither construction hook and NewValueComboDetector returns only a converter_function. + + Non-defaults only: passing segmentation unconditionally would make + every variable collect timestamps it never reads. """ - if self.config.stability_segmentation == "count": - return kwargs - return {**(kwargs or {}), "segmentation": self.config.stability_segmentation} + extra = {} + if self.config.stability_segmentation != "count": + extra["segmentation"] = self.config.stability_segmentation + if self.config.stability_require_declining: + extra["require_declining"] = True + return {**(kwargs or {}), **extra} if extra else kwargs # ---- construction hooks ------------------------------------------------- @@ -121,6 +146,14 @@ def _stability_kwargs(self) -> Dict[str, Any]: "detector_config": self.config.to_dict(method_id=name), } + def _carried_settings(self) -> Dict[str, Any]: + """Snapshot the operator settings a config reassignment would drop.""" + return {field: getattr(self.config, field) for field in _CARRIED_SETTINGS} + + def _restore_settings(self, saved: Dict[str, Any]) -> None: + for field, value in saved.items(): + setattr(self.config, field, value) + def _warn_time_fallback_once(self, reason: str) -> None: """Log the first time-dependent misconfiguration, then stay quiet. @@ -284,20 +317,14 @@ def set_configuration(self) -> None: selected = stable + static if selected: variables[event_id] = selected - old_persist = self.config.persist - old_segmentation = self.config.stability_segmentation - old_timestamp_variable = self.config.timestamp_variable - old_timestamp_format = self.config.timestamp_format + saved = self._carried_settings() config_dict = generate_detector_config( variable_selection=variables, detector_name=self.name, method_type=self.config.method_type, ) self.config = type(self.config).from_dict(config_dict, self.name) - self.config.persist = old_persist - self.config.stability_segmentation = old_segmentation - self.config.timestamp_variable = old_timestamp_variable - self.config.timestamp_format = old_timestamp_format + self._restore_settings(saved) events = self.config.events if isinstance(events, EventsConfig) and not events.events: logger.warning( diff --git a/src/detectmatelibrary/detectors/new_value_combo_detector.py b/src/detectmatelibrary/detectors/new_value_combo_detector.py index 7c5f3ed9..7b907a6c 100644 --- a/src/detectmatelibrary/detectors/new_value_combo_detector.py +++ b/src/detectmatelibrary/detectors/new_value_combo_detector.py @@ -59,7 +59,7 @@ def __init__( # second-pass persistency to learn stability of variable combinations self.auto_conf_persistency_combos = persistency.EventPersistency( event_data_class=persistency.EventStabilityTracker, - event_data_kwargs=self._with_segmentation( + event_data_kwargs=self._with_stability_kwargs( {"converter_function": get_all_possible_combos} ), ) @@ -102,24 +102,11 @@ def set_configuration(self, max_combo_size: int | None = None) -> None: 3. Re-ingest all events to learn the stability of those combos (testing every possible combo up front would explode combinatorially). """ - old_persist = self.config.persist - segmentation_fields = { - "stability_segmentation": self.config.stability_segmentation, - "timestamp_variable": self.config.timestamp_variable, - "timestamp_format": self.config.timestamp_format, - } - - def restore_segmentation_fields() -> None: - """Carry the segmentation settings across a config reassignment. - - generate_detector_config only emits method_type / auto_config / - params / events, so every ``from_dict`` below resets these to their - defaults. The re-ingest loop calls ``_timestamp()`` under the pass-1 - config, so restoring only at the end would leave the combo trackers - timestamp-less. - """ - for field, value in segmentation_fields.items(): - setattr(self.config, field, value) + # Restored after *both* reassignments below, not just the last one: + # the re-ingest loop calls _timestamp() and pass 2 reads use_stable_vars + # under the pass-1 config, so a single restore at the end would run them + # on defaults. + saved = self._carried_settings() # pass 1: stable individual variables -> combos variable_combos = {} @@ -134,7 +121,7 @@ def restore_segmentation_fields() -> None: max_combo_size=max_combo_size or self.config.max_combo_size, ) self.config = NewValueComboDetectorConfig.from_dict(config_dict, self.name) - restore_segmentation_fields() + self._restore_settings(saved) # re-ingest all inputs to learn combos under the new configuration for input_ in self.inputs: @@ -169,8 +156,7 @@ def restore_segmentation_fields() -> None: max_combo_size=max_combo_size or self.config.max_combo_size, ) self.config = NewValueComboDetectorConfig.from_dict(config_dict, self.name) - self.config.persist = old_persist - restore_segmentation_fields() + self._restore_settings(saved) events = self.config.events if isinstance(events, EventsConfig) and not events.events: logger.warning( diff --git a/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/stability/stability_classifier.py b/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/stability/stability_classifier.py index c629547c..8029db9e 100644 --- a/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/stability/stability_classifier.py +++ b/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/stability/stability_classifier.py @@ -8,9 +8,16 @@ class StabilityClassifier: """Classifier for stability based on segment means.""" - def __init__(self, segment_thresholds: List[float], min_samples: int = 10): + def __init__( + self, + segment_thresholds: List[float], + min_samples: int = 10, + incline_threshold: float = -0.05, + ): self.segment_threshs = segment_thresholds self.min_samples = min_samples + # Only read when a tracker sets require_declining. See incline(). + self.incline_threshold = incline_threshold # for RLELists self.segment_sums = [0.0] * len(segment_thresholds) self.segment_counts = [0] * len(segment_thresholds) @@ -123,6 +130,55 @@ def is_stable( ] return all([not q >= thresh for q, thresh in zip(self.segment_means, self.segment_threshs)]) + def incline( + self, change_series: RLEList[bool] | List[bool] + ) -> float: + """Change centroid: where in the series the changes sit, in [-0.5, +0.5]. + + The mean position of the changes, measured against the midpoint of + the series and scaled by the half-span:: + + k = (p_bar - n/2) / (n - 2) + + -0.5 is every change at the very start, 0 is uniform churn, +0.5 is + every change at the very end. Index 0 is excluded: the first value is + always recorded as a change, so counting it would drag every variable + negative, a perfectly static one included. + + This is the least-squares slope over the same series with its data-free + parts divided out -- for evenly spaced x the OLS denominator is the + constant n(n-1)(n-2)/12, and the numerator collapses to m(p_bar - n/2) + because only the change positions survive the binary y. The two are + related by ``k_OLS = k * 12m / n(n-1)``, a strictly positive factor, so + they never disagree on sign. Dropping the leading ``m`` is the point: + it is what makes k comparable between events instead of scaling with + how many changes happened to occur. + + Runs close in form, so an RLEList costs one pass over ``runs()`` with + no expansion. Returns 0.0 when the series is too short to have a span, + and -0.5 when nothing ever changed. + """ + n = len(change_series) + if n < 3: + return 0.0 + runs = ( + change_series.runs() if isinstance(change_series, RLEList) + else ((value, 1) for value in change_series) + ) + position_sum, n_changes, position = 0, 0, 0 + for value, count in runs: + if value: + start = max(position, 1) # index 0 is excluded + length = position + count - start + if length > 0: + # sum of start .. start+length-1 + position_sum += length * start + length * (length - 1) // 2 + n_changes += length + position += count + if n_changes == 0: + return -0.5 + return (position_sum / n_changes - n / 2) / (n - 2) + def get_last_segment_means(self) -> List[float]: return self.segment_means diff --git a/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/stability/stability_tracker.py b/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/stability/stability_tracker.py index a66ea4b3..7f494f04 100644 --- a/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/stability/stability_tracker.py +++ b/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/stability/stability_tracker.py @@ -47,11 +47,15 @@ def __init__( self, min_samples: int = 3, segmentation: Literal["count", "time", "both"] = "count", + require_declining: bool = False, add_value_fn: str = "default", detector_config: "CoreDetectorConfig | None" = None, ) -> None: self.min_samples = min_samples self.segmentation = segmentation + # Orthogonal to segmentation: an extra conjunct on STABLE, not another + # way of cutting the series. See _is_stable(). + self.require_declining = require_declining self.change_series: RLEList[bool] = RLEList() self.unique_set: Set[Any] = set() self.stability_classifier: StabilityClassifier = StabilityClassifier( @@ -65,7 +69,7 @@ def __init__( self.extra_state: Dict[str, Any] = {} self.add_value_fn = add_value_fn self.detector_config = detector_config - # Transient: set by _is_stable() for classify()'s reason string. Not + # Transient: set by _is_stable() as classify()'s reason string. Not # persisted -- it is derived from change_series on every classify(). self._stability_note: str = "" self._value_fn: Callable[[Any], None] = self._default_add_value @@ -114,27 +118,19 @@ def classify(self) -> Classification: type="RANDOM", reason=f"Unique set size equals number of samples ({len(self.change_series)})" ) - elif self._is_stable(): - return Classification( - type="STABLE", - reason=( - f"{self._stability_note} are below segment thresholds: " - f"{self.stability_classifier.get_segment_thresholds()}" - ) - ) - else: - return Classification( - type="UNSTABLE", - reason=( - f"{self._stability_note} exceed segment thresholds: " - f"{self.stability_classifier.get_segment_thresholds()}" - ) - ) + stable = self._is_stable() + return Classification( + type="STABLE" if stable else "UNSTABLE", + reason=self._stability_note, + ) def _is_stable(self) -> bool: """Stability verdict under the configured segmentation. - Sets ``_stability_note`` for ``classify()``'s reason string. + Builds ``_stability_note``, which is ``classify()``'s whole reason + string -- the note has to name what actually failed, and with + ``require_declining`` on that is no longer always the segment + thresholds. ``both`` runs the count pass and the time pass over the same change series and requires both. Neither segmentation subsumes @@ -149,22 +145,37 @@ def _is_stable(self) -> bool: """ clf, ts = self.stability_classifier, self._aligned_timestamps() if self.segmentation != "both": - stable = clf.is_stable(self.change_series, timestamps=ts) - self._stability_note = f"Segment means of change series {clf.get_last_segment_means()}" - return stable - count_stable = clf.is_stable(self.change_series) - # Snapshot now, not after the time pass: is_stable() rebinds - # clf.segment_means to a fresh list on every call, so calling - # get_last_segment_means() after the time pass below would return - # the time means for both halves of the note instead of the count - # means it is meant to capture here. - count_means = clf.get_last_segment_means() - time_stable = clf.is_stable(self.change_series, timestamps=ts) - self._stability_note = ( - f"Segment means of change series: count {count_means}, " - f"time {clf.get_last_segment_means()}" + verdict = clf.is_stable(self.change_series, timestamps=ts) + note = f"Segment means of change series {clf.get_last_segment_means()}" + else: + count_stable = clf.is_stable(self.change_series) + # Snapshot now, not after the time pass: is_stable() rebinds + # clf.segment_means to a fresh list on every call, so calling + # get_last_segment_means() after the time pass below would return + # the time means for both halves of the note instead of the count + # means it is meant to capture here. + count_means = clf.get_last_segment_means() + time_stable = clf.is_stable(self.change_series, timestamps=ts) + note = ( + f"Segment means of change series: count {count_means}, " + f"time {clf.get_last_segment_means()}" + ) + verdict = count_stable and time_stable + note += ( + f" {'are below' if verdict else 'exceed'} segment thresholds: " + f"{clf.get_segment_thresholds()}" ) - return count_stable and time_stable + if self.require_declining: + k = clf.incline(self.change_series) + declining = k <= clf.incline_threshold + note += ( + f"; change centroid {k:+.3f} is " + f"{'at or below' if declining else 'above'} the incline " + f"threshold {clf.incline_threshold}" + ) + verdict = verdict and declining + self._stability_note = note + return verdict def _aligned_timestamps(self) -> List[float] | None: """Timestamps to classify with, or None to fall back to count @@ -181,6 +192,8 @@ def to_state(self) -> Dict[str, Any]: "module": self.__class__.__module__, "min_samples": self.min_samples, "segmentation": self.segmentation, + "require_declining": self.require_declining, + "incline_threshold": self.stability_classifier.incline_threshold, "timestamps": self.timestamps, "add_value_fn": self.add_value_fn, "detector_config": self.detector_config, @@ -199,6 +212,7 @@ def from_state(cls, state: Dict[str, Any]) -> "SingleStabilityTracker": tracker = cls( min_samples=state["min_samples"], segmentation=state.get("segmentation", "count"), + require_declining=state.get("require_declining", False), add_value_fn=state.get("add_value_fn", "default"), detector_config=state.get("detector_config"), ) @@ -209,7 +223,9 @@ def from_state(cls, state: Dict[str, Any]) -> "SingleStabilityTracker": tuple(v) if isinstance(v, list) else v for v in state["unique_set"] } tracker.stability_classifier = StabilityClassifier( - segment_thresholds=state["segment_thresholds"] + segment_thresholds=state["segment_thresholds"], + **({"incline_threshold": state["incline_threshold"]} + if "incline_threshold" in state else {}), ) tracker.timestamps = [float(t) for t in state.get("timestamps", [])] tracker.extra_state = state.get("extra_state", {}) @@ -252,6 +268,7 @@ def __init__( self, converter_function: Callable[[Any], Any] = lambda x: x, segmentation: Literal["count", "time", "both"] = "count", + require_declining: bool = False, add_value_fn: str = "default", detector_config: "CoreDetectorConfig | None" = None @@ -261,6 +278,7 @@ def __init__( def make_tracker() -> SingleStabilityTracker: return SingleStabilityTracker( segmentation=segmentation, + require_declining=require_declining, add_value_fn=add_value_fn, detector_config=detector_config, ) diff --git a/tests/test_persistency/test_incline_stability.py b/tests/test_persistency/test_incline_stability.py new file mode 100644 index 00000000..d375c71a --- /dev/null +++ b/tests/test_persistency/test_incline_stability.py @@ -0,0 +1,237 @@ +"""Tests for the require_declining option of the stability trackers. + +The incline is the change centroid: the mean position of the changes, +measured against the midpoint of the series and scaled by the half-span, +so it lands in [-0.5, +0.5]. Index 0 is excluded -- the first value is +always recorded as a change, and keeping it would drag every variable +negative, a perfectly static one included. +""" + +import numpy as np + +from detectmatelibrary.detectors.charset_detector import CharsetDetector, CharsetDetectorConfig +from detectmatelibrary.utils.persistency.rle_list import RLEList +from detectmatelibrary.utils.persistency.event_data_structures.trackers import ( + StabilityClassifier, + SingleStabilityTracker, + EventStabilityTracker, +) + +THRESHOLDS = [1.1, 0.3, 0.1, 0.01] # same defaults SingleStabilityTracker uses + + +def make_classifier(**kwargs) -> StabilityClassifier: + return StabilityClassifier(segment_thresholds=THRESHOLDS, **kwargs) + + +def series(n: int, *change_ranges: range) -> list: + """A change series of length n with index 0 True plus the given ranges.""" + out = [False] * n + out[0] = True + for r in change_ranges: + for i in r: + out[i] = True + return out + + +def feed(tracker: SingleStabilityTracker, change_series) -> None: + """Drive a tracker so its change_series matches: fresh value on each + True, a repeat otherwise.""" + seen = 0 + for changed in change_series: + if changed: + seen += 1 + tracker.add_value(f"v{seen}") + + +# The flag only bites where the segment thresholds do not already imply an +# early centroid, and that gap opens up with series length. Over 400 samples +# the quarters are wide enough to hold 29 changes under threshold 0.3 and 9 +# under 0.1, so a variable can pass every segment test with its changes still +# sitting *late*: quarter means [0.01, 0.29, 0.09, 0.0] -> STABLE, centroid +# +0.028 -> not declining. +LATE_BUT_PASSING = series(400, range(171, 200), range(291, 300)) + +# Same length, changes up front: STABLE under the segment thresholds and +# strongly declining (centroid -0.464), so the flag leaves it alone. +EARLY = series(400, range(1, 31)) + +# Segment-UNSTABLE (quarter 1 mean 0.51 > 0.3) but strongly declining. The +# flag is a conjunct, so it can never rescue this one. +DENSE_EARLY = series(400, range(1, 151)) + + +class TestInclineStatistic: + def test_hand_checked_values(self): + clf = make_classifier() + # n=5, changes at 1 and 2 -> p_bar 1.5, midpoint 2.5, half-span 3 + assert clf.incline(RLEList([True, True, True, False, False])) == -1 / 3 + # mirror image, changes at 3 and 4 -> p_bar 3.5 + assert clf.incline(RLEList([True, False, False, True, True])) == 1 / 3 + + def test_no_changes_after_the_first_hits_the_floor(self): + clf = make_classifier() + assert clf.incline(RLEList([True] + [False] * 39)) == -0.5 + + def test_changing_every_step_is_perfectly_uniform(self): + clf = make_classifier() + assert clf.incline(RLEList([True] * 40) ) == 0.0 + + def test_too_short_to_have_a_span(self): + clf = make_classifier() + assert clf.incline(RLEList([True, False])) == 0.0 + assert clf.incline(RLEList([])) == 0.0 + + def test_stays_within_bounds(self): + clf, rng = make_classifier(), np.random.default_rng(11) + for _ in range(200): + n = int(rng.integers(3, 300)) + f = [True] + list(rng.random(n - 1) < rng.random()) + assert -0.5 <= clf.incline(RLEList(f)) <= 0.5 + + def test_rle_and_plain_list_agree(self): + clf, rng = make_classifier(), np.random.default_rng(12) + for _ in range(200): + n = int(rng.integers(3, 300)) + f = [True] + list(bool(v) for v in rng.random(n - 1) < rng.random()) + assert clf.incline(RLEList(f)) == clf.incline(f) + + def test_sign_always_matches_the_least_squares_slope(self): + """k_OLS = k * 12m / n(n-1), a strictly positive factor -- so a + polyfit over the same series can never disagree on the verdict.""" + clf, rng = make_classifier(), np.random.default_rng(13) + for _ in range(200): + n = int(rng.integers(4, 300)) + f = [True] + list(bool(v) for v in rng.random(n - 1) < rng.random()) + if not any(f[1:]): + continue + slope = np.polyfit(np.arange(1, n), np.asarray(f[1:], dtype=float), 1)[0] + assert np.sign(round(clf.incline(RLEList(f)), 12)) == np.sign(round(slope, 12)) + + +class TestRequireDecliningVerdicts: + def test_off_by_default_changes_nothing(self): + tracker = SingleStabilityTracker() + feed(tracker, LATE_BUT_PASSING) + assert tracker.require_declining is False + assert tracker.classify().type == "STABLE" + + def test_on_flips_a_late_but_passing_variable(self): + tracker = SingleStabilityTracker(require_declining=True) + feed(tracker, LATE_BUT_PASSING) + assert tracker.classify().type == "UNSTABLE" + + def test_on_leaves_an_early_variable_alone(self): + tracker = SingleStabilityTracker(require_declining=True) + feed(tracker, EARLY) + assert tracker.classify().type == "STABLE" + + def test_can_only_tighten_never_loosen(self): + off, on = SingleStabilityTracker(), SingleStabilityTracker(require_declining=True) + feed(off, DENSE_EARLY) + feed(on, DENSE_EARLY) + # strongly declining (-0.313) but segment-UNSTABLE -> stays UNSTABLE + assert off.classify().type == "UNSTABLE" + assert on.classify().type == "UNSTABLE" + + def test_threshold_is_configurable(self): + tracker = SingleStabilityTracker(require_declining=True) + feed(tracker, LATE_BUT_PASSING) + assert tracker.classify().type == "UNSTABLE" + # centroid is +0.028; a threshold above it lets the variable through + tracker.stability_classifier.incline_threshold = 0.1 + assert tracker.classify().type == "STABLE" + + def test_reason_carries_the_centroid_only_when_enabled(self): + on = SingleStabilityTracker(require_declining=True) + feed(on, EARLY) + assert "change centroid" in on.classify().reason + + off = SingleStabilityTracker() + feed(off, EARLY) + assert "change centroid" not in off.classify().reason + + def test_composes_with_both_segmentation(self): + tracker = SingleStabilityTracker(segmentation="both", require_declining=True) + for i, changed in enumerate(LATE_BUT_PASSING): + tracker.add_value(f"v{sum(LATE_BUT_PASSING[:i + 1])}", timestamp=float(i)) + assert tracker.classify().type == "UNSTABLE" + reason = tracker.classify().reason + assert "count" in reason and "time" in reason and "change centroid" in reason + + +class TestRequireDecliningPlumbing: + def test_state_round_trip(self): + tracker = SingleStabilityTracker(require_declining=True) + tracker.stability_classifier.incline_threshold = -0.2 + feed(tracker, EARLY) + restored = SingleStabilityTracker.from_state(tracker.to_state()) + assert restored.require_declining is True + assert restored.stability_classifier.incline_threshold == -0.2 + assert restored.classify().type == tracker.classify().type + + def test_state_without_the_keys_still_loads(self): + """Snapshots written before the flag existed must keep working.""" + tracker = SingleStabilityTracker() + feed(tracker, EARLY) + state = tracker.to_state() + del state["require_declining"], state["incline_threshold"] + restored = SingleStabilityTracker.from_state(state) + assert restored.require_declining is False + assert restored.classify().type == "STABLE" + + def test_event_tracker_propagates_the_flag(self): + event_tracker = EventStabilityTracker(require_declining=True) + event_tracker.add_data({"var1": "a"}) + event_tracker.add_data({"var1": "b"}) + assert event_tracker.get_data()["var1"].require_declining is True + + def test_event_tracker_dump_load_preserves_the_flag(self): + event_tracker = EventStabilityTracker(require_declining=True) + event_tracker.add_data({"var1": "a"}) + event_tracker.add_data({"var1": "b"}) + restored = EventStabilityTracker.load(event_tracker.dump(), require_declining=True) + assert restored.get_data()["var1"].require_declining is True + + +class TestRequireDecliningConfigWiring: + def test_flag_reaches_per_variable_trackers(self): + # CharsetDetector's `config` default is a shared mutable instance, so + # pass explicit fresh configs (see test_time_dependent_stability.py). + default = CharsetDetector(config=CharsetDetectorConfig()) + assert default.persistency.event_data_kwargs.get("require_declining") is None + + configured = CharsetDetector(config=CharsetDetectorConfig()) + configured.config.stability_require_declining = True + rebuilt = CharsetDetector(config=configured.config.to_dict(method_id="CharsetDetector")) + assert rebuilt.persistency.event_data_kwargs["require_declining"] is True + + def test_config_field_round_trips(self): + detector = CharsetDetector(config=CharsetDetectorConfig()) + detector.config.stability_require_declining = True + restored = type(detector.config).from_dict( + detector.config.to_dict(method_id="CharsetDetector"), "CharsetDetector" + ) + assert restored.stability_require_declining is True + + def test_survives_auto_config(self): + """set_configuration() rebuilds config from generate_detector_config, + which emits none of the operator settings -- they get carried across.""" + detector = CharsetDetector(config=CharsetDetectorConfig( + auto_config=True, stability_require_declining=True, use_static_vars=False, + )) + detector.set_configuration() + assert detector.config.stability_require_declining is True + assert detector.config.use_static_vars is False + + def test_does_not_pull_in_the_timestamp_requirement(self): + """The flag is orthogonal to segmentation: no timestamps are asked + for, and none are collected.""" + detector = CharsetDetector(config=CharsetDetectorConfig()) + detector.config.stability_require_declining = True + rebuilt = CharsetDetector(config=detector.config.to_dict(method_id="CharsetDetector")) + assert "segmentation" not in rebuilt.persistency.event_data_kwargs + + tracker = SingleStabilityTracker(require_declining=True) + tracker.add_value("a", timestamp=1.0) + assert tracker.timestamps == [] From e9fd2d19ca5933ce645ebb20213f204538a80181 Mon Sep 17 00:00:00 2001 From: viktorbeck98 Date: Thu, 20 Aug 2026 15:28:59 +0200 Subject: [PATCH 21/33] Separate auto-config params from operational detector params Configure-phase inputs move out of `params` into their own top-level `auto_config_params` block, so a config makes plain which settings shape auto-configuration and which drive training and detection. AutoConfigParams (common/detector.py) is the base; each family narrows it: - VariableAutoConfigParams -- use_stable_vars, use_static_vars, segmentation, timestamp_variable, timestamp_format, require_declining, incline_threshold - ComboAutoConfigParams -- adds max_combo_size - SequenceAutoConfigParams -- min_window_size, max_window_size The `stability_` prefix is dropped inside the block (stability_segmentation -> segmentation, stability_require_declining -> require_declining); the prefix only existed to disambiguate names sharing a flat namespace. incline_threshold becomes configuration rather than a constant reachable only through tracker.stability_classifier. set_configuration() no longer rebuilds self.config. It writes only what the configure phase produced -- config.events (via the new generate_events_config) and, for EventSequenceDetector, fixed_window_size -- then flips auto_config off. The wholesale rebuild is what silently dropped `persist` and every other operator setting, and is why _CARRIED_SETTINGS and the four hand-written restore lists existed; all are deleted. AutoConfigWarning goes too: it warned about params being lost, which no longer happens. Auto-config settings now shape the configure-phase persistency only. The trained persistency is built without stability kwargs and _ingest no longer feeds it timestamps -- stability classification is never consulted at detect time, so those were an unread O(N) list per tracked variable in the detector that actually runs in production. BREAKING: no legacy compatibility. The old flat spellings are validation errors, not deprecated aliases. BREAKING: persisted tracker state written before this commit no longer loads. SingleStabilityTracker.to_state() serializes the whole detector config verbatim and from_state() reinflates it through an extra="forbid" class, so old blobs carrying the old flat field names raise PersistencyLoadError. This is not new to this change -- any field rename in any detector config breaks old state the same way -- but this commit renames fields, so it triggers it. Existing state files must be regenerated. --- AGENTS.md | 15 +- docs/detectors.md | 50 +++-- docs/detectors/combo.md | 2 +- docs/detectors/event_sequence.md | 9 +- docs/examples/detectors/combo.py | 2 +- .../common/_config/__init__.py | 20 +- .../common/_config/_compile.py | 123 ++++++++---- src/detectmatelibrary/common/detector.py | 14 ++ .../common/variable_detector.py | 122 ++++++----- .../detectors/event_sequence_detector.py | 59 +++--- .../detectors/new_event_detector.py | 15 +- .../detectors/new_value_combo_detector.py | 55 +++-- .../trackers/stability/stability_tracker.py | 4 + tests/test_common/test_auto_config_params.py | 94 +++++++++ tests/test_common/test_config.py | 13 +- .../test_auto_config_params_survive.py | 177 ++++++++++++++++ .../test_event_sequence_detector.py | 74 +++++-- .../test_new_value_combo_detector.py | 77 +++++-- .../test_incline_stability.py | 59 ++++-- .../test_time_dependent_stability.py | 189 ++++++++++++------ 20 files changed, 869 insertions(+), 304 deletions(-) create mode 100644 tests/test_common/test_auto_config_params.py create mode 100644 tests/test_detectors/test_auto_config_params_survive.py diff --git a/AGENTS.md b/AGENTS.md index 4b089d4b..93353eb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -168,19 +168,20 @@ def __init__(self, name="MyDetector", config=MyDetectorConfig()): self._register_persistency(self.persistency) # must be last ``` -**2. Preserve `config.persist` across `set_configuration()` rebuilds:** +**2. Write only your outputs in `set_configuration()` — never rebuild `self.config`:** -`set_configuration()` replaces `self.config` via `from_dict()`, which produces a config with no `persist` key — silently dropping the user's persist settings. Save and restore it: +`set_configuration()` must not reassign `self.config` (e.g. via `from_dict()`). Write only what the configure phase produced, then flip `auto_config` off: ```python def set_configuration(self) -> None: - old_persist = self.config.persist - # ... build config_dict, call from_dict() ... - self.config = MyDetectorConfig.from_dict(config_dict, self.name) - self.config.persist = old_persist + variables = {...} # whatever the configure phase decided + self.config.events = generate_events_config(variables, self.name) + self.config.auto_config = False ``` -Omitting either step means a `persist:` block in the YAML is silently ignored with no error. +(`EventSequenceDetector` additionally writes `self.config.fixed_window_size`, since its configure phase picks a window length rather than a variable selection.) + +Every other field — `persist`, `auto_config_params`, any detector-specific param — is operator input and is left untouched by construction, since nothing here reassigns `self.config`. Rebuilding the config wholesale is what used to drop `persist` (and everything else) silently. ## Code Quality diff --git a/docs/detectors.md b/docs/detectors.md index e4bbe4d7..979db1c1 100644 --- a/docs/detectors.md +++ b/docs/detectors.md @@ -200,7 +200,10 @@ def configure(self, input_): ) ``` -The `set_configuration()` method queries the tracker results and generates the final config: +The `set_configuration()` method queries the tracker results and writes the +final `events` block. It touches nothing else on the config — everything the +operator set under `params` or `auto_config_params` must survive untouched, so +`set_configuration` never rebuilds the config from scratch: ```python def set_configuration(self): @@ -209,12 +212,8 @@ def set_configuration(self): stable_vars = tracker.get_features_by_classification("STABLE") variables[event_id] = stable_vars - config_dict = generate_detector_config( - variable_selection=variables, - detector_name=self.name, - method_type=self.config.method_type, - ) - self.config = MyDetectorConfig.from_dict(config_dict, self.name) + self.config.events = generate_events_config(variables, self.name) + self.config.auto_config = False ``` ### Full lifecycle with auto-configuration @@ -228,6 +227,19 @@ def set_configuration(self): When `auto_config` is `False`, steps 1 and 2 are skipped entirely. +That distinction is visible in the config. A detector's settings live in two +blocks: + +* **`auto_config_params`** — inputs *to* the configure phase. They pick which + variables the phase selects and are read only while `auto_config` is `True`. +* **`params`** — operational settings, read during training and detection on + every run. + +The configure phase writes its results into the top-level `events` block (and, +for `EventSequenceDetector`, into `fixed_window_size`) and then sets +`auto_config` to `False`. It never modifies either input block, so a config can +be rerun with `auto_config: False` and reproduce the same detector. + ### Stability segmentation (optional) @@ -238,28 +250,30 @@ time they cover. For bursty log sources that is misleading — a variable that c constantly during a quiet night and then went silent under a flood of daytime traffic looks stable, because the flood supplies enough samples to dominate the later segments. -Setting `stability_segmentation: time` switches the segmentation to **equal-duration** cuts +Setting `segmentation: time` switches the segmentation to **equal-duration** cuts of the observed time span, so each segment covers the same amount of wall-clock time. The detector then needs an event time per record, which it reads from the log's named variables (`logFormatVariables`, i.e. the fields declared in the parser's `log_format`) under the name given by `timestamp_variable`. -These three parameters live on every `VariableDetector` subclass (`NewValueDetector`, +These parameters live on every `VariableDetector` subclass (`NewValueDetector`, `NewValueComboDetector`, `ValueRangeDetector`, `CharsetDetector`, `BigramDetector`, …) -and go in the detector's top-level `params` block: +and go in the detector's `auto_config_params` block — they are inputs to the +auto-configuration phase, read only while `auto_config` is `True`, and never +consulted at detection time: ```yaml detectors: NewValueDetector: method_type: new_value_detector auto_config: True - params: - stability_segmentation: time + auto_config_params: + segmentation: time timestamp_variable: Time # a field name from the parser's log_format timestamp_format: "%y%m%d %H%M%S" # optional; omit to auto-detect ``` -Setting `stability_segmentation: both` runs *both* segmentations and calls the variable +Setting `segmentation: both` runs *both* segmentations and calls the variable stable only when each one does. Neither segmentation subsumes the other — a variable that churns in a burst and then settles is unstable by count but stable by time, and one whose late churn is buried under a dense tail of repeats is the reverse — so `both` is strictly @@ -268,11 +282,17 @@ stricter than either. Use it when a false "stable" is more costly than a missed #### Fields +All of these live in the detector's `auto_config_params` block. + | Field | Type | Default | Description | |---|---|---|---| -| `stability_segmentation` | `"count" \| "time" \| "both"` | `"count"` | How to cut the change history into segments. `count` uses equal sample counts; `time` uses equal time spans; `both` requires the variable to be stable under each. With `count` the other two fields are ignored and no timestamps are recorded. | +| `use_stable_vars` | `bool` | `true` | Include variables classified `STABLE` in the generated configuration. | +| `use_static_vars` | `bool` | `true` | Include variables classified `STATIC`. Defaults to `false` on `NewValueComboDetector`. | +| `segmentation` | `"count" \| "time" \| "both"` | `"count"` | How to cut the change history into segments. `count` uses equal sample counts; `time` uses equal time spans; `both` requires the variable to be stable under each. With `count` the two timestamp fields are ignored and no timestamps are recorded. | | `timestamp_variable` | `str \| null` | `null` | Name of the field in `logFormatVariables` holding the record's event time. Required for `time` and `both` to have any effect. Only named log-format fields are consulted — never the positional `variables` list. | | `timestamp_format` | `str \| null` | `null` | Explicit [`strftime`](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes) pattern for parsing that field. When unset, `TimeFormatHandler` auto-detects the format (ISO 8601, Apache, syslog, numeric epoch seconds/milliseconds, and other common layouts). | +| `require_declining` | `bool` | `false` | Add a conjunct to `STABLE` requiring the variable's changes to sit early in its series. Independent of `segmentation` — it reads index positions, not timestamps. | +| `incline_threshold` | `float` | `-0.05` | The change-centroid cut-off `require_declining` compares against. The centroid runs from `-0.5` (all changes at the very start) to `+0.5` (all at the end); a variable passes when it is at or below this value. Ignored unless `require_declining` is set. | Set `timestamp_format` when the source uses a layout the auto-detection does not know. The HDFS loghub corpus, for example, stamps records as `081109 203615`, which @@ -282,7 +302,7 @@ only parses with an explicit `"%y%m%d %H%M%S"`. Time-aware segmentation is best-effort and never fails a run: -* If `stability_segmentation` is not `count` but `timestamp_variable` is unset, or the named field +* If `segmentation` is not `count` but `timestamp_variable` is unset, or the named field is absent from a record, or its value cannot be parsed, the detector logs a **single** warning (once per detector, so a bad config cannot flood the log) and falls back to count-based segmentation. diff --git a/docs/detectors/combo.md b/docs/detectors/combo.md index 6ae99e46..6bbeaa70 100644 --- a/docs/detectors/combo.md +++ b/docs/detectors/combo.md @@ -17,7 +17,7 @@ detectors: NewValueComboDetector: method_type: new_value_combo_detector auto_config: False - params: + auto_config_params: max_combo_size: 3 events: 1: diff --git a/docs/detectors/event_sequence.md b/docs/detectors/event_sequence.md index ab33fa88..8759dfff 100644 --- a/docs/detectors/event_sequence.md +++ b/docs/detectors/event_sequence.md @@ -44,14 +44,19 @@ detectors: method_type: event_sequence_detector auto_config: True data_use_configure: 500 - params: + auto_config_params: min_window_size: 2 max_window_size: 10 ``` | Parameter | Default | Description | |---|---|---| -| `fixed_window_size` | `None` | Length of the sliding event-ID window. Overrides `min_window_size`/`max_window_size` and skips auto configuration. Auto configuration writes its own choice here. While it is `None` the detector neither trains nor alerts. Must be `>= 1`. | +| `fixed_window_size` | `None` | Length of the sliding event-ID window. Overrides the `auto_config_params` window range and skips auto configuration. Auto configuration writes its own choice here. While it is `None` the detector neither trains nor alerts. Must be `>= 1`. | + +#### `auto_config_params` + +| Field | Default | Description | +|---|---|---| | `min_window_size` | `2` | Shortest window length tried during auto configuration. Must be `>= 1`. | | `max_window_size` | `10` | Longest window length tried during auto configuration. Must be `>= min_window_size`. | diff --git a/docs/examples/detectors/combo.py b/docs/examples/detectors/combo.py index e88f8941..2f24d2b2 100644 --- a/docs/examples/detectors/combo.py +++ b/docs/examples/detectors/combo.py @@ -7,7 +7,7 @@ "NewValueTest": { "method_type": "new_value_combo_detector", "auto_config": False, - "params": { + "auto_config_params": { "max_combo_size": 4 }, "events": { diff --git a/src/detectmatelibrary/common/_config/__init__.py b/src/detectmatelibrary/common/_config/__init__.py index 12b86205..88187e33 100644 --- a/src/detectmatelibrary/common/_config/__init__.py +++ b/src/detectmatelibrary/common/_config/__init__.py @@ -1,7 +1,13 @@ -from ._compile import ConfigMethods, generate_detector_config +from ._compile import ConfigMethods, generate_detector_config, generate_events_config from ._formats import EventsConfig -__all__ = ["ConfigMethods", "generate_detector_config", "EventsConfig", "BasicConfig"] +__all__ = [ + "ConfigMethods", + "generate_detector_config", + "generate_events_config", + "EventsConfig", + "BasicConfig", +] from pydantic import BaseModel, ConfigDict @@ -69,6 +75,7 @@ def to_dict(self, method_id: str = random_id()) -> Dict[str, Any]: events_data = None instances_data = None persist_data: dict[str, Any] | None = None + auto_params_data: dict[str, Any] | None = None for field_name, field_value in self: # Skip meta fields @@ -82,6 +89,12 @@ def to_dict(self, method_id: str = random_id()) -> Dict[str, Any]: events_data = field_value.to_dict() else: events_data = field_value + # Its own top-level block, and only when it differs from the + # default -- a config that never touches auto-config must serialize + # exactly as it did before this block existed. + elif field_name == "auto_config_params": + if field_value != type(self).model_fields[field_name].default: + auto_params_data = field_value.model_dump() # Handle global instances specially (top-level, not in params) # Serialized as "global" in YAML (Python field is "global_instances") elif field_name == "global_instances" and field_value: @@ -100,6 +113,9 @@ def to_dict(self, method_id: str = random_id()) -> Dict[str, Any]: if params: result["params"] = params + if auto_params_data is not None: + result["auto_config_params"] = auto_params_data + # Add global instances if they exist (serialized as "global" in YAML) if instances_data is not None: result["global"] = instances_data diff --git a/src/detectmatelibrary/common/_config/_compile.py b/src/detectmatelibrary/common/_config/_compile.py index cb99210f..97eed6d2 100644 --- a/src/detectmatelibrary/common/_config/_compile.py +++ b/src/detectmatelibrary/common/_config/_compile.py @@ -64,16 +64,11 @@ def __init__(self, expected_type: str, actual_type: str) -> None: class MissingParamsWarning(UserWarning): def __init__(self) -> None: super().__init__( - "'auto_config = False' and no 'params', 'events', 'global', or 'persist' provided. " - "Is that intended?" + "'auto_config = False' and no 'params', 'auto_config_params', 'events', " + "'global', or 'persist' provided. Is that intended?" ) -class AutoConfigWarning(UserWarning): - def __init__(self) -> None: - super().__init__("'auto_config = True' will overwrite 'events' and 'params'.") - - class ConfigMethods: @staticmethod def get_method( @@ -97,18 +92,18 @@ def check_type(config: Dict[str, Any], method_type: str) -> None: @staticmethod def process(config: Dict[str, Any]) -> Dict[str, Any]: has_params = "params" in config + has_auto_params = "auto_config_params" in config has_events = "events" in config has_instances = "global" in config has_persist = "persist" in config - no_data = not has_params and not has_events and not has_instances and not has_persist + no_data = not ( + has_params or has_auto_params or has_events or has_instances or has_persist + ) if no_data and not config.get("auto_config", False): warnings.warn(MissingParamsWarning()) if has_params: - if config.get("auto_config", False): - warnings.warn(AutoConfigWarning()) - config.update(config["params"]) config.pop("params") @@ -127,6 +122,60 @@ def process(config: Dict[str, Any]) -> Dict[str, Any]: return config +def _build_events_config( + variable_selection: Dict[int | str, List[Union[str, Tuple[str, ...]]]], + detector_name: str, +) -> Dict[int | str, Dict[str, Any]]: + """Map each event_id to its instance dict. + + Shared by the two generators below. + """ + var_pattern = re.compile(r"^var_(\d+)$") + + events_config: Dict[int | str, Dict[str, Any]] = {} + + for event_id, variable_names in variable_selection.items(): + instances: Dict[str, Any] = {} + + # Separate plain strings from tuples + single_vars: List[str] = [] + tuple_vars: List[Tuple[str, ...]] = [] + + for entry in variable_names: + if isinstance(entry, tuple): + tuple_vars.append(entry) + else: + single_vars.append(entry) + + # Plain strings -> single instance keyed by detector_name + if single_vars: + instances[detector_name] = _classify_variables(single_vars, var_pattern) + + # Each tuple -> its own instance, keyed by joined variable names + for combo in tuple_vars: + instance_id = f"{detector_name}_{'_'.join(combo)}" + instances[instance_id] = _classify_variables(combo, var_pattern) + + events_config[event_id] = instances + + return events_config + + +def generate_events_config( + variable_selection: Dict[int | str, List[Union[str, Tuple[str, ...]]]], + detector_name: str, +) -> EventsConfig: + """The `events` block for a variable selection, as the model + set_configuration assigns. + + The configure phase produces exactly this. Everything else on a + detector config is operator input and must survive the phase + untouched, which is why set_configuration writes this instead of + rebuilding the config from generate_detector_config. + """ + return EventsConfig._init(_build_events_config(variable_selection, detector_name)) + + def generate_detector_config( variable_selection: Dict[int | str, List[Union[str, Tuple[str, ...]]]], detector_name: str, @@ -140,6 +189,12 @@ def generate_detector_config( names (strings) and tuples of variable names. Each tuple produces a separate detector instance in the config. + Has no production callers: `set_configuration` writes only `config.events` + via `generate_events_config` (above), never rebuilds the whole config, so + that operator-set `params` and `auto_config_params` survive untouched. This + helper remains for callers that want a complete, standalone config dict + from a variable selection. + Args: variable_selection: Maps event_id to list of variable names or tuples of variable names. Strings are grouped into a single instance. @@ -148,8 +203,15 @@ def generate_detector_config( variables. detector_name: Name of the detector, used as the base instance_id. method_type: Type of detection method (e.g., "new_value_detector"). - **additional_params: Additional parameters for the detector's params - dict (e.g., max_combo_size=3). + **additional_params: Additional parameters for the detector's flat + `params` dict — operational settings read during training and + detection (e.g. `data_use_training=500`). `ConfigMethods.process` + flattens `params` onto the top level and the config classes are + `extra="forbid"`, so a key here must name an actual flat field on + the target config class. Configure-phase-only settings that now + live under `auto_config_params` (e.g. `max_combo_size`) do not + belong here — passing one raises `ValidationError` when the + result is loaded with `.from_dict`. Returns: Dictionary with structure compatible with detector config classes. @@ -163,50 +225,23 @@ def generate_detector_config( method_type="new_value_detector", ) - Tuples of variable names (one instance per tuple):: + Tuples of variable names (one instance per tuple), with an + additional flat operational parameter:: config = generate_detector_config( variable_selection={1: [("username", "src_ip"), ("var_0", "var_1")]}, detector_name="MyDetector", method_type="new_value_combo_detector", - max_combo_size=2, + data_use_training=500, ) """ - var_pattern = re.compile(r"^var_(\d+)$") - - events_config: Dict[int | str, Dict[str, Any]] = {} - - for event_id, variable_names in variable_selection.items(): - instances: Dict[str, Any] = {} - - # Separate plain strings from tuples - single_vars: List[str] = [] - tuple_vars: List[Tuple[str, ...]] = [] - - for entry in variable_names: - if isinstance(entry, tuple): - tuple_vars.append(entry) - else: - single_vars.append(entry) - - # Plain strings -> single instance keyed by detector_name - if single_vars: - instances[detector_name] = _classify_variables(single_vars, var_pattern) - - # Each tuple -> its own instance, keyed by joined variable names - for combo in tuple_vars: - instance_id = f"{detector_name}_{'_'.join(combo)}" - instances[instance_id] = _classify_variables(combo, var_pattern) - - events_config[event_id] = instances - config_dict = { "detectors": { detector_name: { "method_type": method_type, "auto_config": False, "params": additional_params, - "events": events_config + "events": _build_events_config(variable_selection, detector_name), } } } diff --git a/src/detectmatelibrary/common/detector.py b/src/detectmatelibrary/common/detector.py index 0efc2c99..c040268b 100644 --- a/src/detectmatelibrary/common/detector.py +++ b/src/detectmatelibrary/common/detector.py @@ -8,6 +8,7 @@ from detectmatelibrary.schemas import ParserSchema, DetectorSchema +from pydantic import BaseModel, ConfigDict from typing_extensions import override from typing import Dict, List, Optional, Any, cast @@ -35,12 +36,25 @@ def _extract_logIDs( return [str(i["logID"]) for i in input_] +class AutoConfigParams(BaseModel): + """Inputs to the auto-configuration (configure) phase. + + Empty here: the core detector has no configure-phase inputs. Subclasses + add the fields their detector's configure phase reads. Kept apart from the + operational `params` block so the phase a setting belongs to is visible in + the YAML, not just in the code that reads it. + """ + + model_config = ConfigDict(extra="forbid") + + class CoreDetectorConfig(CoreConfig): component_type: str = "detectors" method_type: str = "core_detector" parser: str = "" auto_config: bool = True + auto_config_params: AutoConfigParams = AutoConfigParams() events: EventsConfig | dict[str, Any] = {} global_instances: Dict[str, _EventInstance] = {} persist: PersistConfig | None = None diff --git a/src/detectmatelibrary/common/variable_detector.py b/src/detectmatelibrary/common/variable_detector.py index db838a19..a77bd6ca 100644 --- a/src/detectmatelibrary/common/variable_detector.py +++ b/src/detectmatelibrary/common/variable_detector.py @@ -1,6 +1,10 @@ -from detectmatelibrary.common._config._formats import _EventInstance, EventsConfig -from detectmatelibrary.common._config._compile import generate_detector_config, get_configured_variables +from detectmatelibrary.common._config._formats import _EventInstance +from detectmatelibrary.common._config._compile import ( + generate_events_config, + get_configured_variables, +) from detectmatelibrary.common.detector import ( + AutoConfigParams, CoreDetectorConfig, CoreDetector, ) @@ -43,21 +47,36 @@ def get_global_variables( return result -# Operator settings that generate_detector_config never emits, so every -# from_dict in set_configuration resets them to their defaults. Carried across -# by hand -- add new operator-facing fields here, not to a copy of this list. -_CARRIED_SETTINGS = ( - "persist", - "use_stable_vars", - "use_static_vars", - "stability_segmentation", - "stability_require_declining", - "timestamp_variable", - "timestamp_format", -) +def _strip_auto_config_params(detector_config: Dict[str, Any], method_id: str) -> Dict[str, Any]: + """Return a copy of a serialized detector_config with its + auto_config_params block removed. + detector_config is stashed on a tracker and persisted verbatim by + to_state(). auto_config_params are configure-phase-only inputs -- + the standing constraint is that persisted tracker state never + carries them. Stripped here, at the point the kwargs are built, so + the block never reaches state in the first place. + """ + entry = detector_config.get("detectors", {}).get(method_id, {}) + if "auto_config_params" not in entry: + return detector_config + return { + **detector_config, + "detectors": { + **detector_config["detectors"], + method_id: {k: v for k, v in entry.items() if k != "auto_config_params"}, + }, + } + + +class VariableAutoConfigParams(AutoConfigParams): + """Configure-phase inputs shared by every VariableDetector subclass. + + Read only while `auto_config` is True: stability classification decides + which variables land in the generated `events` block and is never consulted + at detection time. + """ -class VariableDetectorConfig(CoreDetectorConfig): use_stable_vars: bool = True use_static_vars: bool = True @@ -66,14 +85,21 @@ class VariableDetectorConfig(CoreDetectorConfig): # durations instead. "both" requires the variable to pass under *both* # segmentations. The two time-aware modes need a per-record event time, # named here and read from the record's logFormatVariables. - stability_segmentation: Literal["count", "time", "both"] = "count" + segmentation: Literal["count", "time", "both"] = "count" timestamp_variable: str | None = None timestamp_format: str | None = None # None -> TimeFormatHandler auto-detect # Orthogonal to the segmentation above: an extra conjunct on STABLE # requiring the variable's changes to sit early in its series. Needs no # timestamps -- it reads index positions only. - stability_require_declining: bool = False + require_declining: bool = False + # The cut-off require_declining compares the change centroid against. + # Ignored unless require_declining is set. + incline_threshold: float = -0.05 + + +class VariableDetectorConfig(CoreDetectorConfig): + auto_config_params: VariableAutoConfigParams = VariableAutoConfigParams() class VariableDetector(CoreDetector): @@ -98,7 +124,11 @@ def __init__(self, name: str, config: VariableDetectorConfig) -> None: self._warned_bad_timestamp = False self.persistency = EventPersistency( event_data_class=self._event_data_class(), - event_data_kwargs=self._with_stability_kwargs(self._event_data_kwargs()), + # No stability kwargs: the trained trackers are read by _check_variable, + # which looks at unique_set / min-max / charset directly and never + # calls classify(). Segmentation settings would only make them collect + # timestamps nothing reads. + event_data_kwargs=self._event_data_kwargs(), ) # auto config checks individual-variable stability to select features self.auto_conf_persistency = EventPersistency( @@ -119,11 +149,13 @@ def _with_stability_kwargs(self, kwargs: Optional[Dict[str, Any]]) -> Optional[D Non-defaults only: passing segmentation unconditionally would make every variable collect timestamps it never reads. """ - extra = {} - if self.config.stability_segmentation != "count": - extra["segmentation"] = self.config.stability_segmentation - if self.config.stability_require_declining: + extra: Dict[str, Any] = {} + auto = self.config.auto_config_params + if auto.segmentation != "count": + extra["segmentation"] = auto.segmentation + if auto.require_declining: extra["require_declining"] = True + extra["incline_threshold"] = auto.incline_threshold return {**(kwargs or {}), **extra} if extra else kwargs # ---- construction hooks ------------------------------------------------- @@ -143,17 +175,9 @@ def _stability_kwargs(self) -> Dict[str, Any]: name = type(self).__name__ return { "add_value_fn": name, - "detector_config": self.config.to_dict(method_id=name), + "detector_config": _strip_auto_config_params(self.config.to_dict(method_id=name), name), } - def _carried_settings(self) -> Dict[str, Any]: - """Snapshot the operator settings a config reassignment would drop.""" - return {field: getattr(self.config, field) for field in _CARRIED_SETTINGS} - - def _restore_settings(self, saved: Dict[str, Any]) -> None: - for field, value in saved.items(): - setattr(self.config, field, value) - def _warn_time_fallback_once(self, reason: str) -> None: """Log the first time-dependent misconfiguration, then stay quiet. @@ -171,21 +195,22 @@ def _warn_time_fallback_once(self, reason: str) -> None: def _timestamp(self, input_: ParserSchema) -> float | None: """Resolve the record's event time, or None to use count segmentation.""" - if self.config.stability_segmentation == "count": + auto = self.config.auto_config_params + if auto.segmentation == "count": return None - if not self.config.timestamp_variable: + if not auto.timestamp_variable: # Selecting a time-aware mode without naming the field is an operator # error, not an opt-out -- say so rather than silently no-op. self._warn_time_fallback_once( - f"stability_segmentation is {self.config.stability_segmentation!r} " + f"segmentation is {auto.segmentation!r} " "but timestamp_variable is not set" ) return None - raw = input_["logFormatVariables"].get(self.config.timestamp_variable) - ts = self._time_handler.parse_timestamp(str(raw or ""), self.config.timestamp_format) + raw = input_["logFormatVariables"].get(auto.timestamp_variable) + ts = self._time_handler.parse_timestamp(str(raw or ""), auto.timestamp_format) if ts == "0": self._warn_time_fallback_once( - f"timestamp_variable {self.config.timestamp_variable!r} is missing or " + f"timestamp_variable {auto.timestamp_variable!r} is missing or " f"unparseable (got {raw!r})" ) return None @@ -228,7 +253,6 @@ def _ingest(self, input_: ParserSchema, variables: Dict[str, Any], event_id: Any event_id=event_id, event_template=input_["template"], named_variables=variables, - timestamp=self._timestamp(input_), ) def detect(self, input_: ParserSchema, output_: DetectorSchema) -> bool: # type: ignore @@ -304,29 +328,27 @@ def set_configuration(self) -> None: variables: Dict[Any, Any] = {} for event_id, tracker in self.auto_conf_persistency.get_events_data().items(): stability_tracker = cast(EventStabilityTracker, tracker) + auto = self.config.auto_config_params stable = ( stability_tracker.get_features_by_classification("STABLE") - if self.config.use_stable_vars + if auto.use_stable_vars else [] ) static = ( stability_tracker.get_features_by_classification("STATIC") - if self.config.use_static_vars + if auto.use_static_vars else [] ) selected = stable + static if selected: variables[event_id] = selected - saved = self._carried_settings() - config_dict = generate_detector_config( - variable_selection=variables, - detector_name=self.name, - method_type=self.config.method_type, - ) - self.config = type(self.config).from_dict(config_dict, self.name) - self._restore_settings(saved) - events = self.config.events - if isinstance(events, EventsConfig) and not events.events: + # Write only what the configure phase produced. Rebuilding the config + # from generate_detector_config is what used to drop operator settings: + # it emits four keys, so everything else had to be carried across by + # hand and a forgotten field failed silently. + self.config.events = generate_events_config(variables, self.name) + self.config.auto_config = False + if not self.config.events.events: logger.warning( f"[{self.name}] auto_config=True generated an empty configuration. " "No stable variables were found in configure-phase data. " diff --git a/src/detectmatelibrary/detectors/event_sequence_detector.py b/src/detectmatelibrary/detectors/event_sequence_detector.py index 7da1f1e0..2c5e9b01 100644 --- a/src/detectmatelibrary/detectors/event_sequence_detector.py +++ b/src/detectmatelibrary/detectors/event_sequence_detector.py @@ -5,8 +5,8 @@ from pydantic import Field, model_validator -from detectmatelibrary.common._config._compile import generate_detector_config -from detectmatelibrary.common.detector import CoreDetectorConfig, CoreDetector +from detectmatelibrary.common._config._compile import generate_events_config +from detectmatelibrary.common.detector import AutoConfigParams, CoreDetectorConfig, CoreDetector from detectmatelibrary.tools.logging import logger from detectmatelibrary.utils import persistency from detectmatelibrary.utils.data_buffer import BufferMode @@ -23,28 +23,37 @@ def _decode_sequence(encoded: str) -> tuple[int, ...]: return tuple(int(event_id) for event_id in encoded.split(_SEQUENCE_SEPARATOR)) +class SequenceAutoConfigParams(AutoConfigParams): + """Configure-phase inputs: the candidate window lengths to try. + + @param min_window_size shortest window length tried during the + auto-configuration phase. Only used while `fixed_window_size` is None. + @param max_window_size longest window length tried during the + auto-configuration phase. The longest length whose sequences are + classified STABLE or STATIC wins. + """ + + min_window_size: int = Field(default=2, ge=1) + max_window_size: int = Field(default=10, ge=1) + + @model_validator(mode="after") + def _validate_window_range(self) -> "SequenceAutoConfigParams": + if self.max_window_size < self.min_window_size: + raise ValueError("max_window_size must be >= min_window_size") + return self + + class EventSequenceDetectorConfig(CoreDetectorConfig): """ @param fixed_window_size length of the sliding EventID window. A window whose exact EventID sequence was not seen during training is reported as an anomaly. When - set it overrides `min_window_size`/`max_window_size` and skips + set it overrides the `auto_config_params` window range and skips auto-configuration; auto-configuration writes its own choice here. While it is None the detector is unconfigured and neither trains nor alerts. - @param min_window_size shortest window length tried during the auto-configuration - phase. Only used while `fixed_window_size` is None. - @param max_window_size longest window length tried during the auto-configuration - phase. The longest length whose sequences are classified STABLE or STATIC wins. """ method_type: str = "event_sequence_detector" - min_window_size: int = Field(default=2, ge=1) - max_window_size: int = Field(default=10, ge=1) fixed_window_size: int | None = Field(default=None, ge=1) - - @model_validator(mode="after") - def _validate_window_range(self) -> "EventSequenceDetectorConfig": - if self.max_window_size < self.min_window_size: - raise ValueError("max_window_size must be >= min_window_size") - return self + auto_config_params: SequenceAutoConfigParams = SequenceAutoConfigParams() class EventSequenceDetector(CoreDetector): @@ -174,7 +183,8 @@ def configure(self, input_: ParserSchema) -> None: # type: ignore """ if self.config.fixed_window_size is not None: return - for length in range(self.config.min_window_size, self.config.max_window_size + 1): + auto = self.config.auto_config_params + for length in range(auto.min_window_size, auto.max_window_size + 1): window = self._configure_windows.setdefault(length, deque(maxlen=length)) window.append(input_["EventID"]) if len(window) == length: @@ -216,22 +226,15 @@ def set_configuration(self) -> None: stable.append(int(length)) if not stable: + auto = self.config.auto_config_params logger.warning( f"[{self.name}] auto_config=True found no stable window size in " - f"[{self.config.min_window_size}..{self.config.max_window_size}]. " + f"[{auto.min_window_size}..{auto.max_window_size}]. " "Generating an empty configuration — no instance of this detector is " "created and it will neither train nor alert." ) - old_persist = self.config.persist - self.config = EventSequenceDetectorConfig.from_dict( - generate_detector_config( - variable_selection={}, - detector_name=self.name, - method_type=self.config.method_type, - ), - self.name, - ) - self.config.persist = old_persist + self.config.events = generate_events_config({}, self.name) + self.config.auto_config = False self._release_configure_state() return @@ -240,7 +243,9 @@ def set_configuration(self) -> None: f"[{self.name}] auto_config selected fixed_window_size={chosen} " f"from stable candidates {sorted(stable)}." ) + self.config.events = generate_events_config({}, self.name) self._set_window_length(chosen) + self.config.auto_config = False self._release_configure_state() def _release_configure_state(self) -> None: diff --git a/src/detectmatelibrary/detectors/new_event_detector.py b/src/detectmatelibrary/detectors/new_event_detector.py index 9d9a5826..133d6213 100644 --- a/src/detectmatelibrary/detectors/new_event_detector.py +++ b/src/detectmatelibrary/detectors/new_event_detector.py @@ -1,4 +1,4 @@ -from detectmatelibrary.common._config._compile import generate_detector_config +from detectmatelibrary.common._config._compile import generate_events_config from detectmatelibrary.common.detector import CoreDetectorConfig, CoreDetector from detectmatelibrary.common.variable_detector import get_global_variables from detectmatelibrary.utils import persistency @@ -88,12 +88,7 @@ def configure(self, input_: ParserSchema) -> None: # type: ignore ) def set_configuration(self) -> None: - old_persist = self.config.persist - config_dict = generate_detector_config( - variable_selection={}, - detector_name=self.name, - method_type=self.config.method_type - ) - # Update the config object from the dictionary instead of replacing it - self.config = NewEventDetectorConfig.from_dict(config_dict, self.name) - self.config.persist = old_persist + # This detector keys on EventIDs only -- it selects no variables, so + # the configure phase produces an empty events block. + self.config.events = generate_events_config({}, self.name) + self.config.auto_config = False diff --git a/src/detectmatelibrary/detectors/new_value_combo_detector.py b/src/detectmatelibrary/detectors/new_value_combo_detector.py index 7b907a6c..57fe7549 100644 --- a/src/detectmatelibrary/detectors/new_value_combo_detector.py +++ b/src/detectmatelibrary/detectors/new_value_combo_detector.py @@ -1,6 +1,9 @@ -from detectmatelibrary.common._config import generate_detector_config -from detectmatelibrary.common._config._formats import EventsConfig -from detectmatelibrary.common.variable_detector import VariableDetector, VariableDetectorConfig +from detectmatelibrary.common._config import generate_events_config +from detectmatelibrary.common.variable_detector import ( + VariableDetector, + VariableDetectorConfig, + VariableAutoConfigParams, +) from detectmatelibrary.common._config._compile import get_configured_variables from detectmatelibrary.utils import persistency @@ -39,11 +42,19 @@ def get_all_possible_combos( return combo_dict +class ComboAutoConfigParams(VariableAutoConfigParams): + # Combo-detector default, unchanged from the flat field it replaces. + use_static_vars: bool = False + # Longest variable combination the configure phase will consider. Read only + # while auto_config is True: detection reads the combos the phase wrote into + # `events`, never this. + max_combo_size: int = 3 + + class NewValueComboDetectorConfig(VariableDetectorConfig): method_type: str = "new_value_combo_detector" - max_combo_size: int = 3 - use_static_vars: bool = False + auto_config_params: ComboAutoConfigParams = ComboAutoConfigParams() class NewValueComboDetector(VariableDetector): @@ -102,11 +113,8 @@ def set_configuration(self, max_combo_size: int | None = None) -> None: 3. Re-ingest all events to learn the stability of those combos (testing every possible combo up front would explode combinatorially). """ - # Restored after *both* reassignments below, not just the last one: - # the re-ingest loop calls _timestamp() and pass 2 reads use_stable_vars - # under the pass-1 config, so a single restore at the end would run them - # on defaults. - saved = self._carried_settings() + if max_combo_size is not None: + self.config.auto_config_params.max_combo_size = max_combo_size # pass 1: stable individual variables -> combos variable_combos = {} @@ -114,14 +122,7 @@ def set_configuration(self, max_combo_size: int | None = None) -> None: stable_vars = tracker.get_features_by_classification("STABLE") # type: ignore if len(stable_vars) > 1: variable_combos[event_id] = stable_vars - config_dict = generate_detector_config( - variable_selection=variable_combos, - detector_name=self.name, - method_type=self.config.method_type, - max_combo_size=max_combo_size or self.config.max_combo_size, - ) - self.config = NewValueComboDetectorConfig.from_dict(config_dict, self.name) - self._restore_settings(saved) + self.config.events = generate_events_config(variable_combos, self.name) # re-ingest all inputs to learn combos under the new configuration for input_ in self.inputs: @@ -135,30 +136,24 @@ def set_configuration(self, max_combo_size: int | None = None) -> None: # pass 2: stable/static combos -> final config combo_selection = {} + auto = self.config.auto_config_params for event_id, tracker in self.auto_conf_persistency_combos.get_events_data().items(): stable_combos = ( tracker.get_features_by_classification("STABLE") # type: ignore - if self.config.use_stable_vars + if auto.use_stable_vars else [] ) static_combos = ( tracker.get_features_by_classification("STATIC") # type: ignore - if self.config.use_static_vars + if auto.use_static_vars else [] ) combos = stable_combos + static_combos if combos: combo_selection[event_id] = combos - config_dict = generate_detector_config( - variable_selection=combo_selection, - detector_name=self.name, - method_type=self.config.method_type, - max_combo_size=max_combo_size or self.config.max_combo_size, - ) - self.config = NewValueComboDetectorConfig.from_dict(config_dict, self.name) - self._restore_settings(saved) - events = self.config.events - if isinstance(events, EventsConfig) and not events.events: + self.config.events = generate_events_config(combo_selection, self.name) + self.config.auto_config = False + if not self.config.events.events: logger.warning( f"[{self.name}] auto_config=True generated an empty configuration. " "No stable variable combinations were found in configure-phase data. " diff --git a/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/stability/stability_tracker.py b/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/stability/stability_tracker.py index 7f494f04..87c9c7ad 100644 --- a/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/stability/stability_tracker.py +++ b/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/stability/stability_tracker.py @@ -48,6 +48,7 @@ def __init__( min_samples: int = 3, segmentation: Literal["count", "time", "both"] = "count", require_declining: bool = False, + incline_threshold: float = -0.05, add_value_fn: str = "default", detector_config: "CoreDetectorConfig | None" = None, ) -> None: @@ -60,6 +61,7 @@ def __init__( self.unique_set: Set[Any] = set() self.stability_classifier: StabilityClassifier = StabilityClassifier( segment_thresholds=[1.1, 0.3, 0.1, 0.01], + incline_threshold=incline_threshold, ) # ponytail: O(N) timestamps; switch to fixed-width time buckets if # this ever runs unbounded/streaming. @@ -269,6 +271,7 @@ def __init__( converter_function: Callable[[Any], Any] = lambda x: x, segmentation: Literal["count", "time", "both"] = "count", require_declining: bool = False, + incline_threshold: float = -0.05, add_value_fn: str = "default", detector_config: "CoreDetectorConfig | None" = None @@ -279,6 +282,7 @@ def make_tracker() -> SingleStabilityTracker: return SingleStabilityTracker( segmentation=segmentation, require_declining=require_declining, + incline_threshold=incline_threshold, add_value_fn=add_value_fn, detector_config=detector_config, ) diff --git a/tests/test_common/test_auto_config_params.py b/tests/test_common/test_auto_config_params.py new file mode 100644 index 00000000..0d542c9e --- /dev/null +++ b/tests/test_common/test_auto_config_params.py @@ -0,0 +1,94 @@ +"""The auto_config_params block: parsing, round-trip, and strictness.""" + +import warnings + +import pytest +from pydantic import ValidationError + +from detectmatelibrary.common._config._compile import MissingParamsWarning +from detectmatelibrary.common.detector import AutoConfigParams, CoreDetectorConfig + + +class _Params(AutoConfigParams): + knob: int = 1 + + +class _Config(CoreDetectorConfig): + method_type: str = "test_detector" + auto_config_params: _Params = _Params() + + +def _wrap(entry: dict) -> dict: + return {"detectors": {"TestDetector": entry}} + + +def test_block_is_parsed_into_the_nested_model(): + cfg = _Config.from_dict( + _wrap({ + "method_type": "test_detector", + "auto_config": True, + "auto_config_params": {"knob": 7}, + }), + "TestDetector", + ) + assert cfg.auto_config_params.knob == 7 + + +def test_block_round_trips(): + cfg = _Config.from_dict( + _wrap({ + "method_type": "test_detector", + "auto_config": True, + "auto_config_params": {"knob": 7}, + }), + "TestDetector", + ) + dumped = cfg.to_dict(method_id="TestDetector")["detectors"]["TestDetector"] + assert dumped["auto_config_params"] == {"knob": 7} + assert "knob" not in dumped.get("params", {}) + + +def test_default_block_is_not_emitted(): + """A config that never touches auto-config serializes exactly as before.""" + dumped = _Config().to_dict(method_id="TestDetector")["detectors"]["TestDetector"] + assert "auto_config_params" not in dumped + + +def test_unknown_key_in_block_is_rejected(): + with pytest.raises(ValidationError): + _Config.from_dict( + _wrap({ + "method_type": "test_detector", + "auto_config": True, + "auto_config_params": {"nope": 1}, + }), + "TestDetector", + ) + + +def test_auto_param_under_params_is_rejected(): + """The clean break: the old flat spelling is an error, not a silent no-op.""" + with pytest.raises(ValidationError): + _Config.from_dict( + _wrap({ + "method_type": "test_detector", + "auto_config": True, + "params": {"knob": 7}, + }), + "TestDetector", + ) + + +def test_block_alone_counts_as_data(): + """auto_config_params is real configuration and must not trip + MissingParamsWarning.""" + with warnings.catch_warnings(): + warnings.simplefilter("error", MissingParamsWarning) + _Config.from_dict( + _wrap({ + "method_type": "test_detector", + "auto_config": False, + "auto_config_params": {"knob": 7}, + }), + "TestDetector", + ) diff --git a/tests/test_common/test_config.py b/tests/test_common/test_config.py index d83c5960..45ee65ab 100644 --- a/tests/test_common/test_config.py +++ b/tests/test_common/test_config.py @@ -4,13 +4,13 @@ MissingParamsWarning, TypeNotFoundError, MethodTypeNotMatch, - AutoConfigWarning, ) from detectmatelibrary.common._config._formats import EventsConfig, _EventConfig from detectmatelibrary.common._config import BasicConfig from pydantic import ValidationError from tests.test_data import TEST_CONFIG import pytest +import warnings import yaml @@ -81,11 +81,16 @@ def test_process_auto_config_false(self): config_test, method_id="detector_wrong", component_type="detectors" )) - def test_process_auto_config_warning(self): - with pytest.warns(AutoConfigWarning): - ConfigMethods.process(ConfigMethods.get_method( + def test_process_keeps_params_under_auto_config(self): + """params are operational and survive the configure phase, so + auto_config: True alongside params is no longer suspicious.""" + with warnings.catch_warnings(): + warnings.simplefilter("error") + config = ConfigMethods.process(ConfigMethods.get_method( config_test, method_id="detector_weird", component_type="detectors" )) + assert config["auto_config"] is True + assert config["hello"] == "a" class TestParamsFormat: diff --git a/tests/test_detectors/test_auto_config_params_survive.py b/tests/test_detectors/test_auto_config_params_survive.py new file mode 100644 index 00000000..bc18f3fe --- /dev/null +++ b/tests/test_detectors/test_auto_config_params_survive.py @@ -0,0 +1,177 @@ +"""set_configuration writes only its outputs. + +Every field on a detector config other than `events` and `auto_config` is +operator input and must read back unchanged after the configure phase. This +is the regression test for auto-config inputs being silently reset. +""" + +from detectmatelibrary.common._config._formats import EventsConfig +from detectmatelibrary.detectors.event_sequence_detector import ( + EventSequenceDetector, + EventSequenceDetectorConfig, + SequenceAutoConfigParams, +) +from detectmatelibrary.detectors.new_event_detector import ( + NewEventDetector, + NewEventDetectorConfig, +) +from detectmatelibrary.detectors.new_value_combo_detector import ( + ComboAutoConfigParams, + NewValueComboDetector, + NewValueComboDetectorConfig, +) +from detectmatelibrary.detectors.new_value_detector import ( + NewValueDetector, + NewValueDetectorConfig, +) +from detectmatelibrary.common.variable_detector import VariableAutoConfigParams + + +def _schema(event_id: int, level: str, log_id: str): + return { + "logID": log_id, + "EventID": event_id, + "template": "user <*> from <*>", + "variables": ["alice", "10.0.0.1"], + "logFormatVariables": {"user": "alice", "src": "10.0.0.1", "level": level}, + } + + +_AUTO = dict( + use_stable_vars=True, + use_static_vars=True, + segmentation="time", + require_declining=True, + timestamp_variable="level", + timestamp_format="%y%m%d %H%M%S", +) + +_STREAM = [_schema(1, f"081109 2036{i:02d}", str(i)) for i in range(20)] + + +def _assert_auto_params_intact(config): + auto = config.auto_config_params + assert auto.use_stable_vars is True + assert auto.use_static_vars is True + assert auto.segmentation == "time" + assert auto.require_declining is True + assert auto.timestamp_variable == "level" + assert auto.timestamp_format == "%y%m%d %H%M%S" + + +def test_new_value_detector_keeps_auto_config_params(): + detector = NewValueDetector( + name="NewValueDetector", + config=NewValueDetectorConfig( + parser="MyParser", + auto_config_params=VariableAutoConfigParams(**_AUTO), + ), + ) + for record in _STREAM: + detector.configure(record) + detector.set_configuration() + + _assert_auto_params_intact(detector.config) + assert detector.config.parser == "MyParser" + assert detector.config.auto_config is False + assert isinstance(detector.config.events, EventsConfig) + + +def test_combo_detector_keeps_auto_config_params(): + detector = NewValueComboDetector( + name="NewValueComboDetector", + config=NewValueComboDetectorConfig( + parser="MyParser", + auto_config_params=ComboAutoConfigParams(**_AUTO), + ), + ) + for record in _STREAM: + detector.configure(record) + detector.set_configuration() + + _assert_auto_params_intact(detector.config) + assert detector.config.parser == "MyParser" + assert detector.config.auto_config is False + assert isinstance(detector.config.events, EventsConfig) + + +def test_new_event_detector_keeps_operator_settings(): + detector = NewEventDetector( + name="NewEventDetector", + config=NewEventDetectorConfig(parser="MyParser", data_use_training=17), + ) + for record in _STREAM: + detector.configure(record) + detector.set_configuration() + + assert detector.config.parser == "MyParser" + assert detector.config.data_use_training == 17 + assert detector.config.auto_config is False + assert isinstance(detector.config.events, EventsConfig) + + +def test_event_sequence_detector_keeps_operator_settings(): + # Same shape as test_unstable_stream_generates_no_instance in + # test_event_sequence_detector.py: every EventID is unique, so every + # candidate window fills and clears min_samples but its sequences never + # repeat -- no candidate is ever classified STABLE/STATIC. That drives + # the no-stable-candidate early-return branch this task rewrote, which + # is the widest-blast-radius site: it is not a VariableDetector, so + # before this task it restored only `persist` by hand and silently + # reset every other operator field. + detector = EventSequenceDetector( + name="EventSequenceDetector", + config=EventSequenceDetectorConfig( + parser="MyParser", + data_use_configure=5, + data_use_training=1, + use_config_data_as_training=False, + auto_config_params=SequenceAutoConfigParams( + min_window_size=3, + max_window_size=6, + ), + ), + ) + for i, event_id in enumerate(range(40)): + detector.configure(_schema(event_id, "081109 203600", str(i))) + detector.set_configuration() + + assert detector.config.fixed_window_size is None + assert detector.config.parser == "MyParser" + assert detector.config.data_use_configure == 5 + assert detector.config.data_use_training == 1 + assert detector.config.use_config_data_as_training is False + assert detector.config.auto_config_params.min_window_size == 3 + assert detector.config.auto_config_params.max_window_size == 6 + assert detector.config.auto_config is False + assert isinstance(detector.config.events, EventsConfig) + + +def test_event_sequence_detector_keeps_auto_config_params(): + """The sequence detector writes fixed_window_size, not a fresh config.""" + from detectmatelibrary.detectors.event_sequence_detector import ( + EventSequenceDetector, + EventSequenceDetectorConfig, + SequenceAutoConfigParams, + ) + + detector = EventSequenceDetector( + name="EventSequenceDetector", + config=EventSequenceDetectorConfig( + parser="MyParser", + auto_config_params=SequenceAutoConfigParams( + min_window_size=2, max_window_size=4 + ), + ), + ) + for record in _STREAM: + detector.configure(record) + detector.set_configuration() + + auto = detector.config.auto_config_params + assert auto.min_window_size == 2 + assert auto.max_window_size == 4 + assert detector.config.parser == "MyParser" + assert detector.config.fixed_window_size == 4 + assert detector.config.auto_config is False + assert isinstance(detector.config.events, EventsConfig) diff --git a/tests/test_detectors/test_event_sequence_detector.py b/tests/test_detectors/test_event_sequence_detector.py index 461069ec..d4b872cf 100644 --- a/tests/test_detectors/test_event_sequence_detector.py +++ b/tests/test_detectors/test_event_sequence_detector.py @@ -11,7 +11,7 @@ from pydantic import ValidationError from detectmatelibrary.detectors.event_sequence_detector import EventSequenceDetector, \ - EventSequenceDetectorConfig, BufferMode + EventSequenceDetectorConfig, SequenceAutoConfigParams, BufferMode from detectmatelibrary.parsers.template_matcher import MatcherParser from detectmatelibrary.helper.from_to import From import detectmatelibrary.schemas as schemas @@ -72,7 +72,10 @@ def test_default_initialization(self): assert hasattr(detector, "persistency") # unconfigured until auto-config picks a length assert detector.config.fixed_window_size is None - assert (detector.config.min_window_size, detector.config.max_window_size) == (2, 10) + assert ( + detector.config.auto_config_params.min_window_size, + detector.config.auto_config_params.max_window_size, + ) == (2, 10) def test_custom_config_initialization(self): detector = EventSequenceDetector(name="CustomInit", config=config) @@ -277,7 +280,9 @@ def test_short_configure_phase_skips_unfilled_candidates(self): name="ShortConfig", config=EventSequenceDetectorConfig( data_use_configure=6, data_use_training=1, - min_window_size=2, max_window_size=8, + auto_config_params=SequenceAutoConfigParams( + min_window_size=2, max_window_size=8, + ), ), ) @@ -295,8 +300,10 @@ def test_set_configuration_preserves_user_config(self): data_use_configure=6, data_use_training=10, use_config_data_as_training=False, - min_window_size=2, - max_window_size=8, + auto_config_params=SequenceAutoConfigParams( + min_window_size=2, + max_window_size=8, + ), ), ) @@ -307,17 +314,22 @@ def test_set_configuration_preserves_user_config(self): assert detector.config.parser == "MySequenceParser" assert detector.config.data_use_training == 10 assert detector.config.use_config_data_as_training is False - assert (detector.config.min_window_size, detector.config.max_window_size) == (2, 8) + assert ( + detector.config.auto_config_params.min_window_size, + detector.config.auto_config_params.max_window_size, + ) == (2, 8) def test_configure_windows_follow_config_changes(self): """_configure_windows is built lazily, so changing the range after construction must not raise.""" detector = EventSequenceDetector( name="LateCandidates", - config=EventSequenceDetectorConfig(min_window_size=2, max_window_size=3), + config=EventSequenceDetectorConfig( + auto_config_params=SequenceAutoConfigParams(min_window_size=2, max_window_size=3), + ), ) - detector.config.min_window_size = 4 - detector.config.max_window_size = 5 + detector.config.auto_config_params.min_window_size = 4 + detector.config.auto_config_params.max_window_size = 5 detector.configure(_make_schema(1)) @@ -329,7 +341,8 @@ def test_fixed_window_size_skips_auto_config(self): name="FixedWins", config=EventSequenceDetectorConfig( data_use_configure=5, data_use_training=1, - min_window_size=4, max_window_size=6, fixed_window_size=2, + auto_config_params=SequenceAutoConfigParams(min_window_size=4, max_window_size=6), + fixed_window_size=2, ), ) @@ -347,7 +360,7 @@ def test_no_stable_window_size_generates_no_instance(self): config=EventSequenceDetectorConfig( data_use_configure=5, data_use_training=1, # no window of 8+ can fill within a 5-event configure phase - min_window_size=8, max_window_size=10, + auto_config_params=SequenceAutoConfigParams(min_window_size=8, max_window_size=10), ), ) @@ -367,7 +380,9 @@ def test_unstable_stream_generates_no_instance(self): clears min_samples, but the sequences never settle.""" detector = EventSequenceDetector( name="Unstable", - config=EventSequenceDetectorConfig(min_window_size=2, max_window_size=4), + config=EventSequenceDetectorConfig( + auto_config_params=SequenceAutoConfigParams(min_window_size=2, max_window_size=4), + ), ) for i, event_id in enumerate(range(30)): # every event ID unique @@ -513,7 +528,8 @@ def test_restored_state_disables_auto_config(self): config=EventSequenceDetectorConfig( data_use_configure=5, data_use_training=1, - min_window_size=4, max_window_size=5, # 3 deliberately excluded + # 3 deliberately excluded + auto_config_params=SequenceAutoConfigParams(min_window_size=4, max_window_size=5), persist=PersistConfig(path=base_path, auto_load=True), ), ) @@ -536,8 +552,7 @@ def test_persist_survives_empty_configuration(self): data_use_configure=5, data_use_training=1, # no window of 8+ can fill within a 5-event configure phase - min_window_size=8, - max_window_size=10, + auto_config_params=SequenceAutoConfigParams(min_window_size=8, max_window_size=10), persist=PersistConfig(path=base_path), ), ) @@ -582,14 +597,39 @@ def test_zero_fixed_window_size_rejected(self): def test_zero_min_window_size_rejected(self): with pytest.raises(ValidationError): - EventSequenceDetectorConfig(min_window_size=0) + SequenceAutoConfigParams(min_window_size=0) def test_inverted_window_range_rejected(self): with pytest.raises(ValidationError): - EventSequenceDetectorConfig(min_window_size=5, max_window_size=4) + SequenceAutoConfigParams(min_window_size=5, max_window_size=4) def test_removed_fields_rejected(self): """Extra='forbid': configs written for the old field names must fail loudly rather than silently run with defaults.""" with pytest.raises(ValidationError): EventSequenceDetectorConfig(max_sequence_length=3) + + def test_auto_config_params_round_trip(self): + block = {"min_window_size": 3, "max_window_size": 7} + source = { + "detectors": { + "EventSequenceDetector": { + "method_type": "event_sequence_detector", + "auto_config": True, + "auto_config_params": block, + # Explicit (rather than omitted) so the first from_dict + # already coerces `events` to EventsConfig -- otherwise it + # stays the bare-dict field default and the round-trip + # equality below fails on that unrelated field, not on + # auto_config_params (pydantic does not validate/coerce + # field defaults, only explicit constructor input). + "events": {}, + } + } + } + config = EventSequenceDetectorConfig.from_dict(source, "EventSequenceDetector") + dumped = config.to_dict(method_id="EventSequenceDetector") + entry = dumped["detectors"]["EventSequenceDetector"] + assert block.items() <= entry["auto_config_params"].items() + assert not set(block) & set(entry.get("params", {})) + assert EventSequenceDetectorConfig.from_dict(dumped, "EventSequenceDetector") == config diff --git a/tests/test_detectors/test_new_value_combo_detector.py b/tests/test_detectors/test_new_value_combo_detector.py index cc1f319e..fafec9f7 100644 --- a/tests/test_detectors/test_new_value_combo_detector.py +++ b/tests/test_detectors/test_new_value_combo_detector.py @@ -2,6 +2,7 @@ from detectmatelibrary.detectors.new_value_combo_detector import ( NewValueComboDetector, NewValueComboDetectorConfig, + ComboAutoConfigParams, ) from detectmatelibrary.utils.data_buffer import BufferMode from detectmatelibrary.common._config import generate_detector_config @@ -20,7 +21,7 @@ "CustomInit": { "method_type": "new_value_combo_detector", "auto_config": False, - "params": { + "auto_config_params": { "max_combo_size": 4 }, "events": { @@ -37,7 +38,7 @@ "MultipleDetector": { "method_type": "new_value_combo_detector", "auto_config": False, - "params": { + "auto_config_params": { "max_combo_size": 2 }, "events": { @@ -72,7 +73,7 @@ def test_custom_config_initialization(self): detector = NewValueComboDetector(name="CustomInit", config=config) assert detector.name == "CustomInit" - assert detector.config.max_combo_size == 4 + assert detector.config.auto_config_params.max_combo_size == 4 class TestNewValueComboDetectorTraining: @@ -291,7 +292,7 @@ def test_set_configuration_updates_config(self): # Verify config was updated assert detector.config.events is not None - assert detector.config.max_combo_size == 2 + assert detector.config.auto_config_params.max_combo_size == 2 def test_configuration_workflow(self): """Test complete configuration workflow like in notebook.""" @@ -363,7 +364,7 @@ def test_set_configuration_with_combo_size(self): detector.set_configuration(max_combo_size=4) # Verify max_combo_size was updated - assert detector.config.max_combo_size == 4 + assert detector.config.auto_config_params.max_combo_size == 4 def test_configuration_with_no_stable_variables(self): """Test configuration when no stable variables are found.""" @@ -583,19 +584,19 @@ def test_audit_log_anomalies(self): class TestNewValueComboDetectorSegmentationConfigPreservation: - """set_configuration() reassigns self.config twice (pass 1: combo - candidates, pass 2: final selection), each from a freshly generated config - dict whose params only ever carry max_combo_size. + """auto_config_params survive set_configuration untouched. - stability_segmentation, timestamp_variable and timestamp_format must - survive both reassignments, the same way persist already does. + The configure phase writes only `events` and `auto_config`; every other + field on the config is operator input. """ def test_segmentation_fields_survive_set_configuration(self): cfg = NewValueComboDetectorConfig( - stability_segmentation="time", - timestamp_variable="level", - timestamp_format="%y%m%d %H%M%S", + auto_config_params=ComboAutoConfigParams( + segmentation="time", + timestamp_variable="level", + timestamp_format="%y%m%d %H%M%S", + ), ) detector = NewValueComboDetector(config=cfg, name="NewValueComboDetector") assert detector.config.auto_config is True # the default (set_configuration-first) path @@ -616,13 +617,13 @@ def test_segmentation_fields_survive_set_configuration(self): detector.set_configuration(max_combo_size=2) - assert detector.config.stability_segmentation == "time" - assert detector.config.timestamp_variable == "level" - assert detector.config.timestamp_format == "%y%m%d %H%M%S" + assert detector.config.auto_config_params.segmentation == "time" + assert detector.config.auto_config_params.timestamp_variable == "level" + assert detector.config.auto_config_params.timestamp_format == "%y%m%d %H%M%S" class TestNewValueComboDetectorSegmentationCombos: - """The combo-stability pass must honour stability_segmentation too. + """The combo-stability pass must honour segmentation too. auto_conf_persistency_combos is built directly in __init__ rather than from the _event_data_kwargs hook, and its re-ingest loop in set_configuration @@ -637,8 +638,10 @@ class TestNewValueComboDetectorSegmentationCombos: def _records(segmentation="time"): detector = NewValueComboDetector( config=NewValueComboDetectorConfig( - stability_segmentation=segmentation, - timestamp_variable="ts", + auto_config_params=ComboAutoConfigParams( + segmentation=segmentation, + timestamp_variable="ts", + ), ), name="NewValueComboDetector", ) @@ -678,3 +681,39 @@ def test_combo_trackers_stay_count_based_when_flag_is_off(self): ] assert tracker.segmentation == "count" assert tracker.timestamps == [] + + def test_auto_config_params_round_trip(self): + """A populated block survives from_dict -> to_dict unchanged and never + leaks into params.""" + block = { + "use_stable_vars": True, + "use_static_vars": True, + "segmentation": "both", + "timestamp_variable": "level", + "timestamp_format": "%y%m%d %H%M%S", + "require_declining": True, + } + source = { + "detectors": { + "NewValueComboDetector": { + "method_type": "new_value_combo_detector", + "auto_config": True, + "auto_config_params": block, + # Explicit (rather than omitted) so the first from_dict + # already coerces `events` to EventsConfig -- otherwise it + # stays the bare-dict field default and the round-trip + # equality below fails on that unrelated field, not on + # auto_config_params (pydantic does not validate/coerce + # field defaults, only explicit constructor input). + "events": {}, + } + } + } + config = NewValueComboDetectorConfig.from_dict(source, "NewValueComboDetector") + dumped = config.to_dict(method_id="NewValueComboDetector") + entry = dumped["detectors"]["NewValueComboDetector"] + # Subset, not equality: later tasks add fields to this model and an + # exact-match assertion would break every time one lands. + assert block.items() <= entry["auto_config_params"].items() + assert not set(block) & set(entry.get("params", {})) + assert NewValueComboDetectorConfig.from_dict(dumped, "NewValueComboDetector") == config diff --git a/tests/test_persistency/test_incline_stability.py b/tests/test_persistency/test_incline_stability.py index d375c71a..6a0a9cdb 100644 --- a/tests/test_persistency/test_incline_stability.py +++ b/tests/test_persistency/test_incline_stability.py @@ -10,6 +10,7 @@ import numpy as np from detectmatelibrary.detectors.charset_detector import CharsetDetector, CharsetDetectorConfig +from detectmatelibrary.common.variable_detector import VariableAutoConfigParams from detectmatelibrary.utils.persistency.rle_list import RLEList from detectmatelibrary.utils.persistency.event_data_structures.trackers import ( StabilityClassifier, @@ -75,7 +76,7 @@ def test_no_changes_after_the_first_hits_the_floor(self): def test_changing_every_step_is_perfectly_uniform(self): clf = make_classifier() - assert clf.incline(RLEList([True] * 40) ) == 0.0 + assert clf.incline(RLEList([True] * 40)) == 0.0 def test_too_short_to_have_a_span(self): clf = make_classifier() @@ -97,8 +98,8 @@ def test_rle_and_plain_list_agree(self): assert clf.incline(RLEList(f)) == clf.incline(f) def test_sign_always_matches_the_least_squares_slope(self): - """k_OLS = k * 12m / n(n-1), a strictly positive factor -- so a - polyfit over the same series can never disagree on the verdict.""" + """k_OLS = k * 12m / n(n-1), a strictly positive factor -- so a polyfit + over the same series can never disagree on the verdict.""" clf, rng = make_classifier(), np.random.default_rng(13) for _ in range(200): n = int(rng.integers(4, 300)) @@ -198,40 +199,72 @@ class TestRequireDecliningConfigWiring: def test_flag_reaches_per_variable_trackers(self): # CharsetDetector's `config` default is a shared mutable instance, so # pass explicit fresh configs (see test_time_dependent_stability.py). + # + # require_declining only shapes the configure-phase persistency: the + # trained persistency is read by _check_variable, which never calls + # classify(), so it never receives stability kwargs at all. default = CharsetDetector(config=CharsetDetectorConfig()) assert default.persistency.event_data_kwargs.get("require_declining") is None configured = CharsetDetector(config=CharsetDetectorConfig()) - configured.config.stability_require_declining = True + configured.config.auto_config_params.require_declining = True rebuilt = CharsetDetector(config=configured.config.to_dict(method_id="CharsetDetector")) - assert rebuilt.persistency.event_data_kwargs["require_declining"] is True + assert rebuilt.auto_conf_persistency.event_data_kwargs["require_declining"] is True def test_config_field_round_trips(self): detector = CharsetDetector(config=CharsetDetectorConfig()) - detector.config.stability_require_declining = True + detector.config.auto_config_params.require_declining = True restored = type(detector.config).from_dict( detector.config.to_dict(method_id="CharsetDetector"), "CharsetDetector" ) - assert restored.stability_require_declining is True + assert restored.auto_config_params.require_declining is True def test_survives_auto_config(self): - """set_configuration() rebuilds config from generate_detector_config, - which emits none of the operator settings -- they get carried across.""" + """set_configuration() writes only config.events and flips + config.auto_config to False -- it never touches auto_config_params, so + operator settings survive because nothing overwrites them.""" detector = CharsetDetector(config=CharsetDetectorConfig( - auto_config=True, stability_require_declining=True, use_static_vars=False, + auto_config=True, + auto_config_params=VariableAutoConfigParams( + require_declining=True, use_static_vars=False, + ), )) detector.set_configuration() - assert detector.config.stability_require_declining is True - assert detector.config.use_static_vars is False + assert detector.config.auto_config_params.require_declining is True + assert detector.config.auto_config_params.use_static_vars is False def test_does_not_pull_in_the_timestamp_requirement(self): """The flag is orthogonal to segmentation: no timestamps are asked for, and none are collected.""" detector = CharsetDetector(config=CharsetDetectorConfig()) - detector.config.stability_require_declining = True + detector.config.auto_config_params.require_declining = True rebuilt = CharsetDetector(config=detector.config.to_dict(method_id="CharsetDetector")) assert "segmentation" not in rebuilt.persistency.event_data_kwargs tracker = SingleStabilityTracker(require_declining=True) tracker.add_value("a", timestamp=1.0) assert tracker.timestamps == [] + + +def test_incline_threshold_reaches_the_classifier(): + """The threshold is configuration, not a constant buried in the + classifier.""" + from detectmatelibrary.common.variable_detector import VariableAutoConfigParams + from detectmatelibrary.detectors.new_value_detector import ( + NewValueDetector, + NewValueDetectorConfig, + ) + + detector = NewValueDetector( + name="NewValueDetector", + config=NewValueDetectorConfig( + auto_config_params=VariableAutoConfigParams( + require_declining=True, incline_threshold=-0.25 + ), + ), + ) + persistency = detector.auto_conf_persistency + tracker = persistency.event_data_class(**persistency.event_data_kwargs) + single = tracker.single_tracker_type() + assert single.require_declining is True + assert single.stability_classifier.incline_threshold == -0.25 diff --git a/tests/test_persistency/test_time_dependent_stability.py b/tests/test_persistency/test_time_dependent_stability.py index f8e03d88..e90038de 100644 --- a/tests/test_persistency/test_time_dependent_stability.py +++ b/tests/test_persistency/test_time_dependent_stability.py @@ -1,10 +1,11 @@ -"""Tests for the stability_segmentation option of the stability trackers.""" +"""Tests for the segmentation option of the stability trackers.""" import logging import math import detectmatelibrary.schemas as schemas from detectmatelibrary.detectors.charset_detector import CharsetDetector, CharsetDetectorConfig +from detectmatelibrary.common.variable_detector import VariableAutoConfigParams from detectmatelibrary.utils.persistency.rle_list import RLEList from detectmatelibrary.utils.persistency import EventPersistency from detectmatelibrary.utils.persistency.event_data_structures.trackers import ( @@ -390,24 +391,24 @@ def test_returns_none_when_not_configured(self): def test_parses_iso_timestamp(self): detector = CharsetDetector(config=CharsetDetectorConfig()) - detector.config.stability_segmentation = "time" - detector.config.timestamp_variable = "ts" + detector.config.auto_config_params.segmentation = "time" + detector.config.auto_config_params.timestamp_variable = "ts" assert detector._timestamp(_parser_record("2026-08-04 10:00:00")) == 1785837600.0 def test_parses_explicit_format(self): """HDFS loghub style, absent from COMMON_TIME_FORMATS.""" detector = CharsetDetector(config=CharsetDetectorConfig()) - detector.config.stability_segmentation = "time" - detector.config.timestamp_variable = "ts" - detector.config.timestamp_format = "%y%m%d %H%M%S" + detector.config.auto_config_params.segmentation = "time" + detector.config.auto_config_params.timestamp_variable = "ts" + detector.config.auto_config_params.timestamp_format = "%y%m%d %H%M%S" first = detector._timestamp(_parser_record("081109 203615")) second = detector._timestamp(_parser_record("081109 203645")) assert second - first == 30.0 def test_unparseable_warns_once_and_falls_back(self, caplog): detector = CharsetDetector(config=CharsetDetectorConfig()) - detector.config.stability_segmentation = "time" - detector.config.timestamp_variable = "ts" + detector.config.auto_config_params.segmentation = "time" + detector.config.auto_config_params.timestamp_variable = "ts" with caplog.at_level(logging.WARNING): assert detector._timestamp(_parser_record("not-a-time")) is None assert detector._timestamp(_parser_record("also-not-a-time")) is None @@ -415,11 +416,11 @@ def test_unparseable_warns_once_and_falls_back(self, caplog): assert len(warnings) == 1 def test_unset_timestamp_variable_warns_once_and_falls_back(self, caplog): - """stability_segmentation="time" without timestamp_variable is an - operator error, not an opt-out: it must be distinguishable from a - working time-dependent run, and must not flood the log.""" + """Segmentation="time" without timestamp_variable is an operator error, + not an opt-out: it must be distinguishable from a working time- + dependent run, and must not flood the log.""" detector = CharsetDetector(config=CharsetDetectorConfig()) - detector.config.stability_segmentation = "time" # timestamp_variable left unset + detector.config.auto_config_params.segmentation = "time" # timestamp_variable left unset with caplog.at_level(logging.WARNING): assert detector._timestamp(_parser_record("2026-08-04 10:00:00")) is None assert detector._timestamp(_parser_record("2026-08-04 10:00:01")) is None @@ -436,8 +437,8 @@ def test_flag_off_stays_silent(self, caplog): def test_missing_variable_warns_and_falls_back(self, caplog): detector = CharsetDetector(config=CharsetDetectorConfig()) - detector.config.stability_segmentation = "time" - detector.config.timestamp_variable = "absent" + detector.config.auto_config_params.segmentation = "time" + detector.config.auto_config_params.timestamp_variable = "absent" with caplog.at_level(logging.WARNING): assert detector._timestamp(_parser_record("2026-08-04 10:00:00")) is None assert any("timestamp_variable" in r.message for r in caplog.records) @@ -447,75 +448,73 @@ class TestSegmentationConfigWiring: def test_flag_reaches_per_variable_trackers(self): # CharsetDetector's `config` parameter default is a single shared # CharsetDetectorConfig() instance (pre-existing mutable-default-arg - # pitfall, unrelated to stability_segmentation). Other tests in this + # pitfall, unrelated to segmentation). Other tests in this # module mutate `detector.config.*` in place on a bare # CharsetDetector(), so we pass explicit fresh configs here to stay # isolated from that. + # segmentation only shapes the configure-phase persistency: the + # trained persistency is read by _check_variable, which never calls + # classify(), so it never receives stability kwargs at all. detector = CharsetDetector(config=CharsetDetectorConfig()) assert detector.persistency.event_data_kwargs.get("segmentation") is None configured = CharsetDetector(config=CharsetDetectorConfig()) - configured.config.stability_segmentation = "time" + configured.config.auto_config_params.segmentation = "time" rebuilt = CharsetDetector(config=configured.config.to_dict(method_id="CharsetDetector")) - assert rebuilt.persistency.event_data_kwargs["segmentation"] == "time" + assert rebuilt.auto_conf_persistency.event_data_kwargs["segmentation"] == "time" def test_config_fields_round_trip(self): detector = CharsetDetector(config=CharsetDetectorConfig()) - detector.config.stability_segmentation = "time" - detector.config.timestamp_variable = "ts" - detector.config.timestamp_format = "%y%m%d %H%M%S" + detector.config.auto_config_params.segmentation = "time" + detector.config.auto_config_params.timestamp_variable = "ts" + detector.config.auto_config_params.timestamp_format = "%y%m%d %H%M%S" restored = type(detector.config).from_dict( detector.config.to_dict(method_id="CharsetDetector"), "CharsetDetector" ) - assert restored.stability_segmentation == "time" - assert restored.timestamp_variable == "ts" - assert restored.timestamp_format == "%y%m%d %H%M%S" - - def test_train_populates_timestamps_end_to_end(self): - cfg = { - "detectors": { - "CharsetDetector": { - "method_type": "charset_detector", - "auto_config": False, - "params": { - "stability_segmentation": "time", - "timestamp_variable": "ts", - "timestamp_format": "%y%m%d %H%M%S", - }, - "events": { - 1: { - "inst": { - "params": {}, - "variables": [{"pos": 0, "name": "v", "params": {}}], - } - } - }, - } - } - } + assert restored.auto_config_params.segmentation == "time" + assert restored.auto_config_params.timestamp_variable == "ts" + assert restored.auto_config_params.timestamp_format == "%y%m%d %H%M%S" + + def test_configure_populates_timestamps_end_to_end(self): + """Segmentation settings reach the configure-phase persistency's + trackers end-to-end. + + This used to run through train()/.persistency, but the trained + path no longer receives stability kwargs at all (see + test_train_path_records_no_timestamps) -- configure()/ + .auto_conf_persistency is the phase these settings are for. + """ + cfg = CharsetDetectorConfig( + auto_config_params=VariableAutoConfigParams( + segmentation="time", + timestamp_variable="ts", + timestamp_format="%y%m%d %H%M%S", + ), + ) detector = CharsetDetector(config=cfg, name="CharsetDetector") - detector.train(_parser_record("081109 203615")) - detector.train(_parser_record("081109 203645")) - tracker = detector.persistency.get_events_data()[1].get_data()["v"] + detector.configure(_parser_record("081109 203615")) + detector.configure(_parser_record("081109 203645")) + tracker = detector.auto_conf_persistency.get_events_data()[1].get_data()["var_0"] assert tracker.segmentation == "time" assert len(tracker.timestamps) == len(tracker.change_series) == 2 assert tracker.timestamps[1] - tracker.timestamps[0] == 30.0 def test_segmentation_fields_survive_auto_config_set_configuration(self): - """set_configuration() reassigns self.config wholesale from a config - dict generated with empty params (generate_detector_config only emits - method_type/auto_config/params/events), so stability_segmentation, - timestamp_variable and timestamp_format must be carried across that - reassignment explicitly -- same as `persist` already is. + """set_configuration() writes only self.config.events and then flips + auto_config to False -- it never rebuilds or reassigns self.config + wholesale, so auto_config_params (like every other operator-set field, + e.g. `persist`) is left untouched by construction. auto_config defaults to True and core.py runs set_configuration() before train(), so this is the path every detector takes unless auto_config is explicitly disabled. """ cfg = CharsetDetectorConfig( - stability_segmentation="time", - timestamp_variable="ts", - timestamp_format="%y%m%d %H%M%S", + auto_config_params=VariableAutoConfigParams( + segmentation="time", + timestamp_variable="ts", + timestamp_format="%y%m%d %H%M%S", + ), ) detector = CharsetDetector(config=cfg, name="CharsetDetector") assert detector.config.auto_config is True @@ -524,9 +523,9 @@ def test_segmentation_fields_survive_auto_config_set_configuration(self): detector.configure(_parser_record("081109 203615")) detector.set_configuration() - assert detector.config.stability_segmentation == "time" - assert detector.config.timestamp_variable == "ts" - assert detector.config.timestamp_format == "%y%m%d %H%M%S" + assert detector.config.auto_config_params.segmentation == "time" + assert detector.config.auto_config_params.timestamp_variable == "ts" + assert detector.config.auto_config_params.timestamp_format == "%y%m%d %H%M%S" class TestBothSegmentation: @@ -618,12 +617,14 @@ def test_stability_note_is_not_persisted(self): assert "_stability_note" not in tracker.to_state() def test_config_accepts_both_and_reaches_trackers(self): + # segmentation only shapes the configure-phase persistency (see + # TestSegmentationConfigWiring.test_flag_reaches_per_variable_trackers). configured = CharsetDetector(config=CharsetDetectorConfig()) - configured.config.stability_segmentation = "both" + configured.config.auto_config_params.segmentation = "both" rebuilt = CharsetDetector( config=configured.config.to_dict(method_id="CharsetDetector") ) - assert rebuilt.persistency.event_data_kwargs["segmentation"] == "both" + assert rebuilt.auto_conf_persistency.event_data_kwargs["segmentation"] == "both" def test_event_tracker_propagates_both(self): event_tracker = EventStabilityTracker(segmentation="both") @@ -632,3 +633,67 @@ def test_event_tracker_propagates_both(self): single = event_tracker.get_data()["var1"] assert single.segmentation == "both" assert single.timestamps == [1.0, 2.0] + + +def test_train_path_records_no_timestamps(): + """Auto-config settings shape the configure-phase persistency only. + + Stability classification is never consulted at detect time, so the + trained trackers would carry an unread timestamps list per variable. + """ + from detectmatelibrary.common.variable_detector import VariableAutoConfigParams + from detectmatelibrary.detectors.new_value_detector import ( + NewValueDetector, + NewValueDetectorConfig, + ) + + detector = NewValueDetector( + name="NewValueDetector", + config=NewValueDetectorConfig( + auto_config_params=VariableAutoConfigParams( + segmentation="time", timestamp_variable="ts", + ), + ), + ) + records = [_parser_record(f"2026-08-04 10:{i:02d}:00") for i in range(20)] + for record in records: + detector.configure(record) + detector.set_configuration() + for record in records: + detector.train(record) + + trained = detector.persistency.get_events_data()[1].get_data() + assert trained, "expected the configure phase to select at least one variable" + for tracker in trained.values(): + assert tracker.segmentation == "count" + assert tracker.timestamps == [] + + # the configure-phase persistency still gets them + configured = detector.auto_conf_persistency.get_events_data()[1].get_data() + assert any(t.segmentation == "time" for t in configured.values()) + + +def test_persisted_state_omits_auto_config_params(): + """Persisted tracker state never carries auto_config_params: they are + configure-phase-only inputs, and CharsetDetector's add_value closure + (recovered from `detector_config` on reconstruction, see + _strip_auto_config_params in variable_detector.py) reads only + operational fields, never auto_config_params. + """ + cfg = CharsetDetectorConfig( + auto_config_params=VariableAutoConfigParams( + segmentation="time", timestamp_variable="ts", + ), + ) + detector = CharsetDetector(config=cfg, name="CharsetDetector") + for _ in range(5): + detector.configure(_parser_record("2026-08-04 10:00:00")) + detector.set_configuration() + detector.train(_parser_record("2026-08-04 10:00:00")) + + trained = detector.persistency.get_events_data()[1].get_data() + assert trained, "expected the configure phase to select at least one variable" + for tracker in trained.values(): + state = tracker.to_state() + entry = state["detector_config"]["detectors"]["CharsetDetector"] + assert "auto_config_params" not in entry From dcd9de9aa6ba01b1e0067f2fa1949e74b9e9dfc1 Mon Sep 17 00:00:00 2001 From: viktorbeck98 Date: Thu, 20 Aug 2026 15:41:32 +0200 Subject: [PATCH 22/33] Mark the time-segmentation doc example as an opt-in The section's only config block showed `segmentation: time`, which reads as the recommended setup. The default is `count` -- as the fields table below it already says -- so name that at the point the example appears. --- docs/detectors.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/detectors.md b/docs/detectors.md index 979db1c1..05285f2f 100644 --- a/docs/detectors.md +++ b/docs/detectors.md @@ -260,7 +260,8 @@ These parameters live on every `VariableDetector` subclass (`NewValueDetector`, `NewValueComboDetector`, `ValueRangeDetector`, `CharsetDetector`, `BigramDetector`, …) and go in the detector's `auto_config_params` block — they are inputs to the auto-configuration phase, read only while `auto_config` is `True`, and never -consulted at detection time: +consulted at detection time. `segmentation` defaults to `count`; the block below +opts in to the time-aware mode: ```yaml detectors: @@ -268,7 +269,7 @@ detectors: method_type: new_value_detector auto_config: True auto_config_params: - segmentation: time + segmentation: time # opt-in; the default is count timestamp_variable: Time # a field name from the parser's log_format timestamp_format: "%y%m%d %H%M%S" # optional; omit to auto-detect ``` From 8c5135997ecf75f9fb708f669faf94031e2f9d64 Mon Sep 17 00:00:00 2001 From: "angre.garcia-gomez@ait.ac.at" Date: Wed, 26 Aug 2026 09:32:24 +0200 Subject: [PATCH 23/33] update detectmateperformance --- pyproject.toml | 3 +-- uv.lock | 10 +++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ef97e9c0..f2afde4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,8 +13,7 @@ dependencies = [ "pyyaml>=6.0.3", "regex>=2025.11.3", "numpy>=2.3.2", - #"detectmateperformance>=0.1.0", - "detectmateperformance @ git+https://github.com/ait-detectmate/DetectMatePerformance", + "detectmateperformance>=0.1.5", "msgpack>=1.0.0", "fsspec>=2024.1.0", "pyarrow>=24.0.0", diff --git a/uv.lock b/uv.lock index 6c7df305..dd400d2d 100644 --- a/uv.lock +++ b/uv.lock @@ -247,7 +247,7 @@ requires-dist = [ { name = "detectmatelibrary", extras = ["dataframes"], marker = "extra == 'full'" }, { name = "detectmatelibrary", extras = ["llm"], marker = "extra == 'full'" }, { name = "detectmatelibrary", extras = ["polars-rtcompat"], marker = "extra == 'full'" }, - { name = "detectmateperformance", git = "https://github.com/ait-detectmate/DetectMatePerformance" }, + { name = "detectmateperformance", specifier = ">=0.1.5" }, { name = "fsspec", specifier = ">=2024.1.0" }, { name = "msgpack", specifier = ">=1.0.0" }, { name = "numpy", specifier = ">=2.3.2" }, @@ -279,8 +279,8 @@ dev = [ [[package]] name = "detectmateperformance" -version = "0.1.0" -source = { git = "https://github.com/ait-detectmate/DetectMatePerformance#a5bb075b0bd15e406430e77ccbfcf6fe94a50ab0" } +version = "0.1.5" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "levenshtein" }, { name = "numpy" }, @@ -289,6 +289,10 @@ dependencies = [ { name = "setuptools" }, { name = "tqdm" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/4b/59/92a2666a0062173607e83cf3b1fb9657cbb098c88284102de91202676c6c/detectmateperformance-0.1.5.tar.gz", hash = "sha256:67aa98302a7fc797070a8b0c6eacd147649f1f3df74e24e2753fdf1c0bdaaa1c", size = 826361, upload-time = "2026-08-26T07:28:41.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/c3/5386d48d8979655662de51ba1fdf685f6cd92e021efd13bed7a980dc981c/detectmateperformance-0.1.5-py3-none-any.whl", hash = "sha256:82b1b0d8c0163dab66b251a5766c34140a22b683a567b5075b25386bc6cd5eaa", size = 836303, upload-time = "2026-08-26T07:28:39.719Z" }, +] [[package]] name = "distro" From 4d98181e66a16d38b2018af8b51983be762d4bea Mon Sep 17 00:00:00 2001 From: "angre.garcia-gomez@ait.ac.at" Date: Wed, 26 Aug 2026 09:47:46 +0200 Subject: [PATCH 24/33] update docs --- docs/examples/parsers/drain_parser.py | 70 +++++++++++++++++++++++++++ docs/parsers/drain_parser.md | 64 +----------------------- 2 files changed, 72 insertions(+), 62 deletions(-) create mode 100644 docs/examples/parsers/drain_parser.py diff --git a/docs/examples/parsers/drain_parser.py b/docs/examples/parsers/drain_parser.py new file mode 100644 index 00000000..cfd949a4 --- /dev/null +++ b/docs/examples/parsers/drain_parser.py @@ -0,0 +1,70 @@ +# flake8: noqa + +# --8<-- [start:example_1] +from detectmatelibrary.parsers.drain import DrainParser +from detectmatelibrary import schemas + +# instantiate parser (config can be a dict or a config object) +config_dict = { + "parsers": { + "DrainParser": { + "method_type": "drain_parser", + "data_use_training": 2, + "reset_in_post_train": False, + } + } +} + +parser = DrainParser(config=config_dict) + +parsed = parser.process(schemas.LogSchema({"log": "hello there, general kenobi!"})) +print(parsed["template"]) # "templates not yet generated" + +parsed = parser.process(schemas.LogSchema({"log": "hello there, captain kenobi!"})) +print(parsed["template"]) # "templates not yet generated" + +parsed = parser.process(schemas.LogSchema({"log": "hello there, sargent kenobi!"})) +print(parsed["template"]) # "hello there <*> kenobi" + +parser.update_state("keep_training") +parser.process(schemas.LogSchema({"log": "bella ciao bella ciao"})) +parser.update_state("stop_training") + +parsed = parser.process(schemas.LogSchema({"log": "hello there, sargent kenobi!"})) +print(parsed["template"]) # "hello there <*> kenobi" +# --8<-- [end:example_1] + + +# --8<-- [start:example_2] +from detectmatelibrary.parsers.drain import DrainParser +from detectmatelibrary import schemas + +# instantiate parser (config can be a dict or a config object) +config_dict = { + "parsers": { + "DrainParser": { + "method_type": "drain_parser", + "data_use_training": 2, + "reset_in_post_train": False, + } + } +} + +parser = DrainParser(config=config_dict) + +parsed = parser.process(schemas.LogSchema({"log": "hello there, general kenobi!"})) +print(parsed["template"]) # "templates not yet generated" + +parsed = parser.process(schemas.LogSchema({"log": "hello there, captain kenobi!"})) +print(parsed["template"]) # "templates not yet generated" + +parsed = parser.process(schemas.LogSchema({"log": "hello there, sargent kenobi!"})) +print(parsed["template"]) # "hello there <*> kenobi" + +parser.update_state("keep_training") +parser.process(schemas.LogSchema({"log": "bella ciao bella ciao"})) +parser.update_state("stop_training") + +parsed = parser.process(schemas.LogSchema({"log": "hello there, sargent kenobi!"})) +print(parsed["template"]) # "template not found" +# --8<-- [end:example_2] diff --git a/docs/parsers/drain_parser.md b/docs/parsers/drain_parser.md index 92014716..7a585e7d 100644 --- a/docs/parsers/drain_parser.md +++ b/docs/parsers/drain_parser.md @@ -37,73 +37,13 @@ parsers: Simple usage (Reset = False): ```python -from detectmatelibrary.parsers.drain import DrainParser -from detectmatelibrary import schemas - -# instantiate parser (config can be a dict or a config object) -config_dict = { - "parsers": { - "DrainParser": { - "method_type": "drain_parser", - "data_use_training": 2, - "reset_in_post_train": False, - } - } -} - -parser = DrainParser(config=config_dict) - -parsed = parser.process(schemas.LogSchema({"log": "hello there, general kenobi!"})) -print(parsed["template"]) # "templates not yet generated" - -parsed = parser.process(schemas.LogSchema({"log": "hello there, captain kenobi!"})) -print(parsed["template"]) # "templates not yet generated" - -parsed = parser.process(schemas.LogSchema({"log": "hello there, sargent kenobi!"})) -print(parsed["template"]) # "hello there <*> kenobi" - -parser.update_state("keep_training") -parser.process(schemas.LogSchema({"log": "bella ciao bella ciao"})) -parser.update_state("stop_training") - -parsed = parser.process(schemas.LogSchema({"log": "hello there, sargent kenobi!"})) -print(parsed["template"]) # "hello there <*> kenobi" +--8<-- "docs/examples/parsers/drain_parser.py:example_1" ``` Simple usage (Reset = True): ```python -from detectmatelibrary.parsers.drain import DrainParser -from detectmatelibrary import schemas - -# instantiate parser (config can be a dict or a config object) -config_dict = { - "parsers": { - "DrainParser": { - "method_type": "drain_parser", - "data_use_training": 2, - "reset_in_post_train": False, - } - } -} - -parser = DrainParser(config=config_dict) - -parsed = parser.process(schemas.LogSchema({"log": "hello there, general kenobi!"})) -print(parsed["template"]) # "templates not yet generated" - -parsed = parser.process(schemas.LogSchema({"log": "hello there, captain kenobi!"})) -print(parsed["template"]) # "templates not yet generated" - -parsed = parser.process(schemas.LogSchema({"log": "hello there, sargent kenobi!"})) -print(parsed["template"]) # "hello there <*> kenobi" - -parser.update_state("keep_training") -parser.process(schemas.LogSchema({"log": "bella ciao bella ciao"})) -parser.update_state("stop_training") - -parsed = parser.process(schemas.LogSchema({"log": "hello there, sargent kenobi!"})) -print(parsed["template"]) # "template not found" +--8<-- "docs/examples/parsers/drain_parser.py:example_2" ``` Go back to [Index](../index.md) From 6be9eb56e5c1e91c4db6b6ee15fc352b00d0a8e5 Mon Sep 17 00:00:00 2001 From: "angre.garcia-gomez@ait.ac.at" Date: Wed, 26 Aug 2026 09:56:26 +0200 Subject: [PATCH 25/33] address comments --- .../common/_core_op/_fed_component.py | 14 +++++++++++--- tests/test_common/test_core_federation.py | 6 +++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/detectmatelibrary/common/_core_op/_fed_component.py b/src/detectmatelibrary/common/_core_op/_fed_component.py index 04c40be1..eef4900d 100644 --- a/src/detectmatelibrary/common/_core_op/_fed_component.py +++ b/src/detectmatelibrary/common/_core_op/_fed_component.py @@ -3,7 +3,7 @@ from typing import Self, overload -class IncompabtibleFed(Exception): +class IncompatibleFed(Exception): def __init__(self) -> None: super().__init__("Instances are incompatible") @@ -12,7 +12,7 @@ class _CompOp: @staticmethod def is_compatible(main_inst: object, other_inst: object) -> None: if not isinstance(other_inst, type(main_inst)): - raise IncompabtibleFed() + raise IncompatibleFed() @staticmethod def reset(main_inst: object, attr: str) -> None: @@ -47,6 +47,7 @@ def stack(main_inst: object, attr: str, list_other_inst: list[object]) -> None: class FedOperations: + """Operations related to the federation learning / agregation.""" __COMPONENT: str = "_components" def __init__(self) -> None: @@ -54,11 +55,15 @@ def __init__(self) -> None: _CompOp.reset(self, self.__COMPONENT) def __add__(self, other: object) -> Self: + """Add other components to do the aggregation in a combine first + approach.""" _CompOp.combine(self, attr=self.__COMPONENT, other_inst=other) return self def __sub__(self, other: object) -> Self: + """Remove other components to do the aggregation in a combine first + approach.""" _CompOp.uncombine(self, attr=self.__COMPONENT, other_inst=other) return self @@ -69,6 +74,8 @@ def stack(self, other: bytes | list[bytes]) -> None: @overload def stack(self, other: object | list[object]) -> None: + """Stack other components to do the aggregation in a stack later + approach.""" pass def stack(self, other: object | list[object | bytes] | bytes) -> None: @@ -93,8 +100,9 @@ def to_binary(self) -> bytes | None: return None def from_binary(self, binary: bytes) -> object: - warnings.warn(f"To binary not implemented, return None for {binary!r}") + warnings.warn(f"From binary not implemented, return None for {binary!r}") return None def aggregate_strategy(self, components: set["FedOperations"]) -> None: + """Aggregation strategy use by the component.""" warnings.warn(f"No strategy found, aggregations does nothing for {components}") diff --git a/tests/test_common/test_core_federation.py b/tests/test_common/test_core_federation.py index 66627033..826fe406 100644 --- a/tests/test_common/test_core_federation.py +++ b/tests/test_common/test_core_federation.py @@ -1,4 +1,4 @@ -from detectmatelibrary.common._core_op._fed_component import IncompabtibleFed +from detectmatelibrary.common._core_op._fed_component import IncompatibleFed from detectmatelibrary.common.core import CoreComponent import struct @@ -43,9 +43,9 @@ def test_incompatible(self) -> None: component1 = DummyComponent2(name="comp_1") component2 = DummyComponent(name="comp_2") - with pytest.raises(IncompabtibleFed): + with pytest.raises(IncompatibleFed): component1 + component2 - with pytest.raises(IncompabtibleFed): + with pytest.raises(IncompatibleFed): component1 - component2 def test_stack(self) -> None: From ac4554b0c5479439f1dd97b8e668c9cea9a7a45f Mon Sep 17 00:00:00 2001 From: ipmach Date: Wed, 26 Aug 2026 13:13:44 +0200 Subject: [PATCH 26/33] Correct capitalization in Drain parser description --- docs/parsers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/parsers.md b/docs/parsers.md index b6e94cb7..2db5fc08 100644 --- a/docs/parsers.md +++ b/docs/parsers.md @@ -108,6 +108,6 @@ def test_my_parser_parse(): - [Template Matcher](parsers/template_matcher.md): matches logs against a predefined set of `<*>` templates. - [Template Tree Matcher](parsers/template_tree_matcher.md): matches logs against a predefined set of `<*>` templates using a tree structure. - [LogBatcher Parser](parsers/logbatcher_parser.md): LLM-based parser that infers templates from raw logs with no training data. -- [Drain parser](parsers/drain_parser.md): Parser inspired by [drain publication](https://ieeexplore.ieee.org/document/8029742). +- [Drain parser](parsers/drain_parser.md): Parser inspired by [Drain Publication](https://ieeexplore.ieee.org/document/8029742). Go back to [Index](index.md) From 93788a26d32f2f0a6bbcce48d5d47f8d192956d3 Mon Sep 17 00:00:00 2001 From: Leonhard Kaufmann Date: Wed, 26 Aug 2026 15:34:49 +0200 Subject: [PATCH 27/33] docs: add guide for adding tested doc examples --- docs/development.md | 67 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/docs/development.md b/docs/development.md index a65a0317..e8293958 100644 --- a/docs/development.md +++ b/docs/development.md @@ -41,3 +41,70 @@ In order to run the tests run the following command. The `dev` group already inc ```bash uv run --dev pytest ``` + +## Write testable code snippets for the documentation + +Code examples in the docs are not pasted inline. They live as standalone Python +files under `docs/examples/`, mirrored by category (`docs/examples/parsers/`, +`docs/examples/detectors/`), and are pulled into the Markdown pages via +[`pymdownx.snippets`](https://facelessuser.github.io/pymdown-extensions/extensions/snippets/). +This way every snippet in the docs is an actual `.py` file that gets executed by +the test suite, so a broken example fails CI instead of silently shipping. + +**1. Add the snippet file.** Put your example under `docs/examples//`. +By convention the filename matches its documentation page (`charset.md` → +`docs/examples/detectors/charset.py`). Wrap the part you want to show in section +markers: + +```python +# ;--8<-- [start:basic] +from detectmatelibrary.parsers.logbatcher import LogBatcherParser, LogBatcherParserConfig +# ... +# ;--8<-- [end:basic] +``` + +**2. Include it in the `.md` page.** Paths are relative to the repo root +(`base_path` is set to `.`). Reference the section by name: + +````markdown +```python +;--8<-- "docs/examples/parsers/logbatcher_parser.py:basic" +``` +```` + +You can also include the whole file by dropping the `:section` suffix +(`--8<-- "docs/examples/parsers/template_tree_matcher.py"`), but section markers +are the norm. Because `check_paths: true` is set, the build aborts if the file or +marker doesn't exist — a missing snippet is caught at build time. + +**3. Make sure it's testable.** The test (`tests/test_docs/test_doc_examples.py`) +globs every `.py` under `docs/examples/` and runs each one as a script via +`runpy.run_path(..., run_name="__main__")`. There is no plugin and no assert +requirement: a snippet passes as long as it runs standalone without raising. If +your example needs something unavailable in CI (e.g. an API key), comment out +those calls rather than letting them fail. Run the snippet tests together with +the rest of the suite: + +```bash +uv run --dev pytest +``` + + +## Render and verify the documentation + +Build the static site: + +```bash +uv run --dev mkdocs build +``` + +For a live local preview while editing: + +```bash +uv run --dev mkdocs serve +``` + +`mkdocs` comes in transitively via `mike` in the `dev` group, so `--dev` is +required. There is no `--strict` mode configured; the hard check on the docs is +`check_paths: true` from `pymdownx.snippets`, which fails the build on a missing +snippet or marker. From ad014ee411ddd8af14f342112350d91ef900e1e5 Mon Sep 17 00:00:00 2001 From: Thorina Boenke <68156005+thorinaboenke@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:27:52 +0200 Subject: [PATCH 28/33] Rename New Value Detector to Charset Detector Renamed 'New Value Detector' to 'Charset Detector' for clarity. --- docs/detectors/charset.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/detectors/charset.md b/docs/detectors/charset.md index 3ed4a6e9..63c71689 100644 --- a/docs/detectors/charset.md +++ b/docs/detectors/charset.md @@ -1,4 +1,4 @@ -# New Value Detector +# Charset Detector The Charset Detector raises alerts when previously unseen characters appear in configured fields. It is useful to detect novelty, configuration drift, or the appearance of new actors in the environment. From 646ba5364d5f210827bc17f4fe6b803b9b3667b0 Mon Sep 17 00:00:00 2001 From: ipmach Date: Mon, 31 Aug 2026 16:30:14 +0200 Subject: [PATCH 29/33] Refine language and formatting in drain_parser.md --- docs/parsers/drain_parser.md | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/docs/parsers/drain_parser.md b/docs/parsers/drain_parser.md index 7a585e7d..f20c6afb 100644 --- a/docs/parsers/drain_parser.md +++ b/docs/parsers/drain_parser.md @@ -1,26 +1,24 @@ # Drain parser -The parsed is based in the official [Drain publication](https://ieeexplore.ieee.org/document/8029742). +The parser is derived from the official [Drain publication](https://ieeexplore.ieee.org/document/8029742). -This parser wraps functionality from the DetectMatePerformance project: https://github.com/ait-detectmate/DetectMatePerformance. Prefer use the performance implementation when parsing many log lines in non-stream (batch) mode. +It also wraps functionality from the DetectMatePerformance project: https://github.com/ait-detectmate/DetectMatePerformance. When parsing large numbers of log lines in non-stream (batch) mode, it is recommended to use the performance-oriented implementation. | | Schema | Description | |------------|----------------------------|--------------------| | **Input** | [LogSchema](../schemas.md) | Unstructured log | | **Output** | [ParserSchema](../schemas.md) | Structured log | -WARNING: This parser is not yet in a stable release and may behave differently across platforms or hardware. - ## Configuration -Drain parser arguments: +Drain parser parameters: -- `method_type` (string): parser type identifier (for example `"tree_matcher"`). -- `depth` (int): Number of word layers. -- `max_childs` (int): max number of childs allow in the length layer. -- `sim_thres` (float): similarity threshold. -- `reset_in_post_train` (bool): if true remove the logs in the train buffer when the templates are generated. Otherwise, it safe them for the next train. -- `auto_config` (bool): whether to attempt an optional auto-configuration phase (not required). +- `method_type` (string): identifier for the parser type (e.g., `"tree_matcher"`). +- `depth` (int): number of token/word levels. +- `max_childs` (int): maximum number of children allowed in the given layer. +- `sim_thres` (float): threshold used for similarity. +- `reset_in_post_train` (bool): if enabled, clears logs from the training buffer once templates are created; otherwise, it keeps them for the next training cycle. +- `auto_config` (bool): indicates whether to run an optional auto-configuration step (not mandatory). Example YAML fragment: ```yaml From 3225b130f400c73fcbc446494d5db893e235fe62 Mon Sep 17 00:00:00 2001 From: viktorbeck98 Date: Tue, 1 Sep 2026 20:11:28 +0200 Subject: [PATCH 30/33] feat(stability): four selectable classification methods with a decision rule Replace the `segmentation` enum plus the `require_declining` / `incline_threshold` flags with four independently selectable stability classification methods, combined by a configurable decision rule. - Add `ClassificationMethods` (pydantic): `index`, `time`, `slope_index`, `slope_time` booleans, `slope_threshold` (default -0.05), and `decision` ("consensus" | "majority"). At least one method must be enabled; `extra="forbid"`. - `StabilityClassifier` gains `verdicts()` (per-method votes) and `decide()` (applies the decision rule). `is_stable()` now routes through them. Segment methods (`index`, `time`) cut the change series by equal count or equal duration; slope methods vote STABLE when the change centroid `k <= slope_threshold`. - Timestamps are now only consulted when a time-based method is enabled -- previously they were self-sufficient. Call sites that relied on passing timestamps alone must enable `time` or `slope_time`. - `StabilityTracker` translates legacy persisted state into the new model, so existing event stores keep loading. - Rename the old "count"/"decline" vocabulary to "index"/"slope" throughout, and document the breaking changes plus the fallback behaviour in docs/detectors.md. Tests: replace test_incline_stability.py with test_slope_stability.py and add test_classification_methods.py; update the time-dependent and detector tests for the new configuration surface. Co-Authored-By: Claude Opus 5 --- docs/detectors.md | 162 +++- .../common/variable_detector.py | 80 +- .../detectors/new_value_combo_detector.py | 2 +- .../trackers/__init__.py | 4 +- .../trackers/base/event_tracker.py | 2 +- .../trackers/stability/__init__.py | 2 + .../stability/classification_methods.py | 62 ++ .../stability/stability_classifier.py | 293 +++++-- .../trackers/stability/stability_tracker.py | 191 +++-- .../test_auto_config_params_survive.py | 6 +- .../test_new_value_combo_detector.py | 55 +- .../test_classification_methods.py | 69 ++ .../test_incline_stability.py | 270 ------ .../test_persistency/test_slope_stability.py | 783 ++++++++++++++++++ .../test_time_dependent_stability.py | 262 +++--- 15 files changed, 1607 insertions(+), 636 deletions(-) create mode 100644 src/detectmatelibrary/utils/persistency/event_data_structures/trackers/stability/classification_methods.py create mode 100644 tests/test_persistency/test_classification_methods.py delete mode 100644 tests/test_persistency/test_incline_stability.py create mode 100644 tests/test_persistency/test_slope_stability.py diff --git a/docs/detectors.md b/docs/detectors.md index 05285f2f..85fa3108 100644 --- a/docs/detectors.md +++ b/docs/detectors.md @@ -241,27 +241,40 @@ for `EventSequenceDetector`, into `fixed_window_size`) and then sets be rerun with `auto_config: False` and reproduce the same detector. -### Stability segmentation (optional) - -Stability classification splits a variable's change history into four segments and -compares each segment's rate of change against a threshold. By default the segments -are **equal-count**: each holds the same number of observations, regardless of how much -time they cover. For bursty log sources that is misleading — a variable that changed -constantly during a quiet night and then went silent under a flood of daytime traffic -looks stable, because the flood supplies enough samples to dominate the later segments. - -Setting `segmentation: time` switches the segmentation to **equal-duration** cuts -of the observed time span, so each segment covers the same amount of wall-clock time. The -detector then needs an event time per record, which it reads from the log's named -variables (`logFormatVariables`, i.e. the fields declared in the parser's `log_format`) -under the name given by `timestamp_variable`. +### Stability classification (optional) + +Stability classification decides whether a variable's change history counts as +`STABLE` by running one or more classification methods against it and combining +their verdicts. There are four independent methods, over two primitives and two +axes: + +| method | what it thresholds | axis | +|---|---|---| +| `index` | segment-mean thresholds | equal-count boundaries | +| `time` | segment-mean thresholds | equal-duration boundaries | +| `slope_index` | change centroid vs. `slope_threshold` | index positions | +| `slope_time` | change centroid vs. `slope_threshold` | normalized timestamps | + +Any subset of the four may be enabled, and any single one may stand alone. The +default — `index` alone — is the historical behaviour: each segment's mean rate +of change is compared against a threshold, and the segments are **equal-count**: +each holds the same number of observations, regardless of how much time they +cover. For bursty log sources that is misleading — a variable that changed +constantly during a quiet night and then went silent under a flood of daytime +traffic looks stable, because the flood supplies enough samples to dominate the +later segments. Enabling `time` cuts the same four segments at **equal +durations** instead, so each segment covers the same amount of wall-clock time; +the detector then needs an event time per record, which it reads from the log's +named variables (`logFormatVariables`, i.e. the fields declared in the parser's +`log_format`) under the name given by `timestamp_variable`. `slope_index` and +`slope_time` ask a different question — whether the change centroid sits early +or late in the series — on the index axis and the time axis respectively. These parameters live on every `VariableDetector` subclass (`NewValueDetector`, `NewValueComboDetector`, `ValueRangeDetector`, `CharsetDetector`, `BigramDetector`, …) and go in the detector's `auto_config_params` block — they are inputs to the auto-configuration phase, read only while `auto_config` is `True`, and never -consulted at detection time. `segmentation` defaults to `count`; the block below -opts in to the time-aware mode: +consulted at detection time. ```yaml detectors: @@ -269,17 +282,65 @@ detectors: method_type: new_value_detector auto_config: True auto_config_params: - segmentation: time # opt-in; the default is count - timestamp_variable: Time # a field name from the parser's log_format - timestamp_format: "%y%m%d %H%M%S" # optional; omit to auto-detect + use_stable_vars: True + use_static_vars: True + classification: + index: True # segment-mean thresholds, equal-count cuts + time: False # segment-mean thresholds, equal-duration cuts + slope_index: False # change centroid over index positions + slope_time: False # change centroid over normalized time + slope_threshold: -0.05 # shared by both slope methods + decision: consensus # consensus | majority + timestamp_variable: Time + timestamp_format: "%y%m%d %H%M%S" ``` -Setting `segmentation: both` runs *both* segmentations and calls the variable -stable only when each one does. Neither segmentation subsumes the other — a variable that -churns in a burst and then settles is unstable by count but stable by time, and one whose -late churn is buried under a dense tail of repeats is the reverse — so `both` is strictly -stricter than either. Use it when a false "stable" is more costly than a missed one; use -`time` when the point is specifically to forgive early churn on a bursty source. +Defaults reproduce the historical behaviour exactly: `index: True`, the other +three `False`, `decision: consensus`, `slope_threshold: -0.05`. A config that +sets nothing under `classification` classifies identically to before this change. + +#### The decision rule + +When more than one method is enabled, `decision` picks how their verdicts +combine. `consensus` requires every enabled method to return stable; `majority` +requires strictly more than half of them to. + +| enabled | `consensus` needs | `majority` needs | differ? | +|---|---|---|---| +| 1 | 1/1 | 1/1 | no | +| 2 | 2/2 | 2/2 (a 1–1 tie is UNSTABLE) | no | +| 3 | 3/3 | 2/3 | yes | +| 4 | 4/4 | 3/4 (a 2–2 tie is UNSTABLE) | yes | + +Ties resolve to UNSTABLE. That keeps `majority` from ever being more lenient +than a coin-flip, and makes it collapse onto `consensus` at one and two enabled +methods — turning a third method on is the only place the rule starts to matter. + +**All four methods false is a config error**, rejected by a pydantic validator. +It is not a harmless no-op: classification decides `INSUFFICIENT_DATA`, +`STATIC` and `RANDOM` before any method is consulted, so a method-less config +would silently classify every remaining variable `STABLE`. + +#### Breaking change: the old fields are gone + +`segmentation`, `require_declining` and `incline_threshold` no longer exist. +`VariableAutoConfigParams` sets `extra="forbid"`, so a config still using the old +spellings now raises `ValidationError` at load time rather than being silently +ignored. Two familiar configurations translate as follows: + +| old | new | +|---|---| +| `segmentation: both` | `index: True, time: True, decision: consensus` | +| `segmentation: count, require_declining: True` | `index: True, slope_index: True, decision: consensus` | + +Calling `StabilityClassifier` directly has a related gap the config layer does not: +previously, with no method selection at all, passing `timestamps` to `is_stable` +was self-sufficient — stamps present meant equal-duration cuts, always. Now +`is_stable(series, timestamps=ts)` honours `timestamps` only when a time-axis +method (`time` or `slope_time`) is enabled. A caller who kept an existing +`is_stable(series, timestamps=ts)` call without also turning on `time` or +`slope_time` now gets index-axis classification silently, with no error and no +warning. #### Fields @@ -289,38 +350,59 @@ All of these live in the detector's `auto_config_params` block. |---|---|---|---| | `use_stable_vars` | `bool` | `true` | Include variables classified `STABLE` in the generated configuration. | | `use_static_vars` | `bool` | `true` | Include variables classified `STATIC`. Defaults to `false` on `NewValueComboDetector`. | -| `segmentation` | `"count" \| "time" \| "both"` | `"count"` | How to cut the change history into segments. `count` uses equal sample counts; `time` uses equal time spans; `both` requires the variable to be stable under each. With `count` the two timestamp fields are ignored and no timestamps are recorded. | -| `timestamp_variable` | `str \| null` | `null` | Name of the field in `logFormatVariables` holding the record's event time. Required for `time` and `both` to have any effect. Only named log-format fields are consulted — never the positional `variables` list. | +| `classification` | `ClassificationMethods` | see below | Which classification methods run and how their verdicts combine. | +| `timestamp_variable` | `str \| null` | `null` | Name of the field in `logFormatVariables` holding the record's event time. Required for `time` and `slope_time` to have any effect. Only named log-format fields are consulted — never the positional `variables` list. | | `timestamp_format` | `str \| null` | `null` | Explicit [`strftime`](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes) pattern for parsing that field. When unset, `TimeFormatHandler` auto-detects the format (ISO 8601, Apache, syslog, numeric epoch seconds/milliseconds, and other common layouts). | -| `require_declining` | `bool` | `false` | Add a conjunct to `STABLE` requiring the variable's changes to sit early in its series. Independent of `segmentation` — it reads index positions, not timestamps. | -| `incline_threshold` | `float` | `-0.05` | The change-centroid cut-off `require_declining` compares against. The centroid runs from `-0.5` (all changes at the very start) to `+0.5` (all at the end); a variable passes when it is at or below this value. Ignored unless `require_declining` is set. | Set `timestamp_format` when the source uses a layout the auto-detection does not know. The HDFS loghub corpus, for example, stamps records as `081109 203615`, which only parses with an explicit `"%y%m%d %H%M%S"`. +`classification`'s six fields: + +| Field | Type | Default | Description | +|---|---|---|---| +| `index` | `bool` | `true` | Segment-mean thresholds, equal-count boundaries. | +| `time` | `bool` | `false` | Segment-mean thresholds, equal-duration boundaries. Needs `timestamp_variable`. | +| `slope_index` | `bool` | `false` | Change centroid vs. `slope_threshold`, measured on index positions. | +| `slope_time` | `bool` | `false` | Change centroid vs. `slope_threshold`, measured on normalized timestamps. Needs `timestamp_variable`. | +| `slope_threshold` | `float` | `-0.05` | The change-centroid cut-off both slope methods compare against, on a shared `[-0.5, +0.5]` scale. A variable passes when its centroid is at or below this value. | +| `decision` | `"consensus" \| "majority"` | `"consensus"` | How verdicts from more than one enabled method combine; see above. | + #### Fallback behaviour -Time-aware segmentation is best-effort and never fails a run: +Time-aware classification is best-effort and never fails a run: -* If `segmentation` is not `count` but `timestamp_variable` is unset, or the named field - is absent from a record, or its value cannot be parsed, the detector logs a +* If `time` or `slope_time` is enabled but `timestamp_variable` is unset, or the named + field is absent from a record, or its value cannot be parsed, the detector logs a **single** warning (once per detector, so a bad config cannot flood the log) and - falls back to count-based segmentation. + falls back to the index axis. * If timestamps stop lining up with the recorded observations, or the observed time - span is zero, or they arrive out of order, the classifier silently falls back to - count-based segmentation for that variable. -* Under `both`, any of the fallbacks above make the time pass reuse the count boundaries, - so the mode degrades to plain `count` rather than to an unconditional pass. + span is zero, or they arrive out of order, `time` silently reuses the equal-index + cuts, and `slope_time` computes its centroid on the index axis instead — it + degrades to `slope_index`. +* Under `majority`, a fallen-back method still casts its own vote: if `slope_index` + and `slope_time` are both enabled and timestamps are unusable, both entries compute + the same index-axis centroid, and that verdict carries two of the votes rather than + one. This is deliberate — dropping a fallen-back method from the vote would change + the enabled count from variable to variable and make `majority` mean something + different for each one. The reason string names the axis each slope actually used, + so a doubled vote is visible in the note. +* The same doubling applies to the segment-threshold pair: if `index` and `time` are + both enabled and timestamps are unusable, `time` silently reuses the same equal-count + cuts as `index`, so an identical verdict again carries two votes under `majority` + rather than one. Unlike the slope pair, the reason string does not surface this — + each entry is still labelled by its configured method name (`index` or `time`), not + by the axis it actually used, so a doubled segment-pair vote is invisible in the note. In every fallback case classification still runs and produces a result — only the -segmentation rule changes back to the default. +axis behind it changes back to index. A segment with no observations in it is *not* a fallback: it scores a mean of 0.0, because nothing observed means nothing changed. Equal-duration cuts of a bursty variable leave such segments routinely, so `time` on its own is lenient towards a -burst of churn followed by silence. Use `both` when that leniency matters — the -count pass keeps every segment populated. +burst of churn followed by silence. Enable `index` and `time` together when that +leniency matters — the index pass keeps every segment populated. ### Saving state (persist) diff --git a/src/detectmatelibrary/common/variable_detector.py b/src/detectmatelibrary/common/variable_detector.py index a77bd6ca..32c41daf 100644 --- a/src/detectmatelibrary/common/variable_detector.py +++ b/src/detectmatelibrary/common/variable_detector.py @@ -15,6 +15,9 @@ EventStabilityTracker, SingleStabilityTracker, ) +from detectmatelibrary.utils.persistency.event_data_structures.trackers.stability import ( + ClassificationMethods, +) from detectmatelibrary.utils.persistency.event_persistency import EventPersistency from detectmatelibrary.utils.data_buffer import BufferMode from detectmatelibrary.utils.time_format_handler import TimeFormatHandler @@ -22,7 +25,7 @@ from detectmatelibrary.constants import GLOBAL_EVENT_ID from detectmatelibrary.tools.logging import logger -from typing import Any, Dict, Literal, Optional, cast +from typing import Any, Dict, Optional, cast from typing_extensions import override @@ -80,23 +83,15 @@ class VariableAutoConfigParams(AutoConfigParams): use_stable_vars: bool = True use_static_vars: bool = True - # Stability segmentation. "count" cuts the classifier's segments at equal - # sample counts (the historical behaviour). "time" cuts them at equal - # durations instead. "both" requires the variable to pass under *both* - # segmentations. The two time-aware modes need a per-record event time, - # named here and read from the record's logFormatVariables. - segmentation: Literal["count", "time", "both"] = "count" + # Which stability classification methods decide STABLE, and how their + # verdicts combine. Four independent methods over two primitives and two + # axes; see ClassificationMethods. The two time-axis methods (`time`, + # `slope_time`) need a per-record event time, named here and read from the + # record's logFormatVariables. + classification: ClassificationMethods = ClassificationMethods() timestamp_variable: str | None = None timestamp_format: str | None = None # None -> TimeFormatHandler auto-detect - # Orthogonal to the segmentation above: an extra conjunct on STABLE - # requiring the variable's changes to sit early in its series. Needs no - # timestamps -- it reads index positions only. - require_declining: bool = False - # The cut-off require_declining compares the change centroid against. - # Ignored unless require_declining is set. - incline_threshold: float = -0.05 - class VariableDetectorConfig(CoreDetectorConfig): auto_config_params: VariableAutoConfigParams = VariableAutoConfigParams() @@ -124,39 +119,37 @@ def __init__(self, name: str, config: VariableDetectorConfig) -> None: self._warned_bad_timestamp = False self.persistency = EventPersistency( event_data_class=self._event_data_class(), - # No stability kwargs: the trained trackers are read by _check_variable, - # which looks at unique_set / min-max / charset directly and never - # calls classify(). Segmentation settings would only make them collect - # timestamps nothing reads. + # No classification kwargs: the trained trackers are read by + # _check_variable, which looks at unique_set / min-max / charset + # directly and never calls classify(). A classification block + # would only make them collect timestamps nothing reads. event_data_kwargs=self._event_data_kwargs(), ) # auto config checks individual-variable stability to select features self.auto_conf_persistency = EventPersistency( event_data_class=self._event_data_class(), - event_data_kwargs=self._with_stability_kwargs(self._auto_conf_kwargs()), + event_data_kwargs=self._with_classification_kwargs(self._auto_conf_kwargs()), ) self._register_persistency(self.persistency) - def _with_stability_kwargs(self, kwargs: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: - """Add the stability settings to tracker kwargs, each only when it is - not the default. + def _with_classification_kwargs( + self, kwargs: Optional[Dict[str, Any]] + ) -> Optional[Dict[str, Any]]: + """Add the classification block to tracker kwargs, unless it is the + default. - Done here rather than in _stability_kwargs so every - VariableDetector subclass is covered -- NewValueDetector - overrides neither construction hook and NewValueComboDetector - returns only a converter_function. + Done here rather than in _stability_kwargs so every VariableDetector + subclass is covered -- NewValueDetector overrides neither construction + hook and NewValueComboDetector returns only a converter_function. - Non-defaults only: passing segmentation unconditionally would make - every variable collect timestamps it never reads. + Non-defaults only: forwarding the default block would be noise, and a + block naming a time-axis method would make every variable collect + timestamps it never reads. """ - extra: Dict[str, Any] = {} auto = self.config.auto_config_params - if auto.segmentation != "count": - extra["segmentation"] = auto.segmentation - if auto.require_declining: - extra["require_declining"] = True - extra["incline_threshold"] = auto.incline_threshold - return {**(kwargs or {}), **extra} if extra else kwargs + if auto.classification == ClassificationMethods(): + return kwargs + return {**(kwargs or {}), "classification": auto.classification.model_dump()} # ---- construction hooks ------------------------------------------------- @@ -188,21 +181,22 @@ def _warn_time_fallback_once(self, reason: str) -> None: return self._warned_bad_timestamp = True logger.warning( - "%s: %s; falling back to count-based stability segmentation.", + "%s: %s; falling back to the index axis for stability classification.", self.name, reason, ) def _timestamp(self, input_: ParserSchema) -> float | None: - """Resolve the record's event time, or None to use count - segmentation.""" + """Resolve the record's event time, or None if no enabled + classification method reads the time axis.""" auto = self.config.auto_config_params - if auto.segmentation == "count": + if not auto.classification.needs_timestamps: return None if not auto.timestamp_variable: - # Selecting a time-aware mode without naming the field is an operator - # error, not an opt-out -- say so rather than silently no-op. + # Selecting a time-axis method without naming the field is an + # operator error, not an opt-out -- say so rather than silently + # no-op. self._warn_time_fallback_once( - f"segmentation is {auto.segmentation!r} " + "a time-axis classification method is enabled " "but timestamp_variable is not set" ) return None diff --git a/src/detectmatelibrary/detectors/new_value_combo_detector.py b/src/detectmatelibrary/detectors/new_value_combo_detector.py index 57fe7549..7bdd372b 100644 --- a/src/detectmatelibrary/detectors/new_value_combo_detector.py +++ b/src/detectmatelibrary/detectors/new_value_combo_detector.py @@ -70,7 +70,7 @@ def __init__( # second-pass persistency to learn stability of variable combinations self.auto_conf_persistency_combos = persistency.EventPersistency( event_data_class=persistency.EventStabilityTracker, - event_data_kwargs=self._with_stability_kwargs( + event_data_kwargs=self._with_classification_kwargs( {"converter_function": get_all_possible_combos} ), ) diff --git a/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/__init__.py b/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/__init__.py index 6b7e531c..d4b7d6b4 100644 --- a/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/__init__.py +++ b/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/__init__.py @@ -11,7 +11,8 @@ StabilityClassifier, SingleStabilityTracker, MultiStabilityTracker, - EventStabilityTracker + EventStabilityTracker, + ClassificationMethods, ) from .base import ( EventTracker, @@ -29,4 +30,5 @@ "SingleStabilityTracker", "MultiStabilityTracker", "EventStabilityTracker", + "ClassificationMethods", ] diff --git a/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/base/event_tracker.py b/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/base/event_tracker.py index 7e12103a..55496c2f 100644 --- a/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/base/event_tracker.py +++ b/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/base/event_tracker.py @@ -72,7 +72,7 @@ def load(cls, data: bytes, **kwargs: Any) -> "EventTracker": ``multi_tracker_type`` recorded in the snapshot. For any subclass, ``cls(**kwargs)`` is called instead, which lets subclasses with closure-based factories (e.g. ``EventStabilityTracker``'s - ``segmentation``) rebuild their factory so it survives load. + ``classification``) rebuild their factory so it survives load. Contract for subclasses: ``__init__`` must accept the kwargs forwarded to ``load()`` and must not require additional positional arguments. diff --git a/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/stability/__init__.py b/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/stability/__init__.py index 32ead5dd..33a5e10c 100644 --- a/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/stability/__init__.py +++ b/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/stability/__init__.py @@ -1,9 +1,11 @@ from .stability_tracker import SingleStabilityTracker, MultiStabilityTracker, EventStabilityTracker from .stability_classifier import StabilityClassifier +from .classification_methods import ClassificationMethods __all__ = [ "EventStabilityTracker", "MultiStabilityTracker", "SingleStabilityTracker", "StabilityClassifier", + "ClassificationMethods", ] diff --git a/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/stability/classification_methods.py b/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/stability/classification_methods.py new file mode 100644 index 00000000..89a0d826 --- /dev/null +++ b/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/stability/classification_methods.py @@ -0,0 +1,62 @@ +"""Which stability classification methods run, and how they combine.""" + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, model_validator + +METHOD_NAMES = ("index", "time", "slope_index", "slope_time") + + +class ClassificationMethods(BaseModel): + """Selection of stability classification methods plus the decision rule. + + Four independent methods, two primitives over two axes:: + + index segment-mean thresholds equal-count boundaries + time segment-mean thresholds equal-duration boundaries + slope_index change centroid index positions + slope_time change centroid normalized timestamps + + Any subset may be enabled and any one may stand alone. The default -- + ``index`` alone under ``consensus`` -- is the historical behaviour. + + ``slope_threshold`` is shared by both slope methods: they are the same + quantity measured on two axes and land on the same [-0.5, +0.5] scale, + so one number keeps them comparable. + """ + + model_config = ConfigDict(extra="forbid") + + index: bool = True + time: bool = False + slope_index: bool = False + slope_time: bool = False + slope_threshold: float = -0.05 + decision: Literal["consensus", "majority"] = "consensus" + + @model_validator(mode="after") + def _at_least_one_method(self) -> "ClassificationMethods": + if not self.enabled: + raise ValueError( + "at least one classification method must be enabled " + f"({', '.join(METHOD_NAMES)}). With none enabled, every variable " + "that is not INSUFFICIENT_DATA, STATIC or RANDOM would be " + "classified STABLE by default -- those three are decided before " + "any method is consulted." + ) + return self + + @property + def enabled(self) -> tuple[str, ...]: + """Enabled method names, in the order they appear in the config + block.""" + return tuple(name for name in METHOD_NAMES if getattr(self, name)) + + @property + def needs_timestamps(self) -> bool: + """Whether any enabled method reads the time axis. + + The tracker gates timestamp collection on this: with only index-axis + methods enabled, recording stamps would cost memory nothing reads. + """ + return self.time or self.slope_time diff --git a/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/stability/stability_classifier.py b/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/stability/stability_classifier.py index 8029db9e..c189f2f2 100644 --- a/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/stability/stability_classifier.py +++ b/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/stability/stability_classifier.py @@ -1,9 +1,40 @@ """Classifier for stability based on segment means.""" -from typing import List +from typing import Dict, List import numpy as np from detectmatelibrary.utils.persistency.rle_list import RLEList +from .classification_methods import ClassificationMethods + + +def _timestamps_usable(timestamps: List[float] | None, total_len: int) -> bool: + """Whether these timestamps can carry a time-axis computation. + + Shared by ``_segment_boundaries`` (the ``time`` method) and ``slope()`` + (the ``slope_time`` method), so both fall back to the index axis under + exactly the same conditions. ``_slope`` still degrades further on its + own: even when this predicate says yes, it drops to the index axis when + the first observation's offset leaves no usable span (see its + ``u_first >= 1.0`` guard) -- a case ``_segment_boundaries`` has no + equivalent for. + + ``np.searchsorted`` and the centroid both require sorted input. Merged + sources or concurrent writers can deliver stamps out of order, which + would silently produce wrong boundaries rather than an error. O(N), the + same cost as the isfinite scan. + """ + try: + return ( + timestamps is not None + and len(timestamps) == total_len + and total_len > 0 + and bool(np.all(np.isfinite(timestamps))) + and bool(np.all(np.diff(timestamps) >= 0)) + and timestamps[-1] > timestamps[0] + ) + except TypeError: + # e.g. a None entry: not comparable/convertible -> index axis + return False class StabilityClassifier: @@ -12,63 +43,49 @@ def __init__( self, segment_thresholds: List[float], min_samples: int = 10, - incline_threshold: float = -0.05, + classification: ClassificationMethods | None = None, ): self.segment_threshs = segment_thresholds self.min_samples = min_samples - # Only read when a tracker sets require_declining. See incline(). - self.incline_threshold = incline_threshold + self.classification = classification or ClassificationMethods() # for RLELists self.segment_sums = [0.0] * len(segment_thresholds) self.segment_counts = [0] * len(segment_thresholds) self.n_segments = len(self.segment_threshs) # for lists self.segment_means: List[float] = [] + # Transient, rebuilt by every verdicts() call: one human-readable line + # per enabled method, which is what the tracker's reason string is + # assembled from. Never persisted -- it is derived from the series. + self.last_details: Dict[str, str] = {} 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, - non-decreasing, 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 / out- - of-order timestamps and on zero span. A duration cut that leaves - a segment empty is kept as-is: nothing observed in that window - means no changes in it, which ``is_stable`` scores as a mean of - 0.0. + Equal-index by default: each segment holds the same number of + observations. When timestamps are given (one per item, non-decreasing, + non-zero span), boundaries are equal-DURATION cuts of the observed time + span, mapped back to indices. Falls back to equal-index whenever + ``_timestamps_usable`` says no. A duration cut that leaves a segment + empty is kept as-is: nothing observed in that window means no changes + in it, which ``is_stable`` scores as a mean of 0.0. """ segment_size = total_len / self.n_segments - count_boundaries = [int(i * segment_size) for i in range(self.n_segments + 1)] - count_boundaries[-1] = total_len - try: - use_time = ( - timestamps is not None - and len(timestamps) == total_len - and total_len > 0 - and bool(np.all(np.isfinite(timestamps))) - # np.searchsorted below requires sorted input. Merged sources or - # concurrent writers can deliver stamps out of order, which would - # silently produce wrong boundaries rather than an error. O(N), - # same cost as the isfinite scan above. - and bool(np.all(np.diff(timestamps) >= 0)) - 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 - return count_boundaries + index_boundaries = [int(i * segment_size) for i in range(self.n_segments + 1)] + index_boundaries[-1] = total_len + if timestamps is None or not _timestamps_usable(timestamps, total_len): + return index_boundaries + 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 - def is_stable( + def _segment_verdict( self, change_series: RLEList[bool] | List[bool], timestamps: List[float] | None = None, @@ -82,9 +99,7 @@ def is_stable( the conditions under which time mode falls back to count mode. A segment with no observations in it scores a mean of 0.0 -- no - occurrences means no changes. Equal-duration cuts of a bursty - series leave such segments routinely; pair ``time`` with - ``count`` (segmentation ``both``) if that leniency matters. + occurrences means no changes. """ total_len = len(change_series) if total_len == 0: @@ -130,54 +145,197 @@ def is_stable( ] return all([not q >= thresh for q, thresh in zip(self.segment_means, self.segment_threshs)]) - def incline( - self, change_series: RLEList[bool] | List[bool] + def verdicts( + self, + change_series: RLEList[bool] | List[bool], + timestamps: List[float] | None = None, + ) -> Dict[str, bool]: + """Per-method stability verdicts, enabled methods only. + + Keys are the method names as they appear in the config block -- + "index", "time", "slope_index", "slope_time" -- in block order, so a + reason string built by iterating this dict reads the same way every + time. + + Every enabled method runs; there is no short-circuit. A combined + verdict is only debuggable if the note carries all of the evidence, + and ``last_details`` is populated here for exactly that. + + ``timestamps`` is threaded through only to the two time-axis methods, + ``time`` and ``slope_time``; ``index`` and ``slope_index`` never see + it. A caller who supplies ``timestamps`` without enabling either + time-axis method gets index-axis results with no error and no + warning. + """ + self.last_details = {} + self.segment_means = [] + out: Dict[str, bool] = {} + empty = len(change_series) == 0 + for name in self.classification.enabled: + if name in ("index", "time"): + stamps = timestamps if name == "time" else None + stable = self._segment_verdict(change_series, timestamps=stamps) + self.last_details[name] = ( + f"{name}: means {self.segment_means} " + f"{'below' if stable else 'exceed'} thresholds " + f"{self.segment_threshs} -> {'STABLE' if stable else 'UNSTABLE'}" + ) + else: + stamps = timestamps if name == "slope_time" else None + if empty: + # Matches the segment methods: an empty series has nothing + # that could have changed, so nothing failed. + stable, k, axis = True, 0.0, "index" + else: + k, axis = self._slope(change_series, stamps) + stable = k <= self.classification.slope_threshold + self.last_details[name] = ( + f"{name}: centroid {k:+.3f} ({axis} axis) " + f"{'at or below' if stable else 'above'} slope_threshold " + f"{self.classification.slope_threshold} -> " + f"{'STABLE' if stable else 'UNSTABLE'}" + ) + out[name] = stable + return out + + def decide(self, verdicts: Dict[str, bool]) -> bool: + """Combine per-method verdicts under the configured decision rule. + + ``consensus`` requires every method; ``majority`` requires strictly + more than half, so a tie resolves to UNSTABLE. The two agree at one + and two enabled methods and diverge at three and four. + """ + if not verdicts: + # Unreachable via the config: ClassificationMethods rejects an + # empty method set. Guards direct construction. + return True + n_stable = sum(verdicts.values()) + if self.classification.decision == "consensus": + return n_stable == len(verdicts) + return n_stable * 2 > len(verdicts) + + def is_stable( + self, + change_series: RLEList[bool] | List[bool], + timestamps: List[float] | None = None, + ) -> bool: + """Combined stability verdict under the configured methods. + + ``timestamps`` are forwarded only to whichever enabled methods read + the time axis (``time``, ``slope_time`` -- see ``verdicts()``). With + only index-axis methods enabled, passing ``timestamps`` here has no + effect and the call classifies exactly as if they were omitted. + """ + return self.decide(self.verdicts(change_series, timestamps=timestamps)) + + def get_last_details(self) -> Dict[str, str]: + """One line per method from the last verdicts() call.""" + return self.last_details + + def slope( + self, + change_series: RLEList[bool] | List[bool], + timestamps: List[float] | None = None, ) -> float: """Change centroid: where in the series the changes sit, in [-0.5, +0.5]. - The mean position of the changes, measured against the midpoint of - the series and scaled by the half-span:: + The mean position of the changes, measured against the midpoint of the + range those changes could occupy and scaled by its half-span. -0.5 is + every change at the earliest countable position, 0 is uniform churn, + +0.5 is every change at the very end. + + With no usable timestamps the position is the index ``p``:: k = (p_bar - n/2) / (n - 2) - -0.5 is every change at the very start, 0 is uniform churn, +0.5 is - every change at the very end. Index 0 is excluded: the first value is - always recorded as a change, so counting it would drag every variable - negative, a perfectly static one included. + With usable timestamps it is normalized time ``u``, measured against + the range still reachable once index 0 is excluded:: + + u = (t - t_first) / (t_last - t_first) + u_first = (t_1 - t_first) / (t_last - t_first) + k = (u_bar - (u_first + 1) / 2) / (1 - u_first) + + Substituting evenly spaced stamps collapses the second form into the + first, so the two axes agree exactly on uniformly stamped data. That + is what lets both slope methods share one threshold. - This is the least-squares slope over the same series with its data-free - parts divided out -- for evenly spaced x the OLS denominator is the - constant n(n-1)(n-2)/12, and the numerator collapses to m(p_bar - n/2) - because only the change positions survive the binary y. The two are - related by ``k_OLS = k * 12m / n(n-1)``, a strictly positive factor, so - they never disagree on sign. Dropping the leading ``m`` is the point: - it is what makes k comparable between events instead of scaling with - how many changes happened to occur. + Index 0 is excluded on both axes: the first value is always recorded as + a change, so counting it would drag every variable negative, a + perfectly static one included. On the time axis that exclusion is also + why the denominator is the achievable range and not the full span. + + The index form is the least-squares slope over the same series with its + data-free parts divided out -- for evenly spaced x the OLS denominator + is the constant n(n-1)(n-2)/12, and the numerator collapses to + m(p_bar - n/2) because only the change positions survive the binary y. + The two are related by ``k_OLS = k * 12m / n(n-1)``, a strictly + positive factor, so they never disagree on sign. Dropping the leading + ``m`` is the point: it is what makes k comparable between events + instead of scaling with how many changes happened to occur. Runs close in form, so an RLEList costs one pass over ``runs()`` with no expansion. Returns 0.0 when the series is too short to have a span, and -0.5 when nothing ever changed. """ + return self._slope(change_series, timestamps)[0] + + def _slope( + self, + change_series: RLEList[bool] | List[bool], + timestamps: List[float] | None = None, + ) -> tuple[float, str]: + """``slope()`` plus the axis it actually used, for the reason string. + + The axis is not always the one configured: ``slope_time`` degrades to + the index axis on unusable timestamps, and a note that did not say so + would be misleading. + """ n = len(change_series) if n < 3: - return 0.0 + return 0.0, "index" + # One narrowed local carries "use the time axis" and the stamps + # together, so the two can never drift apart. + stamps = timestamps + if stamps is not None and not _timestamps_usable(stamps, n): + stamps = None + if stamps is not None: + span = stamps[-1] - stamps[0] + u_first = (stamps[1] - stamps[0]) / span + # Every countable position shares one instant: no range to + # normalize against, so fall back rather than divide by zero. + if u_first >= 1.0: + stamps = None runs = ( change_series.runs() if isinstance(change_series, RLEList) else ((value, 1) for value in change_series) ) - position_sum, n_changes, position = 0, 0, 0 + position_sum, n_changes, position = 0.0, 0, 0 for value, count in runs: if value: start = max(position, 1) # index 0 is excluded length = position + count - start if length > 0: - # sum of start .. start+length-1 - position_sum += length * start + length * (length - 1) // 2 + if stamps is not None: + # Per-element accumulation required: summing a multi-element + # run with np.sum() changes order of operations vs. processing + # individual elements, breaking bit-identical agreement between + # RLEList and plain-list code paths. + for i in range(start, start + length): + position_sum += float(stamps[i]) + else: + # sum of start .. start+length-1 + position_sum += length * start + length * (length - 1) // 2 n_changes += length position += count if n_changes == 0: - return -0.5 - return (position_sum / n_changes - n / 2) / (n - 2) + return -0.5, "time" if stamps is not None else "index" + mean_position = position_sum / n_changes + if stamps is not None: + span = stamps[-1] - stamps[0] + u_bar = (mean_position - stamps[0]) / span + u_first = (stamps[1] - stamps[0]) / span + return (u_bar - (u_first + 1) / 2) / (1 - u_first), "time" + return (mean_position - n / 2) / (n - 2), "index" def get_last_segment_means(self) -> List[float]: return self.segment_means @@ -193,5 +351,6 @@ def __call__( def __repr__(self) -> str: return ( f"StabilityClassifier(segment_threshs={self.segment_threshs}, " + f"classification={self.classification}, " f"segment_means={self.segment_means})" ) diff --git a/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/stability/stability_tracker.py b/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/stability/stability_tracker.py index 87c9c7ad..b6fd5d89 100644 --- a/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/stability/stability_tracker.py +++ b/src/detectmatelibrary/utils/persistency/event_data_structures/trackers/stability/stability_tracker.py @@ -7,6 +7,7 @@ from detectmatelibrary.utils.persistency.rle_list import RLEList from ..base import SingleTracker, MultiTracker, EventTracker, Classification from .stability_classifier import StabilityClassifier +from .classification_methods import ClassificationMethods if TYPE_CHECKING: from detectmatelibrary.common.detector import CoreDetectorConfig @@ -40,28 +41,64 @@ def _strip_persist(detector_config: Any, method_id: str) -> Any: } +def _as_methods( + classification: "ClassificationMethods | Dict[str, Any] | None", +) -> ClassificationMethods: + """Accept the block as a model, a plain dict, or nothing. + + to_state() writes a dict and the config layer forwards a dict, so the + tracker has to take both without either caller converting first. + + Returns a copy when given a model instance: an ``EventStabilityTracker`` + shares one passed-in ``ClassificationMethods`` across every per-variable + tracker it creates, so storing the caller's instance as-is would let a + later in-place mutation of it silently change every variable already + built from it. + """ + if classification is None: + return ClassificationMethods() + if isinstance(classification, ClassificationMethods): + return classification.model_copy() + return ClassificationMethods(**classification) + + +def _classification_from_state(state: Dict[str, Any]) -> ClassificationMethods: + """The classification block for a state dict, old or new. + + Legacy snapshots predate the four-method split. Their `segmentation` + enum maps onto the two segment-threshold methods and `require_declining` + onto `slope_index`; their semantics were always AND, so they decide by + consensus. This is the only place the old names survive -- the config + layer rejects them outright. + """ + if "classification" in state: + return ClassificationMethods(**state["classification"]) + segmentation = state.get("segmentation", "count") + return ClassificationMethods( + index=segmentation in ("count", "both"), + time=segmentation in ("time", "both"), + slope_index=bool(state.get("require_declining", False)), + slope_threshold=state.get("incline_threshold", -0.05), + decision="consensus", + ) + + class SingleStabilityTracker(SingleTracker): """Tracks stability of a single feature.""" def __init__( self, min_samples: int = 3, - segmentation: Literal["count", "time", "both"] = "count", - require_declining: bool = False, - incline_threshold: float = -0.05, + classification: "ClassificationMethods | Dict[str, Any] | None" = None, add_value_fn: str = "default", detector_config: "CoreDetectorConfig | None" = None, ) -> None: self.min_samples = min_samples - self.segmentation = segmentation - # Orthogonal to segmentation: an extra conjunct on STABLE, not another - # way of cutting the series. See _is_stable(). - self.require_declining = require_declining 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], - incline_threshold=incline_threshold, + classification=_as_methods(classification), ) # ponytail: O(N) timestamps; switch to fixed-width time buckets if # this ever runs unbounded/streaming. @@ -83,6 +120,25 @@ def __init__( detector = detector_cls() self._value_fn = partial(detector.add_value, self) + @property + def classification(self) -> ClassificationMethods: + """The classification methods in force, owned by the classifier. + + A property rather than a second attribute: the tracker reads it at + ingest time (to decide whether to collect timestamps) and the + classifier reads it at classify() time, and two copies would let + those two drift apart. Reassignable between classify() calls -- the + methods are read when classify() runs, never when values arrive, so + several verdicts can be taken from one ingest by swapping this. + """ + return self.stability_classifier.classification + + @classification.setter + def classification( + self, value: "ClassificationMethods | Dict[str, Any] | None" + ) -> None: + self.stability_classifier.classification = _as_methods(value) + def _default_add_value(self, value: Any) -> None: """Default value semantics: one set entry per whole value.""" before = len(self.unique_set) @@ -96,11 +152,16 @@ def add_value(self, value: Any, timestamp: float | None = None) -> None: detector's ``add_value``. Timestamp bookkeeping stays here so ``timestamps`` cannot drift from ``change_series``: a detector may record nothing for a value (ValueRangeDetector on non-numeric input), and a - length mismatch silently demotes the variable to count segmentation. + length mismatch silently leaves that value off the time axis, demoting + the variable to index-based classification. """ before = len(self.change_series) self._value_fn(value) - if self.segmentation != "count" and timestamp is not None and len(self.change_series) > before: + if ( + self.classification.needs_timestamps + and timestamp is not None + and len(self.change_series) > before + ): self.timestamps.append(float(timestamp)) def classify(self) -> Classification: @@ -127,62 +188,33 @@ def classify(self) -> Classification: ) def _is_stable(self) -> bool: - """Stability verdict under the configured segmentation. + """Stability verdict under the configured classification methods. Builds ``_stability_note``, which is ``classify()``'s whole reason - string -- the note has to name what actually failed, and with - ``require_declining`` on that is no longer always the segment - thresholds. - - ``both`` runs the count pass and the time pass over the same - change series and requires both. Neither segmentation subsumes - the other -- a variable that churns in a burst and then settles is - count-UNSTABLE but time-STABLE, and one whose late churn is buried - under a dense settled tail is the reverse -- so the conjunction is - strictly stricter than either input. - - Deliberately not short-circuited: both passes always run so the - note carries both mean vectors, which is what anyone debugging a - ``both`` verdict needs. Costs one extra O(runs x n_segments) scan. + string. The note names every enabled method, what it found, and how + the decision rule resolved -- with four selectable methods and two + decision rules, naming only the verdict would leave a reader unable + to tell which method drove it. """ - clf, ts = self.stability_classifier, self._aligned_timestamps() - if self.segmentation != "both": - verdict = clf.is_stable(self.change_series, timestamps=ts) - note = f"Segment means of change series {clf.get_last_segment_means()}" - else: - count_stable = clf.is_stable(self.change_series) - # Snapshot now, not after the time pass: is_stable() rebinds - # clf.segment_means to a fresh list on every call, so calling - # get_last_segment_means() after the time pass below would return - # the time means for both halves of the note instead of the count - # means it is meant to capture here. - count_means = clf.get_last_segment_means() - time_stable = clf.is_stable(self.change_series, timestamps=ts) - note = ( - f"Segment means of change series: count {count_means}, " - f"time {clf.get_last_segment_means()}" - ) - verdict = count_stable and time_stable - note += ( - f" {'are below' if verdict else 'exceed'} segment thresholds: " - f"{clf.get_segment_thresholds()}" + clf = self.stability_classifier + verdicts = clf.verdicts(self.change_series, timestamps=self._aligned_timestamps()) + n_stable, n_total = sum(verdicts.values()), len(verdicts) + verdict = clf.decide(verdicts) + details = clf.get_last_details() + self._stability_note = "; ".join( + [details[name] for name in verdicts] + + [f"decision={clf.classification.decision} ({n_stable}/{n_total}) -> " + f"{'STABLE' if verdict else 'UNSTABLE'}"] ) - if self.require_declining: - k = clf.incline(self.change_series) - declining = k <= clf.incline_threshold - note += ( - f"; change centroid {k:+.3f} is " - f"{'at or below' if declining else 'above'} the incline " - f"threshold {clf.incline_threshold}" - ) - verdict = verdict and declining - self._stability_note = note return verdict def _aligned_timestamps(self) -> List[float] | None: - """Timestamps to classify with, or None to fall back to count - segments.""" - if self.segmentation != "count" and len(self.timestamps) == len(self.change_series): + """Timestamps to classify with, or None to fall back to the index + axis.""" + if ( + self.classification.needs_timestamps + and len(self.timestamps) == len(self.change_series) + ): return self.timestamps return None @@ -193,9 +225,7 @@ def to_state(self) -> Dict[str, Any]: "type": self.__class__.__name__, "module": self.__class__.__module__, "min_samples": self.min_samples, - "segmentation": self.segmentation, - "require_declining": self.require_declining, - "incline_threshold": self.stability_classifier.incline_threshold, + "classification": self.classification.model_dump(), "timestamps": self.timestamps, "add_value_fn": self.add_value_fn, "detector_config": self.detector_config, @@ -207,14 +237,20 @@ def to_state(self) -> Dict[str, Any]: @classmethod def from_state(cls, state: Dict[str, Any]) -> "SingleStabilityTracker": - """Restore tracker from a state dict produced by to_state().""" - # Every optional key is read with .get(): a snapshot old enough to still - # carry the removed `expand_value` predates `add_value_fn` too, so - # indexing here would KeyError on exactly the states this tolerance is for. + """Restore tracker from a state dict produced by to_state(). + + Every optional key is read with .get(): a snapshot old enough to still + carry the removed `expand_value` predates `add_value_fn` too, so + indexing here would KeyError on exactly the states this tolerance is + for. The same applies to the classification block -- snapshots written + before the four-method split carry `segmentation` / `require_declining` + / `incline_threshold` instead, and _classification_from_state + translates them. + """ + classification = _classification_from_state(state) tracker = cls( min_samples=state["min_samples"], - segmentation=state.get("segmentation", "count"), - require_declining=state.get("require_declining", False), + classification=classification, add_value_fn=state.get("add_value_fn", "default"), detector_config=state.get("detector_config"), ) @@ -224,10 +260,12 @@ def from_state(cls, state: Dict[str, Any]) -> "SingleStabilityTracker": tracker.unique_set = { tuple(v) if isinstance(v, list) else v for v in state["unique_set"] } + # Rebuilding the classifier drops the one __init__ made, so the block + # is passed from the local -- reading tracker.classification here would + # read through the very object being replaced. tracker.stability_classifier = StabilityClassifier( segment_thresholds=state["segment_thresholds"], - **({"incline_threshold": state["incline_threshold"]} - if "incline_threshold" in state else {}), + classification=classification, ) tracker.timestamps = [float(t) for t in state.get("timestamps", [])] tracker.extra_state = state.get("extra_state", {}) @@ -239,7 +277,7 @@ def __repr__(self) -> str: unique_set_str = "{" + ", ".join(map(str, list_preview_str(self.unique_set))) + "}" RLE_str = list_preview_str(self.change_series.runs()) return ( - f"{self.__class__.__name__}(classification={self.classify()}, change_series={series_str}, " + f"{self.__class__.__name__}(verdict={self.classify()}, change_series={series_str}, " f"unique_set={unique_set_str}, RLE={RLE_str})" ) @@ -269,20 +307,15 @@ class EventStabilityTracker(EventTracker): def __init__( self, converter_function: Callable[[Any], Any] = lambda x: x, - segmentation: Literal["count", "time", "both"] = "count", - require_declining: bool = False, - incline_threshold: float = -0.05, + classification: "ClassificationMethods | Dict[str, Any] | None" = None, add_value_fn: str = "default", - detector_config: "CoreDetectorConfig | None" = None - + detector_config: "CoreDetectorConfig | None" = None, ) -> None: self.multi_tracker: MultiStabilityTracker # for type hinting def make_tracker() -> SingleStabilityTracker: return SingleStabilityTracker( - segmentation=segmentation, - require_declining=require_declining, - incline_threshold=incline_threshold, + classification=classification, add_value_fn=add_value_fn, detector_config=detector_config, ) diff --git a/tests/test_detectors/test_auto_config_params_survive.py b/tests/test_detectors/test_auto_config_params_survive.py index bc18f3fe..ca51152f 100644 --- a/tests/test_detectors/test_auto_config_params_survive.py +++ b/tests/test_detectors/test_auto_config_params_survive.py @@ -40,8 +40,7 @@ def _schema(event_id: int, level: str, log_id: str): _AUTO = dict( use_stable_vars=True, use_static_vars=True, - segmentation="time", - require_declining=True, + classification=dict(index=False, time=True, slope_index=True), timestamp_variable="level", timestamp_format="%y%m%d %H%M%S", ) @@ -53,8 +52,7 @@ def _assert_auto_params_intact(config): auto = config.auto_config_params assert auto.use_stable_vars is True assert auto.use_static_vars is True - assert auto.segmentation == "time" - assert auto.require_declining is True + assert auto.classification.enabled == ("time", "slope_index") assert auto.timestamp_variable == "level" assert auto.timestamp_format == "%y%m%d %H%M%S" diff --git a/tests/test_detectors/test_new_value_combo_detector.py b/tests/test_detectors/test_new_value_combo_detector.py index fafec9f7..cd4d5c01 100644 --- a/tests/test_detectors/test_new_value_combo_detector.py +++ b/tests/test_detectors/test_new_value_combo_detector.py @@ -4,6 +4,9 @@ NewValueComboDetectorConfig, ComboAutoConfigParams, ) +from detectmatelibrary.utils.persistency.event_data_structures.trackers import ( + ClassificationMethods, +) from detectmatelibrary.utils.data_buffer import BufferMode from detectmatelibrary.common._config import generate_detector_config from detectmatelibrary.parsers.template_matcher import MatcherParser @@ -583,17 +586,17 @@ def test_audit_log_anomalies(self): assert detected_ids == {"1859", "1862", "1865", "1866"} -class TestNewValueComboDetectorSegmentationConfigPreservation: +class TestNewValueComboDetectorClassificationConfigPreservation: """auto_config_params survive set_configuration untouched. The configure phase writes only `events` and `auto_config`; every other field on the config is operator input. """ - def test_segmentation_fields_survive_set_configuration(self): + def test_classification_fields_survive_set_configuration(self): cfg = NewValueComboDetectorConfig( auto_config_params=ComboAutoConfigParams( - segmentation="time", + classification=ClassificationMethods(index=False, time=True), timestamp_variable="level", timestamp_format="%y%m%d %H%M%S", ), @@ -617,29 +620,32 @@ def test_segmentation_fields_survive_set_configuration(self): detector.set_configuration(max_combo_size=2) - assert detector.config.auto_config_params.segmentation == "time" + assert detector.config.auto_config_params.classification.enabled == ("time",) assert detector.config.auto_config_params.timestamp_variable == "level" assert detector.config.auto_config_params.timestamp_format == "%y%m%d %H%M%S" -class TestNewValueComboDetectorSegmentationCombos: - """The combo-stability pass must honour segmentation too. +class TestNewValueComboDetectorClassificationCombos: + """The combo-stability pass must honour the classification block too. auto_conf_persistency_combos is built directly in __init__ rather than from the _event_data_kwargs hook, and its re-ingest loop in set_configuration - calls ingest_event itself -- so both halves of the flag (the tracker kwarg - and the per-record timestamp) have to be wired up explicitly. A flag that - reaches the first-pass trackers but not the combo trackers is worse than no - flag at all: the generated config would be selected on a different rule - than the one the operator asked for. + calls ingest_event itself -- so both halves of the classification block + (the tracker kwarg and the per-record timestamp) have to be wired up + explicitly. A classification block that reaches the first-pass trackers + but not the combo trackers is worse than none at all: the generated + config would be selected on a different rule than the one the operator + asked for. """ @staticmethod - def _records(segmentation="time"): + def _records(classification=None): detector = NewValueComboDetector( config=NewValueComboDetectorConfig( auto_config_params=ComboAutoConfigParams( - segmentation=segmentation, + classification=classification or ClassificationMethods( + index=False, time=True + ), timestamp_variable="ts", ), ), @@ -668,18 +674,18 @@ def test_combo_trackers_record_timestamps(self): combo_trackers = detector.auto_conf_persistency_combos.get_events_data()[1].get_data() assert ("var_0", "var_1") in combo_trackers tracker = combo_trackers[("var_0", "var_1")] - assert tracker.segmentation == "time" + assert tracker.classification.enabled == ("time",) assert len(tracker.timestamps) == len(tracker.change_series) == 12 assert tracker.timestamps[1] - tracker.timestamps[0] == 60.0 - def test_combo_trackers_stay_count_based_when_flag_is_off(self): - detector = self._records(segmentation="count") + def test_combo_trackers_stay_index_based_when_no_time_method_is_on(self): + detector = self._records(classification=ClassificationMethods(index=True)) detector.set_configuration(max_combo_size=2) tracker = detector.auto_conf_persistency_combos.get_events_data()[1].get_data()[ ("var_0", "var_1") ] - assert tracker.segmentation == "count" + assert tracker.classification.enabled == ("index",) assert tracker.timestamps == [] def test_auto_config_params_round_trip(self): @@ -688,10 +694,9 @@ def test_auto_config_params_round_trip(self): block = { "use_stable_vars": True, "use_static_vars": True, - "segmentation": "both", + "classification": {"index": True, "time": True, "slope_index": True}, "timestamp_variable": "level", "timestamp_format": "%y%m%d %H%M%S", - "require_declining": True, } source = { "detectors": { @@ -712,8 +717,16 @@ def test_auto_config_params_round_trip(self): config = NewValueComboDetectorConfig.from_dict(source, "NewValueComboDetector") dumped = config.to_dict(method_id="NewValueComboDetector") entry = dumped["detectors"]["NewValueComboDetector"] + auto_params = entry["auto_config_params"] # Subset, not equality: later tasks add fields to this model and an - # exact-match assertion would break every time one lands. - assert block.items() <= entry["auto_config_params"].items() + # exact-match assertion would break every time one lands. `classification` + # round-trips as a full six-key dict (slope_time, slope_threshold and + # decision included), so it is compared via model equality rather than + # raw dict equality. + non_classification = {k: v for k, v in block.items() if k != "classification"} + assert non_classification.items() <= auto_params.items() + assert ClassificationMethods(**auto_params["classification"]) == ClassificationMethods( + **block["classification"] + ) assert not set(block) & set(entry.get("params", {})) assert NewValueComboDetectorConfig.from_dict(dumped, "NewValueComboDetector") == config diff --git a/tests/test_persistency/test_classification_methods.py b/tests/test_persistency/test_classification_methods.py new file mode 100644 index 00000000..9feabc84 --- /dev/null +++ b/tests/test_persistency/test_classification_methods.py @@ -0,0 +1,69 @@ +"""Tests for the classification-method selection model.""" + +import pytest +from pydantic import ValidationError + +from detectmatelibrary.utils.persistency.event_data_structures.trackers import ( + ClassificationMethods, +) + + +class TestDefaults: + def test_default_is_index_only_under_consensus(self): + """The default must reproduce the historical behaviour exactly.""" + m = ClassificationMethods() + assert (m.index, m.time, m.slope_index, m.slope_time) == (True, False, False, False) + assert m.decision == "consensus" + assert m.slope_threshold == -0.05 + + def test_enabled_lists_names_in_block_order(self): + m = ClassificationMethods(index=True, time=True, slope_index=False, slope_time=True) + assert m.enabled == ("index", "time", "slope_time") + + def test_enabled_of_a_single_method(self): + m = ClassificationMethods(index=False, slope_time=True) + assert m.enabled == ("slope_time",) + + +class TestNeedsTimestamps: + @pytest.mark.parametrize( + "kwargs, expected", + [ + ({}, False), + ({"index": False, "slope_index": True}, False), + ({"index": False, "time": True}, True), + ({"index": False, "slope_time": True}, True), + ({"time": True, "slope_time": True}, True), + ], + ) + def test_only_the_time_axis_methods_need_stamps(self, kwargs, expected): + assert ClassificationMethods(**kwargs).needs_timestamps is expected + + +class TestValidation: + def test_no_method_enabled_is_rejected(self): + """A method-less config would silently classify every surviving + variable STABLE, because INSUFFICIENT_DATA / STATIC / RANDOM are + decided before any method is consulted.""" + with pytest.raises(ValidationError, match="at least one classification method"): + ClassificationMethods(index=False) + + def test_unknown_field_is_rejected(self): + with pytest.raises(ValidationError): + ClassificationMethods(segmentation="both") + + def test_unknown_decision_is_rejected(self): + with pytest.raises(ValidationError): + ClassificationMethods(decision="unanimous") + + @pytest.mark.parametrize("rule", ["consensus", "majority"]) + def test_both_decision_rules_are_accepted(self, rule): + assert ClassificationMethods(decision=rule).decision == rule + + +def test_round_trips_through_a_plain_dict(): + """to_state() and the config layer both move this model as a dict.""" + m = ClassificationMethods( + index=False, time=True, slope_time=True, slope_threshold=-0.2, decision="majority" + ) + assert ClassificationMethods(**m.model_dump()) == m diff --git a/tests/test_persistency/test_incline_stability.py b/tests/test_persistency/test_incline_stability.py deleted file mode 100644 index 6a0a9cdb..00000000 --- a/tests/test_persistency/test_incline_stability.py +++ /dev/null @@ -1,270 +0,0 @@ -"""Tests for the require_declining option of the stability trackers. - -The incline is the change centroid: the mean position of the changes, -measured against the midpoint of the series and scaled by the half-span, -so it lands in [-0.5, +0.5]. Index 0 is excluded -- the first value is -always recorded as a change, and keeping it would drag every variable -negative, a perfectly static one included. -""" - -import numpy as np - -from detectmatelibrary.detectors.charset_detector import CharsetDetector, CharsetDetectorConfig -from detectmatelibrary.common.variable_detector import VariableAutoConfigParams -from detectmatelibrary.utils.persistency.rle_list import RLEList -from detectmatelibrary.utils.persistency.event_data_structures.trackers import ( - StabilityClassifier, - SingleStabilityTracker, - EventStabilityTracker, -) - -THRESHOLDS = [1.1, 0.3, 0.1, 0.01] # same defaults SingleStabilityTracker uses - - -def make_classifier(**kwargs) -> StabilityClassifier: - return StabilityClassifier(segment_thresholds=THRESHOLDS, **kwargs) - - -def series(n: int, *change_ranges: range) -> list: - """A change series of length n with index 0 True plus the given ranges.""" - out = [False] * n - out[0] = True - for r in change_ranges: - for i in r: - out[i] = True - return out - - -def feed(tracker: SingleStabilityTracker, change_series) -> None: - """Drive a tracker so its change_series matches: fresh value on each - True, a repeat otherwise.""" - seen = 0 - for changed in change_series: - if changed: - seen += 1 - tracker.add_value(f"v{seen}") - - -# The flag only bites where the segment thresholds do not already imply an -# early centroid, and that gap opens up with series length. Over 400 samples -# the quarters are wide enough to hold 29 changes under threshold 0.3 and 9 -# under 0.1, so a variable can pass every segment test with its changes still -# sitting *late*: quarter means [0.01, 0.29, 0.09, 0.0] -> STABLE, centroid -# +0.028 -> not declining. -LATE_BUT_PASSING = series(400, range(171, 200), range(291, 300)) - -# Same length, changes up front: STABLE under the segment thresholds and -# strongly declining (centroid -0.464), so the flag leaves it alone. -EARLY = series(400, range(1, 31)) - -# Segment-UNSTABLE (quarter 1 mean 0.51 > 0.3) but strongly declining. The -# flag is a conjunct, so it can never rescue this one. -DENSE_EARLY = series(400, range(1, 151)) - - -class TestInclineStatistic: - def test_hand_checked_values(self): - clf = make_classifier() - # n=5, changes at 1 and 2 -> p_bar 1.5, midpoint 2.5, half-span 3 - assert clf.incline(RLEList([True, True, True, False, False])) == -1 / 3 - # mirror image, changes at 3 and 4 -> p_bar 3.5 - assert clf.incline(RLEList([True, False, False, True, True])) == 1 / 3 - - def test_no_changes_after_the_first_hits_the_floor(self): - clf = make_classifier() - assert clf.incline(RLEList([True] + [False] * 39)) == -0.5 - - def test_changing_every_step_is_perfectly_uniform(self): - clf = make_classifier() - assert clf.incline(RLEList([True] * 40)) == 0.0 - - def test_too_short_to_have_a_span(self): - clf = make_classifier() - assert clf.incline(RLEList([True, False])) == 0.0 - assert clf.incline(RLEList([])) == 0.0 - - def test_stays_within_bounds(self): - clf, rng = make_classifier(), np.random.default_rng(11) - for _ in range(200): - n = int(rng.integers(3, 300)) - f = [True] + list(rng.random(n - 1) < rng.random()) - assert -0.5 <= clf.incline(RLEList(f)) <= 0.5 - - def test_rle_and_plain_list_agree(self): - clf, rng = make_classifier(), np.random.default_rng(12) - for _ in range(200): - n = int(rng.integers(3, 300)) - f = [True] + list(bool(v) for v in rng.random(n - 1) < rng.random()) - assert clf.incline(RLEList(f)) == clf.incline(f) - - def test_sign_always_matches_the_least_squares_slope(self): - """k_OLS = k * 12m / n(n-1), a strictly positive factor -- so a polyfit - over the same series can never disagree on the verdict.""" - clf, rng = make_classifier(), np.random.default_rng(13) - for _ in range(200): - n = int(rng.integers(4, 300)) - f = [True] + list(bool(v) for v in rng.random(n - 1) < rng.random()) - if not any(f[1:]): - continue - slope = np.polyfit(np.arange(1, n), np.asarray(f[1:], dtype=float), 1)[0] - assert np.sign(round(clf.incline(RLEList(f)), 12)) == np.sign(round(slope, 12)) - - -class TestRequireDecliningVerdicts: - def test_off_by_default_changes_nothing(self): - tracker = SingleStabilityTracker() - feed(tracker, LATE_BUT_PASSING) - assert tracker.require_declining is False - assert tracker.classify().type == "STABLE" - - def test_on_flips_a_late_but_passing_variable(self): - tracker = SingleStabilityTracker(require_declining=True) - feed(tracker, LATE_BUT_PASSING) - assert tracker.classify().type == "UNSTABLE" - - def test_on_leaves_an_early_variable_alone(self): - tracker = SingleStabilityTracker(require_declining=True) - feed(tracker, EARLY) - assert tracker.classify().type == "STABLE" - - def test_can_only_tighten_never_loosen(self): - off, on = SingleStabilityTracker(), SingleStabilityTracker(require_declining=True) - feed(off, DENSE_EARLY) - feed(on, DENSE_EARLY) - # strongly declining (-0.313) but segment-UNSTABLE -> stays UNSTABLE - assert off.classify().type == "UNSTABLE" - assert on.classify().type == "UNSTABLE" - - def test_threshold_is_configurable(self): - tracker = SingleStabilityTracker(require_declining=True) - feed(tracker, LATE_BUT_PASSING) - assert tracker.classify().type == "UNSTABLE" - # centroid is +0.028; a threshold above it lets the variable through - tracker.stability_classifier.incline_threshold = 0.1 - assert tracker.classify().type == "STABLE" - - def test_reason_carries_the_centroid_only_when_enabled(self): - on = SingleStabilityTracker(require_declining=True) - feed(on, EARLY) - assert "change centroid" in on.classify().reason - - off = SingleStabilityTracker() - feed(off, EARLY) - assert "change centroid" not in off.classify().reason - - def test_composes_with_both_segmentation(self): - tracker = SingleStabilityTracker(segmentation="both", require_declining=True) - for i, changed in enumerate(LATE_BUT_PASSING): - tracker.add_value(f"v{sum(LATE_BUT_PASSING[:i + 1])}", timestamp=float(i)) - assert tracker.classify().type == "UNSTABLE" - reason = tracker.classify().reason - assert "count" in reason and "time" in reason and "change centroid" in reason - - -class TestRequireDecliningPlumbing: - def test_state_round_trip(self): - tracker = SingleStabilityTracker(require_declining=True) - tracker.stability_classifier.incline_threshold = -0.2 - feed(tracker, EARLY) - restored = SingleStabilityTracker.from_state(tracker.to_state()) - assert restored.require_declining is True - assert restored.stability_classifier.incline_threshold == -0.2 - assert restored.classify().type == tracker.classify().type - - def test_state_without_the_keys_still_loads(self): - """Snapshots written before the flag existed must keep working.""" - tracker = SingleStabilityTracker() - feed(tracker, EARLY) - state = tracker.to_state() - del state["require_declining"], state["incline_threshold"] - restored = SingleStabilityTracker.from_state(state) - assert restored.require_declining is False - assert restored.classify().type == "STABLE" - - def test_event_tracker_propagates_the_flag(self): - event_tracker = EventStabilityTracker(require_declining=True) - event_tracker.add_data({"var1": "a"}) - event_tracker.add_data({"var1": "b"}) - assert event_tracker.get_data()["var1"].require_declining is True - - def test_event_tracker_dump_load_preserves_the_flag(self): - event_tracker = EventStabilityTracker(require_declining=True) - event_tracker.add_data({"var1": "a"}) - event_tracker.add_data({"var1": "b"}) - restored = EventStabilityTracker.load(event_tracker.dump(), require_declining=True) - assert restored.get_data()["var1"].require_declining is True - - -class TestRequireDecliningConfigWiring: - def test_flag_reaches_per_variable_trackers(self): - # CharsetDetector's `config` default is a shared mutable instance, so - # pass explicit fresh configs (see test_time_dependent_stability.py). - # - # require_declining only shapes the configure-phase persistency: the - # trained persistency is read by _check_variable, which never calls - # classify(), so it never receives stability kwargs at all. - default = CharsetDetector(config=CharsetDetectorConfig()) - assert default.persistency.event_data_kwargs.get("require_declining") is None - - configured = CharsetDetector(config=CharsetDetectorConfig()) - configured.config.auto_config_params.require_declining = True - rebuilt = CharsetDetector(config=configured.config.to_dict(method_id="CharsetDetector")) - assert rebuilt.auto_conf_persistency.event_data_kwargs["require_declining"] is True - - def test_config_field_round_trips(self): - detector = CharsetDetector(config=CharsetDetectorConfig()) - detector.config.auto_config_params.require_declining = True - restored = type(detector.config).from_dict( - detector.config.to_dict(method_id="CharsetDetector"), "CharsetDetector" - ) - assert restored.auto_config_params.require_declining is True - - def test_survives_auto_config(self): - """set_configuration() writes only config.events and flips - config.auto_config to False -- it never touches auto_config_params, so - operator settings survive because nothing overwrites them.""" - detector = CharsetDetector(config=CharsetDetectorConfig( - auto_config=True, - auto_config_params=VariableAutoConfigParams( - require_declining=True, use_static_vars=False, - ), - )) - detector.set_configuration() - assert detector.config.auto_config_params.require_declining is True - assert detector.config.auto_config_params.use_static_vars is False - - def test_does_not_pull_in_the_timestamp_requirement(self): - """The flag is orthogonal to segmentation: no timestamps are asked - for, and none are collected.""" - detector = CharsetDetector(config=CharsetDetectorConfig()) - detector.config.auto_config_params.require_declining = True - rebuilt = CharsetDetector(config=detector.config.to_dict(method_id="CharsetDetector")) - assert "segmentation" not in rebuilt.persistency.event_data_kwargs - - tracker = SingleStabilityTracker(require_declining=True) - tracker.add_value("a", timestamp=1.0) - assert tracker.timestamps == [] - - -def test_incline_threshold_reaches_the_classifier(): - """The threshold is configuration, not a constant buried in the - classifier.""" - from detectmatelibrary.common.variable_detector import VariableAutoConfigParams - from detectmatelibrary.detectors.new_value_detector import ( - NewValueDetector, - NewValueDetectorConfig, - ) - - detector = NewValueDetector( - name="NewValueDetector", - config=NewValueDetectorConfig( - auto_config_params=VariableAutoConfigParams( - require_declining=True, incline_threshold=-0.25 - ), - ), - ) - persistency = detector.auto_conf_persistency - tracker = persistency.event_data_class(**persistency.event_data_kwargs) - single = tracker.single_tracker_type() - assert single.require_declining is True - assert single.stability_classifier.incline_threshold == -0.25 diff --git a/tests/test_persistency/test_slope_stability.py b/tests/test_persistency/test_slope_stability.py new file mode 100644 index 00000000..36c56121 --- /dev/null +++ b/tests/test_persistency/test_slope_stability.py @@ -0,0 +1,783 @@ +"""Tests for the slope classification methods of the stability trackers. + +The slope is the change centroid: the mean position of the changes, +measured against the midpoint of the range those changes could occupy +and scaled by its half-span, so it lands in [-0.5, +0.5]. Index 0 is +excluded -- the first value is always recorded as a change, and keeping +it would drag every variable negative, a perfectly static one included. + +`slope_index` measures position on the index axis, `slope_time` on +normalized wall-clock time. With evenly spaced timestamps the two agree +exactly; that correspondence is what lets them share one threshold. +""" + +import numpy as np +import pytest +from pydantic import ValidationError + +from detectmatelibrary.detectors.charset_detector import CharsetDetector, CharsetDetectorConfig +from detectmatelibrary.common.variable_detector import VariableAutoConfigParams +from detectmatelibrary.utils.persistency.rle_list import RLEList +from detectmatelibrary.utils.persistency.event_data_structures.trackers import ( + StabilityClassifier, + SingleStabilityTracker, + EventStabilityTracker, + ClassificationMethods, +) + +THRESHOLDS = [1.1, 0.3, 0.1, 0.01] # same defaults SingleStabilityTracker uses + + +def make_classifier(**kwargs) -> StabilityClassifier: + return StabilityClassifier(segment_thresholds=THRESHOLDS, **kwargs) + + +def series(n: int, *change_ranges: range) -> list: + """A change series of length n with index 0 True plus the given ranges.""" + out = [False] * n + out[0] = True + for r in change_ranges: + for i in r: + out[i] = True + return out + + +def feed(tracker: SingleStabilityTracker, change_series) -> None: + """Drive a tracker so its change_series matches: fresh value on each + True, a repeat otherwise.""" + seen = 0 + for changed in change_series: + if changed: + seen += 1 + tracker.add_value(f"v{seen}") + + +# The flag only bites where the segment thresholds do not already imply an +# early centroid, and that gap opens up with series length. Over 400 samples +# the quarters are wide enough to hold 29 changes under threshold 0.3 and 9 +# under 0.1, so a variable can pass every segment test with its changes still +# sitting *late*: quarter means [0.01, 0.29, 0.09, 0.0] -> STABLE, centroid +# +0.028 -> not declining. +LATE_BUT_PASSING = series(400, range(171, 200), range(291, 300)) + +# Same length, changes up front: STABLE under the segment thresholds and +# strongly declining (centroid -0.464), so the flag leaves it alone. +EARLY = series(400, range(1, 31)) + +# Segment-UNSTABLE (quarter 1 mean 0.51 > 0.3) but strongly declining. The +# flag is a conjunct, so it can never rescue this one. +DENSE_EARLY = series(400, range(1, 151)) + + +class TestSlopeIndexAxis: + def test_hand_checked_values(self): + clf = make_classifier() + # n=5, changes at 1 and 2 -> p_bar 1.5, midpoint 2.5, half-span 3 + assert clf.slope(RLEList([True, True, True, False, False])) == -1 / 3 + # mirror image, changes at 3 and 4 -> p_bar 3.5 + assert clf.slope(RLEList([True, False, False, True, True])) == 1 / 3 + + def test_no_changes_after_the_first_hits_the_floor(self): + assert make_classifier().slope(RLEList([True] + [False] * 39)) == -0.5 + + def test_changing_every_step_is_perfectly_uniform(self): + assert make_classifier().slope(RLEList([True] * 40)) == 0.0 + + def test_too_short_to_have_a_span(self): + clf = make_classifier() + assert clf.slope(RLEList([True, False])) == 0.0 + assert clf.slope(RLEList([])) == 0.0 + + def test_stays_within_bounds(self): + clf, rng = make_classifier(), np.random.default_rng(11) + for _ in range(200): + n = int(rng.integers(3, 300)) + f = [True] + list(rng.random(n - 1) < rng.random()) + assert -0.5 <= clf.slope(RLEList(f)) <= 0.5 + + def test_rle_and_plain_list_agree(self): + clf, rng = make_classifier(), np.random.default_rng(12) + for _ in range(200): + n = int(rng.integers(3, 300)) + f = [True] + list(bool(v) for v in rng.random(n - 1) < rng.random()) + assert clf.slope(RLEList(f)) == clf.slope(f) + + def test_sign_always_matches_the_least_squares_slope(self): + """k_OLS = k * 12m / n(n-1), a strictly positive factor -- so a polyfit + over the same series can never disagree on the verdict.""" + clf, rng = make_classifier(), np.random.default_rng(13) + for _ in range(200): + n = int(rng.integers(4, 300)) + f = [True] + list(bool(v) for v in rng.random(n - 1) < rng.random()) + if not any(f[1:]): + continue + slope = np.polyfit(np.arange(1, n), np.asarray(f[1:], dtype=float), 1)[0] + assert np.sign(round(clf.slope(RLEList(f)), 12)) == np.sign(round(slope, 12)) + + +class TestSlopeTimeAxis: + def test_evenly_spaced_stamps_reproduce_the_index_axis(self): + """The property that lets both slope methods share one threshold.""" + clf, rng = make_classifier(), np.random.default_rng(14) + for _ in range(100): + n = int(rng.integers(3, 200)) + f = [True] + list(bool(v) for v in rng.random(n - 1) < rng.random()) + stamps = [float(i) for i in range(n)] + assert clf.slope(RLEList(f), stamps) == pytest.approx(clf.slope(RLEList(f))) + + def test_hand_checked_value_on_a_stretched_span(self): + """Changes at indices 1 and 2 of five, with the tail an eternity later. + + u = 1/101, 2/101 -> u_bar 0.0148515; u_first = 1/101 = 0.0099010 + k = (0.0148515 - (0.0099010 + 1) / 2) / (1 - 0.0099010) = -0.495 + The same series on evenly spaced stamps gives -1/3. + """ + clf = make_classifier() + f = RLEList([True, True, True, False, False]) + assert clf.slope(f, [0.0, 1.0, 2.0, 100.0, 101.0]) == pytest.approx(-0.495) + assert clf.slope(f, [0.0, 1.0, 2.0, 3.0, 4.0]) == pytest.approx(-1 / 3) + + def test_axes_can_disagree_in_sign(self): + """The case that motivates having both slope methods. + + [T,F,F,F,F,F,F,T,T,F] with the whole tail one long silence: the + changes sit late in *record count* (index +0.313) but they all + happened in the first moments of a long observation window (time + -0.493). Only the time axis sees that the variable settled. + """ + clf = make_classifier() + f = RLEList([True] + [False] * 6 + [True, True, False]) + stamps = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 1000.0] + assert clf.slope(f) == pytest.approx(0.3125) + assert clf.slope(f, stamps) == pytest.approx(-0.4935, abs=1e-4) + + def test_changes_late_in_the_span_score_positive(self): + clf = make_classifier() + f = RLEList([True, False, False, True, True]) + assert clf.slope(f, [0.0, 1.0, 2.0, 100.0, 101.0]) > 0.4 + + def test_no_changes_after_the_first_hits_the_floor(self): + clf = make_classifier() + stamps = [float(i) for i in range(40)] + assert clf.slope(RLEList([True] + [False] * 39), stamps) == -0.5 + + def test_stays_within_bounds(self): + clf, rng = make_classifier(), np.random.default_rng(15) + for _ in range(200): + n = int(rng.integers(3, 300)) + f = [True] + list(bool(v) for v in rng.random(n - 1) < rng.random()) + stamps = list(np.cumsum(rng.random(n))) + assert -0.5 <= clf.slope(RLEList(f), stamps) <= 0.5 + + def test_rle_and_plain_list_agree(self): + clf, rng = make_classifier(), np.random.default_rng(16) + for _ in range(100): + n = int(rng.integers(3, 200)) + f = [True] + list(bool(v) for v in rng.random(n - 1) < rng.random()) + stamps = list(np.cumsum(rng.random(n))) + assert clf.slope(RLEList(f), stamps) == clf.slope(f, stamps) + + +class TestSlopeTimeFallsBackToIndex: + """Time-aware classification is best-effort: it degrades to the index + axis rather than failing a run or passing unconditionally.""" + + SERIES = RLEList([True, True, True, False, False]) + + def index_value(self): + return make_classifier().slope(self.SERIES) + + def test_no_timestamps(self): + assert make_classifier().slope(self.SERIES, None) == self.index_value() + + def test_length_mismatch(self): + assert make_classifier().slope(self.SERIES, [0.0, 1.0]) == self.index_value() + + def test_zero_span(self): + stamps = [7.0] * 5 + assert make_classifier().slope(self.SERIES, stamps) == self.index_value() + + def test_out_of_order(self): + stamps = [0.0, 5.0, 2.0, 6.0, 7.0] + assert make_classifier().slope(self.SERIES, stamps) == self.index_value() + + def test_non_finite_entry(self): + stamps = [0.0, 1.0, float("nan"), 3.0, 4.0] + assert make_classifier().slope(self.SERIES, stamps) == self.index_value() + + def test_none_entry(self): + stamps = [0.0, 1.0, None, 3.0, 4.0] + assert make_classifier().slope(self.SERIES, stamps) == self.index_value() + + def test_zero_achievable_range(self): + """t_1 == t_last: every countable position shares one instant, so the + time axis has no range to normalize against.""" + stamps = [0.0, 9.0, 9.0, 9.0, 9.0] + assert make_classifier().slope(self.SERIES, stamps) == self.index_value() + + +class TestSlopeReportsItsAxis: + def test_time_axis_when_usable(self): + clf = make_classifier() + _, axis = clf._slope(RLEList([True, True, False, False]), [0.0, 1.0, 2.0, 3.0]) + assert axis == "time" + + def test_index_axis_on_fallback(self): + clf = make_classifier() + _, axis = clf._slope(RLEList([True, True, False, False]), None) + assert axis == "index" + + +class TestSlopeVerdictsOnTrackers: + def test_off_by_default_changes_nothing(self): + tracker = SingleStabilityTracker() + feed(tracker, LATE_BUT_PASSING) + assert tracker.classification.enabled == ("index",) + assert tracker.classify().type == "STABLE" + + def test_slope_index_flips_a_late_but_passing_variable(self): + tracker = SingleStabilityTracker( + classification=ClassificationMethods(index=True, slope_index=True) + ) + feed(tracker, LATE_BUT_PASSING) + assert tracker.classify().type == "UNSTABLE" + + def test_slope_index_leaves_an_early_variable_alone(self): + tracker = SingleStabilityTracker( + classification=ClassificationMethods(index=True, slope_index=True) + ) + feed(tracker, EARLY) + assert tracker.classify().type == "STABLE" + + def test_consensus_can_only_tighten_never_loosen(self): + off = SingleStabilityTracker() + on = SingleStabilityTracker( + classification=ClassificationMethods(index=True, slope_index=True) + ) + feed(off, DENSE_EARLY) + feed(on, DENSE_EARLY) + # strongly declining (-0.313) but index-UNSTABLE -> stays UNSTABLE + assert off.classify().type == "UNSTABLE" + assert on.classify().type == "UNSTABLE" + + def test_slope_index_can_stand_alone(self): + """DENSE_EARLY is index-UNSTABLE but strongly declining. + + With the segment methods off, only the centroid decides -- and + it says stable. + """ + tracker = SingleStabilityTracker( + classification=ClassificationMethods(index=False, slope_index=True) + ) + feed(tracker, DENSE_EARLY) + assert tracker.classify().type == "STABLE" + + def test_threshold_is_configurable(self): + tracker = SingleStabilityTracker( + classification=ClassificationMethods(index=True, slope_index=True) + ) + feed(tracker, LATE_BUT_PASSING) + assert tracker.classify().type == "UNSTABLE" + # centroid is +0.028; a threshold above it lets the variable through + tracker.classification = ClassificationMethods( + index=True, slope_index=True, slope_threshold=0.1 + ) + assert tracker.classify().type == "STABLE" + + def test_reason_names_every_enabled_method_and_the_decision(self): + tracker = SingleStabilityTracker( + classification=ClassificationMethods(index=True, slope_index=True) + ) + feed(tracker, EARLY) + reason = tracker.classify().reason + assert "index:" in reason and "slope_index:" in reason + assert "decision=consensus (2/2)" in reason + + def test_reason_omits_methods_that_are_off(self): + tracker = SingleStabilityTracker() + feed(tracker, EARLY) + reason = tracker.classify().reason + assert "slope_index:" not in reason and "time:" not in reason + + def test_early_classify_reasons_are_untouched(self): + """STATIC / RANDOM / INSUFFICIENT_DATA are decided before any method is + consulted, so no method setting can reach them.""" + static = SingleStabilityTracker( + classification=ClassificationMethods(index=False, slope_time=True) + ) + for _ in range(10): + static.add_value("a", timestamp=1.0) + assert static.classify().type == "STATIC" + + short = SingleStabilityTracker() + short.add_value("a") + assert short.classify().type == "INSUFFICIENT_DATA" + + +class TestTimestampCollection: + def test_index_only_collects_nothing(self): + tracker = SingleStabilityTracker() + tracker.add_value("a", timestamp=1.0) + assert tracker.timestamps == [] + + def test_slope_index_only_collects_nothing(self): + tracker = SingleStabilityTracker( + classification=ClassificationMethods(index=False, slope_index=True) + ) + tracker.add_value("a", timestamp=1.0) + assert tracker.timestamps == [] + + def test_slope_time_collects_stamps(self): + tracker = SingleStabilityTracker( + classification=ClassificationMethods(index=False, slope_time=True) + ) + tracker.add_value("a", timestamp=1.0) + tracker.add_value("b", timestamp=2.0) + assert tracker.timestamps == [1.0, 2.0] + + def test_slope_time_without_stamps_falls_back_to_the_index_axis(self): + tracker = SingleStabilityTracker( + classification=ClassificationMethods(index=False, slope_time=True) + ) + feed(tracker, LATE_BUT_PASSING) # feed() passes no timestamps + assert tracker.timestamps == [] + assert "index axis" in tracker.classify().reason + + +class TestClassificationIsSwappable: + """The parent repo's notebooks get several verdicts from one ingest by + reassigning this between classify() calls.""" + + def test_reassignment_changes_the_verdict(self): + tracker = SingleStabilityTracker() + feed(tracker, LATE_BUT_PASSING) + assert tracker.classify().type == "STABLE" + tracker.classification = ClassificationMethods(index=True, slope_index=True) + assert tracker.classify().type == "UNSTABLE" + + def test_the_classifier_cannot_drift_from_the_tracker(self): + """One property over one owner -- not two attributes to keep in + sync.""" + tracker = SingleStabilityTracker() + tracker.classification = ClassificationMethods(index=False, time=True) + assert tracker.stability_classifier.classification is tracker.classification + assert tracker.classification.needs_timestamps is True + + def test_accepts_a_plain_dict(self): + """State and config both deliver the block as a dict.""" + tracker = SingleStabilityTracker(classification={"index": True, "time": True}) + assert tracker.classification == ClassificationMethods(index=True, time=True) + + def test_setter_accepts_a_plain_dict(self): + """The setter coerces too, not just the constructor.""" + tracker = SingleStabilityTracker() + tracker.classification = {"index": True, "slope_index": True, "slope_threshold": 0.2} + assert tracker.stability_classifier.classification == ClassificationMethods( + index=True, slope_index=True, slope_threshold=0.2 + ) + assert tracker.classification == ClassificationMethods( + index=True, slope_index=True, slope_threshold=0.2 + ) + + +class TestStatePersistence: + def test_round_trip_preserves_the_block(self): + tracker = SingleStabilityTracker(classification=ClassificationMethods( + index=True, slope_index=True, slope_threshold=-0.2, decision="majority", + )) + feed(tracker, EARLY) + restored = SingleStabilityTracker.from_state(tracker.to_state()) + assert restored.classification == tracker.classification + assert restored.classify().type == tracker.classify().type + + def test_state_is_msgpack_plain(self): + """to_state() must be msgpack-compatible: a pydantic model is not.""" + state = SingleStabilityTracker().to_state() + assert isinstance(state["classification"], dict) + assert set(state["classification"]) == { + "index", "time", "slope_index", "slope_time", "slope_threshold", "decision", + } + + def test_old_keys_are_gone_from_state(self): + state = SingleStabilityTracker().to_state() + for key in ("segmentation", "require_declining", "incline_threshold"): + assert key not in state + + def test_the_note_is_not_persisted(self): + tracker = SingleStabilityTracker() + feed(tracker, EARLY) + tracker.classify() + assert "_stability_note" not in tracker.to_state() + + def test_event_tracker_propagates_the_block(self): + event_tracker = EventStabilityTracker( + classification=ClassificationMethods(index=True, slope_index=True) + ) + event_tracker.add_data({"var1": "a"}) + event_tracker.add_data({"var1": "b"}) + assert event_tracker.get_data()["var1"].classification.enabled == ( + "index", "slope_index", + ) + + def test_event_tracker_dump_load_preserves_the_block(self): + event_tracker = EventStabilityTracker( + classification=ClassificationMethods(index=True, slope_index=True) + ) + event_tracker.add_data({"var1": "a"}) + event_tracker.add_data({"var1": "b"}) + restored = EventStabilityTracker.load( + event_tracker.dump(), + classification={"index": True, "slope_index": True}, + ) + assert restored.get_data()["var1"].classification.enabled == ( + "index", "slope_index", + ) + + +class TestLegacyStateMigration: + """Snapshots written before this change must keep loading. + + This is the one place the old names survive. + """ + + def legacy_state(self, **overrides): + state = SingleStabilityTracker().to_state() + del state["classification"] + state.update(overrides) + return state + + def test_no_stability_keys_at_all(self): + restored = SingleStabilityTracker.from_state(self.legacy_state()) + assert restored.classification == ClassificationMethods() + + def test_segmentation_count(self): + restored = SingleStabilityTracker.from_state( + self.legacy_state(segmentation="count") + ) + assert restored.classification.enabled == ("index",) + + def test_segmentation_time_means_time_alone(self): + restored = SingleStabilityTracker.from_state( + self.legacy_state(segmentation="time") + ) + assert restored.classification.enabled == ("time",) + + def test_segmentation_both(self): + restored = SingleStabilityTracker.from_state( + self.legacy_state(segmentation="both") + ) + assert restored.classification.enabled == ("index", "time") + + def test_require_declining_becomes_slope_index(self): + restored = SingleStabilityTracker.from_state( + self.legacy_state(segmentation="count", require_declining=True) + ) + assert restored.classification.enabled == ("index", "slope_index") + + def test_incline_threshold_becomes_slope_threshold(self): + restored = SingleStabilityTracker.from_state( + self.legacy_state( + segmentation="count", require_declining=True, incline_threshold=-0.25 + ) + ) + assert restored.classification.slope_threshold == -0.25 + + def test_legacy_states_always_decide_by_consensus(self): + restored = SingleStabilityTracker.from_state( + self.legacy_state(segmentation="both", require_declining=True) + ) + assert restored.classification.decision == "consensus" + assert restored.classification.enabled == ("index", "time", "slope_index") + + def test_legacy_state_without_add_value_keys_still_loads(self): + """Old enough to predate add_value_fn as well.""" + state = self.legacy_state(segmentation="both") + del state["add_value_fn"], state["detector_config"] + restored = SingleStabilityTracker.from_state(state) + assert restored.classification.enabled == ("index", "time") + + def test_fed_tracker_with_deleted_classification_key_classifies_correctly(self): + """End-to-end: legacy snapshot with real observations restores and + classifies correctly. This verifies the migration works not just for + config translation but for the full state round-trip.""" + tracker = SingleStabilityTracker() + feed(tracker, EARLY) + state = tracker.to_state() + del state["classification"] + restored = SingleStabilityTracker.from_state(state) + assert restored.classify().type == "STABLE" + + +class TestConfigWiring: + def test_block_reaches_per_variable_trackers(self): + # CharsetDetector's `config` default is a shared mutable instance, so + # pass explicit fresh configs (see test_time_dependent_stability.py). + # + # classification only shapes the configure-phase persistency: the + # trained persistency is read by _check_variable, which never calls + # classify(), so it never receives classification kwargs at all. + default = CharsetDetector(config=CharsetDetectorConfig()) + assert default.persistency.event_data_kwargs.get("classification") is None + + configured = CharsetDetector(config=CharsetDetectorConfig()) + configured.config.auto_config_params.classification = ClassificationMethods( + index=True, slope_index=True + ) + rebuilt = CharsetDetector(config=configured.config.to_dict(method_id="CharsetDetector")) + block = rebuilt.auto_conf_persistency.event_data_kwargs["classification"] + assert ClassificationMethods(**block).enabled == ("index", "slope_index") + + def test_default_block_is_not_forwarded(self): + """Forwarding the default would be noise; the tracker already has + it.""" + default = CharsetDetector(config=CharsetDetectorConfig()) + assert "classification" not in (default.auto_conf_persistency.event_data_kwargs or {}) + + def test_config_field_round_trips(self): + detector = CharsetDetector(config=CharsetDetectorConfig()) + detector.config.auto_config_params.classification = ClassificationMethods( + index=False, slope_time=True, decision="majority" + ) + restored = type(detector.config).from_dict( + detector.config.to_dict(method_id="CharsetDetector"), "CharsetDetector" + ) + assert restored.auto_config_params.classification.enabled == ("slope_time",) + assert restored.auto_config_params.classification.decision == "majority" + + def test_survives_auto_config(self): + """set_configuration() writes only config.events and flips + config.auto_config to False -- it never touches auto_config_params, so + operator settings survive because nothing overwrites them.""" + detector = CharsetDetector(config=CharsetDetectorConfig( + auto_config=True, + auto_config_params=VariableAutoConfigParams( + classification=ClassificationMethods(index=True, slope_index=True), + use_static_vars=False, + ), + )) + detector.set_configuration() + assert detector.config.auto_config_params.classification.enabled == ( + "index", "slope_index", + ) + assert detector.config.auto_config_params.use_static_vars is False + + def test_index_axis_methods_pull_in_no_timestamp_requirement(self): + detector = CharsetDetector(config=CharsetDetectorConfig()) + detector.config.auto_config_params.classification = ClassificationMethods( + index=True, slope_index=True + ) + rebuilt = CharsetDetector(config=detector.config.to_dict(method_id="CharsetDetector")) + assert "classification" not in (rebuilt.persistency.event_data_kwargs or {}) + + tracker = SingleStabilityTracker( + classification=ClassificationMethods(index=True, slope_index=True) + ) + tracker.add_value("a", timestamp=1.0) + assert tracker.timestamps == [] + + +class TestOldConfigFieldsAreRejected: + """Clean break: AutoConfigParams sets extra='forbid', so the old + spellings raise instead of being silently ignored.""" + + @pytest.mark.parametrize( + "kwargs", + [ + {"segmentation": "both"}, + {"require_declining": True}, + {"incline_threshold": -0.25}, + ], + ) + def test_rejected(self, kwargs): + with pytest.raises(ValidationError): + VariableAutoConfigParams(**kwargs) + + +def test_slope_threshold_reaches_the_classifier(): + """The threshold is configuration, not a constant buried in the + classifier.""" + from detectmatelibrary.detectors.new_value_detector import ( + NewValueDetector, + NewValueDetectorConfig, + ) + + detector = NewValueDetector( + name="NewValueDetector", + config=NewValueDetectorConfig( + auto_config_params=VariableAutoConfigParams( + classification=ClassificationMethods( + index=True, slope_index=True, slope_threshold=-0.25 + ), + ), + ), + ) + persistency = detector.auto_conf_persistency + tracker = persistency.event_data_class(**persistency.event_data_kwargs) + single = tracker.single_tracker_type() + assert single.classification.enabled == ("index", "slope_index") + assert single.stability_classifier.classification.slope_threshold == -0.25 + + +# Fixture reused from test_time_dependent_stability.py: 30 fresh values one +# second apart, then the same value repeated 10 times spread over ~17 minutes. +# index -> means [1.0, 1.0, 1.0, 0.0] -> UNSTABLE +# time -> means [0.938, 0.0, 0.0, 0.0] -> STABLE +# slopes -> -0.132 (index), -0.486 (time) -> both STABLE +# The one fixture that splits 3-1, which is what makes majority testable at +# four enabled methods. +CHURN_SERIES = [True] * 30 + [False] * 10 +CHURN_TIMES = [float(i) for i in range(30)] + [100.0 * (i + 1) for i in range(10)] + +UNIFORM_400 = [float(i) for i in range(400)] + + +def methods(**kwargs) -> ClassificationMethods: + """A method block with every default overridable, index included.""" + return ClassificationMethods(**{"index": False, **kwargs}) + + +class TestVerdicts: + def test_only_enabled_methods_appear_in_block_order(self): + clf = make_classifier(classification=methods(slope_time=True, index=True)) + assert list(clf.verdicts(RLEList(LATE_BUT_PASSING), UNIFORM_400)) == [ + "index", "slope_time", + ] + + def test_late_but_passing_splits_segments_from_slope(self): + clf = make_classifier( + classification=ClassificationMethods(index=True, slope_index=True) + ) + assert clf.verdicts(RLEList(LATE_BUT_PASSING)) == { + "index": True, "slope_index": False, + } + + def test_churn_fixture_splits_three_to_one(self): + clf = make_classifier(classification=ClassificationMethods( + index=True, time=True, slope_index=True, slope_time=True, + )) + assert clf.verdicts(RLEList(CHURN_SERIES), CHURN_TIMES) == { + "index": False, "time": True, "slope_index": True, "slope_time": True, + } + + def test_a_slope_method_can_stand_alone(self): + clf = make_classifier(classification=methods(slope_index=True)) + assert clf.verdicts(RLEList(EARLY)) == {"slope_index": True} + + def test_standing_alone_skips_the_segment_means(self): + """With no segment-threshold method enabled the means are never + computed, so the classifier must not report stale ones.""" + clf = make_classifier(classification=methods(slope_index=True)) + clf.verdicts(RLEList(EARLY)) + assert clf.get_last_segment_means() == [] + + def test_empty_series_is_stable_under_every_method(self): + clf = make_classifier(classification=ClassificationMethods( + index=True, time=True, slope_index=True, slope_time=True, + )) + assert clf.verdicts(RLEList([])) == { + "index": True, "time": True, "slope_index": True, "slope_time": True, + } + + def test_slope_threshold_is_read_from_the_block(self): + late = RLEList(LATE_BUT_PASSING) # centroid +0.028 + strict = make_classifier(classification=methods(slope_index=True)) + assert strict.verdicts(late) == {"slope_index": False} + loose = make_classifier( + classification=methods(slope_index=True, slope_threshold=0.1) + ) + assert loose.verdicts(late) == {"slope_index": True} + + +class TestDecisionRule: + """Consensus and majority agree at one and two enabled methods and diverge + at three and four. + + Ties resolve to UNSTABLE. + """ + + @pytest.mark.parametrize( + "verdicts, consensus, majority", + [ + ({"a": True}, True, True), + ({"a": False}, False, False), + ({"a": True, "b": True}, True, True), + ({"a": True, "b": False}, False, False), # 1-1 tie + ({"a": True, "b": True, "c": True}, True, True), + ({"a": True, "b": True, "c": False}, False, True), # 2/3 + ({"a": True, "b": False, "c": False}, False, False), + ({"a": True, "b": True, "c": True, "d": True}, True, True), + ({"a": True, "b": True, "c": True, "d": False}, False, True), # 3/4 + ({"a": True, "b": True, "c": False, "d": False}, False, False), # 2-2 tie + ], + ) + def test_table(self, verdicts, consensus, majority): + for rule, expected in (("consensus", consensus), ("majority", majority)): + clf = make_classifier( + classification=ClassificationMethods(decision=rule) + ) + assert clf.decide(verdicts) is expected + + def test_majority_rescues_the_three_to_one_fixture(self): + block = dict(index=True, time=True, slope_index=True, slope_time=True) + strict = make_classifier( + classification=ClassificationMethods(**block, decision="consensus") + ) + lenient = make_classifier( + classification=ClassificationMethods(**block, decision="majority") + ) + assert strict.is_stable(RLEList(CHURN_SERIES), CHURN_TIMES) is False + assert lenient.is_stable(RLEList(CHURN_SERIES), CHURN_TIMES) is True + + def test_two_two_tie_stays_unstable(self): + block = dict(index=True, time=True, slope_index=True, slope_time=True) + lenient = make_classifier( + classification=ClassificationMethods(**block, decision="majority") + ) + # index/time STABLE, both slopes UNSTABLE (centroid +0.028) + assert lenient.is_stable(RLEList(LATE_BUT_PASSING), UNIFORM_400) is False + + +class TestFallbackDoubleCount: + """A fallen-back method still casts its vote. + + Dropping it instead would make the enabled count vary per variable, + so majority would mean something different for each one. + """ + + def test_both_slopes_vote_the_same_way_without_stamps(self): + clf = make_classifier( + classification=methods(slope_index=True, slope_time=True) + ) + assert clf.verdicts(RLEList(LATE_BUT_PASSING), None) == { + "slope_index": False, "slope_time": False, + } + + def test_the_details_name_the_axis_actually_used(self): + clf = make_classifier( + classification=methods(slope_index=True, slope_time=True) + ) + clf.verdicts(RLEList(LATE_BUT_PASSING), None) + assert "index axis" in clf.get_last_details()["slope_time"] + + +class TestDetails: + def test_segment_method_reports_means_and_thresholds(self): + clf = make_classifier(classification=ClassificationMethods(index=True)) + clf.verdicts(RLEList(EARLY)) + detail = clf.get_last_details()["index"] + assert "index:" in detail and "STABLE" in detail + assert str(THRESHOLDS) in detail + + def test_slope_method_reports_the_centroid_and_threshold(self): + clf = make_classifier(classification=methods(slope_index=True)) + clf.verdicts(RLEList(LATE_BUT_PASSING)) + detail = clf.get_last_details()["slope_index"] + assert "slope_index:" in detail and "UNSTABLE" in detail + assert "-0.05" in detail and "index axis" in detail + + def test_details_cover_exactly_the_enabled_methods(self): + clf = make_classifier(classification=ClassificationMethods( + index=True, slope_time=True, + )) + verdicts = clf.verdicts(RLEList(EARLY), UNIFORM_400) + assert set(clf.get_last_details()) == set(verdicts) diff --git a/tests/test_persistency/test_time_dependent_stability.py b/tests/test_persistency/test_time_dependent_stability.py index e90038de..95ac392e 100644 --- a/tests/test_persistency/test_time_dependent_stability.py +++ b/tests/test_persistency/test_time_dependent_stability.py @@ -1,4 +1,4 @@ -"""Tests for the segmentation option of the stability trackers.""" +"""Tests for the time-axis classification methods of the stability trackers.""" import logging import math @@ -12,13 +12,26 @@ StabilityClassifier, SingleStabilityTracker, EventStabilityTracker, + ClassificationMethods, ) THRESHOLDS = [1.1, 0.3, 0.1, 0.01] # same defaults SingleStabilityTracker uses -def make_classifier() -> StabilityClassifier: - return StabilityClassifier(segment_thresholds=THRESHOLDS) +def make_index_classifier() -> StabilityClassifier: + """Equal-index cuts -- the reference every fallback test compares to.""" + return StabilityClassifier( + segment_thresholds=THRESHOLDS, + classification=ClassificationMethods(index=True), + ) + + +def make_time_classifier() -> StabilityClassifier: + """Equal-duration cuts -- what this file is about.""" + return StabilityClassifier( + segment_thresholds=THRESHOLDS, + classification=ClassificationMethods(index=False, time=True), + ) # Divergence fixture: 3 changes up front, then a quiet tail of 37. @@ -60,60 +73,60 @@ def make_classifier() -> StabilityClassifier: class TestClassifierTimeBoundaries: - def test_count_mode_is_stable_on_divergent_fixture(self): - clf = make_classifier() + def test_index_mode_is_stable_on_divergent_fixture(self): + clf = make_index_classifier() # count segments of 10: means [0.3, 0, 0, 0] -> all below thresholds assert clf.is_stable(RLEList(DIVERGENT_SERIES)) is True def test_time_mode_is_unstable_on_divergent_fixture(self): - clf = make_classifier() + clf = make_time_classifier() # time quarters put a lone change (mean 1.0) into segment 2 (thresh 0.3) assert clf.is_stable(RLEList(DIVERGENT_SERIES), timestamps=DIVERGENT_TIMES) is False - def test_uniform_timestamps_match_count_mode(self): + def test_uniform_timestamps_match_index_mode(self): # N divisible by n_segments -> boundaries coincide exactly series = [True, False, False, True, False, False, False, False] ts = [float(i) for i in range(8)] - clf_count = make_classifier() + clf_count = make_index_classifier() clf_count.is_stable(RLEList(series)) - clf_time = make_classifier() + clf_time = make_time_classifier() clf_time.is_stable(RLEList(series), timestamps=ts) assert clf_time.get_last_segment_means() == clf_count.get_last_segment_means() def test_plain_list_path_supports_timestamps(self): - clf = make_classifier() + clf = make_time_classifier() assert clf.is_stable(list(DIVERGENT_SERIES), timestamps=DIVERGENT_TIMES) is False - def test_zero_span_falls_back_to_count_mode(self): + def test_zero_span_falls_back_to_index_mode(self): series = [True, False, False, False, False, False, False, False] - clf_count = make_classifier() + clf_count = make_index_classifier() expected = clf_count.is_stable(RLEList(series)) - clf_time = make_classifier() + clf_time = make_time_classifier() result = clf_time.is_stable(RLEList(series), timestamps=[5.0] * 8) assert result == expected assert clf_time.get_last_segment_means() == clf_count.get_last_segment_means() - def test_length_mismatch_falls_back_to_count_mode(self): + def test_length_mismatch_falls_back_to_index_mode(self): series = [True, False, False, False, False, False, False, False] - clf_count = make_classifier() + clf_count = make_index_classifier() expected = clf_count.is_stable(RLEList(series)) - clf_time = make_classifier() + clf_time = make_time_classifier() assert clf_time.is_stable(RLEList(series), timestamps=[1.0, 2.0]) == expected - def test_none_timestamp_entry_falls_back_to_count_mode(self): + def test_none_timestamp_entry_falls_back_to_index_mode(self): series = [True, False, False, False, False, False, False, False] - clf_count = make_classifier() + clf_count = make_index_classifier() expected = clf_count.is_stable(RLEList(series)) - clf_time = make_classifier() + clf_time = make_time_classifier() ts = [0.0, 1.0, None, 3.0, 4.0, 5.0, 6.0, 7.0] assert clf_time.is_stable(RLEList(series), timestamps=ts) == expected assert clf_time.get_last_segment_means() == clf_count.get_last_segment_means() - def test_nan_timestamp_entry_falls_back_to_count_mode(self): + def test_nan_timestamp_entry_falls_back_to_index_mode(self): series = [True, False, False, False, False, False, False, False] - clf_count = make_classifier() + clf_count = make_index_classifier() expected = clf_count.is_stable(RLEList(series)) - clf_time = make_classifier() + clf_time = make_time_classifier() ts = [0.0, 1.0, float("nan"), 3.0, 4.0, 5.0, 6.0, 7.0] assert clf_time.is_stable(RLEList(series), timestamps=ts) == expected assert clf_time.get_last_segment_means() == clf_count.get_last_segment_means() @@ -127,19 +140,19 @@ def test_empty_time_segment_scores_zero(self): alone is lenient on a burst followed by silence. Count mode still sees the churn, so `both` catches it. """ - clf_count = make_classifier() + clf_count = make_index_classifier() assert clf_count.is_stable(RLEList(BURSTY_SERIES)) is False - clf_time = make_classifier() + clf_time = make_time_classifier() assert clf_time.is_stable(RLEList(BURSTY_SERIES), timestamps=BURSTY_TIMES) is True assert clf_time.get_last_segment_means() == [0.5, 0.0, 0.0, 0.0] def test_empty_time_segment_scores_zero_on_plain_list_path(self): - clf_time = make_classifier() + clf_time = make_time_classifier() assert clf_time.is_stable(list(BURSTY_SERIES), timestamps=BURSTY_TIMES) is True assert clf_time.get_last_segment_means() == [0.5, 0.0, 0.0, 0.0] - def test_out_of_order_timestamps_fall_back_to_count_mode(self): + def test_out_of_order_timestamps_fall_back_to_index_mode(self): """np.searchsorted requires sorted input. UNSORTED_TIMES is SORTED_TIMES with two entries transposed -- @@ -153,35 +166,35 @@ def test_out_of_order_timestamps_fall_back_to_count_mode(self): unsorted_times = list(sorted_times) unsorted_times[9], unsorted_times[30] = unsorted_times[30], unsorted_times[9] - clf_count = make_classifier() + clf_count = make_index_classifier() expected = clf_count.is_stable(RLEList(series)) - clf_time = make_classifier() + clf_time = make_time_classifier() assert clf_time.is_stable(RLEList(series), timestamps=unsorted_times) == expected assert clf_time.get_last_segment_means() == clf_count.get_last_segment_means() def test_sorted_timestamps_still_use_time_mode(self): """The monotonicity guard must not disable time mode for valid input.""" - clf = make_classifier() + clf = make_time_classifier() assert clf.is_stable(RLEList(DIVERGENT_SERIES), timestamps=DIVERGENT_TIMES) is False - count = make_classifier() - count.is_stable(RLEList(DIVERGENT_SERIES)) - assert clf.get_last_segment_means() != count.get_last_segment_means() + index = make_index_classifier() + index.is_stable(RLEList(DIVERGENT_SERIES)) + assert clf.get_last_segment_means() != index.get_last_segment_means() def test_equal_timestamps_are_not_treated_as_out_of_order(self): """Duplicate stamps are non-decreasing, so they stay in time mode.""" series = [True, False] * 20 times = [float(i // 2) for i in range(40)] # each stamp used twice - clf = make_classifier() + clf = make_time_classifier() assert clf.is_stable(RLEList(series), timestamps=times) is False assert not any(math.isnan(mean) for mean in clf.get_last_segment_means()) def test_list_and_rle_agree_on_ragged_length(self): """13 items over 4 segments: both paths must cut identically.""" series = [True, False, True] + [False] * 10 - clf_list = make_classifier() + clf_list = make_index_classifier() clf_list.is_stable(list(series)) - clf_rle = make_classifier() + clf_rle = make_index_classifier() clf_rle.is_stable(RLEList(series)) assert clf_list.get_last_segment_means() == clf_rle.get_last_segment_means() @@ -208,12 +221,12 @@ def feed_agreeing(tracker: SingleStabilityTracker) -> None: class TestSingleStabilityTrackerSegmentation: def test_timestamps_stored_only_when_enabled(self): - on = SingleStabilityTracker(segmentation="time") + on = SingleStabilityTracker(classification=ClassificationMethods(index=False, time=True)) on.add_value("a", timestamp=1.0) on.add_value("b", timestamp=2.0) assert on.timestamps == [1.0, 2.0] - off = SingleStabilityTracker() # default segmentation="count" + off = SingleStabilityTracker() # default classification=ClassificationMethods(index=True) off.add_value("a", timestamp=1.0) assert off.timestamps == [] @@ -222,13 +235,13 @@ def test_classification_diverges_between_modes(self): feed_divergent(count_mode) assert count_mode.classify().type == "STABLE" - time_mode = SingleStabilityTracker(segmentation="time") + time_mode = SingleStabilityTracker(classification=ClassificationMethods(index=False, time=True)) feed_divergent(time_mode) assert time_mode.classify().type == "UNSTABLE" - def test_missing_timestamps_fall_back_to_count_mode(self): - # time segmentation on, but values arrive without timestamps - tracker = SingleStabilityTracker(segmentation="time") + def test_missing_timestamps_fall_back_to_index_mode(self): + # time classification on, but values arrive without timestamps + tracker = SingleStabilityTracker(classification=ClassificationMethods(index=False, time=True)) for value in ["a", "b", "c"] + ["c"] * 37: tracker.add_value(value) reference = SingleStabilityTracker() @@ -237,10 +250,10 @@ def test_missing_timestamps_fall_back_to_count_mode(self): assert tracker.classify().type == reference.classify().type def test_round_trip_preserves_time_state(self): - tracker = SingleStabilityTracker(segmentation="time") + tracker = SingleStabilityTracker(classification=ClassificationMethods(index=False, time=True)) feed_divergent(tracker) restored = SingleStabilityTracker.from_state(tracker.to_state()) - assert restored.segmentation == "time" + assert restored.classification == ClassificationMethods(index=False, time=True) assert restored.timestamps == tracker.timestamps assert restored.classify().type == "UNSTABLE" @@ -248,10 +261,10 @@ def test_legacy_state_without_time_keys_defaults_off(self): tracker = SingleStabilityTracker() tracker.add_value("hello") state = tracker.to_state() - state.pop("segmentation", None) # simulate pre-flag snapshot + state.pop("classification", None) # simulate pre-flag snapshot state.pop("timestamps", None) restored = SingleStabilityTracker.from_state(state) - assert restored.segmentation == "count" + assert restored.classification == ClassificationMethods(index=True) assert restored.timestamps == [] def test_legacy_state_without_add_value_keys_loads(self): @@ -269,35 +282,38 @@ def test_legacy_state_without_add_value_keys_loads(self): assert restored.detector_config is None assert restored.unique_set == {"hello"} - def test_bursty_series_needs_the_count_pass(self): + def test_bursty_series_needs_the_index_pass(self): """A burst followed by silence is time-STABLE (empty quarters score - 0.0) but count-UNSTABLE, so only `both` refuses to hand it to auto- + 0.0) but index-UNSTABLE, so only `both` refuses to hand it to auto- config variable selection as a monitoring candidate.""" trackers = { - mode: SingleStabilityTracker(segmentation=mode) - for mode in ("count", "time", "both") + "index": SingleStabilityTracker(classification=ClassificationMethods(index=True)), + "time": SingleStabilityTracker(classification=ClassificationMethods(index=False, time=True)), + "both": SingleStabilityTracker(classification=ClassificationMethods(index=True, time=True)), } for value, ts in zip(BURSTY_VALUES, BURSTY_TIMES): for tracker in trackers.values(): tracker.add_value(value, timestamp=ts) - assert trackers["count"].classify().type == "UNSTABLE" + assert trackers["index"].classify().type == "UNSTABLE" assert trackers["time"].classify().type == "STABLE" assert trackers["both"].classify().type == "UNSTABLE" class TestSegmentationPlumbing: def test_event_tracker_propagates_flag_and_timestamp(self): - event_tracker = EventStabilityTracker(segmentation="time") + event_tracker = EventStabilityTracker( + classification=ClassificationMethods(index=False, time=True) + ) event_tracker.add_data({"var1": "a"}, timestamp=1.0) event_tracker.add_data({"var1": "b"}, timestamp=2.0) single = event_tracker.get_data()["var1"] - assert single.segmentation == "time" + assert single.classification == ClassificationMethods(index=False, time=True) assert single.timestamps == [1.0, 2.0] def test_ingest_event_forwards_timestamp(self): storage = EventPersistency( EventStabilityTracker, - event_data_kwargs={"segmentation": "time"}, + event_data_kwargs={"classification": {"index": False, "time": True}}, ) storage.ingest_event(1, "tpl <*>", variables=["a"], timestamp=10.0) storage.ingest_event(1, "tpl <*>", variables=["b"], timestamp=20.0) @@ -312,12 +328,16 @@ def test_ingest_event_without_timestamp_still_works(self): assert single.timestamps == [] def test_event_tracker_dump_load_preserves_timestamps(self): - event_tracker = EventStabilityTracker(segmentation="time") + event_tracker = EventStabilityTracker( + classification=ClassificationMethods(index=False, time=True) + ) event_tracker.add_data({"var1": "a"}, timestamp=1.0) event_tracker.add_data({"var1": "b"}, timestamp=2.0) - restored = EventStabilityTracker.load(event_tracker.dump(), segmentation="time") + restored = EventStabilityTracker.load( + event_tracker.dump(), classification={"index": False, "time": True} + ) single = restored.get_data()["var1"] - assert single.segmentation == "time" + assert single.classification == ClassificationMethods(index=False, time=True) assert single.timestamps == [1.0, 2.0] @@ -327,7 +347,8 @@ class TestSegmentationWithDetectorAddValueFn: def test_detector_backed_tracker_records_timestamps(self): tracker = SingleStabilityTracker( - add_value_fn="CharsetDetector", segmentation="time" + add_value_fn="CharsetDetector", + classification=ClassificationMethods(index=False, time=True), ) tracker.add_value("ab", timestamp=1.0) tracker.add_value("cd", timestamp=2.0) @@ -339,7 +360,8 @@ def test_value_range_skipped_value_keeps_alignment(self): """ValueRangeDetector returns early on non-numeric input without appending to change_series; timestamps must not drift.""" tracker = SingleStabilityTracker( - add_value_fn="ValueRangeDetector", segmentation="time" + add_value_fn="ValueRangeDetector", + classification=ClassificationMethods(index=False, time=True), ) tracker.add_value("1", timestamp=1.0) tracker.add_value("not-a-number", timestamp=2.0) # detector records nothing @@ -349,12 +371,15 @@ def test_value_range_skipped_value_keeps_alignment(self): def test_event_tracker_detector_backed_round_trip(self): event_tracker = EventStabilityTracker( - add_value_fn="CharsetDetector", segmentation="time" + add_value_fn="CharsetDetector", + classification=ClassificationMethods(index=False, time=True), ) event_tracker.add_data({"var1": "ab"}, timestamp=1.0) event_tracker.add_data({"var1": "cd"}, timestamp=2.0) restored = EventStabilityTracker.load( - event_tracker.dump(), add_value_fn="CharsetDetector", segmentation="time" + event_tracker.dump(), + add_value_fn="CharsetDetector", + classification={"index": False, "time": True}, ) single = restored.get_data()["var1"] assert single.unique_set == {"a", "b", "c", "d"} @@ -380,7 +405,7 @@ class TestTimestampResolution: # explicitly rather than bare CharsetDetector(). CharsetDetector.__init__'s # `config` default argument is a single shared CharsetDetectorConfig() # instance (pre-existing mutable-default-arg pitfall, see - # TestSegmentationConfigWiring.test_flag_reaches_per_variable_trackers), and + # TestClassificationConfigWiring.test_flag_reaches_per_variable_trackers), and # several tests here mutate `detector.config.*` in place -- writing through # to that shared instance and leaking state into any other bare-constructed # CharsetDetector for the rest of the process. Passing a fresh config keeps @@ -391,14 +416,14 @@ def test_returns_none_when_not_configured(self): def test_parses_iso_timestamp(self): detector = CharsetDetector(config=CharsetDetectorConfig()) - detector.config.auto_config_params.segmentation = "time" + detector.config.auto_config_params.classification = ClassificationMethods(index=False, time=True) detector.config.auto_config_params.timestamp_variable = "ts" assert detector._timestamp(_parser_record("2026-08-04 10:00:00")) == 1785837600.0 def test_parses_explicit_format(self): """HDFS loghub style, absent from COMMON_TIME_FORMATS.""" detector = CharsetDetector(config=CharsetDetectorConfig()) - detector.config.auto_config_params.segmentation = "time" + detector.config.auto_config_params.classification = ClassificationMethods(index=False, time=True) detector.config.auto_config_params.timestamp_variable = "ts" detector.config.auto_config_params.timestamp_format = "%y%m%d %H%M%S" first = detector._timestamp(_parser_record("081109 203615")) @@ -407,7 +432,7 @@ def test_parses_explicit_format(self): def test_unparseable_warns_once_and_falls_back(self, caplog): detector = CharsetDetector(config=CharsetDetectorConfig()) - detector.config.auto_config_params.segmentation = "time" + detector.config.auto_config_params.classification = ClassificationMethods(index=False, time=True) detector.config.auto_config_params.timestamp_variable = "ts" with caplog.at_level(logging.WARNING): assert detector._timestamp(_parser_record("not-a-time")) is None @@ -416,11 +441,13 @@ def test_unparseable_warns_once_and_falls_back(self, caplog): assert len(warnings) == 1 def test_unset_timestamp_variable_warns_once_and_falls_back(self, caplog): - """Segmentation="time" without timestamp_variable is an operator error, - not an opt-out: it must be distinguishable from a working time- - dependent run, and must not flood the log.""" + """A time-axis classification method without timestamp_variable is an + operator error, not an opt-out: it must be distinguishable from a + working time-dependent run, and must not flood the log.""" detector = CharsetDetector(config=CharsetDetectorConfig()) - detector.config.auto_config_params.segmentation = "time" # timestamp_variable left unset + detector.config.auto_config_params.classification = ClassificationMethods( + index=False, time=True + ) # timestamp_variable left unset with caplog.at_level(logging.WARNING): assert detector._timestamp(_parser_record("2026-08-04 10:00:00")) is None assert detector._timestamp(_parser_record("2026-08-04 10:00:01")) is None @@ -437,46 +464,51 @@ def test_flag_off_stays_silent(self, caplog): def test_missing_variable_warns_and_falls_back(self, caplog): detector = CharsetDetector(config=CharsetDetectorConfig()) - detector.config.auto_config_params.segmentation = "time" + detector.config.auto_config_params.classification = ClassificationMethods(index=False, time=True) detector.config.auto_config_params.timestamp_variable = "absent" with caplog.at_level(logging.WARNING): assert detector._timestamp(_parser_record("2026-08-04 10:00:00")) is None assert any("timestamp_variable" in r.message for r in caplog.records) -class TestSegmentationConfigWiring: +class TestClassificationConfigWiring: def test_flag_reaches_per_variable_trackers(self): # CharsetDetector's `config` parameter default is a single shared # CharsetDetectorConfig() instance (pre-existing mutable-default-arg - # pitfall, unrelated to segmentation). Other tests in this + # pitfall, unrelated to classification). Other tests in this # module mutate `detector.config.*` in place on a bare # CharsetDetector(), so we pass explicit fresh configs here to stay # isolated from that. - # segmentation only shapes the configure-phase persistency: the + # classification only shapes the configure-phase persistency: the # trained persistency is read by _check_variable, which never calls - # classify(), so it never receives stability kwargs at all. + # classify(), so it never receives classification kwargs at all. detector = CharsetDetector(config=CharsetDetectorConfig()) - assert detector.persistency.event_data_kwargs.get("segmentation") is None + assert detector.persistency.event_data_kwargs.get("classification") is None configured = CharsetDetector(config=CharsetDetectorConfig()) - configured.config.auto_config_params.segmentation = "time" + configured.config.auto_config_params.classification = ClassificationMethods( + index=False, time=True + ) rebuilt = CharsetDetector(config=configured.config.to_dict(method_id="CharsetDetector")) - assert rebuilt.auto_conf_persistency.event_data_kwargs["segmentation"] == "time" + block = rebuilt.auto_conf_persistency.event_data_kwargs["classification"] + assert ClassificationMethods(**block).enabled == ("time",) def test_config_fields_round_trip(self): detector = CharsetDetector(config=CharsetDetectorConfig()) - detector.config.auto_config_params.segmentation = "time" + detector.config.auto_config_params.classification = ClassificationMethods( + index=False, time=True + ) detector.config.auto_config_params.timestamp_variable = "ts" detector.config.auto_config_params.timestamp_format = "%y%m%d %H%M%S" restored = type(detector.config).from_dict( detector.config.to_dict(method_id="CharsetDetector"), "CharsetDetector" ) - assert restored.auto_config_params.segmentation == "time" + assert restored.auto_config_params.classification.enabled == ("time",) assert restored.auto_config_params.timestamp_variable == "ts" assert restored.auto_config_params.timestamp_format == "%y%m%d %H%M%S" def test_configure_populates_timestamps_end_to_end(self): - """Segmentation settings reach the configure-phase persistency's + """The classification block reaches the configure-phase persistency's trackers end-to-end. This used to run through train()/.persistency, but the trained @@ -486,7 +518,7 @@ def test_configure_populates_timestamps_end_to_end(self): """ cfg = CharsetDetectorConfig( auto_config_params=VariableAutoConfigParams( - segmentation="time", + classification=ClassificationMethods(index=False, time=True), timestamp_variable="ts", timestamp_format="%y%m%d %H%M%S", ), @@ -495,11 +527,11 @@ def test_configure_populates_timestamps_end_to_end(self): detector.configure(_parser_record("081109 203615")) detector.configure(_parser_record("081109 203645")) tracker = detector.auto_conf_persistency.get_events_data()[1].get_data()["var_0"] - assert tracker.segmentation == "time" + assert tracker.classification.enabled == ("time",) assert len(tracker.timestamps) == len(tracker.change_series) == 2 assert tracker.timestamps[1] - tracker.timestamps[0] == 30.0 - def test_segmentation_fields_survive_auto_config_set_configuration(self): + def test_classification_fields_survive_auto_config_set_configuration(self): """set_configuration() writes only self.config.events and then flips auto_config to False -- it never rebuilds or reassigns self.config wholesale, so auto_config_params (like every other operator-set field, @@ -511,7 +543,7 @@ def test_segmentation_fields_survive_auto_config_set_configuration(self): """ cfg = CharsetDetectorConfig( auto_config_params=VariableAutoConfigParams( - segmentation="time", + classification=ClassificationMethods(index=False, time=True), timestamp_variable="ts", timestamp_format="%y%m%d %H%M%S", ), @@ -523,24 +555,24 @@ def test_segmentation_fields_survive_auto_config_set_configuration(self): detector.configure(_parser_record("081109 203615")) detector.set_configuration() - assert detector.config.auto_config_params.segmentation == "time" + assert detector.config.auto_config_params.classification.enabled == ("time",) assert detector.config.auto_config_params.timestamp_variable == "ts" assert detector.config.auto_config_params.timestamp_format == "%y%m%d %H%M%S" -class TestBothSegmentation: - """`both` is STABLE only when count and time segmentation agree.""" +class TestIndexAndTimeTogether: + """Index + time is STABLE only when both methods agree.""" def test_rejects_when_only_time_is_unstable(self): count_mode = SingleStabilityTracker() feed_divergent(count_mode) assert count_mode.classify().type == "STABLE" - time_mode = SingleStabilityTracker(segmentation="time") + time_mode = SingleStabilityTracker(classification=ClassificationMethods(index=False, time=True)) feed_divergent(time_mode) assert time_mode.classify().type == "UNSTABLE" - both_mode = SingleStabilityTracker(segmentation="both") + both_mode = SingleStabilityTracker(classification=ClassificationMethods(index=True, time=True)) feed_divergent(both_mode) assert both_mode.classify().type == "UNSTABLE" @@ -550,18 +582,23 @@ def test_rejects_when_only_count_is_unstable(self): feed_churn(count_mode) assert count_mode.classify().type == "UNSTABLE" - time_mode = SingleStabilityTracker(segmentation="time") + time_mode = SingleStabilityTracker(classification=ClassificationMethods(index=False, time=True)) feed_churn(time_mode) assert time_mode.classify().type == "STABLE" - both_mode = SingleStabilityTracker(segmentation="both") + both_mode = SingleStabilityTracker(classification=ClassificationMethods(index=True, time=True)) feed_churn(both_mode) assert both_mode.classify().type == "UNSTABLE" def test_accepts_when_both_agree(self): """`both` must not be vacuously strict.""" - for mode in ("count", "time", "both"): - tracker = SingleStabilityTracker(segmentation=mode) + modes = { + "index": ClassificationMethods(index=True), + "time": ClassificationMethods(index=False, time=True), + "both": ClassificationMethods(index=True, time=True), + } + for mode, methods in modes.items(): + tracker = SingleStabilityTracker(classification=methods) feed_agreeing(tracker) assert tracker.classify().type == "STABLE", mode @@ -574,7 +611,7 @@ def test_without_timestamps_matches_count_mode(self): also call an all-STABLE fixture STABLE here; only a fixture that is UNSTABLE when fed without timestamps can tell the two apart. """ - both_mode = SingleStabilityTracker(segmentation="both") + both_mode = SingleStabilityTracker(classification=ClassificationMethods(index=True, time=True)) reference = SingleStabilityTracker() for value in CHURN_VALUES: both_mode.add_value(value) # no timestamp argument @@ -594,44 +631,49 @@ def test_reason_reports_both_mean_vectors(self): also yields UNSTABLE, so this exercises the note on the branch finding 1 wires it into. """ - tracker = SingleStabilityTracker(segmentation="both") + tracker = SingleStabilityTracker(classification=ClassificationMethods(index=True, time=True)) feed_churn(tracker) classification = tracker.classify() assert classification.type == "UNSTABLE" reason = classification.reason - assert "count [1.0, 1.0, 1.0, 0.0]" in reason - assert "time [0.9375, 0.0, 0.0, 0.0]" in reason + assert "index: means [1.0, 1.0, 1.0, 0.0]" in reason + assert "time: means [0.9375, 0.0, 0.0, 0.0]" in reason def test_round_trip_preserves_both_mode(self): - tracker = SingleStabilityTracker(segmentation="both") + tracker = SingleStabilityTracker(classification=ClassificationMethods(index=True, time=True)) feed_churn(tracker) restored = SingleStabilityTracker.from_state(tracker.to_state()) - assert restored.segmentation == "both" + assert restored.classification == ClassificationMethods(index=True, time=True) assert restored.timestamps == tracker.timestamps assert restored.classify().type == "UNSTABLE" def test_stability_note_is_not_persisted(self): - tracker = SingleStabilityTracker(segmentation="both") + tracker = SingleStabilityTracker(classification=ClassificationMethods(index=True, time=True)) feed_agreeing(tracker) tracker.classify() assert "_stability_note" not in tracker.to_state() def test_config_accepts_both_and_reaches_trackers(self): - # segmentation only shapes the configure-phase persistency (see - # TestSegmentationConfigWiring.test_flag_reaches_per_variable_trackers). + # classification only shapes the configure-phase persistency (see + # TestClassificationConfigWiring.test_flag_reaches_per_variable_trackers). configured = CharsetDetector(config=CharsetDetectorConfig()) - configured.config.auto_config_params.segmentation = "both" + configured.config.auto_config_params.classification = ClassificationMethods( + index=True, time=True + ) rebuilt = CharsetDetector( config=configured.config.to_dict(method_id="CharsetDetector") ) - assert rebuilt.auto_conf_persistency.event_data_kwargs["segmentation"] == "both" + block = rebuilt.auto_conf_persistency.event_data_kwargs["classification"] + assert ClassificationMethods(**block).enabled == ("index", "time") def test_event_tracker_propagates_both(self): - event_tracker = EventStabilityTracker(segmentation="both") + event_tracker = EventStabilityTracker( + classification=ClassificationMethods(index=True, time=True) + ) event_tracker.add_data({"var1": "a"}, timestamp=1.0) event_tracker.add_data({"var1": "b"}, timestamp=2.0) single = event_tracker.get_data()["var1"] - assert single.segmentation == "both" + assert single.classification == ClassificationMethods(index=True, time=True) assert single.timestamps == [1.0, 2.0] @@ -651,7 +693,8 @@ def test_train_path_records_no_timestamps(): name="NewValueDetector", config=NewValueDetectorConfig( auto_config_params=VariableAutoConfigParams( - segmentation="time", timestamp_variable="ts", + classification=ClassificationMethods(index=False, time=True), + timestamp_variable="ts", ), ), ) @@ -665,12 +708,12 @@ def test_train_path_records_no_timestamps(): trained = detector.persistency.get_events_data()[1].get_data() assert trained, "expected the configure phase to select at least one variable" for tracker in trained.values(): - assert tracker.segmentation == "count" + assert tracker.classification.enabled == ("index",) assert tracker.timestamps == [] # the configure-phase persistency still gets them configured = detector.auto_conf_persistency.get_events_data()[1].get_data() - assert any(t.segmentation == "time" for t in configured.values()) + assert any(t.classification.enabled == ("time",) for t in configured.values()) def test_persisted_state_omits_auto_config_params(): @@ -682,7 +725,8 @@ def test_persisted_state_omits_auto_config_params(): """ cfg = CharsetDetectorConfig( auto_config_params=VariableAutoConfigParams( - segmentation="time", timestamp_variable="ts", + classification=ClassificationMethods(index=False, time=True), + timestamp_variable="ts", ), ) detector = CharsetDetector(config=cfg, name="CharsetDetector") From bd82ffeda56c5252681d045f8efe2acaffe0105a Mon Sep 17 00:00:00 2001 From: viktorbeck98 Date: Tue, 1 Sep 2026 20:20:49 +0200 Subject: [PATCH 31/33] remove legacy comments --- docs/detectors.md | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/docs/detectors.md b/docs/detectors.md index 85fa3108..67f69b71 100644 --- a/docs/detectors.md +++ b/docs/detectors.md @@ -321,27 +321,6 @@ It is not a harmless no-op: classification decides `INSUFFICIENT_DATA`, `STATIC` and `RANDOM` before any method is consulted, so a method-less config would silently classify every remaining variable `STABLE`. -#### Breaking change: the old fields are gone - -`segmentation`, `require_declining` and `incline_threshold` no longer exist. -`VariableAutoConfigParams` sets `extra="forbid"`, so a config still using the old -spellings now raises `ValidationError` at load time rather than being silently -ignored. Two familiar configurations translate as follows: - -| old | new | -|---|---| -| `segmentation: both` | `index: True, time: True, decision: consensus` | -| `segmentation: count, require_declining: True` | `index: True, slope_index: True, decision: consensus` | - -Calling `StabilityClassifier` directly has a related gap the config layer does not: -previously, with no method selection at all, passing `timestamps` to `is_stable` -was self-sufficient — stamps present meant equal-duration cuts, always. Now -`is_stable(series, timestamps=ts)` honours `timestamps` only when a time-axis -method (`time` or `slope_time`) is enabled. A caller who kept an existing -`is_stable(series, timestamps=ts)` call without also turning on `time` or -`slope_time` now gets index-axis classification silently, with no error and no -warning. - #### Fields All of these live in the detector's `auto_config_params` block. From 27775e0f3f61af46b4d9482ebc4b6da5ca590c2c Mon Sep 17 00:00:00 2001 From: viktorbeck98 Date: Tue, 1 Sep 2026 20:37:21 +0200 Subject: [PATCH 32/33] Move auto_config_params to BasicConfig, beside auto_config Review feedback on #275: the block was declared only on CoreDetectorConfig, yet `auto_config` and `Component.configure()` are both declared on the shared base, and `BasicConfig.to_dict` and `ConfigMethods.process` already handled `auto_config_params` generically for every component type. The base knew about a subclass-only field. `AutoConfigParams` moves to `common/_config` (declaring it on `BasicConfig` from `detector.py` would be an import cycle) and is re-exported from `common.detector` so existing subclass imports keep working. The redundant-alias form satisfies mypy's no_implicit_reexport. Parsers and alert aggregators inherit the block empty; `to_dict` omits a block at its default, so no existing YAML changes. `get_config()` is a `model_dump()`, so it now reports `auto_config_params: {}` on every config -- hence the test_core default_args update. Co-Authored-By: Claude Opus 5 --- docs/detectors.md | 10 +++ .../common/_config/__init__.py | 21 +++++ src/detectmatelibrary/common/detector.py | 16 +--- tests/test_common/test_auto_config_params.py | 77 ++++++++++--------- tests/test_common/test_core.py | 1 + 5 files changed, 76 insertions(+), 49 deletions(-) diff --git a/docs/detectors.md b/docs/detectors.md index 67f69b71..e369be35 100644 --- a/docs/detectors.md +++ b/docs/detectors.md @@ -240,6 +240,16 @@ for `EventSequenceDetector`, into `fixed_window_size`) and then sets `auto_config` to `False`. It never modifies either input block, so a config can be rerun with `auto_config: False` and reproduce the same detector. +Both `auto_config` and `Component.configure()` are declared on the shared base, +so `auto_config_params` is declared there too — on `BasicConfig`, beside +`auto_config` — rather than on the detector config alone. Detectors are the only +component type with a real configure phase today, so they are the only ones that +narrow the block with fields; parsers and alert aggregators inherit it empty, and +an empty block is omitted from the serialized config, so their YAML is unaffected. +A component type that grows a configure phase later subclasses `AutoConfigParams` +and overrides the field, exactly as the variable, combo and sequence detector +families do. + ### Stability classification (optional) diff --git a/src/detectmatelibrary/common/_config/__init__.py b/src/detectmatelibrary/common/_config/__init__.py index 88187e33..f7b7d60e 100644 --- a/src/detectmatelibrary/common/_config/__init__.py +++ b/src/detectmatelibrary/common/_config/__init__.py @@ -7,6 +7,7 @@ "generate_events_config", "EventsConfig", "BasicConfig", + "AutoConfigParams", ] from pydantic import BaseModel, ConfigDict @@ -24,6 +25,25 @@ def random_id(length: int = 10) -> str: return "".join(str(choice(characters)) for _ in range(length)) +class AutoConfigParams(BaseModel): + """Inputs to the auto-configuration (configure) phase. + + Empty here: no component has configure-phase inputs by default. Subclasses + add the fields their own configure phase reads. Kept apart from the + operational `params` block so the phase a setting belongs to is visible in + the YAML, not just in the code that reads it. + + Lives beside `auto_config` on `BasicConfig` rather than on any one + component type: `auto_config` and `Component.configure()` are both declared + at the base, so the block that feeds that phase belongs there too. Empty by + default, and `to_dict` omits it while it stays at its default, so a + component whose configure phase takes no inputs serializes exactly as it + did before this block existed. + """ + + model_config = ConfigDict(extra="forbid") + + class BasicConfig(BaseModel): """Base configuration class with helper methods.""" @@ -33,6 +53,7 @@ class BasicConfig(BaseModel): component_type: str = "default_type" auto_config: bool = False + auto_config_params: AutoConfigParams = AutoConfigParams() def get_config(self) -> Dict[str, Any]: """Return the configuration as a dictionary.""" diff --git a/src/detectmatelibrary/common/detector.py b/src/detectmatelibrary/common/detector.py index c040268b..04735c4e 100644 --- a/src/detectmatelibrary/common/detector.py +++ b/src/detectmatelibrary/common/detector.py @@ -1,4 +1,6 @@ from detectmatelibrary.common._config._formats import EventsConfig, _EventInstance +# Re-exported: subclasses spell it `from detectmatelibrary.common.detector import AutoConfigParams`. +from detectmatelibrary.common._config import AutoConfigParams as AutoConfigParams # noqa: F401 from detectmatelibrary.common.core import CoreComponent, CoreConfig from detectmatelibrary.utils.data_buffer import ArgsBuffer, BufferMode @@ -8,7 +10,6 @@ from detectmatelibrary.schemas import ParserSchema, DetectorSchema -from pydantic import BaseModel, ConfigDict from typing_extensions import override from typing import Dict, List, Optional, Any, cast @@ -36,25 +37,12 @@ def _extract_logIDs( return [str(i["logID"]) for i in input_] -class AutoConfigParams(BaseModel): - """Inputs to the auto-configuration (configure) phase. - - Empty here: the core detector has no configure-phase inputs. Subclasses - add the fields their detector's configure phase reads. Kept apart from the - operational `params` block so the phase a setting belongs to is visible in - the YAML, not just in the code that reads it. - """ - - model_config = ConfigDict(extra="forbid") - - class CoreDetectorConfig(CoreConfig): component_type: str = "detectors" method_type: str = "core_detector" parser: str = "" auto_config: bool = True - auto_config_params: AutoConfigParams = AutoConfigParams() events: EventsConfig | dict[str, Any] = {} global_instances: Dict[str, _EventInstance] = {} persist: PersistConfig | None = None diff --git a/tests/test_common/test_auto_config_params.py b/tests/test_common/test_auto_config_params.py index 0d542c9e..e02a0d6e 100644 --- a/tests/test_common/test_auto_config_params.py +++ b/tests/test_common/test_auto_config_params.py @@ -5,8 +5,13 @@ import pytest from pydantic import ValidationError +from detectmatelibrary.common._config import AutoConfigParams, BasicConfig from detectmatelibrary.common._config._compile import MissingParamsWarning -from detectmatelibrary.common.detector import AutoConfigParams, CoreDetectorConfig +from detectmatelibrary.common.alert_aggregator import CoreAlertAggregatorConfig +from detectmatelibrary.common.detector import CoreDetectorConfig +from detectmatelibrary.common.parser import CoreParserConfig + +CONFIG_CLASSES = (CoreParserConfig, CoreDetectorConfig, CoreAlertAggregatorConfig) class _Params(AutoConfigParams): @@ -22,61 +27,55 @@ def _wrap(entry: dict) -> dict: return {"detectors": {"TestDetector": entry}} -def test_block_is_parsed_into_the_nested_model(): - cfg = _Config.from_dict( - _wrap({ - "method_type": "test_detector", - "auto_config": True, - "auto_config_params": {"knob": 7}, - }), +def _from_dict(**entry: object) -> _Config: + return _Config.from_dict( + _wrap({"method_type": "test_detector", "auto_config": True, **entry}), "TestDetector", ) - assert cfg.auto_config_params.knob == 7 def test_block_round_trips(): - cfg = _Config.from_dict( - _wrap({ - "method_type": "test_detector", - "auto_config": True, - "auto_config_params": {"knob": 7}, - }), - "TestDetector", - ) + """YAML -> pydantic -> YAML, staying out of the operational params + block.""" + cfg = _from_dict(auto_config_params={"knob": 7}) + assert cfg.auto_config_params.knob == 7 + dumped = cfg.to_dict(method_id="TestDetector")["detectors"]["TestDetector"] assert dumped["auto_config_params"] == {"knob": 7} assert "knob" not in dumped.get("params", {}) def test_default_block_is_not_emitted(): - """A config that never touches auto-config serializes exactly as before.""" + """A config that never touches auto-config serializes exactly as before. + + Also covers the component types that inherit the block empty: adding it + to the shared base must not add a key to anyone's YAML. + """ dumped = _Config().to_dict(method_id="TestDetector")["detectors"]["TestDetector"] assert "auto_config_params" not in dumped + for config_cls in CONFIG_CLASSES: + config = config_cls() + dumped = config.to_dict(method_id="M")[config.component_type]["M"] + assert "auto_config_params" not in dumped + def test_unknown_key_in_block_is_rejected(): + """Extra='forbid' reaches every component type, including those that + declare no fields in the block.""" with pytest.raises(ValidationError): - _Config.from_dict( - _wrap({ - "method_type": "test_detector", - "auto_config": True, - "auto_config_params": {"nope": 1}, - }), - "TestDetector", - ) + _from_dict(auto_config_params={"nope": 1}) + + for config_cls in CONFIG_CLASSES: + with pytest.raises(ValidationError): + config_cls(auto_config_params={"nope": 1}) def test_auto_param_under_params_is_rejected(): - """The clean break: the old flat spelling is an error, not a silent no-op.""" + """The clean break: the old flat spelling is an error, not a silent no- + op.""" with pytest.raises(ValidationError): - _Config.from_dict( - _wrap({ - "method_type": "test_detector", - "auto_config": True, - "params": {"knob": 7}, - }), - "TestDetector", - ) + _from_dict(params={"knob": 7}) def test_block_alone_counts_as_data(): @@ -92,3 +91,11 @@ def test_block_alone_counts_as_data(): }), "TestDetector", ) + + +def test_block_is_declared_on_the_shared_base(): + """Not detector-only: the block sits beside `auto_config` on BasicConfig, + since `auto_config` and `Component.configure()` are both declared there.""" + assert "auto_config_params" in BasicConfig.model_fields + for config_cls in CONFIG_CLASSES: + assert isinstance(config_cls().auto_config_params, AutoConfigParams) diff --git a/tests/test_common/test_core.py b/tests/test_common/test_core.py index e5822c34..2ee3c0a2 100644 --- a/tests/test_common/test_core.py +++ b/tests/test_common/test_core.py @@ -28,6 +28,7 @@ class MockConfigWithTraining(CoreConfig): "method_type": "default_method_type", "component_type": "default_type", "auto_config": False, + "auto_config_params": {}, "start_id": 10, "data_use_training": None, "data_use_configure": None, From b9b4b8a79251f5df8406cba3bf299f2b0d52791f Mon Sep 17 00:00:00 2001 From: ipmach Date: Wed, 2 Sep 2026 09:08:23 +0200 Subject: [PATCH 33/33] Bump version from 0.5.2 to 0.5.3 --- src/detectmatelibrary/metadata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/detectmatelibrary/metadata.py b/src/detectmatelibrary/metadata.py index bc943ec2..4f566b8b 100644 --- a/src/detectmatelibrary/metadata.py +++ b/src/detectmatelibrary/metadata.py @@ -7,6 +7,6 @@ __website__ = "https://aecid.ait.ac.at" __license__ = "EUPL-1.2" __status__ = "Development" -__version__ = "0.5.2" +__version__ = "0.5.3" __all__ = ['__authors__', '__contact__', '__copyright__', '__date__', '__deprecated__', '__website__', '__license__', '__status__', '__version__']