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

## [Unreleased]

## [0.3.0] - 2026-05-31

Column scoping — focus detection on the columns that matter in a wide corpus,
deterministically and without guessing.

### Added

- `scan` / `explain` gain **`--columns C,..`** (analyze only these columns) and
**`--exclude C,..`** (analyze every column except these). The two are mutually
exclusive. Projection is applied before detection and to the baseline as well,
so drift comparison stays consistent.
- Column scoping is **explicit, never heuristic.** anomalyx will not guess which
columns are "interesting" — a silent auto-skip would itself be a guess, and
would wrongly drop exactly the near-unique numeric measurements the marquee
detectors rely on (packet `durationNanos`, span durations, latencies). You name
the scope; the result stays deterministic and reproducible.
- An unknown column name in `--columns`/`--exclude` on the primary corpus is a
hard error (exit `2`) — a typo can never silently scope a scan down to nothing
and read as "clean". The baseline is projected leniently (it is a different
corpus and need not carry every scoped column).

### Notes

- This directly tames wide, identifier-heavy corpora. On a real 20k-entry
`journalctl -o json` capture, `scan` emits ~10k mostly-noise `point` findings
across journald's many ID/counter/timestamp fields; `scan --exclude` of those
fields (or `--columns` of the meaningful ones) collapses that to a couple
hundred focused findings without touching detector configuration.
- New `RecordSet::select` / `RecordSet::without` projection primitives in
`ax-core`. No envelope or `config_version` change — column scope is an
input-side projection, so the determinism contract is unchanged.

## [0.2.2] - 2026-05-31

### Fixed
Expand Down Expand Up @@ -106,7 +138,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/v0.2.2...HEAD
[Unreleased]: https://github.com/copyleftdev/anomalyx/compare/v0.3.0...HEAD
[0.3.0]: https://github.com/copyleftdev/anomalyx/compare/v0.2.2...v0.3.0
[0.2.2]: https://github.com/copyleftdev/anomalyx/compare/v0.2.1...v0.2.2
[0.2.1]: https://github.com/copyleftdev/anomalyx/compare/v0.2.0...v0.2.1
[0.2.0]: https://github.com/copyleftdev/anomalyx/compare/v0.1.0...v0.2.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 = "0.2.2"
version = "0.3.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 = "0.2.2", package = "anomalyx-core" }
ax-normalize = { path = "crates/ax-normalize", version = "0.2.2", package = "anomalyx-normalize" }
ax-detect = { path = "crates/ax-detect", version = "0.2.2", package = "anomalyx-detect" }
ax-core = { path = "crates/ax-core", version = "0.3.0", package = "anomalyx-core" }
ax-normalize = { path = "crates/ax-normalize", version = "0.3.0", package = "anomalyx-normalize" }
ax-detect = { path = "crates/ax-detect", version = "0.3.0", package = "anomalyx-detect" }

serde = { version = "1", features = ["derive"] }
serde_json = "1"
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ With `--cadence COL`, column `COL` is read as event times and assessed for
metronomic (automated) regularity. Without it the cadence detector is absent —
which column means "time" is never guessed.

With `--columns C,..` (or its complement `--exclude C,..`) detection is scoped to
a chosen set of columns — the answer to identifier noise on wide corpora (e.g.
`journalctl -o json | anomalyx scan --exclude JOB_ID,_PID,__REALTIME_TIMESTAMP`).
The scope is explicit, never a heuristic guess; an unknown column name is a hard
error so a typo can't silently scan nothing. See [scan modes](docs/src/modes.md).

Exit codes are part of the contract: **`0`** clean · **`1`** anomalies found ·
**`2`** tool error.

Expand Down
179 changes: 173 additions & 6 deletions crates/anomalyx/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,14 @@ fn usage() -> &'static str {
USAGE:\n\
\x20 anomalyx describe Protocol metadata\n\
\x20 anomalyx schema JSON Schema of scan output\n\
\x20 anomalyx scan [--baseline B] [--period N] [--cadence COL] [PATH]\n\
\x20 anomalyx explain <HANDLE> [--baseline B] [--period N] [--cadence COL] [PATH]\n\
\x20 anomalyx scan [--baseline B] [--period N] [--cadence COL] [--columns C,..|--exclude C,..] [PATH]\n\
\x20 anomalyx explain <HANDLE> [--baseline B] [--period N] [--cadence COL] [--columns C,..|--exclude C,..] [PATH]\n\
\n\
--baseline B compare against B (distributional drift + schema-diff)\n\
--period N treat rows as a time series of period N (contextual/seasonal)\n\
--cadence COL assess column COL for metronomic timing (cadence)\n\
--columns C,.. analyze only these columns (focus a wide corpus)\n\
--exclude C,.. analyze every column except these\n\
EXIT: 0 clean · 1 anomalies found · 2 error"
}

