//! External Component Model Interface //! //! This module provides support for external component models via: //! - Dynamic library loading (.dll/.so) via FFI //! - HTTP API calls to external services //! //! ## Architecture //! //! The external model interface allows integration of proprietary or vendor-supplied //! component models that cannot be implemented natively in Rust. //! //! ## FFI Interface (DLL/SO) //! //! External libraries must implement the `entropyk_model` C ABI: //! //! ```c //! // Required exported functions: //! int entropyk_model_compute(double* inputs, double* outputs, int n_in, int n_out); //! int entropyk_model_jacobian(double* inputs, double* jacobian, int n_in, int n_out); //! const char* entropyk_model_name(void); //! const char* entropyk_model_version(void); //! ``` //! //! ## HTTP API Interface //! //! External services must provide REST endpoints: //! //! - `POST /compute`: Accepts JSON with inputs, returns JSON with outputs //! - `POST /jacobian`: Accepts JSON with inputs, returns JSON with Jacobian matrix use crate::ComponentError; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::sync::Arc; /// Configuration for an external model. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ExternalModelConfig { /// Unique identifier for this model pub id: String, /// Model type (ffi or http) pub model_type: ExternalModelType, /// Number of inputs expected pub n_inputs: usize, /// Number of outputs produced pub n_outputs: usize, /// Optional timeout in milliseconds #[serde(default = "default_timeout")] pub timeout_ms: u64, } fn default_timeout() -> u64 { 5000 } /// Type of external model interface. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum ExternalModelType { /// Dynamic library (.dll on Windows, .so on Linux, .dylib on macOS) Ffi { /// Path to the library file library_path: PathBuf, /// Optional function name prefix function_prefix: Option, }, /// HTTP REST API Http { /// Base URL for the API base_url: String, /// Optional API key for authentication api_key: Option, }, } /// Trait for external model implementations. /// /// This trait abstracts over FFI and HTTP interfaces, providing /// a unified interface for the solver. pub trait ExternalModel: Send + Sync { /// Returns the model identifier. fn id(&self) -> &str; /// Returns the number of inputs. fn n_inputs(&self) -> usize; /// Returns the number of outputs. fn n_outputs(&self) -> usize; /// Computes outputs from inputs. /// /// # Arguments /// /// * `inputs` - Input values (length = n_inputs) /// /// # Returns /// /// Output values (length = n_outputs) fn compute(&self, inputs: &[f64]) -> Result, ExternalModelError>; /// Computes the Jacobian matrix. /// /// # Arguments /// /// * `inputs` - Input values /// /// # Returns /// /// Jacobian matrix as a flat array (row-major, n_outputs × n_inputs) fn jacobian(&self, inputs: &[f64]) -> Result, ExternalModelError>; /// Returns model metadata. fn metadata(&self) -> ExternalModelMetadata; } /// Metadata about an external model. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ExternalModelMetadata { /// Model name pub name: String, /// Model version pub version: String, /// Model description pub description: Option, /// Input names/units pub input_names: Vec, /// Output names/units pub output_names: Vec, } /// Errors from external model operations. #[derive(Debug, Clone, thiserror::Error)] pub enum ExternalModelError { /// Library loading failed #[error("Failed to load library: {0}")] LibraryLoad(String), /// Function not found in library #[error("Function not found: {0}")] FunctionNotFound(String), /// Computation failed #[error("Computation failed: {0}")] ComputationFailed(String), /// Invalid input dimensions #[error("Invalid input dimensions: expected {expected}, got {actual}")] InvalidInputDimensions { /// Expected number of inputs expected: usize, /// Actual number received actual: usize, }, /// HTTP request failed #[error("HTTP request failed: {0}")] HttpError(String), /// Timeout exceeded #[error("Operation timed out after {0}ms")] Timeout(u64), /// JSON parsing error #[error("JSON error: {0}")] JsonError(String), /// Model not initialized #[error("Model not initialized")] NotInitialized, } impl From for ComponentError { fn from(err: ExternalModelError) -> Self { ComponentError::InvalidState(format!("External model error: {}", err)) } } /// Request body for HTTP compute endpoint. #[derive(Debug, Serialize)] #[allow(dead_code)] struct ComputeRequest { inputs: Vec, } /// Response from HTTP compute endpoint. #[derive(Debug, Deserialize)] #[allow(dead_code)] struct ComputeResponse { outputs: Vec, } /// Request body for HTTP Jacobian endpoint. #[derive(Debug, Serialize)] #[allow(dead_code)] struct JacobianRequest { inputs: Vec, } /// Response from HTTP Jacobian endpoint. #[derive(Debug, Deserialize)] #[allow(dead_code)] struct JacobianResponse { jacobian: Vec, } /// FFI-based external model (stub implementation). /// /// This is a placeholder that compiles without external dependencies. /// Full FFI support requires the `libloading` crate and unsafe code. #[cfg(not(feature = "ffi"))] pub struct FfiModel { config: ExternalModelConfig, metadata: ExternalModelMetadata, } #[cfg(not(feature = "ffi"))] impl FfiModel { /// Creates a new FFI model (stub - returns error without ffi feature). pub fn new(_config: ExternalModelConfig) -> Result { Err(ExternalModelError::NotInitialized) } /// Creates with mock data for testing. pub fn new_mock( config: ExternalModelConfig, metadata: ExternalModelMetadata, ) -> Result { Ok(Self { config, metadata }) } } #[cfg(not(feature = "ffi"))] impl ExternalModel for FfiModel { fn id(&self) -> &str { &self.config.id } fn n_inputs(&self) -> usize { self.config.n_inputs } fn n_outputs(&self) -> usize { self.config.n_outputs } fn compute(&self, _inputs: &[f64]) -> Result, ExternalModelError> { // Stub implementation Ok(vec![0.0; self.config.n_outputs]) } fn jacobian(&self, _inputs: &[f64]) -> Result, ExternalModelError> { // Stub implementation - returns identity matrix let n = self.config.n_inputs * self.config.n_outputs; Ok(vec![0.0; n]) } fn metadata(&self) -> ExternalModelMetadata { self.metadata.clone() } } /// HTTP-based external model (stub implementation). /// /// This is a placeholder that compiles without external dependencies. /// Full HTTP support requires the `reqwest` crate. #[cfg(not(feature = "http"))] pub struct HttpModel { config: ExternalModelConfig, metadata: ExternalModelMetadata, } #[cfg(not(feature = "http"))] impl HttpModel { /// Creates a new HTTP model (stub - returns error without http feature). pub fn new(_config: ExternalModelConfig) -> Result { Err(ExternalModelError::NotInitialized) } /// Creates with mock data for testing. pub fn new_mock( config: ExternalModelConfig, metadata: ExternalModelMetadata, ) -> Result { Ok(Self { config, metadata }) } } #[cfg(not(feature = "http"))] impl ExternalModel for HttpModel { fn id(&self) -> &str { &self.config.id } fn n_inputs(&self) -> usize { self.config.n_inputs } fn n_outputs(&self) -> usize { self.config.n_outputs } fn compute(&self, _inputs: &[f64]) -> Result, ExternalModelError> { Ok(vec![0.0; self.config.n_outputs]) } fn jacobian(&self, _inputs: &[f64]) -> Result, ExternalModelError> { Ok(vec![0.0; self.config.n_inputs * self.config.n_outputs]) } fn metadata(&self) -> ExternalModelMetadata { self.metadata.clone() } } /// Thread-safe wrapper for external models. /// /// This wrapper ensures safe concurrent access to external models, /// which may not be thread-safe themselves. pub struct ThreadSafeExternalModel { inner: Arc, } impl ThreadSafeExternalModel { /// Creates a new thread-safe wrapper. pub fn new(model: impl ExternalModel + 'static) -> Self { Self { inner: Arc::new(model), } } /// Creates from an existing Arc. pub fn from_arc(model: Arc) -> Self { Self { inner: model } } /// Returns a reference to the inner model. pub fn inner(&self) -> &dyn ExternalModel { self.inner.as_ref() } } impl Clone for ThreadSafeExternalModel { fn clone(&self) -> Self { Self { inner: Arc::clone(&self.inner), } } } impl std::fmt::Debug for ThreadSafeExternalModel { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ThreadSafeExternalModel") .field("id", &self.inner.id()) .finish() } } /// Mock external model for testing. #[derive(Debug, Clone)] pub struct MockExternalModel { id: String, n_inputs: usize, n_outputs: usize, compute_fn: fn(&[f64]) -> Vec, } impl MockExternalModel { /// Creates a new mock model. pub fn new( id: impl Into, n_inputs: usize, n_outputs: usize, compute_fn: fn(&[f64]) -> Vec, ) -> Self { Self { id: id.into(), n_inputs, n_outputs, compute_fn, } } /// Creates a simple linear model: y = x pub fn linear_passthrough(n: usize) -> Self { Self::new("linear_passthrough", n, n, |x| x.to_vec()) } /// Creates a model that doubles inputs. pub fn doubler(n: usize) -> Self { Self::new("doubler", n, n, |x| x.iter().map(|v| v * 2.0).collect()) } } impl ExternalModel for MockExternalModel { fn id(&self) -> &str { &self.id } fn n_inputs(&self) -> usize { self.n_inputs } fn n_outputs(&self) -> usize { self.n_outputs } fn compute(&self, inputs: &[f64]) -> Result, ExternalModelError> { if inputs.len() != self.n_inputs { return Err(ExternalModelError::InvalidInputDimensions { expected: self.n_inputs, actual: inputs.len(), }); } Ok((self.compute_fn)(inputs)) } fn jacobian(&self, inputs: &[f64]) -> Result, ExternalModelError> { // Default: finite difference approximation let h = 1e-6; let mut jacobian = vec![0.0; self.n_outputs * self.n_inputs]; for j in 0..self.n_inputs { let mut inputs_plus = inputs.to_vec(); let mut inputs_minus = inputs.to_vec(); inputs_plus[j] += h; inputs_minus[j] -= h; let y_plus = self.compute(&inputs_plus)?; let y_minus = self.compute(&inputs_minus)?; for i in 0..self.n_outputs { jacobian[i * self.n_inputs + j] = (y_plus[i] - y_minus[i]) / (2.0 * h); } } Ok(jacobian) } fn metadata(&self) -> ExternalModelMetadata { ExternalModelMetadata { name: self.id.clone(), version: "1.0.0".to_string(), description: Some("Mock external model for testing".to_string()), input_names: (0..self.n_inputs).map(|i| format!("input_{}", i)).collect(), output_names: (0..self.n_outputs) .map(|i| format!("output_{}", i)) .collect(), } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_mock_external_model_compute() { let model = MockExternalModel::doubler(3); let result = model.compute(&[1.0, 2.0, 3.0]).unwrap(); assert_eq!(result, vec![2.0, 4.0, 6.0]); } #[test] fn test_mock_external_model_dimensions() { let model = MockExternalModel::doubler(3); assert_eq!(model.n_inputs(), 3); assert_eq!(model.n_outputs(), 3); } #[test] fn test_mock_external_model_invalid_input() { let model = MockExternalModel::doubler(3); let result = model.compute(&[1.0, 2.0]); assert!(result.is_err()); } #[test] fn test_mock_external_model_jacobian() { let model = MockExternalModel::doubler(2); let jac = model.jacobian(&[1.0, 2.0]).unwrap(); // Jacobian of y = 2x should be [[2, 0], [0, 2]] assert!((jac[0] - 2.0).abs() < 0.01); assert!((jac[1] - 0.0).abs() < 0.01); assert!((jac[2] - 0.0).abs() < 0.01); assert!((jac[3] - 2.0).abs() < 0.01); } #[test] fn test_thread_safe_wrapper() { let model = MockExternalModel::doubler(2); let wrapped = ThreadSafeExternalModel::new(model); let result = wrapped.inner().compute(&[1.0, 2.0]).unwrap(); assert_eq!(result, vec![2.0, 4.0]); } #[test] fn test_thread_safe_clone() { let model = MockExternalModel::doubler(2); let wrapped = ThreadSafeExternalModel::new(model); let cloned = wrapped.clone(); assert_eq!(wrapped.inner().id(), cloned.inner().id()); } #[test] fn test_external_model_metadata() { let model = MockExternalModel::doubler(2); let meta = model.metadata(); assert_eq!(meta.name, "doubler"); assert_eq!(meta.version, "1.0.0"); assert_eq!(meta.input_names, vec!["input_0", "input_1"]); assert_eq!(meta.output_names, vec!["output_0", "output_1"]); } #[test] fn test_linear_passthrough_model() { let model = MockExternalModel::linear_passthrough(3); let result = model.compute(&[1.0, 2.0, 3.0]).unwrap(); assert_eq!(result, vec![1.0, 2.0, 3.0]); } #[test] fn test_external_model_config() { let config = ExternalModelConfig { id: "test_model".to_string(), model_type: ExternalModelType::Http { base_url: "http://localhost:8080".to_string(), api_key: Some("secret".to_string()), }, n_inputs: 4, n_outputs: 2, timeout_ms: 3000, }; assert_eq!(config.id, "test_model"); assert_eq!(config.n_inputs, 4); assert_eq!(config.n_outputs, 2); assert_eq!(config.timeout_ms, 3000); } #[test] fn test_error_conversion() { let err = ExternalModelError::ComputationFailed("test error".to_string()); let component_err: ComponentError = err.into(); match component_err { ComponentError::InvalidState(msg) => { assert!(msg.contains("External model error")); } _ => panic!("Expected InvalidState error"), } } }