Skip to content
Draft
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
12 changes: 12 additions & 0 deletions crates/cli/src/asset_validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,18 @@ fn validate_system(path: &PathBuf) -> ExitCode {
result.record_check();
}

for issue in system.validate_cosim_models() {
result.add_error(
"INVALID_COSIM_MODEL",
issue,
Some(
"Set a non-empty id, a positive step_ns, and a model path for fmi/external_process adapters"
.to_string(),
),
Some("cosim_models[]".to_string()),
);
}

// 2. Load Referenced Chip
// Resolving chip path relative to system file
let chip_path_resolved = if let Some(parent) = path.parent() {
Expand Down
168 changes: 168 additions & 0 deletions crates/cli/src/commands/cosim.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
//! `labwired cosim-step`: drive a manifest-declared co-simulation model
//! through the real runner/adapter chain and print the routed outputs.
//!
//! This is the command-line proof that a co-sim model runs *through*
//! LabWired's manifest routing, not just next to it:
//!
//! ```text
//! labwired cosim-step examples/cosim-plant-demo/system.yaml \
//! --set plant.channels.11.enabled=false --json
//! ```

use clap::Args;
use labwired_config::SystemManifest;
use labwired_core::cosim::{CosimRunner, CosimSignalValue, CosimSignals};
use std::path::PathBuf;
use std::process::ExitCode;

use crate::{EXIT_CONFIG_ERROR, EXIT_RUNTIME_ERROR};

#[derive(Args, Debug)]
pub struct CosimStepArgs {
/// Path to the system manifest declaring `cosim_models`.
pub system: PathBuf,

/// Seed a signal-store path before stepping, e.g.
/// `--set plant.channels.11.enabled=false`. Values parse as bool,
/// integer, then float, falling back to text. Repeatable.
#[arg(long = "set", value_name = "PATH=VALUE")]
pub sets: Vec<String>,

/// Number of model steps to advance (time advances to
/// `steps * max(step_ns)`).
#[arg(long, default_value_t = 1)]
pub steps: u64,

/// Print routed outputs as JSON instead of a table.
#[arg(long)]
pub json: bool,
}

fn parse_signal_value(raw: &str) -> CosimSignalValue {
match raw {
"true" => CosimSignalValue::Bool(true),
"false" => CosimSignalValue::Bool(false),
_ => {
if let Ok(value) = raw.parse::<i64>() {
CosimSignalValue::I64(value)
} else if let Ok(value) = raw.parse::<f64>() {
CosimSignalValue::F64(value)
} else {
CosimSignalValue::Text(raw.to_string())
}
}
}
}

fn signal_to_display(value: &CosimSignalValue) -> String {
match value {
CosimSignalValue::Bool(value) => value.to_string(),
CosimSignalValue::I64(value) => value.to_string(),
CosimSignalValue::F64(value) => format!("{value}"),
CosimSignalValue::Text(value) => value.clone(),
}
}

fn signal_to_json(value: &CosimSignalValue) -> serde_json::Value {
match value {
CosimSignalValue::Bool(value) => serde_json::Value::Bool(*value),
CosimSignalValue::I64(value) => serde_json::json!(value),
CosimSignalValue::F64(value) => serde_json::json!(value),
CosimSignalValue::Text(value) => serde_json::Value::String(value.clone()),
}
}

pub fn run_cosim_step(args: CosimStepArgs) -> ExitCode {
let manifest = match SystemManifest::from_file(&args.system) {
Ok(manifest) => manifest,
Err(err) => {
eprintln!(
"error: failed to load manifest {}: {err:#}",
args.system.display()
);
return ExitCode::from(EXIT_CONFIG_ERROR);
}
};

let issues = manifest.validate_cosim_models();
if !issues.is_empty() {
for issue in issues {
eprintln!("error: {issue}");
}
return ExitCode::from(EXIT_CONFIG_ERROR);
}
if manifest.cosim_models.is_empty() {
eprintln!(
"error: manifest {} declares no cosim_models",
args.system.display()
);
return ExitCode::from(EXIT_CONFIG_ERROR);
}

let base_dir = args
.system
.parent()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let mut runner = match CosimRunner::from_configs_with_base(&manifest.cosim_models, &base_dir) {
Ok(runner) => runner,
Err(err) => {
eprintln!("error: failed to build co-sim runner: {err}");
return ExitCode::from(EXIT_RUNTIME_ERROR);
}
};

let mut signals = CosimSignals::new();
for set in &args.sets {
let Some((path, raw)) = set.split_once('=') else {
eprintln!("error: --set expects PATH=VALUE, got '{set}'");
return ExitCode::from(EXIT_CONFIG_ERROR);
};
signals.insert(path.trim().to_string(), parse_signal_value(raw.trim()));
}

let max_step_ns = manifest
.cosim_models
.iter()
.map(|model| model.step_ns)
.max()
.unwrap_or(1);
let target_ns = args.steps.saturating_mul(max_step_ns);

let routed = match runner.step_until_with_signals(target_ns, &mut signals) {
Ok(routed) => routed,
Err(err) => {
eprintln!("error: co-sim step failed: {err}");
return ExitCode::from(EXIT_RUNTIME_ERROR);
}
};

if args.json {
let steps: Vec<serde_json::Value> = routed
.iter()
.map(|step| {
serde_json::json!({
"model": step.model_id,
"outputs": step
.outputs
.iter()
.map(|(path, value)| (path.clone(), signal_to_json(value)))
.collect::<serde_json::Map<String, serde_json::Value>>(),
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&steps).unwrap());
} else {
for step in &routed {
println!("model '{}' routed outputs:", step.model_id);
for (path, value) in &step.outputs {
println!(" {path} = {}", signal_to_display(value));
}
}
if routed.is_empty() {
println!("no model reached its step boundary (try --steps > 0)");
}
}

ExitCode::SUCCESS
}
1 change: 1 addition & 0 deletions crates/cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

pub mod asset;
pub mod codegen;
pub mod cosim;
pub mod coverage;
pub mod fuzz;
pub mod machine;
Expand Down
1 change: 1 addition & 0 deletions crates/cli/src/commands/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ pub(crate) fn run_firmware_riscv(args: RunArgs, _chip_yaml: String) -> ExitCode
chip: args.chip.to_string_lossy().into_owned(),
memory_overrides: Default::default(),
external_devices: vec![],
cosim_models: Vec::new(),
board_io: vec![],
debug_uart: None,
peripherals: vec![],
Expand Down
5 changes: 5 additions & 0 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ enum Commands {
/// Run the Tier-1 chip × peripheral validation matrix and export it.
Tier1Matrix(Tier1MatrixArgs),

/// Step a manifest-declared co-simulation model through the real
/// runner/adapter chain and print the routed outputs.
CosimStep(commands::cosim::CosimStepArgs),

/// Coverage-guided fuzz a firmware in the silicon-validated simulator.
///
/// Mutates an input byte stream injected into the firmware's RAM buffer,
Expand Down Expand Up @@ -878,6 +882,7 @@ fn main() -> ExitCode {
Some(Commands::Snapshot(args)) => commands::snapshot::run_snapshot(args),
Some(Commands::Coverage(args)) => commands::coverage::run_coverage(args),
Some(Commands::Tier1Matrix(args)) => commands::tier1::run_tier1_matrix(args),
Some(Commands::CosimStep(args)) => commands::cosim::run_cosim_step(args),
Some(Commands::Fuzz(args)) => commands::fuzz::run_fuzz(args),
None => commands::run::run_interactive(cli),
}
Expand Down
59 changes: 59 additions & 0 deletions crates/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,34 @@ pub struct ExternalDevice {
pub config: HashMap<String, serde_yaml::Value>,
}

#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CosimAdapter {
ExternalProcess,
Fmi,
Mock,
}

fn default_cosim_step_ns() -> u64 {
1_000
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct CosimModelConfig {
pub id: String,
pub adapter: CosimAdapter,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default = "default_cosim_step_ns")]
pub step_ns: u64,
#[serde(default)]
pub inputs: HashMap<String, String>,
#[serde(default)]
pub outputs: HashMap<String, String>,
#[serde(default)]
pub config: HashMap<String, serde_yaml::Value>,
}

#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum BoardIoKind {
Expand Down Expand Up @@ -230,6 +258,8 @@ pub struct SystemManifest {
#[serde(default)]
pub external_devices: Vec<ExternalDevice>,
#[serde(default)]
pub cosim_models: Vec<CosimModelConfig>,
#[serde(default)]
pub board_io: Vec<BoardIoBinding>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub debug_uart: Option<String>,
Expand Down Expand Up @@ -298,6 +328,35 @@ impl SystemManifest {
let f = std::fs::File::open(path)?;
serde_yaml::from_reader(f).context("Failed to parse System Manifest")
}

pub fn validate_cosim_models(&self) -> Vec<String> {
let mut issues = Vec::new();

for (index, model) in self.cosim_models.iter().enumerate() {
let location = format!("cosim_models[{index}]");
if model.id.trim().is_empty() {
issues.push(format!("{location}.id must be a non-empty identifier"));
}
if model.step_ns == 0 {
issues.push(format!("{location}.step_ns must be greater than zero"));
}
if matches!(
model.adapter,
CosimAdapter::ExternalProcess | CosimAdapter::Fmi
) && model
.model
.as_deref()
.is_none_or(|path| path.trim().is_empty())
{
issues.push(format!(
"{location}.model is required for {:?} adapters",
model.adapter
));
}
}

issues
}
}

#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
Expand Down
74 changes: 73 additions & 1 deletion crates/config/tests/config_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// This software is released under the MIT License.
// See the LICENSE file in the project root for full license information.

use labwired_config::ChipDescriptor;
use labwired_config::{ChipDescriptor, CosimAdapter, SystemManifest};

#[test]
fn test_old_yaml_still_parses() {
Expand Down Expand Up @@ -53,3 +53,75 @@ peripherals:
assert_eq!(desc.peripherals[0].size, Some("1KB".to_string()));
assert_eq!(desc.peripherals[0].irq, Some(37));
}

#[test]
fn system_manifest_parses_cosim_models() {
let yaml = r#"
name: "plant-demo"
chip: "chips/stm32f103.yaml"
cosim_models:
- id: "plant_model"
adapter: "external_process"
model: "./models/plant.jsonl"
step_ns: 10000
inputs:
rem0_enable: "gpio.rem0"
rem1_enable: "gpio.rem1"
outputs:
v_out: "scope.channel_a"
i_out: "meter.output_current"
config:
protocol: "jsonl"
external_devices: []
"#;

let manifest: SystemManifest = serde_yaml::from_str(yaml).unwrap();

assert_eq!(manifest.cosim_models.len(), 1);
let model = &manifest.cosim_models[0];
assert_eq!(model.id, "plant_model");
assert_eq!(model.adapter, CosimAdapter::ExternalProcess);
assert_eq!(model.model.as_deref(), Some("./models/plant.jsonl"));
assert_eq!(model.step_ns, 10_000);
assert_eq!(model.inputs["rem0_enable"], "gpio.rem0");
assert_eq!(model.outputs["v_out"], "scope.channel_a");
assert_eq!(
model.config["protocol"],
serde_yaml::Value::String("jsonl".to_string())
);
}

#[test]
fn system_manifest_rejects_incomplete_cosim_model() {
let yaml = r#"
name: "bad-cosim"
chip: "chips/stm32f103.yaml"
cosim_models:
- id: ""
adapter: "fmi"
step_ns: 0
external_devices: []
"#;

let manifest: SystemManifest = serde_yaml::from_str(yaml).unwrap();
let issues = manifest.validate_cosim_models();

assert!(
issues
.iter()
.any(|issue| issue.contains("cosim_models[0].id")),
"expected missing id validation issue, got {issues:?}"
);
assert!(
issues
.iter()
.any(|issue| issue.contains("cosim_models[0].model")),
"expected missing model validation issue, got {issues:?}"
);
assert!(
issues
.iter()
.any(|issue| issue.contains("cosim_models[0].step_ns")),
"expected invalid step validation issue, got {issues:?}"
);
}
Loading
Loading