From c43a59e49107adc97406ab327591c41123f9d4c2 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Thu, 23 Apr 2026 07:19:17 +0200 Subject: [PATCH 1/2] feat(variant): rivet variant explain for debugging solve outcomes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers "why did my variant pick/skip feature X?" — a dev/debug UX gap called out in the v0.4.3 scope. Two modes: # Full audit: every effective feature + origin, unselected features, # and the constraint list rivet variant explain --model fm.yaml --variant prod.yaml # Single-feature focus: origin, attribute values, and every # constraint that mentions the feature rivet variant explain --model fm.yaml --variant prod.yaml asil-c Each effective feature carries an origin: - `selected` — user listed it under `selects:` - `mandatory` — parent group is mandatory, or is the root - `implied by ` — a constraint forced it in once was selected - `allowed` — present but not proven mandatory `--format json` emits a structured audit for scripts (dashboard uses the same shape for the variant sidebar). Coverage: - explain_single_feature_shows_origin_and_attrs (text mode) - explain_single_feature_json_mode - explain_full_variant_audit_lists_origins_and_unselected Docs: new "Debugging" subsection in docs/getting-started.md under the variant management chapter, with an origin table. Implements: REQ-046 Refs: DD-050 Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/getting-started.md | 28 ++++ rivet-cli/src/main.rs | 244 ++++++++++++++++++++++++++++++++ rivet-cli/tests/variant_emit.rs | 82 +++++++++++ 3 files changed, 354 insertions(+) diff --git a/docs/getting-started.md b/docs/getting-started.md index c847964..f55ce00 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -996,6 +996,34 @@ Exit codes for `value` / `attr`: - `1` — feature not selected (defined but absent from this variant) - `2` — unknown feature, missing attribute key, or variant fails to solve +### Debugging: `rivet variant explain` + +When a variant picks up features you didn't expect (or skips ones you +did), `rivet variant explain` answers the "why?". Two modes: + +```bash +# Full audit: every effective feature + origin, unselected features, +# and the constraint list (debugger's eye view of one solve) +rivet variant explain --model fm.yaml --variant prod.yaml + +# Single-feature focus: origin, attribute values, and every +# constraint that mentions the feature +rivet variant explain --model fm.yaml --variant prod.yaml asil-c +``` + +Each effective feature carries an **origin** explaining how it got +into the set: + +| Origin | Meaning | +|--------------------|---------| +| `selected` | You listed it under `selects:` in the variant config | +| `mandatory` | Parent group is `mandatory`, or this is the root | +| `implied by ` | A constraint forced it in once `` was selected | +| `allowed` | Present in the model, solver did not prove it mandatory | + +Add `--format json` for machine-readable output (the dashboard variant +scope uses the same shape). + ### Variants in the dashboard `rivet serve` auto-discovers variant configuration when these files exist diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index b025f3d..909894a 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -1036,6 +1036,31 @@ enum VariantAction { /// Attribute key key: String, }, + /// Explain a variant: why each feature is (or is not) selected, what + /// the solver did, and which constraints fired. Great for debugging + /// "why did my variant pick/skip feature X?". + /// + /// Without `[feature]`: prints the full audit (every effective + /// feature + origin, attributes, listed unselected features, and + /// the constraint list). With `[feature]`: prints just that + /// feature's state, origin, attribute values, and whether any + /// constraint mentions it. + Explain { + /// Path to feature model YAML file + #[arg(long)] + model: PathBuf, + + /// Path to variant configuration YAML file + #[arg(long)] + variant: PathBuf, + + /// Single feature to explain. Omit for a full-variant audit. + feature: Option, + + /// Output format: "text" (default) or "json" + #[arg(short, long, default_value = "text")] + format: String, + }, } fn main() -> ExitCode { @@ -1306,6 +1331,12 @@ fn run(cli: Cli) -> Result { feature, key, } => cmd_variant_attr(model, variant, feature, key), + VariantAction::Explain { + model, + variant, + feature, + format, + } => cmd_variant_explain(model, variant, feature.as_deref(), format), }, #[cfg(feature = "wasm")] Command::Import { @@ -8041,6 +8072,219 @@ fn cmd_variant_attr( } } +fn cmd_variant_explain( + model_path: &std::path::Path, + variant_path: &std::path::Path, + focus: Option<&str>, + format: &str, +) -> Result { + validate_format(format, &["text", "json"])?; + let (model, resolved) = load_and_solve_variant(model_path, variant_path)?; + use rivet_core::feature_model::FeatureOrigin; + + if let Some(name) = focus { + // Single-feature focus + let exists = model.features.contains_key(name); + let selected = resolved.effective_features.contains(name); + let origin = resolved.origins.get(name); + let attrs = model.features.get(name).map(|f| &f.attributes); + let mentioning_constraints: Vec = model + .constraints + .iter() + .filter_map(|c| { + let rendered = format!("{c:?}"); + if rendered.contains(&format!("\"{name}\"")) || rendered.contains(name) { + Some(rendered) + } else { + None + } + }) + .collect(); + + if format == "json" { + let origin_v = origin.map(|o| match o { + FeatureOrigin::UserSelected => serde_json::json!({ "kind": "selected" }), + FeatureOrigin::Mandatory => serde_json::json!({ "kind": "mandatory" }), + FeatureOrigin::ImpliedBy(c) => serde_json::json!({ "kind": "implied", "by": c }), + FeatureOrigin::AllowedButUnbound => serde_json::json!({ "kind": "allowed" }), + }); + let attrs_v: serde_json::Value = attrs + .map(|m| { + let o: serde_json::Map<_, _> = m + .iter() + .map(|(k, v)| (k.clone(), rivet_core_yaml_to_json(v))) + .collect(); + serde_json::Value::Object(o) + }) + .unwrap_or(serde_json::Value::Null); + let out = serde_json::json!({ + "feature": name, + "declared_in_model": exists, + "selected": selected, + "origin": origin_v, + "attributes": attrs_v, + "mentioning_constraints": mentioning_constraints, + "variant": resolved.name, + }); + println!("{}", serde_json::to_string_pretty(&out)?); + return Ok(selected); + } + + println!("Feature: {name}"); + println!(" declared in model: {}", if exists { "yes" } else { "no" }); + println!(" selected in variant `{}`: {}", resolved.name, selected); + if let Some(o) = origin { + let label = match o { + FeatureOrigin::UserSelected => "user-selected via `selects:`".to_string(), + FeatureOrigin::Mandatory => "auto-selected (mandatory group or root)".to_string(), + FeatureOrigin::ImpliedBy(cause) => format!("implied by `{cause}`"), + FeatureOrigin::AllowedButUnbound => "allowed but unbound".to_string(), + }; + println!(" origin: {label}"); + } else if selected { + println!(" origin: (no origin recorded — legacy path?)"); + } else { + println!(" origin: (not selected — feature absent from effective set)"); + } + if let Some(a) = attrs { + if a.is_empty() { + println!(" attributes: (none)"); + } else { + println!(" attributes:"); + for (k, v) in a { + let rendered = match v { + serde_yaml::Value::Null => "null".into(), + serde_yaml::Value::Bool(b) => b.to_string(), + serde_yaml::Value::Number(n) => n.to_string(), + serde_yaml::Value::String(s) => format!("\"{s}\""), + _ => serde_json::to_string(&rivet_core_yaml_to_json(v)) + .unwrap_or_else(|_| "".into()), + }; + println!(" {k} = {rendered}"); + } + } + } + if !mentioning_constraints.is_empty() { + println!(" constraints mentioning `{name}`:"); + for c in &mentioning_constraints { + println!(" - {c}"); + } + } + return Ok(selected); + } + + // Full-variant audit. + if format == "json" { + let origins: serde_json::Map = resolved + .origins + .iter() + .map(|(n, o)| { + let v = match o { + FeatureOrigin::UserSelected => serde_json::json!({ "kind": "selected" }), + FeatureOrigin::Mandatory => serde_json::json!({ "kind": "mandatory" }), + FeatureOrigin::ImpliedBy(c) => serde_json::json!({ "kind": "implied", "by": c }), + FeatureOrigin::AllowedButUnbound => serde_json::json!({ "kind": "allowed" }), + }; + (n.clone(), v) + }) + .collect(); + let unselected: Vec = model + .features + .keys() + .filter(|k| !resolved.effective_features.contains(*k)) + .cloned() + .collect(); + let constraints: Vec = + model.constraints.iter().map(|c| format!("{c:?}")).collect(); + let attrs: serde_json::Map = resolved + .effective_features + .iter() + .filter_map(|n| { + let f = model.features.get(n)?; + if f.attributes.is_empty() { + None + } else { + let inner: serde_json::Map = f + .attributes + .iter() + .map(|(k, v)| (k.clone(), rivet_core_yaml_to_json(v))) + .collect(); + Some((n.clone(), serde_json::Value::Object(inner))) + } + }) + .collect(); + let out = serde_json::json!({ + "variant": resolved.name, + "effective_features": resolved.effective_features, + "origins": origins, + "unselected_features": unselected, + "attributes": attrs, + "constraints": constraints, + }); + println!("{}", serde_json::to_string_pretty(&out)?); + return Ok(true); + } + + println!("Variant audit: `{}`", resolved.name); + println!(); + println!( + "Effective features ({} of {}):", + resolved.effective_features.len(), + model.features.len() + ); + let width = resolved + .effective_features + .iter() + .map(|s| s.len()) + .max() + .unwrap_or(0); + for name in &resolved.effective_features { + let o = resolved.origins.get(name); + let label = match o { + Some(FeatureOrigin::UserSelected) => "selected".to_string(), + Some(FeatureOrigin::Mandatory) => "mandatory".to_string(), + Some(FeatureOrigin::ImpliedBy(cause)) => format!("implied by {cause}"), + Some(FeatureOrigin::AllowedButUnbound) => "allowed".to_string(), + None => "(no origin)".to_string(), + }; + let attr_note = model + .features + .get(name) + .map(|f| { + if f.attributes.is_empty() { + String::new() + } else { + format!(" [{} attr]", f.attributes.len()) + } + }) + .unwrap_or_default(); + println!(" + {name: = model + .features + .keys() + .filter(|k| !resolved.effective_features.contains(*k)) + .collect(); + if !unselected.is_empty() { + println!(); + println!("Unselected features ({}):", unselected.len()); + for n in &unselected { + println!(" - {n}"); + } + } + + if !model.constraints.is_empty() { + println!(); + println!("Constraints ({}):", model.constraints.len()); + for c in &model.constraints { + println!(" {c:?}"); + } + } + + Ok(true) +} + /// YAML→JSON conversion for non-scalar attribute values printed by /// `rivet variant attr`. Mirrors the internal helper in `variant_emit` /// but is small enough to keep here rather than expose publicly. diff --git a/rivet-cli/tests/variant_emit.rs b/rivet-cli/tests/variant_emit.rs index 83289eb..1ddb5a1 100644 --- a/rivet-cli/tests/variant_emit.rs +++ b/rivet-cli/tests/variant_emit.rs @@ -250,6 +250,88 @@ fn value_unknown_feature_exits_two() { assert_eq!(out.status.code(), Some(2)); } +#[test] +fn explain_single_feature_shows_origin_and_attrs() { + let tmp = tempfile::tempdir().unwrap(); + let (m, v) = write_fixture(tmp.path()); + let out = Command::new(rivet_bin()) + .args([ + "variant", "explain", + "--model", m.to_str().unwrap(), + "--variant", v.to_str().unwrap(), + "asil-c", + ]) + .output() + .unwrap(); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("Feature: asil-c")); + assert!(stdout.contains("selected in variant `prod`: true")); + assert!(stdout.contains("user-selected via `selects:`")); + assert!(stdout.contains("asil-numeric = 3")); + assert!(stdout.contains("reqs = \"fmea-dfa\"")); +} + +#[test] +fn explain_single_feature_json_mode() { + let tmp = tempfile::tempdir().unwrap(); + let (m, v) = write_fixture(tmp.path()); + let out = Command::new(rivet_bin()) + .args([ + "variant", "explain", + "--model", m.to_str().unwrap(), + "--variant", v.to_str().unwrap(), + "--format", "json", + "asil-c", + ]) + .output() + .unwrap(); + assert!(out.status.success()); + let v: serde_json::Value = serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap(); + assert_eq!(v["feature"], "asil-c"); + assert_eq!(v["selected"], true); + assert_eq!(v["origin"]["kind"], "selected"); + assert_eq!(v["attributes"]["asil-numeric"], 3); +} + +#[test] +fn explain_full_variant_audit_lists_origins_and_unselected() { + let tmp = tempfile::tempdir().unwrap(); + // Model with an optional feature so we get both selected and unselected + let model = tmp.path().join("feature-model.yaml"); + fs::write( + &model, + r#" +root: rt +features: + rt: { group: optional, children: [a, b, c] } + a: { group: leaf } + b: { group: leaf } + c: { group: leaf } +"#, + ) + .unwrap(); + let variant = tmp.path().join("v.yaml"); + fs::write(&variant, "name: v\nselects:\n - a\n").unwrap(); + + let out = Command::new(rivet_bin()) + .args([ + "variant", "explain", + "--model", model.to_str().unwrap(), + "--variant", variant.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("Variant audit: `v`")); + assert!(stdout.contains("Effective features")); + assert!(stdout.contains("+ a")); + assert!(stdout.contains("Unselected features")); + assert!(stdout.contains("- b")); + assert!(stdout.contains("- c")); +} + #[test] fn attr_prints_scalar_and_errors_on_missing_key() { let tmp = tempfile::tempdir().unwrap(); From 6cf1c4131f0c612117b55eb1b4989125dd721b7d Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Thu, 23 Apr 2026 07:23:01 +0200 Subject: [PATCH 2/2] test(variant): enrich eu-adas-c example + per-format smoke on realistic model Adds realistic `attributes:` to examples/variant/feature-model.yaml for every market (eu/us/cn with compliance+locale) and every ASIL level (asil-numeric + required analysis techniques). These match the worked examples in docs/getting-started.md so users can run the snippets against the shipped fixture and see the same output. New integration test `every_format_renders_realistic_example` exercises all 7 --format values against the enriched example and asserts each output contains the variant name and the asil-c marker (in whatever casing the format uses). Catches regressions that pass on toy models but break on constraint-driven inclusion, multi-attr features, or non-trivial tree depth. Implements: REQ-046 Refs: DD-050 Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/variant/feature-model.yaml | 28 ++++++++++++++++++ rivet-cli/tests/variant_emit.rs | 45 +++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/examples/variant/feature-model.yaml b/examples/variant/feature-model.yaml index 37cba93..603aa95 100644 --- a/examples/variant/feature-model.yaml +++ b/examples/variant/feature-model.yaml @@ -10,27 +10,55 @@ features: group: alternative children: [eu, us, cn] + # Market-specific compliance metadata eu: group: leaf + attributes: + compliance: "unece-r157" + locale: "en_EU" us: group: leaf + attributes: + compliance: "fmvss-127" + locale: "en_US" cn: group: leaf + attributes: + compliance: "gb-7258" + locale: "zh_CN" safety-level: group: alternative children: [qm, asil-a, asil-b, asil-c, asil-d] + # Each safety level carries an ISO 26262 numeric rating plus the + # minimum required analysis techniques. These show up in emitted + # build config as RIVET_ATTR_ASIL_C_ASIL_NUMERIC=3 etc. qm: group: leaf + attributes: + asil-numeric: 0 + reqs: "none" asil-a: group: leaf + attributes: + asil-numeric: 1 + reqs: "fmea" asil-b: group: leaf + attributes: + asil-numeric: 2 + reqs: "fmea" asil-c: group: leaf + attributes: + asil-numeric: 3 + reqs: "fmea-dfa" asil-d: group: leaf + attributes: + asil-numeric: 4 + reqs: "fmea-dfa-fta" feature-set: group: or diff --git a/rivet-cli/tests/variant_emit.rs b/rivet-cli/tests/variant_emit.rs index 1ddb5a1..b4b339c 100644 --- a/rivet-cli/tests/variant_emit.rs +++ b/rivet-cli/tests/variant_emit.rs @@ -332,6 +332,51 @@ features: assert!(stdout.contains("- c")); } +/// Smoke every formatter against the shipped examples/variant/ fixture. +/// Catches regressions where a format change works on a toy model but +/// breaks on a realistic one (constraint-driven inclusion, multiple +/// attribute types per feature, non-trivial tree depth). +#[test] +fn every_format_renders_realistic_example() { + let manifest = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_root = manifest.parent().expect("workspace root"); + let model = workspace_root.join("examples/variant/feature-model.yaml"); + let variant = workspace_root.join("examples/variant/eu-adas-c.yaml"); + if !model.exists() || !variant.exists() { + // Keep this test silent if the examples dir is stripped from + // a release tarball — real users run it against the repo. + return; + } + for fmt in ["json", "env", "cargo", "cmake", "cpp-header", "bazel", "make"] { + let out = Command::new(rivet_bin()) + .args([ + "variant", "features", + "--model", model.to_str().unwrap(), + "--variant", variant.to_str().unwrap(), + "--format", fmt, + ]) + .output() + .unwrap_or_else(|e| panic!("rivet variant features --format {fmt}: {e}")); + assert!( + out.status.success(), + "--format {fmt} failed: stderr={}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + // Every format should mention the variant name and ASIL-C + // (the headline feature from the example). + assert!( + stdout.contains("eu-adas-c"), + "--format {fmt}: variant name missing in output:\n{stdout}" + ); + let feature_markers = ["ASIL_C", "asil-c"]; + assert!( + feature_markers.iter().any(|m| stdout.contains(m)), + "--format {fmt}: no asil-c marker in output:\n{stdout}" + ); + } +} + #[test] fn attr_prints_scalar_and_errors_on_missing_key() { let tmp = tempfile::tempdir().unwrap();