1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
//! Physics Simulation Orchestration
pub mod parameter_extraction;
pub mod result_injection;
pub mod samm_parser;
pub mod scirs2_thermal;
pub mod simulation_runner;
pub use parameter_extraction::{ParameterExtractor, SimulationParameters};
pub use result_injection::{ResultInjector, SimulationResult};
pub use samm_parser::{AspectModel, SammParser};
pub use scirs2_thermal::SciRS2ThermalSimulation;
pub use simulation_runner::{PhysicsSimulation, SimulationRunner};
use crate::error::{PhysicsError, PhysicsResult};
use oxirs_core::rdf_store::RdfStore;
use std::collections::HashMap;
use std::sync::Arc;
/// Simulation Orchestrator - coordinates parameter extraction, simulation, and result injection
pub struct SimulationOrchestrator {
extractor: Arc<ParameterExtractor>,
injector: Arc<ResultInjector>,
simulations: HashMap<String, Arc<dyn PhysicsSimulation>>,
/// `true` when this orchestrator was built with a real backing RDF
/// store ([`Self::with_store`]); `false` when it was built with
/// [`Self::new`], in which case `extract_parameters` returns
/// simulation-type-keyed mock parameters and `inject_results` is a
/// logging no-op instead of touching any graph.
has_store: bool,
}
impl SimulationOrchestrator {
/// Create a new orchestrator with **no backing RDF store**.
///
/// This is a mock/testing constructor: [`Self::extract_parameters`]
/// falls back to hardcoded, simulation-type-keyed default parameters
/// (ignoring `entity_iri` entirely) and [`Self::inject_results`] is a
/// no-op that only logs a debug message rather than writing anything
/// to RDF. Do not use this constructor in production code paths that
/// need real parameter extraction or result persistence — use
/// [`Self::with_store`] instead.
pub fn new() -> Self {
Self {
extractor: Arc::new(ParameterExtractor::new()),
injector: Arc::new(ResultInjector::new()),
simulations: HashMap::new(),
has_store: false,
}
}
/// Create a new orchestrator backed by a real RDF store.
///
/// This is the production constructor: [`Self::extract_parameters`]
/// queries `store` via SPARQL for the entity's properties (falling
/// back to defaults only for genuinely missing initial conditions,
/// per `ExtractionConfig::use_defaults`), and
/// [`Self::inject_results`] writes simulation results (state
/// trajectory, convergence info, and W3C PROV provenance) back into
/// `store` via SPARQL UPDATE.
pub fn with_store(store: Arc<RdfStore>) -> Self {
Self {
extractor: Arc::new(ParameterExtractor::with_store(store.clone())),
injector: Arc::new(ResultInjector::with_store(store)),
simulations: HashMap::new(),
has_store: true,
}
}
/// `true` if this orchestrator is backed by a real RDF store (i.e. it
/// was constructed with [`Self::with_store`]), `false` if it is
/// operating in mock mode ([`Self::new`]).
pub fn has_store(&self) -> bool {
self.has_store
}
/// Register a simulation type
pub fn register(&mut self, name: impl Into<String>, simulation: Arc<dyn PhysicsSimulation>) {
self.simulations.insert(name.into(), simulation);
}
/// Extract parameters from RDF graph
pub async fn extract_parameters(
&self,
entity_iri: &str,
simulation_type: &str,
) -> PhysicsResult<SimulationParameters> {
self.extractor.extract(entity_iri, simulation_type).await
}
/// Run simulation
pub async fn run(
&self,
simulation_type: &str,
params: SimulationParameters,
) -> PhysicsResult<SimulationResult> {
let simulation = self.simulations.get(simulation_type).ok_or_else(|| {
PhysicsError::Simulation(format!("Unknown simulation type: {}", simulation_type))
})?;
simulation.run(¶ms).await
}
/// Inject results back to RDF
pub async fn inject_results(&self, result: &SimulationResult) -> PhysicsResult<()> {
self.injector.inject(result).await
}
/// Full simulation workflow: extract → run → inject
pub async fn execute_workflow(
&self,
entity_iri: &str,
simulation_type: &str,
) -> PhysicsResult<SimulationResult> {
let params = self.extract_parameters(entity_iri, simulation_type).await?;
let result = self.run(simulation_type, params).await?;
self.inject_results(&result).await?;
Ok(result)
}
}
impl Default for SimulationOrchestrator {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use oxirs_core::query::{UpdateExecutor, UpdateParser};
use oxirs_core::Store;
#[test]
fn test_orchestrator_creation() {
let orchestrator = SimulationOrchestrator::new();
assert!(orchestrator.simulations.is_empty());
assert!(!orchestrator.has_store());
}
/// Regression test for the P0 finding: `SimulationOrchestrator` had no
/// way to attach a real RDF store, so `extract_parameters` always fell
/// back to fabricated mock parameters regardless of `entity_iri`, and
/// `inject_results` always silently no-oped instead of writing to RDF.
/// This proves `with_store` wires a real store through both extraction
/// and injection.
#[tokio::test]
async fn regression_orchestrator_with_store_does_real_extraction() {
let store = RdfStore::default();
// Seed the store with a real triple for the entity under a
// predicate name containing "initial" (not one of the mock's
// hardcoded keys), so we can tell real extraction apart from the
// canned mock parameters.
let insert = r#"
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
INSERT DATA {
<http://example.org/entity1> <http://oxirs.org/physics#initial_temperature> "310.5"^^xsd:double .
}
"#;
let parser = UpdateParser::new();
let parsed = parser.parse(insert).expect("parse failed");
let executor = UpdateExecutor::new(&store);
executor.execute(&parsed).expect("execute failed");
let orchestrator = SimulationOrchestrator::with_store(Arc::new(store));
assert!(orchestrator.has_store());
let params = orchestrator
.extract_parameters("http://example.org/entity1", "thermal")
.await
.expect("extraction failed");
// Real extraction must surface the actual RDF-sourced key...
let extracted = params
.initial_conditions
.get("initial_temperature")
.expect("expected real RDF-extracted initial_temperature");
assert!((extracted.value - 310.5).abs() < 1e-9);
// ...and must NOT silently substitute the mock's canned
// "temperature" = 293.15 key, since real (non-empty) initial
// conditions were already found in the graph.
assert!(
!params.initial_conditions.contains_key("temperature"),
"real extraction must not fall back to mock defaults when RDF data was found"
);
}
/// Regression test proving `inject_results` on a store-backed
/// orchestrator actually persists triples, rather than silently
/// logging and no-oping as it always did before `with_store` existed.
#[tokio::test]
async fn regression_orchestrator_with_store_injects_real_triples() {
let store = Arc::new(RdfStore::default());
let orchestrator = SimulationOrchestrator::with_store(store.clone());
let mut state = HashMap::new();
state.insert("temperature".to_string(), 305.0);
let result = SimulationResult {
entity_iri: "http://example.org/entity1".to_string(),
simulation_run_id: "urn:run:regression-1".to_string(),
timestamp: chrono::Utc::now(),
state_trajectory: vec![result_injection::StateVector { time: 0.0, state }],
derived_quantities: HashMap::new(),
convergence_info: result_injection::ConvergenceInfo {
converged: true,
iterations: 10,
final_residual: 1e-8,
},
provenance: result_injection::SimulationProvenance {
software: "oxirs-physics".to_string(),
version: "0.4.1".to_string(),
parameters_hash: "hash".to_string(),
executed_at: chrono::Utc::now(),
execution_time_ms: 5,
},
};
assert!(store.find_quads(None, None, None, None).unwrap().is_empty());
orchestrator
.inject_results(&result)
.await
.expect("injection failed");
let quads = store
.find_quads(None, None, None, None)
.expect("find_quads failed");
assert!(
!quads.is_empty(),
"with_store orchestrator must actually write triples to the RDF store"
);
}
}