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
32 changes: 31 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,35 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [1.1.0] - 2026-06-01

### Changed

- **Column roles now gate every value-distribution detector, not just `point`.**
`ctx.seasonal`, `coll.cusum`, `dist.ks` / `dist.psi` / `dist.chi2`, and
`mv.mahalanobis` now skip `identifier` and `sequence` columns (and exclude them
from the Mahalanobis feature space). A seasonal subseries, level-shift, drift
test, or joint distance over arbitrary ids or a monotonic ramp is noise, not
signal — this fixes, e.g., `coll.cusum` flagging a shift in a syslog `procid`.
A shared `Role::skips_value_detection()` keeps the rule in one place.
(`struct.schema` stays role-agnostic — null-rate/schema-diff are meaningful for
any column; `cad.regularity` only ever uses the explicit `--cadence` column.)
- This changes detector output when `column_roles = true`, so the
`config_version` fingerprint is bumped (`anomalyx-cfg/9`). Envelope shape and
`PROTOCOL` are unchanged; `--no-column-roles` restores the pre-roles behavior
across all detectors.

### Testing

- Scoped the parser-robustness harness's magic-prefixed fuzz test to formats
whose decode allocation anomalyx bounds (`sqlite`). The binary *container*
decoders (`parquet`/`arrow`, `avro`, `orc`, `evtx`, `pcap`) delegate to crates
that trust the file's internal length fields and can attempt a large
allocation on adversarial input — a property of binary-format parsing, now
**documented** rather than asserted (it surfaced as an intermittent CI OOM).
Those parsers are still fuzzed with arbitrary bytes (rejected at the magic
check).

## [1.0.1] - 2026-06-01

### Fixed
Expand Down Expand Up @@ -343,7 +372,8 @@ Initial release — a contract-first anomaly-detection CLI over arbitrary corpor
gates on every push.
- Dual-licensed under MIT OR Apache-2.0.

[Unreleased]: https://github.com/copyleftdev/anomalyx/compare/v1.0.1...HEAD
[Unreleased]: https://github.com/copyleftdev/anomalyx/compare/v1.1.0...HEAD
[1.1.0]: https://github.com/copyleftdev/anomalyx/compare/v1.0.1...v1.1.0
[1.0.1]: https://github.com/copyleftdev/anomalyx/compare/v1.0.0...v1.0.1
[1.0.0]: https://github.com/copyleftdev/anomalyx/compare/v0.9.0...v1.0.0
[0.9.0]: https://github.com/copyleftdev/anomalyx/compare/v0.8.0...v0.9.0
Expand Down
10 changes: 5 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ members = [
]

[workspace.package]
version = "1.0.1"
version = "1.1.0"
edition = "2021"
rust-version = "1.90"
license = "MIT OR Apache-2.0"
Expand All @@ -22,9 +22,9 @@ authors = ["copyleftdev"]
# `ax-*` names were taken), but the import/extern name stays `ax_core` etc. via
# the dependency key + `package` rename — so no source code changes are needed.
# Keep versions in sync with workspace.package.version above.
ax-core = { path = "crates/ax-core", version = "1.0.1", package = "anomalyx-core" }
ax-normalize = { path = "crates/ax-normalize", version = "1.0.1", package = "anomalyx-normalize" }
ax-detect = { path = "crates/ax-detect", version = "1.0.1", package = "anomalyx-detect" }
ax-core = { path = "crates/ax-core", version = "1.1.0", package = "anomalyx-core" }
ax-normalize = { path = "crates/ax-normalize", version = "1.1.0", package = "anomalyx-normalize" }
ax-detect = { path = "crates/ax-detect", version = "1.1.0", package = "anomalyx-detect" }

