Skip to main content

turnframe_eval/
corpus.rs

1//! The corpus: named scenarios loaded from files (spec §27.6).
2//!
3//! An **item** is one scenario — a starting state, the turn a person takes, and
4//! what must be true afterwards. A **suite** is a set of items with tags, so a
5//! run can say "only the trip write paths" without a second list of file
6//! names.
7//!
8//! **The loader refuses what it does not understand.** Every structure carries
9//! `deny_unknown_fields`, and [`EvalItem::validate`] rejects combinations that
10//! parse but cannot mean anything. A loader that skipped a key it did not
11//! recognise would quietly turn a typo into a weaker test, and a weaker test
12//! into a green build.
13//!
14//! An item also says where each of its parts came from — `authored`, `derived`
15//! or `recorded` ([`PartProvenance`]) — declared per item or per directory
16//! ([`SuiteManifest`]). [`EvalItem::fingerprint`] records what was declared, and
17//! [`crate::baseline::compare`] uses it to tell an intended change from a broken
18//! pairing from a corpus defect. What each name means, and why **no tooling may
19//! ever regenerate a recorded part**, is in
20//! [`docs/evaluation.md`](https://github.com/turnframe-rs/turnframe/blob/main/docs/evaluation.md).
21//!
22//! # An item file
23//!
24//! ```toml
25//! id = "trip.set_name"
26//! name = "Setting the subject commits exactly one command"
27//! tags = ["trip", "write"]
28//!
29//! [[setup.cases]]
30//! workflow = "trip"
31//! case_id = "trip-1"
32//! label = "Trip 1"
33//! revision = 3
34//! state = { status = "draft" }
35//!
36//! [turn]
37//! text = "Set the name to Lisbon"
38//!
39//! [expect]
40//! commands = ["trip.set_name"]
41//! events = ["trip.name_set"]
42//! blocks = ["receipt"]
43//!
44//! [[expect.acts]]
45//! kind = "apply_operation"
46//! operation = "trip.set_name"
47//!
48//! [expect.forbid]
49//! commands = ["trip.rebook"]
50//!
51//! judge = ["language_quality"]
52//! ```
53//!
54
55use std::fmt;
56use std::fmt::Write as _;
57use std::path::{Path, PathBuf};
58
59use serde::{Deserialize, Serialize};
60use turnframe_core::ids::{CaseId, OptionId, WorkflowKey};
61use turnframe_core::interaction::InteractionStatus;
62use turnframe_core::locale::Locale;
63use turnframe_core::replay::TurnPhase;
64use turnframe_core::response::ResponseBlock;
65
66use crate::config::SelectionConfig;
67use crate::judge::JudgeCriterion;
68
69/// Stable identifier of one corpus item.
70#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
71#[serde(transparent)]
72pub struct ItemId(pub String);
73
74impl ItemId {
75    /// Wraps a label.
76    #[must_use]
77    pub fn new(value: impl Into<String>) -> Self {
78        Self(value.into())
79    }
80
81    /// Borrows the label.
82    #[must_use]
83    pub fn as_str(&self) -> &str {
84        &self.0
85    }
86}
87
88impl From<&str> for ItemId {
89    fn from(value: &str) -> Self {
90        Self(value.to_owned())
91    }
92}
93
94impl fmt::Display for ItemId {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        f.write_str(&self.0)
97    }
98}
99
100/// A label a run can select on.
101#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
102#[serde(transparent)]
103pub struct Tag(pub String);
104
105impl Tag {
106    /// Wraps a label.
107    #[must_use]
108    pub fn new(value: impl Into<String>) -> Self {
109        Self(value.into())
110    }
111
112    /// Borrows the label.
113    #[must_use]
114    pub fn as_str(&self) -> &str {
115        &self.0
116    }
117}
118
119impl From<&str> for Tag {
120    fn from(value: &str) -> Self {
121        Self(value.to_owned())
122    }
123}
124
125impl From<String> for Tag {
126    fn from(value: String) -> Self {
127        Self(value)
128    }
129}
130
131impl fmt::Display for Tag {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        f.write_str(&self.0)
134    }
135}
136
137/// A part of an item, named exactly as the item file names it.
138///
139/// The four parts partition everything about an item that decides what it
140/// *measures*. `name`, `description` and `tags` are deliberately outside them:
141/// renaming an item or retagging it changes how a report reads, not what the
142/// run did, and a comparison that broke its pairing over a reworded sentence
143/// would be useless.
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
145#[serde(rename_all = "snake_case")]
146#[non_exhaustive]
147pub enum ItemPart {
148    /// `setup` — the world the turn starts from, every seeded case and every
149    /// seeded state. This is the part a projector under test usually writes.
150    Setup,
151    /// `turn` — what the person did.
152    Turn,
153    /// `expect` — what must be true afterwards.
154    Expect,
155    /// `judge` — which linguistic criteria are graded.
156    Judge,
157}
158
159impl ItemPart {
160    /// Every part, in the order a fingerprint records them.
161    pub const ALL: [Self; 4] = [Self::Setup, Self::Turn, Self::Expect, Self::Judge];
162
163    /// The snake-case name, which is also the key in the item file.
164    #[must_use]
165    pub const fn as_str(self) -> &'static str {
166        match self {
167            Self::Setup => "setup",
168            Self::Turn => "turn",
169            Self::Expect => "expect",
170            Self::Judge => "judge",
171        }
172    }
173}
174
175impl fmt::Display for ItemPart {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        f.write_str(self.as_str())
178    }
179}
180
181/// Where a part of an item came from.
182///
183/// Three values, not two, and the third is the one that costs money when it is
184/// missing. See the module documentation.
185#[derive(
186    Debug, Clone, Copy, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
187)]
188#[serde(rename_all = "snake_case")]
189#[non_exhaustive]
190pub enum PartProvenance {
191    /// A person wrote it and a person maintains it. The default for anything a
192    /// corpus does not declare.
193    ///
194    /// It should not change between two runs of the same experiment, and if it
195    /// did, the two runs were not the same experiment.
196    #[default]
197    Authored,
198    /// The code under test writes it, so changing that code changes the item.
199    ///
200    /// Tooling may regenerate it. A change to it is the intended effect of the
201    /// change being measured, not a broken pairing.
202    Derived,
203    /// Lifted from what the system actually emitted, and kept as testimony.
204    ///
205    /// **No tooling may regenerate it.** A recorded part that differs between
206    /// two runs is a defect in the corpus or in whatever touched it — never a
207    /// result about the model, and never something to fold into a score.
208    Recorded,
209}
210
211impl PartProvenance {
212    /// The snake-case name used in item files and reports.
213    #[must_use]
214    pub const fn as_str(self) -> &'static str {
215        match self {
216            Self::Authored => "authored",
217            Self::Derived => "derived",
218            Self::Recorded => "recorded",
219        }
220    }
221
222    /// Returns `true` when tooling is allowed to rewrite this part.
223    ///
224    /// Only [`Derived`](Self::Derived) is. An `authored` part belongs to the
225    /// person who wrote it, and a `recorded` part is evidence.
226    #[must_use]
227    pub const fn may_be_regenerated(self) -> bool {
228        matches!(self, Self::Derived)
229    }
230}
231
232impl fmt::Display for PartProvenance {
233    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
234        f.write_str(self.as_str())
235    }
236}
237
238/// Where each part of an item came from.
239///
240/// A table in the item file, or in a directory's [`SuiteManifest`]:
241///
242/// ```toml
243/// [provenance]
244/// setup = "derived"
245/// expect = "recorded"
246/// ```
247///
248/// Every part not named is [`PartProvenance::Authored`], and an unknown key is a
249/// parse error rather than a shrug — a corpus that ignored `setpu = "recorded"`
250/// would report regenerated testimony as an ordinary result.
251#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(deny_unknown_fields, default)]
253pub struct Provenance {
254    /// Where `setup` came from — the part a projector under test usually writes.
255    pub setup: PartProvenance,
256    /// Where `turn` came from.
257    pub turn: PartProvenance,
258    /// Where `expect` came from.
259    pub expect: PartProvenance,
260    /// Where `judge` came from.
261    pub judge: PartProvenance,
262}
263
264impl Provenance {
265    /// Everything authored, which is what a corpus that declares nothing means.
266    #[must_use]
267    pub const fn authored() -> Self {
268        Self {
269            setup: PartProvenance::Authored,
270            turn: PartProvenance::Authored,
271            expect: PartProvenance::Authored,
272            judge: PartProvenance::Authored,
273        }
274    }
275
276    /// Where one part came from.
277    #[must_use]
278    pub const fn of(&self, part: ItemPart) -> PartProvenance {
279        match part {
280            ItemPart::Setup => self.setup,
281            ItemPart::Turn => self.turn,
282            ItemPart::Expect => self.expect,
283            ItemPart::Judge => self.judge,
284        }
285    }
286
287    /// Records where one part came from.
288    pub const fn set(&mut self, part: ItemPart, provenance: PartProvenance) {
289        match part {
290            ItemPart::Setup => self.setup = provenance,
291            ItemPart::Turn => self.turn = provenance,
292            ItemPart::Expect => self.expect = provenance,
293            ItemPart::Judge => self.judge = provenance,
294        }
295    }
296
297    /// Returns `true` when nothing at all was declared.
298    #[must_use]
299    pub fn is_empty(&self) -> bool {
300        *self == Self::authored()
301    }
302
303    /// The parts declared as something other than authored, in
304    /// [`ItemPart::ALL`] order.
305    #[must_use]
306    pub fn declared(&self) -> Vec<ItemPart> {
307        ItemPart::ALL
308            .into_iter()
309            .filter(|part| self.of(*part) != PartProvenance::Authored)
310            .collect()
311    }
312}
313
314/// One part's digest, and where the corpus said that part came from.
315#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
316#[serde(deny_unknown_fields)]
317pub struct PartDigest {
318    /// Which part.
319    pub part: ItemPart,
320    /// A digest of its canonical form. Two items with the same digest for a
321    /// part carry the same content in it.
322    pub digest: String,
323    /// Where the corpus said it came from. Absent in a report written before
324    /// provenance existed, which reads as [`PartProvenance::Authored`] — the
325    /// conservative answer, since an authored part that moved breaks a pairing.
326    #[serde(default)]
327    pub provenance: PartProvenance,
328}
329
330/// What an item contained when a run measured it.
331///
332/// A report carries one per item so a later comparison can ask *is this still the
333/// same experiment?* An
334/// [`ItemFingerprint`] with no parts is "unknown" — it comes from a report
335/// written before fingerprints existed — and a comparison says so rather than
336/// pretending the pairing was checked.
337#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
338#[serde(deny_unknown_fields, default)]
339pub struct ItemFingerprint {
340    /// One digest per part, in [`ItemPart::ALL`] order.
341    pub parts: Vec<PartDigest>,
342}
343
344impl ItemFingerprint {
345    /// Returns `true` when nothing was recorded, so no pairing can be checked.
346    #[must_use]
347    pub fn is_unknown(&self) -> bool {
348        self.parts.is_empty()
349    }
350
351    /// The digest recorded for `part`.
352    #[must_use]
353    pub fn digest_of(&self, part: ItemPart) -> Option<&str> {
354        self.parts
355            .iter()
356            .find(|entry| entry.part == part)
357            .map(|entry| entry.digest.as_str())
358    }
359
360    /// Where the corpus said `part` came from.
361    ///
362    /// [`PartProvenance::Authored`] for a part nothing was recorded about, which
363    /// is the conservative reading: an authored part that moved breaks a
364    /// pairing, so an absent declaration never quietly excuses a difference.
365    #[must_use]
366    pub fn provenance_of(&self, part: ItemPart) -> PartProvenance {
367        self.parts
368            .iter()
369            .find(|entry| entry.part == part)
370            .map_or(PartProvenance::Authored, |entry| entry.provenance)
371    }
372
373    /// The parts both fingerprints recorded and disagree about.
374    ///
375    /// Empty when either side is [unknown](Self::is_unknown): nothing can be
376    /// concluded from a digest that was never taken.
377    #[must_use]
378    pub fn differing_parts(&self, other: &Self) -> Vec<ItemPart> {
379        if self.is_unknown() || other.is_unknown() {
380            return Vec::new();
381        }
382        ItemPart::ALL
383            .into_iter()
384            .filter(
385                |part| match (self.digest_of(*part), other.digest_of(*part)) {
386                    (Some(left), Some(right)) => left != right,
387                    _ => false,
388                },
389            )
390            .collect()
391    }
392}
393
394/// One named scenario.
395#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
396#[serde(deny_unknown_fields)]
397pub struct EvalItem {
398    /// Stable identifier, unique within a suite. Reports and baselines join on
399    /// it, so renaming one is renaming a measurement.
400    pub id: ItemId,
401    /// One sentence a human reads in a report.
402    pub name: String,
403    /// Longer prose, when the scenario needs it.
404    #[serde(default, skip_serializing_if = "Option::is_none")]
405    pub description: Option<String>,
406    /// Labels a run selects on.
407    #[serde(default, skip_serializing_if = "Vec::is_empty")]
408    pub tags: Vec<Tag>,
409    /// The state the world is in before the turn.
410    #[serde(default)]
411    pub setup: Setup,
412    /// Turns the person takes first, in the same conversation. Only the last turn is
413    /// observed; what the conversation built is read back after it.
414    #[serde(default, skip_serializing_if = "Vec::is_empty")]
415    pub before: Vec<TurnSpec>,
416    /// The turn the person takes.
417    pub turn: TurnSpec,
418    /// What must be true afterwards, checked without a model.
419    #[serde(default)]
420    pub expect: Expectations,
421    /// Which linguistic qualities a judge grades. Empty means no judge runs,
422    /// which is the right answer for most items.
423    #[serde(default, skip_serializing_if = "Vec::is_empty")]
424    pub judge: Vec<JudgeCriterion>,
425    /// Where each part of this item came from: authored, derived or recorded.
426    ///
427    /// Declared once here, or once for a whole directory in a
428    /// [`SuiteManifest`]. It changes nothing about how the item runs and
429    /// everything about how [`crate::baseline::compare`] reads a difference — an
430    /// intended projection change, a broken pairing, or a corpus defect.
431    #[serde(default, skip_serializing_if = "Provenance::is_empty")]
432    pub provenance: Provenance,
433}
434
435impl EvalItem {
436    /// Checks the combinations `serde` cannot.
437    ///
438    /// # Errors
439    ///
440    /// [`CorpusError::Invalid`] naming the field and the reason.
441    pub fn validate(&self) -> Result<(), CorpusError> {
442        if self.id.as_str().trim().is_empty() {
443            return Err(CorpusError::invalid("id", "an item needs an identifier"));
444        }
445        if self.name.trim().is_empty() {
446            return Err(CorpusError::invalid("name", "an item needs a name"));
447        }
448        for turn in &self.before {
449            turn.validate()?;
450        }
451        self.turn.validate()?;
452        if self.turn.external.is_some() {
453            return Err(CorpusError::invalid(
454                "turn",
455                "the observed turn is the person's; an outside change goes in `before`",
456            ));
457        }
458        self.expect.validate()?;
459        if let Some(understanding) = &self.expect.understanding {
460            understanding.validate(self.turn.text.as_deref())?;
461        }
462        for seed in &self.setup.cases {
463            seed.validate()?;
464        }
465        self.validate_provenance()?;
466        Ok(())
467    }
468
469    /// Checks the `provenance` declaration is one this item could mean.
470    ///
471    /// A part declared on an item that does not have it is a typo wearing a
472    /// valid name: `setup = "recorded"` on an item with no seeded cases claims
473    /// testimony that is not there, and silently accepting it would be exactly
474    /// the shrug this loader refuses everywhere else.
475    fn validate_provenance(&self) -> Result<(), CorpusError> {
476        for part in self.provenance.declared() {
477            if !self.carries(part) {
478                return Err(CorpusError::Invalid {
479                    field: "provenance".to_owned(),
480                    reason: format!(
481                        "`{part}` is declared `{}`, and this item has no `{part}`",
482                        self.provenance.of(part)
483                    ),
484                });
485            }
486        }
487        Ok(())
488    }
489
490    /// Returns `true` when the item actually has something in `part`.
491    ///
492    /// A turn always has something in it — [`TurnSpec::validate`] refuses one
493    /// that does not — so [`ItemPart::Turn`] is always carried.
494    #[must_use]
495    pub fn carries(&self, part: ItemPart) -> bool {
496        match part {
497            // Every channel a setup can arrive on, not only the cases. The
498            // register and the prior exchanges are seeded the same way and
499            // through the same writers, so a scene whose whole world is «this
500            // is what had already been said» does have a setup — and declaring
501            // how it was produced is exactly as meaningful there. Reading only
502            // the cases refused the declaration on a scene that carried
503            // twenty-four turns of history.
504            ItemPart::Setup => {
505                !self.setup.cases.is_empty()
506                    || !self.setup.records.is_empty()
507                    || !self.setup.history.is_empty()
508            }
509            ItemPart::Turn => true,
510            ItemPart::Expect => !self.expect.is_empty(),
511            ItemPart::Judge => !self.judge.is_empty(),
512        }
513    }
514
515    /// Returns `true` when this item carries `tag`.
516    #[must_use]
517    pub fn has_tag(&self, tag: &Tag) -> bool {
518        self.tags.contains(tag)
519    }
520
521    /// Digests the item part by part, recording where the corpus said each part
522    /// came from.
523    ///
524    /// A run stores this on [`ItemReport`](crate::report::ItemReport) so a later
525    /// comparison can tell whether the two runs measured the same thing. The
526    /// digest is over a canonical rendering with object keys sorted, so an item
527    /// whose seeded state was written out with its fields in a different order
528    /// still fingerprints the same.
529    ///
530    /// ```
531    /// use turnframe_eval::corpus::{EvalItem, ItemPart, PartProvenance};
532    ///
533    /// let item: EvalItem = toml::from_str(
534    ///     r#"
535    ///     id = "a"
536    ///     name = "A scenario"
537    ///
538    ///     [provenance]
539    ///     setup = "derived"
540    ///
541    ///     [turn]
542    ///     text = "hello"
543    ///
544    ///     [[setup.cases]]
545    ///     workflow = "trip"
546    ///     case_id = "trip-1"
547    ///     label = "Trip 1"
548    ///     state = { status = "draft" }
549    ///     "#,
550    /// )?;
551    /// item.validate()?;
552    ///
553    /// let fingerprint = item.fingerprint();
554    /// assert_eq!(fingerprint.provenance_of(ItemPart::Setup), PartProvenance::Derived);
555    /// assert_eq!(fingerprint.provenance_of(ItemPart::Turn), PartProvenance::Authored);
556    /// # Ok::<(), Box<dyn std::error::Error>>(())
557    /// ```
558    #[must_use]
559    pub fn fingerprint(&self) -> ItemFingerprint {
560        ItemFingerprint {
561            parts: ItemPart::ALL
562                .into_iter()
563                .map(|part| PartDigest {
564                    part,
565                    digest: self.digest_of(part),
566                    provenance: self.provenance.of(part),
567                })
568                .collect(),
569        }
570    }
571
572    fn digest_of(&self, part: ItemPart) -> String {
573        let rendered = match part {
574            ItemPart::Setup => serde_json::to_value(&self.setup),
575            ItemPart::Turn => serde_json::to_value(&self.turn),
576            ItemPart::Expect => serde_json::to_value(&self.expect),
577            ItemPart::Judge => serde_json::to_value(&self.judge),
578        };
579        // A part of an item is a plain structure of strings, numbers and
580        // `serde_json::Value`s, so this cannot fail; if it ever did, the
581        // message is still deterministic and still distinct per part, which
582        // keeps a fingerprint comparison honest rather than accidentally equal.
583        let rendered = rendered.unwrap_or_else(|error| {
584            serde_json::Value::String(format!("unserializable {part}: {error}"))
585        });
586        let mut canonical = String::new();
587        write_canonical(&rendered, &mut canonical);
588        blake3::hash(canonical.as_bytes()).to_hex().to_string()
589    }
590}
591
592/// Writes a value in a canonical, injective form: object keys sorted, strings
593/// length-prefixed so no content can imitate a delimiter.
594fn write_canonical(value: &serde_json::Value, out: &mut String) {
595    match value {
596        serde_json::Value::Null => out.push('n'),
597        serde_json::Value::Bool(true) => out.push('t'),
598        serde_json::Value::Bool(false) => out.push('f'),
599        serde_json::Value::Number(number) => {
600            let _ = write!(out, "#{number};");
601        }
602        serde_json::Value::String(text) => write_canonical_text(text, out),
603        serde_json::Value::Array(items) => {
604            out.push('[');
605            for item in items {
606                write_canonical(item, out);
607            }
608            out.push(']');
609        }
610        serde_json::Value::Object(map) => {
611            // `serde_json` is built with `preserve_order` in this workspace, so
612            // an object's iteration order is the order the file happened to use.
613            // Sorting here is what makes the digest a property of the content.
614            let mut keys: Vec<&String> = map.keys().collect();
615            keys.sort_unstable();
616            out.push('{');
617            for key in keys {
618                write_canonical_text(key, out);
619                if let Some(entry) = map.get(key) {
620                    write_canonical(entry, out);
621                }
622            }
623            out.push('}');
624        }
625    }
626}
627
628fn write_canonical_text(text: &str, out: &mut String) {
629    let _ = write!(out, "s{}:{text}", text.len());
630}
631
632/// The world before the turn.
633#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
634#[serde(deny_unknown_fields, default)]
635pub struct Setup {
636    /// Cases the harness seeds and the actor is authorized to address.
637    pub cases: Vec<CaseSeed>,
638    /// Things the world holds that are not cases.
639    ///
640    /// A case is work in progress; many scenarios need something that is simply
641    /// there, a traveler already registered or a past booking, which a case about
642    /// it would misdescribe.
643    ///
644    /// The library knows nothing about what a record IS. It carries a kind and
645    /// a payload, and the harness that owns the domain decides what to do with
646    /// them — the same bargain as a case's state, which crosses as JSON for the
647    /// same reason.
648    #[serde(default, skip_serializing_if = "Vec::is_empty")]
649    pub records: Vec<SeededRecord>,
650    /// What was already said in this conversation, oldest first.
651    ///
652    /// Part of the world and not of the turn, which is why it sits here: the
653    /// turn is what the person does next, and this is what they and the
654    /// assistant had already said when they did it.
655    ///
656    /// **It is text, not a replay.** Seeding an exchange does not re-run it:
657    /// nothing is journaled, no case moves, no card opens. What a previous turn
658    /// DID belongs in [`Self::cases`], and keeping the two consistent is the
659    /// author's job — a history saying «done» beside a case that never moved
660    /// describes a product that lies, which is a fine scenario to write on
661    /// purpose and a bad one to write by accident.
662    #[serde(default, skip_serializing_if = "Vec::is_empty")]
663    pub history: Vec<PriorExchange>,
664}
665
666/// Something the world holds that is not a case.
667///
668/// Opaque on purpose: `kind` is a name the harness recognises and `data` is
669/// whatever that harness needs. A library that tried to type these would be a
670/// library with an opinion about what a traveler is.
671#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
672#[serde(deny_unknown_fields)]
673pub struct SeededRecord {
674    /// What kind of thing this is, in the harness's own vocabulary.
675    pub kind: String,
676    /// The record itself, in whatever shape that kind takes.
677    pub data: serde_json::Value,
678}
679
680/// An exchange that already happened in this conversation.
681///
682/// The assistant's side is optional because the interesting histories are the
683/// ones that end badly: a question nobody answered, a turn that died. A corpus
684/// that could only describe well-formed pairs could not seed the conversation a
685/// user is actually annoyed about.
686#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
687#[serde(deny_unknown_fields)]
688pub struct PriorExchange {
689    /// What the person said.
690    pub user: String,
691    /// What the assistant answered, when it answered.
692    #[serde(default, skip_serializing_if = "Option::is_none")]
693    pub assistant: Option<String>,
694}
695
696/// What a case's state must say after the turn.
697///
698/// The path is a JSON Pointer into the workflow's own state, because the state
699/// is the workflow's business and this crate cannot know its shape — so an item
700/// says `/fields/address_street/value` and the library compares what it finds
701/// there, with no opinion about what a field is.
702#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
703#[serde(deny_unknown_fields)]
704pub struct StateExpectation {
705    /// The case this is about.
706    pub case_id: CaseId,
707    /// JSON Pointer into that case's state, as the workflow serializes it.
708    pub path: String,
709    /// The value that path must hold afterwards.
710    #[serde(default, skip_serializing_if = "Option::is_none")]
711    pub equals: Option<serde_json::Value>,
712    /// Values any one of which that path may hold afterwards: the user's words read
713    /// right in more than one form, such as with or without an article.
714    #[serde(default, skip_serializing_if = "Vec::is_empty")]
715    pub one_of: Vec<serde_json::Value>,
716    /// The value that path must still hold, whatever it was.
717    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
718    pub unchanged: bool,
719    /// Nothing must be there: the path holds `null`, or does not resolve.
720    ///
721    /// # Why this is not `equals: null`
722    ///
723    /// Because that cannot be written. [`equals`](Self::equals) is an
724    /// `Option`, so a JSON `null` deserializes as «no expectation given» and
725    /// [`validate`](Self::validate) then refuses the whole entry for asserting
726    /// nothing. The absence had no way to be said at all.
727    ///
728    /// # What could not be measured without it
729    ///
730    /// «I do not have one» is an answer, not a silence, and a collecting workflow
731    /// often turns on it: a person who declines their loyalty number has answered
732    /// the question, and the flow moves on.
733    /// What the turn must do is record the refusal — a field that ends with no
734    /// value and a status that moved — and every expectation this crate had
735    /// asserts that a value IS somewhere. A suite measuring those flows could
736    /// state the value case and not its opposite, which is the half that goes
737    /// wrong: a turn that quietly writes something into a field the user
738    /// declined reads, to every other assertion, exactly like a turn that
739    /// respected them.
740    ///
741    /// Resolving to nothing counts as absent on purpose. A workflow may drop
742    /// the key instead of nulling it, and an assertion that told those two
743    /// apart would be about the serializer rather than about the record.
744    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
745    pub absent: bool,
746    /// Two strings differing only in case are the same value here.
747    ///
748    /// Only meaningful beside [`equals`](Self::equals), and only for a field
749    /// whose case the application does not decide. That is a real category and
750    /// not a loophole: a domain canonicalises what it owns, a booking reference
751    /// to upper case or a loyalty number to its letters and digits, and stores a
752    /// free-text field the way it was handed it, because nobody can guess how a
753    /// person or a place writes its own name.
754    ///
755    /// On those fields the case is the model's typography, not the record's
756    /// content. A person types «lisbon», one turn stores «lisbon» and the next
757    /// «Lisbon», and both are the same answer to the same question: an
758    /// assertion that told them apart would report the lane as wrong for
759    /// capitalising a city. Leave it off — the default — wherever the
760    /// application does canonicalise, because there the case IS the content
761    /// and a lower-case booking reference is one the airline does not find.
762    ///
763    /// It never applies to `unchanged` or `absent`, which compare no text.
764    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
765    pub ignore_case: bool,
766}
767
768impl StateExpectation {
769    /// Checks the expectation asks exactly one thing.
770    ///
771    /// # Errors
772    ///
773    /// [`CorpusError::Invalid`] when it asks more than one thing, or none at
774    /// all. None is the dangerous one: it would read as a strict assertion and
775    /// check nothing.
776    pub fn validate(&self) -> Result<(), CorpusError> {
777        if self.ignore_case && self.equals.is_none() && self.one_of.is_empty() {
778            return Err(CorpusError::Invalid {
779                field: "expect.case_state".to_owned(),
780                reason: String::from(
781                    "`ignore_case` says how to compare a value, so it needs `equals` or `one_of`: \
782                     `unchanged` and `absent` compare no text",
783                ),
784            });
785        }
786        match u8::from(self.equals.is_some())
787            + u8::from(!self.one_of.is_empty())
788            + u8::from(self.unchanged)
789            + u8::from(self.absent)
790        {
791            1 => Ok(()),
792            0 => Err(CorpusError::Invalid {
793                field: "expect.case_state".to_owned(),
794                reason: String::from(
795                    "a state expectation with none of `equals`, `one_of`, `unchanged` or \
796                     `absent` asserts nothing",
797                ),
798            }),
799            _ => Err(CorpusError::Invalid {
800                field: "expect.case_state".to_owned(),
801                reason: String::from(
802                    "a path is expected to hold a value, one of some values, to be \
803                     unchanged, or to hold nothing — exactly one of the four",
804                ),
805            }),
806        }
807    }
808}
809
810/// One case seeded before the turn.
811///
812/// The state crosses as JSON because the harness owns the domain types and this
813/// crate does not: an item file for a trip and one for a traveler differ
814/// only in what this value contains.
815#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
816#[serde(deny_unknown_fields)]
817pub struct CaseSeed {
818    /// Which workflow the case belongs to.
819    pub workflow: WorkflowKey,
820    /// Identifier within the workflow.
821    pub case_id: CaseId,
822    /// Server-authored label the model and the cards see instead of the id.
823    pub label: String,
824    /// Revision the case starts at.
825    #[serde(default = "one")]
826    pub revision: u64,
827    /// The persisted state, as the workflow's own JSON.
828    pub state: serde_json::Value,
829    /// The conversation that opened this case, named rather than identified.
830    ///
831    /// Absent means the one the turn happens in, which is what almost every
832    /// item wants. A name — any name — means a different one, and the harness
833    /// mints an identifier per distinct name: a file cannot know an identifier
834    /// that is created while the corpus runs, and asking it to would make every
835    /// item unrunnable on a second machine.
836    ///
837    /// «This record belongs to another conversation» is a scenario of its own: the
838    /// record is reachable and nameable there by design, so an old case can be
839    /// resumed, and it is not the subject of the turn.
840    #[serde(default, skip_serializing_if = "Option::is_none")]
841    pub conversation: Option<String>,
842}
843
844impl CaseSeed {
845    /// Checks the seed is one a harness could apply.
846    ///
847    /// # Errors
848    ///
849    /// [`CorpusError::Invalid`] when the state is not a JSON object.
850    pub fn validate(&self) -> Result<(), CorpusError> {
851        if !self.state.is_object() {
852            return Err(CorpusError::invalid(
853                "setup.cases.state",
854                "a seeded state must be a JSON object",
855            ));
856        }
857        Ok(())
858    }
859}
860
861const fn one() -> u64 {
862    1
863}
864
865/// The turn the person takes (spec §9: text and a card answer may coexist).
866#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
867#[serde(deny_unknown_fields, default)]
868pub struct TurnSpec {
869    /// What the person typed.
870    pub text: Option<String>,
871    /// The card they clicked, named by the case it belongs to rather than by an
872    /// identifier no file could know in advance.
873    pub reply: Option<CardReplySpec>,
874    /// The origin token a surface issued (spec §12.4).
875    pub origin: Option<OriginSpec>,
876    /// The user within the tenant. The tenant itself is the harness's business.
877    pub user_id: Option<String>,
878    /// Locale of the person.
879    pub locale: Option<Locale>,
880    /// A change from outside the conversation, taken in place of a person's turn.
881    #[serde(skip_serializing_if = "Option::is_none")]
882    pub external: Option<ExternalSpec>,
883}
884
885impl TurnSpec {
886    /// Checks the turn carries something.
887    ///
888    /// # Errors
889    ///
890    /// [`CorpusError::Invalid`] when neither text nor a card answer is present, or
891    /// when an outside change carries anything a person would.
892    pub fn validate(&self) -> Result<(), CorpusError> {
893        if let Some(external) = &self.external {
894            if self.text.is_some() || self.reply.is_some() || self.origin.is_some() {
895                return Err(CorpusError::invalid(
896                    "external",
897                    "an outside change is not a person's turn: it carries no text, card or origin",
898                ));
899            }
900            return external.validate();
901        }
902        if self.text.is_none() && self.reply.is_none() {
903            return Err(CorpusError::invalid(
904                "turn",
905                "a turn needs text, a card answer, or both",
906            ));
907        }
908        Ok(())
909    }
910}
911
912/// A change to a record made outside the conversation, as its own system makes it: an
913/// airline re-quoting a fare. The command is the domain's own, as the workflow serializes
914/// it, and never passes through understanding.
915#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
916#[serde(deny_unknown_fields)]
917pub struct ExternalSpec {
918    /// Workflow of the record.
919    pub workflow: WorkflowKey,
920    /// The record.
921    pub case_id: CaseId,
922    /// The domain command.
923    pub command: serde_json::Value,
924}
925
926impl ExternalSpec {
927    fn validate(&self) -> Result<(), CorpusError> {
928        if self.workflow.as_str().trim().is_empty() || self.case_id.as_str().trim().is_empty() {
929            return Err(CorpusError::invalid(
930                "external",
931                "an outside change names its workflow and record",
932            ));
933        }
934        if self.command.is_null() {
935            return Err(CorpusError::invalid(
936                "external.command",
937                "the command is missing",
938            ));
939        }
940        Ok(())
941    }
942}
943
944/// A click, named by what it answers rather than by an identifier.
945///
946/// A card's [`InteractionId`](turnframe_core::ids::InteractionId) is minted at
947/// run time, so no file can name one. What a file *can* name is the case whose
948/// blocking card is being answered, which is how a person describes it anyway.
949#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
950#[serde(deny_unknown_fields)]
951pub struct CardReplySpec {
952    /// Workflow of the case whose blocking card is answered.
953    pub workflow: WorkflowKey,
954    /// Case whose blocking card is answered; absent, the one blocking card open on a
955    /// case of the workflow, for a case the conversation created.
956    #[serde(default, skip_serializing_if = "Option::is_none")]
957    pub case_id: Option<CaseId>,
958    /// The stored option chosen.
959    pub option: OptionId,
960    /// Free-form value, when the stored option permits one.
961    #[serde(default, skip_serializing_if = "Option::is_none")]
962    pub freeform: Option<String>,
963}
964
965/// A server-issued origin token (spec §12.4).
966#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
967#[serde(deny_unknown_fields)]
968pub struct OriginSpec {
969    /// The opaque token.
970    pub token: String,
971    /// Which surface produced it.
972    #[serde(default, skip_serializing_if = "Option::is_none")]
973    pub surface: Option<String>,
974}
975
976/// What must be true after the turn, checked without a model (spec §27.6).
977///
978/// Every field is optional and every one of them means the same thing when
979/// absent: *this item does not assert on it*. A field that is present is an
980/// exact claim — `commands = []` asserts that the turn compiled no command at
981/// all, which is a different and much stronger statement than leaving the key
982/// out.
983#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
984#[serde(deny_unknown_fields, default)]
985pub struct Expectations {
986    /// Whether the turn must complete at all. Defaults to
987    /// [`OutcomeExpectation::Succeeds`], so a crashed turn fails an item even
988    /// when every other expectation is trivially satisfied by the wreckage.
989    pub outcome: OutcomeExpectation,
990    /// The acts the message was understood to ask for, in order.
991    pub acts: Option<Vec<ActExpectation>>,
992    /// How each act's target resolved.
993    pub target_resolution: Vec<TargetExpectation>,
994    /// The command types journaled this turn, in admission order.
995    pub commands: Option<Vec<String>>,
996    /// The event types committed this turn, in append order.
997    pub events: Option<Vec<String>>,
998    /// The revision each case ends the turn at.
999    pub case_revision: Vec<RevisionExpectation>,
1000    /// The status of the cards on a case.
1001    pub interaction_status: Vec<InteractionStatusExpectation>,
1002    /// What a case's state says after the turn, field by field.
1003    ///
1004    /// The two questions it answers are not the same one. `equals` asks what a
1005    /// value ended up being, which is how a scenario about a user changing their
1006    /// mind, Porto and then Lisbon, can say which one survived; the events
1007    /// alone cannot, because both stories commit the same event. `unchanged`
1008    /// asks that a value did not move at all, which is what a turn whose correct
1009    /// answer writes nothing needs.
1010    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1011    pub case_state: Vec<StateExpectation>,
1012    /// What some case of a workflow holds at the end, seeded or created by the
1013    /// conversation, for a case whose identifier no file can know.
1014    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1015    pub workflow_state: Vec<WorkflowStateExpectation>,
1016    /// How many cases of a workflow exist at the end, seeded and created.
1017    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1018    pub case_count: Vec<CaseCountExpectation>,
1019    /// The kinds of response block, in order.
1020    pub blocks: Option<Vec<BlockKind>>,
1021    /// The phase the turn finished in.
1022    pub turn_phase: Option<TurnPhase>,
1023    /// Effects that must **not** appear.
1024    pub forbid: ForbiddenEffects,
1025    /// What each understanding task must make of the message.
1026    #[serde(default, skip_serializing_if = "Option::is_none")]
1027    pub understanding: Option<crate::understanding::UnderstandingExpectation>,
1028}
1029
1030impl Expectations {
1031    /// Checks the expectations do not contradict each other.
1032    ///
1033    /// # Errors
1034    ///
1035    /// [`CorpusError::Invalid`] when an act names an operation its kind cannot
1036    /// carry, when a resolution names a case it cannot have, or when the same
1037    /// command or event is both required and forbidden.
1038    pub fn validate(&self) -> Result<(), CorpusError> {
1039        for act in self.acts.iter().flatten() {
1040            act.validate()?;
1041        }
1042        for target in &self.target_resolution {
1043            target.validate()?;
1044        }
1045        for state in &self.case_state {
1046            state.validate()?;
1047        }
1048        contradiction("commands", self.commands.as_deref(), &self.forbid.commands)?;
1049        contradiction("events", self.events.as_deref(), &self.forbid.events)?;
1050        Ok(())
1051    }
1052
1053    /// Returns `true` when nothing at all is asserted deterministically.
1054    #[must_use]
1055    pub fn is_empty(&self) -> bool {
1056        self.outcome == OutcomeExpectation::Succeeds
1057            && self.acts.is_none()
1058            && self.target_resolution.is_empty()
1059            && self.commands.is_none()
1060            && self.events.is_none()
1061            && self.case_revision.is_empty()
1062            && self.interaction_status.is_empty()
1063            && self.case_state.is_empty()
1064            && self.workflow_state.is_empty()
1065            && self.case_count.is_empty()
1066            && self.blocks.is_none()
1067            && self.turn_phase.is_none()
1068            && self.forbid.is_empty()
1069            && self.understanding.is_none()
1070    }
1071}
1072
1073fn contradiction(
1074    field: &'static str,
1075    required: Option<&[String]>,
1076    forbidden: &[String],
1077) -> Result<(), CorpusError> {
1078    let Some(required) = required else {
1079        return Ok(());
1080    };
1081    if let Some(clash) = forbidden.iter().find(|name| required.contains(name)) {
1082        return Err(CorpusError::Invalid {
1083            field: field.to_owned(),
1084            reason: format!("`{clash}` is both required and forbidden"),
1085        });
1086    }
1087    Ok(())
1088}
1089
1090/// Whether the turn is expected to complete.
1091///
1092/// A crashed turn satisfies "no command was journaled" for the wrong reason, so
1093/// the default is that a turn must succeed and an item that means otherwise has
1094/// to say so.
1095#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1096#[serde(rename_all = "snake_case")]
1097#[non_exhaustive]
1098pub enum OutcomeExpectation {
1099    /// The orchestrator must return an assistant turn.
1100    #[default]
1101    Succeeds,
1102    /// The orchestrator must fail, with any error.
1103    Fails,
1104    /// The orchestrator must fail with this stable error code.
1105    FailsWith(String),
1106}
1107
1108/// Effects an item asserts must not happen.
1109///
1110/// This is the assertion the specification calls out by name: not "the turn did
1111/// what I wanted" but "the turn did **not** do the dangerous thing". A scenario
1112/// that asks a question about a trip must not rebook it, and the only way
1113/// to test that is to say so.
1114#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1115#[serde(deny_unknown_fields, default)]
1116pub struct ForbiddenEffects {
1117    /// Command types that must not be journaled.
1118    pub commands: Vec<String>,
1119    /// Event types that must not be committed.
1120    pub events: Vec<String>,
1121}
1122
1123impl ForbiddenEffects {
1124    /// Returns `true` when nothing is forbidden.
1125    #[must_use]
1126    pub fn is_empty(&self) -> bool {
1127        self.commands.is_empty() && self.events.is_empty()
1128    }
1129}
1130
1131/// One expected normalized act.
1132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1133#[serde(deny_unknown_fields)]
1134#[non_exhaustive]
1135pub struct ActExpectation {
1136    /// The act variant.
1137    pub kind: ActKind,
1138    /// The operation, for the kinds that name one.
1139    #[serde(default, skip_serializing_if = "Option::is_none")]
1140    pub operation: Option<String>,
1141    /// Other shapes that satisfy this position just as well.
1142    ///
1143    /// For the turns where more than one act is a CORRECT reading, which is not
1144    /// the same as a turn nobody decided about: a request may have two doors,
1145    /// and an expectation admitting one of them reports the other as a defect.
1146    ///
1147    /// Not a way to assert less. Each alternative is a whole shape, written out
1148    /// and validated like the first, so a reader sees the closed set of
1149    /// readings somebody decided were right — never «any act will do».
1150    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1151    pub or: Vec<ActExpectation>,
1152}
1153
1154impl ActExpectation {
1155    /// Checks the operation is one this kind can carry.
1156    ///
1157    /// # Errors
1158    ///
1159    /// [`CorpusError::Invalid`] when an operation is named on a kind that has
1160    /// none — a mistake that would otherwise pass silently, because the
1161    /// observed act has no operation to disagree with.
1162    pub fn validate(&self) -> Result<(), CorpusError> {
1163        if self.operation.is_some() && !self.kind.carries_operation() {
1164            return Err(CorpusError::Invalid {
1165                field: "expect.acts.operation".to_owned(),
1166                reason: format!("`{}` acts do not name an operation", self.kind),
1167            });
1168        }
1169        for alternative in &self.or {
1170            alternative.validate()?;
1171        }
1172        Ok(())
1173    }
1174
1175    /// Whether `kind` and `operation` are one of the shapes this position
1176    /// admits.
1177    #[must_use]
1178    pub fn admits(&self, kind: &str, operation: Option<&String>) -> bool {
1179        let matches = self.kind.as_str() == kind
1180            && self
1181                .operation
1182                .as_ref()
1183                .is_none_or(|wanted| Some(wanted) == operation);
1184        matches || self.or.iter().any(|other| other.admits(kind, operation))
1185    }
1186}
1187
1188/// The act variants an item can expect, named exactly as they appear in JSON.
1189#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1190#[serde(rename_all = "snake_case")]
1191#[non_exhaustive]
1192pub enum ActKind {
1193    /// Apply a registered operation to a target.
1194    ApplyOperation,
1195    /// Start a new case.
1196    StartWorkflow,
1197    /// Cancel an earlier act.
1198    CancelOperation,
1199    /// Answer the active card with typed text.
1200    AnswerActiveInteractionFromText,
1201    /// Pick a target in reply to a selection card.
1202    SelectTarget,
1203}
1204
1205impl ActKind {
1206    /// The snake-case name, matching
1207    /// [`UnderstoodAct::kind_name`](turnframe_core::understanding::UnderstoodAct::kind_name).
1208    #[must_use]
1209    pub const fn as_str(self) -> &'static str {
1210        match self {
1211            Self::ApplyOperation => "apply_operation",
1212            Self::StartWorkflow => "start_workflow",
1213            Self::CancelOperation => "cancel_operation",
1214            Self::AnswerActiveInteractionFromText => "answer_active_interaction_from_text",
1215            Self::SelectTarget => "select_target",
1216        }
1217    }
1218
1219    /// Returns `true` for the kinds that can name an operation.
1220    #[must_use]
1221    pub const fn carries_operation(self) -> bool {
1222        matches!(self, Self::ApplyOperation | Self::CancelOperation)
1223    }
1224}
1225
1226impl fmt::Display for ActKind {
1227    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1228        f.write_str(self.as_str())
1229    }
1230}
1231
1232/// How one act's target must resolve (spec §12.2).
1233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1234#[serde(deny_unknown_fields)]
1235pub struct TargetExpectation {
1236    /// Position of the act in the normalized plan.
1237    pub act_index: usize,
1238    /// The resolution.
1239    pub resolution: ResolutionKind,
1240    /// The case it resolved to, for the resolutions that name one.
1241    #[serde(default, skip_serializing_if = "Option::is_none")]
1242    pub case_id: Option<CaseId>,
1243}
1244
1245impl TargetExpectation {
1246    /// Checks the case identifier is one this resolution can carry.
1247    ///
1248    /// # Errors
1249    ///
1250    /// [`CorpusError::Invalid`] when a case is named on an `ambiguous`,
1251    /// `missing` or `unauthorized` resolution, none of which has one.
1252    pub fn validate(&self) -> Result<(), CorpusError> {
1253        if self.case_id.is_some() && !self.resolution.carries_case() {
1254            return Err(CorpusError::Invalid {
1255                field: "expect.target_resolution.case_id".to_owned(),
1256                reason: format!("a `{}` resolution names no case", self.resolution),
1257            });
1258        }
1259        Ok(())
1260    }
1261}
1262
1263/// The resolutions of [`TargetResolution`](turnframe_core::target::TargetResolution),
1264/// as an item file names them.
1265#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1266#[serde(rename_all = "snake_case")]
1267#[non_exhaustive]
1268pub enum ResolutionKind {
1269    /// Exactly one authorized case.
1270    Exact,
1271    /// Several authorized cases; the runtime must never pick one (I8).
1272    Ambiguous,
1273    /// The token was issued this turn but the case is gone.
1274    Missing,
1275    /// Unknown token, or another tenant's.
1276    Unauthorized,
1277    /// The case moved past the revision the token was issued at.
1278    Stale,
1279}
1280
1281impl ResolutionKind {
1282    /// The snake-case name.
1283    #[must_use]
1284    pub const fn as_str(self) -> &'static str {
1285        match self {
1286            Self::Exact => "exact",
1287            Self::Ambiguous => "ambiguous",
1288            Self::Missing => "missing",
1289            Self::Unauthorized => "unauthorized",
1290            Self::Stale => "stale",
1291        }
1292    }
1293
1294    /// Returns `true` for the resolutions that carry a case reference.
1295    #[must_use]
1296    pub const fn carries_case(self) -> bool {
1297        matches!(self, Self::Exact | Self::Stale)
1298    }
1299}
1300
1301impl fmt::Display for ResolutionKind {
1302    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1303        f.write_str(self.as_str())
1304    }
1305}
1306
1307/// A value some case of a workflow must hold at the end.
1308#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1309#[serde(deny_unknown_fields)]
1310pub struct WorkflowStateExpectation {
1311    /// The workflow.
1312    pub workflow: WorkflowKey,
1313    /// JSON Pointer into a case's state.
1314    pub path: String,
1315    /// The value that path must hold in at least one case.
1316    pub equals: serde_json::Value,
1317    /// Compare strings without regard to case.
1318    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1319    pub ignore_case: bool,
1320}
1321
1322/// How many cases of a workflow must exist at the end.
1323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1324#[serde(deny_unknown_fields)]
1325pub struct CaseCountExpectation {
1326    /// The workflow.
1327    pub workflow: WorkflowKey,
1328    /// How many.
1329    pub count: usize,
1330}
1331
1332/// The revision a case must end the turn at.
1333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1334#[serde(deny_unknown_fields)]
1335pub struct RevisionExpectation {
1336    /// Workflow of the case.
1337    pub workflow: WorkflowKey,
1338    /// The case.
1339    pub case_id: CaseId,
1340    /// The revision. Equal to the seeded one means "nothing committed here".
1341    pub revision: u64,
1342}
1343
1344/// The statuses the cards of a case must have, in creation order (spec §15.4).
1345#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1346#[serde(deny_unknown_fields)]
1347pub struct InteractionStatusExpectation {
1348    /// Workflow of the case.
1349    pub workflow: WorkflowKey,
1350    /// The case.
1351    pub case_id: CaseId,
1352    /// The statuses, oldest card first. An empty list asserts the case has no
1353    /// cards at all.
1354    pub statuses: Vec<InteractionStatus>,
1355}
1356
1357/// The kinds of response block, as an item file names them (spec §18.1).
1358#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1359#[serde(rename_all = "snake_case")]
1360#[non_exhaustive]
1361pub enum BlockKind {
1362    /// Model-authored answer.
1363    Answer,
1364    /// Model-authored transition.
1365    Transition,
1366    /// Server-authored receipt.
1367    Receipt,
1368    /// Server-authored notice.
1369    Notice,
1370    /// A persisted card.
1371    Interaction,
1372    /// An artifact.
1373    Artifact,
1374    /// A block this crate does not know about yet, so a core addition does not
1375    /// silently read as one of the above.
1376    Other,
1377}
1378
1379impl BlockKind {
1380    /// The kind of a rendered block.
1381    #[must_use]
1382    pub const fn of(block: &ResponseBlock) -> Self {
1383        match block {
1384            ResponseBlock::Answer(_) => Self::Answer,
1385            ResponseBlock::Transition(_) => Self::Transition,
1386            ResponseBlock::Receipt(_) => Self::Receipt,
1387            ResponseBlock::Notice(_) => Self::Notice,
1388            ResponseBlock::Interaction(_) => Self::Interaction,
1389            ResponseBlock::Artifact(_) => Self::Artifact,
1390            _ => Self::Other,
1391        }
1392    }
1393
1394    /// The snake-case name.
1395    #[must_use]
1396    pub const fn as_str(self) -> &'static str {
1397        match self {
1398            Self::Answer => "answer",
1399            Self::Transition => "transition",
1400            Self::Receipt => "receipt",
1401            Self::Notice => "notice",
1402            Self::Interaction => "interaction",
1403            Self::Artifact => "artifact",
1404            Self::Other => "other",
1405        }
1406    }
1407}
1408
1409impl fmt::Display for BlockKind {
1410    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1411        f.write_str(self.as_str())
1412    }
1413}
1414
1415/// The optional `suite.toml` (or `suite.json`) beside a directory of items.
1416///
1417/// It exists so a corpus whose seeded states are all written by the same
1418/// projector says so **once**, instead of repeating the table in every file —
1419/// where one forgotten line is a silently unpaired item.
1420///
1421/// ```toml
1422/// # corpus/trip/suite.toml
1423/// [provenance]
1424/// setup = "derived"
1425/// ```
1426///
1427/// The blanket applies to each item that actually has the part; an item with no
1428/// seeded cases is simply unaffected, rather than failing to load. An item's own
1429/// declaration wins over the directory's, part by part, so one item can say
1430/// `setup = "recorded"` inside a directory that derives everything else.
1431#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1432#[serde(deny_unknown_fields, default)]
1433pub struct SuiteManifest {
1434    /// Where each part came from, for every item in the directory that has it.
1435    pub provenance: Provenance,
1436}
1437
1438/// The file name [`Suite::load_dir`] reads a [`SuiteManifest`] from, without
1439/// its extension.
1440const MANIFEST_STEM: &str = "suite";
1441
1442/// A set of items with a name.
1443#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1444#[serde(deny_unknown_fields)]
1445pub struct Suite {
1446    /// What the suite is called, in reports and baselines.
1447    pub name: String,
1448    /// The items, in the order they run.
1449    pub items: Vec<EvalItem>,
1450}
1451
1452impl Suite {
1453    /// Builds a suite, validating every item and refusing duplicate
1454    /// identifiers.
1455    ///
1456    /// # Errors
1457    ///
1458    /// * [`CorpusError::Invalid`] from any item's own validation;
1459    /// * [`CorpusError::DuplicateId`] when two items share an identifier, which
1460    ///   would make a report join two different measurements.
1461    pub fn new(name: impl Into<String>, items: Vec<EvalItem>) -> Result<Self, CorpusError> {
1462        let suite = Self {
1463            name: name.into(),
1464            items,
1465        };
1466        for (index, item) in suite.items.iter().enumerate() {
1467            item.validate()?;
1468            if suite.items[..index].iter().any(|other| other.id == item.id) {
1469                return Err(CorpusError::DuplicateId {
1470                    id: item.id.clone(),
1471                });
1472            }
1473        }
1474        Ok(suite)
1475    }
1476
1477    /// [`Suite::new`], with a blanket [`Provenance`] folded into every item that
1478    /// has the part.
1479    ///
1480    /// Each item is validated with the declaration *it* wrote before the blanket
1481    /// is folded in, so a typo in a file is still an error naming that file's
1482    /// field, and the blanket is never one. An item that declared a part itself
1483    /// keeps its own answer; the blanket only fills in the parts the item left
1484    /// authored.
1485    ///
1486    /// # Errors
1487    ///
1488    /// Whatever [`Suite::new`] raises.
1489    pub fn with_provenance(
1490        name: impl Into<String>,
1491        mut items: Vec<EvalItem>,
1492        provenance: Provenance,
1493    ) -> Result<Self, CorpusError> {
1494        for item in &mut items {
1495            item.validate()?;
1496            for part in provenance.declared() {
1497                if item.carries(part) && item.provenance.of(part) == PartProvenance::Authored {
1498                    item.provenance.set(part, provenance.of(part));
1499                }
1500            }
1501        }
1502        Self::new(name, items)
1503    }
1504
1505    /// Loads one item from a `.toml` or `.json` file.
1506    ///
1507    /// # Errors
1508    ///
1509    /// * [`CorpusError::Read`] when the file cannot be read;
1510    /// * [`CorpusError::UnknownFormat`] when the extension is neither;
1511    /// * [`CorpusError::Parse`] when the document is malformed or carries a key
1512    ///   this crate does not understand;
1513    /// * [`CorpusError::Invalid`] when the item parses but cannot mean
1514    ///   anything.
1515    pub fn load_item(path: impl AsRef<Path>) -> Result<EvalItem, CorpusError> {
1516        let item: EvalItem = read_document(path.as_ref())?;
1517        item.validate()?;
1518        Ok(item)
1519    }
1520
1521    /// Loads the [`SuiteManifest`] of a directory, if it has one.
1522    ///
1523    /// The manifest is `suite.toml` or `suite.json` beside the items. A
1524    /// directory without one has no blanket declaration, which is the ordinary
1525    /// case.
1526    ///
1527    /// # Errors
1528    ///
1529    /// * [`CorpusError::Read`] when the file exists and cannot be read;
1530    /// * [`CorpusError::Parse`] when it is not a manifest, including when it
1531    ///   names a part this crate does not know.
1532    pub fn load_manifest(dir: impl AsRef<Path>) -> Result<SuiteManifest, CorpusError> {
1533        for extension in ["toml", "json"] {
1534            let path = dir.as_ref().join(MANIFEST_STEM).with_extension(extension);
1535            if path.is_file() {
1536                return read_document(&path);
1537            }
1538        }
1539        Ok(SuiteManifest::default())
1540    }
1541
1542    /// Loads every `.toml` and `.json` item in a directory, in file-name order,
1543    /// with the directory's [`SuiteManifest`] applied.
1544    ///
1545    /// A `suite.toml` or `suite.json` in the directory is the manifest, not an
1546    /// item.
1547    ///
1548    /// # Errors
1549    ///
1550    /// * [`CorpusError::Read`] when the directory or one of its files cannot be
1551    ///   read;
1552    /// * whatever [`Suite::load_manifest`], [`Suite::load_item`] and
1553    ///   [`Suite::with_provenance`] raise.
1554    pub fn load_dir(name: impl Into<String>, dir: impl AsRef<Path>) -> Result<Self, CorpusError> {
1555        let dir = dir.as_ref();
1556        let manifest = Self::load_manifest(dir)?;
1557        let mut paths = Vec::new();
1558        let entries = std::fs::read_dir(dir).map_err(|error| CorpusError::Read {
1559            path: dir.to_path_buf(),
1560            message: error.to_string(),
1561        })?;
1562        for entry in entries {
1563            let entry = entry.map_err(|error| CorpusError::Read {
1564                path: dir.to_path_buf(),
1565                message: error.to_string(),
1566            })?;
1567            let path = entry.path();
1568            if path.file_stem().and_then(|stem| stem.to_str()) == Some(MANIFEST_STEM) {
1569                continue;
1570            }
1571            if matches!(
1572                path.extension().and_then(|ext| ext.to_str()),
1573                Some("toml" | "json")
1574            ) {
1575                paths.push(path);
1576            }
1577        }
1578        paths.sort();
1579        let items = paths
1580            .iter()
1581            .map(Self::load_item)
1582            .collect::<Result<Vec<_>, _>>()?;
1583        Self::with_provenance(name, items, manifest.provenance)
1584    }
1585
1586    /// The items a selection runs, in suite order.
1587    #[must_use]
1588    pub fn select(&self, selection: &SelectionConfig) -> Vec<&EvalItem> {
1589        self.items
1590            .iter()
1591            .filter(|item| selection.selects(&item.id, &item.tags))
1592            .collect()
1593    }
1594
1595    /// One item by identifier.
1596    #[must_use]
1597    pub fn get(&self, id: &ItemId) -> Option<&EvalItem> {
1598        self.items.iter().find(|item| &item.id == id)
1599    }
1600
1601    /// Every tag used in the suite, sorted and deduplicated.
1602    #[must_use]
1603    pub fn tags(&self) -> Vec<Tag> {
1604        let mut tags: Vec<Tag> = self
1605            .items
1606            .iter()
1607            .flat_map(|item| item.tags.iter().cloned())
1608            .collect();
1609        tags.sort();
1610        tags.dedup();
1611        tags
1612    }
1613}
1614
1615/// Reads one `.toml` or `.json` document, refusing anything it does not fully
1616/// understand.
1617fn read_document<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, CorpusError> {
1618    let source = std::fs::read_to_string(path).map_err(|error| CorpusError::Read {
1619        path: path.to_path_buf(),
1620        message: error.to_string(),
1621    })?;
1622    match path.extension().and_then(|ext| ext.to_str()) {
1623        Some("toml") => toml::from_str(&source).map_err(|error| CorpusError::Parse {
1624            path: path.to_path_buf(),
1625            message: error.to_string(),
1626        }),
1627        Some("json") => serde_json::from_str(&source).map_err(|error| CorpusError::Parse {
1628            path: path.to_path_buf(),
1629            message: error.to_string(),
1630        }),
1631        _ => Err(CorpusError::UnknownFormat {
1632            path: path.to_path_buf(),
1633        }),
1634    }
1635}
1636
1637/// Why a corpus could not be loaded.
1638#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1639#[non_exhaustive]
1640pub enum CorpusError {
1641    /// The file or directory could not be read.
1642    #[error("corpus path {path} could not be read: {message}")]
1643    Read {
1644        /// The path that was tried.
1645        path: PathBuf,
1646        /// What the filesystem said.
1647        message: String,
1648    },
1649    /// The extension is neither `.toml` nor `.json`.
1650    #[error("corpus file {path} is neither .toml nor .json")]
1651    UnknownFormat {
1652        /// The path that was tried.
1653        path: PathBuf,
1654    },
1655    /// The document is malformed, or carries a key this crate does not know.
1656    #[error("corpus file {path} could not be parsed: {message}")]
1657    Parse {
1658        /// The file.
1659        path: PathBuf,
1660        /// What the parser said, including the unknown key when there was one.
1661        message: String,
1662    },
1663    /// The item parses but cannot mean anything.
1664    #[error("corpus item is invalid at `{field}`: {reason}")]
1665    Invalid {
1666        /// Which field.
1667        field: String,
1668        /// Why it cannot stand.
1669        reason: String,
1670    },
1671    /// Two items share an identifier.
1672    #[error("corpus contains two items with the identifier `{id}`")]
1673    DuplicateId {
1674        /// The identifier used twice.
1675        id: ItemId,
1676    },
1677}
1678
1679impl CorpusError {
1680    fn invalid(field: &str, reason: &str) -> Self {
1681        Self::Invalid {
1682            field: field.to_owned(),
1683            reason: reason.to_owned(),
1684        }
1685    }
1686}
1687
1688#[cfg(test)]
1689mod tests {
1690
1691    #[test]
1692    fn a_world_can_hold_a_record_that_is_not_a_case() {
1693        let item: EvalItem = toml::from_str(
1694            r#"
1695            id = "traveler.delete_a_registered_one"
1696            name = "Deleting a traveler that is already registered"
1697
1698            [[setup.records]]
1699            kind = "traveler"
1700            data = { full_name = "Luca Ferri", loyalty_number = "AZ2345678" }
1701
1702            [turn]
1703            text = "elimina il viaggiatore Luca Ferri"
1704            "#,
1705        )
1706        .expect("si carica");
1707        item.validate().expect("è coerente");
1708        assert_eq!(item.setup.records.len(), 1);
1709        assert_eq!(item.setup.records[0].kind, "traveler");
1710    }
1711
1712    #[test]
1713    fn a_record_without_its_kind_is_refused() {
1714        // The kind is how a harness knows what to do with the payload. Without
1715        // it the record would be seeded by nobody and the item would run in a
1716        // world missing exactly the thing it is about.
1717        let refused = toml::from_str::<EvalItem>(
1718            r#"
1719            id = "traveler.kindless"
1720            name = "A record with no kind"
1721
1722            [[setup.records]]
1723            data = { full_name = "Rossi" }
1724
1725            [turn]
1726            text = "ciao"
1727            "#,
1728        );
1729        assert!(refused.is_err());
1730    }
1731
1732    #[test]
1733    fn an_item_asserting_only_state_is_not_empty() {
1734        // `is_empty` is a list, a list falls behind, and an expectation missing
1735        // from it makes a real item read as one that asserts nothing.
1736        let item: EvalItem = toml::from_str(
1737            r#"
1738            id = "traveler.only_state"
1739            name = "Only a state expectation"
1740
1741            [[expect.case_state]]
1742            case_id = "c-1"
1743            path = "/fields/email/value"
1744            unchanged = true
1745
1746            [turn]
1747            text = "ciao"
1748            "#,
1749        )
1750        .expect("si carica");
1751        assert!(!item.expect.is_empty(), "questo item assertisce qualcosa");
1752    }
1753
1754    #[test]
1755    fn a_state_expectation_that_asks_nothing_is_refused() {
1756        // The dangerous shape: it reads like a strict assertion and checks
1757        // nothing, so an item carrying it looks measured and is not.
1758        let refused = toml::from_str::<EvalItem>(
1759            r#"
1760            id = "traveler.silent"
1761            name = "A state expectation with neither side"
1762
1763            [[expect.case_state]]
1764            case_id = "c-1"
1765            path = "/fields/email/value"
1766
1767            [turn]
1768            text = "ciao"
1769            "#,
1770        )
1771        .expect("si carica")
1772        .validate();
1773        assert!(refused.is_err(), "deve essere rifiutata in validazione");
1774    }
1775
1776    #[test]
1777    fn a_state_expectation_cannot_ask_both_at_once() {
1778        let refused = toml::from_str::<EvalItem>(
1779            r#"
1780            id = "traveler.both"
1781            name = "Both at once"
1782
1783            [[expect.case_state]]
1784            case_id = "c-1"
1785            path = "/fields/email/value"
1786            equals = "a@b.it"
1787            unchanged = true
1788
1789            [turn]
1790            text = "ciao"
1791            "#,
1792        )
1793        .expect("si carica")
1794        .validate();
1795        assert!(refused.is_err());
1796    }
1797
1798    #[test]
1799    fn a_state_expectation_carries_either_side() {
1800        let item: EvalItem = toml::from_str(
1801            r#"
1802            id = "traveler.mind_changed"
1803            name = "The second gate won"
1804
1805            [[expect.case_state]]
1806            case_id = "c-1"
1807            path = "/fields/gate/value"
1808            equals = "Gate B14"
1809
1810            [[expect.case_state]]
1811            case_id = "c-1"
1812            path = "/fields/email/value"
1813            unchanged = true
1814
1815            [turn]
1816            text = "sì quella"
1817            "#,
1818        )
1819        .expect("si carica");
1820        item.validate().expect("è coerente");
1821        assert_eq!(item.expect.case_state.len(), 2);
1822    }
1823
1824    #[test]
1825    fn a_setup_can_carry_what_was_already_said() {
1826        let item: EvalItem = toml::from_str(
1827            r#"
1828            id = "traveler.changed_their_mind"
1829            name = "The user contradicts something they said earlier"
1830
1831            [[setup.history]]
1832            user = "il viaggiatore è Luca Ferri"
1833            assistant = "Va bene. Mi serve anche l'indirizzo."
1834
1835            [[setup.history]]
1836            user = "anzi no, è la Bianchi"
1837
1838            [turn]
1839            text = "sì quella, vai avanti"
1840            "#,
1841        )
1842        .expect("l'item si carica");
1843        item.validate().expect("l'item è coerente");
1844        assert_eq!(item.setup.history.len(), 2);
1845        assert_eq!(
1846            item.setup.history[0].assistant.as_deref(),
1847            Some("Va bene. Mi serve anche l'indirizzo.")
1848        );
1849        assert!(
1850            item.setup.history[1].assistant.is_none(),
1851            "un turno senza risposta è il caso che rende utile questo campo"
1852        );
1853    }
1854
1855    #[test]
1856    fn a_setup_without_history_has_none_rather_than_an_empty_turn() {
1857        let item: EvalItem = toml::from_str(
1858            r#"
1859            id = "traveler.plain"
1860            name = "No history at all"
1861
1862            [turn]
1863            text = "ciao"
1864            "#,
1865        )
1866        .expect("l'item si carica");
1867        assert!(item.setup.history.is_empty());
1868    }
1869
1870    #[test]
1871    fn a_misspelled_side_of_an_exchange_is_refused() {
1872        // `assistent` would otherwise seed a turn the assistant never answered,
1873        // which is a different scenario from the one the author wrote — and a
1874        // plausible one, so nothing downstream would look wrong.
1875        let refused = toml::from_str::<EvalItem>(
1876            r#"
1877            id = "traveler.typo"
1878            name = "A typo"
1879
1880            [[setup.history]]
1881            user = "ciao"
1882            assistent = "ciao a te"
1883
1884            [turn]
1885            text = "ciao"
1886            "#,
1887        );
1888        assert!(refused.is_err(), "una chiave sconosciuta è un errore");
1889    }
1890
1891    #[test]
1892    fn a_case_says_which_conversation_opened_it_by_name() {
1893        let item: EvalItem = toml::from_str(
1894            r#"
1895            id = "trip.written_from_another_chat"
1896            name = "A draft opened elsewhere is not this turn's subject"
1897
1898            [[setup.cases]]
1899            workflow = "trip"
1900            case_id = "trip-1"
1901            label = "Trip 1"
1902            state = { status = "draft" }
1903            conversation = "the other chat"
1904
1905            [turn]
1906            text = "add a line for 100 euro"
1907            "#,
1908        )
1909        .expect("l'item si carica");
1910        item.validate().expect("l'item è coerente");
1911        assert_eq!(
1912            item.setup.cases[0].conversation.as_deref(),
1913            Some("the other chat")
1914        );
1915    }
1916
1917    #[test]
1918    fn a_case_without_one_belongs_to_the_turns_own_conversation() {
1919        let item: EvalItem = toml::from_str(
1920            r#"
1921            id = "trip.ordinary"
1922            name = "The ordinary case"
1923
1924            [[setup.cases]]
1925            workflow = "trip"
1926            case_id = "trip-1"
1927            label = "Trip 1"
1928            state = { status = "draft" }
1929
1930            [turn]
1931            text = "add a line for 100 euro"
1932            "#,
1933        )
1934        .expect("l'item si carica");
1935        assert!(
1936            item.setup.cases[0].conversation.is_none(),
1937            "l'assenza è il caso normale e non va confusa con un nome vuoto"
1938        );
1939    }
1940
1941    #[test]
1942    fn a_misspelled_conversation_key_is_still_refused() {
1943        // The loader's whole posture: a key it does not know is an error. A
1944        // corpus that shrugged at `converstaion` would quietly run the scenario
1945        // it was written to avoid — the record in the turn's own chat — and
1946        // report it green.
1947        let refused = toml::from_str::<EvalItem>(
1948            r#"
1949            id = "trip.typo"
1950            name = "A typo"
1951
1952            [[setup.cases]]
1953            workflow = "trip"
1954            case_id = "trip-1"
1955            label = "Trip 1"
1956            state = { status = "draft" }
1957            converstaion = "the other chat"
1958
1959            [turn]
1960            text = "hello"
1961            "#,
1962        );
1963        assert!(refused.is_err(), "una chiave sconosciuta è un errore");
1964    }
1965
1966    use super::*;
1967
1968    fn item(body: &str) -> Result<EvalItem, String> {
1969        let parsed: EvalItem = toml::from_str(body).map_err(|error| error.to_string())?;
1970        parsed.validate().map_err(|error| error.to_string())?;
1971        Ok(parsed)
1972    }
1973
1974    const MINIMAL: &str = r#"
1975id = "a"
1976name = "A scenario"
1977[turn]
1978text = "hello"
1979"#;
1980
1981    #[test]
1982    fn a_minimal_item_loads() {
1983        let parsed = item(MINIMAL).unwrap();
1984        assert_eq!(parsed.id, ItemId::new("a"));
1985        assert!(parsed.expect.is_empty());
1986        assert!(parsed.judge.is_empty());
1987    }
1988
1989    #[test]
1990    fn an_unknown_field_is_refused_rather_than_skipped() {
1991        let error = item(&format!("{MINIMAL}unexpected = 1\n")).expect_err("unknown key");
1992        assert!(error.contains("unexpected"), "{error}");
1993    }
1994
1995    #[test]
1996    fn a_turn_with_nothing_in_it_is_refused() {
1997        let error = item("id = \"a\"\nname = \"A\"\n[turn]\n").expect_err("empty turn");
1998        assert!(error.contains("turn"), "{error}");
1999    }
2000
2001    #[test]
2002    fn an_operation_on_a_kind_that_has_none_is_refused() {
2003        let error = item(&format!(
2004            "{MINIMAL}[[expect.acts]]\nkind = \"start_workflow\"\noperation = \"trip.rebook\"\n"
2005        ))
2006        .expect_err("operation on start_workflow");
2007        assert!(error.contains("start_workflow"), "{error}");
2008    }
2009
2010    #[test]
2011    fn a_case_on_an_ambiguous_resolution_is_refused() {
2012        let error = item(&format!(
2013            "{MINIMAL}[[expect.target_resolution]]\nact_index = 0\nresolution = \"ambiguous\"\ncase_id = \"trip-1\"\n"
2014        ))
2015        .expect_err("case on ambiguous");
2016        assert!(error.contains("ambiguous"), "{error}");
2017    }
2018
2019    #[test]
2020    fn a_command_both_required_and_forbidden_is_refused() {
2021        let error = item(&format!(
2022            "{MINIMAL}[expect]\ncommands = [\"trip.rebook\"]\nforbid = {{ commands = [\"trip.rebook\"] }}\n"
2023        ))
2024        .expect_err("contradiction");
2025        assert!(error.contains("trip.rebook"), "{error}");
2026    }
2027
2028    #[test]
2029    fn a_seeded_state_must_be_an_object() {
2030        let error = item(&format!(
2031            "{MINIMAL}[[setup.cases]]\nworkflow = \"trip\"\ncase_id = \"trip-1\"\nlabel = \"Trip 1\"\nstate = 7\n"
2032        ))
2033        .expect_err("scalar state");
2034        assert!(error.contains("state"), "{error}");
2035    }
2036
2037    #[test]
2038    fn a_suite_refuses_two_items_with_the_same_identifier() {
2039        let one = item(MINIMAL).unwrap();
2040        let two = item(MINIMAL).unwrap();
2041        let error = Suite::new("dup", vec![one, two]).expect_err("duplicate");
2042        assert!(matches!(error, CorpusError::DuplicateId { .. }), "{error}");
2043    }
2044
2045    const WITH_A_CASE: &str = r#"
2046id = "a"
2047name = "A scenario"
2048[turn]
2049text = "hello"
2050[[setup.cases]]
2051workflow = "trip"
2052case_id = "trip-1"
2053label = "Trip 1"
2054state = { status = "draft" }
2055"#;
2056
2057    #[test]
2058    fn a_provenance_declaration_loads_and_reaches_the_fingerprint() {
2059        let parsed = item(&format!(
2060            "provenance = {{ setup = \"derived\", turn = \"recorded\" }}\n{WITH_A_CASE}"
2061        ))
2062        .unwrap();
2063        assert_eq!(parsed.provenance.setup, PartProvenance::Derived);
2064        let fingerprint = parsed.fingerprint();
2065        assert_eq!(
2066            fingerprint.provenance_of(ItemPart::Setup),
2067            PartProvenance::Derived
2068        );
2069        assert_eq!(
2070            fingerprint.provenance_of(ItemPart::Turn),
2071            PartProvenance::Recorded
2072        );
2073        // Anything undeclared is authored, which is the conservative reading.
2074        assert_eq!(
2075            fingerprint.provenance_of(ItemPart::Expect),
2076            PartProvenance::Authored
2077        );
2078        assert!(!fingerprint.is_unknown());
2079    }
2080
2081    #[test]
2082    fn only_a_derived_part_may_be_regenerated() {
2083        // The whole point of the third value: tooling may rewrite what the code
2084        // under test produced, and may never rewrite what was recorded.
2085        assert!(PartProvenance::Derived.may_be_regenerated());
2086        assert!(!PartProvenance::Authored.may_be_regenerated());
2087        assert!(!PartProvenance::Recorded.may_be_regenerated());
2088    }
2089
2090    #[test]
2091    fn an_unknown_part_name_is_refused_rather_than_skipped() {
2092        let error = item(&format!(
2093            "provenance = {{ setpu = \"derived\" }}\n{WITH_A_CASE}"
2094        ))
2095        .expect_err("typo");
2096        assert!(error.contains("setpu"), "{error}");
2097    }
2098
2099    #[test]
2100    fn an_unknown_provenance_is_refused_rather_than_skipped() {
2101        let error = item(&format!(
2102            "provenance = {{ setup = \"transcribed\" }}\n{WITH_A_CASE}"
2103        ))
2104        .expect_err("unknown value");
2105        assert!(error.contains("transcribed"), "{error}");
2106    }
2107
2108    #[test]
2109    fn a_part_declared_on_an_item_that_has_none_is_refused() {
2110        // `setup = "recorded"` on an item with no seeded cases claims testimony
2111        // that is not there, and a corpus that accepted it would excuse a
2112        // difference nobody ever recorded.
2113        let error = item(&format!(
2114            "provenance = {{ setup = \"recorded\" }}\n{MINIMAL}"
2115        ))
2116        .expect_err("no setup");
2117        assert!(
2118            error.contains("this item has no `setup`"),
2119            "the reason must be the missing part, not a stray parse error: {error}"
2120        );
2121    }
2122
2123    /// A world made of what was already said is still a world.
2124    ///
2125    /// `carries` read only the cases, which was right when a case was the only
2126    /// thing a setup could hold. It is not any more: the register and the prior
2127    /// exchanges are seeded through the same writers, and a scene whose whole
2128    /// setup is twenty-four turns of conversation had its declaration refused
2129    /// as a typo.
2130    #[test]
2131    fn a_setup_made_only_of_history_or_records_is_still_a_setup() {
2132        let mut item = item(MINIMAL).expect("the minimal item loads");
2133        assert!(
2134            !item.carries(ItemPart::Setup),
2135            "nothing seeded, nothing said"
2136        );
2137
2138        item.setup.history.push(PriorExchange {
2139            user: "e il viaggiatore di Torino?".to_owned(),
2140            assistant: None,
2141        });
2142        assert!(item.carries(ItemPart::Setup));
2143
2144        item.setup.history.clear();
2145        item.setup.records.push(SeededRecord {
2146            kind: "traveler".to_owned(),
2147            data: serde_json::json!({"name": "Ferri"}),
2148        });
2149        assert!(item.carries(ItemPart::Setup));
2150    }
2151
2152    #[test]
2153    fn a_blanket_declaration_reaches_every_item_that_has_the_part() {
2154        let with_case = item(WITH_A_CASE).unwrap();
2155        let mut without_case = item(MINIMAL).unwrap();
2156        without_case.id = ItemId::new("b");
2157
2158        let blanket = Provenance {
2159            setup: PartProvenance::Derived,
2160            turn: PartProvenance::Recorded,
2161            ..Provenance::authored()
2162        };
2163        let suite = Suite::with_provenance("s", vec![with_case, without_case], blanket).unwrap();
2164
2165        assert_eq!(suite.items[0].provenance.setup, PartProvenance::Derived);
2166        assert_eq!(suite.items[0].provenance.turn, PartProvenance::Recorded);
2167        // The blanket is not an error on an item that has no `setup`; it simply
2168        // does not apply there.
2169        assert_eq!(suite.items[1].provenance.setup, PartProvenance::Authored);
2170        assert_eq!(suite.items[1].provenance.turn, PartProvenance::Recorded);
2171    }
2172
2173    #[test]
2174    fn an_items_own_declaration_wins_over_the_directorys() {
2175        let recorded = item(&format!(
2176            "provenance = {{ setup = \"recorded\" }}\n{WITH_A_CASE}"
2177        ))
2178        .unwrap();
2179        let suite = Suite::with_provenance(
2180            "s",
2181            vec![recorded],
2182            Provenance {
2183                setup: PartProvenance::Derived,
2184                ..Provenance::authored()
2185            },
2186        )
2187        .unwrap();
2188        assert_eq!(suite.items[0].provenance.setup, PartProvenance::Recorded);
2189    }
2190
2191    #[test]
2192    fn a_fingerprint_ignores_the_order_the_seeded_state_was_written_in() {
2193        let one = item(
2194            "id = \"a\"\nname = \"A\"\n[turn]\ntext = \"hi\"\n[[setup.cases]]\nworkflow = \"trip\"\ncase_id = \"trip-1\"\nlabel = \"L\"\nstate = { alpha = 1, beta = 2 }\n",
2195        )
2196        .unwrap();
2197        let two = item(
2198            "id = \"a\"\nname = \"A\"\n[turn]\ntext = \"hi\"\n[[setup.cases]]\nworkflow = \"trip\"\ncase_id = \"trip-1\"\nlabel = \"L\"\nstate = { beta = 2, alpha = 1 }\n",
2199        )
2200        .unwrap();
2201        assert!(
2202            one.fingerprint()
2203                .differing_parts(&two.fingerprint())
2204                .is_empty()
2205        );
2206    }
2207
2208    #[test]
2209    fn a_changed_seeded_state_shows_up_as_a_changed_setup_part() {
2210        let one = item(WITH_A_CASE).unwrap();
2211        let two = item(&WITH_A_CASE.replace("draft", "sent")).unwrap();
2212        assert_eq!(
2213            one.fingerprint().differing_parts(&two.fingerprint()),
2214            vec![ItemPart::Setup]
2215        );
2216    }
2217
2218    #[test]
2219    fn an_unknown_fingerprint_claims_nothing() {
2220        let known = item(WITH_A_CASE).unwrap().fingerprint();
2221        let unknown = ItemFingerprint::default();
2222        assert!(unknown.is_unknown());
2223        assert!(known.differing_parts(&unknown).is_empty());
2224        assert!(unknown.differing_parts(&known).is_empty());
2225    }
2226
2227    #[test]
2228    fn selection_narrows_a_suite() {
2229        let mut tagged = item(MINIMAL).unwrap();
2230        tagged.tags = vec![Tag::new("trip")];
2231        let mut other = item(MINIMAL).unwrap();
2232        other.id = ItemId::new("b");
2233        other.tags = vec![Tag::new("traveler")];
2234        let suite = Suite::new("s", vec![tagged, other]).unwrap();
2235
2236        let selection = SelectionConfig {
2237            include_tags: vec![Tag::new("trip")],
2238            ..SelectionConfig::default()
2239        };
2240        let selected = suite.select(&selection);
2241        assert_eq!(selected.len(), 1);
2242        assert_eq!(selected[0].id, ItemId::new("a"));
2243        assert_eq!(suite.tags().len(), 2);
2244    }
2245}