diff --git a/CHANGELOG.md b/CHANGELOG.md
index 11c3d5e..7064eaf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
@@ -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
diff --git a/Cargo.lock b/Cargo.lock
index 9c18dac..b0b14fa 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -69,7 +69,7 @@ dependencies = [
[[package]]
name = "anomalyx"
-version = "1.0.1"
+version = "1.1.0"
dependencies = [
"anomalyx-core",
"anomalyx-detect",
@@ -80,7 +80,7 @@ dependencies = [
[[package]]
name = "anomalyx-core"
-version = "1.0.1"
+version = "1.1.0"
dependencies = [
"proptest",
"serde",
@@ -90,7 +90,7 @@ dependencies = [
[[package]]
name = "anomalyx-detect"
-version = "1.0.1"
+version = "1.1.0"
dependencies = [
"anomalyx-core",
"proptest",
@@ -101,7 +101,7 @@ dependencies = [
[[package]]
name = "anomalyx-normalize"
-version = "1.0.1"
+version = "1.1.0"
dependencies = [
"anomalyx-core",
"apache-avro",
@@ -129,7 +129,7 @@ dependencies = [
[[package]]
name = "anomalyx-validate"
-version = "1.0.1"
+version = "1.1.0"
dependencies = [
"anomalyx-core",
"anomalyx-detect",
diff --git a/Cargo.toml b/Cargo.toml
index 0e5be20..a3737d1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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"
@@ -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"
diff --git a/crates/anomalyx/tests/golden/describe.json b/crates/anomalyx/tests/golden/describe.json
index ac48f8c..f0705a6 100644
--- a/crates/anomalyx/tests/golden/describe.json
+++ b/crates/anomalyx/tests/golden/describe.json
@@ -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."
}
diff --git a/crates/anomalyx/tests/golden/scan_basic.json b/crates/anomalyx/tests/golden/scan_basic.json
index a0ad173..0b02f97 100644
--- a/crates/anomalyx/tests/golden/scan_basic.json
+++ b/crates/anomalyx/tests/golden/scan_basic.json
@@ -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
)"}],"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 )"}],"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}
diff --git a/crates/ax-core/src/roles.rs b/crates/ax-core/src/roles.rs
index 69be754..0855495 100644
--- a/crates/ax-core/src/roles.rs
+++ b/crates/ax-core/src/roles.rs
@@ -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.
@@ -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
diff --git a/crates/ax-detect/src/coll.rs b/crates/ax-detect/src/coll.rs
index d3e6bd1..c06d9d9 100644
--- a/crates/ax-detect/src/coll.rs
+++ b/crates/ax-detect/src/coll.rs
@@ -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
@@ -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 = 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
diff --git a/crates/ax-detect/src/config.rs b/crates/ax-detect/src/config.rs
index 4e085e6..b91d88d 100644
--- a/crates/ax-detect/src/config.rs
+++ b/crates/ax-detect/src/config.rs
@@ -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,
diff --git a/crates/ax-detect/src/ctx.rs b/crates/ax-detect/src/ctx.rs
index 134fa91..67f0e9d 100644
--- a/crates/ax-detect/src/ctx.rs
+++ b/crates/ax-detect/src/ctx.rs
@@ -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);
}
@@ -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());
diff --git a/crates/ax-detect/src/dist.rs b/crates/ax-detect/src/dist.rs
index 90c9924..b77fcf5 100644
--- a/crates/ax-detect/src/dist.rs
+++ b/crates/ax-detect/src/dist.rs
@@ -196,6 +196,7 @@ fn for_paired_numeric(
current: &RecordSet,
baseline: &RecordSet,
min_n: usize,
+ column_roles: bool,
mut f: impl FnMut(&str, &[f64], &[f64]),
) -> bool {
let mut any = false;
@@ -203,6 +204,11 @@ fn for_paired_numeric(
if !col.ty.is_numeric() {
continue;
}
+ // Skip identifier/sequence columns: drift in arbitrary ids or a monotonic
+ // ramp is meaningless. (`column_roles = false` disables this.)
+ if column_roles && col.role().skips_value_detection() {
+ continue;
+ }
let Some(bcol) = baseline.column(&col.name) else {
continue;
};
@@ -237,27 +243,33 @@ impl Detector for KsDetector {
out.mark_absent(self.id(), NO_BASELINE);
return;
};
- let any = for_paired_numeric(ctx.current, baseline, cfg.dist_min_n, |name, bas, cur| {
- let Some(d) = ks_statistic(bas, cur) else {
- return;
- };
- let p = ks_pvalue(d, bas.len(), cur.len());
- if p < cfg.dist_alpha {
- out.push(Finding::new(
- self.id(),
- AnomalyClass::Distributional,
- Handle::Dist {
- column: name.to_string(),
- },
- calibrate::from_undercut(p, cfg.dist_alpha),
- d,
- format!(
- "{name}: KS D={d:.4}, p={p:.4} < α={:.4} — distribution shifted",
- cfg.dist_alpha
- ),
- ));
- }
- });
+ let any = for_paired_numeric(
+ ctx.current,
+ baseline,
+ cfg.dist_min_n,
+ cfg.column_roles,
+ |name, bas, cur| {
+ let Some(d) = ks_statistic(bas, cur) else {
+ return;
+ };
+ let p = ks_pvalue(d, bas.len(), cur.len());
+ if p < cfg.dist_alpha {
+ out.push(Finding::new(
+ self.id(),
+ AnomalyClass::Distributional,
+ Handle::Dist {
+ column: name.to_string(),
+ },
+ calibrate::from_undercut(p, cfg.dist_alpha),
+ d,
+ format!(
+ "{name}: KS D={d:.4}, p={p:.4} < α={:.4} — distribution shifted",
+ cfg.dist_alpha
+ ),
+ ));
+ }
+ },
+ );
if !any {
out.mark_absent(
self.id(),
@@ -286,26 +298,32 @@ impl Detector for PsiDetector {
out.mark_absent(self.id(), NO_BASELINE);
return;
};
- let any = for_paired_numeric(ctx.current, baseline, cfg.dist_min_n, |name, bas, cur| {
- let Some(value) = psi(bas, cur, cfg.psi_bins) else {
- return;
- };
- if value > cfg.psi_threshold {
- out.push(Finding::new(
- self.id(),
- AnomalyClass::Distributional,
- Handle::Dist {
- column: name.to_string(),
- },
- calibrate::from_exceedance(value, cfg.psi_threshold),
- value,
- format!(
- "{name}: PSI={value:.4} > {:.4} — population shifted",
- cfg.psi_threshold
- ),
- ));
- }
- });
+ let any = for_paired_numeric(
+ ctx.current,
+ baseline,
+ cfg.dist_min_n,
+ cfg.column_roles,
+ |name, bas, cur| {
+ let Some(value) = psi(bas, cur, cfg.psi_bins) else {
+ return;
+ };
+ if value > cfg.psi_threshold {
+ out.push(Finding::new(
+ self.id(),
+ AnomalyClass::Distributional,
+ Handle::Dist {
+ column: name.to_string(),
+ },
+ calibrate::from_exceedance(value, cfg.psi_threshold),
+ value,
+ format!(
+ "{name}: PSI={value:.4} > {:.4} — population shifted",
+ cfg.psi_threshold
+ ),
+ ));
+ }
+ },
+ );
if !any {
out.mark_absent(
self.id(),
@@ -340,6 +358,11 @@ impl Detector for Chi2Detector {
if col.ty.is_numeric() {
continue;
}
+ // Skip identifier columns: a high-cardinality id mints a "new
+ // category" every row, so chi-square drift on it is pure noise.
+ if cfg.column_roles && col.role().skips_value_detection() {
+ continue;
+ }
let Some(bcol) = baseline.column(&col.name) else {
continue;
};
@@ -618,9 +641,11 @@ mod tests {
fn ks_detector_confidence_is_calibrated_from_pvalue() {
// Mild drift (shift 12) → flagged with a *meaningful* p-value, so the
// calibrated confidence is the unified undercut mapping of (p vs alpha),
- // distinguishable from a degenerate constant.
- let base: Vec = (0..40).map(|i| i as f64).collect();
- let cur: Vec = (0..40).map(|i| i as f64 + 12.0).collect();
+ // distinguishable from a degenerate constant. Values are a non-monotonic
+ // permutation (`*17 mod 40`) so the column reads as a measurement, not a
+ // sequence — KS sorts internally, so the statistic is unchanged.
+ let base: Vec = (0..40).map(|i| ((i * 17) % 40) as f64).collect();
+ let cur: Vec = (0..40).map(|i| ((i * 17) % 40) as f64 + 12.0).collect();
let (b, c) = baseline_vs_current(vec![ncol("x", &base)], vec![ncol("x", &cur)]);
let cfg = DetectConfig::default();
let mut out = Report::new();
@@ -633,11 +658,66 @@ mod tests {
assert!(out.findings[0].confidence > 0.5);
}
+ #[test]
+ fn numeric_detectors_skip_identifier_columns_by_role() {
+ // Strong drift on an identifier-named numeric column: the shared numeric
+ // iterator (KS + PSI) must skip it under roles, and assess it without.
+ let base: Vec = (0..40).map(|i| ((i * 17) % 40) as f64).collect();
+ let cur: Vec = (0..40).map(|i| ((i * 17) % 40) as f64 + 50.0).collect();
+ let (b, c) = baseline_vs_current(vec![ncol("user_id", &base)], vec![ncol("user_id", &cur)]);
+ let ctx = ScanContext::compared(&b, &c);
+
+ let mut on = Report::new();
+ KsDetector.detect(&ctx, &DetectConfig::default(), &mut on);
+ assert!(on.findings.is_empty(), "KS skips an identifier column");
+
+ let off = DetectConfig {
+ column_roles: false,
+ ..DetectConfig::default()
+ };
+ let mut off_out = Report::new();
+ KsDetector.detect(&ctx, &off, &mut off_out);
+ assert_eq!(off_out.findings.len(), 1, "--no-column-roles assesses it");
+ }
+
+ #[test]
+ fn chi2_skips_identifier_columns_by_role() {
+ // An id-named categorical column whose categories fully turn over (≥2
+ // distinct each side so it is an Identifier, not a Constant): chi2 would
+ // scream "new categories", but that is noise on an identifier.
+ let base: Vec<&str> = (0..50)
+ .map(|i| if i % 2 == 0 { "a" } else { "b" })
+ .collect();
+ let cur: Vec<&str> = (0..50)
+ .map(|i| if i % 2 == 0 { "c" } else { "d" })
+ .collect();
+ let (b, c) = baseline_vs_current(
+ vec![scol("session_id", &base)],
+ vec![scol("session_id", &cur)],
+ );
+ let ctx = ScanContext::compared(&b, &c);
+ let mut on = Report::new();
+ Chi2Detector.detect(&ctx, &DetectConfig::default(), &mut on);
+ assert!(on.findings.is_empty(), "chi2 skips an identifier column");
+ let mut off = Report::new();
+ Chi2Detector.detect(
+ &ctx,
+ &DetectConfig {
+ column_roles: false,
+ ..DetectConfig::default()
+ },
+ &mut off,
+ );
+ assert!(!off.findings.is_empty(), "--no-column-roles assesses it");
+ }
+
#[test]
fn ks_detector_runs_at_exactly_min_n() {
let n = DetectConfig::default().dist_min_n; // 20
- let base: Vec = (0..n).map(|i| i as f64).collect();
- let cur: Vec = (0..n).map(|i| i as f64 + 50.0).collect();
+ // Non-monotonic permutation (`*7 mod 20`) so the column is a measurement,
+ // not a sequence; the KS drift (shift 50) is preserved.
+ let base: Vec = (0..n).map(|i| ((i * 7) % n) as f64).collect();
+ let cur: Vec = (0..n).map(|i| ((i * 7) % n) as f64 + 50.0).collect();
let (b, c) = baseline_vs_current(vec![ncol("x", &base)], vec![ncol("x", &cur)]);
let mut out = Report::new();
KsDetector.detect(
diff --git a/crates/ax-detect/src/mv.rs b/crates/ax-detect/src/mv.rs
index eaeb17a..3c99a4f 100644
--- a/crates/ax-detect/src/mv.rs
+++ b/crates/ax-detect/src/mv.rs
@@ -36,11 +36,14 @@ impl MahalanobisDetector {
/// Extracts the numeric feature matrix, keeping only rows where every
/// numeric column has a finite value (a partial row has no position in
/// feature space — honest absence, not imputation).
- fn extract(current: &RecordSet) -> Option {
+ fn extract(current: &RecordSet, column_roles: bool) -> Option {
+ // Build the feature space from numeric *measurement* columns: an
+ // identifier or monotonic-sequence column is a meaningless Mahalanobis
+ // dimension (and inflates the covariance with label noise).
let feats: Vec<&Vec> = current
.columns
.iter()
- .filter(|c| c.ty.is_numeric())
+ .filter(|c| c.ty.is_numeric() && !(column_roles && c.role().skips_value_detection()))
.map(|c| &c.cells)
.collect();
let dim = feats.len();
@@ -116,7 +119,7 @@ impl Detector for MahalanobisDetector {
AnomalyClass::Multivariate
}
fn detect(&self, ctx: &ScanContext, cfg: &DetectConfig, out: &mut Report) {
- let Some(f) = Self::extract(ctx.current) else {
+ let Some(f) = Self::extract(ctx.current, cfg.column_roles) else {
out.mark_absent(
self.id(),
"needs at least 2 numeric columns for a multivariate distance",
@@ -189,6 +192,42 @@ mod tests {
out
}
+ #[test]
+ fn identifier_column_excluded_from_features() {
+ // One measurement + one identifier column. With roles on, the identifier
+ // is not a feature, leaving a single dimension → mv can't run (needs ≥2).
+ // With roles off, both columns are features and it runs.
+ let (xs, ys) = correlated(40);
+ let rs = RecordSet::new(
+ "-",
+ "t",
+ vec![
+ Column::new("x", xs.iter().map(|&v| Value::Float(v)).collect()),
+ Column::new("user_id", ys.iter().map(|&v| Value::Float(v)).collect()),
+ ],
+ );
+ let mut on = Report::new();
+ MahalanobisDetector.detect(&ScanContext::single(&rs), &DetectConfig::default(), &mut on);
+ assert!(
+ on.absent.iter().any(|a| a.reason.contains("2 numeric")),
+ "identifier excluded ⇒ <2 features ⇒ absent, got {:?}",
+ on.absent
+ );
+ let mut off = Report::new();
+ MahalanobisDetector.detect(
+ &ScanContext::single(&rs),
+ &DetectConfig {
+ column_roles: false,
+ ..DetectConfig::default()
+ },
+ &mut off,
+ );
+ assert!(
+ !off.absent.iter().any(|a| a.reason.contains("2 numeric")),
+ "both columns are features when roles are off"
+ );
+ }
+
/// A correlated cloud y ≈ x (with small wobble) of `n` points.
fn correlated(n: usize) -> (Vec, Vec) {
let xs: Vec = (0..n).map(|i| (i % 10) as f64).collect();
diff --git a/crates/ax-detect/src/point.rs b/crates/ax-detect/src/point.rs
index c695525..483fcf2 100644
--- a/crates/ax-detect/src/point.rs
+++ b/crates/ax-detect/src/point.rs
@@ -14,7 +14,7 @@
use crate::config::DetectConfig;
use crate::{calibrate, fdr, robustz, Detector, Report, ScanContext};
use ax_core::finding::Handle;
-use ax_core::{AnomalyClass, Column, Finding, Role, Value};
+use ax_core::{AnomalyClass, Column, Finding, Value};
#[derive(Debug, Default, Clone)]
pub struct PointDetector;
@@ -45,7 +45,7 @@ impl Detector for PointDetector {
// "outlier" is just its endpoint). A constant column is left to
// `scan_column` (it self-no-ops). Roles still ship in the envelope;
// `column_roles = false` disables this skipping entirely.
- if cfg.column_roles && matches!(col.role(), Role::Identifier | Role::Sequence) {
+ if cfg.column_roles && col.role().skips_value_detection() {
continue;
}
scanned += 1;
diff --git a/crates/ax-normalize/tests/robustness.rs b/crates/ax-normalize/tests/robustness.rs
index 940209a..ee52e93 100644
--- a/crates/ax-normalize/tests/robustness.rs
+++ b/crates/ax-normalize/tests/robustness.rs
@@ -1,29 +1,40 @@
//! Parser robustness ("fuzz-style") harness.
//!
-//! Parsers ingest untrusted bytes, so a panic, hang, or unbounded allocation is
-//! a real defect — not just a wrong answer. These property tests throw arbitrary,
-//! truncated, and magic-prefixed-garbage byte streams at the registry and at
-//! every individual parser, asserting only that each call *returns* (Ok or a
-//! clean `AxError`) and never unwinds. Deterministic (proptest seeds); runs in
-//! the normal test gate, no nightly/libFuzzer needed.
+//! Parsers ingest untrusted bytes, so a panic or hang is a real defect — not
+//! just a wrong answer. These property tests throw arbitrary, truncated, and
+//! magic-prefixed byte streams at the registry and at every individual parser,
+//! asserting only that each call *returns* (Ok or a clean `AxError`) and never
+//! unwinds. Runs in the normal test gate, no nightly/libFuzzer needed.
+//!
+//! One honest exclusion: the binary *container* decoders (`parquet`/`arrow`,
+//! `avro`, `orc`, `evtx`, `pcap`) delegate to third-party crates that trust the
+//! file's internal length/count fields, so a maliciously crafted length can make
+//! the underlying crate attempt a huge allocation. That is a property of binary
+//! format parsing, not a logic bug here, and is not something anomalyx can bound
+//! without forking those decoders — so we don't feed them adversarial length
+//! fields (see [`MAGICS`]). They are still fuzzed with arbitrary bytes, which
+//! fail their magic check and are rejected cleanly.
use anomalyx_normalize::ParserRegistry;
use proptest::prelude::*;
-/// Leading bytes that push content-sniffing binary parsers past their magic
-/// check into the real decode path — where header/length handling lives and
-/// where a malformed file is most likely to trip a panic.
+/// Leading bytes that push a parser past its magic check into the real decode
+/// path — where header/length handling lives and a malformed file is most likely
+/// to trip a panic.
+///
+/// **Scope, honestly:** this list is limited to formats whose decode allocation
+/// anomalyx itself bounds — `sqlite` deserializes from the supplied byte buffer,
+/// so it can't allocate beyond the input. The columnar/container decoders
+/// (`parquet`/`arrow` via Polars, `avro`, `orc`, `evtx`, `pcap`) delegate to
+/// third-party crates that *trust the file's internal length/count fields*: a
+/// maliciously crafted length makes the underlying crate attempt a huge
+/// allocation — a known property of binary-format parsing, not a logic bug in
+/// anomalyx, and not something we can prevent without forking those decoders.
+/// Those parsers are still fuzzed with arbitrary bytes below (which fail the
+/// magic check and are rejected cleanly); we just don't feed them adversarial
+/// length fields here and assert a guarantee the dependency doesn't make.
const MAGICS: &[&[u8]] = &[
- b"SQLite format 3\x00", // sqlite
- b"PAR1", // parquet
- b"ARROW1\x00\x00", // arrow IPC
- b"ElfFile\x00", // evtx
- b"Obj\x01", // avro
- b"ORC", // orc
- b"PK\x03\x04", // xlsx/ods (zip)
- b"\xd4\xc3\xb2\xa1", // pcap (LE)
- b"\xa1\xb2\xc3\xd4", // pcap (BE)
- b"\x0a\x0d\x0d\x0a", // pcapng
+ b"SQLite format 3\x00", // sqlite — allocation bounded by the input buffer
];
proptest! {