use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use thiserror::Error;
use uuid::Uuid;
pub use crate::evaluation::{
AcceptanceCriterion, ArtifactRef, CompletionClaim, CriterionEvaluation, CriterionKind,
CriterionVerdict, Evaluation, EvaluationError, EvaluationFinding, EvaluationReport,
EvaluationState, EvaluationSubject, EvaluationVerdict, EvidenceRef, FindingSeverity,
WorkspaceRevision,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct MissionEvaluation {
pub mission: WorkIdentity,
pub verdict: EvaluationVerdict,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum DeliveryBlockReason {
MissingGoalEvaluation,
StaleGoalEvaluation,
GoalNotPassed,
ConflictingGoalEvaluations,
MissingMissionEvaluation,
StaleMissionEvaluation,
MissionNotPassed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case", tag = "state")]
pub enum DeliveryEligibility {
Eligible,
Blocked { reason: DeliveryBlockReason },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum WorkProjectionEvent {
GoalObserved { goal_id: Uuid, revision: u64 },
MissionObserved { mission_id: Uuid, revision: u64 },
DeliveryEligibilityChanged { eligible: bool },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct MissionGateResult {
pub mission: WorkIdentity,
pub delivery: DeliveryEligibility,
pub events: Vec<WorkProjectionEvent>,
}
#[derive(Debug, Clone, Copy)]
pub struct MissionGate<'a> {
pub mission: WorkIdentity,
pub required_goals: &'a [WorkIdentity],
pub goal_evaluations: &'a [Evaluation],
pub mission_evaluation: Option<MissionEvaluation>,
}
impl MissionGate<'_> {
#[must_use]
pub fn evaluate(&self) -> MissionGateResult {
let mut events = Vec::with_capacity(self.required_goals.len() + 2);
let mut blocked = None;
for goal in self.required_goals {
let evaluations: Vec<&Evaluation> = self
.goal_evaluations
.iter()
.filter(|evaluation| {
evaluation.claim.subject.goal.id == goal.id
&& evaluation.claim.subject.goal.kind == goal.kind
})
.collect();
match evaluations.as_slice() {
[] => {
blocked.get_or_insert(DeliveryBlockReason::MissingGoalEvaluation);
}
[evaluation] => {
events.push(WorkProjectionEvent::GoalObserved {
goal_id: goal.id,
revision: goal.revision,
});
if evaluation.claim.subject.goal != *goal
|| evaluation.claim.subject.mission != self.mission
{
blocked.get_or_insert(DeliveryBlockReason::StaleGoalEvaluation);
} else if !evaluation.has_valid_pass() {
blocked.get_or_insert(DeliveryBlockReason::GoalNotPassed);
}
}
_ => {
blocked.get_or_insert(DeliveryBlockReason::ConflictingGoalEvaluations);
}
};
}
match self.mission_evaluation {
None => {
blocked.get_or_insert(DeliveryBlockReason::MissingMissionEvaluation);
}
Some(evaluation) => {
events.push(WorkProjectionEvent::MissionObserved {
mission_id: evaluation.mission.id,
revision: evaluation.mission.revision,
});
if evaluation.mission != self.mission {
blocked.get_or_insert(DeliveryBlockReason::StaleMissionEvaluation);
} else if evaluation.verdict != EvaluationVerdict::Pass {
blocked.get_or_insert(DeliveryBlockReason::MissionNotPassed);
}
}
}
let delivery = blocked.map_or(DeliveryEligibility::Eligible, |reason| {
DeliveryEligibility::Blocked { reason }
});
events.push(WorkProjectionEvent::DeliveryEligibilityChanged {
eligible: matches!(delivery, DeliveryEligibility::Eligible),
});
MissionGateResult {
mission: self.mission,
delivery,
events,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum WorkKind {
Mission,
Goal,
WorkUnit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct WorkIdentity {
pub id: Uuid,
pub kind: WorkKind,
pub revision: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct WorkNode {
pub identity: WorkIdentity,
pub parent_id: Option<Uuid>,
pub title: String,
pub description: Option<String>,
pub status: WorkStatus,
pub priority: WorkPriority,
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum WorkStatus {
Todo,
InProgress,
Completed,
Blocked,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum WorkPriority {
Low,
Medium,
High,
Critical,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub struct WorkEdge {
pub identity: WorkEdgeIdentity,
pub parent_id: Uuid,
pub child_id: Uuid,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub struct WorkEdgeIdentity {
pub id: Uuid,
pub revision: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum WorkGraphError {
#[error("work node cannot depend on itself: {0}")]
SelfDependency(Uuid),
#[error("work dependency would create a cycle: {parent_id} -> {child_id}")]
Cycle { parent_id: Uuid, child_id: Uuid },
#[error("work edge references an unknown node")]
UnknownNode,
#[error("work graph contains a duplicate node identity: {0}")]
DuplicateNode(Uuid),
#[error("work graph contains a duplicate edge")]
DuplicateEdge,
#[error("invalid work containment")]
InvalidContainment,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct WorkGraph {
pub nodes: Vec<WorkNode>,
pub edges: Vec<WorkEdge>,
}
impl WorkGraph {
pub fn new(nodes: Vec<WorkNode>, edges: Vec<WorkEdge>) -> Result<Self, WorkGraphError> {
let kinds: HashMap<_, _> = nodes
.iter()
.map(|node| (node.identity.id, node.identity.kind))
.collect();
if kinds.len() != nodes.len() {
let mut seen = HashSet::new();
let duplicate = nodes
.iter()
.find_map(|node| (!seen.insert(node.identity.id)).then_some(node.identity.id))
.unwrap_or(Uuid::nil());
return Err(WorkGraphError::DuplicateNode(duplicate));
}
if nodes.iter().any(|node| match node.identity.kind {
WorkKind::Mission => node.parent_id.is_some(),
WorkKind::Goal => node
.parent_id
.is_none_or(|parent| kinds.get(&parent) != Some(&WorkKind::Mission)),
WorkKind::WorkUnit => node
.parent_id
.is_some_and(|parent| kinds.get(&parent) != Some(&WorkKind::Goal)),
}) {
return Err(WorkGraphError::InvalidContainment);
}
if edges
.iter()
.any(|edge| !kinds.contains_key(&edge.parent_id) || !kinds.contains_key(&edge.child_id))
{
return Err(WorkGraphError::UnknownNode);
}
let mut edge_ids = HashSet::new();
let mut endpoint_pairs = HashSet::new();
if edges.iter().any(|edge| {
!edge_ids.insert(edge.identity.id)
|| !endpoint_pairs.insert((edge.parent_id, edge.child_id))
}) {
return Err(WorkGraphError::DuplicateEdge);
}
let mut accepted = Vec::with_capacity(edges.len());
for edge in edges {
validate_edge(accepted.iter().copied(), edge)?;
accepted.push(edge);
}
Ok(Self {
nodes,
edges: accepted,
})
}
pub fn with_node(&self, node: WorkNode) -> Result<Self, WorkGraphError> {
let mut nodes = self.nodes.clone();
nodes.push(node);
Self::new(nodes, self.edges.clone())
}
pub fn with_edge(&self, edge: WorkEdge) -> Result<Self, WorkGraphError> {
let mut edges = self.edges.clone();
edges.push(edge);
Self::new(self.nodes.clone(), edges)
}
#[must_use]
pub fn without_edge(&self, edge_id: Uuid) -> Self {
Self {
nodes: self.nodes.clone(),
edges: self
.edges
.iter()
.copied()
.filter(|edge| edge.identity.id != edge_id)
.collect(),
}
}
#[must_use]
pub fn node(&self, id: Uuid) -> Option<&WorkNode> {
self.nodes.iter().find(|node| node.identity.id == id)
}
}
pub fn validate_edge(
edges: impl IntoIterator<Item = WorkEdge>,
edge: WorkEdge,
) -> Result<(), WorkGraphError> {
if edge.parent_id == edge.child_id {
return Err(WorkGraphError::SelfDependency(edge.parent_id));
}
let mut adjacency: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
for existing in edges {
adjacency
.entry(existing.parent_id)
.or_default()
.push(existing.child_id);
}
let mut stack = vec![edge.child_id];
let mut visited = HashSet::new();
while let Some(node) = stack.pop() {
if node == edge.parent_id {
return Err(WorkGraphError::Cycle {
parent_id: edge.parent_id,
child_id: edge.child_id,
});
}
if visited.insert(node)
&& let Some(children) = adjacency.get(&node)
{
stack.extend(children.iter().copied());
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn passing_evaluation(mission: WorkIdentity, goal: WorkIdentity) -> Evaluation {
let subject = EvaluationSubject {
mission,
goal,
workspace: WorkspaceRevision {
id: Uuid::new_v4(),
revision: 1,
},
};
let criterion = AcceptanceCriterion {
id: Uuid::new_v4(),
kind: CriterionKind::Technical,
statement: "works".into(),
required: true,
};
let claim = CompletionClaim::new(subject, vec![criterion.clone()], vec![], vec![], "done")
.expect("claim");
let mut evaluation = claim.evaluation();
evaluation.begin().expect("begin");
let report = EvaluationReport::new(
&claim,
subject,
vec![CriterionEvaluation {
criterion_id: criterion.id,
verdict: CriterionVerdict::Pass,
evidence: vec![],
finding_ids: vec![],
}],
vec![],
)
.expect("report");
evaluation.accept_report(report).expect("accept");
evaluation
}
#[test]
fn rejects_self_dependency_and_cycles() {
let a = Uuid::new_v4();
let b = Uuid::new_v4();
assert_eq!(
validate_edge(
[],
WorkEdge {
identity: WorkEdgeIdentity {
id: Uuid::new_v4(),
revision: 1
},
parent_id: a,
child_id: a
}
),
Err(WorkGraphError::SelfDependency(a))
);
assert_eq!(
validate_edge(
[WorkEdge {
identity: WorkEdgeIdentity {
id: Uuid::new_v4(),
revision: 1
},
parent_id: a,
child_id: b
}],
WorkEdge {
identity: WorkEdgeIdentity {
id: Uuid::new_v4(),
revision: 1
},
parent_id: b,
child_id: a
}
),
Err(WorkGraphError::Cycle {
parent_id: b,
child_id: a
})
);
}
#[test]
fn accepts_disconnected_edge() {
let a = Uuid::new_v4();
let b = Uuid::new_v4();
assert!(
validate_edge(
[],
WorkEdge {
identity: WorkEdgeIdentity {
id: Uuid::new_v4(),
revision: 1
},
parent_id: a,
child_id: b
}
)
.is_ok()
);
}
#[test]
fn mission_gate_requires_independent_mission_pass() {
let mission = WorkIdentity {
id: Uuid::new_v4(),
kind: WorkKind::Mission,
revision: 4,
};
let goal = WorkIdentity {
id: Uuid::new_v4(),
kind: WorkKind::Goal,
revision: 2,
};
let result = MissionGate {
mission,
required_goals: &[goal],
goal_evaluations: &[passing_evaluation(mission, goal)],
mission_evaluation: None,
}
.evaluate();
assert_eq!(
result.delivery,
DeliveryEligibility::Blocked {
reason: DeliveryBlockReason::MissingMissionEvaluation
}
);
assert!(matches!(
result.events.last(),
Some(WorkProjectionEvent::DeliveryEligibilityChanged { eligible: false })
));
}
#[test]
fn mission_gate_emits_deterministic_eligible_projection() {
let mission = WorkIdentity {
id: Uuid::new_v4(),
kind: WorkKind::Mission,
revision: 1,
};
let goal = WorkIdentity {
id: Uuid::new_v4(),
kind: WorkKind::Goal,
revision: 1,
};
let result = MissionGate {
mission,
required_goals: &[goal],
goal_evaluations: &[passing_evaluation(mission, goal)],
mission_evaluation: Some(MissionEvaluation {
mission,
verdict: EvaluationVerdict::Pass,
}),
}
.evaluate();
assert_eq!(result.delivery, DeliveryEligibility::Eligible);
assert_eq!(result.events.len(), 3);
assert!(matches!(
result.events.last(),
Some(WorkProjectionEvent::DeliveryEligibilityChanged { eligible: true })
));
}
#[test]
fn mission_gate_rejects_stale_goal_revision() {
let mission = WorkIdentity {
id: Uuid::new_v4(),
kind: WorkKind::Mission,
revision: 1,
};
let required_goal = WorkIdentity {
id: Uuid::new_v4(),
kind: WorkKind::Goal,
revision: 2,
};
let old_goal = WorkIdentity {
revision: 1,
..required_goal
};
let result = MissionGate {
mission,
required_goals: &[required_goal],
goal_evaluations: &[passing_evaluation(mission, old_goal)],
mission_evaluation: Some(MissionEvaluation {
mission,
verdict: EvaluationVerdict::Pass,
}),
}
.evaluate();
assert_eq!(
result.delivery,
DeliveryEligibility::Blocked {
reason: DeliveryBlockReason::StaleGoalEvaluation
}
);
}
#[test]
fn p4_non_persistent_fixture_covers_claim_staleness_gate_and_delivery_projection() {
let mission = WorkIdentity {
id: Uuid::new_v4(),
kind: WorkKind::Mission,
revision: 1,
};
let goal = WorkIdentity {
id: Uuid::new_v4(),
kind: WorkKind::Goal,
revision: 1,
};
let work_unit = WorkNode {
identity: WorkIdentity {
id: Uuid::new_v4(),
kind: WorkKind::WorkUnit,
revision: 1,
},
parent_id: Some(goal.id),
title: "fixture work unit".into(),
description: None,
status: WorkStatus::Completed,
priority: WorkPriority::Medium,
tags: vec![],
};
assert_eq!(work_unit.status, WorkStatus::Completed);
let mut goal_evaluation = passing_evaluation(mission, goal);
let mut changed_subject = goal_evaluation.claim.subject;
changed_subject.goal.revision = 2;
goal_evaluation
.observe_subject(changed_subject)
.expect("revision change marks the prior evaluation stale");
goal_evaluation
.request_rework()
.expect("stale evaluation requires rework");
assert_eq!(goal_evaluation.state, EvaluationState::Rework);
let stale = MissionGate {
mission,
required_goals: &[changed_subject.goal],
goal_evaluations: &[goal_evaluation.clone()],
mission_evaluation: Some(MissionEvaluation {
mission,
verdict: EvaluationVerdict::Pass,
}),
}
.evaluate();
assert_eq!(
stale.delivery,
DeliveryEligibility::Blocked {
reason: DeliveryBlockReason::StaleGoalEvaluation
}
);
let refreshed = passing_evaluation(mission, changed_subject.goal);
let eligible = MissionGate {
mission,
required_goals: &[changed_subject.goal],
goal_evaluations: &[refreshed],
mission_evaluation: Some(MissionEvaluation {
mission,
verdict: EvaluationVerdict::Pass,
}),
}
.evaluate();
assert_eq!(eligible.delivery, DeliveryEligibility::Eligible);
assert_eq!(eligible.events.len(), 3);
assert!(serde_json::to_value(&eligible).is_ok());
}
#[test]
fn mission_gate_rejects_duplicate_goal_evaluations() {
let mission = WorkIdentity {
id: Uuid::new_v4(),
kind: WorkKind::Mission,
revision: 1,
};
let goal = WorkIdentity {
id: Uuid::new_v4(),
kind: WorkKind::Goal,
revision: 1,
};
let first = passing_evaluation(mission, goal);
let second = passing_evaluation(mission, goal);
let result = MissionGate {
mission,
required_goals: &[goal],
goal_evaluations: &[first, second],
mission_evaluation: Some(MissionEvaluation {
mission,
verdict: EvaluationVerdict::Pass,
}),
}
.evaluate();
assert_eq!(
result.delivery,
DeliveryEligibility::Blocked {
reason: DeliveryBlockReason::ConflictingGoalEvaluations
}
);
}
#[test]
fn mission_gate_rejects_forged_passing_evaluation_without_report() {
let mission = WorkIdentity {
id: Uuid::new_v4(),
kind: WorkKind::Mission,
revision: 1,
};
let goal = WorkIdentity {
id: Uuid::new_v4(),
kind: WorkKind::Goal,
revision: 1,
};
let mut forged = passing_evaluation(mission, goal);
forged.report = None;
let result = MissionGate {
mission,
required_goals: &[goal],
goal_evaluations: &[forged],
mission_evaluation: Some(MissionEvaluation {
mission,
verdict: EvaluationVerdict::Pass,
}),
}
.evaluate();
assert_eq!(
result.delivery,
DeliveryEligibility::Blocked {
reason: DeliveryBlockReason::GoalNotPassed
}
);
}
#[test]
fn mission_gate_rejects_passing_state_with_valid_fail_report() {
let mission = WorkIdentity {
id: Uuid::new_v4(),
kind: WorkKind::Mission,
revision: 1,
};
let goal = WorkIdentity {
id: Uuid::new_v4(),
kind: WorkKind::Goal,
revision: 1,
};
let mut forged = passing_evaluation(mission, goal);
let claim = forged.claim.clone();
forged.report = Some(
EvaluationReport::new(
&claim,
claim.subject,
vec![CriterionEvaluation {
criterion_id: claim.criteria[0].id,
verdict: CriterionVerdict::Fail,
evidence: vec![],
finding_ids: vec![],
}],
vec![],
)
.expect("valid fail report"),
);
let result = MissionGate {
mission,
required_goals: &[goal],
goal_evaluations: &[forged],
mission_evaluation: Some(MissionEvaluation {
mission,
verdict: EvaluationVerdict::Pass,
}),
}
.evaluate();
assert_eq!(
result.delivery,
DeliveryEligibility::Blocked {
reason: DeliveryBlockReason::GoalNotPassed
}
);
}
}