serde = { version = "1", features = ["derive"] }
serde_json = "1"
Expand Down
2 changes: 1 addition & 1 deletion crates/anomalyx/tests/golden/describe.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,6 @@
"cad_max_cv": 0.05,
"cad_min_n": 20
},
"config_version": "anomalyx-cfg/8;pt=3.5000;ptn=8;cr=true;pfdr=;da=0.0500;psi=0.2000;psib=10;dmn=20;snr=0.5000;mva=0.00100;mvn=20;mvr=1e-9;cxp=0;cxt=3.5000;cxm=4;cln=20;clt=5.0000;cdc=;cdcv=0.0500;cdn=20",
"config_version": "anomalyx-cfg/9;pt=3.5000;ptn=8;cr=true;pfdr=;da=0.0500;psi=0.2000;psib=10;dmn=20;snr=0.5000;mva=0.00100;mvn=20;mvr=1e-9;cxp=0;cxt=3.5000;cxm=4;cln=20;clt=5.0000;cdc=;cdcv=0.0500;cdn=20",
"determinism": "Same input + same config_version yields byte-identical output."
}
2 changes: 1 addition & 1 deletion crates/anomalyx/tests/golden/scan_basic.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"protocol":"anomalyx/tq1","config_version":"anomalyx-cfg/8;pt=3.5000;ptn=8;cr=true;pfdr=;da=0.0500;psi=0.2000;psib=10;dmn=20;snr=0.5000;mva=0.00100;mvn=20;mvr=1e-9;cxp=0;cxt=3.5000;cxm=4;cln=20;clt=5.0000;cdc=;cdcv=0.0500;cdn=20","source":"-","format":"csv","rows_scanned":10,"dict":["point.modz","point","cell:reading:9","critical","reading = 1000.000000: modified z-score 450.395 exceeds 3.500 (center=10.000000, scale=1.482600)"],"columns":["detector","class","handle","confidence","severity","score","reason"],"rows":[[0,1,2,1.0,3,450.3945770942938,4]],"absent":[{"detector":"dist.ks","reason":"no baseline provided; distributional drift requires --baseline"},{"detector":"dist.psi","reason":"no baseline provided; distributional drift requires --baseline"},{"detector":"dist.chi2","reason":"no baseline provided; distributional drift requires --baseline"},{"detector":"mv.mahalanobis","reason":"fewer than 20 complete numeric rows to estimate a covariance"},{"detector":"ctx.seasonal","reason":"contextual detection needs a declared period ≥ 2 (pass --period N)"},{"detector":"coll.cusum","reason":"no numeric column with ≥ 20 values and non-zero variance"},{"detector":"cad.regularity","reason":"cadence detection requires a time column (pass --cadence <COL>)"}],"roles":[{"column":"id","role":"identifier"},{"column":"reading","role":"measurement"}],"summary":{"total":1,"max_severity":"critical","by_class":[{"class":"point","count":1},{"class":"contextual","count":0},{"class":"collective","count":0},{"class":"distributional","count":0},{"class":"structural","count":0},{"class":"multivariate","count":0},{"class":"cadence","count":0}]},"exit":1}
{"protocol":"anomalyx/tq1","config_version":"anomalyx-cfg/9;pt=3.5000;ptn=8;cr=true;pfdr=;da=0.0500;psi=0.2000;psib=10;dmn=20;snr=0.5000;mva=0.00100;mvn=20;mvr=1e-9;cxp=0;cxt=3.5000;cxm=4;cln=20;clt=5.0000;cdc=;cdcv=0.0500;cdn=20","source":"-","format":"csv","rows_scanned":10,"dict":["point.modz","point","cell:reading:9","critical","reading = 1000.000000: modified z-score 450.395 exceeds 3.500 (center=10.000000, scale=1.482600)"],"columns":["detector","class","handle","confidence","severity","score","reason"],"rows":[[0,1,2,1.0,3,450.3945770942938,4]],"absent":[{"detector":"dist.ks","reason":"no baseline provided; distributional drift requires --baseline"},{"detector":"dist.psi","reason":"no baseline provided; distributional drift requires --baseline"},{"detector":"dist.chi2","reason":"no baseline provided; distributional drift requires --baseline"},{"detector":"mv.mahalanobis","reason":"needs at least 2 numeric columns for a multivariate distance"},{"detector":"ctx.seasonal","reason":"contextual detection needs a declared period ≥ 2 (pass --period N)"},{"detector":"coll.cusum","reason":"no numeric column with ≥ 20 values and non-zero variance"},{"detector":"cad.regularity","reason":"cadence detection requires a time column (pass --cadence <COL>)"}],"roles":[{"column":"id","role":"identifier"},{"column":"reading","role":"measurement"}],"summary":{"total":1,"max_severity":"critical","by_class":[{"class":"point","count":1},{"class":"contextual","count":0},{"class":"collective","count":0},{"class":"distributional","count":0},{"class":"structural","count":0},{"class":"multivariate","count":0},{"class":"cadence","count":0}]},"exit":1}
21 changes: 21 additions & 0 deletions crates/ax-core/src/roles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@ impl Role {
pub fn is_measured(self) -> bool {
matches!(self, Role::Measurement)
}

/// Whether a *value-distribution* detector (point, contextual, collective,
/// distributional, multivariate) should skip a column of this role: an
/// `Identifier` is an arbitrary label and a `Sequence` is a monotonic ramp,
/// so any value-based anomaly statistic on them is noise, not signal. A
/// `Constant` is left to each detector (it naturally produces nothing), and a
/// `Categorical` is the chi-square detector's legitimate input.
pub fn skips_value_detection(self) -> bool {
matches!(self, Role::Identifier | Role::Sequence)
}
}

/// A column name paired with its classified role, for the envelope.
Expand Down Expand Up @@ -207,6 +217,17 @@ mod tests {
}
}

