Skip to main content

runifold_testkit/
lib.rs

1//! Deterministic helpers for testing Runifold runtime semantics.
2
3mod evaluation;
4mod evaluation_scorers;
5mod evaluation_store;
6mod fault;
7mod golden_trace;
8mod retrieval_evaluation;
9
10use std::{
11    collections::VecDeque,
12    sync::{Arc, Mutex},
13};
14
15use runifold_core::{
16    Budget, BudgetTracker, CapabilitySet, EventFactory, InMemoryJournal, LifecycleEvent,
17    RunContext, RunEvent, RunEventKind,
18};
19use runifold_model::{
20    Model, ModelCallContext, ModelCapabilities, ModelError, ModelErrorKind, ModelEventStream,
21    ModelFuture, ModelRef, ModelRequest, ModelResponse, ModelStreamAccumulator, ModelStreamEvent,
22};
23use serde_json::Value;
24
25pub use evaluation::{
26    EvaluationCase, EvaluationCaseId, EvaluationCaseResult, EvaluationDataset, EvaluationError,
27    EvaluationFailure, EvaluationFailureStage, EvaluationFuture, EvaluationMetrics,
28    EvaluationOutput, EvaluationReport, EvaluationRunner, EvaluationScore, EvaluationScoreSummary,
29    EvaluationScorer, EvaluationTarget, FnScorer, JsonExactMatchScorer, MetricRegression,
30    RegressionComparison, RegressionPolicy, ScoreValue,
31};
32pub use evaluation_scorers::{
33    JsonRule, JsonRuleScorer, JudgeRubric, ModelJudgeScorer, TokenOverlapScorer, WeightedJsonRule,
34};
35pub use evaluation_store::{EvaluationRepository, EvaluationStoreError, FileEvaluationRepository};
36pub use fault::{
37    FaultController, FaultInjectingModel, FaultInjectingTool, FaultScenario, ModelFault,
38    RecoveryHarness, ScenarioAssertionError, ToolFault,
39};
40pub use golden_trace::{GoldenTrace, GoldenTraceMismatch};
41pub use retrieval_evaluation::{
42    RetrievalCaseMetrics, RetrievalEvaluationCase, RetrievalEvaluationError,
43    RetrievalEvaluationReport, RetrievalEvaluationRunner,
44};
45
46type ModelScript = Result<Vec<ModelStreamEvent>, ModelError>;
47type ModelScriptQueue = Arc<Mutex<VecDeque<ModelScript>>>;
48
49/// An isolated root run with deterministic event collection.
50#[derive(Debug)]
51pub struct RunScenario {
52    context: RunContext,
53    events: EventFactory,
54    journal: InMemoryJournal,
55}
56
57impl RunScenario {
58    /// Creates a scenario with an empty capability set.
59    pub fn new(budget: Budget) -> Self {
60        let context = RunContext::root(BudgetTracker::new(budget), CapabilitySet::new());
61        let events = EventFactory::new(context.run_id(), context.parent_run_id());
62        Self {
63            context,
64            events,
65            journal: InMemoryJournal::new(),
66        }
67    }
68
69    /// Returns the scenario's run context.
70    pub const fn context(&self) -> &RunContext {
71        &self.context
72    }
73
74    /// Records a run-start event.
75    pub fn start(&self) -> RunEvent {
76        let event = self
77            .events
78            .emit(RunEventKind::Lifecycle(LifecycleEvent::Started), None);
79        self.journal.push(event.clone());
80        event
81    }
82
83    /// Records a successful terminal event caused by a prior event.
84    pub fn complete(&self, output: Value, caused_by: &RunEvent) -> RunEvent {
85        let event = self.events.emit(
86            RunEventKind::Lifecycle(LifecycleEvent::Completed { output }),
87            Some(caused_by.meta.event_id),
88        );
89        self.journal.push(event.clone());
90        event
91    }
92
93    /// Returns a snapshot of recorded events.
94    pub fn recorded_events(&self) -> Vec<RunEvent> {
95        self.journal.events()
96    }
97}
98
99/// Accumulates a scripted provider event sequence without network access.
100#[derive(Clone, Debug, Default)]
101pub struct ModelScenario {
102    events: Vec<ModelStreamEvent>,
103}
104
105impl ModelScenario {
106    /// Creates an empty model scenario.
107    pub fn new() -> Self {
108        Self::default()
109    }
110
111    /// Appends a provider-neutral stream event.
112    #[must_use]
113    pub fn then(mut self, event: ModelStreamEvent) -> Self {
114        self.events.push(event);
115        self
116    }
117
118    /// Reconstructs the scenario's canonical response.
119    ///
120    /// # Errors
121    ///
122    /// Returns [`ModelError`] if the scripted stream violates lifecycle or
123    /// content-block rules, or if it never produces a terminal response.
124    pub fn response(self) -> Result<ModelResponse, ModelError> {
125        let mut accumulator = ModelStreamAccumulator::new();
126        for event in self.events {
127            if let Some(response) = accumulator.push(event)? {
128                return Ok(response);
129            }
130        }
131        Err(ModelError::local(
132            runifold_model::ModelErrorKind::StreamState,
133            "scripted model stream did not complete",
134        ))
135    }
136}
137
138/// A deterministic, queue-backed model adapter.
139///
140/// Each invocation consumes exactly one queued script. This makes retry,
141/// fallback, and multi-turn behavior explicit in tests.
142#[derive(Clone, Debug, Default)]
143pub struct ScriptedModel {
144    capabilities: ModelCapabilities,
145    scripts: ModelScriptQueue,
146    requests: Arc<Mutex<Vec<ModelRequest>>>,
147    contexts: Arc<Mutex<Vec<ModelCallContext>>>,
148}
149
150impl ScriptedModel {
151    /// Creates a scripted model with unknown capabilities.
152    pub fn new() -> Self {
153        Self::default()
154    }
155
156    /// Replaces the capabilities returned by discovery calls.
157    #[must_use]
158    pub fn with_capabilities(mut self, capabilities: ModelCapabilities) -> Self {
159        self.capabilities = capabilities;
160        self
161    }
162
163    /// Queues one successful canonical event stream.
164    pub fn enqueue(&self, events: impl IntoIterator<Item = ModelStreamEvent>) {
165        self.scripts()
166            .push_back(Ok(events.into_iter().collect::<Vec<_>>()));
167    }
168
169    /// Queues an error returned while opening the next stream.
170    pub fn enqueue_error(&self, error: ModelError) {
171        self.scripts().push_back(Err(error));
172    }
173
174    /// Returns canonical requests observed by this model.
175    pub fn recorded_requests(&self) -> Vec<ModelRequest> {
176        self.requests
177            .lock()
178            .unwrap_or_else(std::sync::PoisonError::into_inner)
179            .clone()
180    }
181
182    /// Returns invocation contexts observed by this model.
183    pub fn recorded_contexts(&self) -> Vec<ModelCallContext> {
184        self.contexts
185            .lock()
186            .unwrap_or_else(std::sync::PoisonError::into_inner)
187            .clone()
188    }
189
190    fn scripts(&self) -> std::sync::MutexGuard<'_, VecDeque<ModelScript>> {
191        self.scripts
192            .lock()
193            .unwrap_or_else(std::sync::PoisonError::into_inner)
194    }
195}
196
197impl Model for ScriptedModel {
198    fn capabilities<'a>(
199        &'a self,
200        _model: &'a ModelRef,
201    ) -> ModelFuture<'a, Result<ModelCapabilities, ModelError>> {
202        let capabilities = self.capabilities.clone();
203        Box::pin(async move { Ok(capabilities) })
204    }
205
206    fn stream(
207        &self,
208        request: ModelRequest,
209        context: ModelCallContext,
210    ) -> ModelFuture<'_, Result<ModelEventStream, ModelError>> {
211        self.requests
212            .lock()
213            .unwrap_or_else(std::sync::PoisonError::into_inner)
214            .push(request);
215        self.contexts
216            .lock()
217            .unwrap_or_else(std::sync::PoisonError::into_inner)
218            .push(context);
219        let script = self.scripts().pop_front().unwrap_or_else(|| {
220            Err(ModelError::local(
221                ModelErrorKind::Protocol,
222                "scripted model has no queued invocation",
223            ))
224        });
225        Box::pin(async move {
226            let events = script?;
227            Ok(
228                Box::pin(futures_util::stream::iter(events.into_iter().map(Ok)))
229                    as ModelEventStream,
230            )
231        })
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use std::collections::BTreeMap;
238
239    use runifold_core::Budget;
240    use runifold_model::{
241        ContentBlockKind, ContentPart, FinishReason, Message, Model, ModelCallContext,
242        ModelErrorKind, ModelRef, ModelRequest, ModelStreamEvent,
243    };
244
245    use super::{ModelScenario, RunScenario, ScriptedModel};
246
247    #[test]
248    fn scenario_records_a_causal_lifecycle() {
249        let scenario = RunScenario::new(Budget::default());
250        let started = scenario.start();
251        let completed = scenario.complete(serde_json::json!({"ok": true}), &started);
252        let events = scenario.recorded_events();
253
254        assert_eq!(events, vec![started.clone(), completed.clone()]);
255        assert_eq!(completed.meta.caused_by, Some(started.meta.event_id));
256    }
257
258    #[test]
259    fn model_scenario_accumulates_a_response() {
260        let response = ModelScenario::new()
261            .then(ModelStreamEvent::ResponseStarted {
262                id: Some("test-response".into()),
263                model: ModelRef::new("test", "scripted"),
264            })
265            .then(ModelStreamEvent::ContentBlockStarted {
266                index: 0,
267                kind: ContentBlockKind::Text,
268            })
269            .then(ModelStreamEvent::TextDelta {
270                index: 0,
271                text: "hello".into(),
272            })
273            .then(ModelStreamEvent::ContentBlockCompleted { index: 0 })
274            .then(ModelStreamEvent::ResponseCompleted {
275                finish_reason: FinishReason::Stop,
276                provider_metadata: BTreeMap::new(),
277            })
278            .response()
279            .unwrap();
280
281        assert_eq!(response.content, vec![ContentPart::text("hello")]);
282    }
283
284    #[test]
285    fn scripted_model_uses_the_same_stream_accumulator_as_real_adapters() {
286        let model = ScriptedModel::new();
287        model.enqueue([
288            ModelStreamEvent::ResponseStarted {
289                id: Some("test-response".into()),
290                model: ModelRef::new("test", "scripted"),
291            },
292            ModelStreamEvent::ContentPartCompleted {
293                index: 0,
294                part: ContentPart::text("hello"),
295            },
296            ModelStreamEvent::ResponseCompleted {
297                finish_reason: FinishReason::Stop,
298                provider_metadata: BTreeMap::new(),
299            },
300        ]);
301        let request = ModelRequest::new(ModelRef::new("test", "scripted"), Message::user("hello"));
302
303        let response = futures_executor::block_on(model.invoke(request, ModelCallContext::new()))
304            .expect("script should complete");
305
306        assert_eq!(response.content, vec![ContentPart::text("hello")]);
307    }
308
309    #[test]
310    fn scripted_model_invocation_observes_preexisting_cancellation() {
311        let model = ScriptedModel::new();
312        model.enqueue([]);
313        let request = ModelRequest::new(ModelRef::new("test", "scripted"), Message::user("hello"));
314        let context = ModelCallContext::new();
315        context.cancellation().cancel();
316
317        let error = futures_executor::block_on(model.invoke(request, context))
318            .expect_err("cancelled invocation must fail");
319
320        assert_eq!(error.kind, ModelErrorKind::Cancelled);
321    }
322}