converge_optimization/packs/backlog_prioritization/
solver.rs1use super::types::*;
4use converge_pack::PackSolver;
5use converge_pack::gate::GateResult as Result;
6use converge_pack::gate::{ProblemSpec, ReplayEnvelope, SolverReport, StopReason};
7
8pub struct WsjfSolver;
15
16impl WsjfSolver {
17 pub fn solve_backlog(
19 &self,
20 input: &BacklogPrioritizationInput,
21 spec: &ProblemSpec,
22 ) -> Result<(BacklogPrioritizationOutput, SolverReport)> {
23 let seed = spec.seed();
24
25 let mut scored_items: Vec<_> = input
27 .items
28 .iter()
29 .map(|item| (item, item.wsjf_score()))
30 .collect();
31
32 scored_items.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
34
35 let _tie_break = &spec.determinism.tie_break;
37
38 let mut final_order: Vec<(&BacklogItem, f64)> = Vec::new();
40 let mut current_score = f64::NEG_INFINITY;
41 let mut score_group: Vec<(&BacklogItem, f64)> = vec![];
42
43 for (item, score) in scored_items {
44 if (score - current_score).abs() < 0.01 {
45 score_group.push((item, score));
46 } else {
47 if !score_group.is_empty() {
48 score_group.sort_by(|a, b| a.0.id.cmp(&b.0.id));
50 final_order.extend(score_group.drain(..));
51 }
52 score_group = vec![(item, score)];
53 current_score = score;
54 }
55 }
56 if !score_group.is_empty() {
58 score_group.sort_by(|a, b| a.0.id.cmp(&b.0.id));
59 final_order.extend(score_group.drain(..));
60 }
61
62 let mut ranked_items = Vec::new();
64 let mut completed: Vec<&str> = Vec::new();
65 let mut cumulative_effort: i64 = 0;
66 let mut total_value = 0.0;
67
68 let mut pending: Vec<_> = final_order;
70 let mut rank = 1;
71
72 while !pending.is_empty() {
73 let before_len = pending.len();
74
75 let mut to_remove = Vec::new();
77 for (i, (item, wsjf)) in pending.iter().enumerate() {
78 if item.dependencies_satisfied(&completed) {
79 let included = cumulative_effort + item.effort_points <= input.capacity_points;
80
81 if included {
82 cumulative_effort += item.effort_points;
83 total_value += item.business_value;
84 }
85
86 ranked_items.push(RankedItem {
87 item_id: item.id.clone(),
88 item_title: item.title.clone(),
89 rank,
90 wsjf_score: *wsjf,
91 included_in_capacity: included,
92 cumulative_effort: if included { cumulative_effort } else { 0 },
93 ranking_reason: if item.dependencies.is_empty() {
94 format!("WSJF score: {:.2}", wsjf)
95 } else {
96 format!(
97 "WSJF: {:.2}, after dependencies: {:?}",
98 wsjf, item.dependencies
99 )
100 },
101 });
102
103 completed.push(&item.id);
104 to_remove.push(i);
105 rank += 1;
106 break; }
108 }
109
110 for i in to_remove.into_iter().rev() {
112 pending.remove(i);
113 }
114
115 if pending.len() == before_len {
117 for (item, wsjf) in pending.drain(..) {
119 ranked_items.push(RankedItem {
120 item_id: item.id.clone(),
121 item_title: item.title.clone(),
122 rank,
123 wsjf_score: wsjf,
124 included_in_capacity: false,
125 cumulative_effort: 0,
126 ranking_reason: format!(
127 "Dependency cycle detected: {:?}",
128 item.dependencies
129 ),
130 });
131 rank += 1;
132 }
133 }
134 }
135
136 let included_count = ranked_items
137 .iter()
138 .filter(|r| r.included_in_capacity)
139 .count();
140 let excluded_count = ranked_items.len() - included_count;
141
142 let output = BacklogPrioritizationOutput {
143 ranked_items,
144 total_value,
145 total_effort: cumulative_effort,
146 included_count,
147 excluded_count,
148 };
149
150 let replay = ReplayEnvelope::minimal(seed);
151 let report = if included_count > 0 {
152 SolverReport::optimal("wsjf-v1", total_value, replay)
153 } else {
154 SolverReport::feasible("wsjf-v1", 0.0, StopReason::Feasible, replay)
155 };
156
157 Ok((output, report))
158 }
159}
160
161impl PackSolver for WsjfSolver {
162 fn id(&self) -> &'static str {
163 "wsjf-v1"
164 }
165
166 fn solve(&self, spec: &ProblemSpec) -> Result<(serde_json::Value, SolverReport)> {
167 let input: BacklogPrioritizationInput = spec.inputs_as()?;
168 let (output, report) = self.solve_backlog(&input, spec)?;
169 let json = serde_json::to_value(&output)
170 .map_err(|e| converge_pack::GateError::invalid_input(e.to_string()))?;
171 Ok((json, report))
172 }
173
174 fn is_exact(&self) -> bool {
175 false }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use converge_pack::gate::ObjectiveSpec;
183
184 fn create_test_input() -> BacklogPrioritizationInput {
185 BacklogPrioritizationInput {
186 items: vec![
187 BacklogItem {
188 id: "feat-1".to_string(),
189 title: "High Value Feature".to_string(),
190 business_value: 80.0,
191 time_criticality: 60.0,
192 risk_reduction: 40.0,
193 effort_points: 5,
194 dependencies: vec![],
195 },
196 BacklogItem {
197 id: "feat-2".to_string(),
198 title: "Quick Win".to_string(),
199 business_value: 40.0,
200 time_criticality: 80.0,
201 risk_reduction: 30.0,
202 effort_points: 2,
203 dependencies: vec![],
204 },
205 BacklogItem {
206 id: "feat-3".to_string(),
207 title: "Dependent Feature".to_string(),
208 business_value: 90.0,
209 time_criticality: 50.0,
210 risk_reduction: 60.0,
211 effort_points: 8,
212 dependencies: vec!["feat-1".to_string()],
213 },
214 ],
215 capacity_points: 15,
216 }
217 }
218
219 fn create_spec(input: &BacklogPrioritizationInput, seed: u64) -> ProblemSpec {
220 ProblemSpec::builder("test", "tenant")
221 .objective(ObjectiveSpec::maximize("value"))
222 .inputs(input)
223 .unwrap()
224 .seed(seed)
225 .build()
226 .unwrap()
227 }
228
229 #[test]
230 fn test_wsjf_ranking() {
231 let solver = WsjfSolver;
232 let input = create_test_input();
233 let spec = create_spec(&input, 42);
234
235 let (output, report) = solver.solve_backlog(&input, &spec).unwrap();
236
237 assert!(report.feasible);
238 assert_eq!(output.ranked_items.len(), 3);
239
240 assert_eq!(output.ranked_items[0].item_id, "feat-2");
244 }
245
246 #[test]
247 fn test_dependency_ordering() {
248 let solver = WsjfSolver;
249 let input = create_test_input();
250 let spec = create_spec(&input, 42);
251
252 let (output, _) = solver.solve_backlog(&input, &spec).unwrap();
253
254 let feat1_rank = output
256 .ranked_items
257 .iter()
258 .find(|r| r.item_id == "feat-1")
259 .unwrap()
260 .rank;
261 let feat3_rank = output
262 .ranked_items
263 .iter()
264 .find(|r| r.item_id == "feat-3")
265 .unwrap()
266 .rank;
267
268 assert!(feat1_rank < feat3_rank);
270 }
271
272 #[test]
273 fn test_capacity_constraint() {
274 let solver = WsjfSolver;
275 let mut input = create_test_input();
276 input.capacity_points = 7; let spec = create_spec(&input, 42);
279 let (output, _) = solver.solve_backlog(&input, &spec).unwrap();
280
281 assert_eq!(output.included_count, 2);
282 assert_eq!(output.total_effort, 7);
283 }
284
285 #[test]
286 fn test_determinism() {
287 let solver = WsjfSolver;
288 let input = create_test_input();
289
290 let spec1 = create_spec(&input, 12345);
291 let spec2 = create_spec(&input, 12345);
292
293 let (output1, _) = solver.solve_backlog(&input, &spec1).unwrap();
294 let (output2, _) = solver.solve_backlog(&input, &spec2).unwrap();
295
296 for (a, b) in output1.ranked_items.iter().zip(output2.ranked_items.iter()) {
297 assert_eq!(a.item_id, b.item_id);
298 assert_eq!(a.rank, b.rank);
299 }
300 }
301}