From eb81803291130c80439110839578915a0fbae261 Mon Sep 17 00:00:00 2001 From: Shreyas Sankpal Date: Sun, 5 Apr 2026 00:38:04 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20complete=20v1.3.0=20=E2=80=94=20doctor?= =?UTF-8?q?=20diagnostics,=20--backend=20flag,=20conformance=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #52 Provider-aware doctor diagnostics: - Add ProviderRegistry::diagnose_all() returning BackendDiagnostics - Doctor output enumerates all compiled backends with availability - Doctor JSON includes backends array with per-backend diagnostics - Updated doctor-introspection schema and example #53 CLI --backend flag and auto-select strategy: - Add --backend flag to override backend selection - Support [inference] section in TOML config (backend = 'auto'|'cpu'|etc.) - WRAITHRUN_BACKEND env var support - Auto-select picks highest-priority available backend when unspecified - Helpful error when requested backend is unavailable/unknown - Run report JSON includes selected backend name - Updated run-report schema and example #54 Integration test harness for multi-backend conformance: - backend_contract_tests! macro validates all trait methods - 9 contract tests per backend (name, priority, availability, diagnostics, sessions) - 5 registry-level tests (discover, diagnose_all, best_available, fallback) - CPU conformance suite runs on every CI leg - Vitis conformance suite runs when compiled with vitis feature Closes #52, closes #53, closes #54 --- cli/src/main.rs | 186 ++++++++++++++++- core_engine/src/agent.rs | 2 + core_engine/src/lib.rs | 2 + docs/schemas/doctor-introspection.schema.json | 40 ++++ .../doctor-introspection.example.json | 26 ++- docs/schemas/examples/run-report.example.json | 1 + docs/schemas/run-report.schema.json | 4 + inference_bridge/src/backend.rs | 29 +++ inference_bridge/tests/backend_conformance.rs | 195 ++++++++++++++++++ wraithrun.example.toml | 6 + 10 files changed, 488 insertions(+), 3 deletions(-) create mode 100644 inference_bridge/tests/backend_conformance.rs diff --git a/cli/src/main.rs b/cli/src/main.rs index 0528895..d343213 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -485,7 +485,9 @@ use core_engine::{ }; use cyber_tools::{ToolRegistry, ToolSpec}; use inference_bridge::onnx_vitis::{inspect_runtime_compatibility, RuntimeCompatibilitySeverity}; -use inference_bridge::{probe_model_capability, ModelConfig, OnnxVitisEngine, VitisEpConfig}; +use inference_bridge::{ + backend::ProviderRegistry, probe_model_capability, ModelConfig, OnnxVitisEngine, VitisEpConfig, +}; use serde::{Deserialize, Serialize}; use serde_json::Value; use sha2::{Digest, Sha256}; @@ -774,6 +776,11 @@ struct Cli { #[arg(long)] vitis_cache_key: Option, + /// Override inference backend selection (e.g. "cpu", "vitis"). + /// Use "auto" or omit to auto-select the highest-priority available backend. + #[arg(long, value_name = "NAME")] + backend: Option, + #[arg(long, value_enum)] capability_override: Option, } @@ -802,6 +809,13 @@ struct SettingsFragment { vitis_config: Option, vitis_cache_dir: Option, vitis_cache_key: Option, + backend: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct InferenceConfig { + backend: Option, } #[derive(Debug, Clone, Default, Deserialize)] @@ -810,6 +824,8 @@ struct FileConfig { #[serde(flatten)] defaults: SettingsFragment, profiles: HashMap, + #[serde(default)] + inference: InferenceConfig, } #[derive(Debug, Clone)] @@ -836,6 +852,7 @@ struct RuntimeConfig { vitis_config: Option, vitis_cache_dir: Option, vitis_cache_key: Option, + backend: Option, capability_override: Option, tools_dir: Option, allowed_plugins: Vec, @@ -865,6 +882,7 @@ struct RuntimeConfigView { vitis_config: Option, vitis_cache_dir: Option, vitis_cache_key: Option, + backend: Option, } #[derive(Debug, Clone, Serialize)] @@ -891,6 +909,7 @@ struct RuntimeConfigSources { vitis_config: String, vitis_cache_dir: String, vitis_cache_key: String, + backend: String, } #[derive(Debug, Serialize, Deserialize)] @@ -1088,6 +1107,8 @@ struct DoctorSummaryView { struct DoctorReportView<'a> { summary: DoctorSummaryView, checks: &'a [DoctorCheck], + #[serde(skip_serializing_if = "<[DoctorBackendEntry]>::is_empty")] + backends: &'a [DoctorBackendEntry], } #[derive(Debug, Default, Serialize)] @@ -1155,6 +1176,7 @@ impl RuntimeConfig { vitis_config: None, vitis_cache_dir: None, vitis_cache_key: None, + backend: None, capability_override: None, tools_dir: None, allowed_plugins: Vec::new(), @@ -1222,6 +1244,9 @@ impl RuntimeConfig { if let Some(vitis_cache_key) = &fragment.vitis_cache_key { self.vitis_cache_key = Some(vitis_cache_key.clone()); } + if let Some(backend) = &fragment.backend { + self.backend = Some(backend.clone()); + } } } @@ -1250,6 +1275,7 @@ impl RuntimeConfigSources { vitis_config: "default".to_string(), vitis_cache_dir: "default".to_string(), vitis_cache_key: "default".to_string(), + backend: "default".to_string(), } } } @@ -1338,6 +1364,23 @@ struct DoctorCheck { #[derive(Debug, Default, Serialize)] struct DoctorReport { checks: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + backends: Vec, +} + +#[derive(Debug, Clone, Serialize)] +struct DoctorBackendEntry { + name: String, + priority: u32, + available: bool, + diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize)] +struct DoctorBackendDiag { + status: DoctorStatus, + check: String, + detail: String, } impl DoctorReport { @@ -2215,6 +2258,13 @@ fn merge_sources( if let Some(file_config) = file_config { resolved.apply_fragment(&file_config.defaults); + // Apply [inference] section if present. + if let Some(backend) = &file_config.inference.backend { + if backend != "auto" { + resolved.backend = Some(backend.clone()); + } + } + if let Some(profile_name) = profile.as_deref() { if let Some(profile_settings) = lookup_profile(&file_config.profiles, profile_name) { resolved.apply_fragment(profile_settings); @@ -2273,6 +2323,16 @@ fn merge_sources_with_explanation( .unwrap_or_else(|| "config defaults".to_string()); apply_fragment_with_source(&mut resolved, &mut sources, &file_config.defaults, &source); + // Apply [inference] section if present. + if let Some(backend) = &file_config.inference.backend { + if backend != "auto" { + resolved.backend = Some(backend.clone()); + sources.backend = file_config_path + .map(|path| format!("config [inference] ({})", path.display())) + .unwrap_or_else(|| "config [inference]".to_string()); + } + } + if let Some(profile_name) = profile.as_deref() { if let Some(profile_settings) = lookup_profile(&file_config.profiles, profile_name) { let source = file_config_path @@ -2477,6 +2537,7 @@ fn env_settings_fragment() -> Result { vitis_config: read_env_string("WRAITHRUN_VITIS_CONFIG")?, vitis_cache_dir: read_env_string("WRAITHRUN_VITIS_CACHE_DIR")?, vitis_cache_key: read_env_string("WRAITHRUN_VITIS_CACHE_KEY")?, + backend: read_env_string("WRAITHRUN_BACKEND")?, }) } @@ -2550,6 +2611,9 @@ fn apply_cli_overrides(runtime: &mut RuntimeConfig, cli: &Cli) { if let Some(vitis_cache_key) = &cli.vitis_cache_key { runtime.vitis_cache_key = Some(vitis_cache_key.clone()); } + if let Some(backend) = &cli.backend { + runtime.backend = Some(backend.clone()); + } if let Some(capability_override) = cli.capability_override { runtime.capability_override = Some(capability_override); } @@ -2651,6 +2715,10 @@ fn apply_fragment_with_source( runtime.vitis_cache_key = Some(vitis_cache_key.clone()); sources.vitis_cache_key = source.to_string(); } + if let Some(backend) = &fragment.backend { + runtime.backend = Some(backend.clone()); + sources.backend = source.to_string(); + } } fn apply_cli_overrides_with_source( @@ -2750,6 +2818,10 @@ fn apply_cli_overrides_with_source( runtime.vitis_cache_key = Some(vitis_cache_key.clone()); sources.vitis_cache_key = "cli --vitis-cache-key".to_string(); } + if let Some(backend) = &cli.backend { + runtime.backend = Some(backend.clone()); + sources.backend = "cli --backend".to_string(); + } if let Some(capability_override) = cli.capability_override { runtime.capability_override = Some(capability_override); } @@ -3733,6 +3805,7 @@ impl RuntimeConfigView { vitis_config: runtime.vitis_config.clone(), vitis_cache_dir: runtime.vitis_cache_dir.clone(), vitis_cache_key: runtime.vitis_cache_key.clone(), + backend: runtime.backend.clone(), } } } @@ -3925,6 +3998,59 @@ fn run_doctor(cli: &Cli) -> DoctorReport { } } + // Inference backend diagnostics. + let registry = ProviderRegistry::discover(); + for bd in registry.diagnose_all() { + let status = if bd.info.available { + DoctorStatus::Pass + } else { + DoctorStatus::Warn + }; + let availability = if bd.info.available { + "available" + } else { + "not available" + }; + report.push( + status, + "inference-backend", + format!( + "{} (priority {}) — {}", + bd.info.name, bd.info.priority, availability + ), + ); + let mut backend_diags = Vec::new(); + for diag in &bd.diagnostics { + let diag_status = match diag.severity { + inference_bridge::backend::DiagnosticSeverity::Pass => { + DoctorStatus::Pass + } + inference_bridge::backend::DiagnosticSeverity::Warn => { + DoctorStatus::Warn + } + inference_bridge::backend::DiagnosticSeverity::Fail => { + DoctorStatus::Fail + } + }; + report.push( + diag_status, + "inference-backend", + format!(" {} — {}: {}", bd.info.name, diag.check, diag.message), + ); + backend_diags.push(DoctorBackendDiag { + status: diag_status, + check: diag.check.clone(), + detail: diag.message.clone(), + }); + } + report.backends.push(DoctorBackendEntry { + name: bd.info.name, + priority: bd.info.priority, + available: bd.info.available, + diagnostics: backend_diags, + }); + } + // Plugin tools check. if !runtime.allowed_plugins.is_empty() { let tools_dir = runtime @@ -4492,6 +4618,7 @@ fn render_doctor_report_json(report: &DoctorReport) -> Result { fail: fail_count, }, checks: &report.checks, + backends: &report.backends, }; render_json_with_contract(&view) } @@ -5744,9 +5871,14 @@ async fn run_agent_once(runtime: &RuntimeConfig, dry_run: bool) -> Result (Some("vitis".to_string()), cfg.into_backend_config()), + None if runtime.backend.is_some() => (runtime.backend.clone(), Default::default()), None => (None, Default::default()), }; let model_config = ModelConfig { @@ -5759,6 +5891,8 @@ async fn run_agent_once(runtime: &RuntimeConfig, dry_run: bool) -> Result probe > default. let (tier, capability_report) = if let Some(cap_override) = runtime.capability_override { let tier = cap_override.to_tier(); @@ -5800,7 +5934,50 @@ async fn run_agent_once(runtime: &RuntimeConfig, dry_run: bool) -> Result Result { + if let Some(requested) = &runtime.backend { + if requested.eq_ignore_ascii_case("auto") { + // Explicit "auto" — fall through to auto-select. + } else { + // User asked for a specific backend. + match registry.get(requested) { + Some(backend) => { + if backend.is_available() { + return Ok(backend.name().to_string()); + } + bail!( + "{} backend is registered but not available on this system", + backend.name() + ); + } + None => { + let available = registry + .list() + .iter() + .map(|p| p.name.clone()) + .collect::>() + .join(", "); + bail!( + "\"{}\" backend not found; available backends: {}", + requested, + available + ); + } + } + } + } + + // Auto-select: pick highest-priority available backend. + match registry.best_available() { + Some(backend) => Ok(backend.name().to_string()), + None => bail!("no inference backend available"), + } } fn append_live_fallback_finding(report: &mut RunReport, decision: &LiveFallbackDecision) { @@ -6006,6 +6183,7 @@ mod tests { vitis_config: None, vitis_cache_dir: None, vitis_cache_key: None, + backend: None, capability_override: None, tools_dir: None, allowed_plugins: vec![], @@ -6017,6 +6195,7 @@ mod tests { task: "Check suspicious listener ports and summarize risk".to_string(), case_id: Some("CASE-2026-0001".to_string()), max_severity: Some(FindingSeverity::Medium), + backend: None, model_capability: None, live_fallback_decision: None, run_timing: None, @@ -6673,6 +6852,7 @@ mod tests { ..SettingsFragment::default() }, )]), + ..Default::default() }; let env_overrides = SettingsFragment { @@ -6911,6 +7091,7 @@ mod tests { ("team-default".to_string(), SettingsFragment::default()), ("incident-hotfix".to_string(), SettingsFragment::default()), ]), + ..Default::default() }; let rendered = render_profile_list( @@ -6930,6 +7111,7 @@ mod tests { let file_config = FileConfig { defaults: SettingsFragment::default(), profiles: HashMap::from([("incident-hotfix".to_string(), SettingsFragment::default())]), + ..Default::default() }; let rendered = render_profile_list_json( diff --git a/core_engine/src/agent.rs b/core_engine/src/agent.rs index fc4b893..2a384f2 100644 --- a/core_engine/src/agent.rs +++ b/core_engine/src/agent.rs @@ -117,6 +117,7 @@ impl Agent { task: task.to_string(), case_id: None, max_severity: Some(FindingSeverity::Info), + backend: None, model_capability: self.model_capability_report.clone(), live_fallback_decision: None, run_timing: Some(build_run_timing_metrics(run_started_at, None)), @@ -212,6 +213,7 @@ impl Agent { task: task.to_string(), case_id: None, max_severity: report_max_severity, + backend: None, model_capability: self.model_capability_report.clone(), live_fallback_decision: None, run_timing: Some(build_run_timing_metrics( diff --git a/core_engine/src/lib.rs b/core_engine/src/lib.rs index c1aa152..240d00a 100644 --- a/core_engine/src/lib.rs +++ b/core_engine/src/lib.rs @@ -361,6 +361,8 @@ pub struct RunReport { #[serde(default, skip_serializing_if = "Option::is_none")] pub max_severity: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub backend: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub model_capability: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub live_fallback_decision: Option, diff --git a/docs/schemas/doctor-introspection.schema.json b/docs/schemas/doctor-introspection.schema.json index 24f385e..76764e6 100644 --- a/docs/schemas/doctor-introspection.schema.json +++ b/docs/schemas/doctor-introspection.schema.json @@ -54,6 +54,46 @@ }, "additionalProperties": false } + }, + "backends": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "priority", "available", "diagnostics"], + "properties": { + "name": { + "type": "string" + }, + "priority": { + "type": "integer", + "minimum": 0 + }, + "available": { + "type": "boolean" + }, + "diagnostics": { + "type": "array", + "items": { + "type": "object", + "required": ["status", "check", "detail"], + "properties": { + "status": { + "type": "string", + "enum": ["pass", "warn", "fail"] + }, + "check": { + "type": "string" + }, + "detail": { + "type": "string" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } } }, "additionalProperties": false diff --git a/docs/schemas/examples/doctor-introspection.example.json b/docs/schemas/examples/doctor-introspection.example.json index 93cc60a..4f9409a 100644 --- a/docs/schemas/examples/doctor-introspection.example.json +++ b/docs/schemas/examples/doctor-introspection.example.json @@ -1,7 +1,7 @@ { "contract_version": "1.0.0", "summary": { - "pass": 5, + "pass": 7, "warn": 1, "fail": 0 }, @@ -37,6 +37,30 @@ "detail": "Vitis config is set but file was not found: ./models/vitis_ep.json", "reason_code": "vitis_config_missing", "remediation": "Install the RyzenAI SDK or set ORT_DYLIB_PATH to a Vitis-enabled ONNX Runtime build." + }, + { + "status": "pass", + "name": "inference-backend", + "detail": "CPU (priority 0) — available" + }, + { + "status": "pass", + "name": "inference-backend", + "detail": " CPU — cpu-backend: CPU execution provider is always available" + } + ], + "backends": [ + { + "name": "CPU", + "priority": 0, + "available": true, + "diagnostics": [ + { + "status": "pass", + "check": "cpu-backend", + "detail": "CPU execution provider is always available" + } + ] } ] } diff --git a/docs/schemas/examples/run-report.example.json b/docs/schemas/examples/run-report.example.json index 7c43514..3ed367f 100644 --- a/docs/schemas/examples/run-report.example.json +++ b/docs/schemas/examples/run-report.example.json @@ -3,6 +3,7 @@ "task": "Investigate unauthorized SSH keys", "case_id": "CASE-2026-IR-1001", "max_severity": "medium", + "backend": "CPU", "model_capability": { "tier": "basic", "estimated_params_b": 1.2, diff --git a/docs/schemas/run-report.schema.json b/docs/schemas/run-report.schema.json index ed1cc88..1a91a70 100644 --- a/docs/schemas/run-report.schema.json +++ b/docs/schemas/run-report.schema.json @@ -25,6 +25,10 @@ "type": ["string", "null"], "enum": ["info", "low", "medium", "high", "critical", null] }, + "backend": { + "type": ["string", "null"], + "description": "Name of the inference backend used for this run (e.g. CPU, AMD Vitis NPU)." + }, "model_capability": { "type": ["object", "null"], "required": ["tier", "estimated_params_b", "execution_provider", "smoke_latency_ms", "vocab_size"], diff --git a/inference_bridge/src/backend.rs b/inference_bridge/src/backend.rs index b7c48f4..80b9e65 100644 --- a/inference_bridge/src/backend.rs +++ b/inference_bridge/src/backend.rs @@ -81,6 +81,13 @@ pub struct ProviderInfo { pub available: bool, } +/// Full diagnostic output for a single backend. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BackendDiagnostics { + pub info: ProviderInfo, + pub diagnostics: Vec, +} + /// Abstraction over a hardware execution provider. /// /// Backends report their availability at runtime (not just compile time) so @@ -201,6 +208,28 @@ impl ProviderRegistry { .collect() } + /// Run diagnostics on every registered backend and return results. + /// + /// Each entry pairs the backend's [`ProviderInfo`] with its diagnostic + /// entries, sorted by descending priority so the most-preferred backend + /// appears first. + pub fn diagnose_all(&self) -> Vec { + let mut results: Vec = self + .backends + .iter() + .map(|b| BackendDiagnostics { + info: ProviderInfo { + name: b.name().to_string(), + priority: b.priority(), + available: b.is_available(), + }, + diagnostics: b.diagnose(), + }) + .collect(); + results.sort_by_key(|b| std::cmp::Reverse(b.info.priority)); + results + } + /// Lists the names of all available backends. pub fn available_names(&self) -> Vec { self.backends diff --git a/inference_bridge/tests/backend_conformance.rs b/inference_bridge/tests/backend_conformance.rs new file mode 100644 index 0000000..404f333 --- /dev/null +++ b/inference_bridge/tests/backend_conformance.rs @@ -0,0 +1,195 @@ +//! Multi-backend conformance test harness. +//! +//! This module validates that every `ExecutionProviderBackend` implementation +//! satisfies the trait contract. Adding a new backend requires only one +//! invocation of the `backend_contract_tests!` macro. + +use inference_bridge::backend::{ + BackendOptions, CpuBackend, DiagnosticSeverity, ExecutionProviderBackend, ProviderRegistry, +}; +use inference_bridge::ModelConfig; +use std::path::PathBuf; + +fn test_model_config(dry_run: bool) -> ModelConfig { + ModelConfig { + model_path: PathBuf::from("tests/fixtures/dummy.onnx"), + tokenizer_path: None, + max_new_tokens: 1, + temperature: 0.0, + dry_run, + backend_override: None, + backend_config: Default::default(), + } +} + +/// Contract test suite exercised against each compiled backend. +macro_rules! backend_contract_tests { + ($mod_name:ident, $backend_expr:expr) => { + mod $mod_name { + use super::*; + + fn backend() -> impl ExecutionProviderBackend { + $backend_expr + } + + #[test] + fn name_is_non_empty() { + assert!( + !backend().name().is_empty(), + "backend name must not be empty" + ); + } + + #[test] + fn name_is_ascii() { + assert!( + backend().name().is_ascii(), + "backend name should be ASCII for consistent matching" + ); + } + + #[test] + fn priority_is_finite() { + // priority is u32 so always finite, but verify it's accessible + let _ = backend().priority(); + } + + #[test] + fn is_available_is_deterministic() { + let a = backend().is_available(); + let b = backend().is_available(); + assert_eq!(a, b, "is_available() should be deterministic across calls"); + } + + #[test] + fn config_keys_are_non_empty_strings() { + for key in backend().config_keys() { + assert!(!key.is_empty(), "config key must not be empty"); + } + } + + #[test] + fn diagnose_returns_entries() { + let entries = backend().diagnose(); + // Every backend should report at least one diagnostic. + assert!( + !entries.is_empty(), + "diagnose() must return at least one entry" + ); + } + + #[test] + fn diagnose_entries_are_well_formed() { + for entry in backend().diagnose() { + assert!( + !entry.check.is_empty(), + "diagnostic check name must not be empty" + ); + assert!( + !entry.message.is_empty(), + "diagnostic message must not be empty" + ); + // Severity must be one of the defined variants. + match entry.severity { + DiagnosticSeverity::Pass + | DiagnosticSeverity::Warn + | DiagnosticSeverity::Fail => {} + } + } + } + + #[test] + fn dry_run_session_succeeds() { + let config = test_model_config(true); + let session = backend().build_session(&config, &BackendOptions::new()); + assert!( + session.is_ok(), + "build_session with dry_run=true should always succeed: {:?}", + session.err() + ); + } + + #[test] + fn dry_run_session_generates() { + let config = test_model_config(true); + let session = backend() + .build_session(&config, &BackendOptions::new()) + .expect("dry-run session build"); + let result = session.generate("test prompt", 10); + assert!(result.is_ok(), "dry-run generate should succeed"); + assert!( + !result.unwrap().is_empty(), + "dry-run generate should return non-empty text" + ); + } + } + }; +} + +// ----------------------------------------------------------------------- +// Invoke the conformance suite for each compiled backend. +// ----------------------------------------------------------------------- + +backend_contract_tests!(cpu_conformance, CpuBackend); + +#[cfg(feature = "vitis")] +backend_contract_tests!(vitis_conformance, inference_bridge::backend::VitisBackend); + +// ----------------------------------------------------------------------- +// Registry-level tests +// ----------------------------------------------------------------------- + +#[test] +fn registry_discover_is_non_empty() { + let registry = ProviderRegistry::discover(); + assert!(!registry.list().is_empty()); +} + +#[test] +fn registry_diagnose_all_covers_all_backends() { + let registry = ProviderRegistry::discover(); + let list = registry.list(); + let diags = registry.diagnose_all(); + assert_eq!( + list.len(), + diags.len(), + "diagnose_all should return one entry per registered backend" + ); +} + +#[test] +fn registry_diagnose_all_sorted_by_priority_desc() { + let registry = ProviderRegistry::discover(); + let diags = registry.diagnose_all(); + for window in diags.windows(2) { + assert!( + window[0].info.priority >= window[1].info.priority, + "diagnose_all results should be sorted by descending priority" + ); + } +} + +#[test] +fn registry_best_available_matches_highest_priority() { + let registry = ProviderRegistry::discover(); + let best = registry.best_available().expect("at least one backend"); + let all_available: Vec<_> = registry + .list() + .into_iter() + .filter(|p| p.available) + .collect(); + let max_priority = all_available.iter().map(|p| p.priority).max().unwrap(); + assert_eq!(best.priority(), max_priority); +} + +#[test] +fn registry_build_session_with_fallback_dry_run() { + let registry = ProviderRegistry::discover(); + let config = test_model_config(true); + let result = registry.build_session_with_fallback(&config, &BackendOptions::new(), None); + assert!(result.is_ok()); + let (name, session) = result.unwrap(); + assert!(!name.is_empty()); + let output = session.generate("hello", 1).unwrap(); + assert!(!output.is_empty()); +} diff --git a/wraithrun.example.toml b/wraithrun.example.toml index a535935..12cd35b 100644 --- a/wraithrun.example.toml +++ b/wraithrun.example.toml @@ -24,6 +24,12 @@ log = "normal" # evidence_bundle_archive = "./evidence/CASE-2026-0001.tar" # baseline_bundle = "./evidence/CASE-2026-0001" +# Inference backend selection. +# "auto" (default) picks the highest-priority available backend. +# Explicit values: "cpu", "vitis", etc. +[inference] +backend = "auto" + [profiles.local-lab] max_steps = 6 max_new_tokens = 192