perf(fill): skip feature extraction without an angle model
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Engine.ML;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Tests.Fill;
|
||||
|
||||
// The Debug integration check uses process-wide PerfCounters.
|
||||
[Collection(nameof(OpenNest.Tests.BestFit.FillCacheCollection))]
|
||||
public class AngleCandidateBuilderTests
|
||||
{
|
||||
private static Drawing MakeRectDrawing(double w, double h)
|
||||
@@ -23,6 +26,387 @@ public class AngleCandidateBuilderTests
|
||||
PartType type = PartType.Irregular
|
||||
) => 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]
|
||||
public void Build_ReturnsAtLeastTwoAngles()
|
||||
{
|
||||
|
||||
@@ -342,6 +342,90 @@ public class FillPerformanceTests
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user