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