use super::types::*;
use crate::gate::{ProblemSpec, ReplayEnvelope, SolverReport, StopReason};
use crate::packs::PackSolver;
use crate::Result;
pub struct WsjfSolver;
impl WsjfSolver {
pub fn solve_backlog(
&self,
input: &BacklogPrioritizationInput,
spec: &ProblemSpec,
) -> Result<(BacklogPrioritizationOutput, SolverReport)> {
let seed = spec.seed();
let mut scored_items: Vec<_> = input
.items
.iter()
.map(|item| (item, item.wsjf_score()))
.collect();
scored_items.sort_by(|a, b| {
b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
});
let tie_break = &spec.determinism.tie_break;
let mut final_order: Vec<(&BacklogItem, f64)> = Vec::new();
let mut current_score = f64::NEG_INFINITY;
let mut score_group: Vec<(&BacklogItem, f64)> = vec![];
for (item, score) in scored_items {
if (score - current_score).abs() < 0.01 {
score_group.push((item, score));
} else {
if !score_group.is_empty() {
score_group.sort_by(|a, b| a.0.id.cmp(&b.0.id));
final_order.extend(score_group.drain(..));
}
score_group = vec![(item, score)];
current_score = score;
}
}
if !score_group.is_empty() {
score_group.sort_by(|a, b| a.0.id.cmp(&b.0.id));
final_order.extend(score_group.drain(..));
}
let mut ranked_items = Vec::new();
let mut completed: Vec<&str> = Vec::new();
let mut cumulative_effort: i64 = 0;
let mut total_value = 0.0;
let mut pending: Vec<_> = final_order;
let mut rank = 1;
while !pending.is_empty() {
let before_len = pending.len();
let mut to_remove = Vec::new();
for (i, (item, wsjf)) in pending.iter().enumerate() {
if item.dependencies_satisfied(&completed) {
let included = cumulative_effort + item.effort_points <= input.capacity_points;
if included {
cumulative_effort += item.effort_points;
total_value += item.business_value;
}
ranked_items.push(RankedItem {
item_id: item.id.clone(),
item_title: item.title.clone(),
rank,
wsjf_score: *wsjf,
included_in_capacity: included,
cumulative_effort: if included { cumulative_effort } else { 0 },
ranking_reason: if item.dependencies.is_empty() {
format!("WSJF score: {:.2}", wsjf)
} else {
format!("WSJF: {:.2}, after dependencies: {:?}", wsjf, item.dependencies)
},
});
completed.push(&item.id);
to_remove.push(i);
rank += 1;
break; }
}
for i in to_remove.into_iter().rev() {
pending.remove(i);
}
if pending.len() == before_len {
for (item, wsjf) in pending.drain(..) {
ranked_items.push(RankedItem {
item_id: item.id.clone(),
item_title: item.title.clone(),
rank,
wsjf_score: wsjf,
included_in_capacity: false,
cumulative_effort: 0,
ranking_reason: format!("Dependency cycle detected: {:?}", item.dependencies),
});
rank += 1;
}
}
}
let included_count = ranked_items.iter().filter(|r| r.included_in_capacity).count();
let excluded_count = ranked_items.len() - included_count;
let output = BacklogPrioritizationOutput {
ranked_items,
total_value,
total_effort: cumulative_effort,
included_count,
excluded_count,
};
let replay = ReplayEnvelope::minimal(seed);
let report = if included_count > 0 {
SolverReport::optimal("wsjf-v1", total_value, replay)
} else {
SolverReport::feasible("wsjf-v1", 0.0, StopReason::Feasible, replay)
};
Ok((output, report))
}
}
impl PackSolver for WsjfSolver {
fn id(&self) -> &'static str {
"wsjf-v1"
}
fn solve(&self, spec: &ProblemSpec) -> Result<(serde_json::Value, SolverReport)> {
let input: BacklogPrioritizationInput = spec.inputs_as()?;
let (output, report) = self.solve_backlog(&input, spec)?;
let json = serde_json::to_value(&output)
.map_err(|e| crate::Error::invalid_input(e.to_string()))?;
Ok((json, report))
}
fn is_exact(&self) -> bool {
false }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gate::ObjectiveSpec;
fn create_test_input() -> BacklogPrioritizationInput {
BacklogPrioritizationInput {
items: vec![
BacklogItem {
id: "feat-1".to_string(),
title: "High Value Feature".to_string(),
business_value: 80.0,
time_criticality: 60.0,
risk_reduction: 40.0,
effort_points: 5,
dependencies: vec![],
},
BacklogItem {
id: "feat-2".to_string(),
title: "Quick Win".to_string(),
business_value: 40.0,
time_criticality: 80.0,
risk_reduction: 30.0,
effort_points: 2,
dependencies: vec![],
},
BacklogItem {
id: "feat-3".to_string(),
title: "Dependent Feature".to_string(),
business_value: 90.0,
time_criticality: 50.0,
risk_reduction: 60.0,
effort_points: 8,
dependencies: vec!["feat-1".to_string()],
},
],
capacity_points: 15,
}
}
fn create_spec(input: &BacklogPrioritizationInput, seed: u64) -> ProblemSpec {
ProblemSpec::builder("test", "tenant")
.objective(ObjectiveSpec::maximize("value"))
.inputs(input)
.unwrap()
.seed(seed)
.build()
.unwrap()
}
#[test]
fn test_wsjf_ranking() {
let solver = WsjfSolver;
let input = create_test_input();
let spec = create_spec(&input, 42);
let (output, report) = solver.solve_backlog(&input, &spec).unwrap();
assert!(report.feasible);
assert_eq!(output.ranked_items.len(), 3);
assert_eq!(output.ranked_items[0].item_id, "feat-2");
}
#[test]
fn test_dependency_ordering() {
let solver = WsjfSolver;
let input = create_test_input();
let spec = create_spec(&input, 42);
let (output, _) = solver.solve_backlog(&input, &spec).unwrap();
let feat1_rank = output.ranked_items.iter().find(|r| r.item_id == "feat-1").unwrap().rank;
let feat3_rank = output.ranked_items.iter().find(|r| r.item_id == "feat-3").unwrap().rank;
assert!(feat1_rank < feat3_rank);
}
#[test]
fn test_capacity_constraint() {
let solver = WsjfSolver;
let mut input = create_test_input();
input.capacity_points = 7;
let spec = create_spec(&input, 42);
let (output, _) = solver.solve_backlog(&input, &spec).unwrap();
assert_eq!(output.included_count, 2);
assert_eq!(output.total_effort, 7);
}
#[test]
fn test_determinism() {
let solver = WsjfSolver;
let input = create_test_input();
let spec1 = create_spec(&input, 12345);
let spec2 = create_spec(&input, 12345);
let (output1, _) = solver.solve_backlog(&input, &spec1).unwrap();
let (output2, _) = solver.solve_backlog(&input, &spec2).unwrap();
for (a, b) in output1.ranked_items.iter().zip(output2.ranked_items.iter()) {
assert_eq!(a.item_id, b.item_id);
assert_eq!(a.rank, b.rank);
}
}
}