From c107ab13f719950cc47f5ae9c5f09c7e9f280212 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 13 Oct 2026 20:12:44 -0400 Subject: [PATCH] fix(cutoffs): preserve exact integral scores in strip search Avoid shortest round-trip decimal text for already-integral doubles. The failing tie regression and the independent 9,163-case exact-integer sweep now keep the fewer-pierce result. --- OpenNest.Core/AutomaticCutOffStripSearch.cs | 19 +++++++++---------- .../AutomaticCutOffStripSearchTests.cs | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/OpenNest.Core/AutomaticCutOffStripSearch.cs b/OpenNest.Core/AutomaticCutOffStripSearch.cs index e68d9e30..36075607 100644 --- a/OpenNest.Core/AutomaticCutOffStripSearch.cs +++ b/OpenNest.Core/AutomaticCutOffStripSearch.cs @@ -104,18 +104,17 @@ internal static class AutomaticCutOffStripSearch if (!double.IsFinite(length) || length < 0) throw new ArgumentException("Cut-off segment length must be finite and nonnegative.", nameof(length)); var rounded = System.Math.Round(length, 6); + // A shortest round-trip decimal may spell a different integer near 1e16 + // (for example 26833294515438512d prints as 26833294515438510). + // An already-integral double is exact; never round it through text. + if (rounded == System.Math.Truncate(rounded)) + return new BigInteger(rounded) * 1_000_000; // Compare sums of the specified decimal-rounded model units, not binary // approximations such as 0.1 + 0.2 versus 0.3. The round-trip string - // retains micro-unit digits even at larger (but still fractional) scales. - if (rounded < 1e22) - { - var value = decimal.Parse(rounded.ToString("R", CultureInfo.InvariantCulture), - NumberStyles.Float, CultureInfo.InvariantCulture); - return new BigInteger(decimal.Round(value * 1_000_000m, 0)); - } - // At this magnitude a double has no fractional model units; avoid a - // decimal overflow while retaining an unbounded exact accumulated score. - return new BigInteger(rounded) * 1_000_000; + // retains micro-unit digits for fractional scores. + var value = decimal.Parse(rounded.ToString("R", CultureInfo.InvariantCulture), + NumberStyles.Float, CultureInfo.InvariantCulture); + return new BigInteger(decimal.Round(value * 1_000_000m, 0)); } private static int Compare(State left, State right) diff --git a/OpenNest.Tests/CutOffs/AutomaticCutOffStripSearchTests.cs b/OpenNest.Tests/CutOffs/AutomaticCutOffStripSearchTests.cs index 5c3f70b3..1a4a94ef 100644 --- a/OpenNest.Tests/CutOffs/AutomaticCutOffStripSearchTests.cs +++ b/OpenNest.Tests/CutOffs/AutomaticCutOffStripSearchTests.cs @@ -22,6 +22,20 @@ public class AutomaticCutOffStripSearchTests Assert.Equal(new[] { 20.0, 40.0 }, result); } + [Fact] + public void IntegralDoubleTie_UsesExactValueBeforePierces() + { + var first = 10576245042702564d; + var second = 16257049472735948d; + var combined = 26833294515438512d; + Assert.Equal((decimal)first + (decimal)second, (decimal)combined); + var result = AutomaticCutOffStripSearch.Solve(0, + new[] { new[] { 20.0, 35.0 }, new[] { 40.0, 70.0 } }, + new[] { 35.0, 70.0 }, 35, 17.5, + x => x switch { 20 => (first, 1), 40 => (second, 1), 35 => (combined, 3), _ => (0.0, 0) }); + Assert.Equal(new[] { 20.0, 40.0 }, result); + } + [Fact] public void GlobalOptimum_IsBetterThanGreedyFirstLine() {