simengine 0.2.0

A plugin-based simulation engine runtime and plugin API
Documentation
mod state;

pub use state::{
    EngineState, SimulationState, SimulationStateCondition, StateTransitionCondition,
    StateTransitionConfig,
};

use serde::{Deserialize, Serialize};
use std::{collections::HashMap, fs, path::Path};
use thiserror::Error;

#[derive(Debug, Error)]
pub enum CoreError {
    #[error("failed to read manifest: {0}")]
    ReadManifest(#[from] std::io::Error),

    #[error("invalid manifest json: {0}")]
    InvalidManifest(#[from] serde_json::Error),

    #[error("duplicate simulation endpoint '{endpoint}'")]
    DuplicateEndpoint { endpoint: String },

    #[error("duplicate output variable '{output}' on endpoint '{endpoint}'")]
    DuplicateOutput { endpoint: String, output: String },

    #[error("duplicate input variable '{input}' on endpoint '{endpoint}'")]
    DuplicateInput { endpoint: String, input: String },

    #[error("route source endpoint '{endpoint}' is not declared in this config")]
    RouteSourceNotLocal { endpoint: String },

    #[error("route source output '{output}' does not exist on endpoint '{endpoint}'")]
    MissingRouteOutput { endpoint: String, output: String },

    #[error("route target input '{input}' does not exist on local endpoint '{endpoint}'")]
    MissingRouteInput { endpoint: String, input: String },

    #[error(
        "route type mismatch: {from_endpoint}/{output} is {output_type:?}, but {to_endpoint}/{input} is {input_type:?}"
    )]
    RouteTypeMismatch {
        from_endpoint: String,
        output: String,
        output_type: PrimitiveType,
        to_endpoint: String,
        input: String,
        input_type: PrimitiveType,
    },

    #[error("state transition references unknown simulation '{simulation}'")]
    UnknownTransitionSimulation { simulation: String },

    #[error("state transition has an empty condition")]
    EmptyStateTransitionCondition,

