turnframe_eval/observation.rs
1//! What one execution of an item actually did.
2//!
3//! An [`Observation`] is read back from the same places an operator would look
4//! after an incident — the replay record, the command journal, the event
5//! ledger, the interaction store, the phase marker and the persisted turn — and
6//! nowhere else. Nothing here is inferred from the model's words, which is why
7//! a judge can never be asked whether an effect happened: by the time a judge
8//! sees anything, the effects have already been read from storage.
9//!
10//! The observation is also what makes sampling meaningful. Its
11//! [`signature`](Observation::signature) is a canonical rendering of the
12//! deterministic facts of one run, so two samples that behaved identically
13//! collapse to one string and two that did not, do not.
14
15use std::collections::BTreeSet;
16use std::fmt::Write as _;
17
18use turnframe_core::case::CaseKey;
19use turnframe_core::flow::WorkflowRegistry;
20use turnframe_core::ids::{AccountId, CaseId, InteractionId, TurnId};
21use turnframe_core::interaction::{InteractionKind, InteractionStatus};
22use turnframe_core::replay::{DiscardedAnswer, ProviderAttemptOutcome, ReplayRecord, TurnPhase};
23use turnframe_core::response::{AssistantTurn, ResponseBlock};
24use turnframe_core::target::TargetResolution;
25use turnframe_core::understanding::UnderstoodAct;
26use turnframe_runtime::resume::CARD_UNIT;
27use turnframe_store::conversation::ConversationReader;
28use turnframe_store::events::{EventCursor, EventJournalReader};
29use turnframe_store::interaction::InteractionReader;
30use turnframe_store::journal::CommandJournalReader;
31use turnframe_store::replay::ReplayReader;
32use turnframe_store::stores::Stores;
33
34use crate::corpus::{BlockKind, CaseSeed};
35
36/// How many events one page of the journal carries while the ledger is being
37/// paged.
38///
39/// This is a page size, not a bound: [`Observation::collect`] keeps asking
40/// until the journal says it has caught up, so the only thing this number
41/// changes is how many round trips that takes.
42const EVENT_PAGE_SIZE: usize = 512;
43
44/// A case's state on both sides of the turn.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct ObservedState {
47 /// What the item seeded.
48 pub before: serde_json::Value,
49 /// What the store holds now, or `None` for a case that is no longer there.
50 pub after: Option<serde_json::Value>,
51}
52
53/// One act the message was understood to ask for, as the replay record holds it.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct ObservedAct {
56 /// Snake-case kind, `apply_operation` or `start_workflow`.
57 pub kind: String,
58 /// The operation, when the variant names one.
59 pub operation: Option<String>,
60 /// What the reduction decided about it — `rejected`, `no_change`,
61 /// `awaiting_confirmation`, and the rest of
62 /// [`PlannedActResult::name`](turnframe_core::reduce::PlannedActResult::name).
63 /// `None` for a turn whose understanding never reached the reduction.
64 pub outcome: Option<String>,
65}
66
67impl ObservedAct {
68 fn of(act: &UnderstoodAct, outcome: Option<&String>) -> Self {
69 Self {
70 kind: act.kind_name().to_owned(),
71 operation: act.operation().map(|key| key.as_str().to_owned()),
72 outcome: outcome.cloned(),
73 }
74 }
75}
76
77/// How one act's target resolved.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct ObservedResolution {
80 /// Position of the act among the message's understood acts.
81 pub act_index: usize,
82 /// Snake-case resolution name, e.g. `exact`.
83 pub resolution: String,
84 /// The case, for the resolutions that name one.
85 pub case_id: Option<CaseId>,
86}
87
88impl ObservedResolution {
89 fn of(act_index: usize, resolution: &TargetResolution) -> Self {
90 let (name, case_id) = match resolution {
91 TargetResolution::Exact { case_ref } => ("exact", Some(case_ref.case_id.clone())),
92 TargetResolution::Ambiguous { .. } => ("ambiguous", None),
93 TargetResolution::Missing => ("missing", None),
94 TargetResolution::Unauthorized => ("unauthorized", None),
95 TargetResolution::Stale { case_ref, .. } => ("stale", Some(case_ref.case_id.clone())),
96 _ => ("other", None),
97 };
98 Self {
99 act_index,
100 resolution: name.to_owned(),
101 case_id,
102 }
103 }
104}
105
106/// One persisted card, as the interaction store holds it.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct ObservedInteraction {
109 /// Identifier.
110 pub id: InteractionId,
111 /// The case it belongs to.
112 pub case: CaseKey,
113 /// Its shape.
114 pub kind: InteractionKind,
115 /// Its lifecycle status.
116 pub status: InteractionStatus,
117 /// Whether it owns unqualified answers for the case.
118 pub blocking: bool,
119 /// The turn that created it.
120 pub created_by_turn: TurnId,
121}
122
123/// Everything one execution of an item left behind.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct Observation {
126 /// The turn that was run.
127 pub turn_id: TurnId,
128 /// The orchestrator's error code, when the turn failed outright.
129 pub error_code: Option<String>,
130 /// The acts the message was understood to ask for, in order. Empty for a turn
131 /// with no text, whose acts come from the card it answers.
132 pub acts: Vec<ObservedAct>,
133 /// How each of those acts' targets resolved.
134 pub target_resolutions: Vec<ObservedResolution>,
135 /// The whole understanding of the message, for scoring each task that made it.
136 pub understanding: Option<turnframe_core::understanding::Understanding>,
137 /// Command types journaled this turn, in admission order.
138 pub commands: Vec<String>,
139 /// Event types committed this turn, in append order.
140 pub events: Vec<String>,
141 /// Whether [`events`](Self::events) was cut short by a configured bound.
142 ///
143 /// It is `false` for every observation collected with
144 /// [`collect`](Self::collect), which pages the journal to the end. It can
145 /// only be `true` when a run set
146 /// [`ExecutionConfig::max_observed_events`](crate::config::ExecutionConfig::max_observed_events),
147 /// and when it is, every assertion that reads the event list fails: an
148 /// expectation checked against half a ledger is not a measurement, and a
149 /// forbidden event hiding in the half nobody read would otherwise report a
150 /// green safety row.
151 pub events_truncated: bool,
152 /// The revision each seeded case ended the turn at.
153 pub revisions: Vec<(CaseKey, u64)>,
154 /// What each seeded case held before the turn and after it.
155 ///
156 /// Both sides, because the two questions a corpus asks about state are
157 /// different and one of them has no answer without the before: «this field
158 /// ends at Lisbon» is about the after, and «this field did not move» is
159 /// about the pair.
160 pub states: Vec<(CaseKey, ObservedState)>,
161 /// Cards known for the seeded cases and cards this turn created.
162 pub interactions: Vec<ObservedInteraction>,
163 /// Response block kinds, in order.
164 pub blocks: Vec<BlockKind>,
165 /// Phase the turn finished in.
166 pub phase: Option<TurnPhase>,
167 /// How many provider attempts failed or fell back.
168 pub provider_failures: usize,
169 /// Every answer a model produced this turn that the runtime threw away
170 /// whole, in the order it discarded them.
171 ///
172 /// # Why a measurement wants this
173 ///
174 /// It is the only channel for a turn that did nothing *and had nothing to
175 /// show for it*. Every other field here reports an effect, and the failure
176 /// this answers — the assistant closing a turn without proposing anything
177 /// or asking anything — leaves no effect by definition, so an empty
178 /// `commands` and an empty `events` look exactly like a turn that correctly
179 /// had nothing to do. This list separates the two: a turn whose plan was
180 /// refused for citing words the user never wrote is a defect, and a turn
181 /// that quietly agreed there was nothing to do is not.
182 ///
183 /// Deliberately **not** part of [`signature`](Self::signature) — see there.
184 pub discarded_answers: Vec<DiscardedAnswer>,
185 /// How many cards this turn created.
186 pub cards_created: usize,
187 /// The model-authored text of the turn, which is all a judge ever sees.
188 pub answer: String,
189}
190
191impl Observation {
192 /// Adds the cases every turn of the conversation wrote, read back now, beside the
193 /// seeded ones. A case the conversation created was seeded as nothing.
194 pub async fn with_conversation_cases(
195 mut self,
196 stores: &Stores,
197 workflows: &WorkflowRegistry,
198 account: &AccountId,
199 earlier: &[TurnId],
200 ) -> Self {
201 let mut keys: Vec<CaseKey> = Vec::new();
202 for turn in earlier.iter().copied().chain(std::iter::once(self.turn_id)) {
203 let entries = CommandJournalReader::for_turn(stores.journal().as_ref(), account, &turn)
204 .await
205 .unwrap_or_default();
206 for entry in entries {
207 let key = entry.case_ref.key();
208 let seen =
209 keys.contains(&key) || self.states.iter().any(|(known, _)| *known == key);
210 if !seen {
211 keys.push(key);
212 }
213 }
214 }
215 for key in keys {
216 let Some(registered) = workflows.get(&key.workflow) else {
217 continue;
218 };
219 if let Ok(loaded) = registered.executor.load(account, &key.case_id).await {
220 self.states.push((
221 key,
222 ObservedState {
223 before: serde_json::Value::Null,
224 after: loaded.value,
225 },
226 ));
227 }
228 }
229 self
230 }
231
232 /// Reads back everything one turn left in the stores, paging the event
233 /// journal to the end.
234 ///
235 /// `outcome` is the turn as the orchestrator returned it, or the stable
236 /// error code of the failure. A failed turn is still observed: what it
237 /// journaled and committed before it failed is exactly what a safety
238 /// assertion is about.
239 ///
240 /// The ledger is read through the journal's **sequence cursor**, one page
241 /// after another, until the journal reports it has caught up. A case with
242 /// nine thousand prior events is therefore observed exactly like a case
243 /// with nine, which is the property a seeded corpus depends on: an item
244 /// whose fixture carries a long history must still see the events its own
245 /// turn committed, and must still see a forbidden one.
246 pub async fn collect(
247 stores: &Stores,
248 workflows: &WorkflowRegistry,
249 account: &AccountId,
250 turn_id: TurnId,
251 cases: &[CaseSeed],
252 outcome: Result<&AssistantTurn, String>,
253 ) -> Self {
254 Self::collect_bounded(stores, workflows, account, turn_id, cases, outcome, None).await
255 }
256
257 /// [`collect`](Self::collect), with an optional cap on how many of the
258 /// turn's events are recorded.
259 ///
260 /// `max_events` is `None` for the complete ledger, which is what every
261 /// reproducible run wants. A `Some(limit)` is a deliberate ceiling for a
262 /// corpus run against a real endpoint where one item could commit an
263 /// unbounded number of events, and it is **never silent**: reaching it sets
264 /// [`events_truncated`](Self::events_truncated), and
265 /// [`check`](crate::assertions::check) turns that into a failure of every
266 /// expectation that reads the event list. A bound that made an assertion
267 /// pass would be worse than no assertion at all.
268 #[allow(clippy::too_many_arguments)]
269 pub async fn collect_bounded(
270 stores: &Stores,
271 workflows: &WorkflowRegistry,
272 account: &AccountId,
273 turn_id: TurnId,
274 cases: &[CaseSeed],
275 outcome: Result<&AssistantTurn, String>,
276 max_events: Option<usize>,
277 ) -> Self {
278 let (turn, error_code) = match outcome {
279 Ok(turn) => (Some(turn), None),
280 Err(code) => (None, Some(code)),
281 };
282 let record = ReplayReader::get(stores.replay().as_ref(), account, &turn_id)
283 .await
284 .ok();
285 let entries = CommandJournalReader::for_turn(stores.journal().as_ref(), account, &turn_id)
286 .await
287 .unwrap_or_default();
288
289 let mut case_keys: Vec<CaseKey> = cases
290 .iter()
291 .map(|seed| CaseKey::new(seed.workflow.clone(), seed.case_id.clone()))
292 .collect();
293 for entry in &entries {
294 let key = entry.case_ref.key();
295 if !case_keys.contains(&key) {
296 case_keys.push(key);
297 }
298 }
299 let command_ids: BTreeSet<_> = entries.iter().map(|entry| entry.command_id).collect();
300 let ledger = events_of(stores, account, &case_keys, &command_ids, max_events).await;
301
302 Self {
303 turn_id,
304 error_code,
305 acts: acts_of(record.as_ref()),
306 target_resolutions: resolutions_of(record.as_ref()),
307 understanding: record
308 .as_ref()
309 .and_then(|record| record.understanding.clone()),
310 commands: entries
311 .iter()
312 .map(|entry| entry.command_type.clone())
313 .collect(),
314 events: ledger.events,
315 events_truncated: ledger.truncated,
316 revisions: revisions_of(workflows, account, &case_keys).await,
317 states: states_of(workflows, account, cases).await,
318 interactions: interactions_of(stores, account, turn_id, &case_keys, record.as_ref())
319 .await,
320 blocks: turn
321 .map(|turn| turn.blocks.iter().map(BlockKind::of).collect())
322 .unwrap_or_default(),
323 phase: ConversationReader::turn_phase(
324 stores.conversations().as_ref(),
325 account,
326 &turn_id,
327 )
328 .await
329 .ok()
330 .map(|marker| marker.phase),
331 provider_failures: provider_failures_of(record.as_ref()),
332 discarded_answers: record
333 .as_ref()
334 .map(ReplayRecord::discarded_answers)
335 .unwrap_or_default(),
336 cards_created: record
337 .as_ref()
338 .map_or(0, |record| record.interactions_created.len()),
339 answer: turn.map(narration).unwrap_or_default(),
340 }
341 }
342
343 /// The revision a case ended the turn at, when it was observed.
344 #[must_use]
345 pub fn revision_of(&self, case: &CaseKey) -> Option<u64> {
346 self.revisions
347 .iter()
348 .find(|(key, _)| key == case)
349 .map(|(_, revision)| *revision)
350 }
351
352 /// The cards of one case, oldest first.
353 #[must_use]
354 pub fn interactions_of(&self, case: &CaseKey) -> Vec<&ObservedInteraction> {
355 self.interactions
356 .iter()
357 .filter(|card| &card.case == case)
358 .collect()
359 }
360
361 /// A canonical rendering of the deterministic facts of this run.
362 ///
363 /// Two samples with the same signature behaved the same way; two with
364 /// different signatures did not. It deliberately excludes the model's
365 /// wording, block identifiers and timestamps, because a run that phrases
366 /// the same receipt differently is not a different behaviour — and counting
367 /// it as one would make every item look flaky.
368 #[must_use]
369 pub fn signature(&self) -> String {
370 let mut out = String::new();
371 if let Some(code) = &self.error_code {
372 let _ = writeln!(out, "error={code}");
373 }
374 for act in &self.acts {
375 let _ = writeln!(
376 out,
377 "act={} op={}",
378 act.kind,
379 act.operation.as_deref().unwrap_or("-")
380 );
381 }
382 for resolution in &self.target_resolutions {
383 let _ = writeln!(
384 out,
385 "target[{}]={} case={}",
386 resolution.act_index,
387 resolution.resolution,
388 resolution.case_id.as_ref().map_or("-", CaseId::as_str)
389 );
390 }
391 let _ = writeln!(out, "commands={}", self.commands.join(","));
392 let _ = writeln!(out, "events={}", self.events.join(","));
393 if self.events_truncated {
394 // Only written when it happened, so an ordinary signature is
395 // unchanged — and two runs that differ only in whether the ledger
396 // was cut never collapse into one behaviour.
397 let _ = writeln!(out, "events_truncated=true");
398 }
399 for (case, revision) in &self.revisions {
400 let _ = writeln!(out, "rev {}/{}={revision}", case.workflow, case.case_id);
401 }
402 for card in &self.interactions {
403 let _ = writeln!(
404 out,
405 "card {}/{} {:?} {:?}",
406 card.case.workflow, card.case.case_id, card.kind, card.status
407 );
408 }
409 let blocks: Vec<&str> = self.blocks.iter().map(|kind| kind.as_str()).collect();
410 let _ = writeln!(out, "blocks={}", blocks.join(","));
411 let _ = writeln!(out, "phase={:?}", self.phase);
412 // `discarded_answers` is deliberately absent. The signature answers
413 // "did these two runs behave the same", and a repair round that the
414 // runtime recovered from changed how the turn got to its answer, not
415 // what the answer was. Counting it here would report an item as having
416 // three distinct behaviours because one sample needed a second round to
417 // reach the same effects — which reads as instability in the turn and
418 // is instability in the road to it. It is measured on its own axis
419 // instead; see `ItemReport::samples_with_discards`.
420 out
421 }
422
423 /// The stable codes of the answers this turn lost, in order and with
424 /// repeats.
425 ///
426 /// The reason strings are for a person reading one turn; these are what a
427 /// run groups by.
428 #[must_use]
429 pub fn discard_codes(&self) -> Vec<&str> {
430 self.discarded_answers
431 .iter()
432 .map(|discarded| discarded.code.as_str())
433 .collect()
434 }
435}
436
437fn acts_of(record: Option<&ReplayRecord>) -> Vec<ObservedAct> {
438 let Some(record) = record else {
439 return Vec::new();
440 };
441 // One outcome per recorded act; a missing one means the reduction never ran.
442 message_acts(record)
443 .map(|(at, act)| ObservedAct::of(act, record.act_outcomes.get(at)))
444 .collect()
445}
446
447fn resolutions_of(record: Option<&ReplayRecord>) -> Vec<ObservedResolution> {
448 let Some(record) = record else {
449 return Vec::new();
450 };
451 let acts: Vec<&UnderstoodAct> = message_acts(record).map(|(_, act)| act).collect();
452 record
453 .target_resolutions
454 .iter()
455 .filter_map(|entry| {
456 let at = acts.iter().position(|act| act.id == entry.act)?;
457 Some(ObservedResolution::of(at, &entry.resolution))
458 })
459 .collect()
460}
461
462/// The message's own acts, each with its position among every recorded act. The acts a
463/// card answer stands for are recorded too, in the card's unit, and are not the message's.
464fn message_acts(record: &ReplayRecord) -> impl Iterator<Item = (usize, &UnderstoodAct)> {
465 record
466 .understanding
467 .iter()
468 .flat_map(|understanding| understanding.acts.iter().enumerate())
469 .filter(|(_, act)| act.id.unit != CARD_UNIT)
470}
471
472fn provider_failures_of(record: Option<&ReplayRecord>) -> usize {
473 record.map_or(0, |record| {
474 record
475 .provider_attempts
476 .iter()
477 .filter(|attempt| {
478 matches!(
479 attempt.outcome,
480 ProviderAttemptOutcome::Failed { .. } | ProviderAttemptOutcome::FellBack { .. }
481 )
482 })
483 .count()
484 })
485}
486
487/// The ledger as one observation saw it.
488struct Ledger {
489 /// Event types committed by this turn, in append order.
490 events: Vec<String>,
491 /// Whether a configured bound cut the list short.
492 truncated: bool,
493}
494
495/// The events this turn committed, across every case it touched, in append
496/// order.
497///
498/// The filter on command identifiers is what makes this "the turn's events" and
499/// not "the case's history": an item that starts from a seeded state with prior
500/// events must not see them in its own assertion.
501///
502/// # Why the cursor and not the revision
503///
504/// [`EventJournalReader::list_since`] pages by case revision, and a revision is not a
505/// position: one command commits several events at the same revision, so a
506/// `limit` that lands inside a revision cannot be resumed from. Reading a case
507/// that way means picking a number and hoping no history is longer than it —
508/// which is exactly the silent bound this crate is not allowed to have.
509/// [`EventJournalReader::read_from`] pages by the store-assigned sequence, which *is*
510/// a position, so the loop below simply keeps going until the journal reports
511/// an empty page. The sequence is also the journal's total order across cases,
512/// so the events come out in append order with nothing left to sort.
513async fn events_of(
514 stores: &Stores,
515 account: &AccountId,
516 cases: &[CaseKey],
517 command_ids: &BTreeSet<turnframe_core::ids::CommandId>,
518 max_events: Option<usize>,
519) -> Ledger {
520 let mut events = Vec::new();
521 let mut truncated = false;
522 let mut cursor = EventCursor::START;
523 'paging: loop {
524 let Ok(page) = EventJournalReader::read_from(
525 stores.events().as_ref(),
526 account,
527 cursor,
528 EVENT_PAGE_SIZE,
529 )
530 .await
531 else {
532 break;
533 };
534 if page.is_empty() {
535 break;
536 }
537 cursor = page.next_cursor;
538 for event in page.events {
539 if !command_ids.contains(&event.command_id) || !cases.contains(&event.case_key) {
540 continue;
541 }
542 if max_events.is_some_and(|limit| events.len() >= limit) {
543 truncated = true;
544 break 'paging;
545 }
546 events.push(event.event_type);
547 }
548 }
549 Ledger { events, truncated }
550}
551
552/// Reads each seeded case back, keeping the state the item declared beside it.
553async fn states_of(
554 workflows: &WorkflowRegistry,
555 account: &AccountId,
556 cases: &[CaseSeed],
557) -> Vec<(CaseKey, ObservedState)> {
558 let mut found = Vec::new();
559 for seed in cases {
560 let key = CaseKey::new(seed.workflow.clone(), seed.case_id.clone());
561 let Some(registered) = workflows.get(&seed.workflow) else {
562 continue;
563 };
564 let after = match registered.executor.load(account, &seed.case_id).await {
565 Ok(loaded) => loaded.value,
566 // A case that cannot be read is not a case that did not move: the
567 // difference matters to `unchanged`, so nothing is recorded and the
568 // assertion says it could not look rather than passing quietly.
569 Err(_) => continue,
570 };
571 found.push((
572 key,
573 ObservedState {
574 before: seed.state.clone(),
575 after,
576 },
577 ));
578 }
579 found
580}
581
582async fn revisions_of(
583 workflows: &WorkflowRegistry,
584 account: &AccountId,
585 cases: &[CaseKey],
586) -> Vec<(CaseKey, u64)> {
587 let mut found = Vec::new();
588 for case in cases {
589 let Some(registered) = workflows.get(&case.workflow) else {
590 continue;
591 };
592 if let Ok(loaded) = registered.executor.load(account, &case.case_id).await {
593 found.push((case.clone(), loaded.revision.0));
594 }
595 }
596 found
597}
598
599/// The cards worth observing: the ones this turn created, the ones it
600/// *answered*, and whatever is still open on the seeded cases.
601///
602/// The answered card is the one that would otherwise vanish from the report. A
603/// turn that resolves a confirmation leaves it `Resolved`, which means it is no
604/// longer open and was not created this turn — so an item asserting "the card
605/// ended up resolved" would see nothing at all. It is read back from the turn's
606/// own persisted input.
607async fn interactions_of(
608 stores: &Stores,
609 account: &AccountId,
610 turn_id: TurnId,
611 cases: &[CaseKey],
612 record: Option<&ReplayRecord>,
613) -> Vec<ObservedInteraction> {
614 let mut ids: Vec<InteractionId> = Vec::new();
615 for id in record
616 .iter()
617 .flat_map(|record| &record.interactions_created)
618 {
619 if !ids.contains(id) {
620 ids.push(*id);
621 }
622 }
623 if let Ok(stored) =
624 ConversationReader::load_turn(stores.conversations().as_ref(), account, &turn_id).await
625 && let Some(answered) = stored.user.input.interaction_response.as_ref()
626 && !ids.contains(&answered.interaction_id)
627 {
628 ids.push(answered.interaction_id);
629 }
630 for case in cases {
631 let open =
632 InteractionReader::list_open_for_case(stores.interactions().as_ref(), account, case)
633 .await
634 .unwrap_or_default();
635 for card in open {
636 if !ids.contains(&card.id) {
637 ids.push(card.id);
638 }
639 }
640 }
641
642 let mut found = Vec::new();
643 for id in ids {
644 let Ok(record) = InteractionReader::get(stores.interactions().as_ref(), account, &id).await
645 else {
646 continue;
647 };
648 let card = record.interaction;
649 found.push((
650 card.created_at,
651 ObservedInteraction {
652 id: card.id,
653 case: card.case_ref.key(),
654 kind: card.kind,
655 status: card.status,
656 blocking: card.blocking,
657 created_by_turn: card.created_by_turn,
658 },
659 ));
660 }
661 found.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.id.cmp(&right.1.id)));
662 found.into_iter().map(|(_, card)| card).collect()
663}
664
665/// The model-authored text of a turn — answers and transitions, nothing else.
666///
667/// Receipts, notices and cards are server-authored: their wording is the
668/// library's, not the model's, so grading it would measure Turnframe rather
669/// than the model under test.
670fn narration(turn: &AssistantTurn) -> String {
671 turn.blocks
672 .iter()
673 .filter_map(|block| match block {
674 ResponseBlock::Answer(answer) => Some(answer.text.as_str()),
675 ResponseBlock::Transition(transition) => Some(transition.text.as_str()),
676 _ => None,
677 })
678 .collect::<Vec<_>>()
679 .join(" ")
680}