From 41fbcfc476c7bee119c04236344c5c8e6b94a114 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 3 Nov 2026 12:50:07 +0000 Subject: [PATCH 1/4] test(stack): expect one runtime version per toolchain A foss-2026.1 fixture pins Eigen 3.4.1 from LAMMPS and Eigen 5.0.0 from SciPy-bundle. The `stack order` command still writes both builds, which `stack solve` rejects. --- .../easyconfigs/Eigen-3.4.1-foss-2026.1.eb | 5 ++ .../easyconfigs/Eigen-5.0.0-foss-2026.1.eb | 5 ++ .../easyconfigs/LAMMPS-2025.07-foss-2026.1.eb | 8 ++++ .../SciPy-bundle-2026.06-foss-2026.1.eb | 8 ++++ src/build_order.rs | 37 +++++++++++++++ tests/stack_order_version.rs | 47 +++++++++++++++++++ 6 files changed, 110 insertions(+) create mode 100644 fixtures/stack_order_version_conflict/easyconfigs/Eigen-3.4.1-foss-2026.1.eb create mode 100644 fixtures/stack_order_version_conflict/easyconfigs/Eigen-5.0.0-foss-2026.1.eb create mode 100644 fixtures/stack_order_version_conflict/easyconfigs/LAMMPS-2025.07-foss-2026.1.eb create mode 100644 fixtures/stack_order_version_conflict/easyconfigs/SciPy-bundle-2026.06-foss-2026.1.eb create mode 100644 tests/stack_order_version.rs diff --git a/fixtures/stack_order_version_conflict/easyconfigs/Eigen-3.4.1-foss-2026.1.eb b/fixtures/stack_order_version_conflict/easyconfigs/Eigen-3.4.1-foss-2026.1.eb new file mode 100644 index 00000000..3206b265 --- /dev/null +++ b/fixtures/stack_order_version_conflict/easyconfigs/Eigen-3.4.1-foss-2026.1.eb @@ -0,0 +1,5 @@ +name = 'Eigen' +version = '3.4.1' +homepage = 'https://eigen.tuxfamily.org' +description = "Fixture for two runtime pins of Eigen at one toolchain." +toolchain = {'name': 'foss', 'version': '2026.1'} diff --git a/fixtures/stack_order_version_conflict/easyconfigs/Eigen-5.0.0-foss-2026.1.eb b/fixtures/stack_order_version_conflict/easyconfigs/Eigen-5.0.0-foss-2026.1.eb new file mode 100644 index 00000000..cace9a9e --- /dev/null +++ b/fixtures/stack_order_version_conflict/easyconfigs/Eigen-5.0.0-foss-2026.1.eb @@ -0,0 +1,5 @@ +name = 'Eigen' +version = '5.0.0' +homepage = 'https://eigen.tuxfamily.org' +description = "Fixture for two runtime pins of Eigen at one toolchain." +toolchain = {'name': 'foss', 'version': '2026.1'} diff --git a/fixtures/stack_order_version_conflict/easyconfigs/LAMMPS-2025.07-foss-2026.1.eb b/fixtures/stack_order_version_conflict/easyconfigs/LAMMPS-2025.07-foss-2026.1.eb new file mode 100644 index 00000000..acf471af --- /dev/null +++ b/fixtures/stack_order_version_conflict/easyconfigs/LAMMPS-2025.07-foss-2026.1.eb @@ -0,0 +1,8 @@ +name = 'LAMMPS' +version = '2025.07' +homepage = 'https://www.lammps.org' +description = "Fixture: runtime dependency on Eigen 3.4.1." +toolchain = {'name': 'foss', 'version': '2026.1'} +dependencies = [ + ('Eigen', '3.4.1'), +] diff --git a/fixtures/stack_order_version_conflict/easyconfigs/SciPy-bundle-2026.06-foss-2026.1.eb b/fixtures/stack_order_version_conflict/easyconfigs/SciPy-bundle-2026.06-foss-2026.1.eb new file mode 100644 index 00000000..16b92c1b --- /dev/null +++ b/fixtures/stack_order_version_conflict/easyconfigs/SciPy-bundle-2026.06-foss-2026.1.eb @@ -0,0 +1,8 @@ +name = 'SciPy-bundle' +version = '2026.06' +homepage = 'https://scipy.org' +description = "Fixture: runtime dependency on Eigen 5.0.0." +toolchain = {'name': 'foss', 'version': '2026.1'} +dependencies = [ + ('Eigen', '5.0.0'), +] diff --git a/src/build_order.rs b/src/build_order.rs index e0e552e1..37d748ed 100644 --- a/src/build_order.rs +++ b/src/build_order.rs @@ -1347,6 +1347,43 @@ mod tests { assert!(at("Lib") < at("App"), "{seq:?}"); } + /// Eigen 3.4.1 and 5.0.0 are the runtime pair in + /// `a_runtime_dependency_still_takes_one_version_per_stack`. LAMMPS and + /// SciPy-bundle are the two requesters. One toolchain, two exact pins, + /// no version satisfies both. + #[test] + fn two_runtime_versions_of_one_dependency_are_refused() { + let foss = tc("foss", "2026.1"); + let all = vec![ + candidate("Eigen", "3.4.1", foss.clone(), vec![]), + candidate("Eigen", "5.0.0", foss.clone(), vec![]), + candidate( + "LAMMPS", + "2025.07", + foss.clone(), + vec![dep("Eigen", "==3.4.1", None)], + ), + candidate( + "SciPy-bundle", + "2026.06", + foss, + vec![dep("Eigen", "==5.0.0", None)], + ), + ]; + let err = build_order( + &all, + &["LAMMPS".into(), "SciPy-bundle".into()], + Choice::Newest, + ) + .expect_err("two Eigen versions must not be ordered"); + let shown = err.to_string(); + assert!(shown.contains("Eigen"), "{shown}"); + assert!(shown.contains("3.4.1"), "{shown}"); + assert!(shown.contains("5.0.0"), "{shown}"); + assert!(shown.contains("LAMMPS"), "{shown}"); + assert!(shown.contains("SciPy-bundle"), "{shown}"); + } + /// The case a stack solve cannot express: two builds of one name, both /// required, both installed side by side as different modules. #[test] diff --git a/tests/stack_order_version.rs b/tests/stack_order_version.rs new file mode 100644 index 00000000..e4a7d3ff --- /dev/null +++ b/tests/stack_order_version.rs @@ -0,0 +1,47 @@ +//! Two runtime pins of one dependency at one toolchain. +//! +//! Eigen 3.4.1 and Eigen 5.0.0 are the versions in +//! `a_runtime_dependency_still_takes_one_version_per_stack`: a runtime +//! dependency has to agree on one version, so `stack solve` rejects the +//! closure. The names are the Snellius 2026 site-layer pair, LAMMPS and +//! SciPy-bundle. `stack order` must refuse that closure and name both +//! requesters, rather than writing an order `stack solve` cannot install. + +use std::path::PathBuf; +use std::process::Command; + +fn easyconfigs() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("fixtures/stack_order_version_conflict/easyconfigs") +} + +#[test] +fn two_runtime_versions_in_one_toolchain_are_refused() { + let tmp = tempfile::tempdir().expect("tempdir"); + let out = tmp.path().join("order.txt"); + let status = Command::new(env!("CARGO_BIN_EXE_eb-stack")) + .args(["stack", "order", "--easyconfigs"]) + .arg(easyconfigs()) + .args(["--root", "LAMMPS", "--root", "SciPy-bundle", "--out"]) + .arg(&out) + .env_remove("EASYBUILD_FILTER_DEPS") + .output() + .expect("run stack order"); + let stderr = String::from_utf8_lossy(&status.stderr); + assert!( + !status.status.success(), + "two Eigen versions were written into the order:\nstdout:\n{}\nstderr:\n{stderr}", + String::from_utf8_lossy(&status.stdout) + ); + assert_eq!(status.status.code(), Some(1), "{stderr}"); + assert!(stderr.contains("Eigen"), "{stderr}"); + assert!(stderr.contains("3.4.1"), "{stderr}"); + assert!(stderr.contains("5.0.0"), "{stderr}"); + assert!(stderr.contains("LAMMPS"), "{stderr}"); + assert!(stderr.contains("SciPy-bundle"), "{stderr}"); + assert!( + !out.exists(), + "stack order wrote {} after refusing the closure", + out.display() + ); +} From 634456e0ff2af87b7de60bee42f82018756efee4 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 3 Nov 2026 12:54:48 +0000 Subject: [PATCH 2/4] fix(stack): refuse two runtime versions in one toolchain The `stack order` command kept Eigen 3.4.1 for LAMMPS and Eigen 5.0.0 for SciPy-bundle at foss-2026.1. The `stack solve` command rejects that closure because a runtime dependency has one version per toolchain. The order now fails and names both requesters. Side-by-side builds stay: another toolchain, a system bootstrap pair, or a build-only tool. A recipe is not repinned. --- src/build_order.rs | 344 +++++++++++++++++++++++++++++++++++++++- src/main.rs | 9 +- src/resolvo_provider.rs | 188 ++++++++++++++-------- 3 files changed, 464 insertions(+), 77 deletions(-) diff --git a/src/build_order.rs b/src/build_order.rs index 37d748ed..b0ef34cd 100644 --- a/src/build_order.rs +++ b/src/build_order.rs @@ -12,10 +12,22 @@ //! So a node here is a whole module, name and version and toolchain and //! versionsuffix together, the way a functional deployment model keys a //! package by its full identity and lets several coexist -//! (doi:10.1017/s0956796810000195). Two versions of binutils, or Perl at -//! GCCcore and at SYSTEM, are simply two nodes. Nothing has to be reconciled, -//! because EasyBuild installs them side by side as different modules, which is -//! what makes them co-installable in the sense of doi:10.1145/2522920.2522927. +//! (doi:10.1017/s0956796810000195). Two versions of binutils on the system +//! toolchain, or Perl at GCCcore and at SYSTEM, are two nodes. The package +//! key in [`crate::resolvo_provider`] gives those the same distinct names +//! `stack solve` uses, because EasyBuild installs them side by side +//! (doi:10.1145/2522920.2522927). A build-only tool such as CMake is the same +//! kind of node: EasyBuild loads it only while the recipe that names it builds. +//! +//! A runtime dependency does not coexist that way. `stack solve` keeps one +//! version of that name at one toolchain, and it rejects a closure where no +//! candidate satisfies every such requirement. Eigen 3.4.1 required by one +//! recipe and Eigen 5.0.0 required by another is that closure. [`build_order`] +//! refuses it before an order is written, and the error names each requester +//! and the version it resolved. The order does not repin a recipe. Choosing +//! which pin to keep would be the policy decision this module leaves to the +//! solver, and an exact pin the other recipe does not accept has no version +//! the solver can keep. //! //! What remains is a choice function for requirements that admit more than one //! candidate, and a topological sort. Both are deterministic, so the same tree @@ -188,6 +200,32 @@ pub enum OrderError { /// component is named, not just the edge that happened to close it, since /// a bootstrap chain is broken by choosing where to cut the whole loop. Cycle(Vec), + /// Runtime requirements of one name at one toolchain disagree, and no + /// candidate satisfies all of them. `stack solve` rejects that closure. + /// The order does not pick a winner. + RuntimeVersions(Vec), +} + +/// One recipe whose runtime dependency resolved to a version in a conflict. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimePin { + /// The module that stated the requirement. + pub requester: ModuleKey, + /// The version requirement as the recipe wrote it, such as `==3.4.1`. + pub requirement: String, + /// The version that requirement resolved to. + pub resolved: String, +} + +/// Runtime pins of one name at one toolchain that cannot share a stack. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeConflict { + /// Package name. + pub name: String, + /// Toolchain label, `foss-2026.1` or `system`. + pub toolchain: String, + /// Who asked, sorted by requester, then requirement, then resolved version. + pub requests: Vec, } impl std::fmt::Display for OrderError { @@ -228,7 +266,39 @@ impl std::fmt::Display for OrderError { let names: Vec = component.iter().map(ToString::to_string).collect(); write!(f, "dependency cycle among {}", names.join(", ")) } + Self::RuntimeVersions(conflicts) => { + for (index, conflict) in conflicts.iter().enumerate() { + if index > 0 { + write!(f, " ")?; + } + write!(f, "{conflict}")?; + } + Ok(()) + } + } + } +} + +impl std::fmt::Display for RuntimeConflict { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{} at {} is required at more than one version, and no version satisfies every runtime requirement. stack order does not choose one: stack solve rejects this closure", + self.name, self.toolchain + )?; + for (index, pin) in self.requests.iter().enumerate() { + if index == 0 { + write!(f, ". ")?; + } else { + write!(f, "; ")?; + } + write!( + f, + "{} requires {} {} (resolved {})", + pin.requester, self.name, pin.requirement, pin.resolved + )?; } + Ok(()) } } @@ -991,6 +1061,153 @@ pub fn closure_against_installed( } } +/// A slot disagrees once the closure selected this many modules for it. +/// +/// The package key in `resolvo_provider` keeps one runtime version. The +/// second selected module is the disagreement `stack solve` rejects. +const MODULES_THAT_DISAGREE: usize = 2; + +/// One runtime requirement that resolved into a slot under inspection. +struct RuntimeAsk<'a> { + requester: ModuleKey, + recipe: &'a Candidate, + dep: &'a DepReq, + resolved: String, +} + +/// The version a resolved candidate contributes to an error. +/// +/// A versionsuffix is part of the module identity, so two builds that share +/// a version string and differ only there stay distinguishable. +fn resolved_label(candidate: &Candidate) -> String { + match candidate.versionsuffix.as_deref() { + Some(suffix) if !suffix.is_empty() => format!("{}{suffix}", candidate.version), + _ => candidate.version.clone(), + } +} + +/// Refuse a closure whose runtime requirements cannot share one version. +/// +/// Slots come from the modules the graph selected, using the same package +/// key as `stack solve`. A system bootstrap pair and a build-only tool carry +/// the version in that key, so they are not inspected. A runtime slot with +/// two selected modules is an error when no candidate satisfies every runtime +/// requirement that resolved into it. A requirement that does admit one +/// shared version is left alone: each recipe still resolves on its own, and +/// this function does not rewrite a pin. +// The error names every requester. That makes it a large Err variant on a +// path that returns at most once per run, the same trade as [`build_order`]. +#[allow(clippy::result_large_err)] +fn refuse_disjoint_runtime_versions( + graph: &BuildGraph, + candidates: &[Candidate], + choice: Choice, +) -> Result<(), OrderError> { + let by_module: BTreeMap = candidates + .iter() + .map(|candidate| (ModuleKey::of(candidate), candidate)) + .collect(); + let closure: Vec<&Candidate> = graph + .node_weights() + .filter_map(|key| by_module.get(key).copied()) + .collect(); + let slots = crate::resolvo_provider::InstallSlots::from_candidates(closure.iter().copied()); + let mut groups: BTreeMap> = BTreeMap::new(); + for candidate in &closure { + if slots.version_in_key(candidate) { + continue; + } + groups + .entry(slots.package_key(candidate)) + .or_default() + .insert(ModuleKey::of(candidate)); + } + + let by_name = index_by_name(candidates); + let mut conflicts = Vec::new(); + for (slot, members) in &groups { + if members.len() < MODULES_THAT_DISAGREE { + continue; + } + let mut asks: Vec> = Vec::new(); + for recipe in closure.iter().copied() { + for dep in &recipe.dependencies { + let Some(picked) = resolve_dep(&by_name, recipe, dep, choice, candidates) else { + continue; + }; + let resolved_key = ModuleKey::of(picked); + if !members.contains(&resolved_key) { + continue; + } + asks.push(RuntimeAsk { + requester: ModuleKey::of(recipe), + recipe, + dep, + resolved: resolved_label(picked), + }); + } + } + asks.sort_by(|left, right| { + left.requester + .cmp(&right.requester) + .then_with(|| left.dep.version_req.cmp(&right.dep.version_req)) + .then_with(|| left.resolved.cmp(&right.resolved)) + }); + asks.dedup_by(|left, right| { + left.requester == right.requester + && left.dep.version_req == right.dep.version_req + && left.resolved == right.resolved + }); + if asks.is_empty() { + continue; + } + let jointly_satisfiable = candidates.iter().any(|candidate| { + !slots.version_in_key(candidate) + && slots.package_key(candidate) == *slot + && asks.iter().all(|ask| { + satisfies(candidate, ask.dep) + && !within_generation(vec![candidate], ask.recipe, candidates, ask.dep) + .is_empty() + }) + }); + if jointly_satisfiable { + continue; + } + let Some(first) = members.iter().next() else { + continue; + }; + let toolchain = members + .iter() + .map(|key| key.toolchain.as_str()) + .collect::>() + .into_iter() + .collect::>() + .join(", "); + conflicts.push(RuntimeConflict { + name: first.name.clone(), + toolchain, + requests: asks + .iter() + .map(|ask| RuntimePin { + requester: ask.requester.clone(), + requirement: ask.dep.version_req.clone(), + resolved: ask.resolved.clone(), + }) + .collect(), + }); + } + conflicts.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then_with(|| left.toolchain.cmp(&right.toolchain)) + }); + if conflicts.is_empty() { + Ok(()) + } else { + Err(OrderError::RuntimeVersions(conflicts)) + } +} + /// What to build, in the order to build it. /// /// Roots are package names, optionally `name==version`. Every dependency the @@ -1007,6 +1224,9 @@ pub fn build_order( choice: Choice, ) -> Result, OrderError> { let graph = build_graph(candidates, roots, choice)?; + // Refuse before sorting. A closure `stack solve` cannot install is not + // an order, even when the graph itself is acyclic. + refuse_disjoint_runtime_versions(&graph, candidates, choice)?; let by_key: BTreeMap = candidates.iter().map(|c| (ModuleKey::of(c), c)).collect(); @@ -1382,6 +1602,122 @@ mod tests { assert!(shown.contains("5.0.0"), "{shown}"); assert!(shown.contains("LAMMPS"), "{shown}"); assert!(shown.contains("SciPy-bundle"), "{shown}"); + assert!( + matches!(err, OrderError::RuntimeVersions(_)), + "expected a runtime version conflict, got {err}" + ); + } + + /// CMake 3.31.11 and 4.2.1 are the pair in + /// `two_recipes_may_build_with_different_versions_of_one_tool`. A + /// builddependency is loaded only for the recipe that names it. + #[test] + fn two_build_only_versions_of_one_tool_both_appear() { + let foss = tc("foss", "2026.1"); + let mut parmetis = candidate("ParMETIS", "4.0.3", foss.clone(), vec![]); + parmetis.builddependencies = vec![dep("CMake", "==3.31.11", None)]; + let mut petsc = candidate( + "PETSc", + "3.25.0", + foss.clone(), + vec![dep("ParMETIS", "==4.0.3", None)], + ); + petsc.builddependencies = vec![dep("CMake", "==4.2.1", None)]; + let all = vec![ + candidate("CMake", "3.31.11", foss.clone(), vec![]), + candidate("CMake", "4.2.1", foss, vec![]), + parmetis, + petsc, + ]; + let order = build_order(&all, &["PETSc".into()], Choice::Newest) + .expect("both CMake builds stay in the order"); + let seq = names(&order); + assert!( + seq.iter().any(|module| module.starts_with("CMake-3.31.11")), + "{seq:?}" + ); + assert!( + seq.iter().any(|module| module.starts_with("CMake-4.2.1")), + "{seq:?}" + ); + } + + /// binutils 2.40 and 2.42 are the system pair in + /// `a_generation_can_carry_both_halves_of_a_bootstrap_pair`. + #[test] + fn a_system_bootstrap_pair_still_orders_both_versions() { + let system = tc("system", "system"); + let all = vec![ + candidate("binutils", "2.40", system.clone(), vec![]), + candidate( + "binutils", + "2.42", + system.clone(), + vec![dep("Perl", "==5.38.0", Some(system.clone()))], + ), + candidate( + "Perl", + "5.38.0", + system.clone(), + vec![dep("binutils", "==2.40", Some(system.clone()))], + ), + candidate( + "App", + "1.0", + tc("foss", "2026.1"), + vec![dep("binutils", "==2.42", Some(system))], + ), + ]; + let order = build_order(&all, &["App".into()], Choice::Newest) + .expect("both binutils builds stay in the order"); + let seq = names(&order); + assert!( + seq.iter().any(|module| module == "binutils-2.40-system"), + "{seq:?}" + ); + assert!( + seq.iter().any(|module| module == "binutils-2.42-system"), + "{seq:?}" + ); + } + + /// An unpinned requirement and an exact pin share a version, so the + /// closure is solvable. The order still lists both resolutions and does + /// not rewrite the pin. + #[test] + fn a_shared_version_is_not_repinned() { + let foss = tc("foss", "2026.1"); + let all = vec![ + candidate("Eigen", "3.4.1", foss.clone(), vec![]), + candidate("Eigen", "5.0.0", foss.clone(), vec![]), + candidate( + "LAMMPS", + "2025.07", + foss.clone(), + vec![dep("Eigen", "", None)], + ), + candidate( + "SciPy-bundle", + "2026.06", + foss, + vec![dep("Eigen", "==3.4.1", None)], + ), + ]; + let order = build_order( + &all, + &["LAMMPS".into(), "SciPy-bundle".into()], + Choice::Newest, + ) + .expect("a shared version is not a refusal"); + let seq = names(&order); + assert!( + seq.iter().any(|module| module.starts_with("Eigen-3.4.1")), + "{seq:?}" + ); + assert!( + seq.iter().any(|module| module.starts_with("Eigen-5.0.0")), + "{seq:?}" + ); } /// The case a stack solve cannot express: two builds of one name, both diff --git a/src/main.rs b/src/main.rs index d66542ba..b32b981b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1104,10 +1104,11 @@ enum StackCommand { }, /// Order the builds a set of roots needs, as a graph rather than a stack. /// - /// Unlike `solve`, this does not pick one version per package: it takes - /// what the recipes pin and sequences them, so a generation that carries - /// two builds of one name gets both, in an order that respects every - /// dependency. + /// Two builds of one name stay when EasyBuild installs them side by side: + /// different toolchains, a system bootstrap pair, or a build-only tool. + /// A runtime dependency has to agree on one version per toolchain. When + /// no version satisfies every such requirement, the command exits 1 and + /// names each requester and version. It does not repin a recipe. #[command( after_long_help = "Examples:\n eb-stack stack order --easyconfigs easyconfigs/ --root 'zlib==1.3.1'" )] diff --git a/src/resolvo_provider.rs b/src/resolvo_provider.rs index d674f0c3..513f5b2c 100644 --- a/src/resolvo_provider.rs +++ b/src/resolvo_provider.rs @@ -70,6 +70,122 @@ fn toolchain_label(tc: &crate::domain::Toolchain) -> String { } } +/// A name is qualified by toolchain once it is carried at this many labels. +/// +/// One label keeps the plain package name. A second label is a different +/// module level, which is the case [`package_key`] qualifies. +const TOOLCHAIN_LEVELS_THAT_QUALIFY: usize = 2; + +/// Each system-toolchain version becomes its own package once a name has +/// this many versions there. +/// +/// The bootstrap pair [`package_key`] names is two: binutils 2.40 builds the +/// GCCcore that builds binutils 2.42. +const SYSTEM_VERSIONS_THAT_SPLIT: usize = 2; + +/// Build-dependency versions are split into their own packages once a name +/// is required at this many versions and runtime references still agree. +const BUILD_VERSIONS_THAT_SPLIT: usize = 2; + +/// Several build-dependency versions stay co-installable only when the +/// distinct runtime version strings number at most this many. +/// +/// [`package_key`] admits that split only when every runtime reference asks +/// for the same version. A second runtime version string keeps one package. +const MAX_RUNTIME_VERSION_STRINGS_FOR_BUILD_SPLIT: usize = 1; + +/// The sets [`package_key`] reads, computed once for a candidate list. +/// +/// `stack solve` and `stack order` both use these sets, so a closure the +/// solver cannot install is the same closure the order refuses. +#[derive(Debug, Clone)] +pub(crate) struct InstallSlots { + /// Names carried at more than one toolchain label. + multi_level: HashSet, + /// Names with more than one version on the system toolchain. + system_multi: HashSet, + /// Names whose build-dependency versions may be installed together. + build_multi: HashSet, +} + +impl InstallSlots { + /// The sets for every candidate the caller will key. + pub(crate) fn from_candidates<'a>(candidates: impl IntoIterator) -> Self { + let mut levels_of: HashMap> = HashMap::new(); + let mut system_versions: HashMap> = HashMap::new(); + let mut build_versions: HashMap> = HashMap::new(); + let mut runtime_versions: HashMap> = HashMap::new(); + for candidate in candidates { + levels_of + .entry(candidate.name.clone()) + .or_default() + .insert(toolchain_label(&candidate.toolchain)); + if crate::hierarchy::is_system_toolchain(&candidate.toolchain) { + system_versions + .entry(candidate.name.clone()) + .or_default() + .insert(candidate.version.clone()); + } + for (deps, seen) in [ + (&candidate.builddependencies, &mut build_versions), + (&candidate.dependencies, &mut runtime_versions), + ] { + for dep in deps { + let version = dep.version_req.trim_start_matches("==").to_string(); + seen.entry(dep.name.clone()).or_default().insert(version); + } + } + } + let multi_level = levels_of + .into_iter() + .filter(|(_, levels)| levels.len() >= TOOLCHAIN_LEVELS_THAT_QUALIFY) + .map(|(name, _)| name) + .collect(); + let system_multi = system_versions + .into_iter() + .filter(|(_, versions)| versions.len() >= SYSTEM_VERSIONS_THAT_SPLIT) + .map(|(name, _)| name) + .collect(); + let build_multi = build_versions + .into_iter() + .filter(|(name, versions)| { + versions.len() >= BUILD_VERSIONS_THAT_SPLIT + && runtime_versions.get(name).is_none_or(|versions| { + versions.len() <= MAX_RUNTIME_VERSION_STRINGS_FOR_BUILD_SPLIT + }) + }) + .map(|(name, _)| name) + .collect(); + Self { + multi_level, + system_multi, + build_multi, + } + } + + /// The resolvo package name for one candidate. + pub(crate) fn package_key(&self, candidate: &Candidate) -> String { + package_key( + &candidate.name, + &candidate.toolchain, + &candidate.version, + &self.multi_level, + &self.system_multi, + &self.build_multi, + ) + } + + /// Whether another version of this candidate is a different package. + /// + /// True for a system bootstrap pair and for a build-only tool. False for + /// a runtime dependency, which keeps one version at one toolchain. + pub(crate) fn version_in_key(&self, candidate: &Candidate) -> bool { + let name = candidate.name.as_str(); + let system = crate::hierarchy::is_system_toolchain(&candidate.toolchain); + (self.system_multi.contains(name) && system) || (self.build_multi.contains(name) && !system) + } +} + /// The resolvo package name for a candidate. /// /// Plain for the ordinary case, qualified by toolchain for the names that a @@ -410,70 +526,11 @@ impl EbProvider { // Only names that genuinely appear at several levels are qualified. // Everything else keeps its plain name, so the common case reads the // same in every message and lock the tool has ever written. - let mut levels_of: HashMap> = HashMap::new(); - for c in candidates.iter() { - levels_of - .entry(c.name.clone()) - .or_default() - .insert(toolchain_label(&c.toolchain)); - } - let multi_level: HashSet = levels_of - .iter() - .filter(|(_, levels)| levels.len() > 1) - .map(|(name, _)| name.clone()) - .collect(); - - // Names the system level carries at more than one version. These are - // the bootstrap pairs, and each version is its own package. - let mut system_versions: HashMap> = HashMap::new(); - for c in candidates.iter() { - if crate::hierarchy::is_system_toolchain(&c.toolchain) { - system_versions - .entry(c.name.clone()) - .or_default() - .insert(c.version.clone()); - } - } - let system_multi: HashSet = system_versions - .iter() - .filter(|(_, versions)| versions.len() > 1) - .map(|(name, _)| name.clone()) - .collect(); - - let mut build_versions: HashMap> = HashMap::new(); - let mut runtime_versions: HashMap> = HashMap::new(); - for c in candidates.iter() { - for (deps, seen) in [ - (&c.builddependencies, &mut build_versions), - (&c.dependencies, &mut runtime_versions), - ] { - for d in deps { - let version = d.version_req.trim_start_matches("==").to_string(); - seen.entry(d.name.clone()).or_default().insert(version); - } - } - } - let build_multi: HashSet = build_versions - .iter() - .filter(|(name, versions)| { - versions.len() > 1 && runtime_versions.get(*name).is_none_or(|r| r.len() <= 1) - }) - .map(|(name, _)| name.clone()) - .collect(); + let slots = InstallSlots::from_candidates(candidates.iter()); let mut by_name: HashMap> = HashMap::new(); for (i, c) in candidates.iter().enumerate() { - by_name - .entry(package_key( - &c.name, - &c.toolchain, - &c.version, - &multi_level, - &system_multi, - &build_multi, - )) - .or_default() - .push(i); + by_name.entry(slots.package_key(c)).or_default().push(i); } // Sort by (version, versionsuffix) so same version with different // suffixes get distinct, deterministic ranks rather than colliding. @@ -704,14 +761,7 @@ impl EbProvider { let mut keys_by_name: HashMap> = HashMap::new(); for c in candidates.iter() { - let key = package_key( - &c.name, - &c.toolchain, - &c.version, - &multi_level, - &system_multi, - &build_multi, - ); + let key = slots.package_key(c); let entry = keys_by_name.entry(c.name.clone()).or_default(); if !entry.contains(&key) { entry.push(key); From f1190ba2b34d646a515d2cb29fdd2866c0824bbd Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 3 Nov 2026 12:55:19 +0000 Subject: [PATCH 3/4] docs(stack): record the runtime version refusal The `stack order` command refuses two runtime versions of one name at one toolchain and names both requesters. The docs say that side-by-side builds stay. They also say that a recipe is not repinned. Both points are in the how-to, the CLI reference, the format reference, and the changelog. --- CHANGELOG.md | 14 ++++++ docs/orgmode/howto/solve-lock.org | 34 +++++++++++++-- docs/orgmode/reference/cli.org | 16 ++++--- docs/orgmode/reference/formats.org | 18 +++++--- llms-full.txt | 68 +++++++++++++++++++++++------- 5 files changed, 120 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07b7aabb..a2abf86f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to this project are documented here. ## Unreleased +### Fixed + +- `stack order` exits 1 when runtime requirements of one name at one + toolchain have no version that satisfies every one of them. The error + names each requester and the version it resolved. Eigen 3.4.1 required + by LAMMPS and Eigen 5.0.0 required by SciPy-bundle, both at + `foss-2026.1`, is the closure `stack solve` already rejects. The + command writes no order file. It does not repin a recipe: choosing + which exact pin to drop would describe a build the easyconfig does + not ask for. Two builds of one name stay in the order when EasyBuild + installs them side by side: another toolchain, a system bootstrap + pair (binutils 2.40 and 2.42), or a build-only tool (CMake 3.31.11 + and 4.2.1). Those are the same package keys `stack solve` uses. + ### Added - `stack provided` includes the one-based build-list line on every diff --git a/docs/orgmode/howto/solve-lock.org b/docs/orgmode/howto/solve-lock.org index 681cd7c9..3296e290 100644 --- a/docs/orgmode/howto/solve-lock.org +++ b/docs/orgmode/howto/solve-lock.org @@ -73,9 +73,37 @@ See [[file:../reference/cli.org][the CLI reference]]. * Order every pinned build -~stack solve~ picks one version of each name. ~stack order~ keeps every -pinned build, and a name can stay twice. ~stack order~ writes the builds -in dependency order: +~stack solve~ picks one version of each name. ~stack order~ sequences the +builds the recipes pin. A name stays twice when EasyBuild installs the +two builds side by side. That is the same package key ~stack solve~ uses: +another toolchain (Perl at GCCcore and at SYSTEM), a system bootstrap +pair (binutils 2.40 and 2.42), or a build-only tool (CMake 3.31.11 and +4.2.1, loaded only while the recipe that names it builds). + +A runtime dependency has to agree on one version per toolchain. When no +version satisfies every such requirement, ~stack order~ exits 1 and names +each requester and the version it resolved. It writes no order file. It +does not repin a recipe. Choosing which exact pin to keep is the decision +~stack solve~ already rejects, and rewriting the pin would describe a +build the easyconfig does not ask for. An unpinned requirement that still +admits the pinned version is not this refusal: each recipe keeps the +module its own resolution selected. + +Eigen 3.4.1 required by LAMMPS and Eigen 5.0.0 required by SciPy-bundle, +both at ~foss/2026.1~, is that refusal. The fixture is +~fixtures/stack_order_version_conflict~. + +#+begin_src bash +eb-stack stack order \ + --easyconfigs fixtures/stack_order_version_conflict/easyconfigs \ + --root LAMMPS --root SciPy-bundle \ + --out /tmp/build-order.txt +#+end_src + +Standard error names both requesters. ~stack solve~ of the same roots +with ~--toolchain foss/2026.1~ reports the same conflict. + +~stack order~ writes a successful closure in dependency order: #+begin_src bash eb-stack stack order \ diff --git a/docs/orgmode/reference/cli.org b/docs/orgmode/reference/cli.org index 6f4e425d..3e827adb 100644 --- a/docs/orgmode/reference/cli.org +++ b/docs/orgmode/reference/cli.org @@ -626,10 +626,14 @@ dependencies to ~eb --robot~. ~stack sbom --lock~ re-reads the easyconfigs named in the lock and restores their runtime ~dependsOn~ edges. ~stack order~ sequences the recipes the roots pin, in an order that -respects every dependency. It keeps every build the recipes pin and -does not pick one version per name, so a name the generation builds -twice stays twice and both builds appear; ~stack solve~ picks one -version of that name, this command does not. ~--root~ repeats, and +respects every dependency. Two builds of one name stay when EasyBuild +installs them side by side, which is the same package key ~stack solve~ +uses: another toolchain, a system bootstrap pair, or a build-only tool. +A runtime dependency has to agree on one version per toolchain. When no +version satisfies every such requirement the command exits 1, names each +requester and the version it resolved, and writes no order file. It does +not repin a recipe. See [[file:../howto/solve-lock.org][Order every pinned build]]. +~--root~ repeats, and ~name==version~ pins one build. ~--filter-deps~ drops each named dependency before that order is built, together with the subtree under it, using the same matching as ~stack solve~; when the flag is absent @@ -641,8 +645,8 @@ easystack. ~--hashes-out~ writes one line per module, ~ ~, a digest of the easyconfig and of the hashes of everything it needs. A module whose recipe could not be read is still listed, and the stdout line ~incomplete=~ counts those. Stdout ends -with ~order= builds=~, and a name with two builds also prints -~multiple builds of :~. +with ~order= builds=~, and a name with two co-installable builds +also prints ~multiple builds of :~. ~stack easystack~ writes the lock, sorted by package name, as the easystack ~eb --easystack~ consumes: ~easyconfigs:~ is a YAML list of diff --git a/docs/orgmode/reference/formats.org b/docs/orgmode/reference/formats.org index 9c49c4ac..1c73ae9e 100644 --- a/docs/orgmode/reference/formats.org +++ b/docs/orgmode/reference/formats.org @@ -217,12 +217,18 @@ solver selection, not a scan of installed software. * Build order (~stack order --out~) -One easyconfig path per line, in dependency order. A generation that -builds one name twice lists both paths. ~--oldest~ changes which unpinned -version is chosen; it does not change the file shape. - -Stdout names a repeated build as ~multiple builds of : , ...~ -and ends with ~order= builds=~. +One easyconfig path per line, in dependency order. A name EasyBuild +installs twice lists both paths: another toolchain, a system bootstrap +pair, or a build-only tool. ~--oldest~ changes which unpinned version is +chosen; it does not change the file shape. When runtime requirements of +one name at one toolchain have no version that satisfies every one of +them, the command writes no file and exits 1. Standard error names each +requester and the version it resolved. The file is not a place where a +pin is rewritten. + +Stdout names a co-installable repeated build as +~multiple builds of : , ...~ and ends with +~order= builds=~. * Input hashes (~--hashes-out~) diff --git a/llms-full.txt b/llms-full.txt index b4a7afcc..ebacb07d 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1824,9 +1824,37 @@ See [the CLI reference](https://eb-stack.rgoswami.me/../reference/cli.html.md). # Order every pinned build -`stack solve` picks one version of each name. `stack order` keeps every -pinned build, and a name can stay twice. `stack order` writes the builds -in dependency order: +`stack solve` picks one version of each name. `stack order` sequences the +builds the recipes pin. A name stays twice when EasyBuild installs the +two builds side by side. That is the same package key `stack solve` uses: +another toolchain (Perl at GCCcore and at SYSTEM), a system bootstrap +pair (binutils 2.40 and 2.42), or a build-only tool (CMake 3.31.11 and +4.2.1, loaded only while the recipe that names it builds). + +A runtime dependency has to agree on one version per toolchain. When no +version satisfies every such requirement, `stack order` exits 1 and names +each requester and the version it resolved. It writes no order file. It +does not repin a recipe. Choosing which exact pin to keep is the decision +`stack solve` already rejects, and rewriting the pin would describe a +build the easyconfig does not ask for. An unpinned requirement that still +admits the pinned version is not this refusal: each recipe keeps the +module its own resolution selected. + +Eigen 3.4.1 required by LAMMPS and Eigen 5.0.0 required by SciPy-bundle, +both at `foss/2026.1`, is that refusal. The fixture is +`fixtures/stack_order_version_conflict`. + +```bash +eb-stack stack order \ + --easyconfigs fixtures/stack_order_version_conflict/easyconfigs \ + --root LAMMPS --root SciPy-bundle \ + --out /tmp/build-order.txt +``` + +Standard error names both requesters. `stack solve` of the same roots +with `--toolchain foss/2026.1` reports the same conflict. + +`stack order` writes a successful closure in dependency order: ```bash eb-stack stack order \ @@ -5895,10 +5923,14 @@ dependencies to `eb --robot`. `stack sbom --lock` re-reads the easyconfigs named in the lock and restores their runtime `dependsOn` edges. `stack order` sequences the recipes the roots pin, in an order that -respects every dependency. It keeps every build the recipes pin and -does not pick one version per name, so a name the generation builds -twice stays twice and both builds appear; `stack solve` picks one -version of that name, this command does not. `--root` repeats, and +respects every dependency. Two builds of one name stay when EasyBuild +installs them side by side, which is the same package key `stack solve` +uses: another toolchain, a system bootstrap pair, or a build-only tool. +A runtime dependency has to agree on one version per toolchain. When no +version satisfies every such requirement the command exits 1, names each +requester and the version it resolved, and writes no order file. It does +not repin a recipe. See [Order every pinned build](https://eb-stack.rgoswami.me/../howto/solve-lock.html.md). +`--root` repeats, and `name==version` pins one build. `--filter-deps` drops each named dependency before that order is built, together with the subtree under it, using the same matching as `stack solve`; when the flag is absent @@ -5910,8 +5942,8 @@ easystack. `--hashes-out` writes one line per module, ` `, a digest of the easyconfig and of the hashes of everything it needs. A module whose recipe could not be read is still listed, and the stdout line `incomplete=` counts those. Stdout ends -with `order= builds=`, and a name with two builds also prints -`multiple builds of :`. +with `order= builds=`, and a name with two co-installable builds +also prints `multiple builds of :`. `stack easystack` writes the lock, sorted by package name, as the easystack `eb --easystack` consumes: `easyconfigs:` is a YAML list of @@ -8339,12 +8371,18 @@ solver selection, not a scan of installed software. # Build order (`stack order --out`) -One easyconfig path per line, in dependency order. A generation that -builds one name twice lists both paths. `--oldest` changes which unpinned -version is chosen; it does not change the file shape. - -Stdout names a repeated build as `multiple builds of : , ...` -and ends with `order= builds=`. +One easyconfig path per line, in dependency order. A name EasyBuild +installs twice lists both paths: another toolchain, a system bootstrap +pair, or a build-only tool. `--oldest` changes which unpinned version is +chosen; it does not change the file shape. When runtime requirements of +one name at one toolchain have no version that satisfies every one of +them, the command writes no file and exits 1. Standard error names each +requester and the version it resolved. The file is not a place where a +pin is rewritten. + +Stdout names a co-installable repeated build as +`multiple builds of : , ...` and ends with +`order= builds=`. # Input hashes (`--hashes-out`) From f65ea77485fcd89af1f02fd8f3581b0403cfdac9 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Tue, 3 Nov 2026 12:56:32 +0000 Subject: [PATCH 4/4] ci(stack): run the runtime version order test The default test workflow names every integration test. The new `stack order` fixture was not in that list. --- .github/workflows/ci_test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci_test.yml b/.github/workflows/ci_test.yml index 2a844467..cc9fb83c 100644 --- a/.github/workflows/ci_test.yml +++ b/.github/workflows/ci_test.yml @@ -208,6 +208,7 @@ jobs: --test solver_completeness \ --test stack_files \ --test stack_order_filter \ + --test stack_order_version \ --test source_probe \ -- --nocapture