Expand All @@ -87,9 +89,22 @@ struct ScanArgs {
baseline: Option<String>,
period: Option<usize>,
cadence: Option<String>,
/// `--columns`: analyze only these columns (allowlist).
columns: Option<Vec<String>>,
/// `--exclude`: analyze every column except these (denylist).
exclude: Option<Vec<String>>,
positional: Vec<String>,
}

/// Splits a `--columns`/`--exclude` value into trimmed, non-empty column names.
fn parse_column_list(v: &str) -> Vec<String> {
v.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect()
}

fn parse_scan_args(args: &[String]) -> Result<ScanArgs, AxError> {
let mut parsed = ScanArgs::default();
let mut it = args.iter();
Expand All @@ -116,12 +131,64 @@ fn parse_scan_args(args: &[String]) -> Result<ScanArgs, AxError> {
.ok_or_else(|| AxError::Config("--cadence requires a column name".into()))?;
parsed.cadence = Some(v.clone());
}
"--columns" => {
let v = it.next().ok_or_else(|| {
AxError::Config("--columns requires a comma-separated list".into())
})?;
let cols = parse_column_list(v);
if cols.is_empty() {
return Err(AxError::Config(
"--columns requires at least one column name".into(),
));
}
parsed.columns = Some(cols);
}
"--exclude" => {
let v = it.next().ok_or_else(|| {
AxError::Config("--exclude requires a comma-separated list".into())
})?;
let cols = parse_column_list(v);
if cols.is_empty() {
return Err(AxError::Config(
"--exclude requires at least one column name".into(),
));
}
parsed.exclude = Some(cols);
}
_ => parsed.positional.push(arg.clone()),
}
}
if parsed.columns.is_some() && parsed.exclude.is_some() {
return Err(AxError::Config(
"use --columns or --exclude, not both".into(),
));
}
Ok(parsed)
}

/// Applies any `--columns`/`--exclude` projection to a record set before
/// detection. When `validate` is set (the primary corpus), an unknown column
/// name is a hard error (exit 2) — a typo must never silently scope the scan
/// down to nothing and read as "clean". The baseline is projected leniently
/// (`validate = false`): it is a different corpus and need not carry every
/// scoped column.
fn scope_columns(rs: RecordSet, args: &ScanArgs, validate: bool) -> Result<RecordSet, AxError> {
if validate {
if let Some(names) = args.columns.as_ref().or(args.exclude.as_ref()) {
for n in names {
if rs.column(n).is_none() {
return Err(AxError::Config(format!("no such column '{n}'")));
}
}
}
}
Ok(match (&args.columns, &args.exclude) {
(Some(keep), _) => rs.select(keep),
(_, Some(drop)) => rs.without(drop),
_ => rs,
})
}

