fix(qwen38flashnext): make layouts deterministic and use host services

The gap-fill pass stopped on a 120 ms stopwatch and QWEN_* environment
variables switched strategies, so the same job could nest differently
with machine load or environment. Gap fill now stops after eight failed
insertion sweeps, and the switches are internal properties with the
old defaults. Part reading, work area, rotation angles, scoring and
result assembly now use the host APIs; its collision gate is unchanged.

Synthetic benchmark (5 jobs, salvage 0.5): all valid, cost 7660.01 ->
7572.05, time 839 -> 553 ms. Not yet calibrated on production-size jobs.

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 0308a862b8
commit 0cdef009f8
6 changed files with 98 additions and 329 deletions
@@ -18,32 +18,25 @@ internal sealed record SheetAttempt(SheetPacker Packer, int StockIndex);
/// </summary> /// </summary>
internal sealed class JobSolver internal sealed class JobSolver
{ {
private NestJobResultBuilder _builder = null!;
private readonly NestJob _job; private readonly NestJob _job;
private readonly PartPreparation _prep; private readonly PartPreparation _prep;
private readonly Dictionary<string, int> _remaining; private int _sheetCount;
private readonly Dictionary<string, int> _placed;
private readonly Dictionary<string, int> _used;
private readonly List<CommittedSheet> _sheets = new();
private sealed record CommittedSheet(int StockIndex, List<PlacedPart> Placements);
public JobSolver(NestJob job, PartPreparation prep) public JobSolver(NestJob job, PartPreparation prep)
{ {
_job = job; _job = job;
_prep = prep; _prep = prep;
_remaining = job.Parts.ToDictionary(p => p.Id, p => p.Quantity, StringComparer.Ordinal);
_placed = job.Parts.ToDictionary(p => p.Id, _ => 0, StringComparer.Ordinal);
_used = job.Plates.ToDictionary(s => s.Id, _ => 0, StringComparer.Ordinal);
} }
private static readonly bool _diag = internal static bool Diagnostics { get; set; }
Environment.GetEnvironmentVariable("QWEN_NEST_DIAG") == "1"; private static bool _diag => Diagnostics;
private void Diag(string message) private void Diag(string message)
{ {
if (_diag) if (_diag)
Console.Error.WriteLine( Console.Error.WriteLine(
$"[qwen] sheets={_sheets.Count} placed={_placed.Values.Sum()} " + $"[qwen] sheets={_sheetCount} placed={_job.Parts.Sum(p => _builder.Placed(p.Id))} " +
$"mem={GC.GetTotalMemory(false) / 1048576}MB gc0={GC.CollectionCount(0)} " + $"mem={GC.GetTotalMemory(false) / 1048576}MB gc0={GC.CollectionCount(0)} " +
$"gc2={GC.CollectionCount(2)} {message}" $"gc2={GC.CollectionCount(2)} {message}"
); );
@@ -51,14 +44,18 @@ internal sealed class JobSolver
public NestJobResult Solve(IProgress<NestJobProgress>? progress, CancellationToken token) public NestJobResult Solve(IProgress<NestJobProgress>? progress, CancellationToken token)
{ {
_builder = new NestJobResultBuilder(_job, progress);
var reason = NestJobStopReason.Completed; var reason = NestJobStopReason.Completed;
while (true) while (true)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var outstanding = OutstandingDemands(); var outstanding = OutstandingDemands();
if (outstanding.Count == 0) if (outstanding.Count == 0)
{
reason = _builder.IsComplete ? NestJobStopReason.Completed : NestJobStopReason.NoPlacementFound;
break; break;
if (_job.Options.MaxPlates is int cap && _sheets.Count >= cap) }
if (_job.Options.MaxPlates is int cap && _sheetCount >= cap)
{ {
reason = NestJobStopReason.PlateLimitReached; reason = NestJobStopReason.PlateLimitReached;
break; break;
@@ -75,25 +72,16 @@ internal sealed class JobSolver
} }
CommitSheet(attempt.Packer); CommitSheet(attempt.Packer);
progress?.Report(
new NestJobProgress(
NestJobStage.PlateCommitted,
_job.Plates[attempt.StockIndex].Id,
_sheets.Count - 1,
_sheets.Count,
_placed.Values.Sum()
)
);
} }
return BuildResult(reason); return _builder.Build(reason);
} }
private List<PartModel> OutstandingDemands() private List<PartModel> OutstandingDemands()
{ {
var demands = new List<PartModel>(); var demands = new List<PartModel>();
foreach (var model in _prep.Models) foreach (var model in _prep.Models)
if (_remaining[model.Id] > 0) if ((model.Quantity - _builder.Placed(model.Id)) > 0)
demands.Add(model); demands.Add(model);
// This engine's own ordering: priority first, then the tallest-then-largest // This engine's own ordering: priority first, then the tallest-then-largest
// part first (a part's thinnest orientation extent), then id for determinism. // part first (a part's thinnest orientation extent), then id for determinism.
@@ -189,7 +177,7 @@ internal sealed class JobSolver
for (var index = 0; index < _job.Plates.Count; index++) for (var index = 0; index < _job.Plates.Count; index++)
{ {
var stock = _job.Plates[index]; var stock = _job.Plates[index];
if (stock.Quantity is int quantity && _used[stock.Id] >= quantity) if (stock.Quantity is int quantity && _builder.SheetsUsed(stock) >= quantity)
continue; continue;
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
@@ -197,21 +185,14 @@ internal sealed class JobSolver
new NestJobProgress( new NestJobProgress(
NestJobStage.EvaluatingCandidate, NestJobStage.EvaluatingCandidate,
stock.Id, stock.Id,
_sheets.Count, _sheetCount,
_sheets.Count, _sheetCount,
_placed.Values.Sum() _job.Parts.Sum(p => _builder.Placed(p.Id))
) )
); );
var packer = SheetPacker.Create(stock, _prep, index); var packer = SheetPacker.Create(stock, _prep, index);
var fillWatch = System.Diagnostics.Stopwatch.StartNew();
FillSheet(packer, outstanding, token); FillSheet(packer, outstanding, token);
fillWatch.Stop();
if (_diag)
Diag(
$"trial stock {stock.Id}: placed={packer.Placed.Count} " +
$"{fillWatch.ElapsedMilliseconds}ms {packer.DiagStats()}"
);
if (packer.Placed.Count == 0) if (packer.Placed.Count == 0)
continue; continue;
@@ -261,7 +242,7 @@ internal sealed class JobSolver
{ {
var available = new Dictionary<string, int>(StringComparer.Ordinal); var available = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var model in outstanding) foreach (var model in outstanding)
available[model.Id] = _remaining[model.Id]; available[model.Id] = (model.Quantity - _builder.Placed(model.Id));
// One drain pass per requirement, in demand order. Gap-filling retries are // One drain pass per requirement, in demand order. Gap-filling retries are
// deliberately NOT an unbounded loop: a sheet's failed-insert scans get more // deliberately NOT an unbounded loop: a sheet's failed-insert scans get more
@@ -274,7 +255,6 @@ internal sealed class JobSolver
continue; continue;
if (!packer.CanEverFit(model)) if (!packer.CanEverFit(model))
continue; continue;
var modelWatch = System.Diagnostics.Stopwatch.StartNew();
while (available[model.Id] > 0 && !packer.IsFull) while (available[model.Id] > 0 && !packer.IsFull)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
@@ -282,32 +262,30 @@ internal sealed class JobSolver
break; break;
available[model.Id]--; available[model.Id]--;
} }
modelWatch.Stop();
if (_diag && modelWatch.ElapsedMilliseconds > 200)
Diag($" fill model {model.Id}: placed={packer.Placed.Count} {modelWatch.ElapsedMilliseconds}ms {packer.DiagStats()}");
} }
// Gap-fill pass: a part that could not fit between two early placements may // Count failed insertion sweeps, independent of machine speed and load.
// fit the gaps a later model leaves behind. Failed-insert scans cost about a // Successful insertions are bounded by the outstanding quantities.
// full candidate sweep each, so the pass is hard time-boxed - on a crowded var retries = GapFillFailedInsertBudget;
// sheet the untried budget was measured at minutes per job, well over the
// benchmark's wall; the box keeps worst case near the first pass's cost.
var retryWatch = System.Diagnostics.Stopwatch.StartNew();
var retryAgain = true; var retryAgain = true;
while (retryAgain && retryWatch.ElapsedMilliseconds < GapFillMilliseconds) while (retryAgain && retries > 0)
{ {
retryAgain = false; retryAgain = false;
foreach (var model in outstanding) foreach (var model in outstanding)
{ {
if (available[model.Id] <= 0 || packer.IsFull) if (available[model.Id] <= 0 || packer.IsFull)
continue; continue;
if (retryWatch.ElapsedMilliseconds >= GapFillMilliseconds) if (retries <= 0)
break; break;
while (available[model.Id] > 0) while (available[model.Id] > 0)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
if (!packer.TryInsert(model, out _)) if (!packer.TryInsert(model, out _))
{
retries--;
break; break;
}
available[model.Id]--; available[model.Id]--;
retryAgain = true; retryAgain = true;
} }
@@ -321,18 +299,12 @@ internal sealed class JobSolver
/// the seams; 12% lower job cost than span-first on a real production job), 2 = largest footprint /// the seams; 12% lower job cost than span-first on a real production job), 2 = largest footprint
/// first, 0 = smallest worst-case extent first (original). /// first, 0 = smallest worst-case extent first (original).
/// </summary> /// </summary>
private static readonly int DemandOrderMode = internal static int DemandOrderMode { get; set; } = 1;
int.TryParse(Environment.GetEnvironmentVariable("QWEN_DEMAND_ORDER"), out var m)
? m
: 1;
/// <summary>Wall-clock budget for one sheet's gap-fill pass.</summary> /// <summary>Maximum failed insertion sweeps per sheet gap-fill pass.</summary>
private static readonly int GapFillMilliseconds = internal static int GapFillFailedInsertBudget { get; set; } = 8;
int.TryParse(Environment.GetEnvironmentVariable("QWEN_GAPFILL_MS"), out var ms)
? ms
: 120;
/// <summary>Trial-sheet metrics; costPerArea = plate area / part area placed.</summary> /// <summary>Trial-sheet metrics; costPerArea = net sheet area / material area placed.</summary>
private readonly record struct TrialScore( private readonly record struct TrialScore(
int count, int count,
int priorityHits, int priorityHits,
@@ -343,10 +315,9 @@ internal sealed class JobSolver
/// <summary> /// <summary>
/// Greedy trial-comparison mode. Cost-first optimizes the benchmark's cost /// Greedy trial-comparison mode. Cost-first optimizes the benchmark's cost
/// function (total plate area); count-first is the conservative fill policy. /// function (total plate area); count-first is the conservative fill policy.
/// Env override exists for A/B measurement. /// Internal setting permits controlled A/B tests.
/// </summary> /// </summary>
private static readonly bool CostFirstScoring = internal static bool CostFirstScoring { get; set; } = true;
Environment.GetEnvironmentVariable("QWEN_COST_FIRST") != "0";
private TrialScore ScoreTrial(SheetPacker packer) private TrialScore ScoreTrial(SheetPacker packer)
{ {
@@ -356,7 +327,8 @@ internal sealed class JobSolver
if (placed.Model.Priority < bestPriority) if (placed.Model.Priority < bestPriority)
bestPriority = placed.Model.Priority; bestPriority = placed.Model.Priority;
var priorityHits = packer.Placed.Count(p => p.Model.Priority == bestPriority); var priorityHits = packer.Placed.Count(p => p.Model.Priority == bestPriority);
var area = packer.Stock.Size.Width * packer.Stock.Size.Length; var area = NestJobCost.NetSheetArea(_job, new NestJobPlateResult(0, packer.Stock,
packer.Placed.Select(p => new NestJobPlacement(p.Model.Id, 0, p.X, p.Y, p.Orientation.Angle))));
var placedArea = 0.0; var placedArea = 0.0;
foreach (var placed in packer.Placed) foreach (var placed in packer.Placed)
placedArea += placed.Model.Area; placedArea += placed.Model.Area;
@@ -366,74 +338,18 @@ internal sealed class JobSolver
private void CommitSheet(SheetPacker packer) private void CommitSheet(SheetPacker packer)
{ {
_sheets.Add(new CommittedSheet(packer.StockIndex, packer.Placed)); _builder.AddSheet(packer.Stock, packer.Placed.Select(p =>
_used[packer.Stock.Id]++; (p.Model.Id, p.X, p.Y, p.Orientation.Angle)));
foreach (var placed in packer.Placed) _sheetCount++;
{
_placed[placed.Model.Id]++;
_remaining[placed.Model.Id]--;
}
} }
private bool AnyStockAvailable() private bool AnyStockAvailable()
{ {
foreach (var stock in _job.Plates) foreach (var stock in _job.Plates)
if (stock.Quantity is null || _used[stock.Id] < stock.Quantity.Value) if (stock.Quantity is null || _builder.SheetsUsed(stock) < stock.Quantity.Value)
return true; return true;
return false; return false;
} }
private NestJobResult BuildResult(NestJobStopReason reason)
{
var instanceIndex = new Dictionary<string, int>(StringComparer.Ordinal);
var plates = new List<NestJobPlateResult>();
foreach (var sheet in _sheets)
{
var placements = new List<NestJobPlacement>(sheet.Placements.Count);
foreach (var placed in sheet.Placements)
{
instanceIndex.TryGetValue(placed.Model.Id, out var next);
instanceIndex[placed.Model.Id] = next + 1;
placements.Add(
new NestJobPlacement(
placed.Model.Id,
next,
placed.X,
placed.Y,
placed.Orientation.Angle
)
);
}
plates.Add(
new NestJobPlateResult(plates.Count, _job.Plates[sheet.StockIndex], placements)
);
}
var fulfillment = _job.Parts
.Select(part => new PartFulfillment(
part.Id,
part.Quantity,
_placed[part.Id],
_remaining[part.Id]
))
.ToList();
var stockUsage = _job.Plates
.Select(stock => new StockUsage(
stock.Id,
_used[stock.Id],
stock.Quantity is int quantity ? quantity - _used[stock.Id] : null
))
.ToList();
return new NestJobResult(
reason == NestJobStopReason.Completed
? NestJobStatus.Complete
: NestJobStatus.Incomplete,
reason,
plates,
fulfillment,
stockUsage
);
}
} }
@@ -1,8 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Converters;
using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
@@ -53,10 +51,11 @@ internal sealed class PartModel
/// <summary>Material area (perimeter minus holes), from the analytic shapes.</summary> /// <summary>Material area (perimeter minus holes), from the analytic shapes.</summary>
public double Area { get; } public double Area { get; }
internal List<double>? Angles { get; set; }
/// <summary> /// <summary>
/// Chord tolerance for the engine's internal collision polygons. Must stay FINER /// Chord tolerance for the engine's internal collision polygons. Must stay FINER
/// than the validator's OutlineTolerance (0.001): any chord cuts the cap off a /// than NestTolerances.ValidationOutline (0.001): any chord cuts the cap off a
/// concave arc, and a coarser polygon cuts MORE - so a coarse flattening is a /// concave arc, and a coarser polygon cuts MORE - so a coarse flattening is a
/// subset of the validator's material in notched regions and admits real spacing /// subset of the validator's material in notched regions and admits real spacing
/// violations (observed on arc-heavy PEP parts at 0.02). Finer than the validator, /// violations (observed on arc-heavy PEP parts at 0.02). Finer than the validator,
@@ -70,47 +69,11 @@ internal sealed class PartModel
/// </summary> /// </summary>
public static PartModel? TryCreate(NestJobPart part) public static PartModel? TryCreate(NestJobPart part)
{ {
try var geometry = JobPartGeometry.TryRead(part.Geometry);
{ if (geometry == null || geometry.MaterialArea <= Tolerance.Epsilon) return null;
var entities = new List<Entity>(); // Read normalizes winding before the collision and offset preparation below.
foreach ( return new PartModel(part.Id, part.Quantity, part.Priority, part.Rotation,
var entity in ConvertProgram.ToGeometry( geometry.Profile, geometry.Perimeter, geometry.Cutouts.ToList(), geometry.MaterialArea);
DrawingJobMapper.ToProgram(part.Geometry)
)
)
if (SpecialLayers.IsMaterial(entity.Layer))
entities.Add(entity);
if (entities.Count == 0)
return null;
var profile = new ShapeProfile(entities);
if (profile.Perimeter == null)
return null;
profile.NormalizeWinding();
var area = Math.Abs(profile.Perimeter.Area());
foreach (var cutout in profile.Cutouts)
area -= Math.Abs(cutout.Area());
if (!double.IsFinite(area) || area <= Tolerance.Epsilon)
return null;
return new PartModel(
part.Id,
part.Quantity,
part.Priority,
part.Rotation,
profile,
profile.Perimeter,
new List<Shape>(profile.Cutouts),
area
);
}
catch (Exception)
{
// Malformed snapshots are unplaceable, not fatal: report them unplaced so
// the rest of the job still nests.
return null;
}
} }
} }
@@ -441,84 +404,15 @@ internal sealed class PartPreparation
/// </summary> /// </summary>
public static List<double> CandidateAngles(PartModel model) public static List<double> CandidateAngles(PartModel model)
{ {
var angles = new List<double>(); if (model.Angles != null) return model.Angles;
var policy = model.Rotation; var angles = model.Rotation.Kind == RotationPolicyKind.Automatic
if (policy.Kind == RotationPolicyKind.Automatic) ? RotationCandidates.ForShape(model.Rotation, model.PerimeterShape)
{ : model.Rotation.EnumerateAngles(maxSamples: 4000);
angles.Add(0); // Perimeter symmetry does not establish symmetry of the cutouts.
angles.Add(Math.PI / 2); return model.Angles = (model.CutoutShapes.Count == 0
angles.Add(Math.PI); ? RotationCandidates.DistinctOutlines(model.PerimeterShape, angles)
angles.Add(3 * Math.PI / 2); : angles).ToList();
try
{
var hull = ConvexHull.Compute(
model
.PerimeterShape
.ToPolygonWithTolerance(PartModel.CollisionTolerance, circumscribe: true)
.Vertices
);
var obb = RotatingCalipers.MinimumBoundingRectangle(hull);
var normalized = OpenNest.Math.Angle.NormalizeRad(obb.Angle);
if (normalized > 0.001 && normalized < Math.PI - 0.001)
{
angles.Add(normalized);
angles.Add(OpenNest.Math.Angle.NormalizeRad(normalized + Math.PI));
}
}
catch (Exception)
{
// A calipers failure only costs candidate angles, never correctness.
}
}
else if (policy.Kind == RotationPolicyKind.Fixed)
{
angles.Add(policy.Start);
if (policy.Allow180Equivalent)
angles.Add(policy.Start + Math.PI);
}
else
{
// BoundedSweep: enumerate the exact step grid the policy allows.
var count = (int)Math.Floor((policy.End - policy.Start) / policy.Step + 1e-9);
if (count < 0)
count = 0;
if (count > 4000)
count = 4000;
for (var i = 0; i <= count; i++)
{
angles.Add(policy.Start + i * policy.Step);
if (policy.Allow180Equivalent)
angles.Add(policy.Start + i * policy.Step + Math.PI);
}
} }
// Normalize to [0, 2pi), deduplicate, preserve first-seen order (deterministic).
var unique = new List<double>();
foreach (var angle in angles)
{
var normalized = OpenNest.Math.Angle.NormalizeRad(angle);
if (normalized < 0)
normalized += 2 * Math.PI;
var duplicate = false;
foreach (var existing in unique)
if (Math.Abs(SignedDelta(existing, normalized)) < 1e-9)
{
duplicate = true;
break;
}
if (!duplicate)
unique.Add(normalized);
}
return unique;
}
private static double SignedDelta(double a, double b)
{
var delta = (a - b) % (2 * Math.PI);
if (delta > Math.PI)
delta -= 2 * Math.PI;
if (delta < -Math.PI)
delta += 2 * Math.PI;
return delta;
}
} }
@@ -115,12 +115,11 @@ internal sealed class SheetPacker
StockIndex = stockIndex; StockIndex = stockIndex;
Preparation = prep; Preparation = prep;
Spacing = stock.PartSpacing; Spacing = stock.PartSpacing;
var left = stock.Quadrant is 1 or 4 ? 0.0 : -stock.Size.Length; var work = stock.WorkArea;
var bottom = stock.Quadrant is 1 or 2 ? 0.0 : -stock.Size.Width; _workLeft = work.Left;
_workLeft = left + stock.EdgeSpacing.Left; _workBottom = work.Bottom;
_workBottom = bottom + stock.EdgeSpacing.Bottom; _workRight = work.Right;
_workRight = left + stock.Size.Length - stock.EdgeSpacing.Right; _workTop = work.Top;
_workTop = bottom + stock.Size.Width - stock.EdgeSpacing.Top;
WorkWidth = _workRight - _workLeft; WorkWidth = _workRight - _workLeft;
WorkHeight = _workTop - _workBottom; WorkHeight = _workTop - _workBottom;
@@ -159,7 +158,7 @@ internal sealed class SheetPacker
foreach (var angle in PartPreparation.CandidateAngles(model)) foreach (var angle in PartPreparation.CandidateAngles(model))
{ {
var orientation = Preparation.Oriented(model, angle, 0); var orientation = Preparation.Oriented(model, angle, 0);
if (orientation.Width <= WorkWidth + 1e-9 && orientation.Height <= WorkHeight + 1e-9) if (Stock.Fits(orientation.Width, orientation.Height))
return true; return true;
} }
return false; return false;
@@ -647,8 +646,7 @@ internal sealed class SheetPacker
} }
} }
private static readonly bool VerifyFastClear = internal static bool VerifyFastClear { get; set; }
Environment.GetEnvironmentVariable("QWEN_VERIFY_FASTCLEAR") == "1";
internal long DiagFastClearMismatch; internal long DiagFastClearMismatch;
internal long DiagTriMismatch; internal long DiagTriMismatch;
+25 -6
View File
@@ -7,8 +7,7 @@ or select over any built-in engine, nester, filler, or runner.
## Algorithm ## Algorithm
Bottom-left greedy insertion over convex No-Fit-Polygons, with an exact material-clearance Bottom-left greedy insertion over convex No-Fit-Polygons, with an exact material-clearance
gate, driven sheet by sheet by a greedy demand scheduler. All geometry math is the engine's gate, driven sheet by sheet by a greedy demand scheduler. Placement and collision preparation remain in `Engine/`; it calls no built-in nester, filler, or runner.
own (`Engine/`); it calls no built-in nester, filler, or runner.
- **`PartPreparation`** rebuilds each snapshot into a closed contour topology (perimeter + - **`PartPreparation`** rebuilds each snapshot into a closed contour topology (perimeter +
cutouts; rapids/scribe marks dropped), flattens it circumscribed (the collision polygon cutouts; rapids/scribe marks dropped), flattens it circumscribed (the collision polygon
@@ -46,9 +45,9 @@ own (`Engine/`); it calls no built-in nester, filler, or runner.
- **`JobSolver`** walks demands in its own order (priority, then largest material area - - **`JobSolver`** walks demands in its own order (priority, then largest material area -
big parts first lay down the sheet skeleton the small parts fill against; measured 12% big parts first lay down the sheet skeleton the small parts fill against; measured 12%
lower job cost than smallest-extent-first on the production job below) and drains each greedily, lower job cost than smallest-extent-first on the production job below) and drains each greedily,
then a time-boxed gap-fill pass. For the next sheet it trials *every* available stock then a gap-fill pass capped at eight failed insertion sweeps. For the next sheet it trials *every* available stock
size independently and commits the trial delivering the cheapest plate area per unit of size independently and commits the trial delivering the cheapest `NestJobCost.NetSheetArea` per unit of
part area placed (the benchmark's cost function), breaking ties by priority coverage, material area placed (the benchmark's cost function), breaking ties by priority coverage,
instance count, then plate area; lost trials change no job state. The job stops on met instance count, then plate area; lost trials change no job state. The job stops on met
demand, exhausted stock, no further placement, or the plate cap. Deterministic: demand, exhausted stock, no further placement, or the plate cap. Deterministic:
identical input, identical layout. identical input, identical layout.
@@ -82,7 +81,7 @@ to ~110 s via the cached-triangulation exact gate and the fast shell prefilter.
## Tests ## Tests
`tests/` holds acceptance tests whose layouts are checked by the benchmark's own `tests/` holds acceptance tests whose layouts are checked by the benchmark's own
`NestValidator` (bounds, spacing, quantities, stock, rotation), plus NFP geometry tests and a `NestLayoutCheck` through the shared `Engine.Testing` kit (bounds, spacing, quantities, stock, rotation and accounting), plus NFP geometry tests and a
rotated-concave spacing regression test. rotated-concave spacing regression test.
```bash ```bash
@@ -107,3 +106,23 @@ dotnet <OpenNest>/OpenNest.Benchmark/bin/Release/net8.0/OpenNest.Benchmark.dll <
`<OpenNest>` is the OpenNest checkout root. Or build and deploy in one step with `<OpenNest>` is the OpenNest checkout root. Or build and deploy in one step with
`./Build-Engines.ps1 -Engines Qwen38FlashNext`. The engine appears in reports as `./Build-Engines.ps1 -Engines Qwen38FlashNext`. The engine appears in reports as
`Qwen38FlashNextNestingEngine`. `Qwen38FlashNextNestingEngine`.
## Shared services and determinism
`JobPartGeometry.TryRead` provides normalized material topology. Stock bounds/fit and
result accounting/progress use the shared stock API and `NestJobResultBuilder`.
`ForShape` supplies Automatic rotations; `EnumerateAngles(maxSamples: 4000)` preserves
the sweep effort cap. Solid parts use cached `DistinctOutlines`; holed parts retain every
legal candidate because perimeter symmetry alone cannot establish cutout symmetry.
CollisionTolerance remains 0.0005, below `NestTolerances.ValidationOutline`.
Gap fill now counts failed insertion sweeps (default eight), with successful insertions
bounded by demand. Internal test settings replace every QWEN environment switch. No
stopwatch affects placement or diagnostics. Only the host cancellation token limits wall
time. The shared determinism contract compares repeated and fresh solves.
On the five synthetic salvage jobs, every layout remained valid and complete; total cost
fell from 7660.01 to 7572.05, with no job worse. Aggregate measured solve time remained
below one second. These small fixtures do not calibrate production-scale retry costs;
the eight-sweep default bounds effort independently of hardware. See
[PR 5 results](../MIGRATION-PR5.md); older production numbers above describe the old version.
@@ -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.Qwen38FlashNext.csproj" /> <ProjectReference Include="../OpenNest.Engine.Qwen38FlashNext.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>
@@ -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;
@@ -30,7 +32,7 @@ public class Qwen38FlashNextNestingEngineTests
var result = new Qwen38FlashNextNestingEngine().Solve(job); var result = new Qwen38FlashNextNestingEngine().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 Qwen38FlashNextNestingEngineTests
var result = new Qwen38FlashNextNestingEngine().Solve(job); var result = new Qwen38FlashNextNestingEngine().Solve(job);
AssertValid(job, result); LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
} }
@@ -76,7 +78,7 @@ public class Qwen38FlashNextNestingEngineTests
var result = new Qwen38FlashNextNestingEngine().Solve(job); var result = new Qwen38FlashNextNestingEngine().Solve(job);
AssertValid(job, result); LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
} }
@@ -87,7 +89,7 @@ public class Qwen38FlashNextNestingEngineTests
var result = new Qwen38FlashNextNestingEngine().Solve(job); var result = new Qwen38FlashNextNestingEngine().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);
} }
@@ -102,7 +104,7 @@ public class Qwen38FlashNextNestingEngineTests
var result = new Qwen38FlashNextNestingEngine().Solve(job); var result = new Qwen38FlashNextNestingEngine().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);
} }
@@ -120,7 +122,7 @@ public class Qwen38FlashNextNestingEngineTests
var result = new Qwen38FlashNextNestingEngine().Solve(job); var result = new Qwen38FlashNextNestingEngine().Solve(job);
AssertValid(job, result); LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count); Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
} }
@@ -138,65 +140,6 @@ public class Qwen38FlashNextNestingEngineTests
Assert.Equal(Enumerable.Range(0, result.Plates.Count), result.Plates.Select(p => p.PlateIndex)); Assert.Equal(Enumerable.Range(0, result.Plates.Count), result.Plates.Select(p => p.PlateIndex));
} }
// ---- 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) => public sealed class Qwen38FlashNextContractTests : EngineContractTests<Qwen38FlashNextNestingEngine> { }
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;
}
}