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