From 3b64bd93cef2139ea60db244d25507cc7a29a0ea Mon Sep 17 00:00:00 2001 From: backlundtransform Date: Wed, 2 Sep 2026 10:38:32 +0200 Subject: [PATCH 1/4] fix(build): repair the solution and add branch CI Numerics.sln still listed CSharpNumerics.Engines and NumericTest.Engines, which moved to their own repository in e98465b. dotnet build Numerics.sln therefore fails on a clean checkout with MSB3202. That went unnoticed because nothing builds the solution. publish.yml builds and packs Numerics/Numerics/CSharpNumerics.csproj directly, and it runs only on version tags. The same gap has a second consequence worth naming: publish.yml has no dotnet test step at all, so a release can be published to NuGet without a single test having been executed. Locally the suite is blocked by a Windows application control policy that prevents locally built test assemblies from loading, so in practice the tests have not been running anywhere. Removes the two dead project entries and adds a CI workflow that builds the solution and runs the suite on every push and pull request. It does not publish, and publish.yml is left alone - adding a test gate there is a change to the release path and should be a deliberate decision. --- .github/workflows/ci.yml | 43 ++++++++++++++++++++++++++++++++++++++++ Numerics/Numerics.sln | 28 -------------------------- 2 files changed, 43 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9fc3bc8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +# Verifies every branch and pull request. Releases are handled separately by +# publish.yml, which runs on version tags — this workflow never publishes. +# +# It exists because nothing else runs the test suite. publish.yml builds and +# packs the library project directly and never invokes dotnet test, so until now +# a release could go out without a single test having been executed. The suite +# also cannot be relied on to run locally: a Windows application control policy +# can block locally built test assemblies from loading. +on: + push: + branches: + - '**' + tags-ignore: + - 'v*' + pull_request: + +permissions: + contents: read + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 10.0.x + + - name: Restore + run: dotnet restore Numerics/Numerics.sln + + - name: Build + run: dotnet build Numerics/Numerics.sln -c Release --no-restore + + - name: Test + run: dotnet test Numerics/NumericTest/NumericTest.csproj -c Release --no-build diff --git a/Numerics/Numerics.sln b/Numerics/Numerics.sln index d3d3f40..8722a23 100644 --- a/Numerics/Numerics.sln +++ b/Numerics/Numerics.sln @@ -7,12 +7,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CSharpNumerics", "Numerics\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NumericTest", "NumericTest\NumericTest.csproj", "{26D48AFB-281B-452F-B8C8-5BCCD105925C}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSharpNumerics.Engines", "CSharpNumerics.Engines\CSharpNumerics.Engines.csproj", "{55009F7A-AA19-4DBF-84CC-F2AAD2CA2D40}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Numerics", "Numerics", "{35409048-A951-1400-ED33-5FB42D2B3E23}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NumericTest.Engines", "NumericTest.Engines\NumericTest.Engines.csproj", "{18A7CECA-72FF-4A82-B1E4-339B8AB10F8D}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -47,30 +43,6 @@ Global {26D48AFB-281B-452F-B8C8-5BCCD105925C}.Release|x64.Build.0 = Release|Any CPU {26D48AFB-281B-452F-B8C8-5BCCD105925C}.Release|x86.ActiveCfg = Release|Any CPU {26D48AFB-281B-452F-B8C8-5BCCD105925C}.Release|x86.Build.0 = Release|Any CPU - {55009F7A-AA19-4DBF-84CC-F2AAD2CA2D40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {55009F7A-AA19-4DBF-84CC-F2AAD2CA2D40}.Debug|Any CPU.Build.0 = Debug|Any CPU - {55009F7A-AA19-4DBF-84CC-F2AAD2CA2D40}.Debug|x64.ActiveCfg = Debug|Any CPU - {55009F7A-AA19-4DBF-84CC-F2AAD2CA2D40}.Debug|x64.Build.0 = Debug|Any CPU - {55009F7A-AA19-4DBF-84CC-F2AAD2CA2D40}.Debug|x86.ActiveCfg = Debug|Any CPU - {55009F7A-AA19-4DBF-84CC-F2AAD2CA2D40}.Debug|x86.Build.0 = Debug|Any CPU - {55009F7A-AA19-4DBF-84CC-F2AAD2CA2D40}.Release|Any CPU.ActiveCfg = Release|Any CPU - {55009F7A-AA19-4DBF-84CC-F2AAD2CA2D40}.Release|Any CPU.Build.0 = Release|Any CPU - {55009F7A-AA19-4DBF-84CC-F2AAD2CA2D40}.Release|x64.ActiveCfg = Release|Any CPU - {55009F7A-AA19-4DBF-84CC-F2AAD2CA2D40}.Release|x64.Build.0 = Release|Any CPU - {55009F7A-AA19-4DBF-84CC-F2AAD2CA2D40}.Release|x86.ActiveCfg = Release|Any CPU - {55009F7A-AA19-4DBF-84CC-F2AAD2CA2D40}.Release|x86.Build.0 = Release|Any CPU - {18A7CECA-72FF-4A82-B1E4-339B8AB10F8D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {18A7CECA-72FF-4A82-B1E4-339B8AB10F8D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {18A7CECA-72FF-4A82-B1E4-339B8AB10F8D}.Debug|x64.ActiveCfg = Debug|Any CPU - {18A7CECA-72FF-4A82-B1E4-339B8AB10F8D}.Debug|x64.Build.0 = Debug|Any CPU - {18A7CECA-72FF-4A82-B1E4-339B8AB10F8D}.Debug|x86.ActiveCfg = Debug|Any CPU - {18A7CECA-72FF-4A82-B1E4-339B8AB10F8D}.Debug|x86.Build.0 = Debug|Any CPU - {18A7CECA-72FF-4A82-B1E4-339B8AB10F8D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {18A7CECA-72FF-4A82-B1E4-339B8AB10F8D}.Release|Any CPU.Build.0 = Release|Any CPU - {18A7CECA-72FF-4A82-B1E4-339B8AB10F8D}.Release|x64.ActiveCfg = Release|Any CPU - {18A7CECA-72FF-4A82-B1E4-339B8AB10F8D}.Release|x64.Build.0 = Release|Any CPU - {18A7CECA-72FF-4A82-B1E4-339B8AB10F8D}.Release|x86.ActiveCfg = Release|Any CPU - {18A7CECA-72FF-4A82-B1E4-339B8AB10F8D}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From e47ccc460bf7648513496669ef7d929763eb7a4c Mon Sep 17 00:00:00 2001 From: backlundtransform Date: Wed, 2 Sep 2026 14:32:23 +0200 Subject: [PATCH 2/4] test: unblock the suite on Linux and name the empty-fold failure Two failures that the new CI surfaced. Both predate it; nothing had run the suite before. TestMandelbrot constructs a System.Drawing Bitmap, and System.Drawing.Common is Windows-only from .NET 6 onward, so it can never pass on a Linux runner. It now reports inconclusive off Windows rather than failing. Inconclusive rather than skipped because the test is not irrelevant there - it simply cannot run - and the Mandelbrot mathematics is already covered by the complex number tests beside it. The alternative, moving the whole suite onto a Windows runner for one rendering test, would be a poor trade for a library that otherwise targets netstandard2.1. StratifiedKFoldCrossValidator is a real bug in shipped code, and this commit does not fix it. Folds are filled class by class with each class restarting at fold 0, so a class with fewer members than the fold count leaves later folds short, and small classes throughout leave them empty. An empty fold then reaches the VectorN constructor and fails with 'values cannot be null or empty', which says nothing about the cause. I could not reproduce the root cause by reading: the test generates 100 samples in two balanced classes across five folds, which should give twenty per fold and no empty one. Since the suite cannot be executed locally - a Windows application control policy blocks locally built test assemblies - guessing at a fix for cross-validation code that users are already running would be worse than leaving it visible. So this adds a guard that fails with the sample count, the class count and the smallest class size. The test still fails, deliberately, but the next CI run reports what the fold distribution actually is, which is what is needed to fix it properly. --- Numerics/NumericTest/ComplexNumberTests.cs | 12 ++++++++++++ .../StratifiedKFoldCrossValidator.cs | 17 +++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/Numerics/NumericTest/ComplexNumberTests.cs b/Numerics/NumericTest/ComplexNumberTests.cs index 1e5eaee..26d2b23 100644 --- a/Numerics/NumericTest/ComplexNumberTests.cs +++ b/Numerics/NumericTest/ComplexNumberTests.cs @@ -1,4 +1,5 @@ using Xunit.Sdk; +using System; using CSharpNumerics.Numerics.Objects; using System.Drawing; using CSharpNumerics.Numerics; @@ -90,6 +91,17 @@ public void TestComplexDivision() [TestMethod] public void TestMandelbrot() { + // System.Drawing.Common is Windows-only from .NET 6 onward, and CI + // runs on Linux. The Mandelbrot maths is exercised by the complex + // number tests above; this one only checks that it can be rendered, + // so it is reported as inconclusive off Windows rather than being + // silently skipped or forcing the whole suite onto a Windows runner. + if (!OperatingSystem.IsWindows()) + { + Assert.Inconclusive("System.Drawing.Common requires Windows."); + return; + } + var maxValueExtent = 2.0; var bitmap = new Bitmap(600, 600); diff --git a/Numerics/Numerics/ML/CrossValidators/StratifiedKFoldCrossValidator.cs b/Numerics/Numerics/ML/CrossValidators/StratifiedKFoldCrossValidator.cs index 218c1e6..3507129 100644 --- a/Numerics/Numerics/ML/CrossValidators/StratifiedKFoldCrossValidator.cs +++ b/Numerics/Numerics/ML/CrossValidators/StratifiedKFoldCrossValidator.cs @@ -68,6 +68,23 @@ public CrossValidationResult Run(Matrix X, VectorN y) } + // Folds are filled class by class, each class restarting at fold 0, + // so a class with fewer members than Folds leaves the later folds + // short and a data set of entirely small classes leaves them empty. + // Without this the failure surfaces further down as an opaque + // "values cannot be null or empty" from the VectorN constructor, + // which says nothing about the cause. + for (int fold = 0; fold < Folds; fold++) + { + if (foldIndices[fold].Count == 0) + throw new InvalidOperationException( + $"Fold {fold + 1} of {Folds} is empty, so it cannot be validated against. " + + $"Samples: {n}, classes: {classIndices.Count}, " + + $"smallest class: {classIndices.Values.Min(c => c.Count)} member(s). " + + "Stratified folds need at least as many members in every class as there are folds; " + + "reduce the fold count or supply more samples per class."); + } + for (int fold = 0; fold < Folds; fold++) { var testIdx = foldIndices[fold].OrderBy(x => x).ToArray(); From a519e08063ceb3a7798194e18d45473627a11fce Mon Sep 17 00:00:00 2001 From: backlundtransform Date: Wed, 2 Sep 2026 15:08:32 +0200 Subject: [PATCH 3/4] fix(statistics): align Series.FromCsv column names with the data Root cause of the stratified k-fold failure CI surfaced, and it is worse than the failing test. Series.FromCsv returned Cols as header.Skip(1). The idiom was copied from TimeSeries.FromCsv, where column 0 is the time axis and genuinely is excluded from Data. Series has no time axis, so every name was shifted one step left of the column it described, and IndexOf(Cols, name) pointed at the wrong data column. The stratified test was the loud symptom: its y became a continuous feature instead of the class labels, GroupBy produced a hundred singleton classes, the per-class round-robin put every sample in fold 0, and the empty training set for that fold crashed the VectorN constructor. The quiet symptom matters more. RollingCrossValidator and LeaveOneOutCrossValidator resolve the target through the same lookup, so every cross-validation run on a Series loaded from CSV has been training with the target leaked into the features and validating against a feature column - producing plausible-looking scores. The regression test in the existing suite asserted BestScore > -10, which nonsense results clear comfortably. Cols is now the names of exactly the columns present in Data, in order, whatever was excluded. TimeSeries.FromCsv is untouched: its Skip(1) is correct. The stratified test now excludes the target from X via the same looked-up index instead of a hardcoded one, and a regression test pins the Cols-to-Data alignment directly. --- .../NumericTest/TimeserieValidationTests.cs | 22 ++++++++++++++++++- Numerics/Numerics/Statistics/Data/Series.cs | 8 ++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/Numerics/NumericTest/TimeserieValidationTests.cs b/Numerics/NumericTest/TimeserieValidationTests.cs index bb43321..dbe6e1c 100644 --- a/Numerics/NumericTest/TimeserieValidationTests.cs +++ b/Numerics/NumericTest/TimeserieValidationTests.cs @@ -1,4 +1,6 @@ using CSharpNumerics.ML; +using System; +using System.Linq; using CSharpNumerics.ML.CrossValidators; using CSharpNumerics.ML.Models.Classification; using CSharpNumerics.ML.Models.Regression; @@ -70,6 +72,24 @@ public void ShuffleSplitCV_Should_WorkOnRegressionData() Assert.IsTrue(result.BestScore > -1); } + [TestMethod] + public void SeriesFromCsv_ColsMustAlignWithData() + { + // Regression test for the off-by-one that made every Series-based + // cross-validation train on a leaked target and validate against a + // feature: Cols was header.Skip(1), so IndexOf(Cols, name) pointed + // one column left of the truth in Data. + CsvTestDataGenerator.GenerateClassificationCsv("cols_alignment.csv"); + var df = Series.FromCsv("cols_alignment.csv"); + + Assert.AreEqual(df.Data.Length, df.Cols.Length, + "every data column must have a name"); + + var target = df.Data[Array.IndexOf(df.Cols, "Target")]; + Assert.IsTrue(target.All(v => v == 0.0 || v == 1.0), + "the column Cols calls 'Target' must hold the class labels, not a feature"); + } + [TestMethod] public void StratifiedKFoldCV_Should_WorkOnClassificationData() { @@ -85,7 +105,7 @@ public void StratifiedKFoldCV_Should_WorkOnClassificationData() var cv = new StratifiedKFoldCrossValidator(pipelineGrid, folds: 5); - var result = cv.Run(df.ToMatrix(2), new VectorN(df.Data[colIndex])); + var result = cv.Run(df.ToMatrix(colIndex), new VectorN(df.Data[colIndex])); Assert.IsTrue(result.BestScore > 0); diff --git a/Numerics/Numerics/Statistics/Data/Series.cs b/Numerics/Numerics/Statistics/Data/Series.cs index 6e5354c..e6aac49 100644 --- a/Numerics/Numerics/Statistics/Data/Series.cs +++ b/Numerics/Numerics/Statistics/Data/Series.cs @@ -113,6 +113,12 @@ public static Series FromCsv( } var index = Enumerable.Range(0, rows).ToArray(); - return new Series(index, data, [.. header.Skip(1)], groups); + + // Cols must name exactly the columns that ended up in Data, in the same + // order. The previous header.Skip(1) was copied from TimeSeries, where + // column 0 is the time axis and really is excluded from Data - here it + // shifted every name one step left, so a lookup like + // IndexOf(Cols, "Target") pointed at the wrong data column. + return new Series(index, data, [.. featureIdx.Select(i => header[i])], groups); } } From 46981248dd880defc8cc3c535d59a6a45dd03334 Mon Sep 17 00:00:00 2001 From: backlundtransform Date: Thu, 3 Sep 2026 11:10:48 +0200 Subject: [PATCH 4/4] fix(ml): make a seeded Monte Carlo clustering run deterministic end to end RunBootstrap_SameSeed_ShouldBeReproducible failed in CI with two seeded runs differing by 0.014. The test has always been flaky; it was exposed, not broken, by the recent changes. MonteCarloClustering.Seed seeded the bootstrap sampling and nothing else. The model clone fitted inside each iteration kept its own seed, and the test's KMeans had none - so every centroid initialisation ran off new Random(), which draws a fresh nondeterministic seed per instance. Two identical seeded runs therefore agreed only as long as k-means happened to land in the same local optima both times. On well-separated data that is usually true, which is why the test mostly passed, and 'mostly' is the definition of a flaky test. A seeded run now hands each model clone a seed derived from the run's own generator, through the SetHyperParameters mechanism the models already have. Derived per iteration rather than fixed, so bootstrap replicates keep independent initialisations. Models that take no seed, like DBSCAN, ignore the key. Unseeded runs are untouched. Exact numbers from previously seeded runs will differ, since the generator now also feeds the model seeds. Nothing can have depended on the old values - the model initialisation was nondeterministic, which is the bug. --- .../ML/Clustering/MonteCarloClustering.cs | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/Numerics/Numerics/ML/Clustering/MonteCarloClustering.cs b/Numerics/Numerics/ML/Clustering/MonteCarloClustering.cs index bc186a6..b34a2ca 100644 --- a/Numerics/Numerics/ML/Clustering/MonteCarloClustering.cs +++ b/Numerics/Numerics/ML/Clustering/MonteCarloClustering.cs @@ -1,4 +1,5 @@ using CSharpNumerics.ML.Clustering.Interfaces; +using CSharpNumerics.ML.Models.Interfaces; using CSharpNumerics.ML.Clustering.Results; using CSharpNumerics.ML.Scalers.Interfaces; using CSharpNumerics.Numerics.Objects; @@ -62,7 +63,13 @@ public class MonteCarloClustering /// Number of Monte Carlo iterations. Default 100. public int Iterations { get; set; } = 100; - /// Optional random seed for reproducibility. + /// + /// Optional random seed for reproducibility. When set it governs the whole + /// run: bootstrap sampling, and a per-iteration seed handed to each model + /// clone that accepts one, so an unseeded KMeans no longer re-randomises its + /// initialisation between what should be identical runs. Models that take no + /// seed, like DBSCAN, ignore it. + /// public int? Seed { get; set; } /// Confidence level for reported intervals. Default 0.95. @@ -120,6 +127,7 @@ public MonteCarloClusteringResult RunBootstrap( // 3. Fit & predict var modelClone = algorithm.Clone(); + SeedClone(modelClone, rng); VectorN labels = modelClone.FitPredict(xBoot); // 4. Score @@ -257,6 +265,7 @@ public MonteCarloClusteringResult RunExperiment( { var modelClone = algorithm.Clone(); SetK(modelClone, k); + SeedClone(modelClone, rng); VectorN labels = modelClone.FitPredict(xBoot); double score = evaluator.Score(xBoot, labels); @@ -304,6 +313,25 @@ public MonteCarloClusteringResult RunExperiment( /// /// Bootstrap sampling: draw n indices from [0, n) with replacement. /// + /// + /// Hands a model clone a seed derived from the run's generator, so that a + /// seeded Monte Carlo run is deterministic end to end. Without this the + /// bootstrap sampling was reproducible but the model's own initialisation + /// was not, and RunBootstrap on an unseeded KMeans differed between + /// identical runs whenever k-means landed in a different local optimum. + /// Derived per iteration rather than fixed, so replicates stay independent. + /// No-op when the run is unseeded or the model takes no hyperparameters. + /// + private void SeedClone(IClusteringModel modelClone, RandomGenerator rng) + { + if (!Seed.HasValue) return; + if (modelClone is IHasHyperparameters seedable) + seedable.SetHyperParameters(new Dictionary + { + ["Seed"] = rng.NextInt(int.MaxValue) + }); + } + private static int[] SampleWithReplacement(RandomGenerator rng, int populationSize, int sampleSize) { var result = new int[sampleSize];