docs(engines): template on shared services and a determinism rule

New engines start from the shared test kit (the template's tests are a
one-line EngineContractTests subclass) and the host APIs, so they don't
re-derive geometry reading, work areas or validator tolerances.
BENCH-RULES.md now forbids clocks, unseeded randomness and environment
variables from influencing placement (budgets count work; wall time only
through the host's cancellation token) and lists the kit as read-only.
Build-Engines.ps1 deploys only OpenNest.Engine.* folders, and
New-Engine.ps1 -IncludeBuildFiles copies the kit.

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 09:29:28 -04:00
co-authored by Codex Claude Opus 5.5
parent 0cdef009f8
commit 33cc2ee810
8 changed files with 63 additions and 92 deletions
+2 -2
View File
@@ -27,7 +27,7 @@ if (-not (Test-Path (Join-Path $OpenNestRoot 'OpenNest.Benchmark/OpenNest.Benchm
} }
$OpenNestRoot = (Resolve-Path $OpenNestRoot).Path $OpenNestRoot = (Resolve-Path $OpenNestRoot).Path
dotnet build (Join-Path $OpenNestRoot 'OpenNest.Benchmark/OpenNest.Benchmark.csproj') -c $Configuration dotnet build (Join-Path $OpenNestRoot 'OpenNest.Benchmark/OpenNest.Benchmark.csproj') -c $Configuration -m:1 -nr:false
if ($LASTEXITCODE -ne 0) { throw 'OpenNest.Benchmark build failed.' } if ($LASTEXITCODE -ne 0) { throw 'OpenNest.Benchmark build failed.' }
$deployDir = Join-Path $OpenNestRoot "OpenNest.Benchmark/bin/$Configuration/net8.0/Engines" $deployDir = Join-Path $OpenNestRoot "OpenNest.Benchmark/bin/$Configuration/net8.0/Engines"
@@ -38,7 +38,7 @@ $projects = Get-ChildItem $PSScriptRoot -Directory -Filter 'OpenNest.Engine.*' |
foreach ($dir in $projects) { foreach ($dir in $projects) {
$csproj = Join-Path $dir.FullName "$($dir.Name).csproj" $csproj = Join-Path $dir.FullName "$($dir.Name).csproj"
dotnet build $csproj -c $Configuration "-p:OpenNestRoot=$OpenNestRoot/" dotnet build $csproj -c $Configuration "-p:OpenNestRoot=$OpenNestRoot/" -m:1 -nr:false
if ($LASTEXITCODE -ne 0) { throw "$($dir.Name) build failed." } if ($LASTEXITCODE -ne 0) { throw "$($dir.Name) build failed." }
$dll = Join-Path $dir.FullName "bin/$Configuration/net8.0/$($dir.Name).dll" $dll = Join-Path $dir.FullName "bin/$Configuration/net8.0/$($dir.Name).dll"
+18 -11
View File
@@ -7,8 +7,8 @@
placeholder in file names and contents. The new engine builds against OpenNest via placeholder in file names and contents. The new engine builds against OpenNest via
Directory.Build.props, which must sit in <Destination> or a parent folder. Directory.Build.props, which must sit in <Destination> or a parent folder.
With -IncludeBuildFiles, Directory.Build.props/.targets are copied into <Destination> With -IncludeBuildFiles, Directory.Build.props/.targets and the shared Engine.Testing
too, so the engine can live outside this repo, e.g. in an Engines/ folder inside an source kit are copied into <Destination>, so the engine can live outside this repo, e.g. in an Engines/ folder inside an
OpenNest checkout (the props detect that layout on their own). OpenNest checkout (the props detect that layout on their own).
.EXAMPLE .EXAMPLE
@@ -29,21 +29,28 @@ $target = Join-Path $Destination "OpenNest.Engine.$Name"
if (Test-Path $target) { throw "'$target' already exists." } if (Test-Path $target) { throw "'$target' already exists." }
New-Item -ItemType Directory -Force $Destination | Out-Null New-Item -ItemType Directory -Force $Destination | Out-Null
Copy-Item $template $target -Recurse # A built template contains binary/obj files. Copy only source files, never rewrite binaries.
$template = (Resolve-Path $template).Path
Get-ChildItem $target -Recurse -File | ForEach-Object { Get-ChildItem $template -Recurse -File |
Where-Object { $_.FullName.Substring($template.Length) -notmatch '[\\/](bin|obj)[\\/]' } |
ForEach-Object {
$relative = $_.FullName.Substring($template.Length + 1).Replace('__NAME__', $Name)
$output = Join-Path $target $relative
New-Item -ItemType Directory -Force (Split-Path $output -Parent) | Out-Null
$text = [IO.File]::ReadAllText($_.FullName) $text = [IO.File]::ReadAllText($_.FullName)
[IO.File]::WriteAllText($_.FullName, $text.Replace('__NAME__', $Name)) [IO.File]::WriteAllText($output, $text.Replace('__NAME__', $Name))
} }
# Deepest paths first so renaming a folder never invalidates a pending child path.
Get-ChildItem $target -Recurse | Where-Object Name -like '*__NAME__*' |
Sort-Object { $_.FullName.Length } -Descending |
ForEach-Object { Rename-Item $_.FullName $_.Name.Replace('__NAME__', $Name) }
if ($IncludeBuildFiles) { if ($IncludeBuildFiles) {
foreach ($file in 'Directory.Build.props', 'Directory.Build.targets') { foreach ($file in 'Directory.Build.props', 'Directory.Build.targets') {
Copy-Item (Join-Path $PSScriptRoot $file) $Destination -Force Copy-Item (Join-Path $PSScriptRoot $file) $Destination -Force
} }
# The acceptance tests reference the shared, read-only kit beside the engine.
$kitTarget = Join-Path $Destination 'Engine.Testing'
New-Item -ItemType Directory -Force $kitTarget | Out-Null
Get-ChildItem (Join-Path $PSScriptRoot 'Engine.Testing') -File |
Where-Object { $_.Extension -in '.cs', '.csproj' } |
Copy-Item -Destination $kitTarget -Force
} }
Write-Host "Created $target" Write-Host "Created $target"
+8 -2
View File
@@ -32,6 +32,12 @@ dotnet test OpenNest.Engine.Opus55/tests/OpenNest.Engine.Opus55.Tests.csproj
Each engine's README covers its algorithm and benchmark results. Each engine's README covers its algorithm and benchmark results.
`Engine.Testing/` contains shared xUnit contract tests, shapes, job builders and layout
assertions backed by `NestLayoutCheck`. It is a test dependency, not a plugin;
`Build-Engines.ps1` only deploys projects in `OpenNest.Engine.*` directories. All engines
require the shared-services APIs in the sibling host checkout. See
[the PR 5 migration report](MIGRATION-PR5.md) for validation and benchmark results.
## Writing a new engine ## Writing a new engine
```powershell ```powershell
@@ -41,10 +47,10 @@ Each engine's README covers its algorithm and benchmark results.
This copies `_Template/` to `OpenNest.Engine.Nova/`: an `INestingEngine` stub, a README This copies `_Template/` to `OpenNest.Engine.Nova/`: an `INestingEngine` stub, a README
spelling out what counts as an independent engine, `BENCH-RULES.md` (how a model's run spelling out what counts as an independent engine, `BENCH-RULES.md` (how a model's run
works: workspace limits, git, the real-part archive, reporting), and starter acceptance tests checked by works: workspace limits, git, the real-part archive, reporting), and starter acceptance tests checked by
the benchmark's own `NestValidator` (they fail until `Solve()` is implemented). the shared `NestLayoutCheck` (they fail until `Solve()` is implemented).
To work inside an OpenNest checkout instead, stamp it into an `Engines/` folder there and To work inside an OpenNest checkout instead, stamp it into an `Engines/` folder there and
bring the shared build files along; they detect that layout automatically: bring the shared build files and test kit along; they detect that layout automatically:
```powershell ```powershell
./New-Engine.ps1 -Name Nova -Destination <OpenNest>/Engines -IncludeBuildFiles ./New-Engine.ps1 -Name Nova -Destination <OpenNest>/Engines -IncludeBuildFiles
+6
View File
@@ -41,6 +41,12 @@ file covers how the run itself works.
## Tests and scoring ## Tests and scoring
- `Engine.Testing/` is the shared, read-only test kit supplied beside engine folders in
bench copies. Reference it for shapes, job construction, layout assertions and inherited
engine contract tests; do not copy or edit it during an engine optimization run.
- Placement must be deterministic: no clocks, unseeded randomness or environment variables
may influence placement. Budgets count work. Wall time may stop work only through the
host's cancellation token. Diagnostics must not affect placement decisions.
- Keep the starter tests in `tests/` and keep them passing. Add tests; do not weaken, - Keep the starter tests in `tests/` and keep them passing. Add tests; do not weaken,
skip or delete existing ones. If you believe an existing test is wrong, leave it and explain skip or delete existing ones. If you believe an existing test is wrong, leave it and explain
why in your report. why in your report.
+10 -4
View File
@@ -18,6 +18,10 @@ real-part drawing archive, tests and your final report.
Reuse is encouraged. These are tools you drive, composed by your own decision logic: Reuse is encouraged. These are tools you drive, composed by your own decision logic:
- `OpenNest.Engine.Jobs`: `JobPartGeometry`, stock `WorkArea`/`Area`/`Fits`,
`RotationPolicy.EnumerateAngles`, `RotationCandidates`, `NestJobCost`, `NestTolerances`,
`NestLayoutCheck`, and `NestJobResultBuilder`. These prepare geometry, check and account
for decisions made by your algorithm; they do not choose placements.
- `OpenNest.Core` geometry: `Polygon`, `Shape`, `BoundingBox`, `Vector`, `Box`, `ConvexHull`, - `OpenNest.Core` geometry: `Polygon`, `Shape`, `BoundingBox`, `Vector`, `Box`, `ConvexHull`,
`ConvexDecomposition`, `RotatingCalipers`, `Collision`, `NoFitPolygon`, `ShapeProfile`, `ConvexDecomposition`, `RotatingCalipers`, `Collision`, `NoFitPolygon`, `ShapeProfile`,
`SpatialQuery`. `SpatialQuery`.
@@ -46,10 +50,12 @@ measured numbers) so it can be generalized and upstreamed for every engine later
## Tests ## Tests
`tests/` holds starter acceptance tests. Every layout is checked by the benchmark's own `tests/` references the read-only `../Engine.Testing` kit and subclasses
`NestValidator` (bounds, spacing, quantities, stock, rotation), so a passing test means the `EngineContractTests<TEngine>`. `LayoutAssert.Valid` uses `NestLayoutCheck.Violations`,
benchmark will accept the layout. They fail until `Solve()` is implemented. Keep them and the benchmark's shared validation primitive, plus strict bounds and accounting checks.
add engine-specific tests next to them. The acceptance tests fail until `Solve()` is implemented. Keep them and add engine-specific
tests next to them. No clocks, unseeded randomness or environment variables may influence
placement; count work for budgets and honor the host cancellation token for wall time.
```bash ```bash
dotnet test OpenNest.Engine.__NAME__/tests/OpenNest.Engine.__NAME__.Tests.csproj dotnet test OpenNest.Engine.__NAME__/tests/OpenNest.Engine.__NAME__.Tests.csproj
+7 -3
View File
@@ -18,6 +18,7 @@ public sealed class __NAME__NestingEngine : INestingEngine
) )
{ {
ArgumentNullException.ThrowIfNull(job); ArgumentNullException.ThrowIfNull(job);
token.ThrowIfCancellationRequested();
// TODO: implement independent placement logic here. // TODO: implement independent placement logic here.
// //
@@ -30,9 +31,12 @@ public sealed class __NAME__NestingEngine : INestingEngine
// job.Plates -> candidate stock sheets (size, spacing, edge spacing, quadrant, quantity) // job.Plates -> candidate stock sheets (size, spacing, edge spacing, quadrant, quantity)
// job.Options -> job-wide options // job.Options -> job-wide options
// //
// Return a NestJobResult built from NestJobPlateResult (one per used sheet, holding // Read material with JobPartGeometry.TryRead(part.Geometry); use stock.WorkArea/Fits,
// ordered NestJobPlacement values), PartFulfillment (requested vs placed per part id), // RotationCandidates.ForShape and NestJobCost as primitives for your own decisions.
// and StockUsage (sheets used per stock id). // var result = new NestJobResultBuilder(job, progress);
// result.AddSheet(stock, poses); // (PartId, X, Y, Rotation); call only on commit.
// return result.Build(NestJobStopReason.NoPlacementFound);
// The builder assigns sheet/instance indices, fulfillment, usage and commit progress.
throw new NotImplementedException("__NAME__ nesting engine placement logic not yet implemented."); throw new NotImplementedException("__NAME__ nesting engine placement logic not yet implemented.");
} }
@@ -10,8 +10,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Using Include="Xunit" /> <Using Include="Xunit" />
<ProjectReference Include="../../Engine.Testing/OpenNest.Engine.Testing.csproj" />
<ProjectReference Include="../OpenNest.Engine.__NAME__.csproj" /> <ProjectReference Include="../OpenNest.Engine.__NAME__.csproj" />
<!-- The benchmark's NestValidator is the arbiter the engine is scored by. -->
<ProjectReference Include="$(OpenNestRoot)OpenNest.Benchmark/OpenNest.Benchmark.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+10 -67
View File
@@ -1,7 +1,9 @@
using OpenNest.Engine.Testing;
using static OpenNest.Engine.Testing.JobBuilder;
using static OpenNest.Engine.Testing.Shapes;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using OpenNest.Benchmark;
using OpenNest.CNC; using OpenNest.CNC;
using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters; using OpenNest.Engine.Jobs.Adapters;
@@ -10,7 +12,7 @@ using OpenNest.Geometry;
namespace OpenNest.Engine.__NAME__.Tests; namespace OpenNest.Engine.__NAME__.Tests;
/// <summary> /// <summary>
/// Starter acceptance tests. Every layout is checked by the same NestValidator the benchmark /// Starter acceptance tests. Every layout is checked by the shared NestLayoutCheck the benchmark
/// scores with, so a passing test means the benchmark will accept the layout. They fail until /// scores with, so a passing test means the benchmark will accept the layout. They fail until
/// Solve() is implemented; add engine-specific tests alongside them. /// Solve() is implemented; add engine-specific tests alongside them.
/// </summary> /// </summary>
@@ -30,7 +32,7 @@ public class __NAME__NestingEngineTests
var result = new __NAME__NestingEngine().Solve(job); var result = new __NAME__NestingEngine().Solve(job);
AssertValid(job, result); LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Single(result.Plates); Assert.Single(result.Plates);
Assert.Equal(12, result.Plates[0].Placements.Count); Assert.Equal(12, result.Plates[0].Placements.Count);
@@ -55,7 +57,7 @@ public class __NAME__NestingEngineTests
var result = new __NAME__NestingEngine().Solve(job); var result = new __NAME__NestingEngine().Solve(job);
AssertValid(job, result); LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
} }
@@ -66,7 +68,7 @@ public class __NAME__NestingEngineTests
var result = new __NAME__NestingEngine().Solve(job); var result = new __NAME__NestingEngine().Solve(job);
AssertValid(job, result); LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.True(result.Plates.Count > 1); Assert.True(result.Plates.Count > 1);
} }
@@ -81,70 +83,11 @@ public class __NAME__NestingEngineTests
var result = new __NAME__NestingEngine().Solve(job); var result = new __NAME__NestingEngine().Solve(job);
AssertValid(job, result); LayoutAssert.Valid(job, result);
var huge = Assert.Single(result.Fulfillment, f => f.PartId == "huge"); var huge = Assert.Single(result.Fulfillment, f => f.PartId == "huge");
Assert.Equal(1, huge.Unplaced); Assert.Equal(1, huge.Unplaced);
} }
// ---- helpers -------------------------------------------------------------------------
private static void AssertValid(NestJob job, NestJobResult result)
{
var materialized = NestResultMaterializer.Materialize(job, result);
var runs = materialized.Nest.Plates.Select(plate => (Plate: plate, Parts: plate.Parts.ToList())).ToList();
var requirements = job.Parts.ToDictionary<NestJobPart, Drawing, (string Name, int Quantity)>(
p => materialized.DrawingsByPartId[p.Id],
p => (p.Id, p.Quantity),
ReferenceEqualityComparer.Instance
);
var validation = NestValidator.Validate(runs, requirements);
NestValidator.ValidateAgainstJob(job, result, job.Parts.ToDictionary(p => p.Id, p => p.Id), validation);
Assert.True(validation.Valid, string.Join(Environment.NewLine, validation.Violations));
foreach (var f in result.Fulfillment)
Assert.Equal(f.Requested, f.Placed + f.Unplaced);
}
private static NestJob Job(NestJobPart[] parts, NestPlateStock[] stock, NestJobOptions? options = null) =>
new(parts, stock, options);
private static NestJobPart Part(string id, Program program, int quantity, RotationPolicy? rotation = null) =>
new(id, PartGeometrySnapshot.FromProgram(program), quantity, 0, rotation);
/// <param name="width">Y extent.</param>
/// <param name="length">X extent.</param>
private static NestPlateStock Stock(
string id,
double width,
double length,
double spacing = 0,
Spacing edge = default,
int quadrant = 1,
int? quantity = null
) => new(id, new Size(width, length), quantity, spacing, edge, quadrant);
private static Program Polyline(params (double X, double Y)[] points)
{
var program = new Program();
program.Codes.Add(new RapidMove(points[0].X, points[0].Y));
foreach (var (x, y) in points.Skip(1))
program.Codes.Add(new LinearMove(x, y));
program.Codes.Add(new LinearMove(points[0].X, points[0].Y));
return program;
}
private static Program Rectangle(double w, double h) => Polyline((0, 0), (w, 0), (w, h), (0, h));
private static Program Triangle(double w, double h) => Polyline((0, 0), (w, 0), (w * 0.3, h));
private static Program LShape(double w, double h, double t) => Polyline((0, 0), (w, 0), (w, t), (t, t), (t, h), (0, h));
private static Program Disc(double r)
{
var program = new Program();
program.Codes.Add(new RapidMove(r, 0));
program.Codes.Add(new ArcMove(-r, 0, 0, 0, RotationType.CCW));
program.Codes.Add(new ArcMove(r, 0, 0, 0, RotationType.CCW));
return program;
}
} }
public sealed class __NAME__ContractTests : EngineContractTests<__NAME__NestingEngine> { }