Skip to main content

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}