Skip to main content

converge_optimization/packs/backlog_prioritization/
mod.rs

1//! Backlog Prioritization Pack
2//!
3//! JTBD: "Prioritize work under value-risk-effort tradeoffs."
4//!
5//! ## Problem
6//!
7//! Given:
8//! - Work items with value, risk, effort estimates
9//! - Dependencies between items
10//! - Capacity constraints
11//!
12//! Find:
13//! - Prioritized backlog using WSJF (Weighted Shortest Job First)
14//!
15//! ## Solver
16//!
17//! Uses WSJF scoring:
18//! 1. Calculate WSJF = (Business Value + Time Criticality + Risk Reduction) / Effort
19//! 2. Sort by WSJF descending
20//! 3. Respect dependency ordering
21//! 4. Mark items within capacity as included
22
23mod invariants;
24mod solver;
25mod types;
26
27pub use invariants::*;
28pub use solver::*;
29pub use types::*;
30
31use crate::packs::{InvariantDef, InvariantResult, Pack, PackSolveResult, default_gate_evaluation};
32use converge_pack::gate::GateResult as Result;
33use converge_pack::gate::{KernelTraceLink, ProblemSpec, PromotionGate, ProposedPlan};
34use converge_pack::{CONFIDENCE_STEP_MAJOR, CONFIDENCE_STEP_MINOR};
35
36/// Backlog Prioritization Pack
37pub struct BacklogPrioritizationPack;
38
39impl Pack for BacklogPrioritizationPack {
40    fn name(&self) -> &'static str {
41        "backlog-prioritization"
42    }
43
44    fn version(&self) -> &'static str {
45        "1.0.0"
46    }
47
48    fn validate_inputs(&self, inputs: &serde_json::Value) -> Result<()> {
49        let input: BacklogPrioritizationInput =
50            serde_json::from_value(inputs.clone()).map_err(|e| {
51                converge_pack::GateError::invalid_input(format!("Invalid input: {}", e))
52            })?;
53        input.validate()
54    }
55
56    fn invariants(&self) -> &[InvariantDef] {
57        INVARIANTS
58    }
59
60    fn solve(&self, spec: &ProblemSpec) -> Result<PackSolveResult> {
61        let input: BacklogPrioritizationInput = spec.inputs_as()?;
62        input.validate()?;
63
64        let solver = WsjfSolver;
65        let (output, report) = solver.solve_backlog(&input, spec)?;
66
67        let trace = KernelTraceLink::audit_only(format!("trace-{}", spec.problem_id));
68        let confidence = calculate_confidence(&output, &input);
69
70        let plan = ProposedPlan::from_payload(
71            format!("plan-{}", spec.problem_id),
72            self.name(),
73            output.summary(),
74            &output,
75            confidence,
76            trace,
77        )?;
78
79        Ok(PackSolveResult::new(plan, report))
80    }
81
82    fn check_invariants(&self, plan: &ProposedPlan) -> Result<Vec<InvariantResult>> {
83        let output: BacklogPrioritizationOutput = plan.plan_as()?;
84        Ok(check_all_invariants(&output))
85    }
86
87    fn evaluate_gate(
88        &self,
89        _plan: &ProposedPlan,
90        invariant_results: &[InvariantResult],
91    ) -> PromotionGate {
92        default_gate_evaluation(invariant_results, self.invariants())
93    }
94}
95
96fn calculate_confidence(
97    output: &BacklogPrioritizationOutput,
98    input: &BacklogPrioritizationInput,
99) -> f64 {
100    if output.ranked_items.is_empty() {
101        return 0.0;
102    }
103
104    let mut confidence: f64 = 0.5;
105
106    // Higher confidence if we included items in capacity
107    if output.included_count > 0 {
108        confidence += CONFIDENCE_STEP_MAJOR;
109    }
110
111    // Higher confidence if capacity is well utilized
112    if output.total_effort > 0 {
113        let utilization = output.total_effort as f64 / input.capacity_points as f64;
114        if utilization >= 0.7 {
115            confidence += CONFIDENCE_STEP_MAJOR;
116        } else if utilization >= 0.5 {
117            confidence += CONFIDENCE_STEP_MINOR;
118        }
119    }
120
121    // Higher confidence if total value is good
122    if output.total_value >= 100.0 {
123        confidence += CONFIDENCE_STEP_MINOR;
124    }
125
126    confidence.min(1.0)
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use converge_pack::gate::ObjectiveSpec;
133
134    fn create_test_input() -> BacklogPrioritizationInput {
135        BacklogPrioritizationInput {
136            items: vec![
137                BacklogItem {
138                    id: "feat-1".to_string(),
139                    title: "Feature 1".to_string(),
140                    business_value: 80.0,
141                    time_criticality: 60.0,
142                    risk_reduction: 40.0,
143                    effort_points: 5,
144                    dependencies: vec![],
145                },
146                BacklogItem {
147                    id: "feat-2".to_string(),
148                    title: "Feature 2".to_string(),
149                    business_value: 40.0,
150                    time_criticality: 80.0,
151                    risk_reduction: 30.0,
152                    effort_points: 2,
153                    dependencies: vec![],
154                },
155            ],
156            capacity_points: 10,
157        }
158    }
159
160    #[test]
161    fn test_pack_name() {
162        let pack = BacklogPrioritizationPack;
163        assert_eq!(pack.name(), "backlog-prioritization");
164        assert_eq!(pack.version(), "1.0.0");
165    }
166
167    #[test]
168    fn test_validate_inputs() {
169        let pack = BacklogPrioritizationPack;
170        let input = create_test_input();
171        let json = serde_json::to_value(&input).unwrap();
172        assert!(pack.validate_inputs(&json).is_ok());
173    }
174
175    #[test]
176    fn test_solve_basic() {
177        let pack = BacklogPrioritizationPack;
178        let input = create_test_input();
179
180        let spec = ProblemSpec::builder("test-001", "test-tenant")
181            .objective(ObjectiveSpec::maximize("value"))
182            .inputs(&input)
183            .unwrap()
184            .seed(42)
185            .build()
186            .unwrap();
187
188        let result = pack.solve(&spec).unwrap();
189        assert!(result.is_feasible());
190
191        let output: BacklogPrioritizationOutput = result.plan.plan_as().unwrap();
192        assert_eq!(output.ranked_items.len(), 2);
193    }
194
195    #[test]
196    fn test_check_invariants() {
197        let pack = BacklogPrioritizationPack;
198        let input = create_test_input();
199
200        let spec = ProblemSpec::builder("test-002", "test-tenant")
201            .objective(ObjectiveSpec::maximize("value"))
202            .inputs(&input)
203            .unwrap()
204            .seed(42)
205            .build()
206            .unwrap();
207
208        let result = pack.solve(&spec).unwrap();
209        let invariants = pack.check_invariants(&result.plan).unwrap();
210
211        let all_pass = invariants.iter().all(|r| r.passed);
212        assert!(all_pass);
213    }
214
215    #[test]
216    fn test_gate_promotes() {
217        let pack = BacklogPrioritizationPack;
218        let input = create_test_input();
219
220        let spec = ProblemSpec::builder("test-003", "test-tenant")
221            .objective(ObjectiveSpec::maximize("value"))
222            .inputs(&input)
223            .unwrap()
224            .seed(42)
225            .build()
226            .unwrap();
227
228        let result = pack.solve(&spec).unwrap();
229        let invariants = pack.check_invariants(&result.plan).unwrap();
230        let gate = pack.evaluate_gate(&result.plan, &invariants);
231
232        assert!(gate.is_promoted());
233    }
234
235    #[test]
236    fn test_determinism() {
237        let pack = BacklogPrioritizationPack;
238        let input = create_test_input();
239
240        let spec1 = ProblemSpec::builder("test-a", "tenant")
241            .objective(ObjectiveSpec::maximize("value"))
242            .inputs(&input)
243            .unwrap()
244            .seed(99999)
245            .build()
246            .unwrap();
247
248        let spec2 = ProblemSpec::builder("test-b", "tenant")
249            .objective(ObjectiveSpec::maximize("value"))
250            .inputs(&input)
251            .unwrap()
252            .seed(99999)
253            .build()
254            .unwrap();
255
256        let result1 = pack.solve(&spec1).unwrap();
257        let result2 = pack.solve(&spec2).unwrap();
258
259        let output1: BacklogPrioritizationOutput = result1.plan.plan_as().unwrap();
260        let output2: BacklogPrioritizationOutput = result2.plan.plan_as().unwrap();
261
262        for (a, b) in output1.ranked_items.iter().zip(output2.ranked_items.iter()) {
263            assert_eq!(a.item_id, b.item_id);
264            assert_eq!(a.rank, b.rank);
265        }
266    }
267}