diff --git a/OpenNest.Core/PerfCounters.cs b/OpenNest.Core/PerfCounters.cs index 8bec7d5..faf5758 100644 --- a/OpenNest.Core/PerfCounters.cs +++ b/OpenNest.Core/PerfCounters.cs @@ -12,10 +12,12 @@ namespace OpenNest private static long findBestFits; private static long offsetPerimeterEntities; private static long partIntersects; + private static long fillScoreComputations; public static long FindBestFits => Interlocked.Read(ref findBestFits); public static long OffsetPerimeterEntities => Interlocked.Read(ref offsetPerimeterEntities); public static long PartIntersects => Interlocked.Read(ref partIntersects); + public static long FillScoreComputations => Interlocked.Read(ref fillScoreComputations); [Conditional("DEBUG")] public static void CountFindBestFits() => Interlocked.Increment(ref findBestFits); @@ -27,11 +29,15 @@ namespace OpenNest [Conditional("DEBUG")] public static void CountPartIntersects() => Interlocked.Increment(ref partIntersects); + [Conditional("DEBUG")] + public static void CountFillScoreComputation() => Interlocked.Increment(ref fillScoreComputations); + public static void Reset() { Interlocked.Exchange(ref findBestFits, 0); Interlocked.Exchange(ref offsetPerimeterEntities, 0); Interlocked.Exchange(ref partIntersects, 0); + Interlocked.Exchange(ref fillScoreComputations, 0); } } } diff --git a/OpenNest.Engine/Fill/DefaultFillComparer.cs b/OpenNest.Engine/Fill/DefaultFillComparer.cs index b09fe57..73049e9 100644 --- a/OpenNest.Engine/Fill/DefaultFillComparer.cs +++ b/OpenNest.Engine/Fill/DefaultFillComparer.cs @@ -17,6 +17,9 @@ namespace OpenNest.Engine.Fill if (current == null || current.Count == 0) return true; + if (candidate.Count != current.Count) + return candidate.Count > current.Count; + return FillScore.Compute(candidate, workArea) > FillScore.Compute(current, workArea); } } diff --git a/OpenNest.Engine/Fill/FillScore.cs b/OpenNest.Engine/Fill/FillScore.cs index 78ef417..d13c2cf 100644 --- a/OpenNest.Engine/Fill/FillScore.cs +++ b/OpenNest.Engine/Fill/FillScore.cs @@ -26,6 +26,8 @@ namespace OpenNest.Engine.Fill if (parts == null || parts.Count == 0) return default; + PerfCounters.CountFillScoreComputation(); + var totalPartArea = 0.0; var minX = double.MaxValue; var minY = double.MaxValue; diff --git a/OpenNest.Tests/Fill/FillComparerTests.cs b/OpenNest.Tests/Fill/FillComparerTests.cs index 5c498aa..4f7ddd4 100644 --- a/OpenNest.Tests/Fill/FillComparerTests.cs +++ b/OpenNest.Tests/Fill/FillComparerTests.cs @@ -1,6 +1,8 @@ +using OpenNest.CNC; using OpenNest.Engine; using OpenNest.Engine.Fill; using OpenNest.Geometry; +using OpenNest.Tests.BestFit; namespace OpenNest.Tests.Fill; @@ -62,8 +64,212 @@ public class DefaultFillComparerTests }; Assert.True(comparer.IsBetter(candidate, current, workArea)); } + + [Fact] + public void LowerCount_ReturnsFalse() + { + var candidate = new List { TestHelpers.MakePartAt(0, 0, 10) }; + var current = new List + { + TestHelpers.MakePartAt(0, 0, 10), + TestHelpers.MakePartAt(20, 0, 10), + }; + + Assert.False(comparer.IsBetter(candidate, current, workArea)); + } + + [Fact] + public void SameCount_LowerDensity_ReturnsFalse() + { + var candidate = new List + { + TestHelpers.MakePartAt(0, 0, 10), + TestHelpers.MakePartAt(50, 0, 10), + }; + var current = new List + { + TestHelpers.MakePartAt(0, 0, 10), + TestHelpers.MakePartAt(12, 0, 10), + }; + + Assert.False(comparer.IsBetter(candidate, current, workArea)); + } + + [Fact] + public void ExactScoreTie_ReturnsFalseInBothOrders() + { + var candidate = new List + { + TestHelpers.MakePartAt(5, 7, 10), + TestHelpers.MakePartAt(25, 7, 10), + }; + var current = new List + { + TestHelpers.MakePartAt(0, 0, 10), + TestHelpers.MakePartAt(20, 0, 10), + }; + + Assert.Equal(FillScore.Compute(candidate, workArea), FillScore.Compute(current, workArea)); + Assert.False(comparer.IsBetter(candidate, current, workArea)); + Assert.False(comparer.IsBetter(current, candidate, workArea)); + Assert.False(comparer.IsBetter(candidate, candidate, workArea)); + } + + [Fact] + public void UnequalCounts_SmallerLayoutIsDenser_ButCountWinsInBothOrders() + { + var larger = new List + { + TestHelpers.MakePartAt(0, 0, 10), + TestHelpers.MakePartAt(40, 0, 10), + TestHelpers.MakePartAt(80, 0, 10), + }; + var smaller = new List + { + TestHelpers.MakePartAt(0, 0, 10), + TestHelpers.MakePartAt(12, 0, 10), + }; + + Assert.True(FillScore.Compute(smaller, workArea).Density > FillScore.Compute(larger, workArea).Density); + Assert.True(comparer.IsBetter(larger, smaller, workArea)); + Assert.False(comparer.IsBetter(smaller, larger, workArea)); + } + + [Theory] + [InlineData(0, 0, false)] + [InlineData(0, 1, false)] + [InlineData(0, 2, false)] + [InlineData(1, 0, false)] + [InlineData(1, 1, false)] + [InlineData(1, 2, false)] + [InlineData(2, 0, true)] + [InlineData(2, 1, true)] + [InlineData(2, 2, false)] + public void NullEmptyAndNonemptyInputs_PreserveGuardOrder(int candidateKind, int currentKind, bool expected) + { + // 0 = null, 1 = empty, 2 = one valid part; comparing that part to itself ties. + var inputs = new List?[] + { + null, + new(), + new() { TestHelpers.MakePartAt(0, 0, 10) }, + }; + + Assert.Equal(expected, comparer.IsBetter(inputs[candidateKind], inputs[currentKind], workArea)); + } + + [Fact] + public void ValidLayoutMatrix_MatchesReferenceInBothOrdersAndTies_WithoutMutatingInputs() + { + var layouts = new List> { new() }; + foreach (var count in new[] { 1, 2, 4, 7 }) + foreach (var size in new[] { 1.0, 3.0 }) + foreach (var pitch in new[] { 4.0, 12.0 }) + foreach (var origin in new[] { new Vector(0, 0), new Vector(5, 9) }) + { + var parts = new List(); + for (var i = 0; i < count; i++) + parts.Add(TestHelpers.MakePartAt(origin.X + i % 3 * pitch, origin.Y + i / 3 * pitch, size)); + // Also vary enumeration order; the comparer must not reorder caller lists. + if (origin.X > 0) + parts.Reverse(); + layouts.Add(parts); + } + + foreach (var parts in layouts) + { + for (var i = 0; i < parts.Count; i++) + { + var part = parts[i]; + Assert.True(double.IsFinite(part.BaseDrawing.Area)); + Assert.True(part.BaseDrawing.Area > 0); + Assert.True(workArea.Contains(part.BoundingBox)); + foreach (var value in new[] { part.Left, part.Right, part.Top, part.Bottom, part.Rotation }) + Assert.True(double.IsFinite(value)); + for (var j = 0; j < i; j++) + Assert.False(part.BoundingBox.Intersects(parts[j].BoundingBox)); + } + } + + var before = layouts.Select(Snapshot).ToArray(); + var workAreaBefore = (workArea.X, workArea.Y, workArea.Length, workArea.Width); + // Full Cartesian matrix includes both argument orders, self-comparisons, + // translated/permuted exact ties, and equal/unequal counts and densities. + foreach (var candidate in layouts) + foreach (var current in layouts) + { + var expected = FillScore.Compute(candidate, workArea) > FillScore.Compute(current, workArea); + Assert.Equal(expected, comparer.IsBetter(candidate, current, workArea)); + } + + for (var i = 0; i < layouts.Count; i++) + Assert.Equal(before[i], Snapshot(layouts[i])); + Assert.Equal(workAreaBefore, (workArea.X, workArea.Y, workArea.Length, workArea.Width)); + } + + private static object[] Snapshot(List parts) + { + var values = new List(); + foreach (var part in parts) + { + values.Add(part); + values.Add(part.BaseDrawing); + values.Add(part.BaseDrawing.Area); + values.Add(part.Location); + values.Add(part.Rotation); + values.Add(part.BoundingBox); + values.Add((part.Left, part.Right, part.Top, part.Bottom)); + foreach (var program in new[] { part.Program, part.BaseDrawing.Program }) + { + values.Add(program); + values.Add(program.Mode); + values.Add(program.Rotation); + foreach (var code in program.Codes) + { + values.Add(code); + if (code is Motion motion) + values.Add(motion.EndPoint); + } + } + } + return values.ToArray(); + } } +#if DEBUG +// PerfCounters is process-wide. This collection excludes all parallel tests; +// always clear counters in finally, including when the skipped-work assertion fails. +[Collection(nameof(FillCacheCollection))] +public class DefaultFillComparerWorkTests +{ + [Theory] + [InlineData(2, 1, 0)] + [InlineData(1, 2, 0)] + [InlineData(2, 2, 2)] + [InlineData(0, 1, 0)] + [InlineData(1, 0, 0)] + public void IsBetter_ComputesScoresOnlyForNonemptyEqualCounts(int candidateCount, int currentCount, long expectedComputations) + { + var candidate = Enumerable.Range(0, candidateCount).Select(i => TestHelpers.MakePartAt(i * 20, 0, 10)).ToList(); + var current = Enumerable.Range(0, currentCount).Select(i => TestHelpers.MakePartAt(i * 20, 0, 10)).ToList(); + var workArea = new Box(0, 0, 100, 100); + var comparer = new DefaultFillComparer(); + var expected = FillScore.Compute(candidate, workArea) > FillScore.Compute(current, workArea); + + PerfCounters.Reset(); + try + { + Assert.Equal(expected, comparer.IsBetter(candidate, current, workArea)); + Assert.Equal(expectedComputations, PerfCounters.FillScoreComputations); + } + finally + { + PerfCounters.Reset(); + } + } +} +#endif + public class VerticalRemnantComparerTests { private readonly IFillComparer comparer = new VerticalRemnantComparer(); diff --git a/OpenNest.Tests/Fill/FillPerformanceTests.cs b/OpenNest.Tests/Fill/FillPerformanceTests.cs new file mode 100644 index 0000000..0b0ed08 --- /dev/null +++ b/OpenNest.Tests/Fill/FillPerformanceTests.cs @@ -0,0 +1,157 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using OpenNest.Engine.Fill; +using OpenNest.Geometry; +using OpenNest.Shapes; +using OpenNest.Tests.BestFit; +using Xunit.Abstractions; + +namespace OpenNest.Tests.Fill; + +[Collection(nameof(FillCacheCollection))] +[Trait("Category", "FillPerformance")] +public class FillPerformanceTests +{ + private readonly ITestOutputHelper output; + + public FillPerformanceTests(ITestOutputHelper output) => this.output = output; + + [SkippableFact] + public void DefaultComparer_ReportsUnequalCountsAndEqualCountControl() + { + // Xunit.SkippableFact 1.4.13 calls its skip-unless API IfNot. + Skip.IfNot(Environment.GetEnvironmentVariable("OPENNEST_RUN_FILL_PERF") == "1", + "Set OPENNEST_RUN_FILL_PERF=1 to run opt-in fill microbenchmarks."); + + var workArea = new Box(0, 0, 256, 256); + var drawing = new RectangleShape { Length = 2, Width = 1 }.GetDrawing(); + var larger = MakeGrid(drawing, 2048, 4); + var smaller = MakeGrid(drawing, 2047, 3); + var equalCountCompact = MakeGrid(drawing, 2048, 3); + AssertValidRectangles(larger, workArea); + AssertValidRectangles(smaller, workArea); + AssertValidRectangles(equalCountCompact, workArea); + Assert.True(FillScore.Compute(smaller, workArea).Density > FillScore.Compute(larger, workArea).Density); + +#if DEBUG + output.WriteLine("Configuration=Debug (diagnostic only; use Release for measurements)."); +#else + output.WriteLine("Configuration=Release."); +#endif + output.WriteLine($"Runtime={RuntimeInformation.FrameworkDescription}; OS={RuntimeInformation.OSDescription}; " + + $"architecture={RuntimeInformation.ProcessArchitecture}; processors={Environment.ProcessorCount}; " + + $"Stopwatch.Frequency={Stopwatch.Frequency} ticks/s."); + output.WriteLine("Synthetic 2x1 rectangles, 64 columns, work area=(0,0,256,256). " + + "Larger: 2048 parts, pitch=4; smaller: 2047 parts, pitch=3; equal-count compact: 2048 parts, pitch=3."); + output.WriteLine("Each batch alternates argument order (half forward, half reverse). " + + "Actual/reference batch order alternates between repetitions. " + + "Construction, validation, assertions and output excluded; delegate/loop/result-consumption overhead included. " + + "Allocations use GC.GetAllocatedBytesForCurrentThread around synchronous calls only. " + + "These are local microbenchmarks, not timing gates or whole-job speedup estimates."); + + // A large fixed batch also makes the optimized, constant-time path measurable. + // Keep these inputs and iteration counts identical for before/after measurements. + ReportCase("unequal-counts", larger, smaller, workArea, 500_000); + ReportCase("equal-count-control", equalCountCompact, larger, workArea, 10_000); + } + + private void ReportCase(string name, List candidate, List current, Box workArea, int callsPerBatch) + { + var comparer = new DefaultFillComparer(); + var actual = new Func, List, Box, bool>(comparer.IsBetter); + var reference = new Func, List, Box, bool>((a, b, area) => + FillScore.Compute(a, area) > FillScore.Compute(b, area)); + var expectedForward = reference(candidate, current, workArea); + var expectedReverse = reference(current, candidate, workArea); + Assert.Equal(expectedForward, actual(candidate, current, workArea)); + Assert.Equal(expectedReverse, actual(current, candidate, workArea)); + var expectedTrueCount = callsPerBatch / 2 * ((expectedForward ? 1 : 0) + (expectedReverse ? 1 : 0)); + + var warmupCallsPerBatch = 50_000; + var repetitions = 7; + // Interleaved warmup allows JIT/tiering and cached drawing/bounds access to settle. + for (var i = 0; i < 2; i++) + { + Measure(actual, candidate, current, workArea, warmupCallsPerBatch); + Measure(reference, candidate, current, workArea, warmupCallsPerBatch); + } + + output.WriteLine($"{name}: warmup=2 batches x {warmupCallsPerBatch} calls per implementation; " + + $"measured={repetitions} batches x {callsPerBatch} calls per implementation; " + + $"expected true results/batch={expectedTrueCount}."); + var actualSamples = new Sample[repetitions]; + var referenceSamples = new Sample[repetitions]; + for (var i = 0; i < repetitions; i++) + { + if (i % 2 == 0) + { + actualSamples[i] = Measure(actual, candidate, current, workArea, callsPerBatch); + referenceSamples[i] = Measure(reference, candidate, current, workArea, callsPerBatch); + } + else + { + referenceSamples[i] = Measure(reference, candidate, current, workArea, callsPerBatch); + actualSamples[i] = Measure(actual, candidate, current, workArea, callsPerBatch); + } + // Consume measured results and check correctness outside the timed region. + Assert.Equal(expectedTrueCount, actualSamples[i].TrueCount); + Assert.Equal(expectedTrueCount, referenceSamples[i].TrueCount); + output.WriteLine(FormattableString.Invariant( + $"{name} batch {i + 1}: actual={actualSamples[i].Milliseconds:F6} ms, {actualSamples[i].AllocatedBytes} B; reference={referenceSamples[i].Milliseconds:F6} ms, {referenceSamples[i].AllocatedBytes} B.")); + } + ReportSummary(name, "actual", actualSamples, callsPerBatch); + ReportSummary(name, "reference", referenceSamples, callsPerBatch); + } + + private void ReportSummary(string name, string implementation, Sample[] samples, int callsPerBatch) + { + var times = samples.Select(s => s.Milliseconds).OrderBy(t => t).ToArray(); + var bytes = samples.Select(s => s.AllocatedBytes).OrderBy(b => b).ToArray(); + var median = samples.Length / 2; + output.WriteLine(FormattableString.Invariant( + $"{name} {implementation}: batch ms min/median/max={times[0]:F6}/{times[median]:F6}/{times[^1]:F6}; ns/call min/median/max={times[0] * 1_000_000 / callsPerBatch:F3}/{times[median] * 1_000_000 / callsPerBatch:F3}/{times[^1] * 1_000_000 / callsPerBatch:F3}; batch bytes min/median/max={bytes[0]}/{bytes[median]}/{bytes[^1]}; B/call min/median/max={(double)bytes[0] / callsPerBatch:F6}/{(double)bytes[median] / callsPerBatch:F6}/{(double)bytes[^1] / callsPerBatch:F6}.")); + } + + private static Sample Measure(Func, List, Box, bool> compare, + List candidate, List current, Box workArea, int calls) + { + var trueCount = 0; + var allocatedBefore = GC.GetAllocatedBytesForCurrentThread(); + var start = Stopwatch.GetTimestamp(); + for (var i = 0; i < calls; i++) + { + var forward = i % 2 == 0; + if (compare(forward ? candidate : current, forward ? current : candidate, workArea)) + trueCount++; + } + var elapsed = Stopwatch.GetTimestamp() - start; + var allocated = GC.GetAllocatedBytesForCurrentThread() - allocatedBefore; + return new Sample(elapsed * 1000.0 / Stopwatch.Frequency, allocated, trueCount); + } + + private static List MakeGrid(Drawing drawing, int count, double pitch) + { + var parts = new List(count); + for (var i = 0; i < count; i++) + parts.Add(new Part(drawing, new Vector(i % 64 * pitch, i / 64 * pitch))); + return parts; + } + + private static void AssertValidRectangles(List parts, Box workArea) + { + for (var i = 0; i < parts.Count; i++) + { + var part = parts[i]; + Assert.Equal(2.0, part.BaseDrawing.Area); + Assert.Equal(2.0, part.BoundingBox.Length); + Assert.Equal(1.0, part.BoundingBox.Width); + Assert.True(workArea.Contains(part.BoundingBox)); + foreach (var value in new[] { part.Left, part.Right, part.Bottom, part.Top, part.Rotation }) + Assert.True(double.IsFinite(value)); + for (var j = 0; j < i; j++) + Assert.False(part.BoundingBox.Intersects(parts[j].BoundingBox)); + } + } + + private readonly record struct Sample(double Milliseconds, long AllocatedBytes, int TrueCount); +} diff --git a/README.md b/README.md index 9be2c6c..a424cbd 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,17 @@ dotnet run --project OpenNest/OpenNest.csproj # desktop app (W `OpenNest.WinForms.Tests` (desktop-assembly tests) runs on Windows only. Format changed files with `dotnet format OpenNest.sln --include `. +### Opt-in fill performance measurements + +```bash +OPENNEST_RUN_FILL_PERF=1 dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release \ + --filter "Category=FillPerformance" --logger "console;verbosity=detailed" +``` + +For PowerShell, set `$env:OPENNEST_RUN_FILL_PERF = '1'` before the `dotnet test` command and remove it afterward with `Remove-Item Env:OPENNEST_RUN_FILL_PERF`. Without the exact value `1`, these tests skip, including during normal suite runs. + +The comparer microbenchmark uses deterministic, valid nonoverlapping rectangles, warmup, seven interleaved actual/reference batches, both argument orders, and an equal-count control. It reports min/median/max duration and synchronous current-thread allocations, both per batch and per call; construction and correctness assertions are outside the timed region. Keep inputs and batch sizes fixed when comparing changes. These measurements are not timing-threshold tests and do not establish whole-job speedups. Debug-only skipped-score work checks run with `dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Debug --filter "FullyQualifiedName~DefaultFillComparerWorkTests"`. + ### Quick start 1. File > New Nest diff --git a/docs/performance/fill-performance.md b/docs/performance/fill-performance.md new file mode 100644 index 0000000..e750784 --- /dev/null +++ b/docs/performance/fill-performance.md @@ -0,0 +1,115 @@ +# Fill performance measurements + +## Count-first comparer slice — 2026-09-25 + +This report covers only the count-first `DefaultFillComparer` change. Geometry, custom-comparer score elimination, ML work, and whole-job optimization have not been implemented or measured here. + +### Source and environment + +Final staged review corrected two delegate declarations to the repository's `var` style. The complete original/optimized measurement pair was then rerun with that identical final harness; all timing tables below are from those final runs, not mixed with earlier samples. The original production comparer was temporarily restored only for the before measurement and reinstated in `finally`. + +- Baseline: `1b862dc1a896df502935b900094c15c3300999c5` on `master`. +- Optimized source: the count-first implementation delivered with this report; use `git log -1 --format=%H -- OpenNest.Engine/Fill/DefaultFillComparer.cs` to resolve its implementation commit. +- The original comparer was measured before adding the fast path. Tests and the Debug-only diagnostic were already present; the diagnostic call is compiled out in Release. +- Original comparer SHA-256: `8d436b88831d3187b319d52084131773f998c94ff4a227262cf15e4d27013b8f`. +- Optimized comparer SHA-256: `2444d30ddc92fa662f5ad50ac2cca0a7c50da3aee15f0fc932d58cf64023333d`. +- Identical before/after measurement harness SHA-256: `6a3b911748e31027bab6922d9d54f89b6fd7257884e1eecc5461db032089c7bf` (`OpenNest.Tests/Fill/FillPerformanceTests.cs`). +- Machine: `hermes`, KVM guest, 4 vCPUs presented as AMD Ryzen 9 5900X; Ubuntu 24.04.5 LTS x64; Linux 6.8.0-142-generic. No CPU pinning. +- Build SDK: 10.0.112. Measured runtime, emitted by the test: .NET 8.0.31; Release; stopwatch frequency 1,000,000,000 ticks/second. + +### Workload and timing scope + +All inputs are fixture-independent, finite, nonoverlapping 2×1 rectangles inside a `(0,0,256,256)` work area, arranged in 64 columns. The larger layout has 2,048 parts at pitch 4; the smaller layout has 2,047 at pitch 3 (higher density despite lower count). The equal-count control compares 2,048 parts at pitch 3 against the larger layout. + +Each implementation gets two warmup batches of 50,000 calls per case, then seven measured batches. Unequal-count batches contain 500,000 calls; equal-count batches contain 10,000. Argument order alternates each call, with half forward and half reverse. Actual/reference batch order alternates between repetitions. The reference is the prior scoring expression, `FillScore.Compute(a, area) > FillScore.Compute(b, area)`, not a duplicate comparer class. + +Inputs, cached drawing areas/bounds, and JIT paths are warm. There is no fill-cache operation or cache reset in this workload. Construction, layout validation, correctness assertions, and output are outside timing. Delegate/loop/result-consumption overhead and JIT optimization are included. Returned true counts are checked after each batch. + +Allocated bytes are measured around synchronous work with `GC.GetAllocatedBytesForCurrentThread`, not process-wide memory or RSS. Every batch of both implementations, before and after, reported zero allocated managed bytes on the measured thread. + +### Results + +Nanoseconds per call, minimum / median / maximum over seven batches: + +| Case | Implementation | Before | After | +| --- | --- | --- | --- | +| unequal-counts | Comparer | 11901.864 / 11979.646 / 12194.228 | 1.800 / 1.878 / 3.221 | +| unequal-counts | Reference expression | 11654.151 / 12085.460 / 12269.212 | 12093.576 / 12247.735 / 12419.104 | +| equal-count-control | Comparer | 11617.457 / 12455.571 / 12814.497 | 11874.469 / 12372.208 / 12666.589 | +| equal-count-control | Reference expression | 11576.379 / 12344.544 / 12983.099 | 11736.897 / 12565.047 / 12864.353 | + +The unequal-count median batch decreased from 5,989.823190 ms to 0.939182 ms. Their ratio is approximately 6,378× for this warm, repeated local-operation workload only. The optimized path is close to loop/JIT overhead; its nanosecond estimate is not an isolated method-latency guarantee. The unchanged reference varied slightly between processes, illustrating host/runtime variability. Do not extrapolate the ratio to nesting jobs. + +Equal-count medians remained in the same range as the reference; these runs show no material regression, not evidence of an equal-count optimization. No elapsed-time thresholds are enforced in CI. + +### Behavioral and work-removal evidence + +- Characterization covers null/empty guard order, lower-count rejection, count winning against density, both density directions for equal counts, and exact ties retaining the current result. +- A deterministic matrix of 33 valid layouts checks all 1,089 ordered pairs against the reference expression, including empty, self, translated, and reordered cases. It snapshots caller part/drawing/program identities, positions, rotations, bounds, and motion endpoints. +- A Debug-only `PerfCounters.FillScoreComputations` diagnostic follows the existing conditional counter pattern. Concrete lists and nonvirtual getters do not offer a practical direct scan spy without broader production changes. +- Before the fast path, both unequal-count work assertions failed with **expected 0, actual 2** score computations; the other three work cases passed. Afterward, all five passed: unequal/empty cases compute no scores, while nonempty equal-count cases compute both scores. Counter tests run in the existing nonparallel `FillCacheCollection` and reset counters in `finally`. +- Debug instrumentation does not establish Release behavior by reading zero counters; Release evidence is the compiled-out call convention, source fast path, equivalent outputs, and timings above. + +### Test results + +| Run | Passed | Skipped | Failed | +| --- | ---: | ---: | ---: | +| Original full main suite, Release | 1,140 | 12 | 0 | +| Original full engine suite, Release | 300 | 0 | 0 | +| Final full main suite, Release | 1,154 | 13 | 0 | +| Final full main suite, Debug | 1,159 | 13 | 0 | +| Final full engine suite, Release | 300 | 0 | 0 | + +The 12 original skips are optional `ChrFontTests`: `ChrFontPath not configured in test-config.json or file not found`. The additional final skip is the opt-in performance test. The five extra Debug cases are the score-work tests. The performance test passed with the opt-in value `1`, and was separately verified to skip when unset and when set to `0`. + +The implementer's broader comparer/score selection passed 28 Release tests before and after, and 33 Debug tests afterward (it also selected `NestProgressTests.BestDensity_MatchesFillScoreFormula`). Parent re-verification using the plan's narrower `FillComparerTests|FillScoreTests` filter passed 27 Release tests, and the five Debug work tests passed separately. Existing unrelated compiler warnings remain; no failures are hidden as baseline bugs. Source formatting and `git diff --check` passed. + +### Reproduction + +```bash +OPENNEST_RUN_FILL_PERF=1 dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release \ + --filter 'Category=FillPerformance' --logger 'console;verbosity=detailed' +dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Debug \ + --filter 'FullyQualifiedName~DefaultFillComparerWorkTests' +``` + +Normal suite runs skip the measurement. The installed Xunit.SkippableFact 1.4.13 names its inverse skip predicate `Skip.IfNot`, not `Skip.Unless`; no package change was needed. README documents both shell and PowerShell invocation. + +The corresponding `CLAUDE.md` workflow edit was denied by the protected-file permission gate. That file was left unchanged; this documentation sync remains blocked pending explicit approval. No retry or alternate write was attempted. + +No Windows UI runtime tests, actual ONNX inference, representative production corpus, or whole-job benchmark was run for this slice. These results do not complete the broader initial optimization batch. + +### Raw measured batches + +Times in milliseconds; allocations were 0 bytes for every actual/reference batch below. Preserved from matching raw TRX and console outputs before removing temporary test artifacts. + +| State | Case | Batch | Comparer ms | Reference ms | +| --- | --- | ---: | ---: | ---: | +| pre | unequal-counts | 1 | 6097.114079 | 6093.454749 | +| pre | unequal-counts | 2 | 5950.931964 | 6031.795978 | +| pre | unequal-counts | 3 | 6010.884370 | 5827.075456 | +| pre | unequal-counts | 4 | 5989.823190 | 6019.619401 | +| pre | unequal-counts | 5 | 5953.063588 | 6134.606110 | +| pre | unequal-counts | 6 | 6051.466272 | 6042.729937 | +| pre | unequal-counts | 7 | 5973.554301 | 6083.705108 | +| pre | equal-count-control | 1 | 126.868005 | 129.830987 | +| pre | equal-count-control | 2 | 124.555712 | 128.105601 | +| pre | equal-count-control | 3 | 125.615121 | 124.509854 | +| pre | equal-count-control | 4 | 128.144975 | 120.288678 | +| pre | equal-count-control | 5 | 121.787519 | 123.445436 | +| pre | equal-count-control | 6 | 116.562527 | 115.763791 | +| pre | equal-count-control | 7 | 116.174565 | 118.153899 | +| post | unequal-counts | 1 | 0.931488 | 6123.867689 | +| post | unequal-counts | 2 | 0.933221 | 6046.787840 | +| post | unequal-counts | 3 | 0.939182 | 6209.551894 | +| post | unequal-counts | 4 | 1.610389 | 6120.239321 | +| post | unequal-counts | 5 | 1.210914 | 6145.847134 | +| post | unequal-counts | 6 | 0.945585 | 6160.595776 | +| post | unequal-counts | 7 | 0.899788 | 6097.613482 | +| post | equal-count-control | 1 | 122.282550 | 121.856075 | +| post | equal-count-control | 2 | 124.576228 | 123.526717 | +| post | equal-count-control | 3 | 123.722076 | 125.650465 | +| post | equal-count-control | 4 | 126.342491 | 128.643534 | +| post | equal-count-control | 5 | 126.665891 | 125.683547 | +| post | equal-count-control | 6 | 118.822360 | 126.622891 | +| post | equal-count-control | 7 | 118.744694 | 117.368968 |