Skip to main content

oris_evokernel/
adapters.rs

1//! Adapters connecting oris-evokernel concrete implementations to the
2//! `SignalExtractorPort` and `SandboxPort` traits defined in `oris-evolution`.
3//!
4//! Inject these into `StandardEvolutionPipeline` to wire up the Detect and
5//! Execute stages with real runtime infrastructure:
6//!
7//! ```no_run
8//! use std::sync::Arc;
9//! use oris_evolution::{EvolutionPipelineConfig, StandardEvolutionPipeline};
10//! use oris_evokernel::adapters::{LocalSandboxAdapter, RuntimeSignalExtractorAdapter};
11//! # use oris_evolution::Selector;
12//! # fn build_selector() -> Arc<dyn Selector> { unimplemented!() }
13//! let pipeline = StandardEvolutionPipeline::new(
14//!     EvolutionPipelineConfig::default(),
15//!     build_selector(),
16//! )
17//! .with_signal_extractor(Arc::new(RuntimeSignalExtractorAdapter::new()))
18//! .with_sandbox(Arc::new(LocalSandboxAdapter::new(
19//!     "run-001",
20//!     "/path/to/workspace",
21//!     "/tmp/oris-sandbox",
22//! )));
23//! ```
24
25use std::path::PathBuf;
26
27use oris_evolution::{
28    EvaluateInput, EvaluatePort, EvaluationRecommendation, EvaluationResult, EvolutionSignal,
29    GeneStorePersistPort, IssueSeverity, PreparedMutation, SandboxExecutionResult, SandboxPort,
30    SignalExtractorInput, SignalExtractorPort, SignalType, ValidateInput, ValidatePort,
31    ValidationIssue, ValidationResult,
32};
33use oris_sandbox::{LocalProcessSandbox, Sandbox, SandboxError, SandboxPolicy};
34
35use crate::signal_extractor::RuntimeSignalExtractor;
36// ─────────────────────────────────────────────────────────────────────────────
37// RuntimeSignalExtractorAdapter
38// ─────────────────────────────────────────────────────────────────────────────
39
40/// Wraps `RuntimeSignalExtractor` to implement the `SignalExtractorPort` trait.
41///
42/// Converts extracted `RuntimeSignal`s into `EvolutionSignal`s required by
43/// `StandardEvolutionPipeline`'s Detect stage.
44pub struct RuntimeSignalExtractorAdapter {
45    inner: RuntimeSignalExtractor,
46}
47
48impl RuntimeSignalExtractorAdapter {
49    /// Create a new adapter with a fresh `RuntimeSignalExtractor`.
50    pub fn new() -> Self {
51        Self {
52            inner: RuntimeSignalExtractor::new(),
53        }
54    }
55}
56
57impl Default for RuntimeSignalExtractorAdapter {
58    fn default() -> Self {
59        Self::new()
60    }
61}
62
63impl SignalExtractorPort for RuntimeSignalExtractorAdapter {
64    fn extract(&self, input: &SignalExtractorInput) -> Vec<EvolutionSignal> {
65        let runtime_signals = self.inner.extract_all(
66            input.compiler_output.as_deref(),
67            input.stack_trace.as_deref(),
68            input.logs.as_deref(),
69        );
70
71        runtime_signals
72            .into_iter()
73            .map(|rs| {
74                use crate::signal_extractor::RuntimeSignalType;
75                let signal_type = match rs.signal_type {
76                    RuntimeSignalType::PerformanceIssue => SignalType::Performance {
77                        metric: "runtime".to_string(),
78                        improvement_potential: rs.confidence,
79                    },
80                    RuntimeSignalType::ResourceExhaustion => SignalType::ResourceOptimization {
81                        resource_type: "system".to_string(),
82                        current_usage: rs.confidence,
83                    },
84                    RuntimeSignalType::CompilerDiagnostic
85                    | RuntimeSignalType::RuntimePanic
86                    | RuntimeSignalType::Timeout
87                    | RuntimeSignalType::TestFailure
88                    | RuntimeSignalType::ConfigError
89                    | RuntimeSignalType::SecurityIssue
90                    | RuntimeSignalType::GenericError => SignalType::ErrorPattern {
91                        error_type: format!("{:?}", rs.signal_type),
92                        frequency: 1,
93                    },
94                };
95
96                EvolutionSignal {
97                    signal_id: rs.signal_id,
98                    signal_type,
99                    source_task_id: String::new(),
100                    confidence: rs.confidence,
101                    description: rs.content,
102                    metadata: rs.metadata,
103                }
104            })
105            .collect()
106    }
107}
108
109// ─────────────────────────────────────────────────────────────────────────────
110// LocalSandboxAdapter
111// ─────────────────────────────────────────────────────────────────────────────
112
113/// Wraps `LocalProcessSandbox` to implement the synchronous `SandboxPort` trait.
114///
115/// `Sandbox::apply` is async; this adapter bridges the gap by spawning a
116/// dedicated thread that blocks on the future, which is safe regardless of
117/// whether the caller is inside a tokio runtime or not.
118pub struct LocalSandboxAdapter {
119    inner: LocalProcessSandbox,
120    policy: SandboxPolicy,
121}
122
123impl LocalSandboxAdapter {
124    /// Create a new adapter.
125    ///
126    /// - `run_id`        — unique identifier for this pipeline run
127    /// - `workspace_root` — root of the workspace being mutated
128    /// - `temp_root`     — directory for temporary sandbox copies
129    pub fn new<S, P, Q>(run_id: S, workspace_root: P, temp_root: Q) -> Self
130    where
131        S: Into<String>,
132        P: Into<PathBuf>,
133        Q: Into<PathBuf>,
134    {
135        Self {
136            inner: LocalProcessSandbox::new(run_id, workspace_root, temp_root),
137            policy: SandboxPolicy::default(),
138        }
139    }
140
141    /// Override the default `SandboxPolicy`.
142    pub fn with_policy(mut self, policy: SandboxPolicy) -> Self {
143        self.policy = policy;
144        self
145    }
146}
147
148impl SandboxPort for LocalSandboxAdapter {
149    fn execute(&self, mutation: &PreparedMutation) -> SandboxExecutionResult {
150        // Clone data needed inside the thread.
151        let policy = self.policy.clone();
152
153        // We need to own the sandbox and mutation for the async block. Clone
154        // the mutation; wrap the sandbox in Arc so the thread can use it.
155        let mutation_owned = mutation.clone();
156        let inner = &self.inner;
157
158        // Execute the async `apply` call on a dedicated blocking thread so
159        // that this function can remain synchronous without deadlocking an
160        // existing tokio runtime.
161        let run_result: Result<_, SandboxError> = {
162            match tokio::runtime::Handle::try_current() {
163                Ok(handle) => {
164                    // We are inside an existing tokio runtime — offload to a
165                    // blocking thread pool slot to avoid blocking the executor.
166                    std::thread::scope(|s| {
167                        s.spawn(|| handle.block_on(inner.apply(&mutation_owned, &policy)))
168                            .join()
169                            .unwrap_or_else(|_| {
170                                Err(SandboxError::Io("sandbox thread panicked".into()))
171                            })
172                    })
173                }
174                Err(_) => {
175                    // No runtime in scope — create a temporary one.
176                    tokio::runtime::Runtime::new()
177                        .map_err(|e| SandboxError::Io(e.to_string()))
178                        .and_then(|rt| rt.block_on(inner.apply(&mutation_owned, &policy)))
179                }
180            }
181        };
182
183        match run_result {
184            Ok(receipt) => {
185                let changed: Vec<String> = receipt
186                    .changed_files
187                    .iter()
188                    .map(|p| p.display().to_string())
189                    .collect();
190                SandboxExecutionResult {
191                    success: receipt.applied,
192                    stdout: changed.join("\n"),
193                    stderr: String::new(),
194                    duration_ms: 0,
195                    message: if receipt.applied {
196                        "Sandbox mutation applied successfully".to_string()
197                    } else {
198                        "Sandbox applied but patch was not marked as applied".to_string()
199                    },
200                }
201            }
202            Err(e) => SandboxExecutionResult::failure(
203                format!("{e}"),
204                format!("Sandbox execution failed: {e}"),
205                0,
206            ),
207        }
208    }
209}
210
211// ─────────────────────────────────────────────────────────────────────────────
212// SqliteGeneStorePersistAdapter
213// ─────────────────────────────────────────────────────────────────────────────
214
215/// Implements `GeneStorePersistPort` using `oris_genestore::SqliteGeneStore`.
216///
217/// Convert `oris-evolution`'s string-typed Gene fields (signals/strategy/
218/// validation) into the richer `oris_genestore::Gene` domain model, then
219/// upsert into the SQLite store. The `gene_id` string is parsed as a UUID;
220/// if it is not a valid UUID a new random one is generated.
221///
222/// Async store calls are bridged synchronously via a dedicated thread,
223/// matching the pattern used by `LocalSandboxAdapter`.
224pub struct SqliteGeneStorePersistAdapter {
225    store: oris_genestore::SqliteGeneStore,
226}
227
228impl SqliteGeneStorePersistAdapter {
229    /// Open (or create) the store at `path`. Use `":memory:"` in tests.
230    pub fn open(path: &str) -> anyhow::Result<Self> {
231        Ok(Self {
232            store: oris_genestore::SqliteGeneStore::open(path)?,
233        })
234    }
235}
236
237impl GeneStorePersistPort for SqliteGeneStorePersistAdapter {
238    fn persist_gene(
239        &self,
240        gene_id: &str,
241        signals: &[String],
242        strategy: &[String],
243        validation: &[String],
244    ) -> bool {
245        use chrono::Utc;
246        use oris_genestore::{Gene, GeneStore};
247        use uuid::Uuid;
248
249        let id = Uuid::parse_str(gene_id).unwrap_or_else(|_| Uuid::new_v4());
250        let gene = Gene {
251            id,
252            name: format!("gene-{}", &gene_id[..gene_id.len().min(8)]),
253            description: signals.first().cloned().unwrap_or_default(),
254            tags: signals.to_vec(),
255            template: strategy.join("\n"),
256            preconditions: vec![],
257            validation_steps: validation.to_vec(),
258            confidence: 0.70,
259            use_count: 0,
260            success_count: 0,
261            quality_score: 0.60,
262            created_at: Utc::now(),
263            last_used_at: None,
264            last_boosted_at: None,
265        };
266
267        let store = &self.store;
268        let result = match tokio::runtime::Handle::try_current() {
269            Ok(handle) => std::thread::scope(|s| {
270                s.spawn(|| handle.block_on(store.upsert_gene(&gene)))
271                    .join()
272                    .unwrap_or_else(|_| Err(anyhow::anyhow!("thread panicked")))
273            }),
274            Err(_) => tokio::runtime::Runtime::new()
275                .map_err(|e| anyhow::anyhow!(e))
276                .and_then(|rt| rt.block_on(store.upsert_gene(&gene))),
277        };
278
279        if let Err(ref e) = result {
280            eprintln!("[SqliteGeneStorePersistAdapter] persist_gene error: {e}");
281        }
282        result.is_ok()
283    }
284
285    fn mark_reused(&self, gene_id: &str, capsule_ids: &[String]) -> bool {
286        use oris_genestore::GeneStore;
287        use uuid::Uuid;
288
289        let id = match Uuid::parse_str(gene_id) {
290            Ok(u) => u,
291            Err(_) => return false,
292        };
293
294        let store = &self.store;
295        let result = match tokio::runtime::Handle::try_current() {
296            Ok(handle) => std::thread::scope(|s| {
297                s.spawn(|| handle.block_on(store.record_gene_outcome(id, true)))
298                    .join()
299                    .unwrap_or_else(|_| Err(anyhow::anyhow!("thread panicked")))
300            }),
301            Err(_) => tokio::runtime::Runtime::new()
302                .map_err(|e| anyhow::anyhow!(e))
303                .and_then(|rt| rt.block_on(store.record_gene_outcome(id, true))),
304        };
305
306        // Log capsule IDs for traceability (store doesn't have capsule-level
307        // reuse tracking in this minimal integration path).
308        if !capsule_ids.is_empty() {
309            eprintln!(
310                "[SqliteGeneStorePersistAdapter] mark_reused gene={} capsules={:?}",
311                gene_id, capsule_ids
312            );
313        }
314
315        if let Err(ref e) = result {
316            eprintln!("[SqliteGeneStorePersistAdapter] mark_reused error: {e}");
317        }
318        result.is_ok()
319    }
320}
321
322// ─────────────────────────────────────────────────────────────────────────────
323// SandboxOutputValidateAdapter
324// ─────────────────────────────────────────────────────────────────────────────
325
326/// Implements `ValidatePort` by interpreting sandbox execution output.
327///
328/// **Logic** (fully synchronous, no I/O):
329/// * `execution_success = true`  → `passed: true, score: 0.9`
330/// * `execution_success = false` and stderr matches known failure tokens
331///   (e.g. `FAILED`, `error[E`, `panicked at`) → `passed: false, score: 0.0, issues = [...]`
332/// * Otherwise failure → `passed: false, score: 0.2` (generic I/O error)
333pub struct SandboxOutputValidateAdapter;
334
335impl SandboxOutputValidateAdapter {
336    /// Keywords that identify hard test/compile failures in stderr.
337    const FAIL_TOKENS: &'static [&'static str] = &[
338        "FAILED",
339        "error[E",
340        "error:",
341        "panicked at",
342        "thread '",
343        "COMPILATION FAILED",
344        "test result: FAILED",
345    ];
346
347    pub fn new() -> Self {
348        Self
349    }
350}
351
352impl Default for SandboxOutputValidateAdapter {
353    fn default() -> Self {
354        Self::new()
355    }
356}
357
358impl ValidatePort for SandboxOutputValidateAdapter {
359    fn validate(&self, input: &ValidateInput) -> ValidationResult {
360        if input.execution_success {
361            return ValidationResult {
362                proposal_id: input.proposal_id.clone(),
363                passed: true,
364                score: 0.9,
365                issues: vec![],
366                simulation_results: None,
367            };
368        }
369
370        // Execution failed — classify the failure.
371        let matching_tokens: Vec<&str> = Self::FAIL_TOKENS
372            .iter()
373            .copied()
374            .filter(|&tok| input.stderr.contains(tok) || input.stdout.contains(tok))
375            .collect();
376
377        let (score, description) = if !matching_tokens.is_empty() {
378            (
379                0.0_f32,
380                format!(
381                    "Sandbox execution failed (matched tokens: {}). stderr snippet: {}",
382                    matching_tokens.join(", "),
383                    &input.stderr[..input.stderr.len().min(200)],
384                ),
385            )
386        } else {
387            (
388                0.2_f32,
389                format!(
390                    "Sandbox execution failed without a recognised pattern. stderr snippet: {}",
391                    &input.stderr[..input.stderr.len().min(200)],
392                ),
393            )
394        };
395
396        ValidationResult {
397            proposal_id: input.proposal_id.clone(),
398            passed: false,
399            score,
400            issues: vec![ValidationIssue {
401                severity: IssueSeverity::Error,
402                description,
403                location: None,
404            }],
405            simulation_results: None,
406        }
407    }
408}
409
410// ─────────────────────────────────────────────────────────────────────────────
411// MutationEvaluatorAdapter
412// ─────────────────────────────────────────────────────────────────────────────
413
414/// Implements `EvaluatePort` by delegating to `oris_mutation_evaluator::MutationEvaluator`.
415///
416/// The evaluator is async; this adapter bridges to the synchronous `EvaluatePort` contract
417/// using a dedicated thread (same pattern as `LocalSandboxAdapter`), avoiding runtime nesting.
418pub struct MutationEvaluatorAdapter {
419    evaluator: oris_mutation_evaluator::MutationEvaluator,
420}
421
422impl MutationEvaluatorAdapter {
423    /// Construct the adapter with the given evaluator.
424    /// Use `MockMutationBackend` for tests or `EnvRoutedBackend` for production.
425    pub fn new(evaluator: oris_mutation_evaluator::MutationEvaluator) -> Self {
426        Self { evaluator }
427    }
428
429    /// Convenience constructor using a mock critic (offline / no API key).
430    /// For a production evaluator with a real LLM, construct `MutationEvaluator`
431    /// manually with the desired `LlmCritic` implementation.
432    pub fn from_mock() -> Self {
433        let backend = oris_mutation_evaluator::MockCritic::passing();
434        Self::new(oris_mutation_evaluator::MutationEvaluator::new(backend))
435    }
436}
437
438impl EvaluatePort for MutationEvaluatorAdapter {
439    fn evaluate(&self, input: &EvaluateInput) -> EvaluationResult {
440        use oris_mutation_evaluator::types::{
441            EvoSignal, MutationProposal, SignalKind as EvalSignalKind,
442        };
443        use uuid::Uuid;
444
445        // Map signal strings → EvoSignal (use generic CompilerError as kind).
446        let signals: Vec<EvoSignal> = input
447            .signals
448            .iter()
449            .map(|s| EvoSignal {
450                kind: EvalSignalKind::CompilerError,
451                message: s.clone(),
452                location: None,
453            })
454            .collect();
455
456        let id = Uuid::parse_str(&input.proposal_id).unwrap_or_else(|_| Uuid::new_v4());
457        let proposal = MutationProposal {
458            id,
459            intent: input.intent.clone(),
460            original: input.original.clone(),
461            proposed: input.proposed.clone(),
462            signals,
463            source_gene_id: None,
464        };
465
466        // Bridge async → sync using a dedicated thread so we never nest runtimes.
467        let evaluator = &self.evaluator;
468        let report_result = match tokio::runtime::Handle::try_current() {
469            Ok(handle) => std::thread::scope(|s| {
470                s.spawn(|| handle.block_on(evaluator.evaluate(&proposal)))
471                    .join()
472                    .unwrap_or_else(|_| Err(anyhow::anyhow!("evaluator thread panicked")))
473            }),
474            Err(_) => tokio::runtime::Runtime::new()
475                .map_err(|e| anyhow::anyhow!(e))
476                .and_then(|rt| rt.block_on(evaluator.evaluate(&proposal))),
477        };
478
479        match report_result {
480            Ok(report) => {
481                let recommendation = match report.verdict {
482                    oris_mutation_evaluator::Verdict::Reject => EvaluationRecommendation::Reject,
483                    oris_mutation_evaluator::Verdict::Promote => EvaluationRecommendation::Accept,
484                    oris_mutation_evaluator::Verdict::ApplyOnly => EvaluationRecommendation::Accept,
485                };
486                EvaluationResult {
487                    score: report.composite_score as f32,
488                    improvements: vec![report.rationale.clone()],
489                    regressions: report
490                        .anti_patterns
491                        .iter()
492                        .map(|ap| ap.description.clone())
493                        .collect(),
494                    recommendation,
495                }
496            }
497            Err(e) => {
498                eprintln!("[MutationEvaluatorAdapter] evaluate error: {e}");
499                // Fail-closed: return a neutral score and require human review.
500                EvaluationResult {
501                    score: 0.0,
502                    improvements: vec![],
503                    regressions: vec![format!("Evaluator error: {e}")],
504                    recommendation: EvaluationRecommendation::RequiresHumanReview,
505                }
506            }
507        }
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514    use oris_mutation_evaluator::{MockCritic, MutationEvaluator};
515
516    fn sample_evaluate_input() -> EvaluateInput {
517        EvaluateInput {
518            proposal_id: "11111111-1111-1111-1111-111111111111".to_string(),
519            intent: "Fix compiler error in docs helper".to_string(),
520            original: "fn helper() -> i32 { broken_call() }".to_string(),
521            proposed: "fn helper() -> i32 { 42 }".to_string(),
522            signals: vec!["cannot find function `broken_call` in this scope".to_string()],
523        }
524    }
525
526    #[test]
527    fn validate_passes_on_success_with_clean_output() {
528        let adapter = SandboxOutputValidateAdapter::new();
529        let result = adapter.validate(&ValidateInput {
530            proposal_id: "proposal-clean".to_string(),
531            execution_success: true,
532            stdout: String::new(),
533            stderr: String::new(),
534        });
535
536        assert!(result.passed);
537        assert_eq!(result.score, 0.9);
538        assert!(result.issues.is_empty());
539    }
540
541    #[test]
542    fn validate_fails_on_execution_failure_with_fail_token() {
543        let adapter = SandboxOutputValidateAdapter::new();
544        let result = adapter.validate(&ValidateInput {
545            proposal_id: "proposal-failed".to_string(),
546            execution_success: false,
547            stdout: "test result: FAILED. 1 passed; 2 failed".to_string(),
548            stderr: "".to_string(),
549        });
550
551        assert!(!result.passed);
552        assert_eq!(result.score, 0.0);
553        assert_eq!(result.issues.len(), 1);
554        assert!(result.issues[0].description.contains("FAILED"));
555    }
556
557    #[test]
558    fn validate_passes_on_success_even_if_stdout_has_warnings() {
559        let adapter = SandboxOutputValidateAdapter::new();
560        let result = adapter.validate(&ValidateInput {
561            proposal_id: "proposal-warn".to_string(),
562            execution_success: true,
563            stdout: "warning: unused import\nwarning: dead_code".to_string(),
564            stderr: String::new(),
565        });
566
567        assert!(result.passed);
568        assert_eq!(result.score, 0.9);
569        assert!(result.issues.is_empty());
570    }
571
572    #[test]
573    fn evaluator_adapter_from_mock_returns_accept() {
574        let adapter = MutationEvaluatorAdapter::from_mock();
575        let result = adapter.evaluate(&sample_evaluate_input());
576
577        assert_eq!(result.recommendation, EvaluationRecommendation::Accept);
578        assert!(result.score > 0.0);
579        assert_eq!(result.improvements.len(), 1);
580        assert!(result.regressions.is_empty());
581    }
582
583    #[test]
584    fn evaluator_adapter_maps_reject_to_reject() {
585        let adapter = MutationEvaluatorAdapter::new(MutationEvaluator::new(MockCritic::failing()));
586        let result = adapter.evaluate(&sample_evaluate_input());
587
588        assert_eq!(result.recommendation, EvaluationRecommendation::Reject);
589        assert!(result.score >= 0.0);
590    }
591}