diff --git a/App.xaml.cs b/App.xaml.cs index 88f0958..f3a211c 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -15,6 +15,7 @@ using Stack_Solver.Views.Pages; using Stack_Solver.Views.Windows; using System.IO; +using System.Windows; using System.Windows.Threading; using Wpf.Ui; using Wpf.Ui.DependencyInjection; @@ -107,15 +108,53 @@ public static IServiceProvider Services /// private async void OnStartup(object sender, StartupEventArgs e) { - Log.Information("Application starting"); + try + { + // Guarantee the writable app-data folder exists before the host, logger or + // database touch it (see AppPaths / DatabaseInitializer). + AppPaths.EnsureAppData(); + + Log.Information("Application starting"); - await _host.StartAsync(); - Log.Information("Host started"); + await _host.StartAsync(); + Log.Information("Host started"); - // Initialize database - var dbInit = Services.GetRequiredService(); - await dbInit.InitializeAsync(); - Log.Information("Database initialized"); + // Initialize database + var dbInit = Services.GetRequiredService(); + await dbInit.InitializeAsync(); + Log.Information("Database initialized"); + } + catch (Exception ex) + { + HandleFatalStartupError(ex); + } + } + + /// + /// Reports an otherwise-silent startup failure: logs it, writes a plain-text crash file to + /// the app-data folder, shows the tester a dialog pointing at that file, then exits. Without + /// this an exception in the async startup path just closes the window with no trace. + /// + private static void HandleFatalStartupError(Exception ex) + { + Log.Fatal(ex, "Fatal error during application startup"); + + string? crashFile = null; + try + { + AppPaths.EnsureAppData(); + crashFile = Path.Combine(AppPaths.AppDataDirectory, "startup-error.log"); + File.WriteAllText(crashFile, $"{DateTime.Now:u}{Environment.NewLine}{ex}"); + } + catch { /* best-effort: the dialog below still shows the message */ } + + var where = crashFile != null ? $"{Environment.NewLine}{Environment.NewLine}Details saved to:{Environment.NewLine}{crashFile}" : ""; + MessageBox.Show( + $"Stack Solver could not start.{Environment.NewLine}{Environment.NewLine}{ex.GetType().Name}: {ex.Message}{where}", + "Stack Solver - startup error", MessageBoxButton.OK, MessageBoxImage.Error); + + Log.CloseAndFlush(); + Current.Shutdown(-1); } /// diff --git a/Stack-Solver.Core/Services/BranchAndPrice/BranchAndPriceAssignmentService.cs b/Stack-Solver.Core/Services/BranchAndPrice/BranchAndPriceAssignmentService.cs index 6d84c1e..257ad7c 100644 --- a/Stack-Solver.Core/Services/BranchAndPrice/BranchAndPriceAssignmentService.cs +++ b/Stack-Solver.Core/Services/BranchAndPrice/BranchAndPriceAssignmentService.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using Google.OrTools.LinearSolver; using Stack_Solver.Models; using Stack_Solver.Models.Assignment; @@ -103,8 +104,12 @@ public static BranchAndPriceSolution Solve( search.SeedIncumbent(incumbent); search.Run(); + Trace.WriteLine(search.Stats.ToString()); - double bound = double.IsInfinity(search.RootBound) ? 0 : search.RootBound; + // Report the strongest valid lower bound: the LP optimum or the always-valid + // combinatorial (physics) bound, whichever is larger. + double lpBound = double.IsInfinity(search.RootBound) ? 0 : search.RootBound; + double bound = Math.Max(lpBound, search.CombinatorialLowerBound); // Pricing kept columns by position-independent SKU signature, so their layers still // sit at their centered positions. Materialize only the chosen columns (few) so the @@ -122,7 +127,8 @@ public static BranchAndPriceSolution Solve( leftovers[sku] = count; return new BranchAndPriceSolution( - new AssignmentResult { Assignments = assignments, Leftovers = leftovers }, bound, search.ProvedOptimal); + new AssignmentResult { Assignments = assignments, Leftovers = leftovers }, bound, search.ProvedOptimal, + search.Stats); } /// Runs root-node column generation and returns the LP optimum and final pool. @@ -189,7 +195,7 @@ private static ColumnGenerationResult RunColumnGeneration( // Heuristic exhausted — call the exact pricer to either find a missed column // or certify LP optimality. ct.ThrowIfCancellationRequested(); - var exactColumn = exact.FindBestColumn(duals); + var exactColumn = exact.FindBestColumn(duals, forbidden: null, ct: ct); if (exactColumn != null && pool.TryAdd(exactColumn)) { rmp.AddColumns([exactColumn]); @@ -309,7 +315,8 @@ public sealed record ColumnGenerationResult( public sealed record BranchAndPriceSolution( AssignmentResult Result, double LowerBound, - bool LowerBoundCertified) + bool LowerBoundCertified, + BranchAndPriceStats? Stats = null) { /// Pallets used by the integer solution. public int Pallets => Result.TotalPallets; diff --git a/Stack-Solver.Core/Services/BranchAndPrice/BranchAndPriceSearch.cs b/Stack-Solver.Core/Services/BranchAndPrice/BranchAndPriceSearch.cs index 4cbac08..754f5b2 100644 --- a/Stack-Solver.Core/Services/BranchAndPrice/BranchAndPriceSearch.cs +++ b/Stack-Solver.Core/Services/BranchAndPrice/BranchAndPriceSearch.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using Google.OrTools.LinearSolver; +using Stack_Solver.Models; using Stack_Solver.Models.Layering; using Stack_Solver.Models.Supports; @@ -23,11 +24,24 @@ internal sealed class BranchAndPriceSearch : IDisposable private const int MaxCgIterations = 500; private const double IntTol = 1e-6; + // Wall-clock slice handed to the restricted-master IP heuristic at the root: a fraction of + // the time left after root column generation, capped so it can never starve the tree search. + private const double IpHeuristicTimeFraction = 0.3; + private static readonly TimeSpan IpHeuristicMaxTime = TimeSpan.FromSeconds(10); + + // The final-polish IP runs after the tree, so it may use most of the remaining time. + private const double IpFinalTimeFraction = 0.9; + private static readonly TimeSpan IpFinalMaxTime = TimeSpan.FromSeconds(60); + private readonly RestrictedMasterProblem _rmp; private readonly ColumnPool _pool = new(); private readonly PricingSolver _heuristic; + private readonly ConstructivePalletPricer _constructive; private readonly ExactPricingSolver _exact; private readonly HashSet _forbidden = new(StringComparer.Ordinal); + private readonly IReadOnlyList _placeableSkus; + private readonly IReadOnlyDictionary _placeableDemand; + private readonly double _combinatorialBound; private readonly CancellationToken _ct; private readonly Stopwatch _stopwatch = Stopwatch.StartNew(); @@ -35,6 +49,9 @@ internal sealed class BranchAndPriceSearch : IDisposable private long _nodeBudget; private bool _completed = true; private bool _allCertified = true; + private bool _provenByBound; + private bool _rootBoundUsable; + private readonly BranchAndPriceStats _stats = new(); private double _bestObjective = double.PositiveInfinity; private List<(BnpColumn Column, int Count)>? _best; @@ -53,13 +70,28 @@ public BranchAndPriceSearch( _ct = ct; _nodeBudget = nodeBudget; _timeBudget = timeBudget; + _placeableSkus = placeableSkus; + _placeableDemand = placeableDemand; + var skuMap = BuildSkuMap(layers); + _combinatorialBound = CombinatorialBound.Compute(placeableDemand, skuMap, pallet); _rmp = new RestrictedMasterProblem(placeableSkus, placeableDemand); _pool.AddRange(seedColumns); _rmp.AddColumns(seedColumns); _heuristic = new PricingSolver(layers, pallet); + _constructive = new ConstructivePalletPricer(skuMap.Values, placeableDemand, pallet); _exact = new ExactPricingSolver(layers, pallet); } + /// Maps each SKU id appearing in the layers to its (box dimensions/weight). + private static Dictionary BuildSkuMap(IReadOnlyList layers) + { + var map = new Dictionary(StringComparer.Ordinal); + foreach (var layer in layers) + foreach (var item in layer.Items) + map.TryAdd(item.SkuType.SkuId, item.SkuType); + return map; + } + /// Certified-or-not LP lower bound at the root (∞ if the root is infeasible). public double RootBound { get; private set; } @@ -71,8 +103,12 @@ public BranchAndPriceSearch( /// Leftover (unmet) units per SKU in the returned solution. public IReadOnlyDictionary OptimalLeftovers => _bestLeftovers; - /// True only when the returned solution is a proven optimum. - public bool ProvedOptimal => _completed && _allCertified && _best != null; + /// True only when the returned solution is a proven optimum — either certified by a + /// valid lower bound, or by a fully-completed, all-certified tree search. + public bool ProvedOptimal => _best != null && (_provenByBound || (_completed && _allCertified)); + + /// Diagnostic counters describing where the solve spent its effort. + public BranchAndPriceStats Stats => _stats; /// /// Seeds a starting integer incumbent (e.g. from ) so @@ -94,22 +130,127 @@ public void SeedIncumbent(IReadOnlyList<(BnpColumn Column, int Count)> columns) _bestLeftovers = new Dictionary(StringComparer.Ordinal); } + /// + /// Certified combinatorial (physics) lower bound on the pallet count — valid regardless of + /// the LP, and the reportable bound when the LP optimum is weaker. See . + /// + public double CombinatorialLowerBound => _combinatorialBound; + public void Run() { var root = SolveNode(); RootBound = root.Feasible ? root.Primal.Sum(p => p.Value) : double.PositiveInfinity; RootCertified = root.Certified; + // The LP optimum is a valid integer lower bound only when the pricer proved it exhaustive + // and the root itself leaves no untiled demand. + _rootBoundUsable = RootCertified && root.Feasible && AllZero(root.Leftovers); + + // Strengthen the incumbent with the restricted-master IP before deciding whether to + // branch: a tight integer solution over the root column pool often matches ⌈bound⌉ and + // certifies optimality here, skipping the tree entirely. + TryImproveIncumbentWithIp(IpHeuristicTimeFraction, IpHeuristicMaxTime); + if (TryCertifyByBound()) + { + FinalizeStats(); + return; + } - // ⌈LP bound⌉ certification: the integer pallet optimum is ≥ the certified LP bound, - // and integral when all placeable demand tiles (it does, with residual layers). So an - // incumbent matching ⌈RootBound⌉ — with no leftover at the root or in the incumbent — - // is already provably optimal; skip the tree. - if (_best != null && RootCertified && root.Feasible - && AllZero(root.Leftovers) && AllZero(_bestLeftovers) - && _bestObjective <= Math.Ceiling(RootBound - IntTol) + IntTol) + // Pallet-count certification: prove the round-up gap by testing whether all demand fits + // in K pallets for K from the lower bound up to the incumbent. The cardinality cap makes + // these subproblems tight (≤ K columns), cracking the round-up cases the uncapped tree + // cannot. Runs before the expensive uncapped tree so the budget isn't wasted on it. + if (CertifyByPalletCount()) + { + FinalizeStats(); return; + } BranchAndBound(root); + + // Final polish: the tree has built a far richer column pool than the root had. If the + // search stopped early (node/time budget) without proving optimality, an exact IP over + // all generated columns may assemble an incumbent that matches ⌈bound⌉ — certifying an + // optimum the column-variable branching never reached — using any leftover wall-clock. + if (!ProvedOptimal) + { + TryImproveIncumbentWithIp(IpFinalTimeFraction, IpFinalMaxTime); + TryCertifyByBound(); + } + + FinalizeStats(); + } + + /// + /// Marks the incumbent proven optimal when it places all demand with no more pallets than + /// ⌈max(certified LP bound, combinatorial bound)⌉. Both inputs are valid lower bounds on the + /// integer optimum, so a matching incumbent is optimal. Idempotent; safe to call repeatedly. + /// + private bool TryCertifyByBound() + { + if (_best == null || !AllZero(_bestLeftovers)) return false; + + if (_bestObjective <= CertifiedLowerBound() + IntTol) + { + _provenByBound = true; + _stats.RootCertificationFired = true; + return true; + } + return false; + } + + /// + /// ⌈max(certified LP bound, combinatorial bound)⌉ — a valid integer lower bound on the pallet + /// count. The LP term is included only when the root LP was certified and left no demand + /// untiled (); the combinatorial term is always valid. + /// + private int CertifiedLowerBound() + { + int lpLb = _rootBoundUsable ? (int)Math.Ceiling(RootBound - IntTol) : int.MinValue; + int comboLb = (int)Math.Ceiling(_combinatorialBound - IntTol); + return Math.Max(lpLb, comboLb); + } + + /// + /// Runs the restricted-master IP over the current column pool and adopts its assignment as + /// the incumbent when it covers all demand with fewer pallets than the current best. Bounded + /// to of the remaining time (capped at ) so + /// it cannot starve the branch-and-bound search. + /// + private void TryImproveIncumbentWithIp(double fraction, TimeSpan cap) + { + var remaining = _timeBudget - _stopwatch.Elapsed; + if (remaining <= TimeSpan.Zero) return; + + var slice = TimeSpan.FromMilliseconds(remaining.TotalMilliseconds * fraction); + if (slice > cap) slice = cap; + + var ip = RestrictedMasterHeuristic.Solve(_pool.Columns, _placeableSkus, _placeableDemand, slice, _ct); + _stats.RestrictedMasterIpRan = true; + if (ip == null || !AllZero(ip.Leftovers)) return; + + double objective = ip.Columns.Sum(c => c.Count); + _stats.RestrictedMasterIpObjective = objective; + if (objective >= _bestObjective - IntTol) return; + + // The IP found a strictly better full-coverage assignment — adopt it, and make sure its + // columns are in the pool/RMP so the tree can also reach (and branch around) it. + var added = new List(); + foreach (var (col, _) in ip.Columns) + if (_pool.TryAdd(col)) added.Add(col); + if (added.Count > 0) _rmp.AddColumns(added); + + _best = ip.Columns.Where(c => c.Count > 0).Select(c => (c.Column, c.Count)).ToList(); + _bestObjective = objective; + _bestLeftovers = new Dictionary(StringComparer.Ordinal); + } + + private void FinalizeStats() + { + _stats.Completed = _completed; + _stats.AllCertified = _allCertified; + _stats.RootBound = RootBound; + _stats.BestObjective = _best != null ? _bestObjective : double.NaN; + _stats.Elapsed = _stopwatch.Elapsed; } private static bool AllZero(IReadOnlyDictionary leftovers) @@ -122,9 +263,14 @@ private void BranchAndBound(NodeSolve node) { _ct.ThrowIfCancellationRequested(); if (!node.Feasible) return; + _stats.TreeNodes++; + // A single uncertified node anywhere invalidates the tree's optimality proof. + _allCertified &= node.Certified; - // Prune: this node's LP objective cannot beat the incumbent objective. - if (node.Objective >= _bestObjective - IntTol) return; + // Prune: no completion of this node can beat the incumbent. The node bound is the + // stronger of its LP objective and the global combinatorial bound (both valid lower + // bounds on the integer optimum reachable here). + if (Math.Max(node.Objective, _combinatorialBound) >= _bestObjective - IntTol) return; if (node.Integral) { @@ -162,6 +308,132 @@ private void BranchAndBound(NodeSolve node) } } + /// + /// Certifies optimality by branching on the total pallet count. For K from the certified + /// lower bound up to incumbent−1, tests whether all demand fits in at most K pallets under a + /// cardinality cap Σx ≤ K. The first feasible K is the proven optimum (every smaller K was + /// already proven infeasible, and K ≥ the lower bound); if every K up to incumbent−1 is + /// infeasible the incumbent itself is optimal. Returns true when it certifies (recording the + /// proof), false when inconclusive (budget/time out or non-exhaustive pricing) so the caller + /// can fall back to the uncapped tree. The cap and forbidden set are always restored. + /// + private bool CertifyByPalletCount() + { + if (_best == null || !AllZero(_bestLeftovers)) return false; + + int lstart = Math.Max(1, CertifiedLowerBound()); + int incumbent = (int)Math.Round(_bestObjective); + if (lstart >= incumbent) return false; // no gap (TryCertifyByBound would have fired) + + try + { + for (int k = lstart; k < incumbent; k++) + { + _stats.MaxPalletCountTested = k; + _forbidden.Clear(); + _rmp.SetCardinalityCap(k); + + var outcome = SearchCappedZeroLeftover(out var found); + if (outcome == CappedOutcome.Found) + { + _best = found; + _bestObjective = found!.Sum(c => c.Count); + _bestLeftovers = new Dictionary(StringComparer.Ordinal); + _provenByBound = true; + _stats.PalletCountCertified = true; + return true; + } + if (outcome == CappedOutcome.Inconclusive) return false; + // Infeasible → optimum ≥ K+1; try the next count. + } + + // No zero-leftover packing exists with ≤ incumbent−1 pallets ⇒ the incumbent is optimal. + _provenByBound = true; + _stats.PalletCountCertified = true; + return true; + } + finally + { + _forbidden.Clear(); + _rmp.ClearCardinalityCap(); + } + } + + private enum CappedOutcome { Found, Infeasible, Inconclusive } + + /// + /// Depth-first search for an integral, zero-leftover solution under the active cardinality + /// cap. Branches on the most-fractional column like the main tree, but keeps its own + /// exhaustion/certification flags so it never disturbs the global incumbent or its proof. + /// Found is always a genuine feasible solution; Infeasible is trusted only when + /// the whole subtree was explored with exhaustive pricing at every node, otherwise the + /// verdict is Inconclusive. + /// + private CappedOutcome SearchCappedZeroLeftover(out List<(BnpColumn Column, int Count)>? found) + { + bool completed = true; + bool allCertified = true; + List<(BnpColumn Column, int Count)>? result = null; + + CappedOutcome Dfs(NodeSolve node) + { + _ct.ThrowIfCancellationRequested(); + if (!node.Feasible) return CappedOutcome.Infeasible; + allCertified &= node.Certified; + + // The LP objective is a valid lower bound when certified. If it already forces a + // leftover (objective ≥ the big-M penalty), no integral completion here covers all + // demand. (Σx ≤ K < penalty, so the penalty term alone pushes it over the threshold.) + if (node.Objective >= _rmp.LeftoverPenalty - IntTol) return CappedOutcome.Infeasible; + + if (node.Integral) + { + if (!AllZero(node.Leftovers)) return CappedOutcome.Infeasible; + result = node.Primal + .Select(p => (p.Column, Count: (int)Math.Round(p.Value))) + .Where(p => p.Count > 0) + .ToList(); + return CappedOutcome.Found; + } + + var (col, value) = MostFractional(node.Primal); + string sig = col.Signature; + int floorV = (int)Math.Floor(value); + + // Down branch: x_t ≤ ⌊v⌋ (forbid the column when ⌊v⌋ = 0). + if (!TakeNode()) { completed = false; return CappedOutcome.Inconclusive; } + { + var (lb, ub) = _rmp.GetBounds(sig); + _rmp.SetBounds(sig, lb, floorV); + bool forbade = floorV == 0 && _forbidden.Add(sig); + var down = Dfs(SolveNode()); + if (forbade) _forbidden.Remove(sig); + _rmp.SetBounds(sig, lb, ub); + if (down == CappedOutcome.Found) return CappedOutcome.Found; + if (down == CappedOutcome.Inconclusive) { completed = false; return CappedOutcome.Inconclusive; } + } + + // Up branch: x_t ≥ ⌈v⌉. + if (!TakeNode()) { completed = false; return CappedOutcome.Inconclusive; } + { + var (lb, ub) = _rmp.GetBounds(sig); + _rmp.SetBounds(sig, floorV + 1, ub); + var up = Dfs(SolveNode()); + _rmp.SetBounds(sig, lb, ub); + if (up == CappedOutcome.Found) return CappedOutcome.Found; + if (up == CappedOutcome.Inconclusive) { completed = false; return CappedOutcome.Inconclusive; } + } + + return CappedOutcome.Infeasible; // both children infeasible + } + + var top = Dfs(SolveNode()); + found = result; + if (top == CappedOutcome.Found) return CappedOutcome.Found; + if (!completed || !allCertified) return CappedOutcome.Inconclusive; + return CappedOutcome.Infeasible; + } + private bool TakeNode() { if (_nodeBudget <= 0 || _stopwatch.Elapsed > _timeBudget) { _completed = false; return false; } @@ -188,9 +460,11 @@ private static (BnpColumn Column, double Value) MostFractional(IReadOnlyList<(Bn /// private NodeSolve SolveNode() { + _stats.LpNodesSolved++; for (int iter = 0; iter < MaxCgIterations; iter++) { _ct.ThrowIfCancellationRequested(); + _stats.CgIterations++; var status = _rmp.Solve(); if (status is not (Solver.ResultStatus.OPTIMAL or Solver.ResultStatus.FEASIBLE)) @@ -198,8 +472,12 @@ private NodeSolve SolveNode() var duals = _rmp.Duals(); + // Under a cardinality cap Σx ≤ K the cap's dual μ ≤ 0 shifts every column's reduced + // cost; an improving column then needs Σa·π > 1 − μ. Without a cap this is just 1. + double threshold = _rmp.IsCardinalityCapped ? 1.0 - _rmp.CardinalityDual() : 1.0; + var added = new List(); - foreach (var c in _heuristic.FindColumns(duals, _forbidden)) + foreach (var c in _heuristic.FindColumns(duals, _forbidden, threshold)) if (_pool.TryAdd(c)) added.Add(c); if (added.Count > 0) @@ -208,21 +486,43 @@ private NodeSolve SolveNode() continue; } - var exactColumn = _exact.FindBestColumn(duals, _forbidden); + // The pool pricer has stalled: try the constructive box-built pricer, which can + // synthesize pallets the fixed layer pool cannot express (e.g. a layer merging two + // SKUs' leftovers). It is heuristic and additive — it never certifies, so the exact + // pricer below remains the certification signal; we only run that once the + // constructive finder is also exhausted. + var built = new List(); + foreach (var c in _constructive.FindColumns(duals, _forbidden, threshold)) + if (_pool.TryAdd(c)) built.Add(c); + + if (built.Count > 0) + { + _rmp.AddColumns(built); + _stats.ConstructivePricerColumns += built.Count; + continue; + } + + var remaining = _timeBudget - _stopwatch.Elapsed; + var exactColumn = _exact.FindBestColumn( + duals, _forbidden, _ct, + DateTime.UtcNow + (remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero), + threshold); + _stats.ExactPricerCalls++; + _stats.ExactPricerNodes += _exact.LastNodesExplored; + if (_exact.LastSearchExhaustive) _stats.ExactPricerExhaustive++; + else _stats.ExactPricerTruncated++; if (exactColumn != null && _pool.TryAdd(exactColumn)) { _rmp.AddColumns([exactColumn]); continue; } - bool certified = _exact.LastSearchExhaustive; - if (!certified) _allCertified = false; - - return new NodeSolve(true, _rmp.ObjectiveValue, _rmp.PrimalSolution(), _rmp.Leftovers(), _rmp.IsIntegral(), certified); + // The node is certified (a true LP lower bound) only when the exact pricer exhausted + // its search. Callers aggregate this into their own certification flag. + return new NodeSolve(true, _rmp.ObjectiveValue, _rmp.PrimalSolution(), _rmp.Leftovers(), _rmp.IsIntegral(), _exact.LastSearchExhaustive); } - // CG iteration cap hit: treat as an uncertified feasible node. - _allCertified = false; + // CG iteration cap hit: a feasible but uncertified node. bool solved = _rmp.Solve() is Solver.ResultStatus.OPTIMAL or Solver.ResultStatus.FEASIBLE; return solved ? new NodeSolve(true, _rmp.ObjectiveValue, _rmp.PrimalSolution(), _rmp.Leftovers(), _rmp.IsIntegral(), false) diff --git a/Stack-Solver.Core/Services/BranchAndPrice/BranchAndPriceStats.cs b/Stack-Solver.Core/Services/BranchAndPrice/BranchAndPriceStats.cs new file mode 100644 index 0000000..0ca8102 --- /dev/null +++ b/Stack-Solver.Core/Services/BranchAndPrice/BranchAndPriceStats.cs @@ -0,0 +1,80 @@ +namespace Stack_Solver.Services.BranchAndPrice +{ + /// + /// Diagnostic counters for a single branch-and-price solve. Purely observational — it + /// influences no decision — and exists to show where the time goes: how much is spent in + /// the branch-and-bound tree versus column generation, and how often the exact pricer is + /// truncated by its node budget or deadline (the regime that loses certification). Use it + /// to confirm bottleneck proportions before tuning. + /// + public sealed class BranchAndPriceStats + { + /// Branch-and-bound nodes entered (root included). + public int TreeNodes { get; set; } + + /// LP nodes solved by column generation (SolveNode calls). + public int LpNodesSolved { get; set; } + + /// Total column-generation iterations across all nodes. + public int CgIterations { get; set; } + + /// Exact-pricer (FindBestColumn) invocations. + public int ExactPricerCalls { get; set; } + + /// Exact-pricer calls that explored the whole tree (certifying, memo-eligible). + public int ExactPricerExhaustive { get; set; } + + /// Exact-pricer calls cut short by the node budget or deadline (non-certifying). + public int ExactPricerTruncated { get; set; } + + /// Total DFS nodes visited across all exact-pricer calls. + public long ExactPricerNodes { get; set; } + + /// Improving columns contributed by the constructive box-built pricer. + public int ConstructivePricerColumns { get; set; } + + /// True when the ⌈LP-bound⌉ short-circuit proved optimality at the root, skipping the tree. + public bool RootCertificationFired { get; set; } + + /// True when pallet-count (cardinality) certification proved the returned optimum. + public bool PalletCountCertified { get; set; } + + /// Largest pallet count K tested by the cardinality certification loop (0 if it never ran). + public int MaxPalletCountTested { get; set; } + + /// True when the restricted-master IP heuristic was invoked at the root. + public bool RestrictedMasterIpRan { get; set; } + + /// Pallet count of the restricted-master IP's assignment (NaN if it produced none). + public double RestrictedMasterIpObjective { get; set; } = double.NaN; + + /// True when the search exhausted the tree within budget. + public bool Completed { get; set; } + + /// True when every solved node certified its LP optimum. + public bool AllCertified { get; set; } + + /// Root LP lower bound (∞ if the root was infeasible). + public double RootBound { get; set; } + + /// Objective (pallet count) of the returned incumbent. + public double BestObjective { get; set; } + + /// Wall-clock time spent in the solve. + public TimeSpan Elapsed { get; set; } + + /// One-line summary suitable for a debug/trace log. + public override string ToString() => + $"B&P [{Elapsed.TotalSeconds:0.00}s] " + + $"tree={TreeNodes} lpNodes={LpNodesSolved} cgIters={CgIterations} " + + $"pricer={ExactPricerCalls}(exh={ExactPricerExhaustive} trunc={ExactPricerTruncated}) " + + $"pricerNodes={ExactPricerNodes} constructiveCols={ConstructivePricerColumns} " + + $"ip={(RestrictedMasterIpRan ? (double.IsNaN(RestrictedMasterIpObjective) ? "none" : RestrictedMasterIpObjective.ToString("0.##")) : "off")} " + + $"rootCert={(RootCertificationFired ? "Y" : "N")} " + + $"palletCountCert={(PalletCountCertified ? $"Y(K={MaxPalletCountTested})" : "N")} " + + $"completed={(Completed ? "Y" : "N")} " + + $"allCertified={(AllCertified ? "Y" : "N")} " + + $"rootBound={(double.IsInfinity(RootBound) ? "inf" : RootBound.ToString("0.##"))} " + + $"best={BestObjective:0.##}"; + } +} diff --git a/Stack-Solver.Core/Services/BranchAndPrice/CombinatorialBound.cs b/Stack-Solver.Core/Services/BranchAndPrice/CombinatorialBound.cs new file mode 100644 index 0000000..4d53a47 --- /dev/null +++ b/Stack-Solver.Core/Services/BranchAndPrice/CombinatorialBound.cs @@ -0,0 +1,57 @@ +using Stack_Solver.Models; +using Stack_Solver.Models.Supports; + +namespace Stack_Solver.Services.BranchAndPrice +{ + /// + /// A certified combinatorial lower bound on the number of pallets needed to hold a demand — + /// computed from physics alone, independent of the LP relaxation. It strengthens the weak + /// volumetric LP bound in the large-demand regime, where it lets the ⌈bound⌉ root + /// short-circuit certify optimality without entering the tree. + /// + /// Two resource relaxations, each provably a lower bound (their max is taken): + /// + /// Weight: Σ demand·boxWeight cannot exceed pallets · maxStackWeight. + /// Volume: Σ demand·boxVolume cannot exceed pallets · (footprint · stack height). + /// + /// + /// Deliberately omitted: a per-SKU "layer count" (partial-layer-waste) bound. That would + /// be stronger but is unsound here — layers may mix SKUs (see BLFGenerationStrategy), + /// so a single mixed layer can pack two SKUs' partial layers together, making a per-SKU sum + /// exceed the true optimum. Only resource bounds that hold regardless of layer composition are + /// used, so the result can never exceed the real pallet minimum. + /// + public static class CombinatorialBound + { + /// + /// Fractional lower bound on pallets to hold . Callers take + /// ⌈value⌉ for the integer bound. SKUs missing from contribute + /// nothing (already filtered as unplaceable upstream). + /// + public static double Compute( + IReadOnlyDictionary demand, + IReadOnlyDictionary skus, + Pallet pallet) + { + ArgumentNullException.ThrowIfNull(demand); + ArgumentNullException.ThrowIfNull(skus); + ArgumentNullException.ThrowIfNull(pallet); + + int availHeight = pallet.MaxStackHeight - pallet.Height; + double palletVolume = (double)pallet.Length * pallet.Width * Math.Max(0, availHeight); + double maxWeight = pallet.MaxStackWeight; + + double totalWeight = 0, totalVolume = 0; + foreach (var (sku, d) in demand) + { + if (d <= 0 || !skus.TryGetValue(sku, out var box)) continue; + totalWeight += d * box.Weight; + totalVolume += (double)d * box.Length * box.Width * box.Height; + } + + double weightBound = maxWeight > 0 ? totalWeight / maxWeight : 0; + double volumeBound = palletVolume > 0 ? totalVolume / palletVolume : 0; + return Math.Max(weightBound, volumeBound); + } + } +} diff --git a/Stack-Solver.Core/Services/BranchAndPrice/ConstructivePalletPricer.cs b/Stack-Solver.Core/Services/BranchAndPrice/ConstructivePalletPricer.cs new file mode 100644 index 0000000..594510f --- /dev/null +++ b/Stack-Solver.Core/Services/BranchAndPrice/ConstructivePalletPricer.cs @@ -0,0 +1,379 @@ +using Stack_Solver.Helpers.Layering; +using Stack_Solver.Models; +using Stack_Solver.Models.Layering; +using Stack_Solver.Models.Metadata; +using Stack_Solver.Models.Supports; + +namespace Stack_Solver.Services.BranchAndPrice +{ + /// + /// Constructive, support-aware, dual-guided pricing subproblem. Unlike the stacking pricers + /// (, ) — which only combine + /// pre-generated layers from a fixed pool — this builds a pallet bottom-up from boxes, + /// so it can synthesize layers the pool never contained (e.g. a single layer merging the + /// leftovers of two SKUs). Guided by the demand-constraint duals π_i, it tiles each layer with + /// boxes, then stacks the next layer on the occupied cells of the one below (100% inter-layer + /// support), respecting the same physical rules as the other pricers: available height, stack + /// weight, the top-heavy load-density order, and the distinct-SKU cap. + /// + /// Each layer is packed SKU-by-SKU: a SKU is laid down in whichever orientation tiles the + /// most boxes on the still-free support, then the next SKU fills the remaining gaps. This keeps + /// layers dense and their geometry clean for display. A handful of attempts with different SKU + /// fill orders give diversity. + /// + /// It augments the stacking pricers — every pallet it builds becomes a candidate + /// column whose dual value Σ_i a_i·π_i, when it exceeds the reduced-cost threshold, is an + /// improving column. Like it is a heuristic finder: it never proves + /// the absence of improving columns, so it does not certify LP optimality (that remains the job + /// of the exact pricer / the global certificate). Deterministic across runs. + /// + public sealed class ConstructivePalletPricer + { + private const double Epsilon = 1e-6; + + private readonly IReadOnlyList _skus; + private readonly IReadOnlyDictionary> _variantsBySku; + private readonly IReadOnlyDictionary _demand; + private readonly Pallet _pallet; + private readonly int _gridStep; + private readonly int _gridWidth; // Y cells (along pallet Width) + private readonly int _gridLength; // X cells (along pallet Length) + private readonly int _availHeight; + private readonly double _maxWeight; + private readonly double _tolerance; + + /// SKU fill orders tried per call, for column diversity. + private enum FillStrategy { CoverageFirst, DualFirst, DemandFirst } + private static readonly FillStrategy[] Strategies = + [FillStrategy.CoverageFirst, FillStrategy.DualFirst, FillStrategy.DemandFirst]; + + public ConstructivePalletPricer( + IReadOnlyCollection skus, + IReadOnlyDictionary demand, + Pallet pallet) + { + ArgumentNullException.ThrowIfNull(skus); + _demand = demand ?? throw new ArgumentNullException(nameof(demand)); + _pallet = pallet ?? throw new ArgumentNullException(nameof(pallet)); + + // Only SKUs with positive demand can be packed; dedupe by id. + _skus = skus.Where(s => s != null && demand.GetValueOrDefault(s.SkuId) > 0) + .GroupBy(s => s.SkuId, StringComparer.Ordinal) + .Select(g => g.First()) + .ToList(); + + _gridStep = ComputeGridStep(_skus); + _variantsBySku = _skus.ToDictionary( + s => s.SkuId, + s => (IReadOnlyList)SkuVariantFactory.CreateAllOrientations([s]), + StringComparer.Ordinal); + _gridWidth = (int)Math.Ceiling((double)pallet.Width / _gridStep); + _gridLength = (int)Math.Ceiling((double)pallet.Length / _gridStep); + _availHeight = pallet.MaxStackHeight - pallet.Height; + _maxWeight = pallet.MaxStackWeight; + _tolerance = pallet.LoadDensityTolerance; + } + + /// Maximum number of distinct improving columns returned per call. + public int MaxColumns { get; init; } = 8; + + /// + /// Builds pallets from boxes under the duals and returns up to + /// distinct improving columns (dual value > ), + /// highest value first. A handful of build attempts with different SKU fill priorities give + /// diversity. Columns whose signature is in (forbidden by a + /// branching constraint) are never returned. Empty when none improves. + /// + public IReadOnlyList FindColumns( + IReadOnlyDictionary duals, + IReadOnlySet? forbidden = null, + double reducedCostThreshold = 1.0) + { + ArgumentNullException.ThrowIfNull(duals); + if (_availHeight <= 0 || _skus.Count == 0) return []; + + var best = new Dictionary(StringComparer.Ordinal); + + foreach (var strategy in Strategies) + { + var layers = BuildPallet(strategy, duals); + if (layers.Count == 0) continue; + + var column = new BnpColumn(PalletTemplate.FromLayers(layers)); + if (forbidden != null && forbidden.Contains(column.Signature)) continue; + + double value = ColumnValue(layers, duals); + if (value <= reducedCostThreshold + Epsilon) continue; + + if (!best.TryGetValue(column.Signature, out var existing) || value > existing.Value) + best[column.Signature] = (column, value); + } + + return best.Values + .OrderByDescending(e => e.Value) + .Take(MaxColumns) + .Select(e => e.Column) + .ToList(); + } + + /// + /// Builds one pallet bottom-up: each layer is packed on the occupancy of the layer below, + /// then committed only if it satisfies the top-heavy load-density order. Stops when height is + /// exhausted or no further box can be placed. + /// + private List BuildPallet(FillStrategy strategy, IReadOnlyDictionary duals) + { + var layers = new List(); + // Remaining per-SKU cap (the demand) — a single pallet never carries more than total demand. + var remaining = new Dictionary(StringComparer.Ordinal); + foreach (var s in _skus) remaining[s.SkuId] = _demand.GetValueOrDefault(s.SkuId); + + // Support region starts as the whole pallet surface (everything supported). + var support = new bool[_gridWidth, _gridLength]; + for (int y = 0; y < _gridWidth; y++) + for (int x = 0; x < _gridLength; x++) + support[y, x] = true; + + int usedHeight = 0; + double usedWeight = 0; + double lowerDensity = double.PositiveInfinity; // first layer rests on the pallet: no density constraint + var distinctSkus = new HashSet(StringComparer.Ordinal); + + while (usedHeight < _availHeight) + { + var plan = BuildLayer(support, remaining, usedWeight, _availHeight - usedHeight, distinctSkus, strategy, duals); + if (plan.Items.Count == 0) break; + + var layer = MaterializeLayer(plan); + + // Top-heavy load-density order: a layer may rest on the one below only when its load + // density does not exceed it by more than the tolerance. The plan touched no shared + // state, so a rejected layer is simply dropped and building stops. + if (!StackingLoadRule.Allows(lowerDensity, layer.Metrics.LoadDensity, _tolerance)) break; + + foreach (var (sku, count) in plan.PlacedBySku) remaining[sku] -= count; + usedHeight += plan.Height; + usedWeight += layer.Metrics.TotalWeight; + lowerDensity = layer.Metrics.LoadDensity; + distinctSkus.UnionWith(plan.PlacedBySku.Keys); + support = layer.Geometry!.OccupancyGrid; + layers.Add(layer); + } + + return layers; + } + + /// + /// Packs a single layer SKU-by-SKU. SKUs are ordered by the strategy; each is laid down in + /// whichever orientation tiles the most boxes on the still-free support, the next filling the + /// gaps. Every box sits entirely on the support region (100% support) without overlapping a + /// box already placed, and respects the remaining cap, distinct-SKU cap, stack-weight limit, + /// and remaining height. Touches no shared state — it reports the plan for the caller to commit. + /// + private LayerPlan BuildLayer( + bool[,] support, + IReadOnlyDictionary remaining, + double usedWeight, + int heightBudget, + IReadOnlySet distinctSkus, + FillStrategy strategy, + IReadOnlyDictionary duals) + { + var supportPrefix = BuildPrefixSum(support); + var layerOcc = new bool[_gridWidth, _gridLength]; + var items = new List(); + var placed = new Dictionary(StringComparer.Ordinal); + var runningDistinct = new HashSet(distinctSkus, StringComparer.Ordinal); + double runningWeight = usedWeight; + int layerHeight = 0; + + foreach (var sku in OrderSkus(support, supportPrefix, remaining, strategy, duals)) + { + int cap = remaining.GetValueOrDefault(sku.SkuId); + if (cap <= 0 || sku.Height > heightBudget) continue; + if (!runningDistinct.Contains(sku.SkuId) && runningDistinct.Count >= PricingRules.MaxDistinctSkusPerTemplate) continue; + + var fill = TileBestOrientation(sku, layerOcc, supportPrefix, support, cap, runningWeight, heightBudget); + if (fill.Count == 0) continue; + + layerOcc = fill.Occupancy; + items.AddRange(fill.Items); + placed[sku.SkuId] = placed.GetValueOrDefault(sku.SkuId) + fill.Count; + runningWeight += fill.Weight; + runningDistinct.Add(sku.SkuId); + if (sku.Height > layerHeight) layerHeight = sku.Height; + } + + return new LayerPlan(items, placed, layerHeight); + } + + /// Orders the still-demanded SKUs for this layer per the diversity strategy. + private IEnumerable OrderSkus( + bool[,] support, + int[,] supportPrefix, + IReadOnlyDictionary remaining, + FillStrategy strategy, + IReadOnlyDictionary duals) + { + var emptyOcc = new bool[_gridWidth, _gridLength]; + + double Pi(SKU s) => duals.GetValueOrDefault(s.SkuId, 0.0); + int Remaining(SKU s) => remaining.GetValueOrDefault(s.SkuId); + // Area this SKU could cover on the empty support — its best single-SKU layer footprint. + long Coverage(SKU s) + { + int cap = Remaining(s); + if (cap <= 0) return 0; + var fit = TileBestOrientation(s, emptyOcc, supportPrefix, support, cap, 0, s.Height); + return (long)fit.Count * s.Length * s.Width; + } + + var live = _skus.Where(s => Remaining(s) > 0).ToList(); + + return strategy switch + { + FillStrategy.DualFirst => live + .OrderByDescending(Pi).ThenByDescending(Coverage).ThenBy(s => s.SkuId, StringComparer.Ordinal), + FillStrategy.DemandFirst => live + .OrderByDescending(Remaining).ThenByDescending(Pi).ThenBy(s => s.SkuId, StringComparer.Ordinal), + _ => live // CoverageFirst + .OrderByDescending(Coverage).ThenByDescending(Pi).ThenBy(s => s.SkuId, StringComparer.Ordinal), + }; + } + + /// + /// Tiles a SKU into the free support in whichever orientation places the most boxes (ties + /// resolved to the non-rotated orientation for determinism). Returns the resulting occupancy + /// grid (a fresh copy), the placed items, the count, and the added weight. + /// + private TileResult TileBestOrientation( + SKU sku, bool[,] occupancy, int[,] supportPrefix, bool[,] support, + int cap, double runningWeight, int heightBudget) + { + TileResult best = TileResult.Empty; + foreach (var variant in _variantsBySku[sku.SkuId]) + { + var result = TileVariant(variant, occupancy, supportPrefix, support, cap, runningWeight, heightBudget); + if (result.Count > best.Count) best = result; // first (non-rotated) wins ties + } + return best; + } + + /// Places one box variant repeatedly, row-major, into the free + supported cells. + private TileResult TileVariant( + SkuVariant variant, bool[,] occupancy, int[,] supportPrefix, bool[,] support, + int cap, double runningWeight, int heightBudget) + { + if (variant.Sku.Height > heightBudget) return TileResult.Empty; + + int cellsX = Math.Max(1, (int)Math.Ceiling((double)variant.SpanX / _gridStep)); + int cellsY = Math.Max(1, (int)Math.Ceiling((double)variant.SpanY / _gridStep)); + double weight = variant.Sku.Weight; + + var occ = (bool[,])occupancy.Clone(); + var items = new List(); + int count = 0; + double added = 0; + + for (int cy = 0; cy + cellsY <= _gridWidth; cy++) + { + for (int cx = 0; cx + cellsX <= _gridLength; cx++) + { + if (count >= cap || runningWeight + added + weight > _maxWeight) goto done; + if (occ[cy, cx] || !support[cy, cx]) continue; + // 100% support: every footprint cell must lie on the support region. + if (RectSum(supportPrefix, cy, cx, cellsY, cellsX) != cellsX * cellsY) continue; + if (Overlaps(occ, cy, cx, cellsY, cellsX)) continue; + + for (int y = cy; y < cy + cellsY; y++) + for (int x = cx; x < cx + cellsX; x++) + occ[y, x] = true; + + items.Add(new PositionedItem(variant.Sku, cx * _gridStep, cy * _gridStep, variant.Rotated)); + count++; + added += weight; + } + } + + done: + return new TileResult(occ, items, count, added); + } + + private Layer MaterializeLayer(LayerPlan plan) + { + var layer = new Layer("constructive", plan.Items, new LayerMetadata(0, plan.Height, "constructive box-built layer")); + layer.Geometry = LayerGeometryBuilder.Build(layer, _pallet, _gridStep); + layer.Metrics = LayerMetricsCalculator.Compute(layer, _pallet); + layer.Metadata.Utilization = layer.Metrics.Utilization / 100.0; + return layer; + } + + /// Dual value Σ_i a_i·π_i of the built pallet. + private static double ColumnValue(IReadOnlyList layers, IReadOnlyDictionary duals) + { + double value = 0; + foreach (var layer in layers) + foreach (var item in layer.Items) + value += duals.GetValueOrDefault(item.SkuType.SkuId, 0.0); + return value; + } + + private static bool Overlaps(bool[,] occ, int row, int col, int h, int w) + { + for (int y = row; y < row + h; y++) + for (int x = col; x < col + w; x++) + if (occ[y, x]) return true; + return false; + } + + private int[,] BuildPrefixSum(bool[,] occupancy) + { + var prefix = new int[_gridWidth + 1, _gridLength + 1]; + for (int y = 0; y < _gridWidth; y++) + for (int x = 0; x < _gridLength; x++) + prefix[y + 1, x + 1] = (occupancy[y, x] ? 1 : 0) + + prefix[y, x + 1] + prefix[y + 1, x] - prefix[y, x]; + return prefix; + } + + // Count of set cells in rows [row, row+h) and cols [col, col+w); region must be within bounds. + private static int RectSum(int[,] prefix, int row, int col, int h, int w) + { + int r2 = row + h; + int c2 = col + w; + return prefix[r2, c2] - prefix[row, c2] - prefix[r2, col] + prefix[row, col]; + } + + private static int ComputeGridStep(IEnumerable skus) + { + int gcd = 0; + foreach (var s in skus) + { + if (s == null) continue; + gcd = Gcd(gcd, s.Length); + gcd = Gcd(gcd, s.Width); + } + return Math.Max(gcd, 1); + } + + private static int Gcd(int a, int b) + { + while (b != 0) { int t = b; b = a % b; a = t; } + return a; + } + + private readonly record struct LayerPlan( + List Items, + Dictionary PlacedBySku, + int Height); + + private readonly record struct TileResult( + bool[,] Occupancy, + List Items, + int Count, + double Weight) + { + public static TileResult Empty => new(new bool[0, 0], [], 0, 0); + } + } +} diff --git a/Stack-Solver.Core/Services/BranchAndPrice/ExactPricingSolver.cs b/Stack-Solver.Core/Services/BranchAndPrice/ExactPricingSolver.cs index 4f24222..828e291 100644 --- a/Stack-Solver.Core/Services/BranchAndPrice/ExactPricingSolver.cs +++ b/Stack-Solver.Core/Services/BranchAndPrice/ExactPricingSolver.cs @@ -35,17 +35,35 @@ public ExactPricingSolver(IReadOnlyList layers, Pallet pallet) /// Maximum number of search nodes before the search aborts as non-exhaustive. public long NodeBudget { get; init; } = 5_000_000; + /// + /// Node budget used when the transposition table is unavailable (a forbidden set is active, + /// i.e. inside branch-and-bound). Without memoization the DFS is exponential, so this caps a + /// single deep-node pricing call to stop it monopolizing the solver's whole time budget; such + /// a call is reported non-exhaustive, never wrongly certifying optimality. + /// + public long UncertifiedNodeBudget { get; init; } = 250_000; + /// True iff the most recent explored the whole tree. public bool LastSearchExhaustive { get; private set; } + /// DFS nodes visited by the most recent (diagnostics). + public long LastNodesExplored { get; private set; } + /// Demand-constraint duals π_i. /// Column signatures barred by branching; never returned, but still traversed as prefixes. + /// Cancelled cooperatively from inside the search; on cancellation the search stops early and is reported non-exhaustive. + /// Absolute wall-clock cutoff. When reached the search stops early and is reported non-exhaustive, so a long pricing call cannot overrun the solver's time budget. + /// A column is improving only when its dual value Σ a·π exceeds this. Defaults to 1 (the unit pallet cost); a cardinality cap raises it to 1 − μ. public BnpColumn? FindBestColumn( IReadOnlyDictionary duals, - IReadOnlySet? forbidden = null) + IReadOnlySet? forbidden = null, + CancellationToken ct = default, + DateTime? deadlineUtc = null, + double reducedCostThreshold = 1.0) { ArgumentNullException.ThrowIfNull(duals); LastSearchExhaustive = true; + LastNodesExplored = 0; if (_rules.AvailHeight <= 0) return null; var cands = BuildCandidates(duals); @@ -64,7 +82,12 @@ public ExactPricingSolver(IReadOnlyList layers, Pallet pallet) valuePerWeight = Math.Max(valuePerWeight, c.Weight > 0 ? c.Value / c.Weight : double.PositiveInfinity); } - var ctx = new SearchContext(cands, _rules, valuePerHeight, valuePerWeight, NodeBudget, forbidden); + // The transposition table is only sound with no forbidden columns (see SearchContext); + // when it is unavailable, cap the (then exponential) DFS so one call can't eat the budget. + bool memoEligible = forbidden == null || forbidden.Count == 0; + long budget = memoEligible ? NodeBudget : Math.Min(NodeBudget, UncertifiedNodeBudget); + + var ctx = new SearchContext(cands, _rules, valuePerHeight, valuePerWeight, budget, forbidden, ct, deadlineUtc); for (int i = 0; i < cands.Count; i++) { ctx.Descend(i); @@ -72,8 +95,9 @@ public ExactPricingSolver(IReadOnlyList layers, Pallet pallet) } LastSearchExhaustive = !ctx.BudgetExhausted; + LastNodesExplored = Math.Max(0, budget - ctx.RemainingBudget); - if (ctx.BestValue > 1.0 + Epsilon && ctx.BestStack.Count > 0) + if (ctx.BestValue > reducedCostThreshold + Epsilon && ctx.BestStack.Count > 0) { var layers = ctx.BestStack.Select(i => cands[i].Layer).ToList(); return new BnpColumn(PalletTemplate.FromLayers(layers)); @@ -109,6 +133,11 @@ private List BuildCandidates(IReadOnlyDictionary duals) private sealed class SearchContext { + // How often (in nodes) the wall-clock / cancellation cutoff is polled. + private const int CheckStride = 8192; + // Hard cap on transposition-table entries, bounding its memory (~50 MB at this size). + private const int MemoCap = 1_000_000; + private readonly List _cands; private readonly PricingRules _rules; private readonly double _valuePerHeight; @@ -118,6 +147,21 @@ private sealed class SearchContext private readonly IReadOnlySet? _forbidden; private long _budget; + private readonly CancellationToken _ct; + private readonly bool _hasDeadline; + private readonly DateTime _deadline; + private int _checkCountdown = CheckStride; + + // Transposition table: best accumulated value seen at each equivalent partial-stack + // state. Two stacks are equivalent for future exploration when they share the same top + // layer, used height, used weight, and set of present SKUs — they then admit exactly the + // same extensions with the same values, so a state revisited at no-greater value can be + // pruned. Disabled when a forbidden set is in play (forbidden-ness depends on the exact + // column signature, which two state-equivalent stacks can differ on) to stay sound. + private readonly Dictionary? _memo; + private readonly Dictionary _skuBit = new(StringComparer.Ordinal); + private long _skuMask; + private readonly List _stack = []; // candidate indices, bottom→top private readonly Dictionary _skuRefs = new(StringComparer.Ordinal); private int _distinct; @@ -129,7 +173,10 @@ private sealed class SearchContext public List BestStack { get; } = []; public bool BudgetExhausted { get; private set; } - public SearchContext(List cands, PricingRules rules, double valuePerHeight, double valuePerWeight, long budget, IReadOnlySet? forbidden) + /// Node budget left after the search (diagnostics; may be slightly negative on the aborting node). + public long RemainingBudget => _budget; + + public SearchContext(List cands, PricingRules rules, double valuePerHeight, double valuePerWeight, long budget, IReadOnlySet? forbidden, CancellationToken ct, DateTime? deadlineUtc) { _cands = cands; _rules = rules; @@ -139,6 +186,29 @@ public SearchContext(List cands, PricingRules rules, double valuePerHeight _budget = budget; _availH = rules.AvailHeight; _forbidden = forbidden; + _ct = ct; + _hasDeadline = deadlineUtc.HasValue; + _deadline = deadlineUtc ?? default; + + // The transposition table keys on a SKU-presence bitmask; build the SKU→bit map and + // enable memoization only when there is no forbidden set and the layers use ≤ 63 + // distinct SKUs (so the mask fits a long). + if (forbidden == null || forbidden.Count == 0) + { + int bit = 0; + bool fits = true; + foreach (var c in cands) + { + foreach (var sku in c.Layer.Metrics.UsedSkuTypes) + { + if (_skuBit.ContainsKey(sku)) continue; + if (bit >= 63) { fits = false; break; } + _skuBit[sku] = 1L << bit++; + } + if (!fits) break; + } + if (fits) _memo = new Dictionary(); + } } public void Descend(int i) @@ -159,6 +229,16 @@ public void Descend(int i) if (_budget-- <= 0) { BudgetExhausted = true; return; } + if (--_checkCountdown <= 0) + { + _checkCountdown = CheckStride; + if (_ct.IsCancellationRequested || (_hasDeadline && DateTime.UtcNow >= _deadline)) + { + BudgetExhausted = true; + return; + } + } + Push(i, c); if (_value > BestValue && !IsForbidden()) @@ -168,6 +248,23 @@ public void Descend(int i) BestStack.AddRange(_stack); } + // Transposition-table prune: if this same state was already explored at an equal or + // greater accumulated value, everything reachable from here was already found; skip + // it. Otherwise record this (better) value and explore. i is the current top. + if (_memo != null) + { + var key = new StateKey(i, _usedHeight, (long)Math.Round(_usedWeight * 1000.0), _skuMask); + if (_memo.TryGetValue(key, out double seen)) + { + if (seen >= _value - Epsilon) { Pop(c); return; } + _memo[key] = _value; + } + else if (_memo.Count < MemoCap) + { + _memo[key] = _value; + } + } + // Optimistic bound: fill the remaining height and weight at the best value // densities, taking the binding (smaller) of the two resource limits. double bound = _value + Math.Min( @@ -219,7 +316,7 @@ private void Push(int i, in Cand c) foreach (var sku in c.Layer.Metrics.UsedSkuTypes) { int n = _skuRefs.GetValueOrDefault(sku); - if (n == 0) _distinct++; + if (n == 0) { _distinct++; if (_memo != null) _skuMask |= _skuBit[sku]; } _skuRefs[sku] = n + 1; } } @@ -234,9 +331,15 @@ private void Pop(in Cand c) { int n = _skuRefs[sku] - 1; _skuRefs[sku] = n; - if (n == 0) _distinct--; + if (n == 0) { _distinct--; if (_memo != null) _skuMask &= ~_skuBit[sku]; } } } + + // Identifies partial stacks that admit the same future extensions at the same values: + // same top layer (support + density + canonical tie-break), remaining height and weight, + // and set of present SKUs (distinct-SKU cap). Weight is quantised to milligrams so that + // floating-point summation order does not split otherwise-identical states. + private readonly record struct StateKey(int Top, int UsedHeight, long UsedWeightMg, long SkuMask); } } } diff --git a/Stack-Solver.Core/Services/BranchAndPrice/PricingSolver.cs b/Stack-Solver.Core/Services/BranchAndPrice/PricingSolver.cs index e900c2a..12ef0e0 100644 --- a/Stack-Solver.Core/Services/BranchAndPrice/PricingSolver.cs +++ b/Stack-Solver.Core/Services/BranchAndPrice/PricingSolver.cs @@ -36,13 +36,16 @@ public PricingSolver(IReadOnlyList layers, Pallet pallet) public int MaxColumns { get; init; } = 8; /// - /// Returns up to distinct improving columns (dual value > 1), - /// highest value first. Columns whose signature is in - /// (forbidden by a branching constraint) are never returned. Empty when none is found. + /// Returns up to distinct improving columns (dual value > + /// ), highest value first. Columns whose signature is + /// in (forbidden by a branching constraint) are never returned. + /// Empty when none is found. The threshold defaults to 1 (the unit pallet cost); a + /// cardinality cap raises it to 1 − μ. /// public IReadOnlyList FindColumns( IReadOnlyDictionary duals, - IReadOnlySet? forbidden = null) + IReadOnlySet? forbidden = null, + double reducedCostThreshold = 1.0) { ArgumentNullException.ThrowIfNull(duals); if (_rules.AvailHeight <= 0) return []; @@ -59,7 +62,7 @@ public IReadOnlyList FindColumns( for (int depth = 0; depth < MaxLayersPerTemplate && beam.Count > 0; depth++) { foreach (var st in beam) - if (st.Value > 1.0 + ReducedCostEpsilon) + if (st.Value > reducedCostThreshold + ReducedCostEpsilon) Record(best, st, forbidden); if (depth == MaxLayersPerTemplate - 1) break; diff --git a/Stack-Solver.Core/Services/BranchAndPrice/RestrictedMasterHeuristic.cs b/Stack-Solver.Core/Services/BranchAndPrice/RestrictedMasterHeuristic.cs new file mode 100644 index 0000000..1639171 --- /dev/null +++ b/Stack-Solver.Core/Services/BranchAndPrice/RestrictedMasterHeuristic.cs @@ -0,0 +1,107 @@ +using Google.OrTools.LinearSolver; + +namespace Stack_Solver.Services.BranchAndPrice +{ + /// + /// Primal "price-and-branch" heuristic: solves the set-partition master as an integer program + /// restricted to the columns generated so far. The LP relaxation gives a (weak) lower bound; + /// this gives a strong integer upper bound — a real pallet assignment — that the search + /// can adopt as its incumbent. When that incumbent matches the ⌈LP / combinatorial bound⌉, the + /// root short-circuit certifies optimality without ever entering the branch-and-bound tree. + /// + /// It is a heuristic only because it is limited to the current column pool: it cannot + /// invent a pallet pattern that column generation has not produced. It never returns an + /// infeasible or demand-violating assignment — leftover slack keeps it feasible, and the caller + /// inspects the leftovers. + /// + public static class RestrictedMasterHeuristic + { + /// + /// Solves the integer master over within . + /// Returns the chosen pallet multiset and any unmet demand, or null when no MIP backend is + /// available or no feasible integer solution is found in time. + /// + public static Result? Solve( + IReadOnlyList columns, + IReadOnlyList skuOrder, + IReadOnlyDictionary demand, + TimeSpan timeLimit, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(columns); + ArgumentNullException.ThrowIfNull(skuOrder); + ArgumentNullException.ThrowIfNull(demand); + + if (columns.Count == 0 || skuOrder.Count == 0) return null; + if (timeLimit <= TimeSpan.Zero) return null; + ct.ThrowIfCancellationRequested(); + + // SCIP is OR-Tools' default MIP backend; fall back to CBC. Either may be absent in a + // trimmed build, in which case the heuristic is simply skipped. + using var solver = Solver.CreateSolver("SCIP") ?? Solver.CreateSolver("CBC"); + if (solver is null) return null; + + var objective = solver.Objective(); + objective.SetMinimization(); + + // Big-M leftover penalty, larger than any achievable pallet count (≤ Σ demand), so the + // optimizer drives leftovers to their minimum before trading any pallets. + long totalDemand = 0; + foreach (var sku in skuOrder) totalDemand += demand.GetValueOrDefault(sku); + double leftoverPenalty = totalDemand + 1; + + var rows = new Dictionary(skuOrder.Count, StringComparer.Ordinal); + foreach (var sku in skuOrder) + { + int d = demand.GetValueOrDefault(sku); + var row = solver.MakeConstraint(d, d, $"demand_{sku}"); + var slack = solver.MakeIntVar(0, d, $"l_{sku}"); + row.SetCoefficient(slack, 1.0); + objective.SetCoefficient(slack, leftoverPenalty); + rows[sku] = row; + } + + var vars = new Variable[columns.Count]; + for (int t = 0; t < columns.Count; t++) + { + var x = solver.MakeIntVar(0, double.PositiveInfinity, $"x{t}"); + objective.SetCoefficient(x, 1.0); + foreach (var sku in skuOrder) + { + int a = columns[t].CountOf(sku); + if (a != 0) rows[sku].SetCoefficient(x, a); + } + vars[t] = x; + } + + solver.SetTimeLimit((long)Math.Max(1, timeLimit.TotalMilliseconds)); + var status = solver.Solve(); + if (status is not (Solver.ResultStatus.OPTIMAL or Solver.ResultStatus.FEASIBLE)) + return null; + + var chosen = new List<(BnpColumn Column, int Count)>(); + for (int t = 0; t < columns.Count; t++) + { + int count = (int)Math.Round(vars[t].SolutionValue()); + if (count > 0) chosen.Add((columns[t], count)); + } + + var leftovers = new Dictionary(StringComparer.Ordinal); + foreach (var sku in skuOrder) + { + int covered = 0; + foreach (var (col, count) in chosen) covered += col.CountOf(sku) * count; + int missing = demand.GetValueOrDefault(sku) - covered; + if (missing > 0) leftovers[sku] = missing; + } + + return new Result(chosen, leftovers); + } + + /// Chosen pallet templates with their multiplicities. + /// Unmet demand per SKU (empty when the assignment covers everything). + public sealed record Result( + IReadOnlyList<(BnpColumn Column, int Count)> Columns, + IReadOnlyDictionary Leftovers); + } +} diff --git a/Stack-Solver.Core/Services/BranchAndPrice/RestrictedMasterProblem.cs b/Stack-Solver.Core/Services/BranchAndPrice/RestrictedMasterProblem.cs index 497ad54..cac84f9 100644 --- a/Stack-Solver.Core/Services/BranchAndPrice/RestrictedMasterProblem.cs +++ b/Stack-Solver.Core/Services/BranchAndPrice/RestrictedMasterProblem.cs @@ -27,6 +27,12 @@ public sealed class RestrictedMasterProblem : IDisposable private readonly Dictionary _bySignature = new(StringComparer.Ordinal); private readonly Dictionary _slack = new(StringComparer.Ordinal); + // Optional cardinality cap Σ_t x_t ≤ K, created lazily by SetCardinalityCap. When active, + // every column variable carries coefficient 1; leftover slacks are excluded so the cap + // limits pallets only. Used by pallet-count certification to test whether all demand fits + // in at most K pallets. + private Constraint? _cardinality; + /// Placeable SKUs that form the demand constraints. /// Exact demand per SKU; must contain every SKU in . public RestrictedMasterProblem(IReadOnlyList skuOrder, IReadOnlyDictionary demand) @@ -80,6 +86,7 @@ public Variable AddColumn(BnpColumn column) int a = column.CountOf(sku); if (a != 0) _demand[sku].SetCoefficient(v, a); } + _cardinality?.SetCoefficient(v, 1.0); _columns.Add((column, v)); _bySignature[column.Signature] = v; return v; @@ -103,6 +110,37 @@ public void AddColumns(IEnumerable columns) foreach (var c in columns) AddColumn(c); } + /// + /// Constrains the total pallet count Σ_t x_t ≤ . Created on first use + /// (back-filling coefficient 1 on every existing column); thereafter the cap is just + /// retightened. New columns added while the cap is active also receive coefficient 1. + /// + public void SetCardinalityCap(int k) + { + if (_cardinality == null) + { + _cardinality = _solver.MakeConstraint(double.NegativeInfinity, k, "cardinality"); + foreach (var (_, var) in _columns) _cardinality.SetCoefficient(var, 1.0); + } + else + { + _cardinality.SetUb(k); + } + } + + /// Relaxes the cardinality cap (ub +∞) so the uncapped master is recovered. + public void ClearCardinalityCap() => _cardinality?.SetUb(double.PositiveInfinity); + + /// True while a finite cardinality cap is in force (its dual then shifts pricing). + public bool IsCardinalityCapped => _cardinality != null && !double.IsPositiveInfinity(_cardinality.Ub()); + + /// + /// Dual price μ of the cardinality cap from the most recent solve (0 when no cap is set). + /// A binding ≤ cap on a minimization yields μ ≤ 0; it enters every column's reduced cost + /// (1 − Σ a·π − μ), so the pricer's improving threshold rises from 1 to 1 − μ. + /// + public double CardinalityDual() => _cardinality?.DualValue() ?? 0.0; + public Solver.ResultStatus Solve() => _solver.Solve(); /// Objective value Σ x_t of the most recent solve. diff --git a/Stack-Solver.Infrastructure/DatabaseInitializer.cs b/Stack-Solver.Infrastructure/DatabaseInitializer.cs index 31e83e6..8592d87 100644 --- a/Stack-Solver.Infrastructure/DatabaseInitializer.cs +++ b/Stack-Solver.Infrastructure/DatabaseInitializer.cs @@ -1,6 +1,8 @@ +using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Stack_Solver.Data; +using System.IO; namespace Stack_Solver.Services { @@ -14,12 +16,58 @@ public class DatabaseInitializer(IDbContextFactory factory public async Task InitializeAsync(CancellationToken ct = default) { logger.LogInformation("Initializing database"); + // SQLite creates the database file but not its parent directory; ensure the app-data + // folder exists first, or MigrateAsync throws "unable to open database file" on a + // machine where it does not yet exist (e.g. a clean install). + AppPaths.EnsureAppData(); + + try + { + await MigrateAsync(ct); + } + catch (SqliteException ex) when (ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase)) + { + // The on-disk database has application tables but no matching migration-history + // entry — a limbo state left by an interrupted earlier run (schema created but not + // recorded), which migrations cannot advance over. Back the file up and rebuild from + // scratch: the local DB only caches the user's SKU library, and the backup preserves + // it for manual recovery. + logger.LogWarning(ex, "Database is in an inconsistent migration state; rebuilding it (previous file backed up)"); + BackupDatabaseFile(); + await MigrateAsync(ct); + } + + logger.LogInformation("Database initialization completed"); + } + + // Apply any pending EF Core migrations. This both creates the database on first run and + // upgrades an existing user's database in-place on app updates, preserving data. (Do not use + // EnsureCreated here — it cannot evolve an existing schema.) + private async Task MigrateAsync(CancellationToken ct) + { await using var db = await factory.CreateDbContextAsync(ct); - // Apply any pending EF Core migrations. This both creates the database on first run - // and upgrades an existing user's database in-place on app updates, preserving data. - // (Do not use EnsureCreated here — it cannot evolve an existing schema.) await db.Database.MigrateAsync(ct); - logger.LogInformation("Database initialization completed"); + } + + // Rename the inconsistent database (and its journal files) aside so a fresh one can be built. + private void BackupDatabaseFile() + { + // Release the file handle the connection pool is holding, or the move fails. + SqliteConnection.ClearAllPools(); + + string path = AppPaths.DatabaseFile; + if (!File.Exists(path)) + return; + + string backup = Path.Combine(AppPaths.AppDataDirectory, $"stacksolver.corrupt-{DateTime.Now:yyyyMMddHHmmss}.db"); + File.Move(path, backup, overwrite: true); + logger.LogWarning("Backed up inconsistent database to {Backup}", backup); + + // The old write-ahead-log/shared-memory files belong to the backed-up database; drop the + // stragglers so they cannot attach to the freshly created one. + foreach (string sidecar in new[] { path + "-wal", path + "-shm" }) + if (File.Exists(sidecar)) + File.Delete(sidecar); } } -} \ No newline at end of file +} diff --git a/Tests/Stack-Solver.Tests/Services/BranchAndPrice/CombinatorialBoundTests.cs b/Tests/Stack-Solver.Tests/Services/BranchAndPrice/CombinatorialBoundTests.cs new file mode 100644 index 0000000..51afc19 --- /dev/null +++ b/Tests/Stack-Solver.Tests/Services/BranchAndPrice/CombinatorialBoundTests.cs @@ -0,0 +1,63 @@ +using Stack_Solver.Models; +using Stack_Solver.Models.Supports; +using Stack_Solver.Services.BranchAndPrice; + +namespace Services.BranchAndPrice +{ + /// + /// The combinatorial bound must be a valid (never-exceeding) lower bound on the pallet count, + /// and must take the stronger of the weight and volume relaxations. + /// + public class CombinatorialBoundTests + { + // A 120×90 surface on a 14-tall pallet capped at 180 tall leaves 166 of stack height. + private static Pallet Pallet() => new("P", 120, 90, 14) { MaxStackHeight = 180, MaxStackWeight = 1000 }; + + private static Dictionary Skus(params SKU[] skus) => + skus.ToDictionary(s => s.SkuId, s => s, StringComparer.Ordinal); + + [Fact] + public void VolumeBinding_DominatesWhenLight() + { + var pallet = Pallet(); + // One box exactly fills the pallet's footprint (120×90) and half its stack height (83). + // Two such boxes = exactly one pallet of volume → bound 1.0; three → 1.5. + var sku = new SKU { SkuId = "A", Name = "A", Length = 120, Width = 90, Height = 83, Weight = 0.001 }; + + Assert.Equal(1.0, CombinatorialBound.Compute(new Dictionary { ["A"] = 2 }, Skus(sku), pallet), 3); + Assert.Equal(1.5, CombinatorialBound.Compute(new Dictionary { ["A"] = 3 }, Skus(sku), pallet), 3); + } + + [Fact] + public void WeightBinding_DominatesWhenHeavy() + { + var pallet = Pallet(); // MaxStackWeight = 1000 + // Tiny but heavy: volume is negligible, weight binds. 1500 kg / 1000 per pallet = 1.5. + var sku = new SKU { SkuId = "H", Name = "H", Length = 1, Width = 1, Height = 1, Weight = 500 }; + + Assert.Equal(1.5, CombinatorialBound.Compute(new Dictionary { ["H"] = 3 }, Skus(sku), pallet), 3); + } + + [Fact] + public void NeverExceedsAFeasiblePackedPalletCount() + { + var pallet = Pallet(); + // 10 boxes that each occupy a small fraction of a pallet: the bound must be < 1 + // (a single pallet can hold them), i.e. it never claims more pallets than needed. + var sku = new SKU { SkuId = "S", Name = "S", Length = 20, Width = 20, Height = 20, Weight = 1 }; + + double bound = CombinatorialBound.Compute(new Dictionary { ["S"] = 10 }, Skus(sku), pallet); + Assert.True(bound < 1.0, $"bound {bound} must not exceed the 1 pallet that holds 10 small boxes"); + } + + [Fact] + public void UnknownOrZeroDemandContributesNothing() + { + var pallet = Pallet(); + var sku = new SKU { SkuId = "A", Name = "A", Length = 120, Width = 90, Height = 83, Weight = 1 }; + + Assert.Equal(0.0, CombinatorialBound.Compute(new Dictionary { ["A"] = 0 }, Skus(sku), pallet), 6); + Assert.Equal(0.0, CombinatorialBound.Compute(new Dictionary { ["missing"] = 100 }, Skus(sku), pallet), 6); + } + } +} diff --git a/Tests/Stack-Solver.Tests/Services/BranchAndPrice/ConstructivePalletPricerTests.cs b/Tests/Stack-Solver.Tests/Services/BranchAndPrice/ConstructivePalletPricerTests.cs new file mode 100644 index 0000000..b091ced --- /dev/null +++ b/Tests/Stack-Solver.Tests/Services/BranchAndPrice/ConstructivePalletPricerTests.cs @@ -0,0 +1,87 @@ +using Stack_Solver.Models; +using Stack_Solver.Models.Supports; +using Stack_Solver.Services; +using Stack_Solver.Services.BranchAndPrice; + +namespace Services.BranchAndPrice +{ + /// + /// The constructive pricer builds a pallet bottom-up from boxes, so it can synthesize the merged + /// leftover layers the fixed layer pool never contains. The headline case is the user's 50/30/3 + /// instance (A ×50, B ×30, D ×3): a single pallet holds all demand, but only if leftovers of + /// different SKUs may share a layer — exactly what this pricer enables. + /// + public class ConstructivePalletPricerTests + { + // Same height + uniform material ⇒ weight ∝ base area ⇒ equal load density, so the + // top-heavy stacking rule never blocks (and the 950 kg stack limit stays slack). + private static SKU Sku(string id, int length, int width) => new() + { + SkuId = id, + Name = id, + Length = length, + Width = width, + Height = 20, + Weight = length * width * 0.005, + Rotatable = true, + }; + + private static (List skus, Dictionary demand, Pallet pallet) Instance() + { + var skus = new List { Sku("A", 38, 23), Sku("B", 26, 13), Sku("D", 50, 30) }; + var demand = new Dictionary { ["A"] = 50, ["B"] = 30, ["D"] = 3 }; + var pallet = new Pallet("P", 120, 80, 14) + { + MaxStackHeight = 180, + MaxStackWeight = 950, + MaxTopHeavyPercent = 50, + MaxSkuOverhang = 0, + }; + return (skus, demand, pallet); + } + + [Fact] + public void FindColumns_FiveThirtyThree_WithDualsFavoringLeftovers_BuildsSingleZeroLeftoverPallet() + { + var (skus, demand, pallet) = Instance(); + var pricer = new ConstructivePalletPricer(skus, demand, pallet); + + // Duals favour the small-count leftovers (B, D) — the regime where the merged pallet + // is improving (Σ a·π = 50·0.02 + 30·0.05 + 3·0.05 = 2.65 > 1). + var duals = new Dictionary { ["A"] = 0.02, ["B"] = 0.05, ["D"] = 0.05 }; + + var columns = pricer.FindColumns(duals, forbidden: null, reducedCostThreshold: 1.0); + + Assert.NotEmpty(columns); + + // A zero-leftover pallet placing all demand must be among the built columns. + var full = columns.FirstOrDefault(c => + c.CountOf("A") == 50 && c.CountOf("B") == 30 && c.CountOf("D") == 3); + Assert.True(full != null, + "Expected a single pallet placing all demand; got: " + + string.Join("; ", columns.Select(c => c.Signature))); + + // Every layer is support-valid: each box rests entirely on the layer below. + var layers = full!.Template.Layers; + for (int i = 1; i < layers.Count; i++) + { + var support = LayerSupportAnalyzer.Analyze(layers[i - 1], layers[i], pallet); + Assert.Equal(0, support.TotalUnsupportedArea); + } + } + + [Fact] + public void FindColumns_IsDeterministicAcrossRuns() + { + var (skus, demand, pallet) = Instance(); + var duals = new Dictionary { ["A"] = 0.02, ["B"] = 0.05, ["D"] = 0.05 }; + + var first = new ConstructivePalletPricer(skus, demand, pallet).FindColumns(duals); + var second = new ConstructivePalletPricer(skus, demand, pallet).FindColumns(duals); + + Assert.Equal( + first.Select(c => c.Signature).ToList(), + second.Select(c => c.Signature).ToList()); + } + } +} diff --git a/Tests/Stack-Solver.Tests/Services/BranchAndPrice/ConstructivePricerIntegrationTests.cs b/Tests/Stack-Solver.Tests/Services/BranchAndPrice/ConstructivePricerIntegrationTests.cs new file mode 100644 index 0000000..4157377 --- /dev/null +++ b/Tests/Stack-Solver.Tests/Services/BranchAndPrice/ConstructivePricerIntegrationTests.cs @@ -0,0 +1,66 @@ +using Stack_Solver.Models; +using Stack_Solver.Models.Supports; +using Stack_Solver.Services.BranchAndPrice; +using Stack_Solver.Services.Layering; + +namespace Services.BranchAndPrice +{ + /// + /// End-to-end check of the Phase-1 fix for the user's 50/30/3 consolidation case (A ×50, + /// B ×30, D ×3). The fixed layer pool holds only single-SKU layers, so before the constructive + /// pricer the solver returned 2 pallets and (falsely) called it optimal — it could not merge the + /// leftover B and D with spare A capacity. The constructive pricer builds the merged single + /// pallet from boxes, which the pallet-count certifier then proves optimal at K=1 (a Found-at-K + /// existence witness, sound regardless of layer completeness). + /// + public class ConstructivePricerIntegrationTests + { + // Same height + uniform material ⇒ weight ∝ base area ⇒ equal load density (top-heavy rule + // slack) and a slack 950 kg stack limit — matching the mergeable pallet the user observed. + private static SKU Sku(string id, int length, int width, int qty) => new() + { + SkuId = id, + Name = id, + Length = length, + Width = width, + Height = 20, + Weight = length * width * 0.005, + Rotatable = true, + Quantity = qty, + }; + + [Fact] + public void Solve_FiveThirtyThree_ConsolidatesToOneCertifiedPallet() + { + var skus = new List { Sku("A", 38, 23, 50), Sku("B", 26, 13, 30), Sku("D", 50, 30, 3) }; + var demand = new Dictionary { ["A"] = 50, ["B"] = 30, ["D"] = 3 }; + var pallet = new Pallet("P", 120, 80, 14) + { + MaxStackHeight = 180, + MaxStackWeight = 950, + MaxTopHeavyPercent = 50, + MaxSkuOverhang = 0, + }; + + var layers = new HomogeneousGenerationStrategy().Generate(skus, pallet, new GenerationOptions()); + + var solution = BranchAndPriceAssignmentService.Solve( + layers, demand, pallet, new GenerationOptions(), ct: TestContext.Current.CancellationToken); + + string stats = solution.Stats!.ToString(); + + // All demand placed, nothing left over. + foreach (var (sku, qty) in demand) + { + int placed = solution.Result.Assignments.Sum(a => a.Template.SkuCounts.GetValueOrDefault(sku) * a.Count); + Assert.Equal(qty, placed); + } + Assert.Empty(solution.Result.Leftovers); + + // The headline: a single pallet, proven optimal — not the old false "2, optimal". + Assert.Equal(1, solution.Pallets); + Assert.True(solution.LowerBoundCertified, stats); + Assert.True(solution.Stats!.ConstructivePricerColumns > 0, stats); + } + } +} diff --git a/Tests/Stack-Solver.Tests/Services/BranchAndPrice/ExactPricingSolverTests.cs b/Tests/Stack-Solver.Tests/Services/BranchAndPrice/ExactPricingSolverTests.cs index ce5774f..346fdd5 100644 --- a/Tests/Stack-Solver.Tests/Services/BranchAndPrice/ExactPricingSolverTests.cs +++ b/Tests/Stack-Solver.Tests/Services/BranchAndPrice/ExactPricingSolverTests.cs @@ -53,6 +53,26 @@ public void FindBestColumn_ReturnsImprovingColumnAndCompletesExhaustively() Assert.True(exact.LastSearchExhaustive); } + [Fact] + public void FindBestColumn_RaisedThreshold_SuppressesAColumnThatPricesOutAtOne() + { + var (layers, pallet) = TwoSkuLayers(); + var duals = new Dictionary { ["A"] = 0.1, ["B"] = 0.1 }; + var exact = new ExactPricingSolver(layers, pallet); + + // At the default threshold the best column is improving (value > 1). + var atOne = exact.FindBestColumn(duals, reducedCostThreshold: 1.0); + Assert.NotNull(atOne); + double bestValue = Value(atOne!, duals); + Assert.True(bestValue > 1.0); + + // Raising the threshold above that value (as a cardinality dual would) makes it + // non-improving, so nothing is returned — but the search is still exhaustive. + var raised = exact.FindBestColumn(duals, reducedCostThreshold: bestValue + 1.0); + Assert.Null(raised); + Assert.True(exact.LastSearchExhaustive); + } + [Fact] public void FindBestColumn_IsNeverWorseThanHeuristic() { diff --git a/Tests/Stack-Solver.Tests/Services/BranchAndPrice/PalletCountCertificationTests.cs b/Tests/Stack-Solver.Tests/Services/BranchAndPrice/PalletCountCertificationTests.cs new file mode 100644 index 0000000..f450fa4 --- /dev/null +++ b/Tests/Stack-Solver.Tests/Services/BranchAndPrice/PalletCountCertificationTests.cs @@ -0,0 +1,82 @@ +using Stack_Solver.Models; +using Stack_Solver.Models.Supports; +using Stack_Solver.Services.BranchAndPrice; +using Stack_Solver.Services.Layering; + +namespace Services.BranchAndPrice +{ + /// + /// Pallet-count (cardinality) certification closes the bin-packing "+1 round-up" gap that the + /// ⌈LP-bound⌉ short-circuit and the uncapped tree cannot. The gap needs a large-capacity layer + /// (so the volumetric LP bound rounds down) together with a hard reason the demand still cannot + /// share that few pallets. Here the blocker is the distinct-SKU-per-template cap (3): with one + /// height-limited layer per pallet and a full layer holding nine boxes, the LP meets each unit + /// of demand with a fraction of a homogeneous column (bound = SKUs/9 ⇒ ⌈⌉ = 1), yet a single + /// pallet can carry at most three distinct SKUs — so more than three SKUs cannot fit in one. + /// (Merging different SKUs into one layer is exactly what the constructive pricer now does, so + /// the old "two single-SKU layers can't share a pallet" premise is no longer a valid blocker.) + /// + public class PalletCountCertificationTests + { + // A full 9-box layer fits the pallet (3×3), so a homogeneous column covers 9 — far more than + // the demand of 1. Quantity 9 lets that full layer be generated. One 10-high layer fits the + // 24−14 = 10 of stack height; two do not. + private static SKU Box(string id) => new() + { + SkuId = id, Name = id, Length = 40, Width = 30, Height = 10, Quantity = 9, Rotatable = false, + }; + + private static Pallet OneLayerPallet() => new("P", 120, 90, 14) { MaxStackHeight = 24 }; + + [Fact] + public void Solve_FourSkusExceedingTheDistinctCap_CertifiesTwoPallets() + { + // Four SKUs, one unit each: a single pallet's lone layer admits at most three distinct + // SKUs, so the fourth forces a second pallet. The LP bound is only 4/9 ⇒ ⌈⌉ = 1. + var skus = new List { Box("A"), Box("B"), Box("C"), Box("D") }; + var pallet = OneLayerPallet(); + var demand = new Dictionary { ["A"] = 1, ["B"] = 1, ["C"] = 1, ["D"] = 1 }; + var layers = new HomogeneousGenerationStrategy().Generate(skus, pallet, new GenerationOptions()); + + var solution = BranchAndPriceAssignmentService.Solve( + layers, demand, pallet, new GenerationOptions(), ct: TestContext.Current.CancellationToken); + + foreach (var sku in demand.Keys) + { + int placed = solution.Result.Assignments.Sum(a => a.Template.SkuCounts.GetValueOrDefault(sku) * a.Count); + Assert.Equal(1, placed); + } + Assert.Empty(solution.Result.Leftovers); + + // The headline: a proven optimum of 2, certified by pallet-count branching (the LP bound + // alone is 4/9 → ⌈⌉ = 1 and could not certify this). + string stats = solution.Stats!.ToString(); + Assert.Equal(2, solution.Pallets); + Assert.True(solution.LowerBoundCertified, stats); + Assert.True(solution.Stats!.PalletCountCertified, stats); + Assert.True(solution.Stats.MaxPalletCountTested >= 1, stats); + } + + [Fact] + public void Solve_SevenSkusExceedingTheDistinctCap_CertifiesThreePalletsAcrossMultipleCounts() + { + // Seven SKUs, one unit each, ≤ 3 distinct per pallet ⇒ ⌈7/3⌉ = 3 pallets. The LP bound is + // 7/9 ⇒ ⌈⌉ = 1, so the certifier must reject K=1 and K=2 (both leave a SKU uncovered) + // before concluding the incumbent of 3 is optimal. + var skus = new List { Box("A"), Box("B"), Box("C"), Box("D"), Box("E"), Box("F"), Box("G") }; + var pallet = OneLayerPallet(); + var demand = skus.ToDictionary(s => s.SkuId, _ => 1, StringComparer.Ordinal); + var layers = new HomogeneousGenerationStrategy().Generate(skus, pallet, new GenerationOptions()); + + var solution = BranchAndPriceAssignmentService.Solve( + layers, demand, pallet, new GenerationOptions(), ct: TestContext.Current.CancellationToken); + + Assert.Empty(solution.Result.Leftovers); + string stats = solution.Stats!.ToString(); + Assert.Equal(3, solution.Pallets); + Assert.True(solution.LowerBoundCertified, stats); + Assert.True(solution.Stats!.PalletCountCertified, stats); + Assert.True(solution.Stats.MaxPalletCountTested >= 2, stats); // tested K=1 and K=2 + } + } +} diff --git a/Tests/Stack-Solver.Tests/Services/BranchAndPrice/RestrictedMasterHeuristicTests.cs b/Tests/Stack-Solver.Tests/Services/BranchAndPrice/RestrictedMasterHeuristicTests.cs new file mode 100644 index 0000000..bb3b075 --- /dev/null +++ b/Tests/Stack-Solver.Tests/Services/BranchAndPrice/RestrictedMasterHeuristicTests.cs @@ -0,0 +1,63 @@ +using Stack_Solver.Models.Supports; +using Stack_Solver.Services.BranchAndPrice; + +namespace Services.BranchAndPrice +{ + /// + /// The restricted-master IP must return the minimum-pallet integer assignment expressible in + /// the given column pool, and honestly report any demand the pool cannot cover. These tests + /// also confirm a MIP backend (SCIP/CBC) is present in the build. + /// + public class RestrictedMasterHeuristicTests + { + private static BnpColumn Col(params (string Sku, int Count)[] counts) => + new(new PalletTemplate + { + SkuCounts = counts.ToDictionary(c => c.Sku, c => c.Count, StringComparer.Ordinal) + }); + + private static readonly TimeSpan Limit = TimeSpan.FromSeconds(5); + + [Fact] + public void PrefersTheSinglePalletThatCoversAllDemand() + { + // Two pure pallets (2 pallets) vs one mixed full-capacity pallet (1 pallet). + var columns = new[] { Col(("A", 10)), Col(("B", 10)), Col(("A", 10), ("B", 10)) }; + var demand = new Dictionary { ["A"] = 10, ["B"] = 10 }; + + var result = RestrictedMasterHeuristic.Solve(columns, ["A", "B"], demand, Limit, TestContext.Current.CancellationToken); + + Assert.NotNull(result); // a null here would mean no MIP backend loaded + Assert.Empty(result.Leftovers); + Assert.Equal(1, result.Columns.Sum(c => c.Count)); + } + + [Fact] + public void MinimizesPalletCountOverTheColumnChoices() + { + // Covering 10 of A: one big pallet beats two small ones. + var columns = new[] { Col(("A", 5)), Col(("A", 10)) }; + var demand = new Dictionary { ["A"] = 10 }; + + var result = RestrictedMasterHeuristic.Solve(columns, ["A"], demand, Limit, TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.Empty(result.Leftovers); + Assert.Equal(1, result.Columns.Sum(c => c.Count)); + } + + [Fact] + public void ReportsLeftoverWhenThePoolCannotCoverDemand() + { + // No column carries B, so B is reported as unmet rather than silently dropped. + var columns = new[] { Col(("A", 10)) }; + var demand = new Dictionary { ["A"] = 10, ["B"] = 7 }; + + var result = RestrictedMasterHeuristic.Solve(columns, ["A", "B"], demand, Limit, TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.Equal(7, result.Leftovers["B"]); + Assert.False(result.Leftovers.ContainsKey("A")); + } + } +} diff --git a/Tests/Stack-Solver.Tests/Services/BranchAndPrice/RestrictedMasterProblemTests.cs b/Tests/Stack-Solver.Tests/Services/BranchAndPrice/RestrictedMasterProblemTests.cs new file mode 100644 index 0000000..956853f --- /dev/null +++ b/Tests/Stack-Solver.Tests/Services/BranchAndPrice/RestrictedMasterProblemTests.cs @@ -0,0 +1,68 @@ +using Stack_Solver.Models.Supports; +using Stack_Solver.Services.BranchAndPrice; + +namespace Services.BranchAndPrice +{ + /// + /// The cardinality cap Σ_t x_t ≤ K underpins pallet-count certification: it must bind when + /// set (forcing leftover when fewer pallets cannot cover demand), yield a dual that raises the + /// pricer's improving threshold (1 − μ ≥ 1), apply to columns added after it is set, and be + /// fully reversible. + /// + public class RestrictedMasterProblemTests + { + private static BnpColumn Col(params (string Sku, int Count)[] counts) => + new(new PalletTemplate + { + SkuCounts = counts.ToDictionary(c => c.Sku, c => c.Count, StringComparer.Ordinal) + }); + + [Fact] + public void CardinalityCap_BindsAndReverses() + { + // Two pure pallets cover A=10, B=10 in exactly 2 pallets. + var demand = new Dictionary { ["A"] = 10, ["B"] = 10 }; + using var rmp = new RestrictedMasterProblem(["A", "B"], demand); + rmp.AddColumns([Col(("A", 10)), Col(("B", 10))]); + + rmp.Solve(); + Assert.False(rmp.IsCardinalityCapped); + Assert.Equal(2.0, rmp.PalletSum(), 6); + Assert.Empty(rmp.Leftovers()); + + // Cap to one pallet: only one SKU can be fully covered, the other is forced leftover, + // and the cap binds (Σx ≤ 1) so its dual makes columns price harder (1 − μ ≥ 1). + rmp.SetCardinalityCap(1); + rmp.Solve(); + Assert.True(rmp.IsCardinalityCapped); + Assert.True(rmp.PalletSum() <= 1.0 + 1e-6); + Assert.NotEmpty(rmp.Leftovers()); + Assert.True(rmp.CardinalityDual() <= 1e-6); // μ ≤ 0 ⇒ threshold 1 − μ ≥ 1 + + // Relaxing the cap recovers the uncapped two-pallet optimum. + rmp.ClearCardinalityCap(); + rmp.Solve(); + Assert.False(rmp.IsCardinalityCapped); + Assert.Equal(2.0, rmp.PalletSum(), 6); + Assert.Empty(rmp.Leftovers()); + } + + [Fact] + public void CardinalityCap_AppliesToColumnsAddedAfterItIsSet() + { + var demand = new Dictionary { ["A"] = 10, ["B"] = 10 }; + using var rmp = new RestrictedMasterProblem(["A", "B"], demand); + rmp.AddColumns([Col(("A", 10)), Col(("B", 10))]); + + rmp.SetCardinalityCap(1); + // A mixed pallet covering all demand is added AFTER the cap; it must still be counted + // by the cap, so a single one of it satisfies Σx ≤ 1 with no leftover. + rmp.AddColumn(Col(("A", 10), ("B", 10))); + rmp.Solve(); + + Assert.True(rmp.PalletSum() <= 1.0 + 1e-6); + Assert.Equal(1.0, rmp.PalletSum(), 6); + Assert.Empty(rmp.Leftovers()); + } + } +} diff --git a/ViewModels/Pages/ResultsViewModel.cs b/ViewModels/Pages/ResultsViewModel.cs index bbd7227..4c05b82 100644 --- a/ViewModels/Pages/ResultsViewModel.cs +++ b/ViewModels/Pages/ResultsViewModel.cs @@ -327,12 +327,12 @@ private List BuildSolutions( ct.ThrowIfCancellationRequested(); - AssignmentResult? bnpResult = null; + BranchAndPriceSolution? bnp = null; if (_useBranchAndPrice) { try { - bnpResult = BranchAndPriceAssignmentService.Assign(filtered, demand, pallet, options, greedy, ct); + bnp = BranchAndPriceAssignmentService.Solve(filtered, demand, pallet, options, greedy, ct); } catch (OperationCanceledException) { throw; } catch { /* Branch-and-price is best-effort; other results still shown */ } @@ -341,8 +341,9 @@ private List BuildSolutions( ct.ThrowIfCancellationRequested(); var result = new List(); - if (bnpResult != null && bnpResult.Assignments.Count > 0) - result.Add(new SolutionDisplay(result.Count + 1, "Branch & Price", bnpResult, skus, _palletLength, _palletWidth, _palletHeight)); + if (bnp != null && bnp.Result.Assignments.Count > 0) + result.Add(new SolutionDisplay(result.Count + 1, "Branch & Price", bnp.Result, skus, _palletLength, _palletWidth, _palletHeight, + isProvenOptimal: bnp.LowerBoundCertified, lowerBound: bnp.LowerBound)); if (cpsatResult != null && cpsatResult.Assignments.Count > 0) result.Add(new SolutionDisplay(result.Count + 1, "CP-SAT", cpsatResult, skus, _palletLength, _palletWidth, _palletHeight)); if (greedy.Assignments.Count > 0) @@ -686,6 +687,15 @@ private string BuildSolutionText(SolutionDisplay s) sb.AppendLine($"Pallet types: {s.PalletTypes}"); sb.AppendLine($"Items packed: {s.TotalItemsPacked}"); sb.AppendLine($"Avg. utilization: {s.Efficiency}"); + if (s.IsProvenOptimal is bool optimal) + { + if (optimal) + sb.AppendLine("Solution quality: proven optimal"); + else if (s.LowerBound > 1e-9) + sb.AppendLine($"Solution quality: best found (not proven optimal; lower bound ⌈{s.LowerBound:0.##}⌉ = {Math.Ceiling(s.LowerBound - 1e-9):0} pallets)"); + else + sb.AppendLine("Solution quality: best found (not proven optimal)"); + } sb.AppendLine(); foreach (var a in Assignments) sb.AppendLine($" {a.Name} ×{a.Count} — {a.Contents}"); @@ -828,6 +838,12 @@ public partial class SolutionDisplay : ObservableObject public int TotalItemsPacked { get; } public string Efficiency { get; } + /// True/false when the solver reports optimality status (Branch & Price); null when not applicable. + public bool? IsProvenOptimal { get; } + + /// LP lower bound on the pallet count, when the solver provides one. + public double LowerBound { get; } + public SolutionDisplay( int number, string name, @@ -835,7 +851,9 @@ public SolutionDisplay( IReadOnlyList skus, int palletLength, int palletWidth, - int palletHeight) + int palletHeight, + bool? isProvenOptimal = null, + double? lowerBound = null) { Number = number; Name = name; @@ -844,6 +862,8 @@ public SolutionDisplay( PalletLength = palletLength; PalletWidth = palletWidth; PalletHeight = palletHeight; + IsProvenOptimal = isProvenOptimal; + LowerBound = lowerBound ?? 0; TotalPallets = result.TotalPallets; PalletTypes = result.Assignments.Count; TotalItemsPacked = result.Assignments.Sum(a => a.Template.TotalBoxCount * a.Count); diff --git a/docs/bnp-box-level-pricing-plan.md b/docs/bnp-box-level-pricing-plan.md new file mode 100644 index 0000000..e166761 --- /dev/null +++ b/docs/bnp-box-level-pricing-plan.md @@ -0,0 +1,147 @@ +# Box-level pricing: build pallets from boxes (support-aware finder + CP-SAT proof) + +> Status: APPROVED plan, not yet implemented. Builds on the (uncommitted) Stage-1 pallet-count +> branching on branch `perf/bnp-pallet-count`. Saved here for durability; working copy was at +> `~/.claude/plans/mossy-sleeping-nebula.md`. + +## Context + +Branch-and-price proves optimality only over a **fixed pre-generated layer set** — the pricers stack +existing layers, never building new ones. So leftovers of different SKUs can't share a layer, and the +solver returned **2 pallets "proven optimal"** for the user's 50/30/3 case (A 38×23×20 ×50, +B 26×13×20 ×30, D 50×30×20 ×3; pallet 120×80×14, max stack 180, max weight 950, 50% top-heavy, +0 overhang / 100% support) when 1 pallet is achievable by merging the leftover B and D — a **false +certificate**, the one outcome this project must never produce. + +The fix makes the **pricing subproblem build pallets from boxes**, guided by the SKU duals π_i, so it +can synthesize any support-valid layer/pallet — generalizing column generation to the full space of +physical pallets so the bound and certificate are sound over *all* pallets, not a subset. + +Decisions taken across review: +- **Finder = heuristic, support-aware, owns result geometry.** A constructive dual-guided pallet + builder generates each layer's boxes constrained to the occupancy of the layer below (so + arrangements like "2 side-by-side + 1 over the gap" are exploited), producing clean, support-valid + geometry. No CP-SAT in the result path. +- **Proof = CP-SAT, proof-only.** An exact dual-weighted CP-SAT solve runs sparingly to *certify*; + its arrangements are never displayed. +- **Bounded + honest.** Keep the ≤10-min budget; report `proven optimal = false` if the exact check + can't finish. + +Two phases. **Phase 1** (the constructive finder) fixes the wrong answer and — via Stage-1's +**Found-at-K** existence witness, which needs no layer-completeness — soundly certifies consolidation +cases like 50/30/3. **Phase 2** (CP-SAT exact check) makes the *infeasibility/LP-bound* certification +path globally sound. Builds on the uncommitted Stage-1 pallet-count branching (`perf/bnp-pallet-count`). + +## Phase 1 — Constructive support-aware dual-guided pallet pricer (the finder) + +New `ConstructivePalletPricer` (`Services/BranchAndPrice/`) that, given the duals, builds a pallet +**bottom-up from boxes**, returning improving columns (pallets where Σ_i a_i π_i > 1). It augments — +does not replace — the existing stacking pricers (keep those over the app's nice layer pool; the +constructive pricer adds the box-built columns the pool lacks). It fires when the pool pricers stall, +per node. + +Algorithm (reusing existing geometry machinery): +- Track a **support region** = occupancy grid of the current top (start = full pallet), plus + `usedHeight`, `usedWeight`, pallet SKU set, density-of-layer-below, and per-SKU remaining cap = d_i. +- **Build the next layer**: greedily place box variants (`SkuVariantFactory.CreateAllOrientations`), + preferring high-π_i SKUs, at grid positions (grid = gcd of dims, as `CPSATGenerationStrategy` / + `LayerGeometryBuilder` use) where the box's footprint cells lie **entirely within the support + region** (100% support) and don't overlap boxes already in this layer — respecting remaining cap, + the `MaxDistinctSkusPerTemplate` cap, `usedWeight ≤ MaxStackWeight`, `usedHeight + boxHeight ≤ + availHeight`, and the top-heavy density rule (`StackingLoadRule`: new layer density ≤ below × + (1+tol)). Row-major / shelf placement keeps the geometry clean for display. +- Add the layer (`Layer` built via `LayerGeometryBuilder` + `LayerMetricsCalculator`, so it + materializes and renders like any other), set support region = its occupancy, repeat until height + is exhausted or no box can be placed. +- Emit the pallet as a `BnpColumn`; if Σ a_i π_i > 1 it is an improving column. Run a few attempts + (varying SKU priority / orientation order) for diversity. + +**Support-awareness** is the occupancy-region constraint above: layer k+1's boxes must sit on layer +k's occupied cells. This is the same notion `LayerSupportAnalyzer` already encodes (occupancy grids ++ cell coverage); the finder enforces it *while placing*, so it discovers support-coupled +arrangements the old isolated generation missed. + +**Wiring.** Derive the `SKU` set from the layers via the existing `BuildSkuMap(layers)` pattern (box +dims/weight/rotatable) and hand it + pallet + demand caps to `BranchAndPriceSearch`; call the +constructive pricer inside `SolveNode`'s CG loop after the stacking pricers find nothing. Columns +flow through the master unchanged. + +**Why 50/30/3 is fixed soundly.** Under the K=1 cap, the constructive pricer builds the single pallet +(5 full A layers, 1 full B layer, a merged 5A+6B layer on top, a 3D layer) — zero leftover → +**Found at K=1 → optimum 1**. Found is an existence witness, so the certificate is globally sound +regardless of layer completeness. + +## Phase 2 — CP-SAT exact dual-weighted check (global certificate, proof-only) + +At node convergence, run **one** CP-SAT solve that bounds the maximum pallet dual value Σ_i a_i π_i +over all valid stacked pallets (reuse `CPSATGenerationStrategy`'s candidate/non-overlap/quantity +model with objective `Maximize(Σ π·use)`, extended to the stacked-pallet constraints). If the proven +max ≤ 1 → no improving column → the node's LP bound is a valid *global* lower bound → certify. If it +finds a pallet > 1 → a missed improving column → add and continue CG. Timeout → uncertified (honest). +Dropping the hardest constraint (100% inter-layer support) only *loosens* an upper bound, so the +check stays **sound** even relaxed (certifies fewer nodes, never falsely); tightening support is an +incremental follow-up. This replaces the pool-relative exhaustiveness signal +(`ExactPricingSolver.LastSearchExhaustive` → `_allCertified`) for the LP-bound certification paths. + +## Soundness scope (stated honestly) + +After both phases, "proven optimal" is sound over the **grid-based packing model** (placements on the +gcd-of-dimensions grid) — far richer than today's fixed layer set and matching the app's existing +packing fidelity, but not full continuous-geometry optimality (out of scope throughout this codebase). + +## Critical files + +- `Stack-Solver.Core/Services/BranchAndPrice/ConstructivePalletPricer.cs` (new, Phase 1) — the + support-aware constructive finder; reuses `SkuVariantFactory`, `LayerGeometryBuilder`, + `LayerMetricsCalculator`, `StackingLoadRule`, `PricingRules`, occupancy grids. +- `Stack-Solver.Core/Services/BranchAndPrice/BranchAndPriceSearch.cs` — call the constructive pricer + in `SolveNode`'s CG loop; (Phase 2) swap the certification signal. +- `Stack-Solver.Core/Services/BranchAndPrice/BranchAndPriceAssignmentService.cs` — pass SKUs/pallet/ + demand caps to the search. +- `Stack-Solver.Core/Services/BranchAndPrice/ExactPalletPricer.cs` (new, Phase 2) — CP-SAT + certification model, reusing `CPSATGenerationStrategy`'s formulation with a dual objective. +- Existing stacking pricers (`PricingSolver`, `ExactPricingSolver`) stay as-is (additive change). + +## Verification + +- **Unit** — `ConstructivePalletPricer`: for the 50/30/3 SKUs and demand caps, with duals favoring + B/D it builds a single zero-leftover pallet placing all demand; every layer is support-valid (each + box's cells lie on the layer below per `LayerSupportAnalyzer.Analyze`); deterministic across runs. +- **Integration (headline)** — the exact 50/30/3 instance via `Solve` returns **1 pallet, all demand + placed, no leftover, `LowerBoundCertified == true`** (Phase 1, Found-at-K). Box weights weren't + given; the test sets them so 950 kg is non-binding (matching the observed mergeable pallet) — + confirm real weights if the limit should bind. +- **Support-coupling** — a small crafted instance where the optimum needs boxes placed over a partial + layer's gap (the 2+1 case): the constructive pricer finds it; a from-isolation generation does not. +- **Differential** — same instance with the constructive pricer disabled still returns 2, pinning the + fix; **Phase 2**: an optimum certified only by lower-count infeasibility is `LowerBoundCertified` + only with the CP-SAT check, and the check rejects a hand-built infeasible "improving" pallet. +- **Regression** — `dotnet test`, all existing tests (74 + Stage-1) pass (additive). +- **Manual** — rerun 50/30/3 in the app: 1 pallet, proven optimal, clean geometry; confirm a medium + instance (3 SKUs ~100 each) stays within the 10-min budget. + +## Out of scope / follow-ups + +- Tightening Phase-2 support encoding toward exact; continuous (non-grid) optimality. +- CP-SAT latency tuning (time slices, caching across nodes/duals) for the 10-min target. +- `LayerPackingHeuristic` using the constructive builder for a tighter initial incumbent. +- Commit Stage-1 pallet-count branching together with Phase 1. + +## Resume notes (where we left off) + +- **Stage 1 (pallet-count branching): implemented, all 74 tests green, NOT committed**, on branch + `perf/bnp-pallet-count` (off `perf/bnp-pricing`). Files touched: `RestrictedMasterProblem` + (cardinality cap), `PricingSolver`/`ExactPricingSolver` (reducedCostThreshold param), + `BranchAndPriceSearch` (`CertifyByPalletCount` + `SearchCappedZeroLeftover`), `BranchAndPriceStats`, + plus tests (`RestrictedMasterProblemTests`, `PalletCountCertificationTests`, threshold test). +- **Phase 1 of THIS plan: not started.** APIs already gathered for the constructive pricer — + `SkuVariant`(VariantId,Sku,SpanX,SpanY,Rotated) via `SkuVariantFactory.CreateAllOrientations`; + `PositionedItem(SKU,x,y,rotated)` with `GetXSpan`/`GetYSpan` (X=Length, Y=Width unless rotated); + `LayerGeometryBuilder.Build(layer, supportSurface, gridStep)` → `OccupancyGrid[y,x]`; + `LayerMetricsCalculator.Compute` → `LoadDensity = TotalWeight/FootprintArea`; + `LayerMetadata(utilization,height,description)`; `Layer(name, List, metadata)`; + `StackingLoadRule.Allows(lowerDensity, upperDensity, tolerance)`; `Pallet.AvailHeight = + MaxStackHeight - Height`, `MaxStackWeight`, `LoadDensityTolerance = MaxTopHeavyPercent/100`, + `OverhangRule`; `PricingRules.MaxDistinctSkusPerTemplate = 3`; `BnpColumn(PalletTemplate)` with + SKU-count signature; `PalletTemplate.FromLayers(layers)`. Box weights for the 50/30/3 instance were + NOT provided — set non-binding in tests unless the user supplies them.