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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- **A short series no longer returns an unqualified all-clear from
``detect_signals``.** ``SignalConfig.min_observations`` (default 20) was
unreachable: ``SignalDetector`` read a hardcoded per-rule table and never
consulted the config, so ``SignalConfig(min_observations=30)`` changed nothing.
Separately, the detector already recorded which rules it skipped for length
(``rules_skipped``, added in 0.2.0) but ``SignalResult.summary`` ignored that and
printed ``✓ No signals detected`` regardless. On a four-point X chart six of the
eight rules cannot fire, and the one that can is weakened by any trend in the
data, so the checkmark described an examination that had mostly not happened.

Now: the per-rule table is the *structural* minimum (``RULE_MIN_OBSERVATIONS``,
a rule is skipped below it, as before) and ``min_observations`` is the *advisory*
minimum. Below it the detector emits a ``ProcessBehaviorWarning`` naming both
numbers (one per chart evaluated; silence it with
``warnings.simplefilter('ignore', ProcessBehaviorWarning)`` once a pipeline has
accounted for it) and the result is marked partial even when every runnable
rule ran.
``SignalResult`` gains ``rules_evaluated``, ``rules_applicable``,
``n_observations``, ``min_observations``, ``below_min_observations`` and
``evaluation_note``; ``is_partial`` / ``evaluation_status`` now also reflect the
advisory threshold. A partial evaluation prints
``⚠ Partial evaluation in X: 2 of 8 rules applicable at n=4 (...). No signals
from the rules evaluated.`` and the checkmark line is reserved for a complete
evaluation. Which rules run, and what they flag, is unchanged. Excel export's
Summary sheet adds evaluation status. Reported privately (GHSA-hw63-2x95-fmpv)
and in #114.
- ``SECURITY.md`` supported-versions table said 0.1.x while the prose said 0.2.x.
The table now matches the prose.

