mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-12 17:34:13 -04:00
feat(cutting): cap feasible entries with side fallback coverage
ContourEntrySelection: lazy bounded selection over one contour's ranked candidates. Greedy global rank order until the cap (default 16) fills or the finite catalogue ends; blocked and incomplete candidates never consume a slot, and once selection settles the untouched tail is never evaluated (EvaluatedCount/EvaluatedKeys pin the counts, not timings). At caps of 4+ a corrective scan represents every side of the candidate bounding rectangle that HAS a feasible candidate — lazily chasing each missing side and replacing the worst selected candidate only when every other covered side survives; one corner may cover two sides. The cap is never exceeded and points are never manufactured on infeasible sides; caps 1-3 obey cap+ranking first, as promised. Verdicts memoize per attempt; duplicate geometric points never take two slots. Honest metadata: Choices/EvaluatedCount/Shortfall/Reason with None, Exhausted and Incomplete kept distinct — 'no tested lead-in fits' is producible only from a fully evaluated Exhausted catalogue; incomplete checks and cancellation are never presented as geometric impossibility. Sides are exactly the ranker's bounding-rectangle geometry. No search changes and no larger search budget.
This commit is contained in:
1 parent
2399cc3936
commit
acd1f21293
2 files changed
+518
No files matched your search
@@ -0,0 +1,250 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.CuttingPlanning;
|
||||
|
||||
/// <summary>Why a selection ended the way it did. Honest metadata, never a geometric overclaim.</summary>
|
||||
internal enum ContourSelectionShortfall
|
||||
{
|
||||
/// <summary>Selection ended at the cap with the side scan satisfied: a complete verdict.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>
|
||||
/// The finite catalogue was fully evaluated and the selection is all there is. With an
|
||||
/// empty selection this is exactly the sentence "no tested lead-in fits on part N,
|
||||
/// contour M" — and ONLY here may that sentence be spoken.
|
||||
/// </summary>
|
||||
Exhausted,
|
||||
|
||||
/// <summary>
|
||||
/// At least one check could not complete (incomplete material or emission). Never
|
||||
/// present this as geometric impossibility and never as "nothing fits".
|
||||
/// </summary>
|
||||
Incomplete,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of one bounded selection: selected choices in global rank order, how many
|
||||
/// distinct candidates were evaluated, which ones (in catalogue order, for cost tests), and
|
||||
/// why the selection stopped.
|
||||
/// </summary>
|
||||
internal sealed record ContourSelectionResult(
|
||||
IReadOnlyList<ContourChoice> Choices,
|
||||
int EvaluatedCount,
|
||||
IReadOnlyList<(long, long)> EvaluatedKeys,
|
||||
ContourSelectionShortfall Shortfall,
|
||||
string? Reason);
|
||||
|
||||
/// <summary>
|
||||
/// Bounded, lazy selection of lead-feasible entry candidates for ONE contour. Greedy global
|
||||
/// rank order until the cap (default 16) fills or the finite catalogue ends — rejected and
|
||||
/// incomplete candidates never consume a slot. When the cap can afford it (5+), one
|
||||
/// corrective scan makes sure every side of the candidate bounding rectangle that HAS a
|
||||
/// feasible candidate is represented, replacing the worst selected candidate only when
|
||||
/// every other covered side survives; one corner may cover two sides. The cap is never
|
||||
/// exceeded and points are never manufactured on infeasible sides — a capped selection may
|
||||
/// truthfully omit a feasible side. Sides are exactly the ranker's side geometry (the
|
||||
/// candidate-set bounding rectangle). Verdicts are memoized per attempt (the adapter
|
||||
/// memoizes too): no candidate is ever evaluated twice, and once selection settles the
|
||||
/// untouched tail is never evaluated. No search/DFS changes and no larger search budget.
|
||||
/// </summary>
|
||||
internal static class ContourEntrySelection
|
||||
{
|
||||
internal const int DefaultMaxEntries = 16;
|
||||
|
||||
/// <summary>Below this cap all-side coverage is not promised; the cap and ranking bind first (caps 1-3).</summary>
|
||||
internal const int SideCoverageMinCap = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Selects up to <paramref name="maxEntries"/> feasible choices from one contour's
|
||||
/// globally ranked catalogue. <paramref name="evaluate"/> is the S07 adapter verdict,
|
||||
/// called lazily at most once per distinct candidate.
|
||||
/// </summary>
|
||||
internal static ContourSelectionResult Select(
|
||||
IReadOnlyList<ContourEntryCandidate> rankedCandidates,
|
||||
Func<ContourEntryCandidate, ContourFeasibilityVerdict> evaluate,
|
||||
int maxEntries = DefaultMaxEntries,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (rankedCandidates == null)
|
||||
throw new ArgumentException("Ranked candidates are required.", nameof(rankedCandidates));
|
||||
if (evaluate == null)
|
||||
throw new ArgumentException("A feasibility evaluation is required.", nameof(evaluate));
|
||||
if (maxEntries <= 0)
|
||||
throw new ArgumentException("The cap must be positive.", nameof(maxEntries));
|
||||
|
||||
// Distinct points only — the cap must not double-count a geometric duplicate.
|
||||
var candidates = new List<ContourEntryCandidate>();
|
||||
var seen = new HashSet<(long, long)>();
|
||||
foreach (var candidate in rankedCandidates)
|
||||
if (seen.Add(candidate.GeometryKey))
|
||||
candidates.Add(candidate);
|
||||
|
||||
var evaluated = new List<ContourEntryCandidate>();
|
||||
var verdicts = new Dictionary<(long, long), ContourFeasibilityVerdict>();
|
||||
var box = Box(candidates);
|
||||
|
||||
var selected = new List<ContourEntryCandidate>();
|
||||
var sawIncomplete = false;
|
||||
var index = 0;
|
||||
|
||||
ContourFeasibilityVerdict Verdict(ContourEntryCandidate candidate)
|
||||
{
|
||||
if (!verdicts.TryGetValue(candidate.GeometryKey, out var known))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
known = evaluate(candidate);
|
||||
verdicts[candidate.GeometryKey] = known;
|
||||
evaluated.Add(candidate);
|
||||
}
|
||||
return known;
|
||||
}
|
||||
|
||||
// Phase 1: greedy global order until the cap fills or the catalogue ends.
|
||||
for (; index < candidates.Count && selected.Count < maxEntries; index++)
|
||||
{
|
||||
var verdict = Verdict(candidates[index]);
|
||||
if (verdict.Status == ContourFeasibilityStatus.Incomplete)
|
||||
{
|
||||
sawIncomplete = true;
|
||||
break;
|
||||
}
|
||||
if (verdict.IsClear)
|
||||
selected.Add(candidates[index]);
|
||||
}
|
||||
|
||||
// Phase 2: side coverage when the cap affords it. Every missing side with an
|
||||
// unexamined tail is chased lazily; a clear candidate on that side is appended when
|
||||
// a slot remains, otherwise it replaces the worst selected candidate whose removal
|
||||
// keeps every other covered side covered.
|
||||
var coverage = maxEntries >= SideCoverageMinCap && selected.Count > 0;
|
||||
if (coverage && !sawIncomplete)
|
||||
for (var side = 0; side < 4 && !sawIncomplete; side++)
|
||||
{
|
||||
if (selected.Any(c => Sides(c, box).Contains(side)))
|
||||
continue;
|
||||
for (; index < candidates.Count; index++)
|
||||
{
|
||||
var verdict = Verdict(candidates[index]);
|
||||
if (verdict.Status == ContourFeasibilityStatus.Incomplete)
|
||||
{
|
||||
sawIncomplete = true;
|
||||
break;
|
||||
}
|
||||
if (!verdict.IsClear || !Sides(candidates[index], box).Contains(side))
|
||||
continue;
|
||||
TryPlace(selected, candidates[index], box, maxEntries);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var exhausted = index >= candidates.Count;
|
||||
ContourSelectionShortfall shortfall;
|
||||
string? reason;
|
||||
if (sawIncomplete)
|
||||
{
|
||||
shortfall = ContourSelectionShortfall.Incomplete;
|
||||
reason = "At least one lead check could not complete; this is not a geometric verdict and nothing is proven impossible.";
|
||||
}
|
||||
else if (selected.Count >= maxEntries)
|
||||
{
|
||||
shortfall = ContourSelectionShortfall.None;
|
||||
reason = null;
|
||||
}
|
||||
else if (exhausted)
|
||||
{
|
||||
shortfall = ContourSelectionShortfall.Exhausted;
|
||||
reason = selected.Count == 0
|
||||
? "No tested lead-in fits on this contour."
|
||||
: null;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Unreachable: phase 1 ends at cap or catalogue end and phase 2 chases every
|
||||
// side to the end; a cancellation throws before this point.
|
||||
shortfall = ContourSelectionShortfall.Incomplete;
|
||||
reason = "Selection stopped before the catalogue ended.";
|
||||
}
|
||||
|
||||
var ordered = selected
|
||||
.OrderBy(c => evaluated.FindIndex(x => x.GeometryKey == c.GeometryKey) is var e && e >= 0
|
||||
? e : candidates.FindIndex(x => x.GeometryKey == c.GeometryKey))
|
||||
.Select(c => c.Choice)
|
||||
.ToList();
|
||||
return new(ordered, evaluated.Count,
|
||||
evaluated.Select(c => c.GeometryKey).ToList(), shortfall, reason);
|
||||
}
|
||||
|
||||
private static (double MinX, double MinY, double MaxX, double MaxY) Box(
|
||||
List<ContourEntryCandidate> candidates)
|
||||
{
|
||||
double minX = double.PositiveInfinity, minY = double.PositiveInfinity,
|
||||
maxX = double.NegativeInfinity, maxY = double.NegativeInfinity;
|
||||
foreach (var c in candidates)
|
||||
{
|
||||
var p = c.Choice.Point;
|
||||
if (p.X < minX) minX = p.X;
|
||||
if (p.X > maxX) maxX = p.X;
|
||||
if (p.Y < minY) minY = p.Y;
|
||||
if (p.Y > maxY) maxY = p.Y;
|
||||
}
|
||||
if (!double.IsFinite(minX))
|
||||
return (0, 0, 0, 0);
|
||||
return (minX, minY, maxX, maxY);
|
||||
}
|
||||
|
||||
/// <summary>Sides 0 left, 1 right, 2 bottom, 3 top of the candidate bounding rectangle that the point sits on.</summary>
|
||||
private static List<int> Sides(ContourEntryCandidate candidate,
|
||||
(double MinX, double MinY, double MaxX, double MaxY) box)
|
||||
{
|
||||
var p = candidate.Choice.Point;
|
||||
var left = p.X - box.MinX;
|
||||
var right = box.MaxX - p.X;
|
||||
var bottom = p.Y - box.MinY;
|
||||
var top = box.MaxY - p.Y;
|
||||
var min = System.Math.Min(System.Math.Min(left, right), System.Math.Min(bottom, top));
|
||||
var sides = new List<int>(2);
|
||||
if (left <= min + PostVerificationGeometry.Epsilon) sides.Add(0);
|
||||
if (right <= min + PostVerificationGeometry.Epsilon) sides.Add(1);
|
||||
if (bottom <= min + PostVerificationGeometry.Epsilon) sides.Add(2);
|
||||
if (top <= min + PostVerificationGeometry.Epsilon) sides.Add(3);
|
||||
return sides;
|
||||
}
|
||||
|
||||
/// <summary>Appends when a slot remains, else replaces the worst (latest-ranked) candidate whose removal preserves every other covered side.</summary>
|
||||
private static void TryPlace(List<ContourEntryCandidate> selected, ContourEntryCandidate candidate,
|
||||
(double MinX, double MinY, double MaxX, double MaxY) box, int maxEntries)
|
||||
{
|
||||
if (selected.Count < maxEntries)
|
||||
{
|
||||
selected.Add(candidate);
|
||||
return;
|
||||
}
|
||||
var incoming = Sides(candidate, box);
|
||||
for (var i = selected.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var removalSafe = true;
|
||||
for (var side = 0; side < 4 && removalSafe; side++)
|
||||
{
|
||||
if (incoming.Contains(side))
|
||||
continue; // the replacement covers it
|
||||
var covers = selected.Where((c, at) => at != i && Sides(c, box).Contains(side)).Any();
|
||||
var wasCovered = selected.Any(c => Sides(c, box).Contains(side));
|
||||
if (wasCovered && !covers)
|
||||
removalSafe = false;
|
||||
}
|
||||
if (removalSafe)
|
||||
{
|
||||
selected[i] = candidate;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// No safe victim: the side stays truthfully unrepresented at this cap.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Engine.CuttingPlanning;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// The bounded, lazy selection over ranked candidates and the S07 verdict: cap first, side
|
||||
/// coverage as a corrective scan, honest shortfall metadata — and the untouched tail is
|
||||
/// never evaluated. Counts, not elapsed time.
|
||||
/// </summary>
|
||||
public class ContourEntrySelectionTests
|
||||
{
|
||||
private static readonly PreparedContours Owner =
|
||||
PreparedContours.Capture(ExplicitContourTests.Square(false), ExplicitContourTests.Parameters());
|
||||
|
||||
private static ContourEntryCandidate Cand(double x, double y, AutomaticEntryKind kind = AutomaticEntryKind.ConvexCorner) =>
|
||||
new(new ContourChoice(0, 0, new Vector(x, y)) { Owner = Owner }, kind);
|
||||
|
||||
private static Func<ContourEntryCandidate, ContourFeasibilityVerdict> Feasible(params (double X, double Y)[] clearAt)
|
||||
{
|
||||
var set = new HashSet<(long, long)>(clearAt.Select(p => Cand(p.X, p.Y).GeometryKey));
|
||||
return c => set.Contains(c.GeometryKey)
|
||||
? new(ContourFeasibilityStatus.Clear, null)
|
||||
: new(ContourFeasibilityStatus.Blocked, "test block");
|
||||
}
|
||||
|
||||
private static Func<ContourEntryCandidate, ContourFeasibilityVerdict> AllClear() =>
|
||||
_ => new(ContourFeasibilityStatus.Clear, null);
|
||||
|
||||
private static bool Selected(ContourSelectionResult result, double x, double y) =>
|
||||
result.Choices.Any(c => c.Point.DistanceTo(new Vector(x, y)) < 1e-9);
|
||||
|
||||
[Fact]
|
||||
public void LaterSafeCandidateSurvivesTwentyRejectedRivals()
|
||||
{
|
||||
// 18 blocked facing candidates cannot crowd out the one safe point at the tail.
|
||||
var candidates = new List<ContourEntryCandidate>();
|
||||
for (var i = 0; i < 18; i++)
|
||||
candidates.Add(Cand(i * 0.001, 0));
|
||||
candidates.Add(Cand(100, 100));
|
||||
|
||||
var result = ContourEntrySelection.Select(candidates, Feasible((100, 100)));
|
||||
|
||||
Assert.Single(result.Choices);
|
||||
Assert.True(Selected(result, 100, 100));
|
||||
Assert.Equal(19, result.EvaluatedCount);
|
||||
Assert.Equal(ContourSelectionShortfall.Exhausted, result.Shortfall);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryFeasibleSideIsRepresented()
|
||||
{
|
||||
// Corners on left/bottom and right/bottom, plus one point each on top-left and
|
||||
// top-right; a wide tail of blocked points so coverage must chase the sides lazily.
|
||||
var candidates = new List<ContourEntryCandidate>
|
||||
{
|
||||
Cand(0, 0), Cand(10, 0), // bottom corners
|
||||
Cand(0, 10, AutomaticEntryKind.StraightMidpoint), // top-left
|
||||
Cand(10, 10, AutomaticEntryKind.TangentJoint), // top-right
|
||||
};
|
||||
for (var i = 1; i < 30; i++)
|
||||
candidates.Add(Cand(i * 0.3, 0, AutomaticEntryKind.NearCorner)); // blocked bottom tail
|
||||
|
||||
var result = ContourEntrySelection.Select(candidates,
|
||||
Feasible((0, 0), (10, 0), (0, 10), (10, 10)), maxEntries: 6);
|
||||
|
||||
Assert.Equal(4, result.Choices.Count);
|
||||
Assert.True(Selected(result, 0, 10));
|
||||
Assert.True(Selected(result, 10, 10));
|
||||
// The blocked tail was chased to its end exactly once per missing side scan.
|
||||
Assert.Equal(ContourSelectionShortfall.Exhausted, result.Shortfall);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlockedSideIsOmittedWithoutManufacturingPoints()
|
||||
{
|
||||
// No feasible candidate touches the top side.
|
||||
var candidates = new List<ContourEntryCandidate>
|
||||
{
|
||||
Cand(0, 0), Cand(10, 0), Cand(0, 10), Cand(10, 10),
|
||||
};
|
||||
|
||||
var result = ContourEntrySelection.Select(candidates, Feasible((0, 0), (10, 0)));
|
||||
|
||||
Assert.Equal(2, result.Choices.Count);
|
||||
Assert.False(Selected(result, 0, 10));
|
||||
Assert.False(Selected(result, 10, 10));
|
||||
// Every selected choice was feasible: nothing was manufactured.
|
||||
Assert.All(result.Choices, c => Assert.InRange(c.Point.Y, 0, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CapIsNeverExceededAndRankingComesFirst()
|
||||
{
|
||||
var candidates = new List<ContourEntryCandidate>();
|
||||
for (var i = 0; i < 10; i++)
|
||||
candidates.Add(Cand(10 - i * 0.01, 0)); // first-ranked points on the right/bottom
|
||||
|
||||
var result = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 3);
|
||||
|
||||
Assert.Equal(3, result.Choices.Count);
|
||||
Assert.Equal(3, result.EvaluatedCount); // stopped the moment the cap filled
|
||||
Assert.Equal(ContourSelectionShortfall.None, result.Shortfall);
|
||||
Assert.Equal(10.0, result.Choices[0].Point.X, 6); // rank-first point kept
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalCaseNeverEvaluatesTheUntouchedTail()
|
||||
{
|
||||
// Four corners first (all sides covered), then a long clear tail. The cap fills at
|
||||
// 16 with full coverage, so the chase runs empty and the tail stays untouched.
|
||||
var candidates = new List<ContourEntryCandidate>
|
||||
{
|
||||
Cand(0, 0), Cand(10, 0), Cand(0, 10), Cand(10, 10),
|
||||
};
|
||||
for (var i = 0; i < 36; i++)
|
||||
candidates.Add(Cand(1 + i * 0.1, 1));
|
||||
|
||||
var result = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 16);
|
||||
|
||||
Assert.Equal(16, result.Choices.Count);
|
||||
Assert.Equal(16, result.EvaluatedCount);
|
||||
Assert.DoesNotContain(candidates[16].GeometryKey, result.EvaluatedKeys);
|
||||
Assert.DoesNotContain(candidates[^1].GeometryKey, result.EvaluatedKeys);
|
||||
Assert.Equal(ContourSelectionShortfall.None, result.Shortfall);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllBlockedIsExhaustedGeometricImpossibility()
|
||||
{
|
||||
var candidates = new[] { Cand(0, 0), Cand(10, 0), Cand(0, 10), Cand(10, 10) };
|
||||
|
||||
var result = ContourEntrySelection.Select(candidates,
|
||||
_ => new(ContourFeasibilityStatus.Blocked, "test block"));
|
||||
|
||||
Assert.Empty(result.Choices);
|
||||
Assert.Equal(4, result.EvaluatedCount);
|
||||
Assert.Equal(ContourSelectionShortfall.Exhausted, result.Shortfall);
|
||||
Assert.Contains("No tested lead-in fits", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncompleteCheckIsNeverGeometricImpossibility()
|
||||
{
|
||||
var candidates = new[] { Cand(0, 0), Cand(10, 0) };
|
||||
var first = true;
|
||||
|
||||
var result = ContourEntrySelection.Select(candidates, _ =>
|
||||
{
|
||||
ContourFeasibilityVerdict verdict = first
|
||||
? new(ContourFeasibilityStatus.Incomplete, "incomplete material")
|
||||
: new(ContourFeasibilityStatus.Clear, null);
|
||||
first = false;
|
||||
return verdict;
|
||||
});
|
||||
|
||||
Assert.Equal(ContourSelectionShortfall.Incomplete, result.Shortfall);
|
||||
Assert.DoesNotContain("No tested lead-in fits", result.Reason ?? string.Empty);
|
||||
Assert.Contains("not a geometric verdict", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SmallCapsObeyTheCapNotCoverage()
|
||||
{
|
||||
var candidates = new List<ContourEntryCandidate>
|
||||
{
|
||||
Cand(0, 0), Cand(10, 0), Cand(0, 10), Cand(10, 10),
|
||||
};
|
||||
|
||||
var cap1 = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 1);
|
||||
var cap3 = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 3);
|
||||
|
||||
Assert.Single(cap1.Choices);
|
||||
Assert.Equal(3, cap3.Choices.Count);
|
||||
Assert.Equal(1, cap1.EvaluatedCount); // cap fills before any coverage scan
|
||||
Assert.Equal(3, cap3.EvaluatedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidCapStaysInvalid()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
ContourEntrySelection.Select(Array.Empty<ContourEntryCandidate>(), _ => null!, 0));
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
ContourEntrySelection.Select(Array.Empty<ContourEntryCandidate>(), _ => null!, -1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicatePointsNeverConsumeTwoSlots()
|
||||
{
|
||||
var candidates = new[] { Cand(5, 5), Cand(5, 5), Cand(5, 5) };
|
||||
|
||||
var result = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 2);
|
||||
|
||||
Assert.Single(result.Choices);
|
||||
Assert.Equal(1, result.EvaluatedCount); // deduplicated before evaluation
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyCatalogueIsExhaustedWithoutEvaluation()
|
||||
{
|
||||
var result = ContourEntrySelection.Select(Array.Empty<ContourEntryCandidate>(), _ => null!);
|
||||
|
||||
Assert.Empty(result.Choices);
|
||||
Assert.Equal(0, result.EvaluatedCount);
|
||||
Assert.Equal(ContourSelectionShortfall.Exhausted, result.Shortfall);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancellationStopsBeforeAnyEvaluation()
|
||||
{
|
||||
using var cancelled = new CancellationTokenSource();
|
||||
cancelled.Cancel();
|
||||
var candidates = new[] { Cand(0, 0) };
|
||||
|
||||
Assert.ThrowsAny<OperationCanceledException>(() =>
|
||||
ContourEntrySelection.Select(candidates, _ => new(ContourFeasibilityStatus.Clear, null),
|
||||
token: cancelled.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CoverageReplacementDropsTheWorstAndResorts()
|
||||
{
|
||||
// Cap 5 fills on points covering left/bottom/top; the right side is chased to (6,6),
|
||||
// which must displace the worst selected point whose removal preserves the others
|
||||
// ((3,4), the top-only cover is re-covered by (6,6)) and settle at its true rank slot.
|
||||
var candidates = new List<ContourEntryCandidate>
|
||||
{
|
||||
Cand(0, 0), Cand(2, 0), Cand(0, 2), Cand(0, 4), Cand(3, 4), Cand(6, 6),
|
||||
};
|
||||
|
||||
var result = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 5);
|
||||
|
||||
Assert.Equal(5, result.Choices.Count);
|
||||
Assert.True(Selected(result, 6, 6));
|
||||
Assert.False(Selected(result, 3, 4));
|
||||
Assert.Equal(6, result.EvaluatedCount);
|
||||
// Evaluation order IS global rank order; the late chaser sorts to the end.
|
||||
Assert.Equal(6, result.Choices[^1].Point.X);
|
||||
Assert.Equal(0, result.Choices[0].Point.X);
|
||||
Assert.Equal(0, result.Choices[0].Point.Y);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CoverageNeverDestroysTheOnlyCoverOfAnotherSide()
|
||||
{
|
||||
// (2,0) is the ONLY cover of bottom; chasing the right side must not remove it even
|
||||
// though it outranks nothing. The chaser replaces (2,4) (top, re-covered by (6,6)).
|
||||
var candidates = new List<ContourEntryCandidate>
|
||||
{
|
||||
Cand(0, 0), Cand(2, 0), Cand(0, 2), Cand(2, 4), Cand(6, 6),
|
||||
};
|
||||
|
||||
var result = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 4);
|
||||
|
||||
// Cap 4: phase 1 keeps the first four (right not covered: (2,4) left+top only).
|
||||
Assert.True(Selected(result, 2, 0)); // sole bottom cover survives
|
||||
Assert.True(Selected(result, 6, 6)); // right-side chaser placed
|
||||
Assert.False(Selected(result, 2, 4)); // safely displaced (top re-covered)
|
||||
Assert.Equal(ContourSelectionShortfall.None, result.Shortfall);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user