1use futures::StreamExt as _;
4use turnframe_core::ids::TurnId;
5use turnframe_core::interaction::Interaction;
6use turnframe_core::locale::Locale;
7use turnframe_core::response::{AssistantTurn, Expectation, ResponseBlock};
8use turnframe_core::turn::{InteractionResponse, TurnInput};
9use turnframe_runtime::orchestrator::error_code;
10use turnframe_store::interaction::InteractionReader;
11
12use super::goal::Goal;
13use super::score::{Conversation, Ending, Exchange, SimulationReport, score};
14use super::user::{CardOnScreen, Screen, SimulatedUser, UserMove};
15use crate::assertions::check;
16use crate::observation::Observation;
17use crate::runner::{EvalHarness, PreparedRun, SampleIndex};
18
19#[must_use]
21pub fn turn_at(first: TurnId, number: u32) -> TurnId {
22 let offset = u128::from(number) << 64;
23 TurnId::from(uuid::Uuid::from_u128(
24 first.as_uuid().as_u128().wrapping_add(offset),
25 ))
26}
27
28#[derive(Debug, Clone, Copy)]
30pub struct Simulation {
31 samples: u32,
32 concurrency: usize,
33}
34
35impl Default for Simulation {
36 fn default() -> Self {
37 Self {
38 samples: 1,
39 concurrency: 1,
40 }
41 }
42}
43
44impl Simulation {
45 #[must_use]
47 pub fn new() -> Self {
48 Self::default()
49 }
50
51 #[must_use]
53 pub const fn with_samples(mut self, samples: u32) -> Self {
54 self.samples = samples;
55 self
56 }
57
58 #[must_use]
60 pub const fn with_concurrency(mut self, concurrency: usize) -> Self {
61 self.concurrency = concurrency;
62 self
63 }
64
65 pub async fn run(
67 &self,
68 goals: &[Goal],
69 harness: &dyn EvalHarness,
70 user: &dyn SimulatedUser,
71 ) -> SimulationReport {
72 let runs = goals.iter().flat_map(|goal| {
73 goal.manners.iter().flat_map(move |manner| {
74 (0..self.samples.max(1)).map(move |sample| (goal, manner, SampleIndex(sample)))
75 })
76 });
77 let conversations = futures::stream::iter(
78 runs.map(|(goal, manner, sample)| converse(goal, manner, harness, user, sample)),
79 )
80 .buffered(self.concurrency.max(1))
81 .collect()
82 .await;
83 SimulationReport { conversations }
84 }
85}
86
87pub async fn converse(
89 goal: &Goal,
90 manner: &str,
91 harness: &dyn EvalHarness,
92 user: &dyn SimulatedUser,
93 sample: SampleIndex,
94) -> Conversation {
95 let mut conversation = Conversation {
96 goal: goal.id.clone(),
97 manner: manner.to_owned(),
98 sample: sample.number(),
99 exchanges: Vec::new(),
100 ended: Ending::TurnLimit,
101 score: score(&[], false),
102 };
103 let prepared = match harness.prepare(&goal.item(), sample).await {
104 Ok(prepared) => prepared,
105 Err(error) => {
106 conversation.ended = Ending::Unprepared {
107 message: error.to_string(),
108 };
109 return conversation;
110 }
111 };
112 let locale = goal.locale.clone().unwrap_or_else(|| Locale::from("en"));
113 let mut read: Vec<(UserMove, String)> = Vec::new();
114 let mut ran: Vec<TurnId> = Vec::new();
115 let mut offers: Vec<String> = Vec::new();
116 for number in 0..goal.max_turns {
117 let open = open_cards(&prepared).await;
118 let cards: Vec<CardOnScreen> = open.iter().map(|card| on_screen(card, &locale)).collect();
119 let screen = Screen {
120 want: &goal.want,
121 manner,
122 exchanges: &read,
123 cards: &cards,
124 offers: &offers,
125 turns_left: goal.max_turns - number,
126 };
127 let said = match user.next(&screen).await {
128 Ok(UserMove::Done) => {
129 conversation.ended = Ending::Done;
130 break;
131 }
132 Ok(said) => said,
133 Err(message) => {
134 conversation.ended = Ending::UserFailed { message };
135 break;
136 }
137 };
138 let (text, response) = match &said {
139 UserMove::Say { text } => (Some(text.clone()), None),
140 UserMove::Press { option } => match pressed(&open, option, &locale) {
141 Some(response) => (None, Some(response)),
142 None => {
144 let note = format!("(no card on screen has an option «{option}»)");
145 read.push((said.clone(), note));
146 continue;
147 }
148 },
149 UserMove::Done => (None, None),
150 };
151 let turn_id = turn_at(prepared.turn_id, number);
152 let outcome = prepared
153 .orchestrator
154 .handle_turn(input(&prepared, text, response, turn_id, &locale))
155 .await;
156 let observed = Observation::collect(
157 prepared.orchestrator.stores(),
158 prepared.workflows.as_ref(),
159 &prepared.actor.account_id,
160 turn_id,
161 &goal.setup.cases,
162 outcome.as_ref().map_err(error_code),
163 )
164 .await;
165 ran.push(turn_id);
166 let blocking_open = open_cards(&prepared).await.iter().any(|card| card.blocking);
167 offers = outcome.as_ref().map_or_else(
168 |_| Vec::new(),
169 |turn| {
170 turn.offers
171 .iter()
172 .map(|offer| offer.words.clone())
173 .collect()
174 },
175 );
176 let exchange = match &outcome {
177 Ok(turn) => answered(turn, &observed, &locale, blocking_open),
178 Err(error) => Exchange {
179 failed: Some(error_code(error)),
180 ..Exchange::default()
181 },
182 };
183 read.push((said.clone(), exchange.reply.clone()));
184 conversation.exchanges.push(Exchange {
185 said: Some(said),
186 ..exchange
187 });
188 }
189 let reached = reached(goal, &prepared, &ran).await;
190 conversation.score = score(&conversation.exchanges, reached);
191 conversation
192}
193
194async fn reached(goal: &Goal, prepared: &PreparedRun, ran: &[TurnId]) -> bool {
196 let (last, earlier) = match ran.split_last() {
197 Some((last, earlier)) => (*last, earlier),
198 None => (prepared.turn_id, &[][..]),
199 };
200 let mut observed = Observation::collect(
201 prepared.orchestrator.stores(),
202 prepared.workflows.as_ref(),
203 &prepared.actor.account_id,
204 last,
205 &goal.setup.cases,
206 Err(String::new()),
207 )
208 .await
209 .with_conversation_cases(
210 prepared.orchestrator.stores(),
211 prepared.workflows.as_ref(),
212 &prepared.actor.account_id,
213 earlier,
214 )
215 .await;
216 observed.error_code = None;
218 check(&goal.reached.expectations(), &observed).is_empty()
219}
220
221async fn open_cards(prepared: &PreparedRun) -> Vec<Interaction> {
223 let store = prepared.orchestrator.stores().interactions();
224 let mut open = InteractionReader::list_open_for_conversation(
225 store.as_ref(),
226 &prepared.actor.account_id,
227 &prepared.conversation_id,
228 )
229 .await
230 .unwrap_or_default();
231 open.sort_by_key(|card| !card.blocking);
232 open
233}
234
235fn on_screen(card: &Interaction, locale: &Locale) -> CardOnScreen {
236 CardOnScreen {
237 title: card.payload.title.resolve(locale).to_owned(),
238 options: card
239 .payload
240 .options
241 .iter()
242 .map(|option| {
243 (
244 option.id.as_str().to_owned(),
245 option.label.resolve(locale).to_owned(),
246 )
247 })
248 .collect(),
249 }
250}
251
252fn pressed(open: &[Interaction], option: &str, locale: &Locale) -> Option<InteractionResponse> {
254 open.iter().find_map(|card| {
255 let chosen = card.payload.options.iter().find(|offered| {
256 offered.id.as_str().eq_ignore_ascii_case(option)
257 || offered.label.resolve(locale).eq_ignore_ascii_case(option)
258 })?;
259 Some(InteractionResponse {
260 interaction_id: card.id,
261 option_id: chosen.id.clone(),
262 expected_case_revision: card.case_ref.expected_revision,
263 freeform_input: None,
264 })
265 })
266}
267
268fn input(
269 prepared: &PreparedRun,
270 text: Option<String>,
271 interaction_response: Option<InteractionResponse>,
272 turn_id: TurnId,
273 locale: &Locale,
274) -> TurnInput {
275 TurnInput {
276 turn_id,
277 conversation_id: prepared.conversation_id,
278 actor: prepared.actor.clone(),
279 text,
280 interaction_response,
281 attachments: Vec::new(),
282 origin: None,
283 locale: locale.clone(),
284 effort: None,
285 }
286}
287
288fn answered(
290 turn: &AssistantTurn,
291 observed: &Observation,
292 locale: &Locale,
293 blocking_open: bool,
294) -> Exchange {
295 let replied = turn
297 .blocks
298 .iter()
299 .any(|block| matches!(block, ResponseBlock::Transition(_)));
300 let mut shown = Vec::new();
301 let mut prose = String::new();
302 let mut card = false;
303 for block in &turn.blocks {
304 match block {
305 ResponseBlock::Transition(transition) => {
306 prose.clone_from(&transition.text);
307 shown.push(transition.text.clone());
308 }
309 ResponseBlock::Answer(answer) if !replied => shown.push(answer.text.clone()),
310 ResponseBlock::Receipt(receipt) => shown.push(format!(
311 "[{}: {}]",
312 receipt.receipt.title.resolve(locale),
313 receipt.receipt.body.resolve(locale)
314 )),
315 ResponseBlock::Notice(notice) => {
316 shown.push(format!("[{}]", notice.text.resolve(locale)));
317 }
318 ResponseBlock::Interaction(_) => card = true,
319 _ => {}
320 }
321 }
322 let asks_a_question = prose
323 .trim_end()
324 .trim_end_matches(['"', '»', ')', '*', '_'])
325 .ends_with('?');
326 let asks: Vec<String> = turn.expectations.iter().filter_map(asked).collect();
328 Exchange {
329 said: None,
330 reply: shown.join("\n"),
331 failed: None,
332 way_forward: asks_a_question
333 || !asks.is_empty()
334 || card
335 || blocking_open
336 || !turn.offers.is_empty(),
337 asks,
338 offers: turn
339 .offers
340 .iter()
341 .map(|offer| offer.operation.as_str().to_owned())
342 .collect(),
343 refused: observed
344 .acts
345 .iter()
346 .filter(|act| act.outcome.as_deref() == Some("rejected"))
347 .filter_map(|act| act.operation.clone())
348 .collect(),
349 not_understood: observed
350 .understanding
351 .as_ref()
352 .map_or(0, |understanding| understanding.not_understood.len()),
353 }
354}
355
356fn asked(expectation: &Expectation) -> Option<String> {
358 Some(match expectation {
359 Expectation::AwaitingObligation {
360 case_ref,
361 obligation,
362 }
363 | Expectation::AwaitingOperation {
364 case_ref,
365 obligation,
366 ..
367 } => format!("{}/{}: {obligation}", case_ref.workflow, case_ref.case_id),
368 Expectation::AwaitingValue {
369 act,
370 case_ref,
371 missing,
372 } => format!(
373 "{}: {} {}",
374 case_ref.as_ref().map_or_else(String::new, |case| format!(
375 "{}/{}",
376 case.workflow, case.case_id
377 )),
378 act.operation().map_or("", |operation| operation.as_str()),
379 missing.join(",")
380 ),
381 _ => return None,
382 })
383}