fix(benchmark): rank by sheet cost so engines can't game the score

The benchmark is about to be used as the objective for LLM-designed
engines, and several gaps would have rewarded the wrong behavior:

- Ranking was utilization-first, so dropping awkward parts raised the
  score. Rank valid > fully placed > cost > plates, where cost is
  salvage-credited sheet area plus a largest-sheet penalty per unplaced
  part; placing a part is never scored worse than omitting it.
- Salvage rate was ignored in scoring; cost now uses EstimateNetArea,
  recomputed from job geometry rather than trusted from the engine.
- Rotation constraints were never validated. Add RotationPolicy.Allows
  (shared with NestJobPlacementValidator) and check every placement.
- Returned sheets were trusted, so an engine could loosen spacing or
  invent a size. Sheets must now match offered stock.
- Part-in-part placements were flagged as overlaps; spacing now accounts
  for cutouts, with an X-sorted sweep to prune distant pairs.
- Summary averaged per-job percentages; it now sums areas and cost.
- --spacing and sheet sizes parsed with the current culture.
- Warn when .nest jobs offer only their original sheet sizes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
aj
2026-09-22 15:05:43 -04:00
co-authored by Claude Opus 5.5
parent 98c4929d14
commit 57e9f625b6
13 changed files with 669 additions and 84 deletions
+15 -3
View File
@@ -100,11 +100,21 @@ namespace OpenNest.Benchmark
);
var validation = NestValidator.Validate(plateRuns, requirements);
NestValidator.ValidateAgainstJob(
nestJob,
jobResult,
job.Requests.ToDictionary(r => r.Drawing.Id.ToString(), r => r.Drawing.Name),
validation
);
var totalPlaced = plateRuns.Sum(pr => pr.Parts.Count);
var placedArea = validation.Valid
? plateRuns.Sum(pr => pr.Parts.Sum(p => p.BaseDrawing.Area))
: 0;
var plateArea = plateRuns.Sum(pr => pr.Plate.Area());
// 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))
: 0;
var sizeBreakdown = plateRuns
.GroupBy(pr => pr.Plate.Size.ToString(1))
@@ -157,9 +167,7 @@ namespace OpenNest.Benchmark
PlacedArea = placedArea,
SalvageRate = salvageRate,
MinimumSalvageDimension = minimumSalvageDimension,
EstimatedNetArea = jobResult.Plates.Sum(p =>
StockLadderNestingEngine.EstimateNetArea(nestJob, p)
),
EstimatedNetArea = netSheetArea,
Fulfillment = jobResult.Fulfillment,
StockUsage = jobResult.StockUsage,
Plates = jobResult.Plates,
@@ -185,6 +193,8 @@ namespace OpenNest.Benchmark
PartsRequested = requested,
PlacedArea = placedArea,
PlateArea = plateArea,
NetSheetArea = netSheetArea,
UnplacedPartPenalty = job.UnplacedPartPenalty,
PlatesUsed = plateRuns.Count,
SizeBreakdown = sizeBreakdown,
ElapsedMs = sw.ElapsedMilliseconds,
@@ -199,6 +209,7 @@ namespace OpenNest.Benchmark
JobName = job.Name,
Valid = false,
PartsRequested = requested,
UnplacedPartPenalty = job.UnplacedPartPenalty,
ElapsedMs = sw.ElapsedMilliseconds,
Error = $"Timed out after {SolveTimeout.TotalMinutes:F0} minute(s)",
};
@@ -212,6 +223,7 @@ namespace OpenNest.Benchmark
JobName = job.Name,
Valid = false,
PartsRequested = requested,
UnplacedPartPenalty = job.UnplacedPartPenalty,
ElapsedMs = sw.ElapsedMilliseconds,
Error = $"{ex.GetType().Name}: {ex.Message}",
};