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)
+ );
+ }
+}