feat(benchmark): add --progress logging for engine solves

Long whole-job solves ran silently, so there was no way to tell a slow
engine from a hung one until the timeout fired. --progress hands each
solve a JobProgressLog that prints [job/engine] lines for start, finish
(or failure/timeout), every plate commit, and candidate evaluations
throttled to one line per 2 s so parallel runs stay readable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
aj
2026-09-25 14:03:10 -04:00
co-authored by Claude Opus 5.5
parent 695ccc0a3b
commit 23dd99fa2f
7 changed files with 193 additions and 7 deletions
+1 -1
View File
@@ -87,7 +87,7 @@ Compares registered `INestingEngine` implementations against each other on real
- `BenchmarkRunner` fans the (job × engine) pairs out with `Parallel.ForEach` (`NoBuffering`, `MaxDegreeOfParallelism` from `--parallel`, CLI default 3, `Run`'s own default 1) and writes results by index so report order stays job-then-engine. Each solve builds its own `NestJob` snapshot and materialized drawings, so solves share no mutable drawing state. Concurrent solves compete for cores, so `Time(ms)` is only clean at `--parallel 1`. It calls each engine's `INestingEngine.Solve(NestJob)` once per job, under a wall-clock timeout so a runaway or hanging engine can't stall the whole benchmark run, then materializes the result back into legacy `Plate`/`Part` objects via `NestResultMaterializer` for scoring. - `BenchmarkRunner` fans the (job × engine) pairs out with `Parallel.ForEach` (`NoBuffering`, `MaxDegreeOfParallelism` from `--parallel`, CLI default 3, `Run`'s own default 1) and writes results by index so report order stays job-then-engine. Each solve builds its own `NestJob` snapshot and materialized drawings, so solves share no mutable drawing state. Concurrent solves compete for cores, so `Time(ms)` is only clean at `--parallel 1`. It calls each engine's `INestingEngine.Solve(NestJob)` once per job, under a wall-clock timeout so a runaway or hanging engine can't stall the whole benchmark run, then materializes the result back into legacy `Plate`/`Part` objects via `NestResultMaterializer` for scoring.
- `NestValidator` checks the returned layout: every part inside `Plate.WorkArea()`, every pair at least `Plate.PartSpacing` apart (checked geometrically: each part's perimeter inflated and cutouts shrunk by the spacing, tested against the other part's raw material with holes subtracted, so part-in-part inside a cutout is legal; an X-sorted bounding-box sweep prunes distant pairs), and no drawing over its requested quantity. `ValidateAgainstJob` also checks the raw `NestJobResult`: every sheet must match a stock entry the job offered (size, spacing, edge spacing, quadrant; finite quantity not overdrawn), and every placement rotation must satisfy its part's `RotationPolicy.Allows`. An invalid, throwing, or timed-out run places nothing for scoring. - `NestValidator` checks the returned layout: every part inside `Plate.WorkArea()`, every pair at least `Plate.PartSpacing` apart (checked geometrically: each part's perimeter inflated and cutouts shrunk by the spacing, tested against the other part's raw material with holes subtracted, so part-in-part inside a cutout is legal; an X-sorted bounding-box sweep prunes distant pairs), and no drawing over its requested quantity. `ValidateAgainstJob` also checks the raw `NestJobResult`: every sheet must match a stock entry the job offered (size, spacing, edge spacing, quadrant; finite quantity not overdrawn), and every placement rotation must satisfy its part's `RotationPolicy.Allows`. An invalid, throwing, or timed-out run places nothing for scoring.
- Ranking (`Report.Compare`): valid > invalid, fully placed > not, then lower `JobResult.Cost`, then fewer plates. Cost = salvage-credited sheet area (`StockLadderNestingEngine.EstimateNetArea` per plate, recomputed from job geometry) + `BenchmarkJob.UnplacedPartPenalty` (largest candidate sheet area) per unplaced part, so dropping hard parts never improves the score. The summary sums cost and areas across jobs (area-weighted, not a mean of per-job percentages). Without `--sheet-sizes`, `.nest` jobs only offer their original sizes, and the CLI warns that this hints engines. Numeric CLI and manifest sheet sizes parse with the invariant culture (`JobLoader.TryParseSheetSize`). - Ranking (`Report.Compare`): valid > invalid, fully placed > not, then lower `JobResult.Cost`, then fewer plates. Cost = salvage-credited sheet area (`StockLadderNestingEngine.EstimateNetArea` per plate, recomputed from job geometry) + `BenchmarkJob.UnplacedPartPenalty` (largest candidate sheet area) per unplaced part, so dropping hard parts never improves the score. The summary sums cost and areas across jobs (area-weighted, not a mean of per-job percentages). Without `--sheet-sizes`, `.nest` jobs only offer their original sizes, and the CLI warns that this hints engines. Numeric CLI and manifest sheet sizes parse with the invariant culture (`JobLoader.TryParseSheetSize`).
- `--engines Name1,Name2` filters to specific registered engines (default: all); `--csv <path>` writes a flat per-job CSV alongside the console report. - `--engines Name1,Name2` filters to specific registered engines (default: all); `--csv <path>` writes a flat per-job CSV alongside the console report. `--progress` passes each solve a `JobProgressLog`, which writes `[job/engine]` lines for start, finish, every `PlateCommitted`, and `EvaluatingCandidate` throttled to one line per 2 s.
- `tools/PepNestExport` (outside the solution; references `PepLib.Core` from the sibling `PepApi.Core` repo) converts a PepApi year of PEP nests into `.nest` files that keep PEP's placements as the benchmark `Baseline`. PEP loop quirks: sub-loop calls continue the incremental position; lead-in/out, `DESTRUCT CUT` and non-cut moves must not reach the program as rapids (a program's bounding box counts rapid endpoints); contours may be broken by uncut micro-joint tabs (a rapid of up to 0.25 across the tab, at the seam or mid-contour, e.g. a cutout cut as two halves), which the export bridges only where the pieces chain into a closed loop; and one drawing can be placed through several loops with different origins. - `tools/PepNestExport` (outside the solution; references `PepLib.Core` from the sibling `PepApi.Core` repo) converts a PepApi year of PEP nests into `.nest` files that keep PEP's placements as the benchmark `Baseline`. PEP loop quirks: sub-loop calls continue the incremental position; lead-in/out, `DESTRUCT CUT` and non-cut moves must not reach the program as rapids (a program's bounding box counts rapid endpoints); contours may be broken by uncut micro-joint tabs (a rapid of up to 0.25 across the tab, at the seam or mid-contour, e.g. a cutout cut as two halves), which the export bridges only where the pieces chain into a closed loop; and one drawing can be placed through several loops with different origins.
### OpenNest.Mcp (console app, depends on Core + Engine + IO) ### OpenNest.Mcp (console app, depends on Core + Engine + IO)
+14 -4
View File
@@ -33,7 +33,8 @@ namespace OpenNest.Benchmark
double? salvageRate = null, double? salvageRate = null,
double? minimumSalvageDimension = null, double? minimumSalvageDimension = null,
string outputDirectory = null, string outputDirectory = null,
int maxParallelism = 1 int maxParallelism = 1,
System.IO.TextWriter progressLog = null
) )
{ {
var pairs = jobs.SelectMany(job => engines.Select(engine => (Job: job, Engine: engine))) var pairs = jobs.SelectMany(job => engines.Select(engine => (Job: job, Engine: engine)))
@@ -68,7 +69,8 @@ namespace OpenNest.Benchmark
pairs[i].Engine, pairs[i].Engine,
salvageRate, salvageRate,
minimumSalvageDimension, minimumSalvageDimension,
outputDirectory outputDirectory,
progressLog
) )
); );
@@ -223,10 +225,15 @@ namespace OpenNest.Benchmark
NestingEngineInfo engineInfo, NestingEngineInfo engineInfo,
double? salvageRate, double? salvageRate,
double? minimumSalvageDimension, double? minimumSalvageDimension,
string outputDirectory string outputDirectory,
System.IO.TextWriter progressLog
) )
{ {
var requested = job.TotalRequestedQuantity; var requested = job.TotalRequestedQuantity;
var log = progressLog == null
? null
: new JobProgressLog(progressLog, $"{job.Name}/{engineInfo.Name}");
log?.Started();
var sw = Stopwatch.StartNew(); var sw = Stopwatch.StartNew();
try try
@@ -234,7 +241,8 @@ namespace OpenNest.Benchmark
var nestJob = job.BuildNestJob(MaxPlates, salvageRate, minimumSalvageDimension); var nestJob = job.BuildNestJob(MaxPlates, salvageRate, minimumSalvageDimension);
var engine = engineInfo.Factory(); var engine = engineInfo.Factory();
using var cts = new CancellationTokenSource(SolveTimeout); using var cts = new CancellationTokenSource(SolveTimeout);
var jobResult = engine.Solve(nestJob, null, cts.Token); var jobResult = engine.Solve(nestJob, log, cts.Token);
log?.Finished(jobResult, sw.ElapsedMilliseconds);
var materialized = NestResultMaterializer.Materialize(nestJob, jobResult); var materialized = NestResultMaterializer.Materialize(nestJob, jobResult);
var plateRuns = materialized var plateRuns = materialized
@@ -357,6 +365,7 @@ namespace OpenNest.Benchmark
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
sw.Stop(); sw.Stop();
log?.Failed("timed out", sw.ElapsedMilliseconds);
return new JobResult return new JobResult
{ {
EngineName = engineInfo.Name, EngineName = engineInfo.Name,
@@ -371,6 +380,7 @@ namespace OpenNest.Benchmark
catch (Exception ex) catch (Exception ex)
{ {
sw.Stop(); sw.Stop();
log?.Failed($"{ex.GetType().Name}: {ex.Message}", sw.ElapsedMilliseconds);
return new JobResult return new JobResult
{ {
EngineName = engineInfo.Name, EngineName = engineInfo.Name,
+88
View File
@@ -0,0 +1,88 @@
using System;
using System.Diagnostics;
using System.IO;
using OpenNest.Engine.Jobs;
namespace OpenNest.Benchmark
{
/// <summary>
/// Writes one solve's NestJobProgress as log lines prefixed with "[job/engine]". Every
/// PlateCommitted is written; EvaluatingCandidate is throttled to one line per interval so a
/// chatty engine cannot flood the console. One instance per solve; Report is thread-safe.
/// </summary>
public sealed class JobProgressLog : IProgress<NestJobProgress>
{
public static readonly TimeSpan DefaultInterval = TimeSpan.FromSeconds(2);
private readonly TextWriter writer;
private readonly string label;
private readonly TimeSpan interval;
private readonly Func<TimeSpan> clock;
private readonly object sync = new();
private TimeSpan? lastEvaluating;
public JobProgressLog(
TextWriter writer,
string label,
TimeSpan? interval = null,
Func<TimeSpan> clock = null
)
{
this.writer = writer ?? throw new ArgumentNullException(nameof(writer));
this.label = label;
this.interval = interval ?? DefaultInterval;
if (clock == null)
{
var stopwatch = Stopwatch.StartNew();
clock = () => stopwatch.Elapsed;
}
this.clock = clock;
}
public void Started() => Write("started");
public void Finished(NestJobResult result, long elapsedMs) =>
Write(
$"finished in {elapsedMs} ms: {result.Status} ({result.StopReason}), "
+ $"{result.Plates.Count} plate(s)"
);
public void Failed(string error, long elapsedMs) =>
Write($"failed after {elapsedMs} ms: {error}");
public void Report(NestJobProgress value)
{
if (value == null)
return;
if (value.Stage == NestJobStage.PlateCommitted)
{
Write(
$"committed plate {value.CommittedPlates} on stock {value.StockId} "
+ $"({value.CommittedParts} parts placed)"
);
return;
}
lock (sync)
{
var now = clock();
if (lastEvaluating.HasValue && now - lastEvaluating.Value < interval)
return;
lastEvaluating = now;
}
var plate = value.PlateIndex >= 0 ? value.PlateIndex + 1 : value.CommittedPlates + 1;
Write(
$"evaluating plate {plate} on stock {value.StockId} "
+ $"({value.CommittedPlates} plate(s), {value.CommittedParts} parts committed)"
);
}
private void Write(string message)
{
lock (sync)
writer.WriteLine($"[{label}] {message}");
}
}
}
+10 -1
View File
@@ -123,7 +123,8 @@ static class BenchmarkConsole
options.SalvageRate, options.SalvageRate,
options.MinimumSalvageDimension, options.MinimumSalvageDimension,
options.OutputDirectory, options.OutputDirectory,
options.Parallel options.Parallel,
options.Progress ? Console.Out : null
); );
Report.PrintDetailed(results); Report.PrintDetailed(results);
@@ -196,6 +197,10 @@ static class BenchmarkConsole
); );
break; break;
case "--progress":
o.Progress = true;
break;
case "--help": case "--help":
PrintUsage(); PrintUsage();
return null; return null;
@@ -322,6 +327,9 @@ static class BenchmarkConsole
Console.Error.WriteLine( Console.Error.WriteLine(
" which gives the cleanest per-engine timings)" " which gives the cleanest per-engine timings)"
); );
Console.Error.WriteLine(
" --progress Log each solve's start, engine progress and finish"
);
Console.Error.WriteLine(" --help Show this message"); Console.Error.WriteLine(" --help Show this message");
} }
@@ -336,5 +344,6 @@ static class BenchmarkConsole
public double? SalvageRate; public double? SalvageRate;
public double? MinimumSalvageDimension; public double? MinimumSalvageDimension;
public int Parallel = 3; public int Parallel = 3;
public bool Progress;
} }
} }
@@ -296,6 +296,40 @@ public sealed class BenchmarkRunnerTests : IDisposable
Assert.True(File.Exists(Path.Combine(output, "job-Engine0.json"))); Assert.True(File.Exists(Path.Combine(output, "job-Engine0.json")));
} }
[Fact]
public void Run_WithProgressLog_ForwardsEngineProgress()
{
var writer = new StringWriter();
var engines = new List<NestingEngineInfo>
{
new("Reporter", "test double", () => new ReportingEngine()),
};
BenchmarkRunner.Run(LoadJob(), engines, maxParallelism: 1, progressLog: writer);
var log = writer.ToString();
Assert.Contains("[job/Reporter] started", log);
Assert.Contains("[job/Reporter] evaluating plate 1 on stock", log);
Assert.Contains("[job/Reporter] finished in", log);
}
private sealed class ReportingEngine : INestingEngine
{
public NestJobResult Solve(
NestJob job,
IProgress<NestJobProgress>? progress = null,
CancellationToken token = default
)
{
progress?.Report(
new NestJobProgress(NestJobStage.EvaluatingCandidate, job.Plates[0].Id, 0, 0, 0)
);
return new NestJobResultBuilder(job, progress).Build(
NestJobStopReason.NoPlacementFound
);
}
}
private sealed class ConcurrencyProbe private sealed class ConcurrencyProbe
{ {
private int _current; private int _current;
@@ -0,0 +1,45 @@
using OpenNest.Benchmark;
using OpenNest.Engine.Jobs;
namespace OpenNest.Tests.Benchmark;
public sealed class JobProgressLogTests
{
private static NestJobProgress Evaluating(int plateIndex = 0) =>
new(NestJobStage.EvaluatingCandidate, "stock", plateIndex, 0, 0);
[Fact]
public void Report_ThrottlesEvaluatingButAlwaysWritesCommits()
{
var writer = new StringWriter();
var now = TimeSpan.Zero;
var log = new JobProgressLog(writer, "job/engine", TimeSpan.FromSeconds(2), () => now);
log.Report(Evaluating());
log.Report(Evaluating());
log.Report(new NestJobProgress(NestJobStage.PlateCommitted, "stock", 0, 1, 5));
now = TimeSpan.FromSeconds(3);
log.Report(Evaluating(1));
var lines = writer.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(
[
"[job/engine] evaluating plate 1 on stock stock (0 plate(s), 0 parts committed)",
"[job/engine] committed plate 1 on stock stock (5 parts placed)",
"[job/engine] evaluating plate 2 on stock stock (0 plate(s), 0 parts committed)",
],
lines
);
}
[Fact]
public void Report_UnknownPlateIndexFallsBackToNextCommittedPlate()
{
var writer = new StringWriter();
var log = new JobProgressLog(writer, "j/e");
log.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, "s", -1, 2, 9));
Assert.Contains("evaluating plate 3 on stock s", writer.ToString());
}
}
+1 -1
View File
@@ -59,7 +59,7 @@ dotnet run --project OpenNest.Benchmark -- ./benchmark-jobs \
--sheet-sizes 48x96,60x120,72x120 --engines Default,StockLadder --csv results.csv --sheet-sizes 48x96,60x120,72x120 --engines Default,StockLadder --csv results.csv
``` ```
Layouts are validated (bounds, spacing, quantity, rotation, stock match); invalid runs place nothing and pay the penalty. `--parallel` (default 3) speeds up scoring but inflates `Time(ms)` — use `--parallel 1` when comparing speed. Pass `--sheet-sizes` for an unbiased run; otherwise only each file's original sizes are offered. Custom engines drop in as DLLs implementing `INestingEngine` (public parameterless constructor) in an `Engines/` folder next to the benchmark. Community engines live in [OpenNest-Engines](https://git.thecozycat.net/aj/OpenNest-Engines). Layouts are validated (bounds, spacing, quantity, rotation, stock match); invalid runs place nothing and pay the penalty. `--parallel` (default 3) speeds up scoring but inflates `Time(ms)` — use `--parallel 1` when comparing speed. Pass `--sheet-sizes` for an unbiased run; otherwise only each file's original sizes are offered. `--progress` logs each solve's start, the engine's `NestJobProgress` (plate evaluations throttled to one line per 2 s, every plate commit) and its finish. Custom engines drop in as DLLs implementing `INestingEngine` (public parameterless constructor) in an `Engines/` folder next to the benchmark. Community engines live in [OpenNest-Engines](https://git.thecozycat.net/aj/OpenNest-Engines).
## Project Structure ## Project Structure