Skip to main content

rusty_stack/core/
plan.rs

1//! Plan types and topological sorting.
2//!
3//! This module implements:
4//! - [`PlanItem`] — a component update plan item with risk tier, dependencies, etc.
5//! - [`topological_sort`] — sorts components by dependency order
6//! - [`Plan`] — a full update plan with items and summary
7
8use crate::core::types::{RiskTier, ValidationTier};
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, HashSet};
11
12// ---------------------------------------------------------------------------
13// Plan Item
14// ---------------------------------------------------------------------------
15
16/// A single item in an update plan.
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18pub struct PlanItem {
19    /// Component ID.
20    pub component_id: String,
21    /// Currently installed version (empty if not installed).
22    pub current_version: String,
23    /// Proposed version to install.
24    pub proposed_version: String,
25    /// Validation tier of this component.
26    pub validation_tier: ValidationTier,
27    /// Whether this item is selected for apply.
28    pub selected: bool,
29    /// Human-readable rationale for this update.
30    pub rationale: String,
31    /// Component IDs this item depends on.
32    #[serde(default)]
33    pub dependencies: Vec<String>,
34    /// Whether this component can be installed in isolation.
35    pub isolation_safe: bool,
36    /// Risk tier (low/medium/high).
37    pub risk_tier: RiskTier,
38}
39
40impl PlanItem {
41    /// Create a new plan item.
42    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, // default
62        }
63    }
64}
65
66// ---------------------------------------------------------------------------
67// Topological Sort
68// ---------------------------------------------------------------------------
69
70/// Sort components topologically by their dependencies.
71///
72/// Returns a new vector with components ordered such that all dependencies
73/// of a component appear before the component itself.
74///
75/// # Arguments
76/// * `items` - Vector of plan items
77///
78/// # Returns
79/// A new vector with items in topological order.
80///
81/// # Panics
82/// Panics if a dependency cycle is detected.
83pub fn topological_sort(items: Vec<PlanItem>) -> Vec<PlanItem> {
84    // Track remaining dependencies for each node
85    let mut remaining_deps: HashMap<String, Vec<String>> = HashMap::new();
86    // Track which nodes depend on each node (for cleanup)
87    let mut dependents: HashMap<String, Vec<String>> = HashMap::new();
88    let mut all_nodes: HashSet<String> = HashSet::new();
89
90    // Initialize tracking structures
91    for item in &items {
92        all_nodes.insert(item.component_id.clone());
93        // Each node starts with all its declared dependencies
94        remaining_deps.insert(item.component_id.clone(), item.dependencies.clone());
95        // Track dependents for cleanup
96        for dep in &item.dependencies {
97            dependents
98                .entry(dep.clone())
99                .or_default()
100                .push(item.component_id.clone());
101        }
102    }
103
104    // Kahn's algorithm
105    let mut sorted_nodes = Vec::new();
106
107    // Start with nodes that have no dependencies
108    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        // Find all nodes that depend on this node
118        if let Some(dep_on_node) = dependents.get(&node) {
119            for dependent in dep_on_node.clone() {
120                // Remove this node from dependent's remaining dependencies
121                if let Some(deps) = remaining_deps.get_mut(&dependent) {
122                    deps.retain(|d| d != &node);
123                    // If all dependencies are now satisfied, add to queue
124                    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    // Reconstruct plan items in topological order
137    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// ---------------------------------------------------------------------------
149// Plan
150// ---------------------------------------------------------------------------
151
152/// A full update plan with items and summary.
153#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
154pub struct Plan {
155    /// Plan items in execution order.
156    pub items: Vec<PlanItem>,
157    /// Summary statistics.
158    pub summary: PlanSummary,
159}
160
161/// Summary statistics for a plan.
162#[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    /// Create a new plan from items.
175    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    /// Compute summary from a list of plan items.
184    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, // blocked items are filtered out
205        }
206    }
207}
208
209// ===========================================================================
210// Tests
211// ===========================================================================
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use crate::core::types::{RiskTier, ValidationTier};
217
218    // -----------------------------------------------------------------------
219    // VAL-PLAN-001: Topological sort orders dependencies before dependents
220    // -----------------------------------------------------------------------
221
222    #[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        // Order of independent items is not guaranteed, but all should be present
320        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    // -----------------------------------------------------------------------
326    // VAL-PLAN-002: Topological sort detects dependency cycles
327    // -----------------------------------------------------------------------
328
329    #[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    // -----------------------------------------------------------------------
359    // VAL-PLAN-003: Plan creation applies topological sort
360    // -----------------------------------------------------------------------
361
362    #[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    // -----------------------------------------------------------------------
393    // VAL-PLAN-004: Plan summary counts are accurate
394    // -----------------------------------------------------------------------
395
396    #[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    // -----------------------------------------------------------------------
445    // VAL-PLAN-005: PlanItem serde roundtrip
446    // -----------------------------------------------------------------------
447
448    #[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    // -----------------------------------------------------------------------
468    // VAL-PLAN-006: Plan serde roundtrip
469    // -----------------------------------------------------------------------
470
471    #[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    // -----------------------------------------------------------------------
504    // VAL-PLAN-007: Topological sort with dependencies from baseline manifest
505    // -----------------------------------------------------------------------
506
507    #[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}