use crate::core::manifest::{Manifest, ManifestComponent};
use crate::core::plan::{PlanItem, PlanItemInput};
use crate::core::types::{ExecutorKind, ValidationTier};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum UpdateClassification {
Safe,
Guarded,
Blocked,
Candidate,
Experimental,
}
impl UpdateClassification {
pub fn label(self) -> &'static str {
match self {
UpdateClassification::Safe => "safe",
UpdateClassification::Guarded => "guarded",
UpdateClassification::Blocked => "blocked",
UpdateClassification::Candidate => "candidate",
UpdateClassification::Experimental => "experimental",
}
}
pub fn is_visible(self) -> bool {
!matches!(
self,
UpdateClassification::Blocked | UpdateClassification::Experimental
)
}
pub fn is_preselected(self) -> bool {
matches!(self, UpdateClassification::Safe)
}
}
impl fmt::Display for UpdateClassification {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.label())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PlannerItem {
#[serde(flatten)]
pub plan_item: PlanItem,
pub classification: UpdateClassification,
pub visible: bool,
pub selected: bool,
pub classification_reason: String,
pub requires_hardware_check: bool,
pub min_rocm_version: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct CompatibilityContext {
pub rocm_version: String,
pub rocm_channel: String,
pub available_executors: HashSet<ExecutorKind>,
pub installed_components: HashSet<String>,
pub installed_versions: HashMap<String, String>,
pub gpu_architecture: String,
pub runtime_version: String,
}
impl CompatibilityContext {
pub fn new() -> Self {
Self::default()
}
pub fn is_installed(&self, component_id: &str) -> bool {
self.installed_components.contains(component_id)
}
pub fn installed_version(&self, component_id: &str) -> Option<&str> {
self.installed_versions
.get(component_id)
.map(|s| s.as_str())
}
}
#[derive(Debug, Clone, Default)]
pub struct PlannerOptions {
pub scan_only: bool,
pub all_safe: bool,
pub include_experimental: bool,
pub target_components: Vec<String>,
pub json_output: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub enum PlannerError {
ComponentBlocked {
component_id: String,
reason: String,
},
ComponentUnknown { component_id: String },
RuntimeTooOld { current: String, required: String },
DependencyConflict {
component_id: String,
dependency_id: String,
reason: String,
},
}
impl fmt::Display for PlannerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PlannerError::ComponentBlocked {
component_id,
reason,
} => {
write!(f, "component '{component_id}' is blocked: {reason}")
}
PlannerError::ComponentUnknown { component_id } => {
write!(f, "unknown component: '{component_id}'")
}
PlannerError::RuntimeTooOld { current, required } => {
write!(
f,
"runtime version {current} is too old; requires {required}. Run `rusty upgrade` first."
)
}
PlannerError::DependencyConflict {
component_id,
dependency_id,
reason,
} => {
write!(
f,
"dependency conflict for '{component_id}' on '{dependency_id}': {reason}"
)
}
}
}
}
impl std::error::Error for PlannerError {}
pub struct UpdatePlanner;
impl UpdatePlanner {
pub fn new() -> Self {
Self
}
pub fn build_plan(
&self,
manifest: &Manifest,
context: &CompatibilityContext,
options: &PlannerOptions,
) -> Result<Vec<PlannerItem>, PlannerError> {
self.check_runtime_version(manifest, context)?;
if !options.target_components.is_empty() {
for target in &options.target_components {
if !manifest.components.iter().any(|c| c.id == *target) {
return Err(PlannerError::ComponentUnknown {
component_id: target.clone(),
});
}
}
}
let target_set: HashSet<&str> = options
.target_components
.iter()
.map(|s| s.as_str())
.collect();
let required_deps = self.collect_required_dependencies(manifest, &target_set);
let rocm_available_or_planned = !context.rocm_version.is_empty()
|| target_set.contains("rocm")
|| required_deps.contains("rocm");
let planned_rocm_version = manifest
.components
.iter()
.find(|c| {
c.id == "rocm" && (target_set.contains("rocm") || required_deps.contains("rocm"))
})
.map(|c| c.version.as_str());
let mut items = Vec::new();
for component in &manifest.components {
if !options.target_components.is_empty()
&& !target_set.contains(component.id.as_str())
&& !required_deps.contains(component.id.as_str())
{
continue;
}
let current_version = context.installed_version(&component.id).unwrap_or("");
if is_current_or_newer(current_version, &component.version) {
continue;
}
let classification = self.classify_update(
component,
context,
rocm_available_or_planned,
planned_rocm_version,
);
if classification == UpdateClassification::Blocked {
if target_set.contains(component.id.as_str())
|| required_deps.contains(component.id.as_str())
{
let reason = self.block_reason(
component,
context,
rocm_available_or_planned,
planned_rocm_version,
);
return Err(PlannerError::ComponentBlocked {
component_id: component.id.clone(),
reason,
});
}
items.push(self.build_planner_item(
component,
classification,
context,
rocm_available_or_planned,
planned_rocm_version,
));
continue;
}
if classification == UpdateClassification::Experimental
&& !options.include_experimental
&& !target_set.contains(component.id.as_str())
{
continue;
}
items.push(self.build_planner_item(
component,
classification,
context,
rocm_available_or_planned,
planned_rocm_version,
));
}
self.enforce_exclusive_groups(&mut items, &target_set);
if options.all_safe {
if options.include_experimental {
items.retain(|i| {
if target_set.contains(i.plan_item.component_id.as_str()) {
return true;
}
matches!(
i.classification,
UpdateClassification::Safe | UpdateClassification::Experimental
)
});
} else {
items.retain(|i| {
target_set.contains(i.plan_item.component_id.as_str())
|| i.classification == UpdateClassification::Safe
});
}
for item in &mut items {
item.selected = true;
item.plan_item.selected = true;
}
}
if !target_set.is_empty() {
for item in &mut items {
if target_set.contains(item.plan_item.component_id.as_str()) {
item.selected = true;
item.plan_item.selected = true;
}
}
}
self.enforce_dependency_rules(&mut items)?;
Ok(items)
}
fn classify_update(
&self,
component: &ManifestComponent,
context: &CompatibilityContext,
rocm_available_or_planned: bool,
planned_rocm_version: Option<&str>,
) -> UpdateClassification {
if component.validation_tier == ValidationTier::Experimental {
return UpdateClassification::Experimental;
}
if component.validation_tier == ValidationTier::Blocked {
return UpdateClassification::Blocked;
}
if !self.is_hardware_compatible(component, context) {
return UpdateClassification::Blocked;
}
if !self.is_executor_available(component, context) {
return UpdateClassification::Blocked;
}
if !self.is_rocm_compatible(
component,
context,
rocm_available_or_planned,
planned_rocm_version,
) {
return UpdateClassification::Blocked;
}
let current_version = context.installed_version(&component.id).unwrap_or("");
if current_version.is_empty() {
return UpdateClassification::Candidate;
}
if current_version == component.version {
return UpdateClassification::Safe;
}
match self.version_bump_level(current_version, &component.version) {
BumpLevel::Patch => {
if component.validation_tier == ValidationTier::Validated {
UpdateClassification::Safe
} else {
UpdateClassification::Guarded
}
}
BumpLevel::Minor => UpdateClassification::Guarded,
BumpLevel::Major => UpdateClassification::Candidate,
BumpLevel::Unknown => {
UpdateClassification::Guarded
}
}
}
fn build_planner_item(
&self,
component: &ManifestComponent,
classification: UpdateClassification,
context: &CompatibilityContext,
rocm_available_or_planned: bool,
planned_rocm_version: Option<&str>,
) -> PlannerItem {
let current_version = context
.installed_version(&component.id)
.unwrap_or("")
.to_string();
let visible = classification.is_visible();
let selected = classification.is_preselected() && current_version != component.version;
let isolation_safe = matches!(classification, UpdateClassification::Safe);
let classification_reason = self.classification_reason(
component,
classification,
context,
rocm_available_or_planned,
planned_rocm_version,
);
let dependencies = self.dependencies_for_component(component);
let mut item = PlannerItem {
plan_item: PlanItem::new(PlanItemInput {
component_id: component.id.clone(),
current_version: current_version.clone(),
proposed_version: component.version.clone(),
validation_tier: component.validation_tier,
selected,
rationale: classification_reason.clone(),
dependencies,
isolation_safe,
}),
classification,
visible,
selected,
classification_reason,
requires_hardware_check: self.requires_hardware_check(&component.id),
min_rocm_version: component.min_rocm_version.clone(),
};
item.plan_item.exclusive_group = component.exclusive_group.clone();
item
}
fn is_hardware_compatible(
&self,
component: &ManifestComponent,
context: &CompatibilityContext,
) -> bool {
if !component.compatible_channels.is_empty()
&& self.requires_rocm(&component.id)
&& !component
.compatible_channels
.iter()
.any(|ch| ch.eq_ignore_ascii_case(&context.rocm_channel))
{
return false;
}
true
}
fn is_executor_available(
&self,
_component: &ManifestComponent,
context: &CompatibilityContext,
) -> bool {
context
.available_executors
.contains(&ExecutorKind::LegacyScript)
|| context.available_executors.contains(&ExecutorKind::Rust)
}
fn is_rocm_compatible(
&self,
component: &ManifestComponent,
context: &CompatibilityContext,
rocm_available_or_planned: bool,
planned_rocm_version: Option<&str>,
) -> bool {
if !self.requires_rocm(&component.id) {
return true;
}
if component.id == "rocm" {
return true;
}
if context.rocm_version.is_empty() && !rocm_available_or_planned {
return false;
}
let effective_rocm_version = self.effective_rocm_version(context, planned_rocm_version);
if !component.min_rocm_version.is_empty()
&& !effective_rocm_version.is_empty()
&& !version_gte(&effective_rocm_version, &component.min_rocm_version)
{
return false;
}
if !component.compatible_channels.is_empty()
&& !component
.compatible_channels
.iter()
.any(|ch| ch.eq_ignore_ascii_case(&context.rocm_channel))
{
return false;
}
true
}
fn requires_rocm(&self, component_id: &str) -> bool {
matches!(
component_id,
"rocm"
| "rocm-smi"
| "pytorch"
| "triton"
| "onnx"
| "migraphx"
| "flash-attn"
| "flash-attn-triton"
| "flash-attn-ck"
| "rccl"
| "vllm"
| "aiter"
| "deepspeed"
| "bitsandbytes"
| "llama-cpp"
)
}
fn requires_hardware_check(&self, component_id: &str) -> bool {
self.requires_rocm(component_id)
}
fn block_reason(
&self,
component: &ManifestComponent,
context: &CompatibilityContext,
rocm_available_or_planned: bool,
planned_rocm_version: Option<&str>,
) -> String {
if component.validation_tier == ValidationTier::Blocked {
return "component is marked as blocked in manifest".to_string();
}
if !self.is_hardware_compatible(component, context) {
return format!(
"incompatible with ROCm channel '{}' (requires: {})",
context.rocm_channel,
component.compatible_channels.join(", ")
);
}
if !self.is_executor_available(component, context) {
return "no available executor for this platform".to_string();
}
if !self.is_rocm_compatible(
component,
context,
rocm_available_or_planned,
planned_rocm_version,
) {
if context.rocm_version.is_empty() && !rocm_available_or_planned {
return "requires ROCm but ROCm is not installed".to_string();
}
let effective_rocm_version = self.effective_rocm_version(context, planned_rocm_version);
if !component.min_rocm_version.is_empty() {
if effective_rocm_version.is_empty() {
return format!(
"requires ROCm >= {} and will rely on the planned ROCm install",
component.min_rocm_version
);
}
return format!(
"requires ROCm >= {} but effective plan version is {}",
component.min_rocm_version, effective_rocm_version
);
}
return format!(
"incompatible with effective ROCm version ({})",
effective_rocm_version
);
}
"unknown reason".to_string()
}
fn classification_reason(
&self,
component: &ManifestComponent,
classification: UpdateClassification,
context: &CompatibilityContext,
rocm_available_or_planned: bool,
planned_rocm_version: Option<&str>,
) -> String {
let current = context.installed_version(&component.id).unwrap_or("");
match classification {
UpdateClassification::Safe => {
if current == component.version {
format!("already up to date (v{})", component.version)
} else {
format!(
"patch update {} → {} (validated, no new deps)",
current, component.version
)
}
}
UpdateClassification::Guarded => {
format!(
"minor update {} → {} (new dependencies or config changes)",
current, component.version
)
}
UpdateClassification::Blocked => self.block_reason(
component,
context,
rocm_available_or_planned,
planned_rocm_version,
),
UpdateClassification::Candidate => {
if current.is_empty() {
format!("new install v{}", component.version)
} else {
format!(
"major update {} → {} (compatible)",
current, component.version
)
}
}
UpdateClassification::Experimental => {
format!("experimental component v{}", component.version)
}
}
}
fn dependencies_for_component(&self, component: &ManifestComponent) -> Vec<String> {
if !component.dependencies.is_empty() {
return component.dependencies.clone();
}
self.derive_dependencies(&component.id)
}
fn derive_dependencies(&self, component_id: &str) -> Vec<String> {
match component_id {
"pytorch" => vec!["rocm".to_string()],
"triton" => vec!["pytorch".to_string()],
"flash-attn" | "flash-attn-triton" => {
vec![
"pytorch".to_string(),
"rocm".to_string(),
"aiter".to_string(),
]
}
"flash-attn-ck" => {
vec!["pytorch".to_string(), "rocm".to_string()]
}
"deepspeed" => vec!["pytorch".to_string()],
"vllm" => vec!["pytorch".to_string()],
"megatron" => vec!["pytorch".to_string(), "mpi4py".to_string()],
"onnx" => vec!["rocm".to_string()],
"migraphx" => vec!["rocm".to_string()],
"bitsandbytes" => vec!["pytorch".to_string()],
"aiter" => vec!["pytorch".to_string()],
"mpi4py" => vec![],
"wandb" => vec![],
"comfyui" => vec!["pytorch".to_string()],
"vllm-studio" => vec!["vllm".to_string()],
"textgen" => vec!["pytorch".to_string()],
"rocm" => vec![],
"rocm-smi" => vec!["rocm".to_string()],
"llama-cpp" => vec!["rocm".to_string()],
"permanent-env" => vec![],
_ => vec![],
}
}
fn collect_required_dependencies(
&self,
manifest: &Manifest,
targets: &HashSet<&str>,
) -> HashSet<String> {
let mut required = HashSet::new();
let mut stack: Vec<String> = targets.iter().map(|s| (*s).to_string()).collect();
while let Some(component) = stack.pop() {
let deps = manifest
.components
.iter()
.find(|c| c.id == component)
.map(|c| self.dependencies_for_component(c))
.unwrap_or_else(|| self.derive_dependencies(&component));
for dep in deps {
if required.insert(dep.clone()) {
stack.push(dep);
}
}
}
for target in targets {
required.remove(*target);
}
required
}
fn effective_rocm_version(
&self,
context: &CompatibilityContext,
planned_rocm_version: Option<&str>,
) -> String {
match planned_rocm_version {
Some(planned) if context.rocm_version.is_empty() => planned.to_string(),
Some(planned) if version_gte(planned, &context.rocm_version) => planned.to_string(),
_ => context.rocm_version.clone(),
}
}
fn version_bump_level(&self, current: &str, proposed: &str) -> BumpLevel {
let cur_parts = parse_version_parts(current);
let pro_parts = parse_version_parts(proposed);
match (cur_parts, pro_parts) {
(Some(cur), Some(pro)) if !cur.is_empty() && !pro.is_empty() => {
let len = cur.len().max(pro.len());
let mut cmp = std::cmp::Ordering::Equal;
for idx in 0..len {
let cur_part = *cur.get(idx).unwrap_or(&0);
let pro_part = *pro.get(idx).unwrap_or(&0);
cmp = pro_part.cmp(&cur_part);
if cmp != std::cmp::Ordering::Equal {
break;
}
}
match cmp {
std::cmp::Ordering::Less => BumpLevel::Unknown,
std::cmp::Ordering::Equal => BumpLevel::Patch,
std::cmp::Ordering::Greater => {
if pro.first().copied().unwrap_or(0) > cur.first().copied().unwrap_or(0) {
BumpLevel::Major
} else if pro.get(1).copied().unwrap_or(0)
> cur.get(1).copied().unwrap_or(0)
{
BumpLevel::Minor
} else {
BumpLevel::Patch
}
}
}
}
_ => BumpLevel::Unknown,
}
}
fn check_runtime_version(
&self,
manifest: &Manifest,
context: &CompatibilityContext,
) -> Result<(), PlannerError> {
if manifest.min_runtime_version.is_empty() {
return Ok(());
}
if context.runtime_version.is_empty() {
return Ok(());
}
if !version_gte(&context.runtime_version, &manifest.min_runtime_version) {
return Err(PlannerError::RuntimeTooOld {
current: context.runtime_version.clone(),
required: manifest.min_runtime_version.clone(),
});
}
Ok(())
}
fn enforce_exclusive_groups(&self, items: &mut Vec<PlannerItem>, target_set: &HashSet<&str>) {
use std::collections::HashMap;
let mut group_members: HashMap<String, Vec<String>> = HashMap::new();
for item in items.iter() {
let group = &item.plan_item.exclusive_group;
if !group.is_empty() {
group_members
.entry(group.clone())
.or_default()
.push(item.plan_item.component_id.clone());
}
}
let mut survivors: HashSet<String> = HashSet::new();
for members in group_members.values() {
let chosen = members
.iter()
.find(|id| target_set.contains(id.as_str()))
.or_else(|| members.first()) .cloned();
if let Some(id) = chosen {
survivors.insert(id);
}
}
if survivors.is_empty() {
return;
}
items.retain(|i| {
let group = &i.plan_item.exclusive_group;
group.is_empty() || survivors.contains(&i.plan_item.component_id)
});
}
fn enforce_dependency_rules(&self, items: &mut [PlannerItem]) -> Result<(), PlannerError> {
let mut changed = true;
while changed {
changed = false;
let deps_to_select: Vec<String> = items
.iter()
.filter(|i| i.selected)
.flat_map(|i| i.plan_item.dependencies.clone())
.collect();
for dep_id in &deps_to_select {
if let Some(dep_item) = items
.iter_mut()
.find(|i| i.plan_item.component_id == *dep_id)
{
if !dep_item.selected
&& dep_item.classification != UpdateClassification::Blocked
{
dep_item.selected = true;
changed = true;
}
}
}
}
Ok(())
}
}
impl Default for UpdatePlanner {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BumpLevel {
Patch,
Minor,
Major,
Unknown,
}
fn parse_version_parts(version: &str) -> Option<Vec<u32>> {
let base = version.split('-').next()?;
let mut parts = Vec::new();
for segment in base.split('.') {
match segment.parse::<u32>() {
Ok(n) => parts.push(n),
Err(_) => break,
}
}
if parts.is_empty() {
None
} else {
Some(parts)
}
}
fn version_gte(actual: &str, required: &str) -> bool {
let act = parse_version_parts(actual).unwrap_or_default();
let req = parse_version_parts(required).unwrap_or_default();
let max_len = act.len().max(req.len());
for i in 0..max_len {
let a = act.get(i).copied().unwrap_or(0);
let r = req.get(i).copied().unwrap_or(0);
if a > r {
return true;
}
if a < r {
return false;
}
}
true }
fn is_current_or_newer(current: &str, proposed: &str) -> bool {
if current.trim().is_empty() || proposed.trim().is_empty() {
return false;
}
let current = current.trim().trim_start_matches('v');
let proposed = proposed.trim().trim_start_matches('v');
if let (Ok(current), Ok(proposed)) = (
semver::Version::parse(current),
semver::Version::parse(proposed),
) {
return current >= proposed;
}
let Some(current_parts) = parse_version_parts(current) else {
return false;
};
let Some(proposed_parts) = parse_version_parts(proposed) else {
return false;
};
let max_len = current_parts.len().max(proposed_parts.len());
for index in 0..max_len {
let current_part = current_parts.get(index).copied().unwrap_or(0);
let proposed_part = proposed_parts.get(index).copied().unwrap_or(0);
if current_part != proposed_part {
return current_part > proposed_part;
}
}
true
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ScanOutput {
pub installed: Vec<InstalledComponent>,
pub manifest_source: String,
pub rocm_channel: String,
pub rocm_version: String,
pub gpu_architecture: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct InstalledComponent {
pub id: String,
pub version: String,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PlanOutput {
pub plan: Vec<PlannerItemOutput>,
pub summary: PlanSummary,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PlannerItemOutput {
pub component_id: String,
pub current_version: String,
pub proposed_version: String,
pub classification: String,
pub risk_tier: String,
pub selected: bool,
pub visible: bool,
pub rationale: String,
pub dependencies: Vec<String>,
#[serde(default)]
pub exclusive_group: String,
}
impl From<&PlannerItem> for PlannerItemOutput {
fn from(item: &PlannerItem) -> Self {
Self {
component_id: item.plan_item.component_id.clone(),
current_version: item.plan_item.current_version.clone(),
proposed_version: item.plan_item.proposed_version.clone(),
classification: item.classification.label().to_string(),
risk_tier: item.plan_item.risk_tier.label().to_string(),
selected: item.selected,
visible: item.visible,
rationale: item.plan_item.rationale.clone(),
dependencies: item.plan_item.dependencies.clone(),
exclusive_group: item.plan_item.exclusive_group.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PlanSummary {
pub total: usize,
pub safe: usize,
pub guarded: usize,
pub candidate: usize,
pub experimental: usize,
pub blocked: usize,
pub selected: usize,
}
impl PlanSummary {
pub fn from_items(items: &[PlannerItem]) -> Self {
Self {
total: items.len(),
safe: items
.iter()
.filter(|i| i.classification == UpdateClassification::Safe)
.count(),
guarded: items
.iter()
.filter(|i| i.classification == UpdateClassification::Guarded)
.count(),
candidate: items
.iter()
.filter(|i| i.classification == UpdateClassification::Candidate)
.count(),
experimental: items
.iter()
.filter(|i| i.classification == UpdateClassification::Experimental)
.count(),
blocked: items
.iter()
.filter(|i| i.classification == UpdateClassification::Blocked)
.count(),
selected: items.iter().filter(|i| i.selected).count(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::manifest::{Manifest, ManifestComponent};
use crate::core::types::{Category, ValidationTier};
fn make_component(id: &str, version: &str, tier: ValidationTier) -> ManifestComponent {
ManifestComponent {
id: id.to_string(),
version: version.to_string(),
script: format!("install_{id}.sh"),
category: Category::Core,
validation_tier: tier,
min_rocm_version: String::new(),
compatible_channels: vec![],
dependencies: vec![],
exclusive_group: String::new(),
}
}
fn make_component_exclusive(
id: &str,
version: &str,
tier: ValidationTier,
group: &str,
) -> ManifestComponent {
let mut c = make_component(id, version, tier);
c.exclusive_group = group.to_string();
c
}
fn make_manifest(components: Vec<ManifestComponent>) -> Manifest {
Manifest {
schema_version: 2,
sequence: 1,
generated_at: String::new(),
expires_at: None,
min_runtime_version: String::new(),
components,
signature: None,
}
}
fn make_context() -> CompatibilityContext {
let mut ctx = CompatibilityContext::new();
ctx.rocm_version = "7.2.1".to_string();
ctx.rocm_channel = "latest".to_string();
ctx.gpu_architecture = "gfx1100".to_string();
ctx.available_executors = HashSet::from([
ExecutorKind::LegacyScript,
ExecutorKind::Rust,
ExecutorKind::ExternalPackageManager,
]);
ctx
}
fn planner() -> UpdatePlanner {
UpdatePlanner::new()
}
#[test]
fn test_safe_classification_patch_bump_validated() {
let mut context = make_context();
context
.installed_versions
.insert("pytorch".to_string(), "2.4.0".to_string());
context.installed_components.insert("pytorch".to_string());
let component = make_component("pytorch", "2.4.1", ValidationTier::Validated);
let classification =
planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
assert_eq!(classification, UpdateClassification::Safe);
}
#[test]
fn test_safe_classification_same_version_reinstall() {
let mut context = make_context();
context
.installed_versions
.insert("pytorch".to_string(), "2.4.0".to_string());
context.installed_components.insert("pytorch".to_string());
let component = make_component("pytorch", "2.4.0", ValidationTier::Validated);
let classification =
planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
assert_eq!(classification, UpdateClassification::Safe);
}
#[test]
fn test_build_plan_omits_same_version_component() {
let mut context = make_context();
context
.installed_versions
.insert("rocm".to_string(), "7.2.4".to_string());
context.installed_components.insert("rocm".to_string());
let manifest = make_manifest(vec![make_component(
"rocm",
"7.2.4",
ValidationTier::Validated,
)]);
let items = planner()
.build_plan(&manifest, &context, &PlannerOptions::default())
.unwrap();
assert!(
items.is_empty(),
"equal installed and target versions are a no-op"
);
}
#[test]
fn test_build_plan_omits_lower_target_version() {
let mut context = make_context();
context
.installed_versions
.insert("triton".to_string(), "3.7.1".to_string());
context.installed_components.insert("triton".to_string());
let manifest = make_manifest(vec![make_component(
"triton",
"3.7.0",
ValidationTier::Validated,
)]);
let items = planner()
.build_plan(&manifest, &context, &PlannerOptions::default())
.unwrap();
assert!(items.is_empty(), "planner must never propose a downgrade");
}
#[test]
fn test_build_plan_compares_rocm_build_metadata() {
let mut context = make_context();
context
.installed_versions
.insert("pytorch".to_string(), "2.12.1+rocm7.2".to_string());
context.installed_components.insert("pytorch".to_string());
let manifest = make_manifest(vec![make_component(
"pytorch",
"2.12.1",
ValidationTier::Validated,
)]);
let items = planner()
.build_plan(&manifest, &context, &PlannerOptions::default())
.unwrap();
assert!(
items.is_empty(),
"ROCm build metadata must not trigger reinstall"
);
}
#[test]
fn test_guarded_classification_minor_bump() {
let mut context = make_context();
context
.installed_versions
.insert("pytorch".to_string(), "2.4.0".to_string());
context.installed_components.insert("pytorch".to_string());
let component = make_component("pytorch", "2.5.0", ValidationTier::Validated);
let classification =
planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
assert_eq!(classification, UpdateClassification::Guarded);
}
#[test]
fn test_guarded_classification_patch_bump_candidate_tier() {
let mut context = make_context();
context
.installed_versions
.insert("triton".to_string(), "3.0.0".to_string());
context.installed_components.insert("triton".to_string());
let component = make_component("triton", "3.0.1", ValidationTier::Candidate);
let classification =
planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
assert_eq!(classification, UpdateClassification::Guarded);
}
#[test]
fn test_blocked_classification_missing_executor() {
let mut context = CompatibilityContext::new();
context.rocm_version = "7.2.1".to_string();
let component = make_component("pytorch", "2.4.0", ValidationTier::Validated);
let classification =
planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
assert_eq!(classification, UpdateClassification::Blocked);
}
#[test]
fn test_blocked_classification_blocked_tier() {
let context = make_context();
let component = make_component("bad-pkg", "1.0.0", ValidationTier::Blocked);
let classification =
planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
assert_eq!(classification, UpdateClassification::Blocked);
}
#[test]
fn test_candidate_classification_major_bump() {
let mut context = make_context();
context
.installed_versions
.insert("pytorch".to_string(), "2.4.0".to_string());
context.installed_components.insert("pytorch".to_string());
let component = make_component("pytorch", "3.0.0", ValidationTier::Validated);
let classification =
planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
assert_eq!(classification, UpdateClassification::Candidate);
}
#[test]
fn test_candidate_classification_new_install() {
let context = make_context();
let component = make_component("triton", "3.1.0", ValidationTier::Validated);
let classification =
planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
assert_eq!(classification, UpdateClassification::Candidate);
}
#[test]
fn test_experimental_classification() {
let context = make_context();
let component = make_component("deepspeed", "0.15.0", ValidationTier::Experimental);
let classification =
planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
assert_eq!(classification, UpdateClassification::Experimental);
}
#[test]
fn test_experimental_hidden_without_flag() {
let context = make_context();
let manifest = make_manifest(vec![make_component(
"deepspeed",
"0.15.0",
ValidationTier::Experimental,
)]);
let options = PlannerOptions {
include_experimental: false,
..Default::default()
};
let items = planner().build_plan(&manifest, &context, &options).unwrap();
assert!(
items.is_empty(),
"Experimental items should be hidden without flag"
);
}
#[test]
fn test_experimental_visible_with_flag() {
let context = make_context();
let manifest = make_manifest(vec![make_component(
"deepspeed",
"0.15.0",
ValidationTier::Experimental,
)]);
let options = PlannerOptions {
include_experimental: true,
..Default::default()
};
let items = planner().build_plan(&manifest, &context, &options).unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].classification, UpdateClassification::Experimental);
}
#[test]
fn test_safe_updates_preselected() {
let mut context = make_context();
context
.installed_versions
.insert("pytorch".to_string(), "2.4.0".to_string());
context.installed_components.insert("pytorch".to_string());
let manifest = make_manifest(vec![make_component(
"pytorch",
"2.4.1",
ValidationTier::Validated,
)]);
let options = PlannerOptions::default();
let items = planner().build_plan(&manifest, &context, &options).unwrap();
assert_eq!(items.len(), 1);
assert!(items[0].selected, "Safe updates should be preselected");
}
#[test]
fn test_guarded_updates_not_preselected() {
let mut context = make_context();
context
.installed_versions
.insert("pytorch".to_string(), "2.4.0".to_string());
context.installed_components.insert("pytorch".to_string());
let manifest = make_manifest(vec![make_component(
"pytorch",
"2.5.0",
ValidationTier::Validated,
)]);
let options = PlannerOptions::default();
let items = planner().build_plan(&manifest, &context, &options).unwrap();
assert_eq!(items.len(), 1);
assert!(
!items[0].selected,
"Guarded updates should NOT be preselected"
);
}
#[test]
fn test_candidate_updates_visible_not_preselected() {
let mut context = make_context();
context
.installed_versions
.insert("pytorch".to_string(), "2.4.0".to_string());
context.installed_components.insert("pytorch".to_string());
let manifest = make_manifest(vec![make_component(
"pytorch",
"3.0.0",
ValidationTier::Validated,
)]);
let options = PlannerOptions::default();
let items = planner().build_plan(&manifest, &context, &options).unwrap();
assert_eq!(items.len(), 1);
assert!(items[0].visible, "Candidate updates should be visible");
assert!(
!items[0].selected,
"Candidate updates should NOT be preselected"
);
}
#[test]
fn test_blocked_not_in_default_plan() {
let context = make_context();
let manifest = make_manifest(vec![make_component(
"bad-pkg",
"1.0.0",
ValidationTier::Blocked,
)]);
let options = PlannerOptions::default();
let items = planner().build_plan(&manifest, &context, &options).unwrap();
assert_eq!(items.len(), 1);
assert!(!items[0].visible);
assert!(!items[0].selected);
}
#[test]
fn test_blocked_explicit_target_fails() {
let context = make_context();
let manifest = make_manifest(vec![make_component(
"bad-pkg",
"1.0.0",
ValidationTier::Blocked,
)]);
let options = PlannerOptions {
target_components: vec!["bad-pkg".to_string()],
..Default::default()
};
let result = planner().build_plan(&manifest, &context, &options);
assert!(result.is_err());
match result.unwrap_err() {
PlannerError::ComponentBlocked {
component_id,
reason,
} => {
assert_eq!(component_id, "bad-pkg");
assert!(!reason.is_empty());
}
other => panic!("Expected ComponentBlocked, got: {other:?}"),
}
}
#[test]
fn test_dependency_auto_selected() {
let mut context = make_context();
context
.installed_versions
.insert("rocm".to_string(), "7.2.0".to_string());
context.installed_components.insert("rocm".to_string());
context
.installed_versions
.insert("pytorch".to_string(), "2.4.0".to_string());
context.installed_components.insert("pytorch".to_string());
let manifest = make_manifest(vec![
make_component("rocm", "7.2.1", ValidationTier::Validated), make_component("pytorch", "2.4.1", ValidationTier::Validated), ]);
let options = PlannerOptions::default();
let items = planner().build_plan(&manifest, &context, &options).unwrap();
let rocm_item = items
.iter()
.find(|i| i.plan_item.component_id == "rocm")
.unwrap();
let pytorch_item = items
.iter()
.find(|i| i.plan_item.component_id == "pytorch")
.unwrap();
assert!(rocm_item.selected, "ROCm should be selected (safe)");
assert!(pytorch_item.selected, "PyTorch should be selected (safe)");
}
#[test]
fn test_scan_only_produces_plan() {
let mut context = make_context();
context
.installed_versions
.insert("pytorch".to_string(), "2.4.0".to_string());
context.installed_components.insert("pytorch".to_string());
let manifest = make_manifest(vec![make_component(
"pytorch",
"2.4.1",
ValidationTier::Validated,
)]);
let options = PlannerOptions {
scan_only: true,
..Default::default()
};
let items = planner().build_plan(&manifest, &context, &options).unwrap();
assert_eq!(items.len(), 1);
}
#[test]
fn test_all_safe_filters_to_safe_only() {
let mut context = make_context();
context
.installed_versions
.insert("pytorch".to_string(), "2.4.0".to_string());
context.installed_components.insert("pytorch".to_string());
context
.installed_versions
.insert("triton".to_string(), "3.0.0".to_string());
context.installed_components.insert("triton".to_string());
let manifest = make_manifest(vec![
make_component("pytorch", "2.4.1", ValidationTier::Validated), make_component("triton", "3.1.0", ValidationTier::Validated), ]);
let options = PlannerOptions {
all_safe: true,
..Default::default()
};
let items = planner().build_plan(&manifest, &context, &options).unwrap();
assert_eq!(items.len(), 1, "Only safe items should remain");
assert_eq!(items[0].classification, UpdateClassification::Safe);
assert_eq!(items[0].plan_item.component_id, "pytorch");
}
#[test]
fn test_default_flash_attn_group_survivor_carries_aiter_dependency() {
let mut context = make_context();
for id in &["pytorch", "rocm", "aiter", "triton"] {
context.installed_components.insert((*id).to_string());
context
.installed_versions
.insert((*id).to_string(), "1.0.0".to_string());
}
for id in &["flash-attn", "flash-attn-triton", "flash-attn-ck"] {
context.installed_components.insert((*id).to_string());
context
.installed_versions
.insert((*id).to_string(), "2.8.3".to_string());
}
let manifest = make_manifest(vec![
make_component_exclusive(
"flash-attn",
"2.8.4",
ValidationTier::Validated,
"flash-attn-backend",
),
make_component_exclusive(
"flash-attn-triton",
"2.8.4",
ValidationTier::Validated,
"flash-attn-backend",
),
make_component_exclusive(
"flash-attn-ck",
"2.8.4",
ValidationTier::Validated,
"flash-attn-backend",
),
]);
let options = PlannerOptions::default();
let items = planner().build_plan(&manifest, &context, &options).unwrap();
let fa_items: Vec<_> = items
.iter()
.filter(|i| i.plan_item.exclusive_group == "flash-attn-backend")
.collect();
assert_eq!(fa_items.len(), 1, "exclusive group must keep one backend");
assert_eq!(
fa_items[0].plan_item.component_id, "flash-attn",
"default survivor is the first-listed (legacy) id"
);
assert!(
fa_items[0]
.plan_item
.dependencies
.contains(&"aiter".to_string()),
"legacy flash-attn (normalized to triton) must depend on aiter; got {:?}",
fa_items[0].plan_item.dependencies
);
}
#[test]
fn test_explicit_flash_attn_ck_target_drops_triton_and_uses_ck_closure() {
let mut context = make_context();
for id in &["pytorch", "rocm", "aiter", "triton"] {
context.installed_components.insert((*id).to_string());
context
.installed_versions
.insert((*id).to_string(), "1.0.0".to_string());
}
let manifest = make_manifest(vec![
make_component_exclusive(
"flash-attn",
"2.8.4",
ValidationTier::Validated,
"flash-attn-backend",
),
make_component_exclusive(
"flash-attn-ck",
"2.8.4",
ValidationTier::Validated,
"flash-attn-backend",
),
]);
let options = PlannerOptions {
target_components: vec!["flash-attn-ck".to_string()],
..Default::default()
};
let items = planner().build_plan(&manifest, &context, &options).unwrap();
let ck = items
.iter()
.find(|i| i.plan_item.component_id == "flash-attn-ck")
.expect("explicitly-targeted CK survives");
assert!(
!ck.plan_item.dependencies.contains(&"aiter".to_string()),
"CK backend must NOT depend on aiter; got {:?}",
ck.plan_item.dependencies
);
assert!(items
.iter()
.all(|i| i.plan_item.component_id != "flash-attn"));
}
#[test]
fn test_targeted_candidate_is_selected() {
let mut context = make_context();
context
.installed_versions
.insert("pytorch".to_string(), "2.4.0".to_string());
context.installed_components.insert("pytorch".to_string());
let manifest = make_manifest(vec![make_component(
"pytorch",
"3.0.0",
ValidationTier::Validated,
)]);
let options = PlannerOptions {
target_components: vec!["pytorch".to_string()],
..Default::default()
};
let items = planner().build_plan(&manifest, &context, &options).unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].classification, UpdateClassification::Candidate);
assert!(items[0].selected);
assert!(items[0].plan_item.selected);
}
#[test]
fn test_all_safe_keeps_explicit_targeted_non_safe_component() {
let mut context = make_context();
context
.installed_versions
.insert("pytorch".to_string(), "2.4.0".to_string());
context.installed_components.insert("pytorch".to_string());
context
.installed_versions
.insert("triton".to_string(), "3.0.0".to_string());
context.installed_components.insert("triton".to_string());
let manifest = make_manifest(vec![
make_component("pytorch", "2.4.1", ValidationTier::Validated), make_component("triton", "3.1.0", ValidationTier::Validated), ]);
let options = PlannerOptions {
all_safe: true,
target_components: vec!["triton".to_string()],
..Default::default()
};
let items = planner().build_plan(&manifest, &context, &options).unwrap();
assert_eq!(
items.len(),
2,
"Targeted component and dependencies are in scope"
);
let triton = items
.iter()
.find(|i| i.plan_item.component_id == "triton")
.unwrap();
assert_eq!(triton.classification, UpdateClassification::Guarded);
assert!(triton.selected);
assert!(triton.plan_item.selected);
let pytorch = items
.iter()
.find(|i| i.plan_item.component_id == "pytorch")
.unwrap();
assert_eq!(pytorch.classification, UpdateClassification::Safe);
assert!(pytorch.selected);
}
#[test]
fn test_targeted_blocked_component_fails() {
let context = make_context();
let manifest = make_manifest(vec![
make_component("pytorch", "2.5.0", ValidationTier::Validated),
make_component("bad-pkg", "1.0.0", ValidationTier::Blocked),
]);
let options = PlannerOptions {
target_components: vec!["bad-pkg".to_string()],
..Default::default()
};
let result = planner().build_plan(&manifest, &context, &options);
assert!(result.is_err(), "Targeting a blocked component should fail");
}
#[test]
fn test_targeted_rocm_component_allows_planned_rocm_prereq() {
let context = make_context();
let manifest = make_manifest(vec![
make_component("rocm", "7.2.4", ValidationTier::Validated),
make_component("pytorch", "2.5.0", ValidationTier::Validated),
]);
let options = PlannerOptions {
target_components: vec!["pytorch".to_string()],
..Default::default()
};
let items = planner().build_plan(&manifest, &context, &options).unwrap();
let ids: HashSet<_> = items
.iter()
.map(|i| i.plan_item.component_id.as_str())
.collect();
assert!(ids.contains("rocm"));
assert!(ids.contains("pytorch"));
}
#[test]
fn test_targeted_component_uses_manifest_declared_dependencies() {
let context = make_context();
let mut custom = make_component("custom-runtime", "1.2.0", ValidationTier::Validated);
custom.dependencies = vec!["rocm".to_string(), "mpi4py".to_string()];
let manifest = make_manifest(vec![
make_component("rocm", "7.2.4", ValidationTier::Validated),
make_component("mpi4py", "4.0.0", ValidationTier::Validated),
custom,
]);
let options = PlannerOptions {
target_components: vec!["custom-runtime".to_string()],
..Default::default()
};
let items = planner().build_plan(&manifest, &context, &options).unwrap();
let ids: HashSet<_> = items
.iter()
.map(|i| i.plan_item.component_id.as_str())
.collect();
assert!(ids.contains("custom-runtime"));
assert!(ids.contains("rocm"));
assert!(ids.contains("mpi4py"));
}
#[test]
fn test_targeted_component_with_planned_rocm_not_blocked_by_min_version_on_fresh_host() {
let context = make_context();
let manifest = make_manifest(vec![
make_component("rocm", "7.2.4", ValidationTier::Validated),
ManifestComponent {
min_rocm_version: "7.2.4".to_string(),
..make_component("llama-cpp", "0.2.0", ValidationTier::Validated)
},
]);
let options = PlannerOptions {
target_components: vec!["llama-cpp".to_string()],
..Default::default()
};
let items = planner().build_plan(&manifest, &context, &options).unwrap();
let ids: HashSet<_> = items
.iter()
.map(|i| i.plan_item.component_id.as_str())
.collect();
assert!(ids.contains("rocm"));
assert!(ids.contains("llama-cpp"));
}
#[test]
fn test_targeted_unknown_component_fails() {
let context = make_context();
let manifest = make_manifest(vec![make_component(
"pytorch",
"2.5.0",
ValidationTier::Validated,
)]);
let options = PlannerOptions {
target_components: vec!["nonexistent".to_string()],
..Default::default()
};
let result = planner().build_plan(&manifest, &context, &options);
assert!(
result.is_err(),
"Targeting a nonexistent component should fail"
);
}
#[test]
fn test_planner_works_with_baseline_manifest() {
let context = make_context();
let manifest = make_manifest(vec![make_component(
"pytorch",
"2.4.0",
ValidationTier::Validated,
)]);
let options = PlannerOptions::default();
let items = planner().build_plan(&manifest, &context, &options).unwrap();
assert!(!items.is_empty());
}
#[test]
fn test_runtime_too_old_error() {
let error = PlannerError::RuntimeTooOld {
current: "0.1.0".to_string(),
required: "0.2.0".to_string(),
};
let msg = error.to_string();
assert!(msg.contains("0.1.0"));
assert!(msg.contains("0.2.0"));
assert!(msg.contains("rusty upgrade"));
}
#[test]
fn test_rocm_channel_in_context() {
let mut context = make_context();
context.rocm_channel = "legacy".to_string();
let manifest = make_manifest(vec![make_component(
"pytorch",
"2.4.0",
ValidationTier::Validated,
)]);
let options = PlannerOptions::default();
let items = planner().build_plan(&manifest, &context, &options).unwrap();
assert!(!items.is_empty());
}
#[test]
fn test_classification_serde_roundtrip() {
let classifications = [
UpdateClassification::Safe,
UpdateClassification::Guarded,
UpdateClassification::Blocked,
UpdateClassification::Candidate,
UpdateClassification::Experimental,
];
for c in &classifications {
let json = serde_json::to_string(c).unwrap();
let back: UpdateClassification = serde_json::from_str(&json).unwrap();
assert_eq!(*c, back);
}
}
#[test]
fn test_plan_summary_from_items() {
let mut context = make_context();
context
.installed_versions
.insert("pytorch".to_string(), "2.4.0".to_string());
context.installed_components.insert("pytorch".to_string());
context
.installed_versions
.insert("triton".to_string(), "3.0.0".to_string());
context.installed_components.insert("triton".to_string());
let manifest = make_manifest(vec![
make_component("pytorch", "2.4.1", ValidationTier::Validated), make_component("triton", "3.1.0", ValidationTier::Validated), make_component("new-pkg", "1.0.0", ValidationTier::Validated), make_component("bad-pkg", "1.0.0", ValidationTier::Blocked), ]);
let options = PlannerOptions::default();
let items = planner().build_plan(&manifest, &context, &options).unwrap();
let summary = PlanSummary::from_items(&items);
assert_eq!(summary.total, 4);
assert_eq!(summary.safe, 1);
assert_eq!(summary.guarded, 1);
assert_eq!(summary.candidate, 1);
assert_eq!(summary.blocked, 1);
assert_eq!(summary.selected, 1); }
#[test]
fn test_planner_item_output_conversion() {
let mut context = make_context();
context
.installed_versions
.insert("pytorch".to_string(), "2.4.0".to_string());
context.installed_components.insert("pytorch".to_string());
let manifest = make_manifest(vec![make_component(
"pytorch",
"2.4.1",
ValidationTier::Validated,
)]);
let options = PlannerOptions::default();
let items = planner().build_plan(&manifest, &context, &options).unwrap();
let output: Vec<PlannerItemOutput> = items.iter().map(PlannerItemOutput::from).collect();
assert_eq!(output.len(), 1);
assert_eq!(output[0].component_id, "pytorch");
assert_eq!(output[0].classification, "safe");
assert!(output[0].selected);
}
#[test]
fn test_full_plan_build_mixed_classifications() {
let mut context = make_context();
context
.installed_versions
.insert("rocm".to_string(), "7.2.0".to_string());
context.installed_components.insert("rocm".to_string());
context
.installed_versions
.insert("pytorch".to_string(), "2.4.0".to_string());
context.installed_components.insert("pytorch".to_string());
context
.installed_versions
.insert("triton".to_string(), "3.0.0".to_string());
context.installed_components.insert("triton".to_string());
let manifest = make_manifest(vec![
make_component("rocm", "7.2.1", ValidationTier::Validated), make_component("pytorch", "2.5.0", ValidationTier::Validated), make_component("triton", "4.0.0", ValidationTier::Validated), make_component("wandb", "0.17.0", ValidationTier::Validated), make_component("deepspeed", "0.15.0", ValidationTier::Experimental), ]);
let options = PlannerOptions {
include_experimental: true,
..Default::default()
};
let items = planner().build_plan(&manifest, &context, &options).unwrap();
assert_eq!(items.len(), 5);
let rocm = items
.iter()
.find(|i| i.plan_item.component_id == "rocm")
.unwrap();
assert_eq!(rocm.classification, UpdateClassification::Safe);
assert!(rocm.selected);
let pytorch = items
.iter()
.find(|i| i.plan_item.component_id == "pytorch")
.unwrap();
assert_eq!(pytorch.classification, UpdateClassification::Guarded);
assert!(!pytorch.selected);
let triton = items
.iter()
.find(|i| i.plan_item.component_id == "triton")
.unwrap();
assert_eq!(triton.classification, UpdateClassification::Candidate);
assert!(!triton.selected);
let wandb = items
.iter()
.find(|i| i.plan_item.component_id == "wandb")
.unwrap();
assert_eq!(wandb.classification, UpdateClassification::Candidate);
assert!(!wandb.selected);
let deepspeed = items
.iter()
.find(|i| i.plan_item.component_id == "deepspeed")
.unwrap();
assert_eq!(deepspeed.classification, UpdateClassification::Experimental);
assert!(!deepspeed.selected);
}
#[test]
fn test_compatibility_context_default() {
let ctx = CompatibilityContext::default();
assert!(ctx.rocm_version.is_empty());
assert!(ctx.rocm_channel.is_empty());
assert!(ctx.available_executors.is_empty());
assert!(ctx.installed_components.is_empty());
assert!(ctx.installed_versions.is_empty());
assert!(ctx.gpu_architecture.is_empty());
assert!(ctx.runtime_version.is_empty());
}
#[test]
fn test_compatibility_context_is_installed() {
let mut ctx = CompatibilityContext::new();
assert!(!ctx.is_installed("pytorch"));
ctx.installed_components.insert("pytorch".to_string());
assert!(ctx.is_installed("pytorch"));
}
#[test]
fn test_compatibility_context_installed_version() {
let mut ctx = CompatibilityContext::new();
assert!(ctx.installed_version("pytorch").is_none());
ctx.installed_versions
.insert("pytorch".to_string(), "2.4.0".to_string());
assert_eq!(ctx.installed_version("pytorch"), Some("2.4.0"));
}
#[test]
fn test_planner_error_display() {
let err = PlannerError::ComponentBlocked {
component_id: "bad-pkg".to_string(),
reason: "incompatible hardware".to_string(),
};
assert!(err.to_string().contains("bad-pkg"));
assert!(err.to_string().contains("incompatible hardware"));
let err = PlannerError::ComponentUnknown {
component_id: "nonexistent".to_string(),
};
assert!(err.to_string().contains("nonexistent"));
let err = PlannerError::RuntimeTooOld {
current: "0.1.0".to_string(),
required: "0.2.0".to_string(),
};
assert!(err.to_string().contains("0.1.0"));
assert!(err.to_string().contains("rusty upgrade"));
}
#[test]
fn test_planner_error_is_std_error() {
let err = PlannerError::ComponentUnknown {
component_id: "test".to_string(),
};
let _: &dyn std::error::Error = &err;
}
#[test]
fn test_scan_output_serde_roundtrip() {
let output = ScanOutput {
installed: vec![InstalledComponent {
id: "pytorch".to_string(),
version: "2.4.0".to_string(),
status: "installed".to_string(),
}],
manifest_source: "baseline".to_string(),
rocm_channel: "latest".to_string(),
rocm_version: "7.2.1".to_string(),
gpu_architecture: "gfx1100".to_string(),
};
let json = serde_json::to_string(&output).unwrap();
let back: ScanOutput = serde_json::from_str(&json).unwrap();
assert_eq!(output, back);
}
#[test]
fn test_plan_output_serde_roundtrip() {
let output = PlanOutput {
plan: vec![PlannerItemOutput {
component_id: "pytorch".to_string(),
current_version: "2.4.0".to_string(),
proposed_version: "2.4.1".to_string(),
classification: "safe".to_string(),
risk_tier: "low".to_string(),
selected: true,
visible: true,
rationale: "patch update".to_string(),
dependencies: vec!["rocm".to_string()],
exclusive_group: String::new(),
}],
summary: PlanSummary {
total: 1,
safe: 1,
guarded: 0,
candidate: 0,
experimental: 0,
blocked: 0,
selected: 1,
},
};
let json = serde_json::to_string(&output).unwrap();
let back: PlanOutput = serde_json::from_str(&json).unwrap();
assert_eq!(output, back);
}
#[test]
fn test_classification_visibility() {
assert!(UpdateClassification::Safe.is_visible());
assert!(UpdateClassification::Guarded.is_visible());
assert!(!UpdateClassification::Blocked.is_visible());
assert!(UpdateClassification::Candidate.is_visible());
assert!(!UpdateClassification::Experimental.is_visible());
}
#[test]
fn test_classification_preselection() {
assert!(UpdateClassification::Safe.is_preselected());
assert!(!UpdateClassification::Guarded.is_preselected());
assert!(!UpdateClassification::Blocked.is_preselected());
assert!(!UpdateClassification::Candidate.is_preselected());
assert!(!UpdateClassification::Experimental.is_preselected());
}
#[test]
fn test_classification_labels() {
assert_eq!(UpdateClassification::Safe.label(), "safe");
assert_eq!(UpdateClassification::Guarded.label(), "guarded");
assert_eq!(UpdateClassification::Blocked.label(), "blocked");
assert_eq!(UpdateClassification::Candidate.label(), "candidate");
assert_eq!(UpdateClassification::Experimental.label(), "experimental");
}
#[test]
fn test_derive_dependencies_pytorch() {
let deps = planner().derive_dependencies("pytorch");
assert!(deps.contains(&"rocm".to_string()));
}
#[test]
fn test_derive_dependencies_triton() {
let deps = planner().derive_dependencies("triton");
assert!(deps.contains(&"pytorch".to_string()));
}
#[test]
fn test_derive_dependencies_rocm() {
let deps = planner().derive_dependencies("rocm");
assert!(deps.is_empty());
}
#[test]
fn test_derive_dependencies_unknown() {
let deps = planner().derive_dependencies("unknown-component");
assert!(deps.is_empty());
}
#[test]
fn test_version_bump_patch() {
assert_eq!(
planner().version_bump_level("1.0.0", "1.0.1"),
BumpLevel::Patch
);
}
#[test]
fn test_version_bump_minor() {
assert_eq!(
planner().version_bump_level("1.0.0", "1.1.0"),
BumpLevel::Minor
);
}
#[test]
fn test_version_bump_major() {
assert_eq!(
planner().version_bump_level("1.0.0", "2.0.0"),
BumpLevel::Major
);
}
#[test]
fn test_version_bump_unknown() {
assert_eq!(
planner().version_bump_level("abc", "def"),
BumpLevel::Unknown
);
}
#[test]
fn test_version_bump_downgrade_is_unknown() {
assert_eq!(
planner().version_bump_level("7.3.0", "7.2.4"),
BumpLevel::Unknown
);
assert_eq!(
planner().version_bump_level("2.5.1", "2.5.0"),
BumpLevel::Unknown
);
}
#[test]
fn test_version_gte_equal() {
assert!(version_gte("7.2.1", "7.2.1"));
}
#[test]
fn test_version_gte_greater() {
assert!(version_gte("7.3.0", "7.2.1"));
assert!(version_gte("8.0.0", "7.2.1"));
assert!(version_gte("7.2.2", "7.2.1"));
}
#[test]
fn test_version_gte_less() {
assert!(!version_gte("7.2.0", "7.2.1"));
assert!(!version_gte("6.0.0", "7.0.0"));
}
#[test]
fn test_version_gte_different_lengths() {
assert!(version_gte("7.2", "7.2.0"));
assert!(version_gte("7.2.1", "7.2"));
assert!(!version_gte("7.1", "7.2.0"));
}
#[test]
fn test_is_current_or_newer_handles_semver_and_opaque_targets() {
assert!(is_current_or_newer("1.27.1", "1.27.1"));
assert!(is_current_or_newer("1.28.0", "1.27.1"));
assert!(is_current_or_newer("2.12.1+rocm7.2", "2.12.1"));
assert!(!is_current_or_newer("1.23.2", "1.27.1"));
assert!(!is_current_or_newer("v0.20.1", "latest"));
assert!(is_current_or_newer("0.1.19.dev40+g6c48c5fa0", "0.1.0"));
}
#[test]
fn test_parse_version_parts_truncates_dev_suffix() {
assert_eq!(
parse_version_parts("0.1.19.dev40+g6c48c5fa0"),
Some(vec![0, 1, 19])
);
assert_eq!(parse_version_parts("7.2.1"), Some(vec![7, 2, 1]));
assert_eq!(parse_version_parts("7.2"), Some(vec![7, 2]));
assert_eq!(parse_version_parts("latest"), None);
}
#[test]
fn test_blocked_by_rocm_version_requirement() {
let context = make_context();
let component = ManifestComponent {
id: "pytorch".to_string(),
version: "3.0.0".to_string(),
script: "install_pytorch.sh".to_string(),
category: crate::core::types::Category::Core,
validation_tier: ValidationTier::Validated,
min_rocm_version: "99.0.0".to_string(), compatible_channels: vec![],
dependencies: vec![],
exclusive_group: String::new(),
};
let classification =
planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
assert_eq!(classification, UpdateClassification::Blocked);
}
#[test]
fn test_not_blocked_by_satisfied_rocm_requirement() {
let context = make_context();
let component = ManifestComponent {
id: "pytorch".to_string(),
version: "3.0.0".to_string(),
script: "install_pytorch.sh".to_string(),
category: crate::core::types::Category::Core,
validation_tier: ValidationTier::Validated,
min_rocm_version: "7.0.0".to_string(),
compatible_channels: vec![],
dependencies: vec![],
exclusive_group: String::new(),
};
let classification =
planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
assert_ne!(classification, UpdateClassification::Blocked);
}
#[test]
fn test_blocked_by_incompatible_channel() {
let mut context = make_context();
context.rocm_channel = "legacy".to_string();
let component = ManifestComponent {
id: "pytorch".to_string(),
version: "3.0.0".to_string(),
script: "install_pytorch.sh".to_string(),
category: crate::core::types::Category::Core,
validation_tier: ValidationTier::Validated,
min_rocm_version: String::new(),
compatible_channels: vec!["latest".to_string(), "stable".to_string()],
dependencies: vec![],
exclusive_group: String::new(),
};
let classification =
planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
assert_eq!(classification, UpdateClassification::Blocked);
}
#[test]
fn test_allowed_by_compatible_channel() {
let mut context = make_context();
context.rocm_channel = "latest".to_string();
let component = ManifestComponent {
id: "pytorch".to_string(),
version: "3.0.0".to_string(),
script: "install_pytorch.sh".to_string(),
category: crate::core::types::Category::Core,
validation_tier: ValidationTier::Validated,
min_rocm_version: String::new(),
compatible_channels: vec!["latest".to_string(), "stable".to_string()],
dependencies: vec![],
exclusive_group: String::new(),
};
let classification =
planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
assert_ne!(classification, UpdateClassification::Blocked);
}
#[test]
fn test_channel_filter_legacy_excludes_latest_only() {
let mut context = make_context();
context.rocm_channel = "legacy".to_string();
let manifest = make_manifest(vec![ManifestComponent {
id: "pytorch".to_string(),
version: "3.0.0".to_string(),
script: "install_pytorch.sh".to_string(),
category: crate::core::types::Category::Core,
validation_tier: ValidationTier::Validated,
min_rocm_version: String::new(),
compatible_channels: vec!["latest".to_string()],
dependencies: vec![],
exclusive_group: String::new(),
}]);
let options = PlannerOptions::default();
let items = planner().build_plan(&manifest, &context, &options).unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].classification, UpdateClassification::Blocked);
assert!(!items[0].visible);
}
#[test]
fn test_runtime_too_old_blocks_plan() {
let context = make_context();
let mut manifest = make_manifest(vec![make_component(
"pytorch",
"3.0.0",
ValidationTier::Validated,
)]);
manifest.min_runtime_version = "99.0.0".to_string();
let mut ctx = context;
ctx.runtime_version = "0.1.0".to_string();
let options = PlannerOptions::default();
let result = planner().build_plan(&manifest, &ctx, &options);
assert!(result.is_err());
match result.unwrap_err() {
PlannerError::RuntimeTooOld { current, required } => {
assert_eq!(current, "0.1.0");
assert_eq!(required, "99.0.0");
}
other => panic!("Expected RuntimeTooOld, got: {other:?}"),
}
}
#[test]
fn test_runtime_version_satisfied() {
let mut context = make_context();
context.runtime_version = "1.0.0".to_string();
let mut manifest = make_manifest(vec![make_component(
"pytorch",
"3.0.0",
ValidationTier::Validated,
)]);
manifest.min_runtime_version = "0.5.0".to_string();
let options = PlannerOptions::default();
let result = planner().build_plan(&manifest, &context, &options);
assert!(result.is_ok());
}
#[test]
fn test_blocked_when_rocm_not_installed_but_required() {
let mut context = CompatibilityContext::new();
context.rocm_version = String::new(); context.available_executors =
HashSet::from([ExecutorKind::LegacyScript, ExecutorKind::Rust]);
let component = make_component("pytorch", "3.0.0", ValidationTier::Validated);
let classification =
planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
assert_eq!(classification, UpdateClassification::Blocked);
}
#[test]
fn test_rocm_component_allowed_when_rocm_not_installed() {
let mut context = CompatibilityContext::new();
context.rocm_version = String::new();
context.available_executors =
HashSet::from([ExecutorKind::LegacyScript, ExecutorKind::Rust]);
let component = make_component("rocm", "7.2.4", ValidationTier::Validated);
let classification =
planner().classify_update(&component, &context, !context.rocm_version.is_empty(), None);
assert_ne!(classification, UpdateClassification::Blocked);
}
#[test]
fn test_isolation_safe_tracks_classification() {
let mut context = make_context();
context
.installed_versions
.insert("pytorch".to_string(), "2.4.0".to_string());
context.installed_components.insert("pytorch".to_string());
context
.installed_versions
.insert("triton".to_string(), "3.0.0".to_string());
context.installed_components.insert("triton".to_string());
let manifest = make_manifest(vec![
make_component("pytorch", "2.4.1", ValidationTier::Validated), make_component("triton", "3.1.0", ValidationTier::Validated), ]);
let items = planner()
.build_plan(&manifest, &context, &PlannerOptions::default())
.unwrap();
let pytorch = items
.iter()
.find(|i| i.plan_item.component_id == "pytorch")
.unwrap();
let triton = items
.iter()
.find(|i| i.plan_item.component_id == "triton")
.unwrap();
assert!(pytorch.plan_item.isolation_safe);
assert!(!triton.plan_item.isolation_safe);
}
#[test]
fn test_block_reason_includes_rocm_version() {
let context = make_context();
let component = ManifestComponent {
id: "pytorch".to_string(),
version: "3.0.0".to_string(),
script: "install_pytorch.sh".to_string(),
category: crate::core::types::Category::Core,
validation_tier: ValidationTier::Validated,
min_rocm_version: "99.0.0".to_string(),
compatible_channels: vec![],
dependencies: vec![],
exclusive_group: String::new(),
};
let reason =
planner().block_reason(&component, &context, !context.rocm_version.is_empty(), None);
assert!(reason.contains("99.0.0"));
assert!(reason.contains("7.2.1"));
}
#[test]
fn test_block_reason_channel_incompatibility() {
let mut context = make_context();
context.rocm_channel = "legacy".to_string();
let component = ManifestComponent {
id: "pytorch".to_string(),
version: "3.0.0".to_string(),
script: "install_pytorch.sh".to_string(),
category: crate::core::types::Category::Core,
validation_tier: ValidationTier::Validated,
min_rocm_version: String::new(),
compatible_channels: vec!["latest".to_string()],
dependencies: vec![],
exclusive_group: String::new(),
};
let reason =
planner().block_reason(&component, &context, !context.rocm_version.is_empty(), None);
assert!(reason.contains("legacy"));
}
}