1use crate::core::types::{RiskTier, ValidationTier};
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, HashSet};
11
12#[derive(Debug, Clone)]
21pub struct PlanItemInput {
22 pub component_id: String,
24 pub current_version: String,
26 pub proposed_version: String,
28 pub validation_tier: ValidationTier,
30 pub selected: bool,
32 pub rationale: String,
34 pub dependencies: Vec<String>,
36 pub isolation_safe: bool,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
42pub struct PlanItem {
43 pub component_id: String,
45 pub current_version: String,
47 pub proposed_version: String,
49 pub validation_tier: ValidationTier,
51 pub selected: bool,
53 pub rationale: String,
55 #[serde(default)]
57 pub dependencies: Vec<String>,
58 pub isolation_safe: bool,
60 pub risk_tier: RiskTier,
62}
63
64impl PlanItem {
65 pub fn new(input: PlanItemInput) -> Self {
67 Self {
68 component_id: input.component_id,
69 current_version: input.current_version,
70 proposed_version: input.proposed_version,
71 validation_tier: input.validation_tier,
72 selected: input.selected,
73 rationale: input.rationale,
74 dependencies: input.dependencies,
75 isolation_safe: input.isolation_safe,
76 risk_tier: RiskTier::Medium, }
78 }
79}
80
81pub fn topological_sort(items: Vec<PlanItem>) -> Vec<PlanItem> {
99 let mut remaining_deps: HashMap<String, Vec<String>> = HashMap::new();
101 let mut dependents: HashMap<String, Vec<String>> = HashMap::new();
103 let mut all_nodes: HashSet<String> = HashSet::new();
104
105 for item in &items {
107 all_nodes.insert(item.component_id.clone());
108 remaining_deps.insert(item.component_id.clone(), item.dependencies.clone());
110 for dep in &item.dependencies {
112 dependents
113 .entry(dep.clone())
114 .or_default()
115 .push(item.component_id.clone());
116 }
117 }
118
119 let mut sorted_nodes = Vec::new();
121
122 let mut queue: Vec<String> = all_nodes
124 .iter()
125 .filter(|id| remaining_deps.get(*id).is_none_or(|d| d.is_empty()))
126 .cloned()
127 .collect();
128
129 while let Some(node) = queue.pop() {
130 sorted_nodes.push(node.clone());
131
132 if let Some(dep_on_node) = dependents.get(&node) {
134 for dependent in dep_on_node.clone() {
135 if let Some(deps) = remaining_deps.get_mut(&dependent) {
137 deps.retain(|d| d != &node);
138 if deps.is_empty() {
140 queue.push(dependent);
141 }
142 }
143 }
144 }
145 }
146
147 if sorted_nodes.len() != all_nodes.len() {
148 panic!("Dependency cycle detected in plan items");
149 }
150
151 let mut item_map: HashMap<String, PlanItem> = items
153 .into_iter()
154 .map(|item| (item.component_id.clone(), item))
155 .collect();
156
157 sorted_nodes
158 .into_iter()
159 .filter_map(|id| item_map.remove(&id))
160 .collect()
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
169pub struct Plan {
170 pub items: Vec<PlanItem>,
172 pub summary: PlanSummary,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
178pub struct PlanSummary {
179 pub total: usize,
180 pub selected: usize,
181 pub safe: usize,
182 pub guarded: usize,
183 pub candidate: usize,
184 pub experimental: usize,
185 pub blocked: usize,
186}
187
188impl Plan {
189 pub fn new(items: Vec<PlanItem>) -> Self {
191 let items = topological_sort(items);
192 let summary = PlanSummary::from_items(&items);
193 Self { items, summary }
194 }
195}
196
197impl PlanSummary {
198 pub fn from_items(items: &[PlanItem]) -> Self {
200 Self {
201 total: items.len(),
202 selected: items.iter().filter(|i| i.selected).count(),
203 safe: items
204 .iter()
205 .filter(|i| i.risk_tier == RiskTier::Low)
206 .count(),
207 guarded: items
208 .iter()
209 .filter(|i| i.risk_tier == RiskTier::Medium)
210 .count(),
211 candidate: items
212 .iter()
213 .filter(|i| i.risk_tier == RiskTier::High)
214 .count(),
215 experimental: items
216 .iter()
217 .filter(|i| i.validation_tier == ValidationTier::Experimental)
218 .count(),
219 blocked: 0, }
221 }
222}
223
224#[cfg(test)]
229mod tests {
230 use super::*;
231 use crate::core::types::{RiskTier, ValidationTier};
232
233 #[test]
238 fn test_topological_sort_simple_dependency() {
239 let items = vec![
240 PlanItem::new(PlanItemInput {
241 component_id: "pytorch".into(),
242 current_version: "2.4.0".into(),
243 proposed_version: "2.5.0".into(),
244 validation_tier: ValidationTier::Validated,
245 selected: true,
246 rationale: "update pytorch".into(),
247 dependencies: vec!["rocm".to_string()],
248 isolation_safe: true,
249 }),
250 PlanItem::new(PlanItemInput {
251 component_id: "rocm".into(),
252 current_version: "7.2.0".into(),
253 proposed_version: "7.3.0".into(),
254 validation_tier: ValidationTier::Validated,
255 selected: true,
256 rationale: "update rocm".into(),
257 dependencies: vec![],
258 isolation_safe: true,
259 }),
260 ];
261
262 let sorted = topological_sort(items);
263 assert_eq!(sorted[0].component_id, "rocm");
264 assert_eq!(sorted[1].component_id, "pytorch");
265 }
266
267 #[test]
268 fn test_topological_sort_complex_dependency_chain() {
269 let items = vec![
270 PlanItem::new(PlanItemInput {
271 component_id: "pytorch".into(),
272 current_version: "2.4.0".into(),
273 proposed_version: "2.5.0".into(),
274 validation_tier: ValidationTier::Validated,
275 selected: true,
276 rationale: "update pytorch".into(),
277 dependencies: vec!["rocm".to_string()],
278 isolation_safe: true,
279 }),
280 PlanItem::new(PlanItemInput {
281 component_id: "triton".into(),
282 current_version: "3.0.0".into(),
283 proposed_version: "3.1.0".into(),
284 validation_tier: ValidationTier::Validated,
285 selected: true,
286 rationale: "update triton".into(),
287 dependencies: vec!["pytorch".to_string()],
288 isolation_safe: true,
289 }),
290 PlanItem::new(PlanItemInput {
291 component_id: "rocm".into(),
292 current_version: "7.2.0".into(),
293 proposed_version: "7.3.0".into(),
294 validation_tier: ValidationTier::Validated,
295 selected: true,
296 rationale: "update rocm".into(),
297 dependencies: vec![],
298 isolation_safe: true,
299 }),
300 ];
301
302 let sorted = topological_sort(items);
303 assert_eq!(sorted[0].component_id, "rocm");
304 assert_eq!(sorted[1].component_id, "pytorch");
305 assert_eq!(sorted[2].component_id, "triton");
306 }
307
308 #[test]
309 fn test_topological_sort_empty_dependencies() {
310 let items = vec![
311 PlanItem::new(PlanItemInput {
312 component_id: "pytorch".into(),
313 current_version: "2.4.0".into(),
314 proposed_version: "2.5.0".into(),
315 validation_tier: ValidationTier::Validated,
316 selected: true,
317 rationale: "update pytorch".into(),
318 dependencies: vec![],
319 isolation_safe: true,
320 }),
321 PlanItem::new(PlanItemInput {
322 component_id: "rocm".into(),
323 current_version: "7.2.0".into(),
324 proposed_version: "7.3.0".into(),
325 validation_tier: ValidationTier::Validated,
326 selected: true,
327 rationale: "update rocm".into(),
328 dependencies: vec![],
329 isolation_safe: true,
330 }),
331 ];
332
333 let sorted = topological_sort(items);
334 let ids: HashSet<_> = sorted.iter().map(|i| i.component_id.clone()).collect();
336 assert!(ids.contains("pytorch"));
337 assert!(ids.contains("rocm"));
338 }
339
340 #[test]
345 #[should_panic(expected = "Dependency cycle detected")]
346 fn test_topological_sort_detects_cycle() {
347 let items = vec![
348 PlanItem::new(PlanItemInput {
349 component_id: "pytorch".into(),
350 current_version: "2.4.0".into(),
351 proposed_version: "2.5.0".into(),
352 validation_tier: ValidationTier::Validated,
353 selected: true,
354 rationale: "update pytorch".into(),
355 dependencies: vec!["triton".to_string()],
356 isolation_safe: true,
357 }),
358 PlanItem::new(PlanItemInput {
359 component_id: "triton".into(),
360 current_version: "3.0.0".into(),
361 proposed_version: "3.1.0".into(),
362 validation_tier: ValidationTier::Validated,
363 selected: true,
364 rationale: "update triton".into(),
365 dependencies: vec!["pytorch".to_string()],
366 isolation_safe: true,
367 }),
368 ];
369
370 topological_sort(items);
371 }
372
373 #[test]
378 fn test_plan_creation_applies_topological_sort() {
379 let items = vec![
380 PlanItem::new(PlanItemInput {
381 component_id: "pytorch".into(),
382 current_version: "2.4.0".into(),
383 proposed_version: "2.5.0".into(),
384 validation_tier: ValidationTier::Validated,
385 selected: true,
386 rationale: "update pytorch".into(),
387 dependencies: vec!["rocm".to_string()],
388 isolation_safe: true,
389 }),
390 PlanItem::new(PlanItemInput {
391 component_id: "rocm".into(),
392 current_version: "7.2.0".into(),
393 proposed_version: "7.3.0".into(),
394 validation_tier: ValidationTier::Validated,
395 selected: true,
396 rationale: "update rocm".into(),
397 dependencies: vec![],
398 isolation_safe: true,
399 }),
400 ];
401
402 let plan = Plan::new(items);
403 assert_eq!(plan.items[0].component_id, "rocm");
404 assert_eq!(plan.items[1].component_id, "pytorch");
405 }
406
407 #[test]
412 fn test_plan_summary_counts() {
413 let items = vec![
414 PlanItem {
415 component_id: "rocm".to_string(),
416 current_version: "7.2.0".to_string(),
417 proposed_version: "7.3.0".to_string(),
418 validation_tier: ValidationTier::Validated,
419 selected: true,
420 rationale: "update rocm".to_string(),
421 dependencies: vec![],
422 isolation_safe: true,
423 risk_tier: RiskTier::Low,
424 },
425 PlanItem {
426 component_id: "pytorch".to_string(),
427 current_version: "2.4.0".to_string(),
428 proposed_version: "2.5.0".to_string(),
429 validation_tier: ValidationTier::Validated,
430 selected: true,
431 rationale: "update pytorch".to_string(),
432 dependencies: vec!["rocm".to_string()],
433 isolation_safe: true,
434 risk_tier: RiskTier::Medium,
435 },
436 PlanItem {
437 component_id: "deepspeed".to_string(),
438 current_version: "".to_string(),
439 proposed_version: "0.15.0".to_string(),
440 validation_tier: ValidationTier::Experimental,
441 selected: false,
442 rationale: "new experimental".to_string(),
443 dependencies: vec!["pytorch".to_string()],
444 isolation_safe: true,
445 risk_tier: RiskTier::High,
446 },
447 ];
448
449 let plan = Plan::new(items);
450 assert_eq!(plan.summary.total, 3);
451 assert_eq!(plan.summary.selected, 2);
452 assert_eq!(plan.summary.safe, 1);
453 assert_eq!(plan.summary.guarded, 1);
454 assert_eq!(plan.summary.candidate, 1);
455 assert_eq!(plan.summary.experimental, 1);
456 assert_eq!(plan.summary.blocked, 0);
457 }
458
459 #[test]
464 fn test_plan_item_serde_roundtrip() {
465 let item = PlanItem {
466 component_id: "pytorch".to_string(),
467 current_version: "2.4.0".to_string(),
468 proposed_version: "2.5.0".to_string(),
469 validation_tier: ValidationTier::Validated,
470 selected: true,
471 rationale: "update pytorch".to_string(),
472 dependencies: vec!["rocm".to_string()],
473 isolation_safe: true,
474 risk_tier: RiskTier::Medium,
475 };
476
477 let json = serde_json::to_string(&item).unwrap();
478 let back: PlanItem = serde_json::from_str(&json).unwrap();
479 assert_eq!(item, back);
480 }
481
482 #[test]
487 fn test_plan_serde_roundtrip() {
488 let items = vec![
489 PlanItem::new(PlanItemInput {
490 component_id: "rocm".into(),
491 current_version: "7.2.0".into(),
492 proposed_version: "7.3.0".into(),
493 validation_tier: ValidationTier::Validated,
494 selected: true,
495 rationale: "update rocm".into(),
496 dependencies: vec![],
497 isolation_safe: true,
498 }),
499 PlanItem::new(PlanItemInput {
500 component_id: "pytorch".into(),
501 current_version: "2.4.0".into(),
502 proposed_version: "2.5.0".into(),
503 validation_tier: ValidationTier::Validated,
504 selected: true,
505 rationale: "update pytorch".into(),
506 dependencies: vec!["rocm".to_string()],
507 isolation_safe: true,
508 }),
509 ];
510
511 let plan = Plan::new(items);
512 let json = serde_json::to_string(&plan).unwrap();
513 let back: Plan = serde_json::from_str(&json).unwrap();
514 assert_eq!(plan.items, back.items);
515 assert_eq!(plan.summary, back.summary);
516 }
517
518 #[test]
523 fn test_topological_sort_with_llama_cpp_dependencies() {
524 let items = vec![
525 PlanItem::new(PlanItemInput {
526 component_id: "llama-cpp".into(),
527 current_version: "".into(),
528 proposed_version: "latest".into(),
529 validation_tier: ValidationTier::Experimental,
530 selected: true,
531 rationale: "install llama-cpp".into(),
532 dependencies: vec!["rocm".to_string()],
533 isolation_safe: true,
534 }),
535 PlanItem::new(PlanItemInput {
536 component_id: "rocm".into(),
537 current_version: "7.2.0".into(),
538 proposed_version: "7.3.0".into(),
539 validation_tier: ValidationTier::Validated,
540 selected: true,
541 rationale: "update rocm".into(),
542 dependencies: vec![],
543 isolation_safe: true,
544 }),
545 ];
546
547 let sorted = topological_sort(items);
548 assert_eq!(sorted[0].component_id, "rocm");
549 assert_eq!(sorted[1].component_id, "llama-cpp");
550 }
551}