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

## [Unreleased]

## [0.9.0] - 2026-06-01

### Added

- **`scan` / `explain` gain `--set KEY=VALUE`** (repeatable) — override any
detector-config field by name (`--set point_threshold=4.0`, `--set
dist_alpha=0.01`, `--set column_roles=false`, …). The settable keys and their
defaults are exactly what `describe`'s `config` object lists. An unknown key,
or a value that doesn't fit the field's type, is a hard error (exit `2`).
Overrides flow into `config_version`, so a tuned run stays reproducible and
self-describing — tuning is never silent. (The common knobs keep their
dedicated flags: `--fdr`, `--cad-max-cv`, `--period`, `--cadence`.)
- Implemented as a JSON round-trip over the serialized `DetectConfig`, so every
field is settable with no per-field code; no envelope/`PROTOCOL` change.

### Testing

- **Golden-envelope snapshot tests** (`anomalyx/tests/golden.rs`). Run the actual
Expand Down Expand Up @@ -294,7 +309,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.8.0...HEAD
[Unreleased]: https://github.com/copyleftdev/anomalyx/compare/v0.9.0...HEAD
[0.9.0]: https://github.com/copyleftdev/anomalyx/compare/v0.8.0...v0.9.0
[0.8.0]: https://github.com/copyleftdev/anomalyx/compare/v0.7.0...v0.8.0
[0.7.0]: https://github.com/copyleftdev/anomalyx/compare/v0.6.0...v0.7.0
[0.6.0]: https://github.com/copyleftdev/anomalyx/compare/v0.5.0...v0.6.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.8.0"
version = "0.9.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.8.0", package = "anomalyx-core" }
ax-normalize = { path = "crates/ax-normalize", version = "0.8.0", package = "anomalyx-normalize" }
ax-detect = { path = "crates/ax-detect", version = "0.8.0", package = "anomalyx-detect" }
ax-core = { path = "crates/ax-core", version = "0.9.0", package = "anomalyx-core" }
ax-normalize = { path = "crates/ax-normalize", version = "0.9.0", package = "anomalyx-normalize" }
ax-detect = { path = "crates/ax-detect", version = "0.9.0", package = "anomalyx-detect" }

serde = { version = "1", features = ["derive"] }
serde_json = "1"
Expand Down
137 changes: 135 additions & 2 deletions crates/anomalyx/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ fn usage() -> &'static str {
--top N emit only the N most severe findings (summary/exit unchanged)\n\
--min-severity S emit only findings ≥ S (info|low|medium|high|critical)\n\
--no-column-roles don't skip identifier/sequence columns (roles still shown)\n\
--set K=V override a detector-config field (see `describe`); repeatable\n\
EXIT: 0 clean · 1 anomalies found · 2 error"
}

Expand Down Expand Up @@ -111,9 +112,63 @@ struct ScanArgs {
/// `--no-column-roles`: disable role-based detector skipping (roles are
/// still reported in the envelope).
no_column_roles: bool,
/// `--set key=value`: detector-config field overrides, applied in order.
sets: Vec<(String, String)>,
positional: Vec<String>,
}

/// Applies `--set key=value` overrides onto `cfg` by field name, via a JSON
/// round-trip so every [`DetectConfig`] field is settable without per-field
/// code. An unknown key, or a value that doesn't fit the field, is a hard error
/// (exit `2`). The overridden values feed `config_version`, so every change is
/// visible and reproducible — tuning is never silent.
fn apply_overrides(cfg: DetectConfig, sets: &[(String, String)]) -> Result<DetectConfig, AxError> {
if sets.is_empty() {
return Ok(cfg);
}
let mut value = serde_json::to_value(&cfg).expect("DetectConfig serializes");
let obj = value
.as_object_mut()
.expect("DetectConfig is a JSON object");
for (key, raw) in sets {
let existing = obj.get(key).ok_or_else(|| {
AxError::Config(format!(
"--set: unknown config key '{key}' (see `describe`)"
))
})?;
let parsed = parse_override(existing, raw).ok_or_else(|| {
AxError::Config(format!("--set {key}: cannot parse '{raw}' for this field"))
})?;
obj.insert(key.clone(), parsed);
}
serde_json::from_value(value)
.map_err(|e| AxError::Config(format!("--set produced an invalid config: {e}")))
}

/// Parses an override string into the JSON shape of the field's current value:
/// bool→bool, float-number→float, integer-number→integer, string→string, and a
/// currently-null (optional) field → number else string.
fn parse_override(existing: &serde_json::Value, raw: &str) -> Option<serde_json::Value> {
use serde_json::Value as J;
match existing {
J::Bool(_) => raw.parse::<bool>().ok().map(J::Bool),
J::Number(n) if n.is_f64() => raw
.parse::<f64>()
.ok()
.and_then(serde_json::Number::from_f64)
.map(J::Number),
J::Number(_) => raw.parse::<i64>().ok().map(|i| J::Number(i.into())),
J::String(_) => Some(J::String(raw.to_string())),
J::Null => raw
.parse::<f64>()
.ok()
.and_then(serde_json::Number::from_f64)
.map(J::Number)
.or_else(|| Some(J::String(raw.to_string()))),
_ => None,
}
}

