perf(fill): skip feature extraction without an angle model
This commit is contained in:
@@ -24,25 +24,7 @@ NuGet dependencies: `ACadSharp` 3.1.32 (DXF/DWG import/export, in OpenNest.IO),
|
|||||||
|
|
||||||
### Fill performance verification
|
### Fill performance verification
|
||||||
|
|
||||||
Opt-in synthetic measurements (`OpenNest.Tests/Fill/FillPerformanceTests.cs`):
|
See [fill verification](docs/performance/fill-verification.md) for opt-in measurements, targeted tests, Debug counter isolation, and predictor initialization rules. Keep training bitmaps by default; the angle builder checks predictor availability before scalar-only extraction.
|
||||||
|
|
||||||
```bash
|
|
||||||
OPENNEST_RUN_FILL_PERF=1 dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release \
|
|
||||||
--filter 'Category=FillPerformance' --logger 'console;verbosity=detailed'
|
|
||||||
```
|
|
||||||
|
|
||||||
Only the exact value `1` enables these tests; otherwise they skip; README documents the PowerShell equivalent.
|
|
||||||
|
|
||||||
The category covers comparer, group-pattern, rotated-pattern, extents-column, and feature-extraction workloads; individual filters match benchmark method names in `FillPerformanceTests.cs`. Keep harness, inputs, warmups and batches identical before/after; exclude setup/assertions from timing. Comparer/extents allocations are synchronous and current-thread only; parallel group fills omit allocation totals. No timing CI gates or whole-job speedup claims. Preserve evidence in [the measured report](docs/performance/fill-performance.md).
|
|
||||||
|
|
||||||
Debug behavior/skipped-work checks:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Debug \
|
|
||||||
--filter 'FullyQualifiedName~DefaultFillComparerWorkTests|FullyQualifiedName~FillHelpersTests|FullyQualifiedName~FillExtentsTests|FullyQualifiedName~StrategyOverlapTests'
|
|
||||||
```
|
|
||||||
|
|
||||||
`PerfCounters.FillScoreComputations`, `PartBoundaryPreparations`, `PartBoundsUpdates`, and `FeatureBitmaskCells` increments compile away in Release: zero Release counters prove nothing. Serialize counter assertions in `FillCacheCollection` and reset in `finally`. Keep `OpenNest.Tests/Fill/LegacyFillExtents.cs` frozen for differential tests, not production or before timings; measure the actual baseline production code.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -10,6 +11,29 @@ namespace OpenNest.Engine.Fill
|
|||||||
public class AngleCandidateBuilder
|
public class AngleCandidateBuilder
|
||||||
{
|
{
|
||||||
private readonly HashSet<double> knownGoodAngles = new();
|
private readonly HashSet<double> knownGoodAngles = new();
|
||||||
|
private readonly Func<bool> isPredictionAvailable;
|
||||||
|
private readonly Func<Drawing, bool, PartFeatures> extractFeatures;
|
||||||
|
private readonly Func<PartFeatures, double, double, List<double>> predictAngles;
|
||||||
|
|
||||||
|
public AngleCandidateBuilder()
|
||||||
|
: this(
|
||||||
|
() => AnglePredictor.IsAvailable,
|
||||||
|
FeatureExtractor.Extract,
|
||||||
|
(features, width, height) => AnglePredictor.PredictAngles(features, width, height)
|
||||||
|
)
|
||||||
|
{ }
|
||||||
|
|
||||||
|
// Per-instance dependencies keep tests independent of the process-wide ONNX session.
|
||||||
|
internal AngleCandidateBuilder(
|
||||||
|
Func<bool> isPredictionAvailable,
|
||||||
|
Func<Drawing, bool, PartFeatures> extractFeatures,
|
||||||
|
Func<PartFeatures, double, double, List<double>> predictAngles
|
||||||
|
)
|
||||||
|
{
|
||||||
|
this.isPredictionAvailable = isPredictionAvailable;
|
||||||
|
this.extractFeatures = extractFeatures;
|
||||||
|
this.predictAngles = predictAngles;
|
||||||
|
}
|
||||||
|
|
||||||
public bool ForceFullSweep { get; set; }
|
public bool ForceFullSweep { get; set; }
|
||||||
|
|
||||||
@@ -87,18 +111,22 @@ namespace OpenNest.Engine.Fill
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<double> ApplyMlPrediction(
|
private List<double> ApplyMlPrediction(
|
||||||
NestItem item,
|
NestItem item,
|
||||||
Box workArea,
|
Box workArea,
|
||||||
double[] baseAngles,
|
double[] baseAngles,
|
||||||
List<double> fallback
|
List<double> fallback
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var features = FeatureExtractor.Extract(item.Drawing);
|
if (!isPredictionAvailable())
|
||||||
|
return fallback;
|
||||||
|
|
||||||
|
// Inference needs only scalar features, never the training bitmap.
|
||||||
|
var features = extractFeatures(item.Drawing, false);
|
||||||
if (features == null)
|
if (features == null)
|
||||||
return fallback;
|
return fallback;
|
||||||
|
|
||||||
var predicted = AnglePredictor.PredictAngles(features, workArea.Width, workArea.Length);
|
var predicted = predictAngles(features, workArea.Width, workArea.Length);
|
||||||
if (predicted == null)
|
if (predicted == null)
|
||||||
return fallback;
|
return fallback;
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ namespace OpenNest.Engine.ML
|
|||||||
{
|
{
|
||||||
public static class AnglePredictor
|
public static class AnglePredictor
|
||||||
{
|
{
|
||||||
private static InferenceSession _session;
|
private static readonly SingleAttemptLoader<InferenceSession> SessionLoader = new(LoadSession);
|
||||||
private static volatile bool _loadAttempted;
|
|
||||||
private static readonly object _lock = new();
|
internal static bool IsAvailable => GetSession() != null;
|
||||||
|
|
||||||
public static List<double> PredictAngles(
|
public static List<double> PredictAngles(
|
||||||
PartFeatures features,
|
PartFeatures features,
|
||||||
@@ -84,38 +84,62 @@ namespace OpenNest.Engine.ML
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static InferenceSession GetSession()
|
private static InferenceSession GetSession() => SessionLoader.GetValue();
|
||||||
|
|
||||||
|
private static InferenceSession LoadSession()
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(typeof(AnglePredictor).Assembly.Location);
|
||||||
|
var modelPath = Path.Combine(dir, "Models", "angle_predictor.onnx");
|
||||||
|
|
||||||
|
if (!File.Exists(modelPath))
|
||||||
|
{
|
||||||
|
Debug.WriteLine($"[AnglePredictor] Model not found: {modelPath}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var session = new InferenceSession(modelPath);
|
||||||
|
Debug.WriteLine("[AnglePredictor] Model loaded successfully");
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One process-lifetime attempt, including missing/failed loads. Generic only so the
|
||||||
|
// publication contract can be tested with reference objects instead of real ONNX files.
|
||||||
|
internal sealed class SingleAttemptLoader<T> where T : class
|
||||||
|
{
|
||||||
|
private readonly Func<T> _load;
|
||||||
|
private readonly object _lock = new();
|
||||||
|
private T _value;
|
||||||
|
private volatile bool _loadAttempted;
|
||||||
|
|
||||||
|
internal SingleAttemptLoader(Func<T> load) => _load = load;
|
||||||
|
|
||||||
|
internal T GetValue()
|
||||||
{
|
{
|
||||||
if (_loadAttempted)
|
if (_loadAttempted)
|
||||||
return _session;
|
return _value;
|
||||||
|
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
if (_loadAttempted)
|
if (_loadAttempted)
|
||||||
return _session;
|
return _value;
|
||||||
|
|
||||||
_loadAttempted = true;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var dir = Path.GetDirectoryName(typeof(AnglePredictor).Assembly.Location);
|
_value = _load();
|
||||||
var modelPath = Path.Combine(dir, "Models", "angle_predictor.onnx");
|
|
||||||
|
|
||||||
if (!File.Exists(modelPath))
|
|
||||||
{
|
|
||||||
Debug.WriteLine($"[AnglePredictor] Model not found: {modelPath}");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
_session = new InferenceSession(modelPath);
|
|
||||||
Debug.WriteLine("[AnglePredictor] Model loaded successfully");
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Debug.WriteLine($"[AnglePredictor] Failed to load model: {ex.Message}");
|
Debug.WriteLine($"[AnglePredictor] Failed to load model: {ex.Message}");
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Publish only after assignment or definitive failure. A concurrent
|
||||||
|
// caller must wait on the lock, not observe a transient null session.
|
||||||
|
_loadAttempted = true;
|
||||||
|
}
|
||||||
|
|
||||||
return _session;
|
return _value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
using OpenNest.Engine;
|
using OpenNest.Engine;
|
||||||
using OpenNest.Engine.Fill;
|
using OpenNest.Engine.Fill;
|
||||||
|
using OpenNest.Engine.ML;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
|
|
||||||
namespace OpenNest.Tests.Fill;
|
namespace OpenNest.Tests.Fill;
|
||||||
|
|
||||||
|
// The Debug integration check uses process-wide PerfCounters.
|
||||||
|
[Collection(nameof(OpenNest.Tests.BestFit.FillCacheCollection))]
|
||||||
public class AngleCandidateBuilderTests
|
public class AngleCandidateBuilderTests
|
||||||
{
|
{
|
||||||
private static Drawing MakeRectDrawing(double w, double h)
|
private static Drawing MakeRectDrawing(double w, double h)
|
||||||
@@ -23,6 +26,387 @@ public class AngleCandidateBuilderTests
|
|||||||
PartType type = PartType.Irregular
|
PartType type = PartType.Irregular
|
||||||
) => new ClassificationResult { PrimaryAngle = primaryAngle, Type = type };
|
) => new ClassificationResult { PrimaryAngle = primaryAngle, Type = type };
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(PartType.Circle)]
|
||||||
|
[InlineData(PartType.Rectangle)]
|
||||||
|
public void Characterization_ClassifiedParts_PreserveExactBaseAngleOrder(PartType type)
|
||||||
|
{
|
||||||
|
var builder = new AngleCandidateBuilder();
|
||||||
|
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||||
|
var primary = Angle.ToRadians(7);
|
||||||
|
|
||||||
|
var angles = builder.Build(item, MakeClassification(primary, type), new Box(0, 0, 100, 50));
|
||||||
|
|
||||||
|
var expected = type == PartType.Circle ? new[] { 0.0 } : new[] { primary, primary + Angle.HalfPI };
|
||||||
|
Assert.Equal(expected, angles);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(PartType.Circle)]
|
||||||
|
[InlineData(PartType.Rectangle)]
|
||||||
|
[InlineData(PartType.Irregular)]
|
||||||
|
public void Characterization_Constraints_OverrideEveryClassification(PartType type)
|
||||||
|
{
|
||||||
|
var builder = new AngleCandidateBuilder();
|
||||||
|
var item = new NestItem
|
||||||
|
{
|
||||||
|
Drawing = MakeRectDrawing(20, 10),
|
||||||
|
RotationStart = 0,
|
||||||
|
RotationEnd = Angle.HalfPI,
|
||||||
|
StepAngle = Angle.ToRadians(30),
|
||||||
|
};
|
||||||
|
|
||||||
|
var angles = builder.Build(item, MakeClassification(0.1, type), new Box(0, 0, 100, 50));
|
||||||
|
|
||||||
|
AssertAngleOrder(new[] { 0.0, 30, 60, 90 }.Select(Angle.ToRadians), angles);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(-1)]
|
||||||
|
public void Characterization_NonpositiveConstraintStep_UsesFiveDegrees(double step)
|
||||||
|
{
|
||||||
|
var builder = new AngleCandidateBuilder();
|
||||||
|
var item = new NestItem
|
||||||
|
{
|
||||||
|
Drawing = MakeRectDrawing(20, 10),
|
||||||
|
RotationStart = Angle.ToRadians(10),
|
||||||
|
RotationEnd = Angle.ToRadians(20),
|
||||||
|
StepAngle = step,
|
||||||
|
};
|
||||||
|
|
||||||
|
var angles = builder.Build(item, MakeClassification(), new Box(0, 0, 100, 50));
|
||||||
|
|
||||||
|
AssertAngleOrder(new[] { 10.0, 15, 20 }.Select(Angle.ToRadians), angles);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Characterization_ReversedConstraints_FallBackToStart()
|
||||||
|
{
|
||||||
|
var builder = new AngleCandidateBuilder();
|
||||||
|
var item = new NestItem
|
||||||
|
{
|
||||||
|
Drawing = MakeRectDrawing(20, 10),
|
||||||
|
RotationStart = Angle.ToRadians(40),
|
||||||
|
RotationEnd = Angle.ToRadians(10),
|
||||||
|
};
|
||||||
|
|
||||||
|
var angles = builder.Build(item, MakeClassification(), new Box(0, 0, 100, 50));
|
||||||
|
|
||||||
|
Assert.Equal(new[] { item.RotationStart }, angles);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Characterization_KnownGoodAngles_PreserveBaseOrderAndPruning()
|
||||||
|
{
|
||||||
|
var builder = new AngleCandidateBuilder();
|
||||||
|
builder.RecordProductive(new List<AngleResult>
|
||||||
|
{
|
||||||
|
new() { AngleDeg = 0, PartCount = 1 },
|
||||||
|
new() { AngleDeg = 45, PartCount = 2 },
|
||||||
|
new() { AngleDeg = 45, PartCount = 3 },
|
||||||
|
new() { AngleDeg = 97, PartCount = 1 },
|
||||||
|
new() { AngleDeg = 65, PartCount = 0 },
|
||||||
|
new() { AngleDeg = 120, PartCount = -1 },
|
||||||
|
});
|
||||||
|
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||||
|
|
||||||
|
var angles = builder.Build(item, MakeClassification(Angle.ToRadians(7)), new Box(0, 0, 100, 50));
|
||||||
|
|
||||||
|
AssertAngleOrder(new[] { 7.0, 97, 0, 45 }.Select(Angle.ToRadians), angles);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertAngleOrder(IEnumerable<double> expected, List<double> actual)
|
||||||
|
{
|
||||||
|
var values = expected.ToArray();
|
||||||
|
Assert.Equal(values.Length, actual.Count);
|
||||||
|
for (var i = 0; i < values.Length; i++)
|
||||||
|
Assert.Equal(values[i], actual[i], precision: 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
// These delegates are deterministic control-flow doubles, not ONNX accuracy evidence.
|
||||||
|
private sealed class PredictionCalls
|
||||||
|
{
|
||||||
|
public bool Available { get; set; } = true;
|
||||||
|
public PartFeatures? Features { get; set; } = new();
|
||||||
|
public List<double>? Prediction { get; set; }
|
||||||
|
public List<string> Calls { get; } = new();
|
||||||
|
public Drawing? ExtractedDrawing { get; private set; }
|
||||||
|
public bool? IncludeBitmask { get; private set; }
|
||||||
|
public PartFeatures? PredictedFeatures { get; private set; }
|
||||||
|
public double SheetWidth { get; private set; }
|
||||||
|
public double SheetHeight { get; private set; }
|
||||||
|
|
||||||
|
public AngleCandidateBuilder CreateBuilder() => new(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
Calls.Add("available");
|
||||||
|
return Available;
|
||||||
|
},
|
||||||
|
(drawing, includeBitmask) =>
|
||||||
|
{
|
||||||
|
Calls.Add("extract");
|
||||||
|
ExtractedDrawing = drawing;
|
||||||
|
IncludeBitmask = includeBitmask;
|
||||||
|
return Features!;
|
||||||
|
},
|
||||||
|
(features, width, height) =>
|
||||||
|
{
|
||||||
|
Calls.Add("predict");
|
||||||
|
PredictedFeatures = features;
|
||||||
|
SheetWidth = width;
|
||||||
|
SheetHeight = height;
|
||||||
|
return Prediction!;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The existing repeated-add sweep includes a final value just below PI (near 180°).
|
||||||
|
// Preserve it: replacing the loop with 36 integer-indexed samples changes behavior.
|
||||||
|
private static IEnumerable<double> FallbackAngles() =>
|
||||||
|
new[] { 7.0, 97 }.Concat(Enumerable.Range(0, 37).Select(i => i * 5.0)).Select(Angle.ToRadians);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_Unavailable_SkipsExtractionAndPrediction_PreservesFallbackOrder()
|
||||||
|
{
|
||||||
|
var calls = new PredictionCalls { Available = false };
|
||||||
|
var builder = calls.CreateBuilder();
|
||||||
|
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||||
|
|
||||||
|
var angles = builder.Build(item, MakeClassification(Angle.ToRadians(7)), new Box(0, 0, 100, 50));
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "available" }, calls.Calls);
|
||||||
|
AssertAngleOrder(FallbackAngles(), angles);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_Available_ExtractsScalarsOnce_ThenForwardsFeaturesAndWorkAreaDimensions()
|
||||||
|
{
|
||||||
|
var calls = new PredictionCalls();
|
||||||
|
var builder = calls.CreateBuilder();
|
||||||
|
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||||
|
var workArea = new Box(3, 5, 123, 47);
|
||||||
|
|
||||||
|
var angles = builder.Build(item, MakeClassification(Angle.ToRadians(7)), workArea);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "available", "extract", "predict" }, calls.Calls);
|
||||||
|
Assert.Same(item.Drawing, calls.ExtractedDrawing);
|
||||||
|
Assert.Equal(false, calls.IncludeBitmask);
|
||||||
|
Assert.Same(calls.Features, calls.PredictedFeatures);
|
||||||
|
// Box.Width is Y and Box.Length is X; preserve this existing argument order.
|
||||||
|
Assert.Equal(47, calls.SheetWidth);
|
||||||
|
Assert.Equal(123, calls.SheetHeight);
|
||||||
|
AssertAngleOrder(FallbackAngles(), angles);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_NullFeatures_SkipsPrediction_PreservesFallbackOrder()
|
||||||
|
{
|
||||||
|
var calls = new PredictionCalls { Features = null };
|
||||||
|
var builder = calls.CreateBuilder();
|
||||||
|
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||||
|
|
||||||
|
var angles = builder.Build(item, MakeClassification(Angle.ToRadians(7)), new Box(0, 0, 100, 50));
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "available", "extract" }, calls.Calls);
|
||||||
|
Assert.Equal(false, calls.IncludeBitmask);
|
||||||
|
AssertAngleOrder(FallbackAngles(), angles);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void Build_NullOrEmptyPrediction_PreservesFallbackOrder(bool empty)
|
||||||
|
{
|
||||||
|
var calls = new PredictionCalls { Prediction = empty ? new List<double>() : null };
|
||||||
|
var builder = calls.CreateBuilder();
|
||||||
|
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||||
|
|
||||||
|
var angles = builder.Build(item, MakeClassification(Angle.ToRadians(7)), new Box(0, 0, 100, 50));
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "available", "extract", "predict" }, calls.Calls);
|
||||||
|
AssertAngleOrder(FallbackAngles(), angles);
|
||||||
|
if (empty)
|
||||||
|
{
|
||||||
|
Assert.Empty(calls.Prediction!);
|
||||||
|
Assert.NotSame(calls.Prediction, angles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_NonemptyPrediction_PreservesPredictionThenBaseThenSweepOrder()
|
||||||
|
{
|
||||||
|
var predicted = new[] { 42.0, 0, 97 }.Select(Angle.ToRadians).ToList();
|
||||||
|
var original = predicted.ToArray();
|
||||||
|
var calls = new PredictionCalls { Prediction = predicted };
|
||||||
|
var builder = calls.CreateBuilder();
|
||||||
|
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||||
|
|
||||||
|
var angles = builder.Build(item, MakeClassification(Angle.ToRadians(7)), new Box(0, 0, 100, 50));
|
||||||
|
|
||||||
|
var expected = new[] { 42.0, 0, 97, 7 }
|
||||||
|
.Concat(Enumerable.Range(1, 36).Select(i => i * 5.0))
|
||||||
|
.Select(Angle.ToRadians);
|
||||||
|
AssertAngleOrder(expected, angles);
|
||||||
|
Assert.Equal(original, predicted);
|
||||||
|
Assert.NotSame(predicted, angles);
|
||||||
|
Assert.Equal(new[] { "available", "extract", "predict" }, calls.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_DuplicatePrediction_KeepsPredictionDuplicates_ButDeduplicatesAddedAnglesByTolerance()
|
||||||
|
{
|
||||||
|
var primary = Angle.ToRadians(7);
|
||||||
|
var predicted = new List<double>
|
||||||
|
{
|
||||||
|
Angle.ToRadians(42), Angle.ToRadians(42), primary + Tolerance.Epsilon / 2, Angle.HalfPI,
|
||||||
|
};
|
||||||
|
var original = predicted.ToArray();
|
||||||
|
var calls = new PredictionCalls { Prediction = predicted };
|
||||||
|
var builder = calls.CreateBuilder();
|
||||||
|
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||||
|
|
||||||
|
var angles = builder.Build(item, MakeClassification(primary), new Box(0, 0, 100, 50));
|
||||||
|
|
||||||
|
// Existing behavior preserves the prediction prefix verbatim, even duplicates.
|
||||||
|
var expected = original.Concat(new[] { primary + Angle.HalfPI })
|
||||||
|
.Concat(Enumerable.Range(0, 37).Where(i => i != 18).Select(i => Angle.ToRadians(i * 5)));
|
||||||
|
AssertAngleOrder(expected, angles);
|
||||||
|
Assert.Equal(original, angles.Take(original.Length));
|
||||||
|
Assert.Equal(original, predicted);
|
||||||
|
Assert.NotSame(predicted, angles);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_Unavailable_WithCardinalBase_DeduplicatesSweepWithoutReordering()
|
||||||
|
{
|
||||||
|
var calls = new PredictionCalls { Available = false };
|
||||||
|
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||||
|
|
||||||
|
var angles = calls.CreateBuilder().Build(item, MakeClassification(), new Box(0, 0, 100, 50));
|
||||||
|
|
||||||
|
var expected = new[] { 0.0, 90 }
|
||||||
|
.Concat(Enumerable.Range(1, 36).Where(i => i != 18).Select(i => i * 5.0))
|
||||||
|
.Select(Angle.ToRadians);
|
||||||
|
AssertAngleOrder(expected, angles);
|
||||||
|
Assert.Equal(new[] { "available" }, calls.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("circle")]
|
||||||
|
[InlineData("rectangle")]
|
||||||
|
[InlineData("constraints-circle")]
|
||||||
|
[InlineData("constraints-rectangle")]
|
||||||
|
[InlineData("constraints-irregular")]
|
||||||
|
[InlineData("known-good")]
|
||||||
|
public void Build_BypassBranches_DoNotCheckAvailabilityOrExtractOrPredict(string branch)
|
||||||
|
{
|
||||||
|
var calls = new PredictionCalls();
|
||||||
|
var builder = calls.CreateBuilder();
|
||||||
|
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||||
|
var classification = MakeClassification(Angle.ToRadians(7));
|
||||||
|
var expected = new[] { 7.0, 97, 45 };
|
||||||
|
if (branch.EndsWith("circle"))
|
||||||
|
{
|
||||||
|
classification.Type = PartType.Circle;
|
||||||
|
expected = new[] { 0.0 };
|
||||||
|
}
|
||||||
|
else if (branch.EndsWith("rectangle"))
|
||||||
|
{
|
||||||
|
classification.Type = PartType.Rectangle;
|
||||||
|
expected = new[] { 7.0, 97 };
|
||||||
|
}
|
||||||
|
if (branch.StartsWith("constraints-"))
|
||||||
|
{
|
||||||
|
item.RotationStart = Angle.ToRadians(10);
|
||||||
|
item.RotationEnd = Angle.ToRadians(20);
|
||||||
|
item.StepAngle = 0;
|
||||||
|
expected = new[] { 10.0, 15, 20 };
|
||||||
|
}
|
||||||
|
if (branch == "known-good")
|
||||||
|
builder.RecordProductive(new List<AngleResult> { new() { AngleDeg = 45, PartCount = 1 } });
|
||||||
|
|
||||||
|
var angles = builder.Build(item, classification, new Box(0, 0, 100, 50));
|
||||||
|
|
||||||
|
Assert.Empty(calls.Calls);
|
||||||
|
AssertAngleOrder(expected.Select(Angle.ToRadians), angles);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void Build_ForceFullSweep_IgnoresKnownGoodPruning_AndStillHonorsAvailability(bool available)
|
||||||
|
{
|
||||||
|
var calls = new PredictionCalls { Available = available };
|
||||||
|
var builder = calls.CreateBuilder();
|
||||||
|
builder.ForceFullSweep = true;
|
||||||
|
builder.RecordProductive(new List<AngleResult> { new() { AngleDeg = 45, PartCount = 1 } });
|
||||||
|
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||||
|
|
||||||
|
var angles = builder.Build(item, MakeClassification(Angle.ToRadians(7)), new Box(0, 0, 100, 50));
|
||||||
|
|
||||||
|
AssertAngleOrder(FallbackAngles(), angles);
|
||||||
|
Assert.Equal(available ? new[] { "available", "extract", "predict" } : new[] { "available" }, calls.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_OnlyNonproductiveAngles_DoNotPruneOrBypassAvailability()
|
||||||
|
{
|
||||||
|
var calls = new PredictionCalls { Available = false };
|
||||||
|
var builder = calls.CreateBuilder();
|
||||||
|
builder.RecordProductive(new List<AngleResult>
|
||||||
|
{
|
||||||
|
new() { AngleDeg = 45, PartCount = 0 },
|
||||||
|
new() { AngleDeg = 60, PartCount = -1 },
|
||||||
|
});
|
||||||
|
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||||
|
|
||||||
|
var angles = builder.Build(item, MakeClassification(Angle.ToRadians(7)), new Box(0, 0, 100, 50));
|
||||||
|
|
||||||
|
AssertAngleOrder(FallbackAngles(), angles);
|
||||||
|
Assert.Equal(new[] { "available" }, calls.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
[Fact]
|
||||||
|
public void Build_Available_RealExtraction_DoesNotScanBitmaskCells()
|
||||||
|
{
|
||||||
|
var extractionCalls = 0;
|
||||||
|
var predictionCalls = 0;
|
||||||
|
var predictedFeatures = (PartFeatures?)null;
|
||||||
|
var builder = new AngleCandidateBuilder(
|
||||||
|
() => true,
|
||||||
|
(drawing, includeBitmask) =>
|
||||||
|
{
|
||||||
|
extractionCalls++;
|
||||||
|
return FeatureExtractor.Extract(drawing, includeBitmask);
|
||||||
|
},
|
||||||
|
(features, width, height) =>
|
||||||
|
{
|
||||||
|
predictionCalls++;
|
||||||
|
predictedFeatures = features;
|
||||||
|
return null!;
|
||||||
|
});
|
||||||
|
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||||
|
|
||||||
|
PerfCounters.Reset();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var angles = builder.Build(item, MakeClassification(Angle.ToRadians(7)), new Box(0, 0, 100, 50));
|
||||||
|
|
||||||
|
Assert.Equal(1, extractionCalls);
|
||||||
|
Assert.Equal(1, predictionCalls);
|
||||||
|
Assert.Equal(0, PerfCounters.FeatureBitmaskCells);
|
||||||
|
Assert.NotNull(predictedFeatures);
|
||||||
|
Assert.Null(predictedFeatures.Bitmask);
|
||||||
|
AssertAngleOrder(FallbackAngles(), angles);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
PerfCounters.Reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Build_ReturnsAtLeastTwoAngles()
|
public void Build_ReturnsAtLeastTwoAngles()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -342,6 +342,90 @@ public class FillPerformanceTests
|
|||||||
ReportFeatureSummary("scalar-only", samples.Select(s => s.Scalar).ToArray(), callsPerBatch);
|
ReportFeatureSummary("scalar-only", samples.Select(s => s.Scalar).ToArray(), callsPerBatch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void IrregularAngles_ReportsWarmNoModelPath()
|
||||||
|
{
|
||||||
|
Skip.IfNot(Environment.GetEnvironmentVariable("OPENNEST_RUN_FILL_PERF") == "1",
|
||||||
|
"Set OPENNEST_RUN_FILL_PERF=1 to run opt-in fill microbenchmarks.");
|
||||||
|
|
||||||
|
// Never move/delete a user's model to obtain a no-model measurement.
|
||||||
|
var modelPath = Path.Combine(
|
||||||
|
Path.GetDirectoryName(typeof(OpenNest.Engine.ML.AnglePredictor).Assembly.Location)!,
|
||||||
|
"Models", "angle_predictor.onnx");
|
||||||
|
Skip.If(File.Exists(modelPath), "No-model measurement requires an output directory without an angle model.");
|
||||||
|
Assert.Null(OpenNest.Engine.ML.AnglePredictor.PredictAngles(new OpenNest.Engine.ML.PartFeatures(), 80, 120));
|
||||||
|
|
||||||
|
var program = new OpenNest.CNC.Program();
|
||||||
|
program.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0)));
|
||||||
|
foreach (var point in new[] { new Vector(20, 0), new Vector(20, 6), new Vector(8, 6),
|
||||||
|
new Vector(8, 14), new Vector(0, 14), new Vector(0, 0) })
|
||||||
|
program.Codes.Add(new OpenNest.CNC.LinearMove(point));
|
||||||
|
var item = new NestItem { Drawing = new Drawing("performance-L", program) };
|
||||||
|
var workArea = new Box(3, 5, 120, 80);
|
||||||
|
var classification = new ClassificationResult { Type = PartType.Irregular, PrimaryAngle = 0.13 };
|
||||||
|
var builder = new AngleCandidateBuilder { ForceFullSweep = true };
|
||||||
|
var build = new Func<List<double>>(() => builder.Build(item, classification, workArea));
|
||||||
|
// Independent pre-4b fallback expression; exact ordered equality, not only count.
|
||||||
|
var expected = new List<double> { classification.PrimaryAngle, classification.PrimaryAngle + OpenNest.Math.Angle.HalfPI };
|
||||||
|
for (var angle = 0.0; angle < System.Math.PI; angle += OpenNest.Math.Angle.ToRadians(5))
|
||||||
|
{
|
||||||
|
if (!expected.Any(existing => OpenNest.Math.Tolerance.IsEqualTo(existing, angle)))
|
||||||
|
expected.Add(angle);
|
||||||
|
}
|
||||||
|
Assert.Equal(expected, build());
|
||||||
|
var warmupCalls = 20_000;
|
||||||
|
var callsPerBatch = 20_000;
|
||||||
|
var repetitions = 7;
|
||||||
|
#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("no-model angles: concave L (0,0)-(20,0)-(20,6)-(8,6)-(8,14)-(0,14), "
|
||||||
|
+ "primary=0.13 rad, workArea=(3,5,120,80), ForceFullSweep=true; public production builder, no delegates replaced. "
|
||||||
|
+ $"Initialization completed outside timing; warmup=2 x {warmupCalls}, measured={repetitions} x {callsPerBatch}. "
|
||||||
|
+ "Synchronous current-thread allocations; construction/assertions/output excluded, loop/result consumption included. "
|
||||||
|
+ "Warm missing-model branch only, not ONNX inference, cold-start latency, or whole-job speedup.");
|
||||||
|
for (var batch = 0; batch < 2; batch++)
|
||||||
|
MeasureAngles(build, warmupCalls);
|
||||||
|
var samples = new AngleSample[repetitions];
|
||||||
|
for (var batch = 0; batch < repetitions; batch++)
|
||||||
|
{
|
||||||
|
var sample = samples[batch] = MeasureAngles(build, callsPerBatch);
|
||||||
|
Assert.Equal((long)expected.Count * callsPerBatch, sample.AngleCount);
|
||||||
|
Assert.Equal(expected, sample.LastResult);
|
||||||
|
output.WriteLine(FormattableString.Invariant(
|
||||||
|
$"no-model-angles batch={batch + 1}: ms={sample.Milliseconds:F6} bytes={sample.AllocatedBytes}."));
|
||||||
|
}
|
||||||
|
var times = samples.Select(s => s.Milliseconds).OrderBy(t => t).ToArray();
|
||||||
|
var bytes = samples.Select(s => s.AllocatedBytes).OrderBy(b => b).ToArray();
|
||||||
|
var median = repetitions / 2;
|
||||||
|
output.WriteLine(FormattableString.Invariant(
|
||||||
|
$"no-model-angles: batch ms min/median/max={times[0]:F6}/{times[median]:F6}/{times[^1]:F6}; us/call min/median/max={times[0] * 1000 / callsPerBatch:F3}/{times[median] * 1000 / callsPerBatch:F3}/{times[^1] * 1000 / callsPerBatch:F3}; B/call min/median/max={(double)bytes[0] / callsPerBatch:F3}/{(double)bytes[median] / callsPerBatch:F3}/{(double)bytes[^1] / callsPerBatch:F3}."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AngleSample MeasureAngles(Func<List<double>> build, int calls)
|
||||||
|
{
|
||||||
|
var count = 0L;
|
||||||
|
var last = new List<double>();
|
||||||
|
var allocatedBefore = GC.GetAllocatedBytesForCurrentThread();
|
||||||
|
var start = Stopwatch.GetTimestamp();
|
||||||
|
for (var i = 0; i < calls; i++)
|
||||||
|
{
|
||||||
|
last = build();
|
||||||
|
count += last.Count;
|
||||||
|
}
|
||||||
|
var elapsed = Stopwatch.GetTimestamp() - start;
|
||||||
|
var allocated = GC.GetAllocatedBytesForCurrentThread() - allocatedBefore;
|
||||||
|
return new AngleSample(elapsed * 1000.0 / Stopwatch.Frequency, allocated, count, last);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct AngleSample(double Milliseconds, long AllocatedBytes,
|
||||||
|
long AngleCount, List<double> LastResult);
|
||||||
|
|
||||||
private const int BitmaskCells = 32 * 32;
|
private const int BitmaskCells = 32 * 32;
|
||||||
|
|
||||||
private static FeatureSample MeasureFeature(Func<OpenNest.Engine.ML.PartFeatures> extract, int calls)
|
private static FeatureSample MeasureFeature(Func<OpenNest.Engine.ML.PartFeatures> extract, int calls)
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
using OpenNest.Engine.ML;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.ML;
|
||||||
|
|
||||||
|
// The generic loader is the production session publication path. Reference objects
|
||||||
|
// isolate its lifetime/concurrency contract; they are not ONNX models or accuracy evidence.
|
||||||
|
public class AnglePredictorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void PredictAngles_DefaultThreshold_RemainsPointThree()
|
||||||
|
{
|
||||||
|
var method = typeof(AnglePredictor).GetMethod(nameof(AnglePredictor.PredictAngles));
|
||||||
|
|
||||||
|
Assert.NotNull(method);
|
||||||
|
var threshold = method.GetParameters().Single(parameter => parameter.Name == "threshold");
|
||||||
|
Assert.True(threshold.HasDefaultValue);
|
||||||
|
Assert.Equal(0.3, threshold.DefaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SessionLoader_Success_IsLazyAndReusesSameSessionWithoutReload()
|
||||||
|
{
|
||||||
|
var attempts = 0;
|
||||||
|
var session = new object();
|
||||||
|
var loader = new SingleAttemptLoader<object>(() =>
|
||||||
|
{
|
||||||
|
attempts++;
|
||||||
|
return session;
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(0, attempts);
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
Assert.Same(session, loader.GetValue());
|
||||||
|
Assert.Equal(1, attempts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SessionLoader_MissingModel_RemainsUnavailableWithoutRetry()
|
||||||
|
{
|
||||||
|
var attempts = 0;
|
||||||
|
var modelPresent = false;
|
||||||
|
var session = new object();
|
||||||
|
var loader = new SingleAttemptLoader<object>(() =>
|
||||||
|
{
|
||||||
|
attempts++;
|
||||||
|
return modelPresent ? session : null!;
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Null(loader.GetValue());
|
||||||
|
modelPresent = true; // Even a subsequently available resource must not trigger a reload.
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
Assert.Null(loader.GetValue());
|
||||||
|
Assert.Equal(1, attempts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SessionLoader_ThrowingLoad_RemainsUnavailableWithoutRetry()
|
||||||
|
{
|
||||||
|
var attempts = 0;
|
||||||
|
var loadFails = true;
|
||||||
|
var loader = new SingleAttemptLoader<object>(() =>
|
||||||
|
{
|
||||||
|
attempts++;
|
||||||
|
if (loadFails)
|
||||||
|
throw new InvalidDataException("Controlled model-load failure.");
|
||||||
|
return new object();
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Null(loader.GetValue());
|
||||||
|
loadFails = false;
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
Assert.Null(loader.GetValue());
|
||||||
|
Assert.Equal(1, attempts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SessionLoaders_AreIndependent_NoGlobalTestSwitchesOrSharedFailures()
|
||||||
|
{
|
||||||
|
var session = new object();
|
||||||
|
var unavailable = new SingleAttemptLoader<object>(() => null!);
|
||||||
|
var available = new SingleAttemptLoader<object>(() => session);
|
||||||
|
|
||||||
|
Assert.Null(unavailable.GetValue());
|
||||||
|
Assert.Same(session, available.GetValue());
|
||||||
|
Assert.Null(unavailable.GetValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("available")]
|
||||||
|
[InlineData("missing")]
|
||||||
|
[InlineData("throwing")]
|
||||||
|
public void SessionLoader_ConcurrentReaders_WaitForDefinitiveAvailability(string outcome)
|
||||||
|
{
|
||||||
|
using var loadEntered = new ManualResetEventSlim();
|
||||||
|
using var releaseLoad = new ManualResetEventSlim();
|
||||||
|
var readerEntered = 0;
|
||||||
|
var readerCompleted = 0;
|
||||||
|
var timeout = TimeSpan.FromSeconds(10);
|
||||||
|
var attempts = 0;
|
||||||
|
var session = new object();
|
||||||
|
var results = new object?[2];
|
||||||
|
var errors = new Exception?[2];
|
||||||
|
var loader = new SingleAttemptLoader<object>(() =>
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref attempts);
|
||||||
|
loadEntered.Set();
|
||||||
|
releaseLoad.Wait();
|
||||||
|
return outcome switch
|
||||||
|
{
|
||||||
|
"available" => session,
|
||||||
|
"missing" => null!,
|
||||||
|
_ => throw new InvalidDataException("Controlled model-load failure."),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
var readSession = (int index) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
results[index] = loader.GetValue();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errors[index] = ex;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var initializer = new Thread(() => readSession(0)) { IsBackground = true };
|
||||||
|
var reader = new Thread(() =>
|
||||||
|
{
|
||||||
|
Volatile.Write(ref readerEntered, 1);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
readSession(1);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Volatile.Write(ref readerCompleted, 1);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
{ IsBackground = true };
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
initializer.Start();
|
||||||
|
Assert.True(loadEntered.Wait(timeout), "The first caller did not enter the loader.");
|
||||||
|
reader.Start();
|
||||||
|
Assert.True(SpinWait.SpinUntil(() => Volatile.Read(ref readerEntered) == 1, timeout),
|
||||||
|
"The concurrent reader did not start.");
|
||||||
|
|
||||||
|
// Observe an actual blocked reader, not just a Task that may not have run yet.
|
||||||
|
// The timeout only bounds a broken test; this is not a latency assertion.
|
||||||
|
Assert.True(SpinWait.SpinUntil(
|
||||||
|
() => Volatile.Read(ref readerCompleted) == 1 || (reader.ThreadState & ThreadState.WaitSleepJoin) != 0,
|
||||||
|
timeout), "The reader neither waited nor completed.");
|
||||||
|
Assert.False(Volatile.Read(ref readerCompleted) == 1,
|
||||||
|
"Availability was published before the blocked load had a definitive result.");
|
||||||
|
Assert.Equal(1, Volatile.Read(ref attempts));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
releaseLoad.Set();
|
||||||
|
if ((initializer.ThreadState & ThreadState.Unstarted) == 0)
|
||||||
|
Assert.True(initializer.Join(timeout), "The initializing caller did not terminate.");
|
||||||
|
if ((reader.ThreadState & ThreadState.Unstarted) == 0)
|
||||||
|
Assert.True(reader.Join(timeout), "The concurrent reader did not terminate.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.All(errors, error => Assert.Null(error));
|
||||||
|
if (outcome == "available")
|
||||||
|
{
|
||||||
|
Assert.All(results, result => Assert.Same(session, result));
|
||||||
|
Assert.Same(session, loader.GetValue());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Assert.All(results, result => Assert.Null(result));
|
||||||
|
Assert.Null(loader.GetValue());
|
||||||
|
}
|
||||||
|
Assert.Equal(1, attempts);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,8 @@ The synchronous rotated-pattern construction control uses a 32-part native-arc g
|
|||||||
|
|
||||||
The feature-extraction measurement compares default `FeatureExtractor.Extract` (32×32 training bitmask) against `Extract(drawing, includeBitmask: false)` on a synthetic ring; run it with `--filter "FullyQualifiedName~FeatureExtraction_ReportsFullAndScalarOnly"`. The one-argument overload keeps generating the bitmap for training callers; scalar-only inference callers receive `Bitmask = null` with identical scalar features. Behavior and Debug bitmask-scan counter checks use `--filter "FullyQualifiedName~FeatureExtractorTests"`.
|
The feature-extraction measurement compares default `FeatureExtractor.Extract` (32×32 training bitmask) against `Extract(drawing, includeBitmask: false)` on a synthetic ring; run it with `--filter "FullyQualifiedName~FeatureExtraction_ReportsFullAndScalarOnly"`. The one-argument overload keeps generating the bitmap for training callers; scalar-only inference callers receive `Bitmask = null` with identical scalar features. Behavior and Debug bitmask-scan counter checks use `--filter "FullyQualifiedName~FeatureExtractorTests"`.
|
||||||
|
|
||||||
|
`AngleCandidateBuilder` now checks the shared one-attempt predictor initialization before extracting anything; unavailable or failed model loads retain the ordered fallback sweep, while available inference requests scalar-only features. Initialization completion is published only after the load outcome is known. Measure the warm missing-model path with `--filter "FullyQualifiedName~IrregularAngles_ReportsWarmNoModelPath"`; this skips when a model is installed and never alters model files. Loader-concurrency and prediction-double tests use `--filter "FullyQualifiedName~AngleCandidateBuilderTests|FullyQualifiedName~AnglePredictorTests|FullyQualifiedName~FeatureExtractorTests"`. These tests do not establish actual ONNX accuracy. See [fill verification](docs/performance/fill-verification.md) for shared workflow safeguards.
|
||||||
|
|
||||||
### Quick start
|
### Quick start
|
||||||
|
|
||||||
1. File > New Nest
|
1. File > New Nest
|
||||||
|
|||||||
@@ -532,3 +532,121 @@ dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release \
|
|||||||
```
|
```
|
||||||
|
|
||||||
This is a capability slice, not yet a production speedup: the inference call site keeps using the default overload until Task 4b gates predictor availability, so real nesting runs show no change from this commit. The measured gain applies to inference callers once wired. No representative production corpus, Windows UI runtime test, or ONNX inference was performed; the Debug counter increments compile away in Release.
|
This is a capability slice, not yet a production speedup: the inference call site keeps using the default overload until Task 4b gates predictor availability, so real nesting runs show no change from this commit. The measured gain applies to inference callers once wired. No representative production corpus, Windows UI runtime test, or ONNX inference was performed; the Debug counter increments compile away in Release.
|
||||||
|
|
||||||
|
## Task 4b — gate angle features on predictor availability — 2026-09-26
|
||||||
|
|
||||||
|
### Scope and contract
|
||||||
|
|
||||||
|
`AngleCandidateBuilder` now checks `AnglePredictor.IsAvailable` before extraction, and available inference requests `includeBitmask: false`. The public parameterless constructor remains; readonly per-instance delegates provide isolated test doubles without process-global overrides. The prediction default threshold (0.3), `Width` then `Length` argument order, prediction-prefix duplicates, tolerance-based appended-angle deduplication, base/sweep order, constraints, classification shortcuts and known-good pruning are unchanged. The legacy repeated-addition sweep includes a value just below π: 37 sweep entries, not an integer-indexed 36-angle replacement.
|
||||||
|
|
||||||
|
`IsAvailable` and `PredictAngles` use the same initializer. The existing lock/volatile one-attempt pattern lives in the narrow internal `SingleAttemptLoader<T>` (generic solely to test with reference objects instead of ONNX files). Completion is published in `finally`, after assignment or definitive failure; a concurrent reader waits rather than seeing transient unavailability. Missing and failed loads remain cached for the process lifetime; no model reload policy, independent availability cache, model-file changes, packages or projects were added. Training extraction is unchanged.
|
||||||
|
|
||||||
|
### Provenance and verification
|
||||||
|
|
||||||
|
- Base: `d70505b7c0e41692a031971fb437bbb165493b9f` on `master`; this section accompanies the final implementation commit. The baseline was an isolated `git archive` with only the identical measurement harness added. Production/project comparison found only `AngleCandidateBuilder.cs` and `AnglePredictor.cs` changed; actual baseline production was measured, not a reimplementation.
|
||||||
|
- Environment: Ubuntu 24.04.5 LTS x64 shared KVM VM, four vCPUs presented as AMD Ryzen 9 5900X; SDK 10.0.112. Test adapters and benchmark output confirm .NET 8.0.31; Stopwatch frequency 1,000,000,000 ticks/s. Serial Release processes, forward then reversed before/after order.
|
||||||
|
- Parent applied whitespace-only fixes to the builder constructor and the loader concurrency test after implementation-agent tests; full suites were rerun afterward. Published measurements use the final source/harness hashes below. An initial short no-model warmup showed transient timing spread; it was replaced in **both** trees by 2×20,000 warmup calls and 7×20,000 measured calls. Only the complete rerun pairs below are acceptance measurements.
|
||||||
|
|
||||||
|
| Source | Before SHA-256 | Delivered/measurement SHA-256 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `OpenNest.Engine/Fill/AngleCandidateBuilder.cs` | `5182f69f604332e1b8bfb46d807c8f830194a88b47f4fd4e90bcea6e91e522c4` | `97bbb82680b90d950f480c65dffab8702bd9402ef849639286d7fc90f1b6b162` |
|
||||||
|
| `OpenNest.Engine/ML/AnglePredictor.cs` | `84bc0eafffe84447dc83e3c7084e5562a67409572fa413f78abe6e7102ba3c72` | `047e3bdb8d9aec1add49ba36e5b7947d1b375e6099a3f36f54061b6ae4d16f7a` |
|
||||||
|
| `OpenNest.Engine/ML/FeatureExtractor.cs` | `2d7b45350abd94d7e728c962aca06f4a86920d0328d8025c75f51ebc7e5ed6b3` | `2d7b45350abd94d7e728c962aca06f4a86920d0328d8025c75f51ebc7e5ed6b3` |
|
||||||
|
| `OpenNest.Tests/Fill/FillPerformanceTests.cs` | `f90412c87393f8542769aae5abef5e340b827003c134d0bf525efef3a3fefa62` | `f90412c87393f8542769aae5abef5e340b827003c134d0bf525efef3a3fefa62` |
|
||||||
|
|
||||||
|
The harness hash in both columns is the newly added identical harness, not the original base harness. Existing feature-extraction timed code is unchanged.
|
||||||
|
|
||||||
|
Genuine RED/GREEN evidence (temporary source mutations restored in `finally`): removing the availability gate failed the exact call sequence (expected availability only, actual availability/extraction/prediction); forcing `true` failed the scalar flag assertion; the real-extractor Debug integration check counted 1,024 bitmap cells instead of 0; publishing before the blocked load failed all three concurrent-reader cases (success, missing, throwing). Restored targeted suites pass. Counter tests use the nonparallel `FillCacheCollection` and reset in `finally`. Release-zero counters are not evidence. The loader concurrency test observes a blocked dedicated reader, not merely an unscheduled task; ten additional process runs passed all three cases.
|
||||||
|
|
||||||
|
| Verification | Passed | Skipped | Failed |
|
||||||
|
| --- | ---: | ---: | ---: |
|
||||||
|
| Pre-change builder characterization | 17 | 0 | 0 |
|
||||||
|
| Targeted Release (builder/predictor/extractor) | 77 | 0 | 0 |
|
||||||
|
| Targeted Debug (builder/predictor/extractor) | 81 | 0 | 0 |
|
||||||
|
| Full main Release, final code | 1,335 | 18 | 0 |
|
||||||
|
| Full main Debug, final code | 1,356 | 18 | 0 |
|
||||||
|
| Full engine Release, final code | 300 | 0 | 0 |
|
||||||
|
| Performance category, variable unset | 0 | 6 | 0 |
|
||||||
|
| Performance category, variable `0` | 0 | 6 | 0 |
|
||||||
|
| Each enabled two-case measurement process (four runs) | 2 | 0 | 0 |
|
||||||
|
|
||||||
|
Full-suite skips are 12 optional CHR-font fixtures plus six opt-in benchmarks. Parent parsed TRX outcomes and reconciled all published raw measurement rows against console output. Changed-file whitespace verification and `git diff --check` passed. Existing optional-fixture, frozen-reference nullable and obsolete API warnings remain outside this slice.
|
||||||
|
|
||||||
|
### Warm no-model path measurements
|
||||||
|
|
||||||
|
Closed concave L vertices `(0,0),(20,0),(20,6),(8,6),(8,14),(0,14)`, primary angle 0.13 radians, work area `(3,5,120,80)`, `ForceFullSweep=true`. Public production builder, no delegate substitution. Missing-model initialization, construction, assertions and output occur outside timing. Exact ordered angles are checked against the pre-change fallback expression. Each process has two 20,000-call warmups and seven 20,000-call batches. Loop/result-count consumption and GC are included; allocations are synchronous current-thread bytes, not process RSS. The benchmark skips if a model exists and never removes it.
|
||||||
|
|
||||||
|
| Batch | Before forward ms / B | After forward ms / B | After reverse ms / B | Before reverse ms / B |
|
||||||
|
| ---: | --- | --- | --- | --- |
|
||||||
|
| 1 | 1217.927296 / 503,670,984 | 55.374701 / 119,200,000 | 52.644238 / 119,200,000 | 1184.910444 / 503,670,984 |
|
||||||
|
| 2 | 1187.003770 / 503,660,640 | 54.922257 / 119,200,000 | 53.317338 / 119,200,000 | 1245.986050 / 503,660,640 |
|
||||||
|
| 3 | 1191.853980 / 503,660,640 | 53.574434 / 119,200,000 | 52.454731 / 119,200,000 | 1234.360386 / 503,660,640 |
|
||||||
|
| 4 | 1207.375477 / 503,660,640 | 52.648817 / 119,200,000 | 51.054148 / 119,200,000 | 1247.194291 / 503,660,640 |
|
||||||
|
| 5 | 1212.013076 / 503,670,984 | 52.498333 / 119,200,000 | 51.060020 / 119,200,000 | 1236.030807 / 503,670,984 |
|
||||||
|
| 6 | 1223.337623 / 503,660,640 | 51.969916 / 119,200,000 | 51.673347 / 119,200,000 | 1265.240358 / 503,660,640 |
|
||||||
|
| 7 | 1249.298485 / 503,660,640 | 51.376286 / 119,200,000 | 52.600186 / 119,200,000 | 1264.421373 / 503,660,640 |
|
||||||
|
|
||||||
|
| Process | µs/call min / median / max | B/call min / median / max |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| before-measure | 59.350 / 60.601 / 62.465 | 25,183.0320 / 25,183.0320 / 25,183.5492 |
|
||||||
|
| after-measure | 2.569 / 2.632 / 2.769 | 5,960.0000 / 5,960.0000 / 5,960.0000 |
|
||||||
|
| after-repeat | 2.553 / 2.623 / 2.666 | 5,960.0000 / 5,960.0000 / 5,960.0000 |
|
||||||
|
| before-repeat | 59.246 / 62.299 / 63.262 | 25,183.0320 / 25,183.0320 / 25,183.5492 |
|
||||||
|
|
||||||
|
Measured median warm-path time changes are -95.66% and -95.79% in the two process orders. Median allocations fall from 25,183.032 to 5,960 B/call (19,223.032 B/call, 76.33%). After allocations are exactly 5,960 B/call in every batch; baseline totals have small variations disclosed above. These results support a local warm no-model improvement, not a stable cross-machine latency guarantee or a whole-nesting-job speedup. No cold-start or failed-model timing is claimed.
|
||||||
|
|
||||||
|
### Separate extraction control
|
||||||
|
|
||||||
|
Existing `FeatureExtraction_ReportsFullAndScalarOnly`: native-arc ring OD20/ID8, two 200-call warmups, seven 1,000-call batches per mode, alternating mode order. This is a separate extraction benchmark, not available-model inference. Feature extraction is identical across these trees; the full versus scalar difference is the already-delivered Task 4a capability, now used by the builder. Timing/bytes include the harness's bitmap-count consumption for full mode (see Task 4a's ~32-byte consumption disclosure), so the delta is not solely production extraction allocation. Process-to-process differences are shared-VM noise/control observations, not a Task 4b extraction speedup claim.
|
||||||
|
|
||||||
|
| Process | Batch | Full ms / B | Scalar-only ms / B |
|
||||||
|
| --- | ---: | --- | --- |
|
||||||
|
| before-measure | 1 | 217.757693 / 35,332,688 | 37.378244 / 34,263,032 |
|
||||||
|
| before-measure | 2 | 170.635584 / 35,332,688 | 34.053642 / 34,252,688 |
|
||||||
|
| before-measure | 3 | 172.566367 / 35,343,032 | 34.951535 / 34,252,688 |
|
||||||
|
| before-measure | 4 | 170.000626 / 35,332,688 | 33.384049 / 34,273,376 |
|
||||||
|
| before-measure | 5 | 169.570514 / 35,332,688 | 34.399103 / 34,252,688 |
|
||||||
|
| before-measure | 6 | 171.122593 / 35,332,688 | 33.865496 / 34,283,720 |
|
||||||
|
| before-measure | 7 | 168.040086 / 35,332,688 | 32.055312 / 34,263,032 |
|
||||||
|
| after-measure | 1 | 227.885101 / 35,332,896 | 32.285726 / 34,252,896 |
|
||||||
|
| after-measure | 2 | 175.358064 / 35,338,120 | 30.359862 / 34,252,896 |
|
||||||
|
| after-measure | 3 | 184.505182 / 35,332,896 | 31.867147 / 34,252,896 |
|
||||||
|
| after-measure | 4 | 169.872394 / 35,332,896 | 29.338054 / 34,252,896 |
|
||||||
|
| after-measure | 5 | 168.238732 / 35,338,120 | 30.606238 / 34,252,896 |
|
||||||
|
| after-measure | 6 | 189.879784 / 35,332,896 | 30.637576 / 34,252,896 |
|
||||||
|
| after-measure | 7 | 174.944123 / 35,332,896 | 30.820803 / 34,252,896 |
|
||||||
|
| after-repeat | 1 | 272.298581 / 35,332,896 | 52.314757 / 34,252,896 |
|
||||||
|
| after-repeat | 2 | 194.604106 / 35,338,120 | 52.072921 / 34,252,896 |
|
||||||
|
| after-repeat | 3 | 159.231266 / 35,332,896 | 33.244075 / 34,252,896 |
|
||||||
|
| after-repeat | 4 | 152.710834 / 35,332,896 | 31.001913 / 34,252,896 |
|
||||||
|
| after-repeat | 5 | 154.032296 / 35,338,120 | 31.102123 / 34,252,896 |
|
||||||
|
| after-repeat | 6 | 150.289445 / 35,332,896 | 29.016949 / 34,252,896 |
|
||||||
|
| after-repeat | 7 | 156.205127 / 35,332,896 | 30.082710 / 34,252,896 |
|
||||||
|
| before-repeat | 1 | 215.909255 / 35,332,688 | 37.149342 / 34,252,688 |
|
||||||
|
| before-repeat | 2 | 188.558239 / 35,332,688 | 35.593749 / 34,252,688 |
|
||||||
|
| before-repeat | 3 | 198.129599 / 35,343,032 | 38.193683 / 34,252,688 |
|
||||||
|
| before-repeat | 4 | 186.170223 / 35,332,688 | 36.974173 / 34,252,688 |
|
||||||
|
| before-repeat | 5 | 193.646279 / 35,332,688 | 46.604443 / 34,252,688 |
|
||||||
|
| before-repeat | 6 | 195.491390 / 35,332,688 | 50.731669 / 34,252,688 |
|
||||||
|
| before-repeat | 7 | 191.895666 / 35,332,688 | 40.247489 / 34,252,688 |
|
||||||
|
|
||||||
|
| Process | Full µs/call min / median / max | Scalar µs/call min / median / max |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| before-measure | 168.040 / 170.636 / 217.758 | 32.055 / 34.054 / 37.378 |
|
||||||
|
| after-measure | 168.239 / 175.358 / 227.885 | 29.338 / 30.638 / 32.286 |
|
||||||
|
| after-repeat | 150.289 / 156.205 / 272.299 | 29.017 / 31.102 / 52.315 |
|
||||||
|
| before-repeat | 186.170 / 193.646 / 215.909 | 35.594 / 38.194 / 50.732 |
|
||||||
|
|
||||||
|
### Reproduction, review and remaining limits
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OPENNEST_RUN_FILL_PERF=1 dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release \
|
||||||
|
--filter 'FullyQualifiedName~IrregularAngles_ReportsWarmNoModelPath|FullyQualifiedName~FeatureExtraction_ReportsFullAndScalarOnly' \
|
||||||
|
--logger 'console;verbosity=detailed'
|
||||||
|
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release \
|
||||||
|
--filter 'FullyQualifiedName~AngleCandidateBuilderTests|FullyQualifiedName~AnglePredictorTests|FullyQualifiedName~FeatureExtractorTests'
|
||||||
|
```
|
||||||
|
|
||||||
|
Repeat the targeted command in Debug for bitmap work assertions. Shared safeguards moved intact (with Task 4b additions) from near-capacity `AGENTS.md` into [fill verification](fill-verification.md); the combined `AGENTS.md` + `CLAUDE.md` is now 31,434 bytes, below 32 KiB. README and the shared instructions describe availability-gated scalar inference; `CLAUDE.md` remains the thin import.
|
||||||
|
|
||||||
|
Independent spec review **PASS**, followed by independent quality/integration review **APPROVED**; neither found Critical, Important or Minor issues. Both reconciled source/harness provenance, raw measurements, summaries, red/green and full-suite evidence. Each independently reran the targeted suites (77 Release, 81 Debug) and both disabled benchmark gates (six skipped each); the spec reviewer ran ten more three-case concurrency processes, and the quality reviewer ran three plus the full engine suite (300 passed). Raw scratch evidence was removed after review, retaining the tables, hashes and test summaries here. No actual ONNX inference or model accuracy test, Windows UI runtime test, or representative production corpus measurement was performed. Task 5 combined acceptance/report remains separate; gated A/B/C follow-ups were not started.
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Fill performance verification
|
||||||
|
|
||||||
|
Opt-in synthetic measurements (`OpenNest.Tests/Fill/FillPerformanceTests.cs`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OPENNEST_RUN_FILL_PERF=1 dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release \
|
||||||
|
--filter 'Category=FillPerformance' --logger 'console;verbosity=detailed'
|
||||||
|
```
|
||||||
|
|
||||||
|
Only the exact value `1` enables these tests; otherwise they skip; [README](../../README.md) documents the PowerShell equivalent.
|
||||||
|
|
||||||
|
The category covers comparer, group-pattern, rotated-pattern, extents-column, feature-extraction, and no-model angle workloads; individual filters match benchmark method names in `FillPerformanceTests.cs`. Keep harness, inputs, warmups and batches identical before/after; exclude setup/assertions from timing. Comparer/extents allocations are synchronous and current-thread only; parallel group fills omit allocation totals. No timing CI gates or whole-job speedup claims. Preserve evidence in [the measured report](fill-performance.md).
|
||||||
|
|
||||||
|
Debug behavior/skipped-work checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Debug \
|
||||||
|
--filter 'FullyQualifiedName~DefaultFillComparerWorkTests|FullyQualifiedName~FillHelpersTests|FullyQualifiedName~FillExtentsTests|FullyQualifiedName~StrategyOverlapTests'
|
||||||
|
```
|
||||||
|
|
||||||
|
`PerfCounters.FillScoreComputations`, `PartBoundaryPreparations`, `PartBoundsUpdates`, and `FeatureBitmaskCells` increments compile away in Release: zero Release counters prove nothing. Serialize counter assertions in `FillCacheCollection` and reset in `finally`. Keep `OpenNest.Tests/Fill/LegacyFillExtents.cs` frozen for differential tests, not production or before timings; measure the actual baseline production code.
|
||||||
|
|
||||||
|
Task 4b checks: `dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release --filter "FullyQualifiedName~AngleCandidateBuilderTests|FullyQualifiedName~AnglePredictorTests|FullyQualifiedName~FeatureExtractorTests"` (repeat in Debug for bitmap counters). `IrregularAngles_ReportsWarmNoModelPath` measures the public builder with a missing model and skips when a model is installed; never remove real model files to benchmark. `FeatureExtraction_ReportsFullAndScalarOnly` measures extraction separately.
|
||||||
|
|
||||||
|
Predictor availability uses the same one-attempt session initialization as inference. Publish completion only after assignment or definitive failure; concurrent callers must wait for the outcome. The builder skips extraction when unavailable and requests scalar-only features when available. Tests use isolated loaders/prediction doubles, not evidence of real ONNX inference.
|
||||||
Reference in New Issue
Block a user