diff --git a/StabilityMatrix.Core/Services/Rocm/RocmPackageHelper.cs b/StabilityMatrix.Core/Services/Rocm/RocmPackageHelper.cs index 1bf15be60..94e9a9b26 100644 --- a/StabilityMatrix.Core/Services/Rocm/RocmPackageHelper.cs +++ b/StabilityMatrix.Core/Services/Rocm/RocmPackageHelper.cs @@ -249,33 +249,43 @@ public async Task InstallWindowsNativeTorchAsync( ); } - progress?.Report(new ProgressReport(-1f, "Installing ROCm torch...", isIndeterminate: true)); - var installConfig = profile.InstallConfig; var multiArchPythonPackageIndexUrl = WindowsRocmSupport.GetMultiArchPythonPackageIndexUrl( state.RuntimeGfxArch ); - var torchArgs = new PipInstallArgs() - .AddKeyedArgs("--index-url", ["--index-url", multiArchPythonPackageIndexUrl]) - .AddArgs( - new Argument($"torch[{multiArchDeviceExtra}]"), - new Argument($"torchvision[{multiArchDeviceExtra}]"), - new Argument("torchaudio") - ); + var usesNightlyIndex = WindowsRocmSupport.UsesNightlyMultiArchPythonPackageIndex( + state.RuntimeGfxArch + ); - if (installConfig.UpgradePackages) - { - torchArgs = torchArgs.AddArg("--upgrade"); - } + // AMD's torchvision wheels carry no torch constraint, and the matched build may be published only + // as a pre-release that pip skips. Resolve the newest paired torch/torchvision and pin both. + progress?.Report(new ProgressReport(-1f, "Resolving ROCm torch...", isIndeterminate: true)); + + var torchResolution = await ResolveTorchInstallAsync( + venvRunner, + multiArchPythonPackageIndexUrl, + multiArchDeviceExtra, + usesNightlyIndex, + onConsoleOutput, + cancellationToken + ) + .ConfigureAwait(false); - if (installConfig.ForceReinstallTorch) - { - torchArgs = torchArgs.AddArg("--force-reinstall"); - } + progress?.Report(new ProgressReport(-1f, "Installing ROCm torch...", isIndeterminate: true)); - if (installedPackage.PipOverrides != null) + var torchArgs = ApplyRocmTorchInstallOptions( + BuildRocmTorchArgs( + multiArchPythonPackageIndexUrl, + torchResolution.TorchSpecifier, + torchResolution.TorchvisionSpecifier + ), + installConfig, + installedPackage + ); + + if (torchResolution.AllowPrerelease) { - torchArgs = torchArgs.WithUserOverrides(installedPackage.PipOverrides); + torchArgs = torchArgs.AddArg("--pre"); } await venvRunner.PipInstall(torchArgs, onConsoleOutput).ConfigureAwait(false); @@ -298,6 +308,187 @@ await VerifyWindowsNativeTorchInstallAsync(venvRunner, onConsoleOutput, cancella .ConfigureAwait(false); } + /// + /// Applies the profile's upgrade/reinstall flags and any user pip overrides to a ROCm torch install command. + /// + private static PipInstallArgs ApplyRocmTorchInstallOptions( + PipInstallArgs args, + PipInstallConfig installConfig, + InstalledPackage installedPackage + ) + { + if (installConfig.UpgradePackages) + { + args = args.AddArg("--upgrade"); + } + + if (installConfig.ForceReinstallTorch) + { + args = args.AddArg("--force-reinstall"); + } + + if (installedPackage.PipOverrides != null) + { + args = args.WithUserOverrides(installedPackage.PipOverrides); + } + + return args; + } + + /// + /// Builds the pip args for the resolved torch/torchvision pair. The specs are keyed so a user + /// override replaces the pin instead of being appended next to it (which pip cannot satisfy). + /// + internal static PipInstallArgs BuildRocmTorchArgs( + string multiArchPythonPackageIndexUrl, + string torchSpecifier, + string torchvisionSpecifier + ) + { + return new PipInstallArgs() + .AddKeyedArgs("--index-url", ["--index-url", multiArchPythonPackageIndexUrl]) + .AddArg(new Argument(key: "torch", value: torchSpecifier)) + .AddArg(new Argument(key: "torchvision", value: torchvisionSpecifier)) + .AddArg("torchaudio"); + } + + /// + /// Resolved torch and torchvision install specifiers, plus whether the install must allow + /// pre-releases (the fallback path where no paired build could be resolved). + /// + private readonly record struct TorchInstallResolution( + string TorchSpecifier, + string TorchvisionSpecifier, + bool AllowPrerelease + ); + + /// + /// Resolves the newest torch that has a paired torchvision on the ROCm index so both can be pinned; + /// falls back to unpinned, pre-release-allowing specifiers when no pair resolves. + /// + private async Task ResolveTorchInstallAsync( + IPyVenvRunner venvRunner, + string multiArchPythonPackageIndexUrl, + string multiArchDeviceExtra, + bool usesNightlyIndex, + Action? onConsoleOutput, + CancellationToken cancellationToken + ) + { + var torchSpecifierBase = $"torch[{multiArchDeviceExtra}]"; + var torchvisionSpecifierBase = $"torchvision[{multiArchDeviceExtra}]"; + + try + { + // torch: finals only on the stable channel, pre/dev builds on the nightly channel. + var torchIndex = await venvRunner + .PipIndex("torch", multiArchPythonPackageIndexUrl, includePrerelease: usesNightlyIndex) + .ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + // torchvision: allow pre-releases so the matched alpha (e.g. 0.29.0a0) is visible. + var torchvisionIndex = await venvRunner + .PipIndex("torchvision", multiArchPythonPackageIndexUrl, includePrerelease: true) + .ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + // On the nightly channel the local label carries the build date, so pin the newest snapshot + // that exists for both torch and the paired torchvision instead of letting pip pair builds + // from different dates. + if (usesNightlyIndex) + { + var snapshot = RocmTorchVersionResolver.SelectHighestPairBySnapshot( + torchIndex?.AvailableVersions, + torchvisionIndex?.AvailableVersions + ); + + if (snapshot is { } selectedSnapshot) + { + Logger.Info( + "Resolved ROCm nightly snapshot torch {TorchVersion} with torchvision {TorchvisionVersion}.", + selectedSnapshot.Torch, + selectedSnapshot.Torchvision + ); + + return new TorchInstallResolution( + $"{torchSpecifierBase}=={selectedSnapshot.Torch}", + $"{torchvisionSpecifierBase}=={selectedSnapshot.Torchvision}", + AllowPrerelease: false + ); + } + } + + var pair = RocmTorchVersionResolver.SelectHighestPair( + torchIndex?.AvailableVersions, + torchvisionIndex?.AvailableVersions + ); + + if (pair is { } selectedPair) + { + if (usesNightlyIndex) + { + onConsoleOutput?.Invoke( + ProcessOutput.FromStdErrLine( + "ROCm: no nightly build shares the same date stamp for torch and torchvision; " + + "falling back to the newest builds of each." + ) + ); + } + + // pip lists versions in descending order, so the first entry is the newest torch. + var newestTorch = torchIndex?.AvailableVersions is { Count: > 0 } torchVersions + ? RocmTorchVersionResolver.StripLocalVersion(torchVersions[0]) + : null; + + if ( + !string.IsNullOrWhiteSpace(newestTorch) + && !string.Equals(newestTorch, selectedPair.Torch, StringComparison.OrdinalIgnoreCase) + ) + { + onConsoleOutput?.Invoke( + ProcessOutput.FromStdErrLine( + $"ROCm: torch {newestTorch} has no matching torchvision build on the index; " + + $"using torch {selectedPair.Torch} instead." + ) + ); + } + + Logger.Info( + "Resolved ROCm torch {TorchVersion} with torchvision {TorchvisionVersion}.", + selectedPair.Torch, + selectedPair.Torchvision + ); + + return new TorchInstallResolution( + $"{torchSpecifierBase}=={selectedPair.Torch}", + $"{torchvisionSpecifierBase}=={selectedPair.Torchvision}", + AllowPrerelease: false + ); + } + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + Logger.Warn( + exception, + "Failed to resolve a paired ROCm torch/torchvision set from the ROCm index." + ); + } + + onConsoleOutput?.Invoke( + ProcessOutput.FromStdErrLine( + "ROCm: no paired torch/torchvision build could be resolved from the index. " + + "Falling back to the newest available builds." + ) + ); + + // Only widen to pre-releases on nightly, where pip would otherwise select an older final. + return new TorchInstallResolution( + torchSpecifierBase, + torchvisionSpecifierBase, + AllowPrerelease: usesNightlyIndex + ); + } + /// /// Builds a compatibility result from the current machine state and package profile. /// This keeps the first ROCm helper slice focused on hardware capability and GPU selection only. @@ -433,14 +624,19 @@ CancellationToken cancellationToken var verificationResult = await venvRunner .Run( - "-c \"import json, torch; print(json.dumps({'version': torch.__version__, 'hip': torch.version.hip, 'cuda': torch.cuda.is_available()}))\"" + "-c \"import json, torch, torchvision; print(json.dumps({'version': torch.__version__, 'torchvision': torchvision.__version__, 'hip': torch.version.hip, 'cuda': torch.cuda.is_available()}))\"" ) .ConfigureAwait(false); var verificationOutput = (verificationResult.StandardOutput ?? string.Empty).Trim(); if (string.IsNullOrWhiteSpace(verificationOutput)) { - throw new InvalidOperationException("Torch verification produced no output."); + var verificationError = (verificationResult.StandardError ?? string.Empty).Trim(); + throw new InvalidOperationException( + string.IsNullOrWhiteSpace(verificationError) + ? "Torch verification produced no output." + : $"Torch verification produced no output. Standard error: {verificationError}" + ); } var verificationJson = TryExtractJsonObject(verificationOutput); @@ -470,6 +666,9 @@ CancellationToken cancellationToken var version = root.TryGetProperty("version", out var versionElement) ? versionElement.GetString() : null; + var torchvisionVersion = root.TryGetProperty("torchvision", out var torchvisionElement) + ? torchvisionElement.GetString() + : null; var hipVersion = root.TryGetProperty("hip", out var hipElement) ? hipElement.GetString() : null; var cudaAvailable = root.TryGetProperty("cuda", out var cudaElement) && cudaElement.GetBoolean(); @@ -480,6 +679,16 @@ CancellationToken cancellationToken ); } + // AMD's torchvision wheels declare no torch version constraint, so an ABI-mismatched pair only + // fails at import time. Importing it here surfaces that as an install failure instead of a + // confusing runtime error later. + if (string.IsNullOrWhiteSpace(torchvisionVersion)) + { + throw new InvalidOperationException( + $"Installed torchvision could not be imported. Verification output: {verificationOutput}" + ); + } + if (!cudaAvailable) { onConsoleOutput?.Invoke( @@ -491,7 +700,7 @@ CancellationToken cancellationToken onConsoleOutput?.Invoke( ProcessOutput.FromStdOutLine( - $"Torch verification: version={version}, hip={hipVersion}, cuda={cudaAvailable}" + $"Torch verification: version={version}, torchvision={torchvisionVersion}, hip={hipVersion}, cuda={cudaAvailable}" ) ); } diff --git a/StabilityMatrix.Core/Services/Rocm/RocmTorchVersionResolver.cs b/StabilityMatrix.Core/Services/Rocm/RocmTorchVersionResolver.cs new file mode 100644 index 000000000..d27b1d6ad --- /dev/null +++ b/StabilityMatrix.Core/Services/Rocm/RocmTorchVersionResolver.cs @@ -0,0 +1,189 @@ +using System.Globalization; + +namespace StabilityMatrix.Core.Services.Rocm; + +/// +/// Resolves the ROCm torch/torchvision pairing. AMD's wheels declare no cross-constraints and the +/// matched build may be published only as a pre-release, so the pair is resolved from the index. +/// +internal static class RocmTorchVersionResolver +{ + // torch 2.x pairs with torchvision 0.(x + 15): 2.14 -> 0.29, 2.15 -> 0.30. + private const int TorchvisionMinorOffset = 15; + + /// Strips a PEP 440 local label (for example +rocm10.1.0). + public static string StripLocalVersion(string? version) + { + if (string.IsNullOrWhiteSpace(version)) + return string.Empty; + + var plusIndex = version.IndexOf('+'); + return plusIndex < 0 ? version : version[..plusIndex]; + } + + /// + /// Returns the (major, minor) release pair, ignoring pre-release/dev/post segments and the + /// local label. Returns when the release segment cannot be parsed. + /// + public static (int Major, int Minor)? TryGetReleasePair(string? version) + { + var core = StripLocalVersion(version); + if (string.IsNullOrWhiteSpace(core)) + return null; + + // Cut at the first character outside the numeric release (the "a0" in "0.29.0a0"). + var end = 0; + while (end < core.Length && (char.IsDigit(core[end]) || core[end] == '.')) + { + end++; + } + + var parts = core[..end].TrimEnd('.').Split('.'); + if ( + parts.Length < 2 + || !int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var major) + || !int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var minor) + ) + { + return null; + } + + return (major, minor); + } + + /// Returns for development releases (for example 2.15.0.dev20261006). + public static bool IsDevelopmentRelease(string? version) => + StripLocalVersion(version).Contains("dev", StringComparison.OrdinalIgnoreCase); + + /// Derives the torchvision (major, minor) paired with a torch version, or if unknown. + public static (int Major, int Minor)? ExpectedTorchvision(string? torchVersion) + { + if (TryGetReleasePair(torchVersion) is not { } torch) + return null; + + // Only the torch 2.x line shares the torchvision 0.x versioning used by the AMD index. + if (torch.Major != 2) + return null; + + return (0, torch.Minor + TorchvisionMinorOffset); + } + + /// + /// Selects the highest version whose release pair is (input is in pip's + /// descending order), skipping development releases. + /// + public static string? SelectHighestMatching( + IReadOnlyList? availableVersions, + (int Major, int Minor) expected + ) + { + if (availableVersions is null) + return null; + + foreach (var version in availableVersions) + { + if (!IsDevelopmentRelease(version) && TryGetReleasePair(version) == expected) + return StripLocalVersion(version); + } + + return null; + } + + /// + /// Yields the distinct base versions (local labels stripped) in their original order, so local-label + /// variants (2.14.0+rocm10.0.0 / 2.14.0+rocm10.1.0) aren't walked twice. + /// + public static IEnumerable DistinctByBaseVersion(IReadOnlyList? versions) + { + if (versions is null) + yield break; + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var version in versions) + { + var baseVersion = StripLocalVersion(version); + if (!string.IsNullOrWhiteSpace(baseVersion) && seen.Add(baseVersion)) + yield return version; + } + } + + /// + /// Selects the newest torch that has a paired torchvision build, walking down until one matches. + /// Returns the paired base versions (local labels stripped), or if none pair. + /// + public static (string Torch, string Torchvision)? SelectHighestPair( + IReadOnlyList? torchVersions, + IReadOnlyList? torchvisionVersions + ) + { + if (torchVersions is null || torchvisionVersions is null) + return null; + + foreach (var torchVersion in DistinctByBaseVersion(torchVersions)) + { + if (ExpectedTorchvision(torchVersion) is not { } expected) + continue; + + if (SelectHighestMatching(torchvisionVersions, expected) is { } torchvisionVersion) + return (StripLocalVersion(torchVersion), torchvisionVersion); + } + + return null; + } + + /// Returns the local version label (for example +rocm10.2.0a20261007), or empty. + public static string GetLocalVersion(string? version) + { + if (string.IsNullOrWhiteSpace(version)) + return string.Empty; + + var plusIndex = version.IndexOf('+'); + return plusIndex < 0 ? string.Empty : version[plusIndex..]; + } + + /// + /// Selects the newest torch build that has a paired torchvision build carrying the same local label + /// (for example +rocm10.2.0a20261007), so both are pinned to the same dated snapshot. Returns + /// the full versions (local labels included), or when no snapshot pairs. + /// + public static (string Torch, string Torchvision)? SelectHighestPairBySnapshot( + IReadOnlyList? torchVersions, + IReadOnlyList? torchvisionVersions + ) + { + if (torchVersions is null || torchvisionVersions is null) + return null; + + // torchVersions is in pip's descending order, so the first matching snapshot is the newest. + foreach (var torchVersion in torchVersions) + { + if (IsDevelopmentRelease(torchVersion) || ExpectedTorchvision(torchVersion) is not { } expected) + continue; + + var localVersion = GetLocalVersion(torchVersion); + if (string.IsNullOrWhiteSpace(localVersion)) + continue; + + foreach (var torchvisionVersion in torchvisionVersions) + { + if (IsDevelopmentRelease(torchvisionVersion)) + continue; + + if ( + TryGetReleasePair(torchvisionVersion) == expected + && string.Equals( + GetLocalVersion(torchvisionVersion), + localVersion, + StringComparison.OrdinalIgnoreCase + ) + ) + { + return (torchVersion, torchvisionVersion); + } + } + } + + return null; + } +} diff --git a/StabilityMatrix.Tests/Core/RocmPackageHelperTests.cs b/StabilityMatrix.Tests/Core/RocmPackageHelperTests.cs index ad79a23b0..16c52eebf 100644 --- a/StabilityMatrix.Tests/Core/RocmPackageHelperTests.cs +++ b/StabilityMatrix.Tests/Core/RocmPackageHelperTests.cs @@ -2,6 +2,7 @@ using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper.HardwareInfo; using StabilityMatrix.Core.Models.Rocm; +using StabilityMatrix.Core.Python; using StabilityMatrix.Core.Services.Rocm; namespace StabilityMatrix.Tests.Core; @@ -113,4 +114,73 @@ public void WindowsRocmSupport_IsSupportedGpu_ReturnsTrue_ForSupportedAmdGpu() Assert.IsTrue(WindowsRocmSupport.IsSupportedGpu(gpu)); } + + [TestMethod] + public void BuildRocmTorchArgs_PinsTorchAndTorchvisionWithDeviceExtras() + { + var args = RocmPackageHelper.BuildRocmTorchArgs( + "https://stable.repo.amd.com/rocm/whl-next/", + "torch[device-gfx1201]==2.14.0", + "torchvision[device-gfx1201]==0.29.0a0" + ); + + var text = args.ToString(); + + StringAssert.Contains(text, "torch[device-gfx1201]==2.14.0"); + StringAssert.Contains(text, "torchvision[device-gfx1201]==0.29.0a0"); + StringAssert.Contains(text, "torchaudio"); + } + + [TestMethod] + public void BuildRocmTorchArgs_UserTorchOverrideReplacesPinnedTorch() + { + var args = RocmPackageHelper.BuildRocmTorchArgs( + "https://stable.repo.amd.com/rocm/whl-next/", + "torch[device-gfx1201]==2.14.0", + "torchvision[device-gfx1201]==0.29.0a0" + ); + + var overrides = new List + { + new() + { + Name = "torch", + Constraint = "==", + Version = "2.13.0", + Action = PipPackageSpecifierOverrideAction.Update, + }, + }; + + var text = args.WithUserOverrides(overrides).ToString(); + + // The override must replace the pin rather than be appended next to it (pip cannot satisfy both). + StringAssert.Contains(text, "torch==2.13.0"); + Assert.IsFalse(text.Contains("torch[device-gfx1201]==2.14.0"), text); + } + + [TestMethod] + public void BuildRocmTorchArgs_UserTorchvisionOverrideReplacesPinnedTorchvision() + { + var args = RocmPackageHelper.BuildRocmTorchArgs( + "https://stable.repo.amd.com/rocm/whl-next/", + "torch[device-gfx1201]==2.14.0", + "torchvision[device-gfx1201]==0.29.0a0" + ); + + var overrides = new List + { + new() + { + Name = "torchvision", + Constraint = "==", + Version = "0.28.0", + Action = PipPackageSpecifierOverrideAction.Update, + }, + }; + + var text = args.WithUserOverrides(overrides).ToString(); + + StringAssert.Contains(text, "torchvision==0.28.0"); + Assert.IsFalse(text.Contains("torchvision[device-gfx1201]==0.29.0a0"), text); + } } diff --git a/StabilityMatrix.Tests/Core/RocmTorchVersionResolverTests.cs b/StabilityMatrix.Tests/Core/RocmTorchVersionResolverTests.cs new file mode 100644 index 000000000..68e11c8f3 --- /dev/null +++ b/StabilityMatrix.Tests/Core/RocmTorchVersionResolverTests.cs @@ -0,0 +1,255 @@ +using StabilityMatrix.Core.Services.Rocm; + +namespace StabilityMatrix.Tests.Core; + +[TestClass] +public class RocmTorchVersionResolverTests +{ + [DataTestMethod] + [DataRow("0.29.0a0+rocm10.1.0", "0.29.0a0")] + [DataRow("2.14.0+rocm10.1.0", "2.14.0")] + [DataRow("2.11.0.3+rocm10.1.0", "2.11.0.3")] + [DataRow("2.15.0a0+rocm10.2.0a20260917", "2.15.0a0")] + [DataRow("2.14.0", "2.14.0")] + public void StripLocalVersion_RemovesLocalLabel(string input, string expected) + { + Assert.AreEqual(expected, RocmTorchVersionResolver.StripLocalVersion(input)); + } + + [TestMethod] + public void StripLocalVersion_ReturnsEmpty_ForNullOrWhitespace() + { + Assert.AreEqual(string.Empty, RocmTorchVersionResolver.StripLocalVersion(null)); + Assert.AreEqual(string.Empty, RocmTorchVersionResolver.StripLocalVersion(" ")); + } + + [DataTestMethod] + [DataRow("2.14.0+rocm10.1.0", 2, 14)] + [DataRow("0.29.0a0+rocm10.1.0", 0, 29)] + [DataRow("2.11.0.3+rocm10.1.0", 2, 11)] + [DataRow("2.15.0a0+rocm10.2.0a20260917", 2, 15)] + [DataRow("0.30.0", 0, 30)] + public void TryGetReleasePair_ReturnsMajorMinor(string input, int major, int minor) + { + var pair = RocmTorchVersionResolver.TryGetReleasePair(input); + + Assert.IsNotNull(pair); + Assert.AreEqual(major, pair.Value.Major); + Assert.AreEqual(minor, pair.Value.Minor); + } + + [DataTestMethod] + [DataRow("not-a-version")] + [DataRow("")] + [DataRow("1")] + public void TryGetReleasePair_ReturnsNull_ForUnparsableInput(string input) + { + Assert.IsNull(RocmTorchVersionResolver.TryGetReleasePair(input)); + } + + [TestMethod] + public void TryGetReleasePair_ReturnsNull_ForNull() + { + Assert.IsNull(RocmTorchVersionResolver.TryGetReleasePair(null)); + } + + [DataTestMethod] + [DataRow("2.11.0", 0, 26)] + [DataRow("2.13.0+rocm10.1.0", 0, 28)] + [DataRow("2.14.0+rocm10.1.0", 0, 29)] + [DataRow("2.15.0a0+rocm10.2.0a20261006", 0, 30)] + public void ExpectedTorchvision_MapsTorchMinor(string torch, int major, int minor) + { + var expected = RocmTorchVersionResolver.ExpectedTorchvision(torch); + + Assert.IsNotNull(expected); + Assert.AreEqual(major, expected.Value.Major); + Assert.AreEqual(minor, expected.Value.Minor); + } + + [TestMethod] + public void ExpectedTorchvision_ReturnsNull_ForUnknownPairings() + { + Assert.IsNull(RocmTorchVersionResolver.ExpectedTorchvision("3.0.0")); + Assert.IsNull(RocmTorchVersionResolver.ExpectedTorchvision(null)); + Assert.IsNull(RocmTorchVersionResolver.ExpectedTorchvision("not-a-version")); + } + + [TestMethod] + public void IsDevelopmentRelease_DetectsDevSegments() + { + Assert.IsTrue(RocmTorchVersionResolver.IsDevelopmentRelease("2.15.0.dev20261006")); + Assert.IsTrue(RocmTorchVersionResolver.IsDevelopmentRelease("2.15.0.dev20261006+rocm10.2.0")); + Assert.IsFalse(RocmTorchVersionResolver.IsDevelopmentRelease("0.29.0a0+rocm10.1.0")); + Assert.IsFalse(RocmTorchVersionResolver.IsDevelopmentRelease("2.14.0+rocm10.1.0")); + } + + [TestMethod] + public void SelectHighestMatching_PrefersMatchedPreRelease_OverStaleFinal() + { + // pip lists available versions in descending order. + string[] available = ["0.29.0a0+rocm10.1.0", "0.28.0+rocm10.1.0", "0.27.0+rocm10.1.0"]; + + var selected = RocmTorchVersionResolver.SelectHighestMatching(available, (0, 29)); + + Assert.AreEqual("0.29.0a0", selected); + } + + [TestMethod] + public void SelectHighestMatching_IgnoresDevelopmentReleases() + { + string[] available = ["0.29.0.dev20261006", "0.29.0a0+rocm10.1.0"]; + + var selected = RocmTorchVersionResolver.SelectHighestMatching(available, (0, 29)); + + Assert.AreEqual("0.29.0a0", selected); + } + + [TestMethod] + public void SelectHighestMatching_ReturnsNull_WhenNoPairMatches() + { + string[] available = ["0.28.0+rocm10.1.0", "0.27.0+rocm10.1.0"]; + + var selected = RocmTorchVersionResolver.SelectHighestMatching(available, (0, 29)); + + Assert.IsNull(selected); + } + + [TestMethod] + public void SelectHighestMatching_ReturnsNull_ForNullInput() + { + Assert.IsNull(RocmTorchVersionResolver.SelectHighestMatching(null, (0, 29))); + } + + [TestMethod] + public void SelectHighestPair_PicksNewestPairedTorch() + { + string[] torch = ["2.14.0+rocm10.1.0", "2.13.0+rocm10.1.0"]; + string[] torchvision = ["0.29.0a0+rocm10.1.0", "0.28.0+rocm10.1.0"]; + + var pair = RocmTorchVersionResolver.SelectHighestPair(torch, torchvision); + + Assert.IsNotNull(pair); + Assert.AreEqual("2.14.0", pair.Value.Torch); + Assert.AreEqual("0.29.0a0", pair.Value.Torchvision); + } + + [TestMethod] + public void SelectHighestPair_SkipsUnpairedNewestTorch() + { + // torch 2.15 exists but no torchvision 0.30 -> walk down to 2.14 / 0.29.0a0 + string[] torch = ["2.15.0+rocm10.2.0", "2.14.0+rocm10.1.0", "2.13.0+rocm10.1.0"]; + string[] torchvision = ["0.29.0a0+rocm10.1.0", "0.28.0+rocm10.1.0"]; + + var pair = RocmTorchVersionResolver.SelectHighestPair(torch, torchvision); + + Assert.IsNotNull(pair); + Assert.AreEqual("2.14.0", pair.Value.Torch); + Assert.AreEqual("0.29.0a0", pair.Value.Torchvision); + } + + [TestMethod] + public void SelectHighestPair_DedupesLocalLabelVariants() + { + string[] torch = ["2.14.0+rocm10.1.0", "2.14.0+rocm10.0.0", "2.13.0+rocm10.0.0"]; + string[] torchvision = ["0.29.0a0+rocm10.1.0"]; + + var pair = RocmTorchVersionResolver.SelectHighestPair(torch, torchvision); + + Assert.IsNotNull(pair); + Assert.AreEqual("2.14.0", pair.Value.Torch); + } + + [TestMethod] + public void SelectHighestPair_ReturnsNull_WhenNothingPairs() + { + string[] torch = ["2.15.0+rocm10.2.0"]; + string[] torchvision = ["0.28.0+rocm10.1.0"]; + + Assert.IsNull(RocmTorchVersionResolver.SelectHighestPair(torch, torchvision)); + } + + [TestMethod] + public void SelectHighestPair_ReturnsNull_ForNullInputs() + { + Assert.IsNull(RocmTorchVersionResolver.SelectHighestPair(null, ["0.29.0a0"])); + Assert.IsNull(RocmTorchVersionResolver.SelectHighestPair(["2.14.0"], null)); + } + + [TestMethod] + public void DistinctByBaseVersion_RemovesLocalLabelDuplicates_PreservingOrder() + { + string[] versions = ["2.14.0+rocm10.1.0", "2.14.0+rocm10.0.0", "2.13.0+rocm10.0.0"]; + + var distinct = RocmTorchVersionResolver.DistinctByBaseVersion(versions).ToList(); + + CollectionAssert.AreEqual(new[] { "2.14.0+rocm10.1.0", "2.13.0+rocm10.0.0" }, distinct); + } + + [TestMethod] + public void GetLocalVersion_ReturnsLocalLabelOrEmpty() + { + Assert.AreEqual( + "+rocm10.2.0a20261007", + RocmTorchVersionResolver.GetLocalVersion("2.15.0a0+rocm10.2.0a20261007") + ); + Assert.AreEqual(string.Empty, RocmTorchVersionResolver.GetLocalVersion("2.14.0")); + Assert.AreEqual(string.Empty, RocmTorchVersionResolver.GetLocalVersion(null)); + } + + [TestMethod] + public void SelectHighestPairBySnapshot_PicksNewestWhenDatesAlign() + { + string[] torch = ["2.15.0a0+rocm10.2.0a20261007", "2.15.0a0+rocm10.2.0a20261006"]; + string[] torchvision = ["0.30.0a0+rocm10.2.0a20261007", "0.30.0a0+rocm10.2.0a20261006"]; + + var pair = RocmTorchVersionResolver.SelectHighestPairBySnapshot(torch, torchvision); + + Assert.IsNotNull(pair); + Assert.AreEqual("2.15.0a0+rocm10.2.0a20261007", pair.Value.Torch); + Assert.AreEqual("0.30.0a0+rocm10.2.0a20261007", pair.Value.Torchvision); + } + + [TestMethod] + public void SelectHighestPairBySnapshot_UsesNewestSharedDate_WhenNewestTorchIsUnpaired() + { + // torch 20261007 exists but torchvision only has 20261006 -> both pin to 20261006. + string[] torch = ["2.15.0a0+rocm10.2.0a20261007", "2.15.0a0+rocm10.2.0a20261006"]; + string[] torchvision = ["0.30.0a0+rocm10.2.0a20261006", "0.29.0a0+rocm10.2.0a20261006"]; + + var pair = RocmTorchVersionResolver.SelectHighestPairBySnapshot(torch, torchvision); + + Assert.IsNotNull(pair); + Assert.AreEqual("2.15.0a0+rocm10.2.0a20261006", pair.Value.Torch); + Assert.AreEqual("0.30.0a0+rocm10.2.0a20261006", pair.Value.Torchvision); + } + + [TestMethod] + public void SelectHighestPairBySnapshot_ReturnsNull_WhenNoSharedDate() + { + string[] torch = ["2.15.0a0+rocm10.2.0a20261007"]; + string[] torchvision = ["0.30.0a0+rocm10.2.0a20261006"]; + + Assert.IsNull(RocmTorchVersionResolver.SelectHighestPairBySnapshot(torch, torchvision)); + } + + [TestMethod] + public void SelectHighestPairBySnapshot_ReturnsNull_WhenTorchHasNoLocalLabel() + { + string[] torch = ["2.14.0"]; + string[] torchvision = ["0.28.0+rocm10.2.0a20261006"]; + + Assert.IsNull(RocmTorchVersionResolver.SelectHighestPairBySnapshot(torch, torchvision)); + } + + [TestMethod] + public void SelectHighestPairBySnapshot_ReturnsNull_ForNullInputs() + { + Assert.IsNull( + RocmTorchVersionResolver.SelectHighestPairBySnapshot(null, ["0.30.0a0+rocm10.2.0a20261007"]) + ); + Assert.IsNull( + RocmTorchVersionResolver.SelectHighestPairBySnapshot(["2.15.0a0+rocm10.2.0a20261007"], null) + ); + } +}