### Removed
- **Seven never-called utilities from ``datasets.synthetic``** — ``make_edge_cases``
(unseeded, non-deterministic), ``compare_sds_characteristics`` (its docstring
Expand Down
8 changes: 4 additions & 4 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@

## Supported versions

processbehavior is in **alpha** (0.1.x). Only the most recent 0.1.x release
receives security fixes. 0.2.x is the supported line; 0.1.x is not patched.
processbehavior is in **alpha**. Only the most recent 0.2.x release receives
security fixes; 0.1.x is not patched.

| Version | Supported |
| ------- | ------------------ |
| 0.1.x | :white_check_mark: |
| < 0.1 | :x: |
| 0.2.x | :white_check_mark: |
| < 0.2 | :x: |

## Reporting a vulnerability

Expand Down
3 changes: 2 additions & 1 deletion docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,8 @@ from processbehavior.signals import SignalConfig, RuleSet, SignalResult
Returned by `result.detect_signals()`.

- Status: `.count`, `.has_signals`, `.is_partial`, `.evaluation_status`,
`.rules_skipped`
`.evaluation_note`, `.rules_evaluated`, `.rules_skipped`, `.rules_applicable`,
`.n_observations`, `.min_observations`, `.below_min_observations`
- Views: `.violations` (list), `.by_rule` (dict), `.by_observation` (dict),
`.flagged_observations`, `.summary`
- Filters: `.get_rule_violations(rule_name)`, `.get_observation_violations(obs_id)`
Expand Down
57 changes: 55 additions & 2 deletions docs/reference/weco-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,12 @@ Not all rules apply to all chart types:

## Minimum Observations

Each rule requires a minimum number of observations:
There are two minimums, and they answer different questions.

### Structural minimum, per rule

Each rule needs a certain number of points before its pattern can occur at all.
A rule below its minimum is skipped, not failed:

| Rule | Minimum Observations |
|------|---------------------|
Expand All @@ -276,7 +281,55 @@ Each rule requires a minimum number of observations:
| 7 | 15 |
| 8 | 8 |

ProcessBehavior automatically skips rules that can't be evaluated due to insufficient data.
These live in `SignalDetector.RULE_MIN_OBSERVATIONS` and are not configurable:
a run of eight cannot exist in six points.

### Advisory minimum, per evaluation

`SignalConfig.min_observations` (default 20) is the series length below which the
evaluation as a whole is reported as **partial**, even if every rule that could run
did run. At four points, six of the eight X/mR rules are structurally inert and the
remaining two have very little to work with. Below the threshold the detector emits
a `ProcessBehaviorWarning` naming both numbers, and the result carries the detail:

```python
signals = result.detect_signals(chart='X')
signals.is_partial # True
signals.evaluation_status # 'partial'
signals.rules_evaluated # ['rule_1', 'rule_2']
signals.rules_skipped # {'rule_3': 'needs 5 observations, have 4', ...}
signals.evaluation_note # '2 of 8 rules applicable at n=4 (below min_observations=20); skipped: ...'
```

### Reading the summary

A partial evaluation never prints the clean-series checkmark. Compare:

```
✓ No signals detected in X
```

which means every applicable rule ran on an adequate series and none fired, with:

```
⚠ Partial evaluation in X: 2 of 8 rules applicable at n=4 (below min_observations=20);
skipped: rule_3 (needs 5), rule_4 (needs 8), rule_5 (needs 6), rule_6 (needs 14),
rule_7 (needs 15), rule_8 (needs 8). No signals from the rules evaluated.
```

which means most of the examination could not be performed. The second is not an
all-clear. On a short series that is also trending, the one rule that can still fire
(Rule 1) is weakened by the trend itself, because the trend inflates the moving range
that sets the limits. Treat a partial result as "not yet examined", and see the
series-length guidance in the user guide before quoting limits from a short series.

To silence the warning in a pipeline that has already accounted for it:

```python
import warnings
from processbehavior import ProcessBehaviorWarning
warnings.simplefilter('ignore', ProcessBehaviorWarning)
```

## Interpreting Results

Expand Down
7 changes: 6 additions & 1 deletion processbehavior/analysis_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -1029,11 +1029,16 @@ def detect_signals(
>>> from processbehavior.signals import SignalConfig
>>> config = SignalConfig(
... enabled_rules=['rule_1', 'rule_2'],
... min_observations=30,
... min_observations=30, # below 30 points the result is marked partial
... ignore_first_n=5
... )
>>> signals = result.detect_signals(config=config)

A short series never returns an unqualified all-clear: when rules were
skipped for length, or the series is below ``min_observations``,
``signals.is_partial`` is True and ``signals.summary`` says which rules
were evaluated.

Access violations:

>>> signals.by_rule['rule_2'] # Rule 2 violations
Expand Down
6 changes: 5 additions & 1 deletion processbehavior/signals/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,11 @@ class SignalConfig:
zone_definition : ZoneDefinition, optional
Custom zone definitions
min_observations : int, default 20
Minimum observations required
Advisory minimum series length for a complete evaluation. Rules that
structurally need more points than are present are always skipped (see
``SignalDetector.RULE_MIN_OBSERVATIONS``); below this threshold the
result is additionally marked partial and a ``ProcessBehaviorWarning``
is emitted naming both numbers. It does not change which rules run.
ignore_first_n : int, default 0
Ignore first N observations (startup period)
ignore_last_n : int, default 0
Expand Down
76 changes: 55 additions & 21 deletions processbehavior/signals/detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@
from __future__ import annotations

import logging
import warnings
from typing import TYPE_CHECKING

import pandas as pd

from ..exceptions import ValidationError
from ..exceptions import ProcessBehaviorWarning, ValidationError
from .config import SignalConfig
from .detectors import (
detect_avoiding_center,
Expand Down Expand Up @@ -67,6 +68,21 @@ class SignalDetector:
'rule_8': '8+ consecutive points avoiding Zone C',
}

# Structural minimum for each rule: the fewest points at which the pattern
# can occur at all. A rule is skipped (not failed) below its minimum. This is
# distinct from ``SignalConfig.min_observations``, the advisory series-length
# threshold below which the whole evaluation is reported as partial.
RULE_MIN_OBSERVATIONS = {
'rule_1': 1,
'rule_2': 3,
'rule_3': 5,
'rule_4': 8,
'rule_5': 6,
'rule_6': 14,
'rule_7': 15,
'rule_8': 8,
}

def detect(
self,
data: pd.DataFrame,
Expand Down Expand Up @@ -146,9 +162,12 @@ def detect(

# Detect violations for each applicable rule
all_violations = pd.DataFrame(index=filtered_data.index)
n_obs = len(filtered_data)
# Applicable rules skipped for too few observations (rule -> reason), so the
# result can report partial evaluation instead of raising.
rules_skipped: dict[str, str] = {}
# Applicable rules that actually ran — the numerator of "k of n rules".
rules_evaluated: list[str] = []

for rule_name in applicable_rules:
if rule_name not in self.RULE_DETECTORS:
Expand All @@ -157,12 +176,11 @@ def detect(

# Check minimum observations — skip (don't abort) rules the group is too small for.
min_obs = self._get_min_observations(rule_name)
if len(filtered_data) < min_obs:
logger.debug(
f'Skipping {rule_name}: insufficient observations (need {min_obs}, have {len(filtered_data)})'
)
rules_skipped[rule_name] = f'needs {min_obs} observations, have {len(filtered_data)}'
if n_obs < min_obs:
logger.debug(f'Skipping {rule_name}: insufficient observations (need {min_obs}, have {n_obs})')
rules_skipped[rule_name] = f'needs {min_obs} observations, have {n_obs}'
continue
rules_evaluated.append(rule_name)

# Apply detector
detector = self.RULE_DETECTORS[rule_name]
Expand All @@ -184,10 +202,30 @@ def detect(
logger.error(f'Error detecting {rule_name}: {e}')
all_violations[rule_name] = False

# Below the advisory threshold the evaluation is reported as partial even
# when every rule that *could* run did run: at short lengths most run-rules
# are structurally inert, and a bare "no signals" would read as an
# all-clear the series has not earned. One warning per detect() call; a
# pipeline that has accounted for this silences it with
# warnings.simplefilter('ignore', ProcessBehaviorWarning).
n_applicable = len(rules_evaluated) + len(rules_skipped)
if n_obs < config.min_observations:
warnings.warn(
f'Signal detection on {n_obs} observations is below min_observations='
f'{config.min_observations}: {len(rules_evaluated)} of {n_applicable} '
f'applicable rules could be evaluated. The result is marked partial; '
f'do not read "no signals" as an all-clear.',
ProcessBehaviorWarning,
# detect() <- _detect_for_chart <- detect_signals_for_result
# <- AnalysisResult.detect_signals <- caller
stacklevel=5,
)

# Build result
return self._build_result(
data=filtered_data, violations=all_violations, stats=stats, value_col=value_col,
chart_name=chart_name, rules_skipped=rules_skipped,
chart_name=chart_name, rules_skipped=rules_skipped, rules_evaluated=rules_evaluated,
n_observations=n_obs, min_observations=config.min_observations,
)

def _validate_inputs(self, data: pd.DataFrame, stats: dict):
Expand Down Expand Up @@ -218,22 +256,15 @@ def _filter_data(self, data: pd.DataFrame, config: SignalConfig) -> pd.DataFrame
return filtered

def _get_min_observations(self, rule_name: str) -> int:
"""Get minimum observations for a rule."""
minimums = {
'rule_1': 1,
'rule_2': 3,
'rule_3': 5,
'rule_4': 8,
'rule_5': 6,
'rule_6': 14,
'rule_7': 15,
'rule_8': 8,
}
return minimums.get(rule_name, 1)
"""Structural minimum observations for a rule (see ``RULE_MIN_OBSERVATIONS``)."""
return self.RULE_MIN_OBSERVATIONS.get(rule_name, 1)

def _build_result(
self, data: pd.DataFrame, violations: pd.DataFrame, stats: dict, value_col: str, chart_name: str,
rules_skipped: dict[str, str] | None = None,
rules_evaluated: list[str] | None = None,
n_observations: int | None = None,
min_observations: int | None = None,
) -> SignalResult:
"""Build SignalResult from violation matrix."""
# Create violation records
Expand Down Expand Up @@ -268,8 +299,11 @@ def _build_result(

violation_df = pd.DataFrame(records) if records else pd.DataFrame()

return SignalResult(violations=violation_df, chart_name=chart_name, data=data, stats=stats,
rules_skipped=rules_skipped)
return SignalResult(
violations=violation_df, chart_name=chart_name, data=data, stats=stats,
rules_skipped=rules_skipped, rules_evaluated=rules_evaluated,
n_observations=n_observations, min_observations=min_observations,
)

def _limits_vary(self, stats: dict) -> bool:
"""Check if control limits vary (per-row limits)."""
Expand Down
Loading
Loading