use std::fmt;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use turnframe_core::ids::{CaseId, OptionId, WorkflowKey};
use turnframe_core::interaction::InteractionStatus;
use turnframe_core::locale::Locale;
use turnframe_core::replay::TurnPhase;
use turnframe_core::response::ResponseBlock;
use crate::config::SelectionConfig;
use crate::judge::JudgeCriterion;
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ItemId(pub String);
impl ItemId {
#[must_use]
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<&str> for ItemId {
fn from(value: &str) -> Self {
Self(value.to_owned())
}
}
impl fmt::Display for ItemId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Tag(pub String);
impl Tag {
#[must_use]
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<&str> for Tag {
fn from(value: &str) -> Self {
Self(value.to_owned())
}
}
impl From<String> for Tag {
fn from(value: String) -> Self {
Self(value)
}
}
impl fmt::Display for Tag {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ItemPart {
Setup,
Turn,
Expect,
Judge,
}
impl ItemPart {
pub const ALL: [Self; 4] = [Self::Setup, Self::Turn, Self::Expect, Self::Judge];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Setup => "setup",
Self::Turn => "turn",
Self::Expect => "expect",
Self::Judge => "judge",
}
}
}
impl fmt::Display for ItemPart {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum PartProvenance {
#[default]
Authored,
Derived,
Recorded,
}
impl PartProvenance {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Authored => "authored",
Self::Derived => "derived",
Self::Recorded => "recorded",
}
}
#[must_use]
pub const fn may_be_regenerated(self) -> bool {
matches!(self, Self::Derived)
}
}
impl fmt::Display for PartProvenance {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Provenance {
pub setup: PartProvenance,
pub turn: PartProvenance,
pub expect: PartProvenance,
pub judge: PartProvenance,
}
impl Provenance {
#[must_use]
pub const fn authored() -> Self {
Self {
setup: PartProvenance::Authored,
turn: PartProvenance::Authored,
expect: PartProvenance::Authored,
judge: PartProvenance::Authored,
}
}
#[must_use]
pub const fn of(&self, part: ItemPart) -> PartProvenance {
match part {
ItemPart::Setup => self.setup,
ItemPart::Turn => self.turn,
ItemPart::Expect => self.expect,
ItemPart::Judge => self.judge,
}
}
pub const fn set(&mut self, part: ItemPart, provenance: PartProvenance) {
match part {
ItemPart::Setup => self.setup = provenance,
ItemPart::Turn => self.turn = provenance,
ItemPart::Expect => self.expect = provenance,
ItemPart::Judge => self.judge = provenance,
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
*self == Self::authored()
}
#[must_use]
pub fn declared(&self) -> Vec<ItemPart> {
ItemPart::ALL
.into_iter()
.filter(|part| self.of(*part) != PartProvenance::Authored)
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PartDigest {
pub part: ItemPart,
pub digest: String,
#[serde(default)]
pub provenance: PartProvenance,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct ItemFingerprint {
pub parts: Vec<PartDigest>,
}
impl ItemFingerprint {
#[must_use]
pub fn is_unknown(&self) -> bool {
self.parts.is_empty()
}
#[must_use]
pub fn digest_of(&self, part: ItemPart) -> Option<&str> {
self.parts
.iter()
.find(|entry| entry.part == part)
.map(|entry| entry.digest.as_str())
}
#[must_use]
pub fn provenance_of(&self, part: ItemPart) -> PartProvenance {
self.parts
.iter()
.find(|entry| entry.part == part)
.map_or(PartProvenance::Authored, |entry| entry.provenance)
}
#[must_use]
pub fn differing_parts(&self, other: &Self) -> Vec<ItemPart> {
if self.is_unknown() || other.is_unknown() {
return Vec::new();
}
ItemPart::ALL
.into_iter()
.filter(
|part| match (self.digest_of(*part), other.digest_of(*part)) {
(Some(left), Some(right)) => left != right,
_ => false,
},
)
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EvalItem {
pub id: ItemId,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<Tag>,
#[serde(default)]
pub setup: Setup,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub before: Vec<TurnSpec>,
pub turn: TurnSpec,
#[serde(default)]
pub expect: Expectations,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub judge: Vec<JudgeCriterion>,
#[serde(default, skip_serializing_if = "Provenance::is_empty")]
pub provenance: Provenance,
}
impl EvalItem {
pub fn validate(&self) -> Result<(), CorpusError> {
if self.id.as_str().trim().is_empty() {
return Err(CorpusError::invalid("id", "an item needs an identifier"));
}
if self.name.trim().is_empty() {
return Err(CorpusError::invalid("name", "an item needs a name"));
}
for turn in &self.before {
turn.validate()?;
}
self.turn.validate()?;
if self.turn.external.is_some() {
return Err(CorpusError::invalid(
"turn",
"the observed turn is the person's; an outside change goes in `before`",
));
}
self.expect.validate()?;
if let Some(understanding) = &self.expect.understanding {
understanding.validate(self.turn.text.as_deref())?;
}
for seed in &self.setup.cases {
seed.validate()?;
}
self.validate_provenance()?;
Ok(())
}
fn validate_provenance(&self) -> Result<(), CorpusError> {
for part in self.provenance.declared() {
if !self.carries(part) {
return Err(CorpusError::Invalid {
field: "provenance".to_owned(),
reason: format!(
"`{part}` is declared `{}`, and this item has no `{part}`",
self.provenance.of(part)
),
});
}
}
Ok(())
}
#[must_use]
pub fn carries(&self, part: ItemPart) -> bool {
match part {
ItemPart::Setup => {
!self.setup.cases.is_empty()
|| !self.setup.records.is_empty()
|| !self.setup.history.is_empty()
}
ItemPart::Turn => true,
ItemPart::Expect => !self.expect.is_empty(),
ItemPart::Judge => !self.judge.is_empty(),
}
}
#[must_use]
pub fn has_tag(&self, tag: &Tag) -> bool {
self.tags.contains(tag)
}
#[must_use]
pub fn fingerprint(&self) -> ItemFingerprint {
ItemFingerprint {
parts: ItemPart::ALL
.into_iter()
.map(|part| PartDigest {
part,
digest: self.digest_of(part),
provenance: self.provenance.of(part),
})
.collect(),
}
}
fn digest_of(&self, part: ItemPart) -> String {
let rendered = match part {
ItemPart::Setup => serde_json::to_value(&self.setup),
ItemPart::Turn => serde_json::to_value(&self.turn),
ItemPart::Expect => serde_json::to_value(&self.expect),
ItemPart::Judge => serde_json::to_value(&self.judge),
};
let rendered = rendered.unwrap_or_else(|error| {
serde_json::Value::String(format!("unserializable {part}: {error}"))
});
let mut canonical = String::new();
write_canonical(&rendered, &mut canonical);
blake3::hash(canonical.as_bytes()).to_hex().to_string()
}
}
fn write_canonical(value: &serde_json::Value, out: &mut String) {
match value {
serde_json::Value::Null => out.push('n'),
serde_json::Value::Bool(true) => out.push('t'),
serde_json::Value::Bool(false) => out.push('f'),
serde_json::Value::Number(number) => {
let _ = write!(out, "#{number};");
}
serde_json::Value::String(text) => write_canonical_text(text, out),
serde_json::Value::Array(items) => {
out.push('[');
for item in items {
write_canonical(item, out);
}
out.push(']');
}
serde_json::Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort_unstable();
out.push('{');
for key in keys {
write_canonical_text(key, out);
if let Some(entry) = map.get(key) {
write_canonical(entry, out);
}
}
out.push('}');
}
}
}
fn write_canonical_text(text: &str, out: &mut String) {
let _ = write!(out, "s{}:{text}", text.len());
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Setup {
pub cases: Vec<CaseSeed>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub records: Vec<SeededRecord>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub history: Vec<PriorExchange>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SeededRecord {
pub kind: String,
pub data: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PriorExchange {
pub user: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assistant: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StateExpectation {
pub case_id: CaseId,
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub equals: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub one_of: Vec<serde_json::Value>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub unchanged: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub absent: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub ignore_case: bool,
}
impl StateExpectation {
pub fn validate(&self) -> Result<(), CorpusError> {
if self.ignore_case && self.equals.is_none() && self.one_of.is_empty() {
return Err(CorpusError::Invalid {
field: "expect.case_state".to_owned(),
reason: String::from(
"`ignore_case` says how to compare a value, so it needs `equals` or `one_of`: \
`unchanged` and `absent` compare no text",
),
});
}
match u8::from(self.equals.is_some())
+ u8::from(!self.one_of.is_empty())
+ u8::from(self.unchanged)
+ u8::from(self.absent)
{
1 => Ok(()),
0 => Err(CorpusError::Invalid {
field: "expect.case_state".to_owned(),
reason: String::from(
"a state expectation with none of `equals`, `one_of`, `unchanged` or \
`absent` asserts nothing",
),
}),
_ => Err(CorpusError::Invalid {
field: "expect.case_state".to_owned(),
reason: String::from(
"a path is expected to hold a value, one of some values, to be \
unchanged, or to hold nothing — exactly one of the four",
),
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CaseSeed {
pub workflow: WorkflowKey,
pub case_id: CaseId,
pub label: String,
#[serde(default = "one")]
pub revision: u64,
pub state: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conversation: Option<String>,
}
impl CaseSeed {
pub fn validate(&self) -> Result<(), CorpusError> {
if !self.state.is_object() {
return Err(CorpusError::invalid(
"setup.cases.state",
"a seeded state must be a JSON object",
));
}
Ok(())
}
}
const fn one() -> u64 {
1
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct TurnSpec {
pub text: Option<String>,
pub reply: Option<CardReplySpec>,
pub origin: Option<OriginSpec>,
pub user_id: Option<String>,
pub locale: Option<Locale>,
#[serde(skip_serializing_if = "Option::is_none")]
pub external: Option<ExternalSpec>,
}
impl TurnSpec {
pub fn validate(&self) -> Result<(), CorpusError> {
if let Some(external) = &self.external {
if self.text.is_some() || self.reply.is_some() || self.origin.is_some() {
return Err(CorpusError::invalid(
"external",
"an outside change is not a person's turn: it carries no text, card or origin",
));
}
return external.validate();
}
if self.text.is_none() && self.reply.is_none() {
return Err(CorpusError::invalid(
"turn",
"a turn needs text, a card answer, or both",
));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExternalSpec {
pub workflow: WorkflowKey,
pub case_id: CaseId,
pub command: serde_json::Value,
}
impl ExternalSpec {
fn validate(&self) -> Result<(), CorpusError> {
if self.workflow.as_str().trim().is_empty() || self.case_id.as_str().trim().is_empty() {
return Err(CorpusError::invalid(
"external",
"an outside change names its workflow and record",
));
}
if self.command.is_null() {
return Err(CorpusError::invalid(
"external.command",
"the command is missing",
));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CardReplySpec {
pub workflow: WorkflowKey,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub case_id: Option<CaseId>,
pub option: OptionId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub freeform: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OriginSpec {
pub token: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub surface: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Expectations {
pub outcome: OutcomeExpectation,
pub acts: Option<Vec<ActExpectation>>,
pub target_resolution: Vec<TargetExpectation>,
pub commands: Option<Vec<String>>,
pub events: Option<Vec<String>>,
pub case_revision: Vec<RevisionExpectation>,
pub interaction_status: Vec<InteractionStatusExpectation>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub case_state: Vec<StateExpectation>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workflow_state: Vec<WorkflowStateExpectation>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub case_count: Vec<CaseCountExpectation>,
pub blocks: Option<Vec<BlockKind>>,
pub turn_phase: Option<TurnPhase>,
pub forbid: ForbiddenEffects,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub understanding: Option<crate::understanding::UnderstandingExpectation>,
}
impl Expectations {
pub fn validate(&self) -> Result<(), CorpusError> {
for act in self.acts.iter().flatten() {
act.validate()?;
}
for target in &self.target_resolution {
target.validate()?;
}
for state in &self.case_state {
state.validate()?;
}
contradiction("commands", self.commands.as_deref(), &self.forbid.commands)?;
contradiction("events", self.events.as_deref(), &self.forbid.events)?;
Ok(())
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.outcome == OutcomeExpectation::Succeeds
&& self.acts.is_none()
&& self.target_resolution.is_empty()
&& self.commands.is_none()
&& self.events.is_none()
&& self.case_revision.is_empty()
&& self.interaction_status.is_empty()
&& self.case_state.is_empty()
&& self.workflow_state.is_empty()
&& self.case_count.is_empty()
&& self.blocks.is_none()
&& self.turn_phase.is_none()
&& self.forbid.is_empty()
&& self.understanding.is_none()
}
}
fn contradiction(
field: &'static str,
required: Option<&[String]>,
forbidden: &[String],
) -> Result<(), CorpusError> {
let Some(required) = required else {
return Ok(());
};
if let Some(clash) = forbidden.iter().find(|name| required.contains(name)) {
return Err(CorpusError::Invalid {
field: field.to_owned(),
reason: format!("`{clash}` is both required and forbidden"),
});
}
Ok(())
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum OutcomeExpectation {
#[default]
Succeeds,
Fails,
FailsWith(String),
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct ForbiddenEffects {
pub commands: Vec<String>,
pub events: Vec<String>,
}
impl ForbiddenEffects {
#[must_use]
pub fn is_empty(&self) -> bool {
self.commands.is_empty() && self.events.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct ActExpectation {
pub kind: ActKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operation: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub or: Vec<ActExpectation>,
}
impl ActExpectation {
pub fn validate(&self) -> Result<(), CorpusError> {
if self.operation.is_some() && !self.kind.carries_operation() {
return Err(CorpusError::Invalid {
field: "expect.acts.operation".to_owned(),
reason: format!("`{}` acts do not name an operation", self.kind),
});
}
for alternative in &self.or {
alternative.validate()?;
}
Ok(())
}
#[must_use]
pub fn admits(&self, kind: &str, operation: Option<&String>) -> bool {
let matches = self.kind.as_str() == kind
&& self
.operation
.as_ref()
.is_none_or(|wanted| Some(wanted) == operation);
matches || self.or.iter().any(|other| other.admits(kind, operation))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ActKind {
ApplyOperation,
StartWorkflow,
CancelOperation,
AnswerActiveInteractionFromText,
SelectTarget,
}
impl ActKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::ApplyOperation => "apply_operation",
Self::StartWorkflow => "start_workflow",
Self::CancelOperation => "cancel_operation",
Self::AnswerActiveInteractionFromText => "answer_active_interaction_from_text",
Self::SelectTarget => "select_target",
}
}
#[must_use]
pub const fn carries_operation(self) -> bool {
matches!(self, Self::ApplyOperation | Self::CancelOperation)
}
}
impl fmt::Display for ActKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TargetExpectation {
pub act_index: usize,
pub resolution: ResolutionKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub case_id: Option<CaseId>,
}
impl TargetExpectation {
pub fn validate(&self) -> Result<(), CorpusError> {
if self.case_id.is_some() && !self.resolution.carries_case() {
return Err(CorpusError::Invalid {
field: "expect.target_resolution.case_id".to_owned(),
reason: format!("a `{}` resolution names no case", self.resolution),
});
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ResolutionKind {
Exact,
Ambiguous,
Missing,
Unauthorized,
Stale,
}
impl ResolutionKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Exact => "exact",
Self::Ambiguous => "ambiguous",
Self::Missing => "missing",
Self::Unauthorized => "unauthorized",
Self::Stale => "stale",
}
}
#[must_use]
pub const fn carries_case(self) -> bool {
matches!(self, Self::Exact | Self::Stale)
}
}
impl fmt::Display for ResolutionKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkflowStateExpectation {
pub workflow: WorkflowKey,
pub path: String,
pub equals: serde_json::Value,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub ignore_case: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CaseCountExpectation {
pub workflow: WorkflowKey,
pub count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RevisionExpectation {
pub workflow: WorkflowKey,
pub case_id: CaseId,
pub revision: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InteractionStatusExpectation {
pub workflow: WorkflowKey,
pub case_id: CaseId,
pub statuses: Vec<InteractionStatus>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum BlockKind {
Answer,
Transition,
Receipt,
Notice,
Interaction,
Artifact,
Other,
}
impl BlockKind {
#[must_use]
pub const fn of(block: &ResponseBlock) -> Self {
match block {
ResponseBlock::Answer(_) => Self::Answer,
ResponseBlock::Transition(_) => Self::Transition,
ResponseBlock::Receipt(_) => Self::Receipt,
ResponseBlock::Notice(_) => Self::Notice,
ResponseBlock::Interaction(_) => Self::Interaction,
ResponseBlock::Artifact(_) => Self::Artifact,
_ => Self::Other,
}
}
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Answer => "answer",
Self::Transition => "transition",
Self::Receipt => "receipt",
Self::Notice => "notice",
Self::Interaction => "interaction",
Self::Artifact => "artifact",
Self::Other => "other",
}
}
}
impl fmt::Display for BlockKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct SuiteManifest {
pub provenance: Provenance,
}
const MANIFEST_STEM: &str = "suite";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Suite {
pub name: String,
pub items: Vec<EvalItem>,
}
impl Suite {
pub fn new(name: impl Into<String>, items: Vec<EvalItem>) -> Result<Self, CorpusError> {
let suite = Self {
name: name.into(),
items,
};
for (index, item) in suite.items.iter().enumerate() {
item.validate()?;
if suite.items[..index].iter().any(|other| other.id == item.id) {
return Err(CorpusError::DuplicateId {
id: item.id.clone(),
});
}
}
Ok(suite)
}
pub fn with_provenance(
name: impl Into<String>,
mut items: Vec<EvalItem>,
provenance: Provenance,
) -> Result<Self, CorpusError> {
for item in &mut items {
item.validate()?;
for part in provenance.declared() {
if item.carries(part) && item.provenance.of(part) == PartProvenance::Authored {
item.provenance.set(part, provenance.of(part));
}
}
}
Self::new(name, items)
}
pub fn load_item(path: impl AsRef<Path>) -> Result<EvalItem, CorpusError> {
let item: EvalItem = read_document(path.as_ref())?;
item.validate()?;
Ok(item)
}
pub fn load_manifest(dir: impl AsRef<Path>) -> Result<SuiteManifest, CorpusError> {
for extension in ["toml", "json"] {
let path = dir.as_ref().join(MANIFEST_STEM).with_extension(extension);
if path.is_file() {
return read_document(&path);
}
}
Ok(SuiteManifest::default())
}
pub fn load_dir(name: impl Into<String>, dir: impl AsRef<Path>) -> Result<Self, CorpusError> {
let dir = dir.as_ref();
let manifest = Self::load_manifest(dir)?;
let mut paths = Vec::new();
let entries = std::fs::read_dir(dir).map_err(|error| CorpusError::Read {
path: dir.to_path_buf(),
message: error.to_string(),
})?;
for entry in entries {
let entry = entry.map_err(|error| CorpusError::Read {
path: dir.to_path_buf(),
message: error.to_string(),
})?;
let path = entry.path();
if path.file_stem().and_then(|stem| stem.to_str()) == Some(MANIFEST_STEM) {
continue;
}
if matches!(
path.extension().and_then(|ext| ext.to_str()),
Some("toml" | "json")
) {
paths.push(path);
}
}
paths.sort();
let items = paths
.iter()
.map(Self::load_item)
.collect::<Result<Vec<_>, _>>()?;
Self::with_provenance(name, items, manifest.provenance)
}
#[must_use]
pub fn select(&self, selection: &SelectionConfig) -> Vec<&EvalItem> {
self.items
.iter()
.filter(|item| selection.selects(&item.id, &item.tags))
.collect()
}
#[must_use]
pub fn get(&self, id: &ItemId) -> Option<&EvalItem> {
self.items.iter().find(|item| &item.id == id)
}
#[must_use]
pub fn tags(&self) -> Vec<Tag> {
let mut tags: Vec<Tag> = self
.items
.iter()
.flat_map(|item| item.tags.iter().cloned())
.collect();
tags.sort();
tags.dedup();
tags
}
}
fn read_document<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, CorpusError> {
let source = std::fs::read_to_string(path).map_err(|error| CorpusError::Read {
path: path.to_path_buf(),
message: error.to_string(),
})?;
match path.extension().and_then(|ext| ext.to_str()) {
Some("toml") => toml::from_str(&source).map_err(|error| CorpusError::Parse {
path: path.to_path_buf(),
message: error.to_string(),
}),
Some("json") => serde_json::from_str(&source).map_err(|error| CorpusError::Parse {
path: path.to_path_buf(),
message: error.to_string(),
}),
_ => Err(CorpusError::UnknownFormat {
path: path.to_path_buf(),
}),
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum CorpusError {
#[error("corpus path {path} could not be read: {message}")]
Read {
path: PathBuf,
message: String,
},
#[error("corpus file {path} is neither .toml nor .json")]
UnknownFormat {
path: PathBuf,
},
#[error("corpus file {path} could not be parsed: {message}")]
Parse {
path: PathBuf,
message: String,
},
#[error("corpus item is invalid at `{field}`: {reason}")]
Invalid {
field: String,
reason: String,
},
#[error("corpus contains two items with the identifier `{id}`")]
DuplicateId {
id: ItemId,
},
}
impl CorpusError {
fn invalid(field: &str, reason: &str) -> Self {
Self::Invalid {
field: field.to_owned(),
reason: reason.to_owned(),
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn a_world_can_hold_a_record_that_is_not_a_case() {
let item: EvalItem = toml::from_str(
r#"
id = "traveler.delete_a_registered_one"
name = "Deleting a traveler that is already registered"
[[setup.records]]
kind = "traveler"
data = { full_name = "Luca Ferri", loyalty_number = "AZ2345678" }
[turn]
text = "elimina il viaggiatore Luca Ferri"
"#,
)
.expect("si carica");
item.validate().expect("è coerente");
assert_eq!(item.setup.records.len(), 1);
assert_eq!(item.setup.records[0].kind, "traveler");
}
#[test]
fn a_record_without_its_kind_is_refused() {
let refused = toml::from_str::<EvalItem>(
r#"
id = "traveler.kindless"
name = "A record with no kind"
[[setup.records]]
data = { full_name = "Rossi" }
[turn]
text = "ciao"
"#,
);
assert!(refused.is_err());
}
#[test]
fn an_item_asserting_only_state_is_not_empty() {
let item: EvalItem = toml::from_str(
r#"
id = "traveler.only_state"
name = "Only a state expectation"
[[expect.case_state]]
case_id = "c-1"
path = "/fields/email/value"
unchanged = true
[turn]
text = "ciao"
"#,
)
.expect("si carica");
assert!(!item.expect.is_empty(), "questo item assertisce qualcosa");
}
#[test]
fn a_state_expectation_that_asks_nothing_is_refused() {
let refused = toml::from_str::<EvalItem>(
r#"
id = "traveler.silent"
name = "A state expectation with neither side"
[[expect.case_state]]
case_id = "c-1"
path = "/fields/email/value"
[turn]
text = "ciao"
"#,
)
.expect("si carica")
.validate();
assert!(refused.is_err(), "deve essere rifiutata in validazione");
}
#[test]
fn a_state_expectation_cannot_ask_both_at_once() {
let refused = toml::from_str::<EvalItem>(
r#"
id = "traveler.both"
name = "Both at once"
[[expect.case_state]]
case_id = "c-1"
path = "/fields/email/value"
equals = "a@b.it"
unchanged = true
[turn]
text = "ciao"
"#,
)
.expect("si carica")
.validate();
assert!(refused.is_err());
}
#[test]
fn a_state_expectation_carries_either_side() {
let item: EvalItem = toml::from_str(
r#"
id = "traveler.mind_changed"
name = "The second gate won"
[[expect.case_state]]
case_id = "c-1"
path = "/fields/gate/value"
equals = "Gate B14"
[[expect.case_state]]
case_id = "c-1"
path = "/fields/email/value"
unchanged = true
[turn]
text = "sì quella"
"#,
)
.expect("si carica");
item.validate().expect("è coerente");
assert_eq!(item.expect.case_state.len(), 2);
}
#[test]
fn a_setup_can_carry_what_was_already_said() {
let item: EvalItem = toml::from_str(
r#"
id = "traveler.changed_their_mind"
name = "The user contradicts something they said earlier"
[[setup.history]]
user = "il viaggiatore è Luca Ferri"
assistant = "Va bene. Mi serve anche l'indirizzo."
[[setup.history]]
user = "anzi no, è la Bianchi"
[turn]
text = "sì quella, vai avanti"
"#,
)
.expect("l'item si carica");
item.validate().expect("l'item è coerente");
assert_eq!(item.setup.history.len(), 2);
assert_eq!(
item.setup.history[0].assistant.as_deref(),
Some("Va bene. Mi serve anche l'indirizzo.")
);
assert!(
item.setup.history[1].assistant.is_none(),
"un turno senza risposta è il caso che rende utile questo campo"
);
}
#[test]
fn a_setup_without_history_has_none_rather_than_an_empty_turn() {
let item: EvalItem = toml::from_str(
r#"
id = "traveler.plain"
name = "No history at all"
[turn]
text = "ciao"
"#,
)
.expect("l'item si carica");
assert!(item.setup.history.is_empty());
}
#[test]
fn a_misspelled_side_of_an_exchange_is_refused() {
let refused = toml::from_str::<EvalItem>(
r#"
id = "traveler.typo"
name = "A typo"
[[setup.history]]
user = "ciao"
assistent = "ciao a te"
[turn]
text = "ciao"
"#,
);
assert!(refused.is_err(), "una chiave sconosciuta è un errore");
}
#[test]
fn a_case_says_which_conversation_opened_it_by_name() {
let item: EvalItem = toml::from_str(
r#"
id = "trip.written_from_another_chat"
name = "A draft opened elsewhere is not this turn's subject"
[[setup.cases]]
workflow = "trip"
case_id = "trip-1"
label = "Trip 1"
state = { status = "draft" }
conversation = "the other chat"
[turn]
text = "add a line for 100 euro"
"#,
)
.expect("l'item si carica");
item.validate().expect("l'item è coerente");
assert_eq!(
item.setup.cases[0].conversation.as_deref(),
Some("the other chat")
);
}
#[test]
fn a_case_without_one_belongs_to_the_turns_own_conversation() {
let item: EvalItem = toml::from_str(
r#"
id = "trip.ordinary"
name = "The ordinary case"
[[setup.cases]]
workflow = "trip"
case_id = "trip-1"
label = "Trip 1"
state = { status = "draft" }
[turn]
text = "add a line for 100 euro"
"#,
)
.expect("l'item si carica");
assert!(
item.setup.cases[0].conversation.is_none(),
"l'assenza è il caso normale e non va confusa con un nome vuoto"
);
}
#[test]
fn a_misspelled_conversation_key_is_still_refused() {
let refused = toml::from_str::<EvalItem>(
r#"
id = "trip.typo"
name = "A typo"
[[setup.cases]]
workflow = "trip"
case_id = "trip-1"
label = "Trip 1"
state = { status = "draft" }
converstaion = "the other chat"
[turn]
text = "hello"
"#,
);
assert!(refused.is_err(), "una chiave sconosciuta è un errore");
}
use super::*;
fn item(body: &str) -> Result<EvalItem, String> {
let parsed: EvalItem = toml::from_str(body).map_err(|error| error.to_string())?;
parsed.validate().map_err(|error| error.to_string())?;
Ok(parsed)
}
const MINIMAL: &str = r#"
id = "a"
name = "A scenario"
[turn]
text = "hello"
"#;
#[test]
fn a_minimal_item_loads() {
let parsed = item(MINIMAL).unwrap();
assert_eq!(parsed.id, ItemId::new("a"));
assert!(parsed.expect.is_empty());
assert!(parsed.judge.is_empty());
}
#[test]
fn an_unknown_field_is_refused_rather_than_skipped() {
let error = item(&format!("{MINIMAL}unexpected = 1\n")).expect_err("unknown key");
assert!(error.contains("unexpected"), "{error}");
}
#[test]
fn a_turn_with_nothing_in_it_is_refused() {
let error = item("id = \"a\"\nname = \"A\"\n[turn]\n").expect_err("empty turn");
assert!(error.contains("turn"), "{error}");
}
#[test]
fn an_operation_on_a_kind_that_has_none_is_refused() {
let error = item(&format!(
"{MINIMAL}[[expect.acts]]\nkind = \"start_workflow\"\noperation = \"trip.rebook\"\n"
))
.expect_err("operation on start_workflow");
assert!(error.contains("start_workflow"), "{error}");
}
#[test]
fn a_case_on_an_ambiguous_resolution_is_refused() {
let error = item(&format!(
"{MINIMAL}[[expect.target_resolution]]\nact_index = 0\nresolution = \"ambiguous\"\ncase_id = \"trip-1\"\n"
))
.expect_err("case on ambiguous");
assert!(error.contains("ambiguous"), "{error}");
}
#[test]
fn a_command_both_required_and_forbidden_is_refused() {
let error = item(&format!(
"{MINIMAL}[expect]\ncommands = [\"trip.rebook\"]\nforbid = {{ commands = [\"trip.rebook\"] }}\n"
))
.expect_err("contradiction");
assert!(error.contains("trip.rebook"), "{error}");
}
#[test]
fn a_seeded_state_must_be_an_object() {
let error = item(&format!(
"{MINIMAL}[[setup.cases]]\nworkflow = \"trip\"\ncase_id = \"trip-1\"\nlabel = \"Trip 1\"\nstate = 7\n"
))
.expect_err("scalar state");
assert!(error.contains("state"), "{error}");
}
#[test]
fn a_suite_refuses_two_items_with_the_same_identifier() {
let one = item(MINIMAL).unwrap();
let two = item(MINIMAL).unwrap();
let error = Suite::new("dup", vec![one, two]).expect_err("duplicate");
assert!(matches!(error, CorpusError::DuplicateId { .. }), "{error}");
}
const WITH_A_CASE: &str = r#"
id = "a"
name = "A scenario"
[turn]
text = "hello"
[[setup.cases]]
workflow = "trip"
case_id = "trip-1"
label = "Trip 1"
state = { status = "draft" }
"#;
#[test]
fn a_provenance_declaration_loads_and_reaches_the_fingerprint() {
let parsed = item(&format!(
"provenance = {{ setup = \"derived\", turn = \"recorded\" }}\n{WITH_A_CASE}"
))
.unwrap();
assert_eq!(parsed.provenance.setup, PartProvenance::Derived);
let fingerprint = parsed.fingerprint();
assert_eq!(
fingerprint.provenance_of(ItemPart::Setup),
PartProvenance::Derived
);
assert_eq!(
fingerprint.provenance_of(ItemPart::Turn),
PartProvenance::Recorded
);
assert_eq!(
fingerprint.provenance_of(ItemPart::Expect),
PartProvenance::Authored
);
assert!(!fingerprint.is_unknown());
}
#[test]
fn only_a_derived_part_may_be_regenerated() {
assert!(PartProvenance::Derived.may_be_regenerated());
assert!(!PartProvenance::Authored.may_be_regenerated());
assert!(!PartProvenance::Recorded.may_be_regenerated());
}
#[test]
fn an_unknown_part_name_is_refused_rather_than_skipped() {
let error = item(&format!(
"provenance = {{ setpu = \"derived\" }}\n{WITH_A_CASE}"
))
.expect_err("typo");
assert!(error.contains("setpu"), "{error}");
}
#[test]
fn an_unknown_provenance_is_refused_rather_than_skipped() {
let error = item(&format!(
"provenance = {{ setup = \"transcribed\" }}\n{WITH_A_CASE}"
))
.expect_err("unknown value");
assert!(error.contains("transcribed"), "{error}");
}
#[test]
fn a_part_declared_on_an_item_that_has_none_is_refused() {
let error = item(&format!(
"provenance = {{ setup = \"recorded\" }}\n{MINIMAL}"
))
.expect_err("no setup");
assert!(
error.contains("this item has no `setup`"),
"the reason must be the missing part, not a stray parse error: {error}"
);
}
#[test]
fn a_setup_made_only_of_history_or_records_is_still_a_setup() {
let mut item = item(MINIMAL).expect("the minimal item loads");
assert!(
!item.carries(ItemPart::Setup),
"nothing seeded, nothing said"
);
item.setup.history.push(PriorExchange {
user: "e il viaggiatore di Torino?".to_owned(),
assistant: None,
});
assert!(item.carries(ItemPart::Setup));
item.setup.history.clear();
item.setup.records.push(SeededRecord {
kind: "traveler".to_owned(),
data: serde_json::json!({"name": "Ferri"}),
});
assert!(item.carries(ItemPart::Setup));
}
#[test]
fn a_blanket_declaration_reaches_every_item_that_has_the_part() {
let with_case = item(WITH_A_CASE).unwrap();
let mut without_case = item(MINIMAL).unwrap();
without_case.id = ItemId::new("b");
let blanket = Provenance {
setup: PartProvenance::Derived,
turn: PartProvenance::Recorded,
..Provenance::authored()
};
let suite = Suite::with_provenance("s", vec![with_case, without_case], blanket).unwrap();
assert_eq!(suite.items[0].provenance.setup, PartProvenance::Derived);
assert_eq!(suite.items[0].provenance.turn, PartProvenance::Recorded);
assert_eq!(suite.items[1].provenance.setup, PartProvenance::Authored);
assert_eq!(suite.items[1].provenance.turn, PartProvenance::Recorded);
}
#[test]
fn an_items_own_declaration_wins_over_the_directorys() {
let recorded = item(&format!(
"provenance = {{ setup = \"recorded\" }}\n{WITH_A_CASE}"
))
.unwrap();
let suite = Suite::with_provenance(
"s",
vec![recorded],
Provenance {
setup: PartProvenance::Derived,
..Provenance::authored()
},
)
.unwrap();
assert_eq!(suite.items[0].provenance.setup, PartProvenance::Recorded);
}
#[test]
fn a_fingerprint_ignores_the_order_the_seeded_state_was_written_in() {
let one = item(
"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",
)
.unwrap();
let two = item(
"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",
)
.unwrap();
assert!(
one.fingerprint()
.differing_parts(&two.fingerprint())
.is_empty()
);
}
#[test]
fn a_changed_seeded_state_shows_up_as_a_changed_setup_part() {
let one = item(WITH_A_CASE).unwrap();
let two = item(&WITH_A_CASE.replace("draft", "sent")).unwrap();
assert_eq!(
one.fingerprint().differing_parts(&two.fingerprint()),
vec![ItemPart::Setup]
);
}
#[test]
fn an_unknown_fingerprint_claims_nothing() {
let known = item(WITH_A_CASE).unwrap().fingerprint();
let unknown = ItemFingerprint::default();
assert!(unknown.is_unknown());
assert!(known.differing_parts(&unknown).is_empty());
assert!(unknown.differing_parts(&known).is_empty());
}
#[test]
fn selection_narrows_a_suite() {
let mut tagged = item(MINIMAL).unwrap();
tagged.tags = vec![Tag::new("trip")];
let mut other = item(MINIMAL).unwrap();
other.id = ItemId::new("b");
other.tags = vec![Tag::new("traveler")];
let suite = Suite::new("s", vec![tagged, other]).unwrap();
let selection = SelectionConfig {
include_tags: vec![Tag::new("trip")],
..SelectionConfig::default()
};
let selected = suite.select(&selection);
assert_eq!(selected.len(), 1);
assert_eq!(selected[0].id, ItemId::new("a"));
assert_eq!(suite.tags().len(), 2);
}
}