#[test]
fn skips_value_detection_targets_identifier_and_sequence() {
assert!(Role::Identifier.skips_value_detection());
assert!(Role::Sequence.skips_value_detection());
// Measurement is analyzed; Categorical feeds chi-square; Constant is
// left to each detector to no-op — none are skipped by this gate.
for r in [Role::Measurement, Role::Categorical, Role::Constant] {
assert!(!r.skips_value_detection(), "{:?} must not be skipped", r);
}
}

#[test]
fn strictly_monotonic_predicate() {
assert!(is_strictly_monotonic(&[1.0, 2.0, 3.0])); // increasing
Expand Down
38 changes: 38 additions & 0 deletions crates/ax-detect/src/coll.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ impl Detector for CusumDetector {
if !col.ty.is_numeric() {
continue;
}
// Skip identifier/sequence columns: a "level shift" in arbitrary ids
// is meaningless, and a monotonic counter is one big ramp the CUSUM
// would always flag. (`column_roles = false` disables this.)
if cfg.column_roles && col.role().skips_value_detection() {
continue;
}
// Finite values in row order, with their original row indices.
let pairs: Vec<(usize, f64)> = col
.cells
Expand Down Expand Up @@ -167,6 +173,38 @@ mod tests {
assert!((standardized_shift(0.0, 2.0, 1.0, 1, 3) - 3.0_f64.sqrt()).abs() < 1e-12);
}

#[test]
fn identifier_column_is_skipped_by_role() {
// A clean 10→40 level shift that a measurement column would flag as a
// range — but a shift in arbitrary ids is meaningless.
let mut v: Vec<f64> = vec![10.0; 20];
v.extend(std::iter::repeat_n(40.0, 20));
let rs = RecordSet::new(
"-",
"t",
vec![Column::new(
"user_id",
v.iter().map(|&x| Value::Float(x)).collect(),
)],
);
let mut on = Report::new();
CusumDetector.detect(&ScanContext::single(&rs), &DetectConfig::default(), &mut on);
assert!(
on.findings.is_empty(),
"cusum on an identifier column is skipped"
);
let mut off = Report::new();
CusumDetector.detect(
&ScanContext::single(&rs),
&DetectConfig {
column_roles: false,
..DetectConfig::default()
},
&mut off,
);
assert_eq!(off.findings.len(), 1, "--no-column-roles assesses it");
}

#[test]
fn sustained_level_shift_is_flagged_as_a_range() {
// Clean 10→40 step at row 20 (no noise) so segment means are exactly
Expand Down
2 changes: 1 addition & 1 deletion crates/ax-detect/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ impl DetectConfig {
/// Deterministic: no wall-clock, no environment.
pub fn version(&self) -> String {
format!(
"anomalyx-cfg/8;pt={:.4};ptn={};cr={};pfdr={};da={:.4};psi={:.4};psib={};dmn={};snr={:.4};mva={:.5};mvn={};mvr={:e};cxp={};cxt={:.4};cxm={};cln={};clt={:.4};cdc={};cdcv={:.4};cdn={}",
"anomalyx-cfg/9;pt={:.4};ptn={};cr={};pfdr={};da={:.4};psi={:.4};psib={};dmn={};snr={:.4};mva={:.5};mvn={};mvr={:e};cxp={};cxt={:.4};cxm={};cln={};clt={:.4};cdc={};cdcv={:.4};cdn={}",
self.point_threshold,
self.point_min_n,
self.column_roles,
Expand Down
35 changes: 35 additions & 0 deletions crates/ax-detect/src/ctx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ impl Detector for SeasonalDetector {
fn scan_all(det: &SeasonalDetector, rs: &RecordSet, cfg: &DetectConfig, out: &mut Report) -> bool {
let mut assessed = false;
for col in &rs.columns {
// Skip identifier/sequence columns: a seasonal subseries of arbitrary
// labels or a monotonic ramp is meaningless. Roles still ship in the
// envelope; `column_roles = false` disables this.
if cfg.column_roles && col.role().skips_value_detection() {
continue;
}
if col.ty.is_numeric() {
assessed |= det.scan_column(col, cfg, out);
}
Expand Down Expand Up @@ -143,6 +149,35 @@ mod tests {
out
}

#[test]
fn identifier_column_is_skipped_by_role() {
// A phase-0 outlier that a measurement column would flag — but on an
// identifier-named column, the seasonal subseries is meaningless.
let mut s = seasonal_series();
s[14] = 50.0;
let rs = RecordSet::new(
"-",
"t",
vec![Column::new(
"user_id",
s.iter().map(|&x| Value::Float(x)).collect(),
)],
);
assert!(
run(&rs, &weekly(7)).findings.is_empty(),
"seasonal on an identifier column is skipped"
);
let off = DetectConfig {
ctx_period: 7,
column_roles: false,
..DetectConfig::default()
};
assert!(
!run(&rs, &off).findings.is_empty(),
"--no-column-roles assesses it"
);
}

#[test]
fn absent_without_a_period() {
let report = run(&col_corpus(&seasonal_series()), &DetectConfig::default());
Expand Down
Loading