    #[error("state transition condition must use either 'all' or 'any', not both")]
    AmbiguousStateTransitionCondition,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Manifest {
    pub framework: FrameworkConfig,

    #[serde(default)]
    pub simulations: Vec<SimulationConfig>,

    #[serde(default)]
    pub routes: Vec<RouteConfig>,

    #[serde(default)]
    pub state_transitions: Vec<StateTransitionConfig>,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct FrameworkConfig {
    pub fps: u32,

    #[serde(default = "default_log_level")]
    pub log_level: String,

    #[serde(default)]
    pub max_frames: Option<u64>,
}

fn default_log_level() -> String {
    "info".to_string()
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SimulationConfig {
    pub name: String,
    pub endpoint: String,
    pub plugin: String,

    #[serde(default)]
    pub inputs: Vec<InputConfig>,

    #[serde(default)]
    pub outputs: Vec<OutputConfig>,

    #[serde(default)]
    pub params: serde_json::Value,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct InputConfig {
    pub name: String,

    #[serde(rename = "type")]
    pub ty: PrimitiveType,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct OutputConfig {
    pub name: String,

    #[serde(rename = "type")]
    pub ty: PrimitiveType,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RouteConfig {
    pub from: RouteFrom,
    pub to: RouteTo,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RouteFrom {
    pub endpoint: String,
    pub output: String,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RouteTo {
    pub endpoint: String,
    pub input: String,
}

#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum PrimitiveType {
    Float32,
}

pub fn load_manifest(path: impl AsRef<Path>) -> Result<Manifest, CoreError> {
    let text = fs::read_to_string(path)?;
    Ok(serde_json::from_str(&text)?)
}

pub fn validate_manifest(manifest: &Manifest) -> Result<(), CoreError> {
    let mut endpoints: HashMap<&str, &SimulationConfig> = HashMap::new();
    let mut outputs: HashMap<(String, String), &OutputConfig> = HashMap::new();
    let mut inputs: HashMap<(String, String), &InputConfig> = HashMap::new();

    for sim in &manifest.simulations {
        if endpoints.insert(sim.endpoint.as_str(), sim).is_some() {
            return Err(CoreError::DuplicateEndpoint {
                endpoint: sim.endpoint.clone(),
            });
        }

        let mut local_outputs: HashMap<&str, ()> = HashMap::new();
        for output in &sim.outputs {
            if local_outputs.insert(output.name.as_str(), ()).is_some() {
                return Err(CoreError::DuplicateOutput {
                    endpoint: sim.endpoint.clone(),
                    output: output.name.clone(),
                });
            }
            outputs.insert((sim.endpoint.clone(), output.name.clone()), output);
        }

        let mut local_inputs: HashMap<&str, ()> = HashMap::new();
        for input in &sim.inputs {
            if local_inputs.insert(input.name.as_str(), ()).is_some() {
                return Err(CoreError::DuplicateInput {
                    endpoint: sim.endpoint.clone(),
                    input: input.name.clone(),
                });
            }
            inputs.insert((sim.endpoint.clone(), input.name.clone()), input);
        }
    }

    for route in &manifest.routes {
        if !endpoints.contains_key(route.from.endpoint.as_str()) {
            return Err(CoreError::RouteSourceNotLocal {
                endpoint: route.from.endpoint.clone(),
            });
        }

        let output = outputs
            .get(&(route.from.endpoint.clone(), route.from.output.clone()))
            .ok_or_else(|| CoreError::MissingRouteOutput {
                endpoint: route.from.endpoint.clone(),
                output: route.from.output.clone(),
            })?;

        let input = inputs.get(&(route.to.endpoint.clone(), route.to.input.clone()));

        if endpoints.contains_key(route.to.endpoint.as_str()) && input.is_none() {
            return Err(CoreError::MissingRouteInput {
                endpoint: route.to.endpoint.clone(),
                input: route.to.input.clone(),
            });
        }

        if let Some(input) = input {
            if output.ty != input.ty {
                return Err(CoreError::RouteTypeMismatch {
                    from_endpoint: route.from.endpoint.clone(),
                    output: route.from.output.clone(),
                    output_type: output.ty.clone(),
                    to_endpoint: route.to.endpoint.clone(),
                    input: route.to.input.clone(),
                    input_type: input.ty.clone(),
                });
            }
        }
    }

    state::validate_state_transitions(&manifest.state_transitions, &manifest.simulations)?;

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn manifest_with_sim(sim: SimulationConfig) -> Manifest {
        Manifest {
            framework: FrameworkConfig {
                fps: 1,
                log_level: "info".to_string(),
                max_frames: Some(1),
            },
            simulations: vec![sim],
            routes: Vec::new(),
            state_transitions: Vec::new(),
        }
    }

    fn simulation(inputs: Vec<InputConfig>, outputs: Vec<OutputConfig>) -> SimulationConfig {
        SimulationConfig {
            name: "sim".to_string(),
            endpoint: "127.0.0.1:7001".to_string(),
            plugin: "sim.dll".to_string(),
            inputs,
            outputs,
            params: serde_json::Value::Null,
        }
    }

    #[test]
    fn rejects_duplicate_inputs_on_same_endpoint() {
        let manifest = manifest_with_sim(simulation(
            vec![
                InputConfig {
                    name: "value".to_string(),
                    ty: PrimitiveType::Float32,
                },
                InputConfig {
                    name: "value".to_string(),
                    ty: PrimitiveType::Float32,
                },
            ],
            Vec::new(),
        ));

        let err = validate_manifest(&manifest).expect_err("duplicate input should fail");

        assert!(matches!(
            err,
            CoreError::DuplicateInput { endpoint, input }
                if endpoint == "127.0.0.1:7001" && input == "value"
        ));
    }

    #[test]
    fn rejects_duplicate_outputs_on_same_endpoint() {
        let manifest = manifest_with_sim(simulation(
            Vec::new(),
            vec![
                OutputConfig {
                    name: "value".to_string(),
                    ty: PrimitiveType::Float32,
                },
                OutputConfig {
                    name: "value".to_string(),
                    ty: PrimitiveType::Float32,
                },
            ],
        ));

        let err = validate_manifest(&manifest).expect_err("duplicate output should fail");

        assert!(matches!(
            err,
            CoreError::DuplicateOutput { endpoint, output }
                if endpoint == "127.0.0.1:7001" && output == "value"
        ));
    }

    #[test]
    fn allows_local_inputs_without_routes() {
        let manifest = manifest_with_sim(simulation(
            vec![InputConfig {
                name: "remote_value".to_string(),
                ty: PrimitiveType::Float32,
            }],
            Vec::new(),
        ));

        validate_manifest(&manifest).expect("input may be fed by another process");
    }

    #[test]
    fn rejects_routes_from_remote_sources() {
        let mut manifest = manifest_with_sim(simulation(
            Vec::new(),
            vec![OutputConfig {
                name: "value".to_string(),
                ty: PrimitiveType::Float32,
            }],
        ));
        manifest.routes.push(RouteConfig {
            from: RouteFrom {
                endpoint: "127.0.0.2:7001".to_string(),
                output: "value".to_string(),
            },
            to: RouteTo {
                endpoint: "127.0.0.1:7001".to_string(),
                input: "value".to_string(),
            },
        });

        let err = validate_manifest(&manifest).expect_err("remote source should fail");

        assert!(matches!(
            err,
            CoreError::RouteSourceNotLocal { endpoint }
                if endpoint == "127.0.0.2:7001"
        ));
    }

    #[test]
    fn rejects_state_transition_for_unknown_simulation() {
        let mut manifest = manifest_with_sim(simulation(Vec::new(), Vec::new()));
        manifest.state_transitions.push(StateTransitionConfig {
            when: StateTransitionCondition {
                all: vec![SimulationStateCondition {
                    sim: "missing".to_string(),
                    state: SimulationState::_READY,
                }],
                any: Vec::new(),
            },
            engine: EngineState::RUNNING,
        });

        let err = validate_manifest(&manifest).expect_err("unknown simulation should fail");

        assert!(matches!(
            err,
            CoreError::UnknownTransitionSimulation { simulation }
                if simulation == "missing"
        ));
    }

    #[test]
    fn rejects_empty_state_transition_condition() {
        let mut manifest = manifest_with_sim(simulation(Vec::new(), Vec::new()));
        manifest.state_transitions.push(StateTransitionConfig {
            when: StateTransitionCondition {
                all: Vec::new(),
                any: Vec::new(),
            },
            engine: EngineState::RUNNING,
        });

        let err = validate_manifest(&manifest).expect_err("empty condition should fail");

        assert!(matches!(err, CoreError::EmptyStateTransitionCondition));
    }
}