From 76e3c9343d3b065064f86ac2d3f33cd00905e7e0 Mon Sep 17 00:00:00 2001 From: zuub-don Date: Mon, 1 Jun 2026 12:34:03 -0700 Subject: [PATCH] feat: --set KEY=VALUE detector-config overrides (0.9.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scan/explain gain --set KEY=VALUE (repeatable) to override any DetectConfig field by name — point_threshold, dist_alpha, mv_alpha, struct_null_rate, column_roles, etc. The settable keys + defaults are exactly describe's `config` object. Implemented as a JSON round-trip over the serialized DetectConfig, so every field is settable with zero per-field code. Unknown key or type-mismatched value → hard error (exit 2). Overrides flow into config_version (e.g. pt= changes), so a tuned run is as reproducible and self-describing as a default one — tuning is never silent. Common knobs keep their dedicated flags (--fdr/--cad-max-cv/--period/--cadence); --set is the general escape hatch for the rest. No envelope/PROTOCOL change. Gates: proptest + cargo-mutants 0-missed on main.rs (77 caught/6 unviable), incl. a test exercising the string-field override branch. Demo on the 127k parquet: --set point_threshold {2→38102, 3.5→32893, 10→18432}. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 18 ++++- Cargo.lock | 10 +-- Cargo.toml | 8 +-- crates/anomalyx/src/main.rs | 137 +++++++++++++++++++++++++++++++++++- docs/src/modes.md | 15 ++++ 5 files changed, 176 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffa8493..1e0959c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/Cargo.lock b/Cargo.lock index e8ebd73..fb44b7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -69,7 +69,7 @@ dependencies = [ [[package]] name = "anomalyx" -version = "0.8.0" +version = "0.9.0" dependencies = [ "anomalyx-core", "anomalyx-detect", @@ -80,7 +80,7 @@ dependencies = [ [[package]] name = "anomalyx-core" -version = "0.8.0" +version = "0.9.0" dependencies = [ "proptest", "serde", @@ -90,7 +90,7 @@ dependencies = [ [[package]] name = "anomalyx-detect" -version = "0.8.0" +version = "0.9.0" dependencies = [ "anomalyx-core", "proptest", @@ -101,7 +101,7 @@ dependencies = [ [[package]] name = "anomalyx-normalize" -version = "0.8.0" +version = "0.9.0" dependencies = [ "anomalyx-core", "apache-avro", @@ -129,7 +129,7 @@ dependencies = [ [[package]] name = "anomalyx-validate" -version = "0.8.0" +version = "0.9.0" dependencies = [ "anomalyx-core", "anomalyx-detect", diff --git a/Cargo.toml b/Cargo.toml index 28beebc..7641d47 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" @@ -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" diff --git a/crates/anomalyx/src/main.rs b/crates/anomalyx/src/main.rs index 2dc4df1..c9026db 100644 --- a/crates/anomalyx/src/main.rs +++ b/crates/anomalyx/src/main.rs @@ -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" } @@ -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, } +/// 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 { + 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 { + use serde_json::Value as J; + match existing { + J::Bool(_) => raw.parse::().ok().map(J::Bool), + J::Number(n) if n.is_f64() => raw + .parse::() + .ok() + .and_then(serde_json::Number::from_f64) + .map(J::Number), + J::Number(_) => raw.parse::().ok().map(|i| J::Number(i.into())), + J::String(_) => Some(J::String(raw.to_string())), + J::Null => raw + .parse::() + .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 { match s.to_ascii_lowercase().as_str() { @@ -212,6 +267,20 @@ fn parse_scan_args(args: &[String]) -> Result { "--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()) @@ -322,7 +391,7 @@ fn cmd_scan(rest: &[String]) -> Result { .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), @@ -386,7 +455,7 @@ fn cmd_explain(rest: &[String]) -> Result { 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), @@ -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(); diff --git a/docs/src/modes.md b/docs/src/modes.md index 845d6f5..972e64e 100644 --- a/docs/src/modes.md +++ b/docs/src/modes.md @@ -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