use crate::work::{WorkIdentity, WorkKind};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use thiserror::Error;
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct WorkspaceRevision {
pub id: Uuid,
pub revision: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct EvaluationSubject {
pub mission: WorkIdentity,
pub goal: WorkIdentity,
pub workspace: WorkspaceRevision,
}
impl EvaluationSubject {
pub fn validate(&self) -> Result<(), EvaluationError> {
if self.mission.kind != WorkKind::Mission {
return Err(EvaluationError::InvalidSubject(
"mission identity is not a Mission",
));
}
if self.goal.kind != WorkKind::Goal {
return Err(EvaluationError::InvalidSubject(
"goal identity is not a Goal",
));
}
Ok(())
}
#[must_use]
pub fn matches(&self, current: &Self) -> bool {
self == current
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub struct ArtifactRef {
pub id: Uuid,
pub locator: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub struct EvidenceRef {
pub id: Uuid,
pub kind: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum CriterionKind {
Behavior,
Technical,
Validation,
Documentation,
Custom(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct AcceptanceCriterion {
pub id: Uuid,
pub kind: CriterionKind,
pub statement: String,
pub required: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct CompletionClaim {
pub id: Uuid,
pub subject: EvaluationSubject,
pub criteria: Vec<AcceptanceCriterion>,
pub changed_artifacts: Vec<ArtifactRef>,
pub claimed_evidence: Vec<EvidenceRef>,
pub executor_summary: String,
}
impl CompletionClaim {
pub fn new(
subject: EvaluationSubject,
criteria: Vec<AcceptanceCriterion>,
changed_artifacts: Vec<ArtifactRef>,
claimed_evidence: Vec<EvidenceRef>,
executor_summary: impl Into<String>,
) -> Result<Self, EvaluationError> {
subject.validate()?;
validate_criteria(&criteria)?;
Ok(Self {
id: Uuid::new_v4(),
subject,
criteria,
changed_artifacts,
claimed_evidence,
executor_summary: executor_summary.into(),
})
}
#[must_use]
pub fn evaluation(&self) -> Evaluation {
Evaluation {
claim: self.clone(),
state: EvaluationState::Pending,
report: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum CriterionVerdict {
Pass,
Fail,
Inconclusive,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum EvaluationVerdict {
Pass,
Fail,
Inconclusive,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum FindingSeverity {
Info,
Warning,
Blocking,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct EvaluationFinding {
pub id: Uuid,
pub criterion_id: Option<Uuid>,
pub severity: FindingSeverity,
pub summary: String,
pub evidence: Vec<EvidenceRef>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct CriterionEvaluation {
pub criterion_id: Uuid,
pub verdict: CriterionVerdict,
pub evidence: Vec<EvidenceRef>,
pub finding_ids: Vec<Uuid>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct EvaluationReport {
pub id: Uuid,
pub claim_id: Uuid,
pub subject: EvaluationSubject,
pub results: Vec<CriterionEvaluation>,
pub findings: Vec<EvaluationFinding>,
pub verdict: EvaluationVerdict,
}
impl EvaluationReport {
pub fn new(
claim: &CompletionClaim,
subject: EvaluationSubject,
results: Vec<CriterionEvaluation>,
findings: Vec<EvaluationFinding>,
) -> Result<Self, EvaluationError> {
if !claim.subject.matches(&subject) {
return Err(EvaluationError::SubjectMismatch);
}
let criterion_ids: HashSet<_> = claim
.criteria
.iter()
.map(|criterion| criterion.id)
.collect();
let mut result_ids = HashSet::new();
if results.iter().any(|result| {
!criterion_ids.contains(&result.criterion_id) || !result_ids.insert(result.criterion_id)
}) {
return Err(EvaluationError::CriterionMismatch);
}
if result_ids.len() != criterion_ids.len() {
return Err(EvaluationError::CriterionMismatch);
}
let finding_ids: HashSet<_> = findings.iter().map(|finding| finding.id).collect();
if finding_ids.len() != findings.len()
|| findings.iter().any(|finding| {
finding
.criterion_id
.is_some_and(|id| !criterion_ids.contains(&id))
|| finding.summary.trim().is_empty()
})
|| results
.iter()
.flat_map(|result| result.finding_ids.iter())
.any(|id| !finding_ids.contains(id))
{
return Err(EvaluationError::FindingMismatch);
}
let verdict = aggregate_verdict(&claim.criteria, &results);
Ok(Self {
id: Uuid::new_v4(),
claim_id: claim.id,
subject,
results,
findings,
verdict,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case", tag = "kind", content = "verdict")]
pub enum EvaluationState {
Pending,
Evaluating,
Verdict(EvaluationVerdict),
Stale,
Rework,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Evaluation {
pub claim: CompletionClaim,
pub state: EvaluationState,
pub report: Option<EvaluationReport>,
}
impl Evaluation {
#[must_use]
pub fn has_valid_pass(&self) -> bool {
if self.state != EvaluationState::Verdict(EvaluationVerdict::Pass) {
return false;
}
let Some(report) = &self.report else {
return false;
};
if report.claim_id != self.claim.id || !self.claim.subject.matches(&report.subject) {
return false;
}
if report.verdict != EvaluationVerdict::Pass {
return false;
}
EvaluationReport::new(
&self.claim,
report.subject,
report.results.clone(),
report.findings.clone(),
)
.is_ok_and(|validated| validated.verdict == report.verdict)
}
pub fn begin(&mut self) -> Result<(), EvaluationError> {
if self.state != EvaluationState::Pending {
return Err(EvaluationError::IllegalTransition);
}
self.state = EvaluationState::Evaluating;
Ok(())
}
pub fn accept_report(&mut self, report: EvaluationReport) -> Result<(), EvaluationError> {
if self.state != EvaluationState::Evaluating || report.claim_id != self.claim.id {
return Err(EvaluationError::IllegalTransition);
}
if !self.claim.subject.matches(&report.subject) {
return Err(EvaluationError::SubjectMismatch);
}
let validated = EvaluationReport::new(
&self.claim,
report.subject,
report.results.clone(),
report.findings.clone(),
)?;
if validated.verdict != report.verdict {
return Err(EvaluationError::VerdictMismatch);
}
self.state = EvaluationState::Verdict(report.verdict);
self.report = Some(report);
Ok(())
}
pub fn observe_subject(&mut self, current: EvaluationSubject) -> Result<(), EvaluationError> {
if self.state
!= EvaluationState::Verdict(
self.report
.as_ref()
.map_or(EvaluationVerdict::Inconclusive, |report| report.verdict),
)
{
return Err(EvaluationError::IllegalTransition);
}
if !self.claim.subject.matches(¤t) {
self.state = EvaluationState::Stale;
}
Ok(())
}
pub fn request_rework(&mut self) -> Result<(), EvaluationError> {
match self.state {
EvaluationState::Verdict(EvaluationVerdict::Fail | EvaluationVerdict::Inconclusive)
| EvaluationState::Stale => {
self.state = EvaluationState::Rework;
Ok(())
}
_ => Err(EvaluationError::IllegalTransition),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum EvaluationError {
#[error("invalid evaluation subject: {0}")]
InvalidSubject(&'static str),
#[error("invalid acceptance criteria")]
InvalidCriteria,
#[error("evaluation criteria do not exactly match the claim")]
CriterionMismatch,
#[error("evaluation findings do not match the report")]
FindingMismatch,
#[error("evaluation subject revision does not match the claim")]
SubjectMismatch,
#[error("illegal evaluation state transition")]
IllegalTransition,
#[error("evaluation verdict contradicts criterion results")]
VerdictMismatch,
}
fn validate_criteria(criteria: &[AcceptanceCriterion]) -> Result<(), EvaluationError> {
let mut ids = HashSet::new();
if criteria.is_empty()
|| criteria
.iter()
.any(|criterion| criterion.statement.trim().is_empty() || !ids.insert(criterion.id))
{
return Err(EvaluationError::InvalidCriteria);
}
Ok(())
}
fn aggregate_verdict(
criteria: &[AcceptanceCriterion],
results: &[CriterionEvaluation],
) -> EvaluationVerdict {
let outcomes: HashMap<_, _> = results
.iter()
.map(|result| (result.criterion_id, result.verdict))
.collect();
let mut required = criteria.iter().filter(|criterion| criterion.required);
if required
.clone()
.any(|criterion| outcomes.get(&criterion.id) == Some(&CriterionVerdict::Fail))
{
EvaluationVerdict::Fail
} else if required
.any(|criterion| outcomes.get(&criterion.id) == Some(&CriterionVerdict::Inconclusive))
{
EvaluationVerdict::Inconclusive
} else {
EvaluationVerdict::Pass
}
}
#[cfg(test)]
mod tests {
use super::*;
fn subject() -> EvaluationSubject {
EvaluationSubject {
mission: WorkIdentity {
id: Uuid::new_v4(),
kind: WorkKind::Mission,
revision: 1,
},
goal: WorkIdentity {
id: Uuid::new_v4(),
kind: WorkKind::Goal,
revision: 2,
},
workspace: WorkspaceRevision {
id: Uuid::new_v4(),
revision: 3,
},
}
}
fn criterion(required: bool) -> AcceptanceCriterion {
AcceptanceCriterion {
id: Uuid::new_v4(),
kind: CriterionKind::Behavior,
statement: "works".into(),
required,
}
}
fn result(id: Uuid, verdict: CriterionVerdict) -> CriterionEvaluation {
CriterionEvaluation {
criterion_id: id,
verdict,
evidence: vec![],
finding_ids: vec![],
}
}
#[test]
fn state_machine_rejects_self_certification_and_stales_on_revision() {
let claim = CompletionClaim::new(subject(), vec![criterion(true)], vec![], vec![], "done")
.expect("valid test fixture");
let mut evaluation = claim.evaluation();
assert_eq!(evaluation.state, EvaluationState::Pending);
let report = EvaluationReport::new(
&claim,
claim.subject,
vec![result(claim.criteria[0].id, CriterionVerdict::Pass)],
vec![],
)
.expect("valid test fixture");
assert_eq!(
evaluation.accept_report(report),
Err(EvaluationError::IllegalTransition)
);
evaluation.begin().expect("valid test fixture");
let report = EvaluationReport::new(
&claim,
claim.subject,
vec![result(claim.criteria[0].id, CriterionVerdict::Pass)],
vec![],
)
.expect("valid test fixture");
evaluation
.accept_report(report)
.expect("valid test fixture");
let mut changed = claim.subject;
changed.goal.revision += 1;
evaluation
.observe_subject(changed)
.expect("valid test fixture");
assert_eq!(evaluation.state, EvaluationState::Stale);
evaluation.request_rework().expect("valid test fixture");
assert_eq!(evaluation.state, EvaluationState::Rework);
}
#[test]
fn locale_is_not_an_evaluation_subject_component() {
let a = subject();
let b = a;
assert!(a.matches(&b));
}
#[test]
fn aggregate_requires_required_criteria_only() {
let required = criterion(true);
let optional = criterion(false);
let claim = CompletionClaim::new(
subject(),
vec![required.clone(), optional.clone()],
vec![],
vec![],
"done",
)
.expect("valid test fixture");
let report = EvaluationReport::new(
&claim,
claim.subject,
vec![
result(required.id, CriterionVerdict::Pass),
result(optional.id, CriterionVerdict::Fail),
],
vec![],
)
.expect("valid test fixture");
assert_eq!(report.verdict, EvaluationVerdict::Pass);
}
#[test]
fn report_rejects_duplicates_and_subject_mismatch() {
let c = criterion(true);
let claim = CompletionClaim::new(subject(), vec![c.clone()], vec![], vec![], "done")
.expect("valid test fixture");
assert_eq!(
EvaluationReport::new(
&claim,
subject(),
vec![result(c.id, CriterionVerdict::Pass)],
vec![]
),
Err(EvaluationError::SubjectMismatch)
);
assert_eq!(
EvaluationReport::new(
&claim,
claim.subject,
vec![
result(c.id, CriterionVerdict::Pass),
result(c.id, CriterionVerdict::Pass)
],
vec![]
),
Err(EvaluationError::CriterionMismatch)
);
}
#[test]
fn accepting_a_forged_report_revalidates_subject_and_verdict() {
let c = criterion(true);
let claim = CompletionClaim::new(subject(), vec![c.clone()], vec![], vec![], "done")
.expect("valid test claim");
let mut evaluation = claim.evaluation();
evaluation
.begin()
.expect("pending claim can begin evaluation");
let valid = EvaluationReport::new(
&claim,
claim.subject,
vec![result(c.id, CriterionVerdict::Pass)],
vec![],
)
.expect("valid test report");
let forged = EvaluationReport {
subject: {
let mut subject = claim.subject;
subject.goal.revision += 1;
subject
},
verdict: EvaluationVerdict::Fail,
..valid
};
assert_eq!(
evaluation.accept_report(forged),
Err(EvaluationError::SubjectMismatch)
);
assert_eq!(evaluation.state, EvaluationState::Evaluating);
let valid = EvaluationReport::new(
&claim,
claim.subject,
vec![result(c.id, CriterionVerdict::Pass)],
vec![],
)
.expect("valid test report");
let forged = EvaluationReport {
verdict: EvaluationVerdict::Fail,
..valid
};
assert_eq!(
evaluation.accept_report(forged),
Err(EvaluationError::VerdictMismatch)
);
assert_eq!(evaluation.state, EvaluationState::Evaluating);
}
}