/// Parses a severity name (case-insensitive) to its bucket.
fn parse_severity(s: &str) -> Option<Severity> {
match s.to_ascii_lowercase().as_str() {
Expand Down Expand Up @@ -212,6 +267,20 @@ fn parse_scan_args(args: &[String]) -> Result<ScanArgs, AxError> {
"--no-column-roles" => {
parsed.no_column_roles = true;
}
"--set" => {
let v = it
.next()
.ok_or_else(|| AxError::Config("--set requires KEY=VALUE".into()))?;
let (k, val) = v.split_once('=').ok_or_else(|| {
AxError::Config(format!("--set expects KEY=VALUE, got '{v}'"))
})?;
if k.is_empty() || val.is_empty() {
return Err(AxError::Config(format!(
"--set KEY and VALUE must both be non-empty, got '{v}'"
)));
}
parsed.sets.push((k.to_string(), val.to_string()));
}
"--columns" => {
let v = it.next().ok_or_else(|| {
AxError::Config("--columns requires a comma-separated list".into())
Expand Down Expand Up @@ -322,7 +391,7 @@ fn cmd_scan(rest: &[String]) -> Result<ExitCode, AxError> {
.map(|b| scope_columns(b, &args, false))
.transpose()?;

let cfg = config_for(&args);
let cfg = apply_overrides(config_for(&args), &args.sets)?;
let ctx = match &baseline {
Some(b) => ScanContext::compared(b, &rs),
None => ScanContext::single(&rs),
Expand Down Expand Up @@ -386,7 +455,7 @@ fn cmd_explain(rest: &[String]) -> Result<ExitCode, AxError> {
let evidence = resolve_handle(&rs, &handle)?;

// Re-run detection and attach any findings that point at this handle.
let cfg = config_for(&args);
let cfg = apply_overrides(config_for(&args), &args.sets)?;
let ctx = match &baseline {
Some(b) => ScanContext::compared(b, &rs),
None => ScanContext::single(&rs),
Expand Down Expand Up @@ -661,6 +730,70 @@ mod tests {
assert!(parse_scan_args(&strings(&["--fdr", "inf"])).is_err());
}

#[test]
fn parse_scan_args_parses_set_overrides() {
let a = parse_scan_args(&strings(&[
"--set",
"point_threshold=4.0",
"--set",
"column_roles=false",
"x.csv",
]))
.unwrap();
assert_eq!(
a.sets,
vec![
("point_threshold".to_string(), "4.0".to_string()),
("column_roles".to_string(), "false".to_string()),
]
);
// missing value, no '=', empty key/value are rejected
assert!(parse_scan_args(&strings(&["--set"])).is_err());
assert!(parse_scan_args(&strings(&["--set", "noequals"])).is_err());
assert!(parse_scan_args(&strings(&["--set", "=5"])).is_err());
assert!(parse_scan_args(&strings(&["--set", "k="])).is_err());
}

#[test]
fn apply_overrides_sets_fields_and_rejects_bad_keys_and_values() {
let base = DetectConfig::default();
// float, integer, and bool fields all override by name
let cfg = apply_overrides(
base.clone(),
&[
("point_threshold".into(), "4.0".into()),
("point_min_n".into(), "5".into()),
("column_roles".into(), "false".into()),
],
)
.unwrap();
assert_eq!(cfg.point_threshold, 4.0);
assert_eq!(cfg.point_min_n, 5);
assert!(!cfg.column_roles);
// an Option field (currently None) can be set
let cfg2 = apply_overrides(base.clone(), &[("point_fdr_q".into(), "0.05".into())]).unwrap();
assert_eq!(cfg2.point_fdr_q, Some(0.05));
// a field whose current value is a string (an already-set cadence column)
// overrides as a string — exercises the string branch.
let with_cad = DetectConfig {
cadence_column: Some("ts".into()),
..DetectConfig::default()
};
let cfg3 = apply_overrides(with_cad, &[("cadence_column".into(), "other".into())]).unwrap();
assert_eq!(cfg3.cadence_column.as_deref(), Some("other"));
// overriding changes the config-version fingerprint (visible, reproducible)
assert_ne!(cfg.version(), base.version());
// empty overrides ⇒ unchanged
assert_eq!(
apply_overrides(base.clone(), &[]).unwrap().version(),
base.version()
);
// unknown key and unparseable value are hard errors
assert!(apply_overrides(base.clone(), &[("nope".into(), "1".into())]).is_err());
assert!(apply_overrides(base.clone(), &[("point_min_n".into(), "4.5".into())]).is_err());
assert!(apply_overrides(base, &[("column_roles".into(), "maybe".into())]).is_err());
}

#[test]
fn no_column_roles_flag_toggles_config() {
let a = parse_scan_args(&strings(&["--no-column-roles", "x.csv"])).unwrap();
Expand Down
15 changes: 15 additions & 0 deletions docs/src/modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,21 @@ real 20k-entry journald capture it cuts point findings from ~12,500 to ~240 (the
`_PID`/`_UID`/`JOB_ID`/timestamp columns) while leaving genuine measurements
untouched. The setting is part of `config_version` (`cr=`).

## `--set KEY=VALUE` — tune detector config

Every detector threshold is a field of the config that `describe` reports.
`--set` overrides any of them by name (repeatable):

```console
$ anomalyx scan --set point_threshold=4.0 --set dist_alpha=0.01 data.csv
$ anomalyx describe | jq .config # the settable keys + their defaults
```

An unknown key or a value that doesn't fit the field is a hard error (exit `2`).
Overrides flow into `config_version`, so a tuned run is just as reproducible and
self-describing as a default one — the knob is never hidden. (The common knobs
also have dedicated flags: `--fdr`, `--cad-max-cv`, `--period`, `--cadence`.)

## `--top N` / `--min-severity S` — output scoping

Detection can surface tens of thousands of findings on a large corpus. These two
Expand Down