1use crate::core::types::{RiskTier, ValidationTier};
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, HashSet};
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18pub struct PlanItem {
19 pub component_id: String,
21 pub current_version: String,
23 pub proposed_version: String,
25 pub validation_tier: ValidationTier,
27 pub selected: bool,
29 pub rationale: String,
31 #[serde(default)]
33 pub dependencies: Vec<String>,
34 pub isolation_safe: bool,
36 pub risk_tier: RiskTier,
38}
39
40impl PlanItem {
41 pub fn new(
43 component_id: &str,
44 current_version: &str,
45 proposed_version: &str,
46 validation_tier: ValidationTier,
47 selected: bool,
48 rationale: &str,
49 dependencies: Vec<String>,
50 isolation_safe: bool,
51 ) -> Self {
52 Self {
53 component_id: component_id.to_string(),
54 current_version: current_version.to_string(),
55 proposed_version: proposed_version.to_string(),
56 validation_tier,
57 selected,
58 rationale: rationale.to_string(),
59 dependencies,
60 isolation_safe,
61 risk_tier: RiskTier::Medium, }
63 }
64}
65
66pub fn topological_sort(items: Vec<PlanItem>) -> Vec<PlanItem> {
84 let mut remaining_deps: HashMap<String, Vec<String>> = HashMap::new();
86 let mut dependents: HashMap<String, Vec<String>> = HashMap::new();
88 let mut all_nodes: HashSet<String> = HashSet::new();
89
90 for item in &items {
92 all_nodes.insert(item.component_id.clone());
93 remaining_deps.insert(item.component_id.clone(), item.dependencies.clone());
95 for dep in &item.dependencies {
97 dependents
98 .entry(dep.clone())
99 .or_default()
100 .push(item.component_id.clone());
101 }
102 }
103
104 let mut sorted_nodes = Vec::new();
106
107 let mut queue: Vec<String> = all_nodes
109 .iter()
110 .filter(|id| remaining_deps.get(*id).map_or(true, |d| d.is_empty()))
111 .cloned()
112 .collect();
113
114 while let Some(node) = queue.pop() {
115 sorted_nodes.push(node.clone());
116
117 if let Some(dep_on_node) = dependents.get(&node) {
119 for dependent in dep_on_node.clone() {
120 if let Some(deps) = remaining_deps.get_mut(&dependent) {
122 deps.retain(|d| d != &node);
123 if deps.is_empty() {
125 queue.push(dependent);
126 }
127 }
128 }
129 }
130 }
131
132 if sorted_nodes.len() != all_nodes.len() {
133 panic!("Dependency cycle detected in plan items");
134 }
135
136 let mut item_map: HashMap<String, PlanItem> = items
138 .into_iter()
139 .map(|item| (item.component_id.clone(), item))
140 .collect();
141
142 sorted_nodes
143 .into_iter()
144 .filter_map(|id| item_map.remove(&id))
145 .collect()
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
154pub struct Plan {
155 pub items: Vec<PlanItem>,
157 pub summary: PlanSummary,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
163pub struct PlanSummary {
164 pub total: usize,
165 pub selected: usize,
166 pub safe: usize,
167 pub guarded: usize,
168 pub candidate: usize,
169 pub experimental: usize,
170 pub blocked: usize,
171}
172
173impl Plan {
174 pub fn new(items: Vec<PlanItem>) -> Self {
176 let items = topological_sort(items);
177 let summary = PlanSummary::from_items(&items);
178 Self { items, summary }
179 }
180}
181
182impl PlanSummary {
183 pub fn from_items(items: &[PlanItem]) -> Self {
185 Self {
186 total: items.len(),
187 selected: items.iter().filter(|i| i.selected).count(),
188 safe: items
189 .iter()
190 .filter(|i| i.risk_tier == RiskTier::Low)
191 .count(),
192 guarded: items
193 .iter()
194 .filter(|i| i.risk_tier == RiskTier::Medium)
195 .count(),
196 candidate: items
197 .iter()
198 .filter(|i| i.risk_tier == RiskTier::High)
199 .count(),
200 experimental: items
201 .iter()
202 .filter(|i| i.validation_tier == ValidationTier::Experimental)
203 .count(),
204 blocked: 0, }
206 }
207}
208
209#[cfg(test)]
214mod tests {
215 use super::*;
216 use crate::core::types::{RiskTier, ValidationTier};
217
218 #[test]
223 fn test_topological_sort_simple_dependency() {
224 let items = vec![
225 PlanItem::new(
226 "pytorch",
227 "2.4.0",
228 "2.5.0",
229 ValidationTier::Validated,
230 true,
231 "update pytorch",
232 vec!["rocm".to_string()],
233 true,
234 ),
235 PlanItem::new(
236 "rocm",
237 "7.2.0",
238 "7.3.0",
239 ValidationTier::Validated,
240 true,
241 "update rocm",
242 vec![],
243 true,
244 ),
245 ];
246
247 let sorted = topological_sort(items);
248 assert_eq!(sorted[0].component_id, "rocm");
249 assert_eq!(sorted[1].component_id, "pytorch");
250 }
251
252 #[test]
253 fn test_topological_sort_complex_dependency_chain() {
254 let items = vec![
255 PlanItem::new(
256 "pytorch",
257 "2.4.0",
258 "2.5.0",
259 ValidationTier::Validated,
260 true,
261 "update pytorch",
262 vec!["rocm".to_string()],
263 true,
264 ),
265 PlanItem::new(
266 "triton",
267 "3.0.0",
268 "3.1.0",
269 ValidationTier::Validated,
270 true,
271 "update triton",
272 vec!["pytorch".to_string()],
273 true,
274 ),
275 PlanItem::new(
276 "rocm",
277 "7.2.0",
278 "7.3.0",
279 ValidationTier::Validated,
280 true,
281 "update rocm",
282 vec![],
283 true,
284 ),
285 ];
286
287 let sorted = topological_sort(items);
288 assert_eq!(sorted[0].component_id, "rocm");
289 assert_eq!(sorted[1].component_id, "pytorch");
290 assert_eq!(sorted[2].component_id, "triton");
291 }
292
293 #[test]
294 fn test_topological_sort_empty_dependencies() {
295 let items = vec![
296 PlanItem::new(
297 "pytorch",
298 "2.4.0",
299 "2.5.0",
300 ValidationTier::Validated,
301 true,
302 "update pytorch",
303 vec![],
304 true,
305 ),
306 PlanItem::new(
307 "rocm",
308 "7.2.0",
309 "7.3.0",
310 ValidationTier::Validated,
311 true,
312 "update rocm",
313 vec![],
314 true,
315 ),
316 ];
317
318 let sorted = topological_sort(items);
319 let ids: HashSet<_> = sorted.iter().map(|i| i.component_id.clone()).collect();
321 assert!(ids.contains("pytorch"));
322 assert!(ids.contains("rocm"));
323 }
324
325 #[test]
330 #[should_panic(expected = "Dependency cycle detected")]
331 fn test_topological_sort_detects_cycle() {
332 let items = vec![
333 PlanItem::new(
334 "pytorch",
335 "2.4.0",
336 "2.5.0",
337 ValidationTier::Validated,
338 true,
339 "update pytorch",
340 vec!["triton".to_string()],
341 true,
342 ),
343 PlanItem::new(
344 "triton",
345 "3.0.0",
346 "3.1.0",
347 ValidationTier::Validated,
348 true,
349 "update triton",
350 vec!["pytorch".to_string()],
351 true,
352 ),
353 ];
354
355 topological_sort(items);
356 }
357
358 #[test]
363 fn test_plan_creation_applies_topological_sort() {
364 let items = vec![
365 PlanItem::new(
366 "pytorch",
367 "2.4.0",
368 "2.5.0",
369 ValidationTier::Validated,
370 true,
371 "update pytorch",
372 vec!["rocm".to_string()],
373 true,
374 ),
375 PlanItem::new(
376 "rocm",
377 "7.2.0",
378 "7.3.0",
379 ValidationTier::Validated,
380 true,
381 "update rocm",
382 vec![],
383 true,
384 ),
385 ];
386
387 let plan = Plan::new(items);
388 assert_eq!(plan.items[0].component_id, "rocm");
389 assert_eq!(plan.items[1].component_id, "pytorch");
390 }
391
392 #[test]
397 fn test_plan_summary_counts() {
398 let items = vec![
399 PlanItem {
400 component_id: "rocm".to_string(),
401 current_version: "7.2.0".to_string(),
402 proposed_version: "7.3.0".to_string(),
403 validation_tier: ValidationTier::Validated,
404 selected: true,
405 rationale: "update rocm".to_string(),
406 dependencies: vec![],
407 isolation_safe: true,
408 risk_tier: RiskTier::Low,
409 },
410 PlanItem {
411 component_id: "pytorch".to_string(),
412 current_version: "2.4.0".to_string(),
413 proposed_version: "2.5.0".to_string(),
414 validation_tier: ValidationTier::Validated,
415 selected: true,
416 rationale: "update pytorch".to_string(),
417 dependencies: vec!["rocm".to_string()],
418 isolation_safe: true,
419 risk_tier: RiskTier::Medium,
420 },
421 PlanItem {
422 component_id: "deepspeed".to_string(),
423 current_version: "".to_string(),
424 proposed_version: "0.15.0".to_string(),
425 validation_tier: ValidationTier::Experimental,
426 selected: false,
427 rationale: "new experimental".to_string(),
428 dependencies: vec!["pytorch".to_string()],
429 isolation_safe: true,
430 risk_tier: RiskTier::High,
431 },
432 ];
433
434 let plan = Plan::new(items);
435 assert_eq!(plan.summary.total, 3);
436 assert_eq!(plan.summary.selected, 2);
437 assert_eq!(plan.summary.safe, 1);
438 assert_eq!(plan.summary.guarded, 1);
439 assert_eq!(plan.summary.candidate, 1);
440 assert_eq!(plan.summary.experimental, 1);
441 assert_eq!(plan.summary.blocked, 0);
442 }
443
444 #[test]
449 fn test_plan_item_serde_roundtrip() {
450 let item = PlanItem {
451 component_id: "pytorch".to_string(),
452 current_version: "2.4.0".to_string(),
453 proposed_version: "2.5.0".to_string(),
454 validation_tier: ValidationTier::Validated,
455 selected: true,
456 rationale: "update pytorch".to_string(),
457 dependencies: vec!["rocm".to_string()],
458 isolation_safe: true,
459 risk_tier: RiskTier::Medium,
460 };
461
462 let json = serde_json::to_string(&item).unwrap();
463 let back: PlanItem = serde_json::from_str(&json).unwrap();
464 assert_eq!(item, back);
465 }
466
467 #[test]
472 fn test_plan_serde_roundtrip() {
473 let items = vec![
474 PlanItem::new(
475 "rocm",
476 "7.2.0",
477 "7.3.0",
478 ValidationTier::Validated,
479 true,
480 "update rocm",
481 vec![],
482 true,
483 ),
484 PlanItem::new(
485 "pytorch",
486 "2.4.0",
487 "2.5.0",
488 ValidationTier::Validated,
489 true,
490 "update pytorch",
491 vec!["rocm".to_string()],
492 true,
493 ),
494 ];
495
496 let plan = Plan::new(items);
497 let json = serde_json::to_string(&plan).unwrap();
498 let back: Plan = serde_json::from_str(&json).unwrap();
499 assert_eq!(plan.items, back.items);
500 assert_eq!(plan.summary, back.summary);
501 }
502
503 #[test]
508 fn test_topological_sort_with_llama_cpp_dependencies() {
509 let items = vec![
510 PlanItem::new(
511 "llama-cpp",
512 "",
513 "latest",
514 ValidationTier::Experimental,
515 true,
516 "install llama-cpp",
517 vec!["rocm".to_string()],
518 true,
519 ),
520 PlanItem::new(
521 "rocm",
522 "7.2.0",
523 "7.3.0",
524 ValidationTier::Validated,
525 true,
526 "update rocm",
527 vec![],
528 true,
529 ),
530 ];
531
532 let sorted = topological_sort(items);
533 assert_eq!(sorted[0].component_id, "rocm");
534 assert_eq!(sorted[1].component_id, "llama-cpp");
535 }
536}