Skip to main content

converge_optimization/packs/backlog_prioritization/
solver.rs

1//! Solver for Backlog Prioritization pack
2
3use super::types::*;
4use converge_pack::PackSolver;
5use converge_pack::gate::GateResult as Result;
6use converge_pack::gate::{ProblemSpec, ReplayEnvelope, SolverReport, StopReason};
7
8/// WSJF-based solver for backlog prioritization
9///
10/// Algorithm:
11/// 1. Calculate WSJF score for each item
12/// 2. Topological sort to respect dependencies
13/// 3. Greedy selection: pick highest WSJF items that fit in capacity
14pub struct WsjfSolver;
15
16impl WsjfSolver {
17    /// Solve the backlog prioritization problem
18    pub fn solve_backlog(
19        &self,
20        input: &BacklogPrioritizationInput,
21        spec: &ProblemSpec,
22    ) -> Result<(BacklogPrioritizationOutput, SolverReport)> {
23        let seed = spec.seed();
24
25        // Calculate WSJF scores and sort
26        let mut scored_items: Vec<_> = input
27            .items
28            .iter()
29            .map(|item| (item, item.wsjf_score()))
30            .collect();
31
32        // Sort by WSJF descending
33        scored_items.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
34
35        // Apply tie-breaking for equal scores
36        let _tie_break = &spec.determinism.tie_break;
37
38        // Group by WSJF score and apply tie-breaking
39        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                    // Sort by ID for deterministic tie-breaking
49                    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        // Don't forget the last group
57        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        // Now process with dependency awareness
63        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        // Track which items have been ranked
69        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            // Find items whose dependencies are satisfied
76            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; // Process one at a time to maintain WSJF order
107                }
108            }
109
110            // Remove processed items
111            for i in to_remove.into_iter().rev() {
112                pending.remove(i);
113            }
114
115            // If no progress, there's a cycle - break out
116            if pending.len() == before_len {
117                // Add remaining items as unresolvable
118                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 // Greedy, not optimal
176    }
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        // feat-2 has highest WSJF: (40+80+30)/2 = 75
241        // feat-1 has WSJF: (80+60+40)/5 = 36
242        // feat-3 depends on feat-1, WSJF: (90+50+60)/8 = 25
243        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        // Find positions
255        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        // feat-1 must come before feat-3
269        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; // Only enough for feat-2 (2) and feat-1 (5)
277
278        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}