feat(qwen38flashnext): add the finished engine
Qwen3.8-Flash-Next's final version after a 14.5-hour optimization run (its commit 7d7fca3): cost-first sheet trials, largest-area-first demand order, and a cached-triangulation exact gate that brought a 219-part production job from timeout to ~106 s. 13/13 tests pass against OpenNest master. README cleaned for publishing: the model-facing template rules are replaced by a one-line independence statement, the production job is described generically instead of by its PEP job/file name (also in a JobSolver comment), results show both sheet pools as re-measured here (the 9-size claim in its report didn't reproduce: it grabs 96x240 and under-fills them), and the stale StockLadder-crash note is gone now that core leaves etch marks out of nesting. Also drops a stale Aurora plugin reference from Opus55's README and lists the engine in the repo README. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,439 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Engine.Jobs;
|
||||
|
||||
namespace OpenNest.Engine.Qwen38FlashNext.Engine;
|
||||
|
||||
using Math = System.Math;
|
||||
|
||||
internal sealed record SheetAttempt(SheetPacker Packer, int StockIndex);
|
||||
|
||||
/// <summary>
|
||||
/// Whole-job decision layer: which stock the next sheet uses, the order parts are
|
||||
/// demanded in, when a sheet is finished, and when the job stops. Every placement
|
||||
/// inside a sheet comes from <see cref="SheetPacker"/>; nothing here delegates to a
|
||||
/// built-in engine, nester, filler, or runner.
|
||||
/// </summary>
|
||||
internal sealed class JobSolver
|
||||
{
|
||||
private readonly NestJob _job;
|
||||
private readonly PartPreparation _prep;
|
||||
private readonly Dictionary<string, int> _remaining;
|
||||
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)
|
||||
{
|
||||
_job = job;
|
||||
_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 =
|
||||
Environment.GetEnvironmentVariable("QWEN_NEST_DIAG") == "1";
|
||||
|
||||
private void Diag(string message)
|
||||
{
|
||||
if (_diag)
|
||||
Console.Error.WriteLine(
|
||||
$"[qwen] sheets={_sheets.Count} placed={_placed.Values.Sum()} " +
|
||||
$"mem={GC.GetTotalMemory(false) / 1048576}MB gc0={GC.CollectionCount(0)} " +
|
||||
$"gc2={GC.CollectionCount(2)} {message}"
|
||||
);
|
||||
}
|
||||
|
||||
public NestJobResult Solve(IProgress<NestJobProgress>? progress, CancellationToken token)
|
||||
{
|
||||
var reason = NestJobStopReason.Completed;
|
||||
while (true)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var outstanding = OutstandingDemands();
|
||||
if (outstanding.Count == 0)
|
||||
break;
|
||||
if (_job.Options.MaxPlates is int cap && _sheets.Count >= cap)
|
||||
{
|
||||
reason = NestJobStopReason.PlateLimitReached;
|
||||
break;
|
||||
}
|
||||
|
||||
var attempt = BestNextSheet(outstanding, progress, token);
|
||||
Diag($"nextSheet -> {(attempt == null ? "none" : $"stock {_job.Plates[attempt.StockIndex].Id} placed {attempt.Packer.Placed.Count}")}");
|
||||
if (attempt == null)
|
||||
{
|
||||
reason = AnyStockAvailable()
|
||||
? NestJobStopReason.NoPlacementFound
|
||||
: NestJobStopReason.StockExhausted;
|
||||
break;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private List<PartModel> OutstandingDemands()
|
||||
{
|
||||
var demands = new List<PartModel>();
|
||||
foreach (var model in _prep.Models)
|
||||
if (_remaining[model.Id] > 0)
|
||||
demands.Add(model);
|
||||
// This engine's own ordering: priority first, then the tallest-then-largest
|
||||
// part first (a part's thinnest orientation extent), then id for determinism.
|
||||
demands.Sort(
|
||||
(a, b) =>
|
||||
{
|
||||
var byPriority = a.Priority.CompareTo(b.Priority);
|
||||
if (byPriority != 0)
|
||||
return byPriority;
|
||||
if (DemandOrderMode == 1)
|
||||
{
|
||||
var byArea = b.Area.CompareTo(a.Area);
|
||||
if (byArea != 0)
|
||||
return byArea;
|
||||
}
|
||||
else if (DemandOrderMode == 2)
|
||||
{
|
||||
// Biggest footprint first (worst-case largest extent, descending).
|
||||
var byMaxSpan = MaximumMaxSpan(b).CompareTo(MaximumMaxSpan(a));
|
||||
if (byMaxSpan != 0)
|
||||
return -byMaxSpan;
|
||||
var byArea2 = b.Area.CompareTo(a.Area);
|
||||
if (byArea2 != 0)
|
||||
return byArea2;
|
||||
}
|
||||
else
|
||||
{
|
||||
var bySpan = MinimumMaxSpan(b).CompareTo(MinimumMaxSpan(a));
|
||||
if (bySpan != 0)
|
||||
return bySpan;
|
||||
var byArea = b.Area.CompareTo(a.Area);
|
||||
if (byArea != 0)
|
||||
return byArea;
|
||||
}
|
||||
return string.CompareOrdinal(a.Id, b.Id);
|
||||
}
|
||||
);
|
||||
return demands;
|
||||
}
|
||||
|
||||
private double MinimumMaxSpan(PartModel model)
|
||||
{
|
||||
if (!_minimumSpan.TryGetValue(model.Id, out var span))
|
||||
{
|
||||
span = double.MaxValue;
|
||||
foreach (var angle in PartPreparation.CandidateAngles(model))
|
||||
{
|
||||
var orientation = _prep.Oriented(model, angle, 0);
|
||||
var worst = Math.Max(orientation.Width, orientation.Height);
|
||||
if (worst < span)
|
||||
span = worst;
|
||||
}
|
||||
_minimumSpan[model.Id] = span;
|
||||
}
|
||||
return span;
|
||||
}
|
||||
|
||||
private readonly Dictionary<string, double> _minimumSpan = new(StringComparer.Ordinal);
|
||||
|
||||
private double MaximumMaxSpan(PartModel model)
|
||||
{
|
||||
if (!_maximumSpan.TryGetValue(model.Id, out var span))
|
||||
{
|
||||
span = 0;
|
||||
foreach (var angle in PartPreparation.CandidateAngles(model))
|
||||
{
|
||||
var orientation = _prep.Oriented(model, angle, 0);
|
||||
var worst = Math.Max(orientation.Width, orientation.Height);
|
||||
if (worst > span)
|
||||
span = worst;
|
||||
}
|
||||
_maximumSpan[model.Id] = span;
|
||||
}
|
||||
return span;
|
||||
}
|
||||
|
||||
private readonly Dictionary<string, double> _maximumSpan = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Packs every available stock size independently and commits the best trial:
|
||||
/// most instances first, then highest priority coverage, then the smallest sheet
|
||||
/// area (the cost function the benchmark scores), then input order.
|
||||
/// </summary>
|
||||
private SheetAttempt? BestNextSheet(
|
||||
List<PartModel> outstanding,
|
||||
IProgress<NestJobProgress>? progress,
|
||||
CancellationToken token
|
||||
)
|
||||
{
|
||||
SheetAttempt? best = null;
|
||||
TrialScore bestScore = default;
|
||||
|
||||
for (var index = 0; index < _job.Plates.Count; index++)
|
||||
{
|
||||
var stock = _job.Plates[index];
|
||||
if (stock.Quantity is int quantity && _used[stock.Id] >= quantity)
|
||||
continue;
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
progress?.Report(
|
||||
new NestJobProgress(
|
||||
NestJobStage.EvaluatingCandidate,
|
||||
stock.Id,
|
||||
_sheets.Count,
|
||||
_sheets.Count,
|
||||
_placed.Values.Sum()
|
||||
)
|
||||
);
|
||||
|
||||
var packer = SheetPacker.Create(stock, _prep, index);
|
||||
var fillWatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
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)
|
||||
continue;
|
||||
|
||||
var score = ScoreTrial(packer);
|
||||
bool Better(TrialScore s)
|
||||
{
|
||||
if (CostFirstScoring)
|
||||
{
|
||||
// Benchmark cost is total plate AREA, so prefer the trial that
|
||||
// delivers the cheapest material per unit of part area placed;
|
||||
// priority coverage still outranks, and count breaks cost ties.
|
||||
if (best == null)
|
||||
return true;
|
||||
if (s.priorityHits != bestScore.priorityHits)
|
||||
return s.priorityHits > bestScore.priorityHits;
|
||||
if (Math.Abs(s.costPerArea - bestScore.costPerArea) > 1e-9)
|
||||
return s.costPerArea < bestScore.costPerArea;
|
||||
if (s.count != bestScore.count)
|
||||
return s.count > bestScore.count;
|
||||
return s.area < bestScore.area;
|
||||
}
|
||||
return best == null
|
||||
|| s.count > bestScore.count
|
||||
|| (s.count == bestScore.count && s.priorityHits > bestScore.priorityHits)
|
||||
|| (
|
||||
s.count == bestScore.count
|
||||
&& s.priorityHits == bestScore.priorityHits
|
||||
&& s.area < bestScore.area
|
||||
);
|
||||
}
|
||||
if (Better(score))
|
||||
{
|
||||
best = new SheetAttempt(packer, index);
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This engine's fill policy for one sheet: walk the demand order and drain each
|
||||
/// requirement greedily; a requirement that cannot place any more instances is
|
||||
/// skipped (never aborts the sheet) and retried on the next sheet. Consumes a
|
||||
/// local copy of demand - losing this trial must not change job state.
|
||||
/// </summary>
|
||||
private void FillSheet(SheetPacker packer, List<PartModel> outstanding, CancellationToken token)
|
||||
{
|
||||
var available = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach (var model in outstanding)
|
||||
available[model.Id] = _remaining[model.Id];
|
||||
|
||||
// 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
|
||||
// expensive as it fills, so an unbounded retry loop blows the benchmark's
|
||||
// 5-minute wall (observed 2-3x on a 69-drawing job). Pass two runs only with
|
||||
// the explicit retry budget below.
|
||||
foreach (var model in outstanding)
|
||||
{
|
||||
if (available[model.Id] <= 0)
|
||||
continue;
|
||||
if (!packer.CanEverFit(model))
|
||||
continue;
|
||||
var modelWatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
while (available[model.Id] > 0 && !packer.IsFull)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!packer.TryInsert(model, out _))
|
||||
break;
|
||||
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
|
||||
// fit the gaps a later model leaves behind. Failed-insert scans cost about a
|
||||
// full candidate sweep each, so the pass is hard time-boxed - on a crowded
|
||||
// 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;
|
||||
while (retryAgain && retryWatch.ElapsedMilliseconds < GapFillMilliseconds)
|
||||
{
|
||||
retryAgain = false;
|
||||
foreach (var model in outstanding)
|
||||
{
|
||||
if (available[model.Id] <= 0 || packer.IsFull)
|
||||
continue;
|
||||
if (retryWatch.ElapsedMilliseconds >= GapFillMilliseconds)
|
||||
break;
|
||||
while (available[model.Id] > 0)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!packer.TryInsert(model, out _))
|
||||
break;
|
||||
available[model.Id]--;
|
||||
retryAgain = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Demand ordering within the priority sort: 1 = largest material area first
|
||||
/// (measured best: big parts establish the sheet skeleton, small ones then fill
|
||||
/// 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).
|
||||
/// </summary>
|
||||
private static readonly int DemandOrderMode =
|
||||
int.TryParse(Environment.GetEnvironmentVariable("QWEN_DEMAND_ORDER"), out var m)
|
||||
? m
|
||||
: 1;
|
||||
|
||||
/// <summary>Wall-clock budget for one sheet's gap-fill pass.</summary>
|
||||
private static readonly int GapFillMilliseconds =
|
||||
int.TryParse(Environment.GetEnvironmentVariable("QWEN_GAPFILL_MS"), out var ms)
|
||||
? ms
|
||||
: 120;
|
||||
|
||||
/// <summary>Trial-sheet metrics; costPerArea = plate area / part area placed.</summary>
|
||||
private readonly record struct TrialScore(
|
||||
int count,
|
||||
int priorityHits,
|
||||
double area,
|
||||
double costPerArea
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Greedy trial-comparison mode. Cost-first optimizes the benchmark's cost
|
||||
/// function (total plate area); count-first is the conservative fill policy.
|
||||
/// Env override exists for A/B measurement.
|
||||
/// </summary>
|
||||
private static readonly bool CostFirstScoring =
|
||||
Environment.GetEnvironmentVariable("QWEN_COST_FIRST") != "0";
|
||||
|
||||
private TrialScore ScoreTrial(SheetPacker packer)
|
||||
{
|
||||
var count = packer.Placed.Count;
|
||||
var bestPriority = int.MaxValue;
|
||||
foreach (var placed in packer.Placed)
|
||||
if (placed.Model.Priority < bestPriority)
|
||||
bestPriority = placed.Model.Priority;
|
||||
var priorityHits = packer.Placed.Count(p => p.Model.Priority == bestPriority);
|
||||
var area = packer.Stock.Size.Width * packer.Stock.Size.Length;
|
||||
var placedArea = 0.0;
|
||||
foreach (var placed in packer.Placed)
|
||||
placedArea += placed.Model.Area;
|
||||
var costPerArea = placedArea > 1e-9 ? area / placedArea : double.MaxValue;
|
||||
return new TrialScore(count, priorityHits, area, costPerArea);
|
||||
}
|
||||
|
||||
private void CommitSheet(SheetPacker packer)
|
||||
{
|
||||
_sheets.Add(new CommittedSheet(packer.StockIndex, packer.Placed));
|
||||
_used[packer.Stock.Id]++;
|
||||
foreach (var placed in packer.Placed)
|
||||
{
|
||||
_placed[placed.Model.Id]++;
|
||||
_remaining[placed.Model.Id]--;
|
||||
}
|
||||
}
|
||||
|
||||
private bool AnyStockAvailable()
|
||||
{
|
||||
foreach (var stock in _job.Plates)
|
||||
if (stock.Quantity is null || _used[stock.Id] < stock.Quantity.Value)
|
||||
return true;
|
||||
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(sheet.StockIndex, _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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user