/// Builds the detector config, applying any `--period` / `--cadence` overrides.
fn config_for(args: &ScanArgs) -> DetectConfig {
DetectConfig {
Expand Down Expand Up @@ -162,8 +229,10 @@ fn load_baseline(path: &Option<String>) -> Result<Option<RecordSet>, AxError> {
fn cmd_scan(rest: &[String]) -> Result<ExitCode, AxError> {
let args = parse_scan_args(rest)?;
let (source, bytes) = read_input(args.positional.first())?;
let rs = ax_normalize::normalize(&source, &bytes)?;
let baseline = load_baseline(&args.baseline)?;
let rs = scope_columns(ax_normalize::normalize(&source, &bytes)?, &args, true)?;
let baseline = load_baseline(&args.baseline)?
.map(|b| scope_columns(b, &args, false))
.transpose()?;

let cfg = config_for(&args);
let ctx = match &baseline {
Expand Down Expand Up @@ -202,8 +271,10 @@ fn cmd_explain(rest: &[String]) -> Result<ExitCode, AxError> {
let handle = Handle::parse(handle_str).ok_or_else(|| AxError::BadHandle(handle_str.clone()))?;

let (source, bytes) = read_input(args.positional.get(1))?;
let rs = ax_normalize::normalize(&source, &bytes)?;
let baseline = load_baseline(&args.baseline)?;
let rs = scope_columns(ax_normalize::normalize(&source, &bytes)?, &args, true)?;
let baseline = load_baseline(&args.baseline)?
.map(|b| scope_columns(b, &args, false))
.transpose()?;

let evidence = resolve_handle(&rs, &handle)?;

Expand Down Expand Up @@ -463,6 +534,102 @@ mod tests {
assert!(parse_scan_args(&strings(&["--period", "notanumber"])).is_err());
}

#[test]
fn parse_column_list_trims_and_drops_empties() {
assert_eq!(parse_column_list("a, b ,c"), strings(&["a", "b", "c"]));
// surrounding/empty entries are dropped, not kept as ""
assert_eq!(parse_column_list(",a,,"), strings(&["a"]));
assert!(parse_column_list(" , ").is_empty());
}

#[test]
fn parse_scan_args_parses_columns_and_exclude() {
let a = parse_scan_args(&strings(&["--columns", "p,q", "cur.csv"])).unwrap();
assert_eq!(a.columns, Some(strings(&["p", "q"])));
assert_eq!(a.exclude, None);
let b = parse_scan_args(&strings(&["--exclude", "noise", "cur.csv"])).unwrap();
assert_eq!(b.exclude, Some(strings(&["noise"])));
assert_eq!(b.columns, None);
}

#[test]
fn parse_scan_args_rejects_columns_and_exclude_together() {
assert!(parse_scan_args(&strings(&["--columns", "a", "--exclude", "b"])).is_err());
}

#[test]
fn parse_scan_args_rejects_missing_or_empty_column_lists() {
assert!(parse_scan_args(&strings(&["--columns"])).is_err());
assert!(parse_scan_args(&strings(&["--exclude"])).is_err());
assert!(parse_scan_args(&strings(&["--columns", " , "])).is_err());
assert!(parse_scan_args(&strings(&["--exclude", ","])).is_err());
}

fn abc() -> RecordSet {
RecordSet::new(
"-",
"csv",
vec![
Column::new("a", vec![Value::Int(1), Value::Int(2)]),
Column::new("b", vec![Value::Int(3), Value::Int(4)]),
Column::new("c", vec![Value::Int(5), Value::Int(6)]),
],
)
}

#[test]
fn scope_columns_select_keeps_only_named() {
let args = ScanArgs {
columns: Some(strings(&["a", "c"])),
..ScanArgs::default()
};
let rs = scope_columns(abc(), &args, true).unwrap();
let names: Vec<&str> = rs.columns.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, ["a", "c"]);
}

#[test]
fn scope_columns_exclude_drops_named() {
let args = ScanArgs {
exclude: Some(strings(&["b"])),
..ScanArgs::default()
};
let rs = scope_columns(abc(), &args, true).unwrap();
let names: Vec<&str> = rs.columns.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, ["a", "c"]);
}

#[test]
fn scope_columns_validates_unknown_name_on_primary() {
// A typo'd column on the primary corpus is a hard error, never a silent
// empty scan that reads as "clean".
let args = ScanArgs {
columns: Some(strings(&["a", "typo"])),
..ScanArgs::default()
};
assert!(scope_columns(abc(), &args, true).is_err());
// The same unknown name on the baseline (validate = false) is tolerated:
// a different corpus need not carry every scoped column.
let scoped = scope_columns(abc(), &args, false).unwrap();
let names: Vec<&str> = scoped.columns.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, ["a"]);
}

#[test]
fn scope_columns_unknown_exclude_name_also_validated() {
let args = ScanArgs {
exclude: Some(strings(&["typo"])),
..ScanArgs::default()
};
assert!(scope_columns(abc(), &args, true).is_err());
}

#[test]
fn scope_columns_noop_without_flags() {
let rs = scope_columns(abc(), &ScanArgs::default(), true).unwrap();
assert_eq!(rs.width(), 3);
}

#[test]
fn config_for_applies_period_override() {
let a = ScanArgs {
Expand Down
Loading