1use crate::work::{WorkIdentity, WorkKind};
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use std::collections::{HashMap, HashSet};
12use thiserror::Error;
13use uuid::Uuid;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
17pub struct WorkspaceRevision {
18 pub id: Uuid,
20 pub revision: u64,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
26pub struct EvaluationSubject {
27 pub mission: WorkIdentity,
29 pub goal: WorkIdentity,
31 pub workspace: WorkspaceRevision,
33}
34
35impl EvaluationSubject {
36 pub fn validate(&self) -> Result<(), EvaluationError> {
38 if self.mission.kind != WorkKind::Mission {
39 return Err(EvaluationError::InvalidSubject(
40 "mission identity is not a Mission",
41 ));
42 }
43 if self.goal.kind != WorkKind::Goal {
44 return Err(EvaluationError::InvalidSubject(
45 "goal identity is not a Goal",
46 ));
47 }
48 Ok(())
49 }
50
51 #[must_use]
53 pub fn matches(&self, current: &Self) -> bool {
54 self == current
55 }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
60pub struct ArtifactRef {
61 pub id: Uuid,
63 pub locator: String,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
69pub struct EvidenceRef {
70 pub id: Uuid,
72 pub kind: String,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
78#[serde(rename_all = "snake_case")]
79pub enum CriterionKind {
80 Behavior,
82 Technical,
84 Validation,
86 Documentation,
88 Custom(String),
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
94pub struct AcceptanceCriterion {
95 pub id: Uuid,
97 pub kind: CriterionKind,
99 pub statement: String,
101 pub required: bool,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
107pub struct CompletionClaim {
108 pub id: Uuid,
110 pub subject: EvaluationSubject,
112 pub criteria: Vec<AcceptanceCriterion>,
114 pub changed_artifacts: Vec<ArtifactRef>,
116 pub claimed_evidence: Vec<EvidenceRef>,
118 pub executor_summary: String,
120}
121
122impl CompletionClaim {
123 pub fn new(
125 subject: EvaluationSubject,
126 criteria: Vec<AcceptanceCriterion>,
127 changed_artifacts: Vec<ArtifactRef>,
128 claimed_evidence: Vec<EvidenceRef>,
129 executor_summary: impl Into<String>,
130 ) -> Result<Self, EvaluationError> {
131 subject.validate()?;
132 validate_criteria(&criteria)?;
133 Ok(Self {
134 id: Uuid::new_v4(),
135 subject,
136 criteria,
137 changed_artifacts,
138 claimed_evidence,
139 executor_summary: executor_summary.into(),
140 })
141 }
142
143 #[must_use]
145 pub fn evaluation(&self) -> Evaluation {
146 Evaluation {
147 claim: self.clone(),
148 state: EvaluationState::Pending,
149 report: None,
150 }
151 }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
156#[serde(rename_all = "snake_case")]
157pub enum CriterionVerdict {
158 Pass,
160 Fail,
162 Inconclusive,
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
168#[serde(rename_all = "snake_case")]
169pub enum EvaluationVerdict {
170 Pass,
172 Fail,
174 Inconclusive,
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
180#[serde(rename_all = "snake_case")]
181pub enum FindingSeverity {
182 Info,
184 Warning,
186 Blocking,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
192pub struct EvaluationFinding {
193 pub id: Uuid,
195 pub criterion_id: Option<Uuid>,
197 pub severity: FindingSeverity,
199 pub summary: String,
201 pub evidence: Vec<EvidenceRef>,
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
207pub struct CriterionEvaluation {
208 pub criterion_id: Uuid,
210 pub verdict: CriterionVerdict,
212 pub evidence: Vec<EvidenceRef>,
214 pub finding_ids: Vec<Uuid>,
216}
217
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
220pub struct EvaluationReport {
221 pub id: Uuid,
223 pub claim_id: Uuid,
225 pub subject: EvaluationSubject,
227 pub results: Vec<CriterionEvaluation>,
229 pub findings: Vec<EvaluationFinding>,
231 pub verdict: EvaluationVerdict,
233}
234
235impl EvaluationReport {
236 pub fn new(
238 claim: &CompletionClaim,
239 subject: EvaluationSubject,
240 results: Vec<CriterionEvaluation>,
241 findings: Vec<EvaluationFinding>,
242 ) -> Result<Self, EvaluationError> {
243 if !claim.subject.matches(&subject) {
244 return Err(EvaluationError::SubjectMismatch);
245 }
246 let criterion_ids: HashSet<_> = claim
247 .criteria
248 .iter()
249 .map(|criterion| criterion.id)
250 .collect();
251 let mut result_ids = HashSet::new();
252 if results.iter().any(|result| {
253 !criterion_ids.contains(&result.criterion_id) || !result_ids.insert(result.criterion_id)
254 }) {
255 return Err(EvaluationError::CriterionMismatch);
256 }
257 if result_ids.len() != criterion_ids.len() {
258 return Err(EvaluationError::CriterionMismatch);
259 }
260 let finding_ids: HashSet<_> = findings.iter().map(|finding| finding.id).collect();
261 if finding_ids.len() != findings.len()
262 || findings.iter().any(|finding| {
263 finding
264 .criterion_id
265 .is_some_and(|id| !criterion_ids.contains(&id))
266 || finding.summary.trim().is_empty()
267 })
268 || results
269 .iter()
270 .flat_map(|result| result.finding_ids.iter())
271 .any(|id| !finding_ids.contains(id))
272 {
273 return Err(EvaluationError::FindingMismatch);
274 }
275 let verdict = aggregate_verdict(&claim.criteria, &results);
276 Ok(Self {
277 id: Uuid::new_v4(),
278 claim_id: claim.id,
279 subject,
280 results,
281 findings,
282 verdict,
283 })
284 }
285}
286
287#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
289#[serde(rename_all = "snake_case", tag = "kind", content = "verdict")]
290pub enum EvaluationState {
291 Pending,
293 Evaluating,
295 Verdict(EvaluationVerdict),
297 Stale,
299 Rework,
301}
302
303#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
305pub struct Evaluation {
306 pub claim: CompletionClaim,
308 pub state: EvaluationState,
310 pub report: Option<EvaluationReport>,
312}
313
314impl Evaluation {
315 #[must_use]
317 pub fn has_valid_pass(&self) -> bool {
318 if self.state != EvaluationState::Verdict(EvaluationVerdict::Pass) {
319 return false;
320 }
321 let Some(report) = &self.report else {
322 return false;
323 };
324 if report.claim_id != self.claim.id || !self.claim.subject.matches(&report.subject) {
325 return false;
326 }
327 if report.verdict != EvaluationVerdict::Pass {
328 return false;
329 }
330 EvaluationReport::new(
331 &self.claim,
332 report.subject,
333 report.results.clone(),
334 report.findings.clone(),
335 )
336 .is_ok_and(|validated| validated.verdict == report.verdict)
337 }
338
339 pub fn begin(&mut self) -> Result<(), EvaluationError> {
341 if self.state != EvaluationState::Pending {
342 return Err(EvaluationError::IllegalTransition);
343 }
344 self.state = EvaluationState::Evaluating;
345 Ok(())
346 }
347
348 pub fn accept_report(&mut self, report: EvaluationReport) -> Result<(), EvaluationError> {
350 if self.state != EvaluationState::Evaluating || report.claim_id != self.claim.id {
351 return Err(EvaluationError::IllegalTransition);
352 }
353 if !self.claim.subject.matches(&report.subject) {
356 return Err(EvaluationError::SubjectMismatch);
357 }
358 let validated = EvaluationReport::new(
359 &self.claim,
360 report.subject,
361 report.results.clone(),
362 report.findings.clone(),
363 )?;
364 if validated.verdict != report.verdict {
365 return Err(EvaluationError::VerdictMismatch);
366 }
367 self.state = EvaluationState::Verdict(report.verdict);
368 self.report = Some(report);
369 Ok(())
370 }
371
372 pub fn observe_subject(&mut self, current: EvaluationSubject) -> Result<(), EvaluationError> {
374 if self.state
375 != EvaluationState::Verdict(
376 self.report
377 .as_ref()
378 .map_or(EvaluationVerdict::Inconclusive, |report| report.verdict),
379 )
380 {
381 return Err(EvaluationError::IllegalTransition);
382 }
383 if !self.claim.subject.matches(¤t) {
384 self.state = EvaluationState::Stale;
385 }
386 Ok(())
387 }
388
389 pub fn request_rework(&mut self) -> Result<(), EvaluationError> {
391 match self.state {
392 EvaluationState::Verdict(EvaluationVerdict::Fail | EvaluationVerdict::Inconclusive)
393 | EvaluationState::Stale => {
394 self.state = EvaluationState::Rework;
395 Ok(())
396 }
397 _ => Err(EvaluationError::IllegalTransition),
398 }
399 }
400}
401
402#[derive(Debug, Clone, PartialEq, Eq, Error)]
404pub enum EvaluationError {
405 #[error("invalid evaluation subject: {0}")]
407 InvalidSubject(&'static str),
408 #[error("invalid acceptance criteria")]
410 InvalidCriteria,
411 #[error("evaluation criteria do not exactly match the claim")]
413 CriterionMismatch,
414 #[error("evaluation findings do not match the report")]
416 FindingMismatch,
417 #[error("evaluation subject revision does not match the claim")]
419 SubjectMismatch,
420 #[error("illegal evaluation state transition")]
422 IllegalTransition,
423 #[error("evaluation verdict contradicts criterion results")]
425 VerdictMismatch,
426}
427
428fn validate_criteria(criteria: &[AcceptanceCriterion]) -> Result<(), EvaluationError> {
429 let mut ids = HashSet::new();
430 if criteria.is_empty()
431 || criteria
432 .iter()
433 .any(|criterion| criterion.statement.trim().is_empty() || !ids.insert(criterion.id))
434 {
435 return Err(EvaluationError::InvalidCriteria);
436 }
437 Ok(())
438}
439
440fn aggregate_verdict(
441 criteria: &[AcceptanceCriterion],
442 results: &[CriterionEvaluation],
443) -> EvaluationVerdict {
444 let outcomes: HashMap<_, _> = results
445 .iter()
446 .map(|result| (result.criterion_id, result.verdict))
447 .collect();
448 let mut required = criteria.iter().filter(|criterion| criterion.required);
449 if required
450 .clone()
451 .any(|criterion| outcomes.get(&criterion.id) == Some(&CriterionVerdict::Fail))
452 {
453 EvaluationVerdict::Fail
454 } else if required
455 .any(|criterion| outcomes.get(&criterion.id) == Some(&CriterionVerdict::Inconclusive))
456 {
457 EvaluationVerdict::Inconclusive
458 } else {
459 EvaluationVerdict::Pass
460 }
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466
467 fn subject() -> EvaluationSubject {
468 EvaluationSubject {
469 mission: WorkIdentity {
470 id: Uuid::new_v4(),
471 kind: WorkKind::Mission,
472 revision: 1,
473 },
474 goal: WorkIdentity {
475 id: Uuid::new_v4(),
476 kind: WorkKind::Goal,
477 revision: 2,
478 },
479 workspace: WorkspaceRevision {
480 id: Uuid::new_v4(),
481 revision: 3,
482 },
483 }
484 }
485
486 fn criterion(required: bool) -> AcceptanceCriterion {
487 AcceptanceCriterion {
488 id: Uuid::new_v4(),
489 kind: CriterionKind::Behavior,
490 statement: "works".into(),
491 required,
492 }
493 }
494
495 fn result(id: Uuid, verdict: CriterionVerdict) -> CriterionEvaluation {
496 CriterionEvaluation {
497 criterion_id: id,
498 verdict,
499 evidence: vec![],
500 finding_ids: vec![],
501 }
502 }
503
504 #[test]
505 fn state_machine_rejects_self_certification_and_stales_on_revision() {
506 let claim = CompletionClaim::new(subject(), vec![criterion(true)], vec![], vec![], "done")
507 .expect("valid test fixture");
508 let mut evaluation = claim.evaluation();
509 assert_eq!(evaluation.state, EvaluationState::Pending);
510 let report = EvaluationReport::new(
511 &claim,
512 claim.subject,
513 vec![result(claim.criteria[0].id, CriterionVerdict::Pass)],
514 vec![],
515 )
516 .expect("valid test fixture");
517 assert_eq!(
518 evaluation.accept_report(report),
519 Err(EvaluationError::IllegalTransition)
520 );
521 evaluation.begin().expect("valid test fixture");
522 let report = EvaluationReport::new(
523 &claim,
524 claim.subject,
525 vec![result(claim.criteria[0].id, CriterionVerdict::Pass)],
526 vec![],
527 )
528 .expect("valid test fixture");
529 evaluation
530 .accept_report(report)
531 .expect("valid test fixture");
532 let mut changed = claim.subject;
533 changed.goal.revision += 1;
534 evaluation
535 .observe_subject(changed)
536 .expect("valid test fixture");
537 assert_eq!(evaluation.state, EvaluationState::Stale);
538 evaluation.request_rework().expect("valid test fixture");
539 assert_eq!(evaluation.state, EvaluationState::Rework);
540 }
541
542 #[test]
543 fn locale_is_not_an_evaluation_subject_component() {
544 let a = subject();
545 let b = a;
546 assert!(a.matches(&b));
547 }
548
549 #[test]
550 fn aggregate_requires_required_criteria_only() {
551 let required = criterion(true);
552 let optional = criterion(false);
553 let claim = CompletionClaim::new(
554 subject(),
555 vec![required.clone(), optional.clone()],
556 vec![],
557 vec![],
558 "done",
559 )
560 .expect("valid test fixture");
561 let report = EvaluationReport::new(
562 &claim,
563 claim.subject,
564 vec![
565 result(required.id, CriterionVerdict::Pass),
566 result(optional.id, CriterionVerdict::Fail),
567 ],
568 vec![],
569 )
570 .expect("valid test fixture");
571 assert_eq!(report.verdict, EvaluationVerdict::Pass);
572 }
573
574 #[test]
575 fn report_rejects_duplicates_and_subject_mismatch() {
576 let c = criterion(true);
577 let claim = CompletionClaim::new(subject(), vec![c.clone()], vec![], vec![], "done")
578 .expect("valid test fixture");
579 assert_eq!(
580 EvaluationReport::new(
581 &claim,
582 subject(),
583 vec![result(c.id, CriterionVerdict::Pass)],
584 vec![]
585 ),
586 Err(EvaluationError::SubjectMismatch)
587 );
588 assert_eq!(
589 EvaluationReport::new(
590 &claim,
591 claim.subject,
592 vec![
593 result(c.id, CriterionVerdict::Pass),
594 result(c.id, CriterionVerdict::Pass)
595 ],
596 vec![]
597 ),
598 Err(EvaluationError::CriterionMismatch)
599 );
600 }
601
602 #[test]
603 fn accepting_a_forged_report_revalidates_subject_and_verdict() {
604 let c = criterion(true);
605 let claim = CompletionClaim::new(subject(), vec![c.clone()], vec![], vec![], "done")
606 .expect("valid test claim");
607 let mut evaluation = claim.evaluation();
608 evaluation
609 .begin()
610 .expect("pending claim can begin evaluation");
611 let valid = EvaluationReport::new(
612 &claim,
613 claim.subject,
614 vec![result(c.id, CriterionVerdict::Pass)],
615 vec![],
616 )
617 .expect("valid test report");
618 let forged = EvaluationReport {
619 subject: {
620 let mut subject = claim.subject;
621 subject.goal.revision += 1;
622 subject
623 },
624 verdict: EvaluationVerdict::Fail,
625 ..valid
626 };
627 assert_eq!(
628 evaluation.accept_report(forged),
629 Err(EvaluationError::SubjectMismatch)
630 );
631 assert_eq!(evaluation.state, EvaluationState::Evaluating);
632
633 let valid = EvaluationReport::new(
634 &claim,
635 claim.subject,
636 vec![result(c.id, CriterionVerdict::Pass)],
637 vec![],
638 )
639 .expect("valid test report");
640 let forged = EvaluationReport {
641 verdict: EvaluationVerdict::Fail,
642 ..valid
643 };
644 assert_eq!(
645 evaluation.accept_report(forged),
646 Err(EvaluationError::VerdictMismatch)
647 );
648 assert_eq!(evaluation.state, EvaluationState::Evaluating);
649 }
650}