feat(engine): expose NestJobCost, the benchmark's scoring

Engines optimized guesses at the benchmark cost: Opus55 re-implemented
salvage credit, Qwen used plate area per part area, Gpt6Astra ignored
salvage. NestJobCost moves StockLadder's EstimateNetArea into a public
home (net sheet area, unplaced-part penalty, whole-result Evaluate) and
the benchmark and StockLadder now call it. Scores are unchanged: tests pin
it against a frozen copy of the old computation and real benchmark runs.
Bounds still include marks, as before, so scores do not move.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
aj
2026-09-25 08:12:07 -04:00
co-authored by Codex Claude Opus 5.5
parent ae2b0beb45
commit dd1a958f5c
7 changed files with 286 additions and 39 deletions
+2 -2
View File
@@ -179,7 +179,7 @@ namespace OpenNest.Benchmark
var plateArea = plateRuns.Sum(run => run.Plate.Area());
var netSheetArea = validation.Valid
? plateResults.Sum(result =>
StockLadderNestingEngine.EstimateNetArea(baselineJob, result)
NestJobCost.NetSheetArea(baselineJob, result)
)
: 0;
var sizeBreakdown = plateRuns
@@ -266,7 +266,7 @@ namespace OpenNest.Benchmark
// Salvage credit is recomputed from the job's own geometry, never taken from the engine.
var netSheetArea = validation.Valid
? jobResult.Plates.Sum(p =>
StockLadderNestingEngine.EstimateNetArea(nestJob, p)
NestJobCost.NetSheetArea(nestJob, p)
)
: 0;
+1 -1
View File
@@ -23,7 +23,7 @@ namespace OpenNest.Benchmark
public double PlateArea { get; init; }
/// <summary>Sheet area consumed after crediting salvageable offcuts
/// (StockLadderNestingEngine.EstimateNetArea summed over every plate).
/// (NestJobCost.NetSheetArea summed over every plate).
/// Equals PlateArea when salvage credit is disabled.</summary>
public double NetSheetArea { get; init; }
@@ -0,0 +1,42 @@
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
namespace OpenNest.Engine.Tests.Jobs;
// Frozen pre-PR2 computation: keep independent of NestJobCost to detect scoring drift.
internal static class LegacyNestJobCost
{
public static double EstimateNetArea(NestJob job, NestJobPlateResult sheet)
{
var area = sheet.Stock.Size.Width * sheet.Stock.Size.Length;
var minimum = job.Options.MinimumSalvageDimension;
if (job.Options.SalvageRate == 0 || minimum <= 0 || sheet.Placements.Count == 0)
return area;
var work = DrawingJobMapper.CreatePlate(sheet.Stock).WorkArea();
var parts = job.Parts.ToDictionary(p => p.Id);
var boxes = sheet
.Placements.Select(p =>
{
var part = new Part(DrawingJobMapper.CreateDrawing(parts[p.PartId]));
part.Rotate(p.Rotation);
part.Location = new OpenNest.Geometry.Vector(p.X, p.Y);
part.UpdateBounds();
return part.BoundingBox;
})
.ToList();
var gap = sheet.Stock.PartSpacing;
var candidates = new[]
{
(work.Length, boxes.Min(b => b.Bottom) - work.Bottom - gap),
(work.Length, work.Top - boxes.Max(b => b.Top) - gap),
(boxes.Min(b => b.Left) - work.Left - gap, work.Width),
(work.Right - boxes.Max(b => b.Right) - gap, work.Width),
};
var salvage = candidates
.Where(c => c.Item1 >= minimum && c.Item2 >= minimum)
.Select(c => c.Item1 * c.Item2)
.DefaultIfEmpty(0)
.Max();
return area - job.Options.SalvageRate * salvage;
}
}
@@ -0,0 +1,115 @@
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
public class NestJobCostTests
{
public static IEnumerable<object[]> EdgeCases()
{
foreach (var rate in new[] { 0.0, 0.5, 1.0 })
foreach (var edge in new[] { "bottom", "top", "left", "right" })
foreach (var quadrant in new[] { 1, 2, 3, 4 })
yield return new object[] { rate, edge, quadrant };
}
[Theory]
[MemberData(nameof(EdgeCases))]
public void EveryEdgeMatchesFrozenScoringExactly(double rate, string edge, int quadrant)
{
var stock = new NestPlateStock("sheet", new Size(20, 20), partSpacing: 0.25,
edgeSpacing: new Spacing(1, 1, 1, 1), quadrant: quadrant);
var work = stock.WorkArea;
var width = edge is "left" or "right" ? 4 : 18;
var height = edge is "bottom" or "top" ? 4 : 18;
var x = work.Left + (edge == "left" ? 14 : 0);
var y = work.Bottom + (edge == "bottom" ? 14 : 0);
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(
TestDrawingFactory.Rectangle(width, height)), 2);
var job = new NestJob(new[] { part }, new[] { stock },
new NestJobOptions(salvageRate: rate, minimumSalvageDimension: 2));
var sheet = new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("part", 0, x, y, 0) });
var expected = 400 - rate * (18 * 13.75);
Assert.Equal(expected, LegacyNestJobCost.EstimateNetArea(job, sheet));
Assert.Equal(expected, NestJobCost.NetSheetArea(job, sheet));
Assert.Equal(expected, NestJobCost.NetSheetArea(job.Options, stock, new Box(x, y, width, height)));
#pragma warning disable CS0618 // Compatibility API must retain the old result.
Assert.Equal(expected, StockLadderNestingEngine.EstimateNetArea(job, sheet));
#pragma warning restore CS0618
}
[Theory]
[InlineData(0, 5, 100)]
[InlineData(0.5, 0, 100)]
[InlineData(0.5, 7, 100)]
[InlineData(0.5, 6, 70)]
[InlineData(0.5, 5, 70)]
public void StockLadderFixtureRetainsThresholds(double rate, double minimum, double expected)
{
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 4)), 1);
var stock = new NestPlateStock("sheet", new Size(10, 10));
var job = new NestJob(new[] { part }, new[] { stock },
new NestJobOptions(salvageRate: rate, minimumSalvageDimension: minimum));
var sheet = new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("part", 0, 0, 0, 0) });
Assert.Equal(expected, NestJobCost.NetSheetArea(job, sheet));
Assert.Equal(LegacyNestJobCost.EstimateNetArea(job, sheet), NestJobCost.NetSheetArea(job, sheet));
}
[Theory]
[InlineData(0)]
[InlineData(0.5)]
public void RotatedMarksAndMultipleSheetsKeepExactLegacyCost(double rate)
{
var program = TestDrawingFactory.Rectangle(4, 3);
program.MoveTo(2, 2);
program.Codes.Add(new LinearMove(9, 2) { Layer = LayerType.Scribe });
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(program), 5);
var stock = new NestPlateStock("sheet", new Size(30, 40));
var other = new NestPlateStock("large", new Size(50, 50));
var job = new NestJob(new[] { part }, new[] { stock, other },
new NestJobOptions(salvageRate: rate, minimumSalvageDimension: 2));
var builder = new NestJobResultBuilder(job);
builder.AddSheet(stock, new[] { ("part", 10.123, 8.456, 0.37), ("part", 25.789, 17.321, 1.12) });
builder.AddSheet(other, new[] { ("part", 12.345, 19.876, 2.13) });
var result = builder.Build(NestJobStopReason.NoPlacementFound);
foreach (var sheet in result.Plates)
Assert.Equal(LegacyNestJobCost.EstimateNetArea(job, sheet), NestJobCost.NetSheetArea(job, sheet));
Assert.Equal(2500, NestJobCost.UnplacedPartPenalty(job));
Assert.Equal(result.Plates.Sum(sheet => LegacyNestJobCost.EstimateNetArea(job, sheet)) + 5000,
NestJobCost.Evaluate(job, result));
}
[Fact]
public void ScoringKeepsEtchBoundsEvenThoughMaterialGeometryExcludesThem()
{
var program = TestDrawingFactory.Rectangle(4, 3);
program.MoveTo(2, 2);
program.Codes.Add(new LinearMove(15, 2) { Layer = LayerType.Scribe });
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(program), 1);
var stock = new NestPlateStock("sheet", new Size(10, 20));
var job = new NestJob(new[] { part }, new[] { stock },
new NestJobOptions(salvageRate: 0.5, minimumSalvageDimension: 1));
var sheet = new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("part", 0, 0, 0, 0) });
Assert.Equal(130, LegacyNestJobCost.EstimateNetArea(job, sheet));
Assert.Equal(130, NestJobCost.NetSheetArea(job, sheet));
Assert.Equal(120, NestJobCost.NetSheetArea(job.Options, stock, JobPartGeometry.Read(part.Geometry).Bounds));
}
[Fact]
public void EmptySheetsAndEmptyStockRetainBenchmarkSemantics()
{
var stock = new NestPlateStock("sheet", new Size(10, 10));
var job = new NestJob(Array.Empty<NestJobPart>(), new[] { stock },
new NestJobOptions(salvageRate: 1, minimumSalvageDimension: 1));
var sheet = new NestJobPlateResult(0, stock, Array.Empty<NestJobPlacement>());
Assert.Equal(100, NestJobCost.NetSheetArea(job, sheet));
Assert.Equal(LegacyNestJobCost.EstimateNetArea(job, sheet), NestJobCost.NetSheetArea(job, sheet));
var empty = new NestJob(Array.Empty<NestJobPart>(), Array.Empty<NestPlateStock>());
Assert.Equal(0, NestJobCost.UnplacedPartPenalty(empty));
Assert.Equal(0, NestJobCost.Evaluate(empty, new NestJobResultBuilder(empty).Build(NestJobStopReason.Completed)));
}
}
+81
View File
@@ -0,0 +1,81 @@
using System.Linq;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
namespace OpenNest.Engine.Jobs;
/// <summary>Benchmark scoring primitives. Lower costs represent less sheet consumption.</summary>
public static class NestJobCost
{
/// <summary>
/// Sheet area less SalvageRate times the largest usable full-width/full-length edge offcut.
/// Both offcut dimensions must meet MinimumSalvageDimension, which must be positive.
/// Bounds intentionally use Part.BoundingBox (including marks) to preserve benchmark scores.
/// </summary>
public static double NetSheetArea(NestJob job, NestJobPlateResult sheet)
{
var area = sheet.Stock.Size.Width * sheet.Stock.Size.Length;
var minimum = job.Options.MinimumSalvageDimension;
if (job.Options.SalvageRate == 0 || minimum <= 0 || sheet.Placements.Count == 0)
return area;
var parts = job.Parts.ToDictionary(p => p.Id);
var boxes = sheet
.Placements.Select(p =>
{
var part = new Part(DrawingJobMapper.CreateDrawing(parts[p.PartId]));
part.Rotate(p.Rotation);
part.Location = new OpenNest.Geometry.Vector(p.X, p.Y);
part.UpdateBounds();
return part.BoundingBox;
})
.ToList();
return NetSheetArea(job.Options, sheet.Stock, boxes.Min(b => b.Left),
boxes.Min(b => b.Bottom), boxes.Max(b => b.Right), boxes.Max(b => b.Top));
}
/// <summary>
/// Computes net area from an existing placed-parts envelope without rebuilding geometry.
/// For benchmark parity the envelope must include the same marks as Part.BoundingBox.
/// Empty sheets should use the sheet overload, which returns their full area.
/// </summary>
public static double NetSheetArea(NestJobOptions options, NestPlateStock stock, Box partsEnvelope) =>
NetSheetArea(options, stock, partsEnvelope.Left, partsEnvelope.Bottom,
partsEnvelope.Right, partsEnvelope.Top);
private static double NetSheetArea(
NestJobOptions options, NestPlateStock stock, double left, double bottom, double right, double top)
{
var area = stock.Size.Width * stock.Size.Length;
var minimum = options.MinimumSalvageDimension;
if (options.SalvageRate == 0 || minimum <= 0)
return area;
var work = stock.WorkArea;
var gap = stock.PartSpacing;
var candidates = new[]
{
(work.Length, bottom - work.Bottom - gap),
(work.Length, work.Top - top - gap),
(left - work.Left - gap, work.Width),
(work.Right - right - gap, work.Width),
};
var salvage = candidates
.Where(c => c.Item1 >= minimum && c.Item2 >= minimum)
.Select(c => c.Item1 * c.Item2)
.DefaultIfEmpty(0)
.Max();
return area - options.SalvageRate * salvage;
}
/// <summary>Largest offered sheet area, charged by the benchmark per unplaced part.</summary>
public static double UnplacedPartPenalty(NestJob job) =>
job.Plates.Count == 0 ? 0 : job.Plates.Max(stock => stock.Size.Width * stock.Size.Length);
/// <summary>
/// Sum of net sheet areas plus unplaced quantity times the largest offered sheet area.
/// Matches the benchmark for a valid run; this method does not validate the result.
/// </summary>
public static double Evaluate(NestJob job, NestJobResult result) =>
result.Plates.Sum(sheet => NetSheetArea(job, sheet))
+ System.Math.Max(0, job.Parts.Sum(part => part.Quantity)
- result.Plates.Sum(sheet => sheet.Placements.Count)) * UnplacedPartPenalty(job);
}
@@ -97,7 +97,7 @@ public sealed class StockLadderNestingEngine : INestingEngine
// Initial construction only: material area, never raw part counts. Repacking below
// compares EXACTLY equivalent demand, and never replaces a sheet by a partial fill.
var value =
EstimateNetArea(job, sheet) / candidate.Placements.Sum(p => areas[p.PartId]);
NestJobCost.NetSheetArea(job, sheet) / candidate.Placements.Sum(p => areas[p.PartId]);
if (value < score - 1e-9)
{
winner = sheet;
@@ -188,7 +188,7 @@ public sealed class StockLadderNestingEngine : INestingEngine
var demand = old.SelectMany(s => s.Placements)
.GroupBy(p => p.PartId)
.ToDictionary(g => g.Key, g => g.Count());
var baseline = old.Sum(s => EstimateNetArea(job, s));
var baseline = old.Sum(s => NestJobCost.NetSheetArea(job, s));
NestJobPlateResult replacement = null;
foreach (var stock in job.Plates)
{
@@ -215,7 +215,7 @@ public sealed class StockLadderNestingEngine : INestingEngine
if (demand.Any(kv => !actual.TryGetValue(kv.Key, out var n) || n != kv.Value))
continue;
var trial = new NestJobPlateResult(index, stock, candidate.Placements);
var cost = EstimateNetArea(job, trial);
var cost = NestJobCost.NetSheetArea(job, trial);
if (cost >= baseline - 1e-9)
continue;
baseline = cost;
@@ -242,37 +242,7 @@ public sealed class StockLadderNestingEngine : INestingEngine
/// empty full-span edge rectangle outside every placed bounding box plus part clearance, within
/// the usable work area, and meeting the caller's minimum in both dimensions. Not a certified
/// remnant: no cut-off toolpath, kerf, handling, or future-demand valuation is modelled.</summary>
public static double EstimateNetArea(NestJob job, NestJobPlateResult sheet)
{
var area = sheet.Stock.Size.Width * sheet.Stock.Size.Length;
var minimum = job.Options.MinimumSalvageDimension;
if (job.Options.SalvageRate == 0 || minimum <= 0 || sheet.Placements.Count == 0)
return area;
var work = DrawingJobMapper.CreatePlate(sheet.Stock).WorkArea();
var parts = job.Parts.ToDictionary(p => p.Id);
var boxes = sheet
.Placements.Select(p =>
{
var part = new Part(DrawingJobMapper.CreateDrawing(parts[p.PartId]));
part.Rotate(p.Rotation);
part.Location = new OpenNest.Geometry.Vector(p.X, p.Y);
part.UpdateBounds();
return part.BoundingBox;
})
.ToList();
var gap = sheet.Stock.PartSpacing;
var candidates = new[]
{
(work.Length, boxes.Min(b => b.Bottom) - work.Bottom - gap),
(work.Length, work.Top - boxes.Max(b => b.Top) - gap),
(boxes.Min(b => b.Left) - work.Left - gap, work.Width),
(work.Right - boxes.Max(b => b.Right) - gap, work.Width),
};
var salvage = candidates
.Where(c => c.Item1 >= minimum && c.Item2 >= minimum)
.Select(c => c.Item1 * c.Item2)
.DefaultIfEmpty(0)
.Max();
return area - job.Options.SalvageRate * salvage;
}
[Obsolete("Use NestJobCost.NetSheetArea instead.")]
public static double EstimateNetArea(NestJob job, NestJobPlateResult sheet) =>
NestJobCost.NetSheetArea(job, sheet);
}
@@ -8,6 +8,45 @@ namespace OpenNest.Tests.Benchmark;
public class BenchmarkScoringTests
{
[Theory]
[InlineData(0, 43, 43, 4080)]
[InlineData(0.5, 43, 43, 4080)]
[InlineData(0, 43, 0, 4080)]
[InlineData(0.5, 43, 0, 4080)]
[InlineData(0, 86, 21, 4104)]
[InlineData(0.5, 86, 21, 4104)]
[InlineData(0, 0, 21, 4104)]
[InlineData(0.5, 0, 21, 4104)]
public void SharedCostEqualsBenchmarkRunForEveryEdgeAndUnplacedParts(
double rate, double x, double y, double salvage)
{
var benchmarkJob = Job(0.5, (Rect(10, 5), 3));
NestJob? capturedJob = null;
NestJobResult? capturedResult = null;
var engine = Engine("Shared cost parity", job =>
{
capturedJob = job;
var builder = new NestJobResultBuilder(job);
builder.AddSheet(job.Plates[0], new[] { (job.Parts[0].Id, x, y, 0.0) });
capturedResult = builder.Build(NestJobStopReason.NoPlacementFound);
return capturedResult.Plates;
});
var scored = Assert.Single(BenchmarkRunner.Run(new List<BenchmarkJob> { benchmarkJob }, new[] { engine },
salvageRate: rate, minimumSalvageDimension: 10));
Assert.True(scored.Valid, string.Join("; ", scored.Violations));
Assert.NotNull(capturedJob);
Assert.NotNull(capturedResult);
Assert.Equal(2, scored.PartsUnplaced);
Assert.Equal(4608 - rate * salvage, scored.NetSheetArea);
Assert.Equal(scored.NetSheetArea,
capturedResult.Plates.Sum(sheet => NestJobCost.NetSheetArea(capturedJob, sheet)));
Assert.Equal(benchmarkJob.UnplacedPartPenalty, NestJobCost.UnplacedPartPenalty(capturedJob));
Assert.Equal(scored.NetSheetArea + 2 * 4608, scored.Cost);
Assert.Equal(scored.Cost, NestJobCost.Evaluate(capturedJob, capturedResult));
}
// ── ranking ──────────────────────────────────────────────────────────────
[Fact]