From f5ffbad948abc986354d69ed029e176259d7e943 Mon Sep 17 00:00:00 2001 From: zuub-don Date: Mon, 1 Jun 2026 16:02:21 -0700 Subject: [PATCH 1/2] feat: column roles gate all value-distribution detectors (1.1.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Role-based skipping now applies to every value-distribution detector, not just point.modz: ctx.seasonal, coll.cusum, dist.ks/psi/chi2, and mv.mahalanobis skip identifier/sequence columns (mv excludes them from the feature matrix). A seasonal subseries / level-shift / drift test / joint distance over arbitrary ids or a monotonic ramp is noise — this closes the gap noted in 1.0.1 where coll.cusum still flagged a syslog procid shift. struct.schema stays role-agnostic (null-rate/schema-diff meaningful for any column); cad.regularity uses only the explicit --cadence column. Shared Role::skips_value_detection() centralizes it. Changes detector output for column_roles=true → config_version bumped anomalyx-cfg/8→/9. Envelope/PROTOCOL unchanged; --no-column-roles restores pre-roles behavior everywhere. Goldens re-blessed for the new fingerprint. Gates: proptest + cargo-mutants 0-missed on all 6 changed files (roles 37 caught; ctx/coll/mv 109; point/dist 187 + 10 loop-bound timeouts). Added role-skip tests per detector. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 21 ++- Cargo.lock | 10 +- Cargo.toml | 8 +- crates/anomalyx/tests/golden/describe.json | 2 +- crates/anomalyx/tests/golden/scan_basic.json | 2 +- crates/ax-core/src/roles.rs | 21 +++ crates/ax-detect/src/coll.rs | 38 ++++ crates/ax-detect/src/config.rs | 2 +- crates/ax-detect/src/ctx.rs | 35 ++++ crates/ax-detect/src/dist.rs | 172 ++++++++++++++----- crates/ax-detect/src/mv.rs | 45 ++++- crates/ax-detect/src/point.rs | 4 +- 12 files changed, 296 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11c3d5e..83dc0ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ 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. + ## [1.0.1] - 2026-06-01 ### Fixed @@ -343,7 +361,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; From 8f5010615ef0177519da68ce03758438dcecf0e6 Mon Sep 17 00:00:00 2001 From: zuub-don Date: Mon, 1 Jun 2026 16:14:42 -0700 Subject: [PATCH 2/2] test: scope robustness magic-prefix fuzz to bounded-allocation formats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The magic-prefixed-garbage test fed crafted length fields to binary container decoders (parquet/arrow/avro/orc/evtx/pcap) that delegate to crates trusting those lengths — a fuzzed length could make the dependency attempt a ~21GB allocation (intermittent CI OOM). That is a binary-format-parsing property, not an anomalyx logic bug, and isn't boundable without forking the decoders. Scoped MAGICS to sqlite (allocation bounded by the input buffer) and documented the limitation; those parsers are still fuzzed via arbitrary bytes (rejected at the magic check). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 11 ++++++ crates/ax-normalize/tests/robustness.rs | 49 +++++++++++++++---------- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83dc0ec..7064eaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,17 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `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 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! {