From dcfafc7269ce2d6cddaf715da6081cdf5dbb3123 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Fri, 10 Jul 2026 20:27:35 -0500 Subject: [PATCH 1/4] feat: separation-effort tiers (fast/max) + PreparedParts + faster dev tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Performance + density work (docs/04-performance-remediation-plan.md): - perf(build): [profile.dev] opt-level=2 — the engine is compute-bound, so `cargo test` ran ~12x slow at opt-level 0 (golden test 633s -> 51s). Byte-identical (opt-level is codegen-only, no IEEE change); goldens unchanged. - feat(sep): SeparationEffort knob (NestConfig field + Python separation_effort=). * Fast: area-conservation hopeless-skip + reduced separator budget for over-subscribed packs — 15x (NFP) / 18x (sampling) on the mixed-13 pack at <=1 part cost. Opt-in; Full default byte-identical; two new golden cases. * Max: densification/compaction — shrink the sheet along the slack axis and re-separate the whole layout to close inter-part gaps, maximizing the usable remnant (v1: +2.8pp remnant on a cone case; the local search plateaus, and a stronger global search is the documented next step). - feat(api): PreparedParts (prepare()/nest_with_prepared() + Python ironnest.prepare()) — reuse the container-independent item import + O(P^2) NFP table across calls (part-in-part): 11.8x on 15 containers, byte-identical (test prepared_equals_nest_with_config_byte_for_byte). - perf(nfp): buffer hoisted to one clone per type in place_pass; candidates() capacity hints. Byte-identical. - diagnostic examples: perf_diag, prepared_bench, density_probe, density_remnant. All 77 optimizer tests pass; workspace clippy -D warnings clean (incl. --features parallel); cross-platform golden re-blessed for the new Fast cases (existing cases byte-identical, verified by direct snapshot diff). Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.toml | 12 + crates/ironnest/src/lib.rs | 8 +- crates/optimizer/examples/bench.rs | 5 +- crates/optimizer/examples/density_probe.rs | 133 +++++ crates/optimizer/examples/density_remnant.rs | 147 +++++ crates/optimizer/examples/nfp_policy_ab.rs | 5 +- crates/optimizer/examples/perf_diag.rs | 107 ++++ crates/optimizer/examples/prepared_bench.rs | 112 ++++ crates/optimizer/src/bin/golden_dump.rs | 47 +- crates/optimizer/src/lib.rs | 505 ++++++++++++++++-- crates/optimizer/src/nfp/mod.rs | 23 +- crates/optimizer/src/nfp/place.rs | 16 +- crates/optimizer/src/sep/mod.rs | 87 ++- crates/optimizer/tests/nfp_mode.rs | 86 ++- ...den__golden_placements_match_snapshot.snap | 18 +- crates/py/src/lib.rs | 206 ++++++- docs/04-performance-remediation-plan.md | 358 +++++++++++++ 17 files changed, 1794 insertions(+), 81 deletions(-) create mode 100644 crates/optimizer/examples/density_probe.rs create mode 100644 crates/optimizer/examples/density_remnant.rs create mode 100644 crates/optimizer/examples/perf_diag.rs create mode 100644 crates/optimizer/examples/prepared_bench.rs create mode 100644 docs/04-performance-remediation-plan.md diff --git a/Cargo.toml b/Cargo.toml index b4dd347..4fb929b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,18 @@ pyo3 = { version = "0.29", features = ["abi3-py313", "extension-module"] } codegen-units = 1 lto = "thin" +# The engine is compute-bound (tight geometry / collision-detection / separation-search loops), so an +# UNOPTIMIZED build runs ~10-12× slower on real nests — which is what made `cargo test` painfully slow +# (measured: the golden test ran golden_dump ×3 at opt-level 0 in ~633 s; at opt-level 2 it is ~51 s). +# opt-level is a code-generation choice ONLY: it never enables fast-math or changes IEEE `+ − × ÷ sqrt` +# results, so placements stay byte-identical and the goldens pass unchanged (the determinism contract +# is unaffected — that lever is codegen-units/target-cpu, pinned above for the *shipped* artifact, and +# deliberately NOT set here so dev/test incremental compiles stay fast). `[profile.test]` inherits this. +# opt-level 3 measured no faster than 2 here; the residual test time is the separation-search tail +# doing real work (docs/04), which optimization can only speed so far — the deeper lever is the engine. +[profile.dev] +opt-level = 2 + # Make the determinism prime directive a build gate. clippy.toml supplies the banned lists; # this enables the lints. Engine crates opt in with `[lints] workspace = true` (py does not, # to avoid false positives from pyo3 macro expansion). Promoted to errors by `-D warnings`. diff --git a/crates/ironnest/src/lib.rs b/crates/ironnest/src/lib.rs index a387c67..d3e48dc 100644 --- a/crates/ironnest/src/lib.rs +++ b/crates/ironnest/src/lib.rs @@ -19,8 +19,8 @@ #[doc(inline)] pub use ironnest_optimizer::{ - MultiSheetSolution, NestConfig, NestError, NestSolution, Placement, PlacementStrategy, Scalar, - Sheet, nest, nest_multi, nest_multi_multistart, nest_multi_multistart_per_item, - nest_multi_per_item, nest_multi_with_config, nest_multistart, nest_multistart_per_item, - nest_per_item, nest_with_config, + MultiSheetSolution, NestConfig, NestError, NestSolution, Placement, PlacementStrategy, + PreparedParts, Scalar, SeparationEffort, Sheet, nest, nest_multi, nest_multi_multistart, + nest_multi_multistart_per_item, nest_multi_per_item, nest_multi_with_config, nest_multistart, + nest_multistart_per_item, nest_per_item, nest_with_config, nest_with_prepared, prepare, }; diff --git a/crates/optimizer/examples/bench.rs b/crates/optimizer/examples/bench.rs index 157f720..e3dc933 100644 --- a/crates/optimizer/examples/bench.rs +++ b/crates/optimizer/examples/bench.rs @@ -14,7 +14,8 @@ //! budget)`. Timing is reported only to stderr-style stdout text; it is NOT part of any golden. use ironnest_optimizer::{ - NestConfig, PlacementStrategy, Scalar, nest, nest_multistart, nest_with_config, + NestConfig, PlacementStrategy, Scalar, SeparationEffort, nest, nest_multistart, + nest_with_config, }; fn rect(w: Scalar, h: Scalar) -> Vec<[Scalar; 2]> { @@ -255,6 +256,7 @@ fn nfp_cache_probe() { budget: 1, restarts: 1, strategy: ironnest_optimizer::PlacementStrategy::Nfp, + separation_effort: ironnest_optimizer::SeparationEffort::Full, }; let mut pair_count = 0usize; let ms = best_ms(3, || { @@ -291,6 +293,7 @@ fn nfp_probe( budget: 2000, // drives only the sampling fallback + the sep tail's insertion attempts restarts: k, strategy: PlacementStrategy::Nfp, + separation_effort: SeparationEffort::Full, }; let mut placed = 0usize; let mut placed_area = 0.0; diff --git a/crates/optimizer/examples/density_probe.rs b/crates/optimizer/examples/density_probe.rs new file mode 100644 index 0000000..a0044f7 --- /dev/null +++ b/crates/optimizer/examples/density_probe.rs @@ -0,0 +1,133 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Throwaway density probe: mimic the consumer image — a few big "developed-cone" annular sectors +//! plus many small square plates, OVER-SUBSCRIBED, so "how many fit" measures packing tightness +//! (utilization at fixed demand is arrangement-independent once everything fits). Compares strategy, +//! rotation granularity, and restarts. Instant/std-trig are input-generation only (bench waiver). + +use ironnest_optimizer::{ + NestConfig, PlacementStrategy, Scalar, SeparationEffort, nest_with_config, +}; + +fn rect(w: Scalar, h: Scalar) -> Vec<[Scalar; 2]> { + vec![[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]] +} + +/// A developed-cone-shell annular sector: outer radius `ro`, inner `ri`, spanning `deg` degrees, +/// opening upward, anchored near the origin. ~convex-ish curved trapezoid (the image's big parts). +#[allow(clippy::disallowed_methods)] +fn sector(ri: Scalar, ro: Scalar, deg: Scalar, steps: usize) -> Vec<[Scalar; 2]> { + let half = deg.to_radians() / 2.0; + let a0 = std::f64::consts::FRAC_PI_2 - half; + let a1 = std::f64::consts::FRAC_PI_2 + half; + let mut pts = Vec::with_capacity(2 * steps + 2); + // outer arc a0 -> a1 + for i in 0..=steps { + let a = a0 + (a1 - a0) * (i as Scalar) / (steps as Scalar); + pts.push([ro * a.cos(), ro * a.sin()]); + } + // inner arc a1 -> a0 + for i in 0..=steps { + let a = a1 - (a1 - a0) * (i as Scalar) / (steps as Scalar); + pts.push([ri * a.cos(), ri * a.sin()]); + } + // shift so min corner ~origin + let (mut mnx, mut mny) = (f64::INFINITY, f64::INFINITY); + for p in &pts { + mnx = mnx.min(p[0]); + mny = mny.min(p[1]); + } + pts.iter().map(|p| [p[0] - mnx, p[1] - mny]).collect() +} + +fn area(o: &[[Scalar; 2]]) -> Scalar { + let n = o.len(); + let mut a = 0.0; + for i in 0..n { + let j = (i + 1) % n; + a += o[i][0] * o[j][1] - o[j][0] * o[i][1]; + } + (0.5 * a).abs() +} + +#[allow(clippy::disallowed_methods)] +fn time_ms(mut f: F) -> f64 { + let t = std::time::Instant::now(); + f(); + t.elapsed().as_secs_f64() * 1000.0 +} + +fn main() { + let cone = sector(28.0, 60.0, 55.0, 10); // ~developed cone shell + let sq = rect(10.0, 10.0); + let items = vec![cone.clone(), sq.clone()]; + let areas = [area(&cone), area(&sq)]; + // Over-subscribed: request far more than fits so "placed area" measures tightness. + let qty = [20usize, 150]; + let container = rect(200.0, 120.0); + let cont_area = 200.0 * 120.0; + let total_req: Scalar = areas[0] * qty[0] as Scalar + areas[1] * qty[1] as Scalar; + println!( + "--- density_probe: {} cones (area {:.0}) + {} squares in 200x120 (req area {:.0} = {:.0}% of sheet) ---", + qty[0], + areas[0], + qty[1], + total_req, + total_req / cont_area * 100.0 + ); + + let cardinal = vec![0.0, 90.0, 180.0, 270.0]; + // Rotation sets to try for the CONE (squares stay cardinal). Sector-matched: multiples of the + // 55° span let sectors tessellate point-to-point. + let fine12: Vec = (0..12).map(|i| i as Scalar * 30.0).collect(); + let sector_match: Vec = (0..8).map(|i| i as Scalar * 45.0).collect(); + + for (label, strat, cone_rots, restarts) in [ + ( + "sampling cardinal K=1", + PlacementStrategy::Sampling, + &cardinal, + 1usize, + ), + ( + "sampling cardinal K=8", + PlacementStrategy::Sampling, + &cardinal, + 8, + ), + ("nfp cardinal K=1", PlacementStrategy::Nfp, &cardinal, 1), + ("nfp cardinal K=8", PlacementStrategy::Nfp, &cardinal, 8), + ("nfp 12-way K=8", PlacementStrategy::Nfp, &fine12, 8), + ("nfp 45-step K=8", PlacementStrategy::Nfp, §or_match, 8), + ] { + let rots = vec![cone_rots.clone(), cardinal.clone()]; + let cfg = NestConfig { + min_sep: 0.0, + seed: 1, + budget: 1000, + restarts, + strategy: strat, + separation_effort: SeparationEffort::Full, + }; + let mut placed = [0usize; 2]; + let ms = time_ms(|| { + let sol = + nest_with_config(&items, &qty, &container, &[], &rots, &cfg).expect("in-domain"); + placed = [0, 0]; + for p in &sol.placements { + placed[p.item] += 1; + } + }); + let placed_area = areas[0] * placed[0] as Scalar + areas[1] * placed[1] as Scalar; + println!( + "{label:<24} cones {}/{} squares {:>2}/{} util {:>5.1}% {ms:>8.0} ms", + placed[0], + qty[0], + placed[1], + qty[1], + placed_area / cont_area * 100.0 + ); + } +} diff --git a/crates/optimizer/examples/density_remnant.rs b/crates/optimizer/examples/density_remnant.rs new file mode 100644 index 0000000..9f2277d --- /dev/null +++ b/crates/optimizer/examples/density_remnant.rs @@ -0,0 +1,147 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Throwaway remnant probe (docs/04 Tier D): the goal is to leave the largest usable REMNANT, i.e. +//! compact the parts into the smallest region. A FIXED part set that all fits, nested Full vs Max, +//! reporting the used bounding box (smaller = bigger remnant) and writing SVGs to /tmp so the two +//! layouts can be compared by eye. Instant/std-trig are output/generation only (bench waiver). + +use std::fmt::Write as _; + +use ironnest_optimizer::{ + NestConfig, PlacementStrategy, Scalar, SeparationEffort, nest_with_config, +}; + +fn rect(w: Scalar, h: Scalar) -> Vec<[Scalar; 2]> { + vec![[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]] +} + +#[allow(clippy::disallowed_methods)] +fn sector(ri: Scalar, ro: Scalar, deg: Scalar, steps: usize) -> Vec<[Scalar; 2]> { + let half = deg.to_radians() / 2.0; + let (a0, a1) = ( + std::f64::consts::FRAC_PI_2 - half, + std::f64::consts::FRAC_PI_2 + half, + ); + let mut pts = Vec::new(); + for i in 0..=steps { + let a = a0 + (a1 - a0) * (i as Scalar) / (steps as Scalar); + pts.push([ro * a.cos(), ro * a.sin()]); + } + for i in 0..=steps { + let a = a1 - (a1 - a0) * (i as Scalar) / (steps as Scalar); + pts.push([ri * a.cos(), ri * a.sin()]); + } + let (mut mnx, mut mny) = (f64::INFINITY, f64::INFINITY); + for p in &pts { + mnx = mnx.min(p[0]); + mny = mny.min(p[1]); + } + pts.iter().map(|p| [p[0] - mnx, p[1] - mny]).collect() +} + +/// Transform an item's original outline to its placed pose (placed = Rot(deg)·orig + (x,y)). +#[allow(clippy::disallowed_methods)] +fn placed_outline(outline: &[[Scalar; 2]], x: Scalar, y: Scalar, deg: Scalar) -> Vec<[Scalar; 2]> { + let (c, s) = (deg.to_radians().cos(), deg.to_radians().sin()); + outline + .iter() + .map(|p| [x + c * p[0] - s * p[1], y + s * p[0] + c * p[1]]) + .collect() +} + +#[allow(clippy::disallowed_methods)] +fn time_ms(mut f: F) -> f64 { + let t = std::time::Instant::now(); + f(); + t.elapsed().as_secs_f64() * 1000.0 +} + +fn main() { + // A FIXED set that all fits comfortably (so the metric is compaction, not admission). + let cone = sector(28.0, 60.0, 55.0, 10); + let sq = rect(10.0, 10.0); + let items = vec![cone.clone(), sq.clone()]; + let qty = [6usize, 24]; + let (cw, ch) = (240.0, 120.0); + let container = rect(cw, ch); + let rots: Vec> = vec![ + (0..8).map(|i| i as Scalar * 45.0).collect(), + vec![0.0, 90.0, 180.0, 270.0], + ]; + + println!( + "--- density_remnant: {} cones + {} squares in {cw}x{ch} (all fit) ---", + qty[0], qty[1] + ); + + for &(label, effort) in &[ + ("Full", SeparationEffort::Full), + ("Max", SeparationEffort::Max), + ] { + let cfg = NestConfig { + min_sep: 0.0, + seed: 1, + budget: 1500, + restarts: 1, + strategy: PlacementStrategy::Nfp, + separation_effort: effort, + }; + let mut placed = 0usize; + let mut used_x = (f64::INFINITY, f64::NEG_INFINITY); + let mut used_y = (f64::INFINITY, f64::NEG_INFINITY); + let mut svg = String::new(); + writeln!( + svg, + "", + cw + 10.0, + ch + 10.0 + ) + .unwrap(); + writeln!( + svg, + "" + ) + .unwrap(); + let ms = time_ms(|| { + let sol = + nest_with_config(&items, &qty, &container, &[], &rots, &cfg).expect("in-domain"); + placed = sol.placements.len(); + used_x = (f64::INFINITY, f64::NEG_INFINITY); + used_y = (f64::INFINITY, f64::NEG_INFINITY); + svg.truncate(svg.find("").map_or(svg.len(), |_| svg.len())); + for p in &sol.placements { + let poly = placed_outline(&items[p.item], p.x, p.y, p.rotation_deg); + let pts: String = poly + .iter() + .map(|q| format!("{:.2},{:.2} ", q[0], q[1])) + .collect(); + let fill = if p.item == 0 { "#f8c" } else { "#8cf" }; + let _ = writeln!( + svg, + "" + ); + for q in &poly { + used_x = (used_x.0.min(q[0]), used_x.1.max(q[0])); + used_y = (used_y.0.min(q[1]), used_y.1.max(q[1])); + } + } + }); + writeln!(svg, "").unwrap(); + let used_w = used_x.1 - used_x.0; + let used_h = used_y.1 - used_y.0; + let used_area = used_w * used_h; + let sheet = cw * ch; + let path = format!("/tmp/nest_{}.svg", label.to_lowercase()); + std::fs::write(&path, &svg).unwrap(); + println!( + "{label:<5} placed {placed:>3} used {used_w:>6.1}x{used_h:<6.1} = {used_area:>8.0} ({:>4.1}% of sheet) \ + remnant {:>5.1}% max_x {:>6.1} {ms:>7.0} ms -> {path}", + used_area / sheet * 100.0, + (sheet - used_area) / sheet * 100.0, + used_x.1, + ); + } + println!("(open /tmp/nest_full.svg and /tmp/nest_max.svg to compare)"); +} diff --git a/crates/optimizer/examples/nfp_policy_ab.rs b/crates/optimizer/examples/nfp_policy_ab.rs index 6c2aed6..4f31bd5 100644 --- a/crates/optimizer/examples/nfp_policy_ab.rs +++ b/crates/optimizer/examples/nfp_policy_ab.rs @@ -10,7 +10,9 @@ //! mode (docs/02 §11.2). `start_order_policy` therefore stays strategy-INDEPENDENT. Re-run when //! touching the policy. `cargo run -p ironnest-optimizer --release --features parallel --example nfp_policy_ab` -use ironnest_optimizer::{NestConfig, PlacementStrategy, Scalar, nest_with_config}; +use ironnest_optimizer::{ + NestConfig, PlacementStrategy, Scalar, SeparationEffort, nest_with_config, +}; fn rect(w: Scalar, h: Scalar) -> Vec<[Scalar; 2]> { vec![[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]] @@ -39,6 +41,7 @@ fn main() { budget: 2000, restarts: 8, strategy: PlacementStrategy::Nfp, + separation_effort: SeparationEffort::Full, }; let sol = nest_with_config(&items, &qty, &container, &[], &rots, &cfg).unwrap(); let placed_area: Scalar = sol.placements.iter().map(|p| areas[p.item]).sum(); diff --git a/crates/optimizer/examples/perf_diag.rs b/crates/optimizer/examples/perf_diag.rs new file mode 100644 index 0000000..0d925d4 --- /dev/null +++ b/crates/optimizer/examples/perf_diag.rs @@ -0,0 +1,107 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Throwaway perf-diagnostic: reproduce the consumer "mixed-13" NFP pack at the consumer's +//! min_sep=0.375, restarts=1, and time the FULL pipeline so the construction-vs-separation-tail +//! split is visible. NOT a determinism path (Instant is benchmark-only). +//! `cargo run -p ironnest-optimizer --release --example perf_diag` + +use ironnest_optimizer::{ + NestConfig, PlacementStrategy, Scalar, SeparationEffort, nest_with_config, +}; + +fn rect(w: Scalar, h: Scalar) -> Vec<[Scalar; 2]> { + vec![[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]] +} + +#[allow(clippy::disallowed_methods)] +fn ngon(n: usize, size: Scalar) -> Vec<[Scalar; 2]> { + let r = size / 2.0; + (0..n) + .map(|i| { + let th = 2.0 * std::f64::consts::PI * (i as Scalar) / (n as Scalar); + [r + r * th.cos(), r + r * th.sin()] + }) + .collect() +} + +#[allow(clippy::disallowed_methods)] +fn time_ms(mut f: F) -> f64 { + let t = std::time::Instant::now(); + f(); + t.elapsed().as_secs_f64() * 1000.0 +} + +fn main() { + // The consumer acceptance corpus (verbatim from nfp_spike.rs): 13 heterogeneous plate shapes, + // qty = base x 2 = 122 instances, 120x60 sheet, min_sep = 0.375-in part gap. + let mixed_items: Vec> = vec![ + rect(24.0, 8.0), + rect(30.0, 3.0), + rect(12.0, 12.0), + vec![ + [0.0, 0.0], + [16.0, 0.0], + [16.0, 5.0], + [5.0, 5.0], + [5.0, 14.0], + [0.0, 14.0], + ], + vec![ + [0.0, 0.0], + [10.0, 0.0], + [10.0, 3.0], + [3.0, 3.0], + [3.0, 9.0], + [0.0, 9.0], + ], + vec![[0.0, 0.0], [12.0, 0.0], [0.0, 12.0]], + vec![[0.0, 0.0], [8.0, 0.0], [0.0, 8.0]], + ngon(48, 14.0), + ngon(48, 8.0), + rect(18.0, 6.0), + ngon(6, 10.0), + vec![[0.0, 0.0], [14.0, 0.0], [11.0, 7.0], [3.0, 7.0]], + rect(36.0, 2.0), + ]; + let mixed_qty: Vec = [4usize, 5, 4, 4, 5, 5, 6, 4, 6, 4, 5, 4, 5] + .iter() + .map(|q| q * 2) + .collect(); + let container = rect(120.0, 60.0); + let rots: Vec> = mixed_items + .iter() + .map(|_| vec![0.0, 90.0, 180.0, 270.0]) + .collect(); + let total: usize = mixed_qty.iter().sum(); + + println!("--- perf_diag: consumer mixed-13, min_sep=0.375, restarts=1 ---"); + for &(mode, strat) in &[ + ("NFP", PlacementStrategy::Nfp), + ("sampling", PlacementStrategy::Sampling), + ] { + for &(estr, effort) in &[ + ("Full", SeparationEffort::Full), + ("Fast", SeparationEffort::Fast), + ] { + let cfg = NestConfig { + min_sep: 0.375, + seed: 1, + budget: 400, + restarts: 1, + strategy: strat, + separation_effort: effort, + }; + let mut placed = 0usize; + let ms = time_ms(|| { + let sol = nest_with_config(&mixed_items, &mixed_qty, &container, &[], &rots, &cfg) + .expect("in-domain"); + placed = sol.placements.len(); + }); + println!("{mode:<9} sep={estr:<4} placed {placed:>4}/{total} {ms:>10.1} ms"); + } + } + // Construction-only reference is nfp_spike.rs's 908 ms (lazy cache) for the same shapes. + println!("(nfp_spike.rs construction-only for the same shapes: ~908 ms placing 51/122)"); +} diff --git a/crates/optimizer/examples/prepared_bench.rs b/crates/optimizer/examples/prepared_bench.rs new file mode 100644 index 0000000..669c33b --- /dev/null +++ b/crates/optimizer/examples/prepared_bench.rs @@ -0,0 +1,112 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Throwaway Tier 2 bench (docs/04): the "part-in-part" workload — nest one 13-type library into M +//! containers. `per-call` rebuilds the O(P^2) pairwise NFP table every time; `prepared` builds it +//! once via prepare() + nest_with_prepared(). Reports the M× precompute saving. Instant is bench-only. + +use ironnest_optimizer::{ + NestConfig, PlacementStrategy, Scalar, SeparationEffort, nest_with_config, nest_with_prepared, + prepare, +}; + +fn rect(w: Scalar, h: Scalar) -> Vec<[Scalar; 2]> { + vec![[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]] +} + +#[allow(clippy::disallowed_methods)] +fn ms(mut f: F) -> f64 { + let t = std::time::Instant::now(); + f(); + t.elapsed().as_secs_f64() * 1000.0 +} + +fn main() { + // A 13-type library (the mixed-13 consumer spread, matching bench.rs's nfp_cache_probe). + let items: Vec> = vec![ + rect(24.0, 8.0), + rect(30.0, 3.0), + rect(12.0, 12.0), + vec![ + [0.0, 0.0], + [16.0, 0.0], + [16.0, 5.0], + [5.0, 5.0], + [5.0, 14.0], + [0.0, 14.0], + ], + vec![ + [0.0, 0.0], + [10.0, 0.0], + [10.0, 3.0], + [3.0, 3.0], + [3.0, 9.0], + [0.0, 9.0], + ], + vec![[0.0, 0.0], [12.0, 0.0], [0.0, 12.0]], + vec![[0.0, 0.0], [8.0, 0.0], [0.0, 8.0]], + rect(18.0, 6.0), + vec![[0.0, 0.0], [14.0, 0.0], [11.0, 7.0], [3.0, 7.0]], + rect(36.0, 2.0), + rect(9.0, 9.0), + rect(20.0, 4.0), + vec![ + [0.0, 0.0], + [11.0, 0.0], + [11.0, 11.0], + [5.5, 15.0], + [0.0, 11.0], + ], + ]; + let qty: Vec = vec![1; items.len()]; + let rots: Vec> = items + .iter() + .map(|_| vec![0.0, 90.0, 180.0, 270.0]) + .collect(); + let min_sep = 0.375; + // M distinct "parent" containers (the part-in-part loop). Kept modest so everything places by + // construction and the O(P^2) precompute — not the nest itself — is what each call pays. + let m = 15usize; + let containers: Vec> = (0..m) + .map(|i| rect(60.0 + i as Scalar, 40.0 + i as Scalar)) + .collect(); + + // --- Per-call: rebuild everything each nest (today's part-in-part cost). --- + let mut placed_a = 0usize; + let per_call = ms(|| { + for c in &containers { + let cfg = NestConfig { + min_sep, + seed: 1, + budget: 400, + restarts: 1, + strategy: PlacementStrategy::Nfp, + separation_effort: SeparationEffort::Full, + }; + let sol = nest_with_config(&items, &qty, c, &[], &rots, &cfg).expect("in-domain"); + placed_a += sol.placements.len(); + } + }); + + // --- Prepared: build the library once, reuse across containers. --- + let mut placed_b = 0usize; + let prepared_total = ms(|| { + let prepared = prepare(&items, &rots, min_sep, PlacementStrategy::Nfp).expect("in-domain"); + for c in &containers { + let sol = + nest_with_prepared(&prepared, &qty, c, &[], 1, 400, 1, SeparationEffort::Full) + .expect("in-domain"); + placed_b += sol.placements.len(); + } + }); + + println!("--- prepared_bench: 13-type library into M={m} containers (NFP, min_sep=0.375) ---"); + println!("per-call (rebuild each): {per_call:>9.1} ms (placed total {placed_a})"); + println!("prepared (build once): {prepared_total:>9.1} ms (placed total {placed_b})"); + println!( + "speedup: {:.1}× (placements identical: {})", + per_call / prepared_total, + placed_a == placed_b + ); +} diff --git a/crates/optimizer/src/bin/golden_dump.rs b/crates/optimizer/src/bin/golden_dump.rs index 7ae51f0..b4b9432 100644 --- a/crates/optimizer/src/bin/golden_dump.rs +++ b/crates/optimizer/src/bin/golden_dump.rs @@ -23,8 +23,8 @@ //! deterministic op history); it is part of the contract, so it is dumped unsorted. use ironnest_optimizer::{ - NestConfig, Placement, PlacementStrategy, Scalar, nest, nest_multistart, nest_per_item, - nest_with_config, + NestConfig, Placement, PlacementStrategy, Scalar, SeparationEffort, nest, nest_multistart, + nest_per_item, nest_with_config, }; use std::fmt::Write as _; @@ -300,6 +300,7 @@ fn dump() -> String { budget: 400, restarts: 1, strategy: PlacementStrategy::Nfp, + separation_effort: SeparationEffort::Full, }; let per_item = |n: usize| -> Vec> { (0..n).map(|_| CARDINAL.to_vec()).collect() }; @@ -393,6 +394,7 @@ fn dump() -> String { budget: 500, restarts: 3, strategy: PlacementStrategy::Nfp, + separation_effort: SeparationEffort::Full, }, ) .expect("in-domain"); @@ -411,6 +413,47 @@ fn dump() -> String { .expect("in-domain"); write_solution(&mut out, &sol.placements, &sol.unplaced); + // Fast separation tier (`SeparationEffort::Fast`, perf Tier 0, docs/04) — a DELIBERATELY + // placement-changing result vs `Full`, pinned into the cross-platform byte-identity contract on + // its own terms (the pure-integer NFP construction + the seeded, fixed-budget separator are as + // deterministic under the reduced Fast budget as under Full). Two cases pin the two Fast branches: + // + // (a) reduced-budget separator: 12 squares of 100 into a 25×25 = 625 sheet — only 4 fit + // geometrically (2×2 in 20×20), so the 8 leftovers each run the REDUCED per-insert separator + // budget (SEP_CONFIG_FAST / SEED_SAMPLES_FAST) and fail. Placed area (400) stays below the + // container (625), so the area-skip does NOT fire here — this case locks the Fast PRNG path. + writeln!(out, "# nfp-fast-reduced-budget").unwrap(); + let fast_cfg = |container_side: Scalar, qty: usize| { + ( + vec![rect(10.0, 10.0)], + vec![qty], + rect(container_side, container_side), + ) + }; + let (items, q, cont) = fast_cfg(25.0, 12); + let nfp_fast = |seed: u64| NestConfig { + min_sep: 0.0, + seed, + budget: 400, + restarts: 1, + strategy: PlacementStrategy::Nfp, + separation_effort: SeparationEffort::Fast, + }; + let sol = + nest_with_config(&items, &q, &cont, &[], &per_item(1), &nfp_fast(7)).expect("in-domain"); + write_solution(&mut out, &sol.placements, &sol.unplaced); + + // (b) area-conservation hopeless-skip: 12 squares of 100 into a 30.01×30.01 ≈ 900.6 sheet — the + // 0.01 slack lets the □E-backoff contact placement seat all 9 (perfect 3×3 grid, area 900), so + // the 3 leftovers each trip the area-skip (900 + 100 > 900.6) and are dropped WITHOUT running + // the separator. Locks the sound Tier 0a skip path. (An exact 30.0 sheet seats only 4 — the + // backoff denies 3-in-a-row — which is why contact-columns above also uses 30.01.) + writeln!(out, "# nfp-fast-area-skip").unwrap(); + let (items, q, cont) = fast_cfg(30.01, 12); + let sol = + nest_with_config(&items, &q, &cont, &[], &per_item(1), &nfp_fast(7)).expect("in-domain"); + write_solution(&mut out, &sol.placements, &sol.unplaced); + out } diff --git a/crates/optimizer/src/lib.rs b/crates/optimizer/src/lib.rs index 1fd56d6..668332f 100644 --- a/crates/optimizer/src/lib.rs +++ b/crates/optimizer/src/lib.rs @@ -105,6 +105,11 @@ pub struct MultiSheetSolution { } /// The collision-detection configuration. Mirrors `lbf`'s reference defaults; internal for 2a. +/// +/// `quadtree_depth`/`cd_threshold` are acceleration-only (they change neither the collision boolean +/// nor the collected hazard set → byte-identical placements). A wall-clock sweep of depth ∈ {4..8} × +/// `cd_threshold` ∈ {8,16,32} on sep-heavy NFP packs (perf Tier 1b, docs/04 §4) found these defaults +/// already near-optimal (variation within best-of-2 noise; depth ≥ 7 is slower), so they are kept. fn default_cde_config() -> CDEConfig { CDEConfig { quadtree_depth: 5, @@ -391,6 +396,7 @@ pub fn multistart_start( starts, PlacementStrategy::Sampling, None, + SeparationEffort::Full, ) } @@ -408,6 +414,7 @@ fn multistart_start_imported( starts: u64, strategy: PlacementStrategy, nfp_cache: Option<&nfp::NfpCache>, + effort: SeparationEffort, ) -> NestSolution { nest_core_imported( problem, @@ -417,6 +424,7 @@ fn multistart_start_imported( start_order_policy(k, starts), strategy, nfp_cache, + effort, ) } @@ -433,6 +441,32 @@ pub enum PlacementStrategy { Nfp, } +/// How hard the post-construction separation tail (`sep::run_separation`) tries on the still-unplaced +/// parts. The separation tail dominates the wall-clock on **over-subscribed** packs — it runs the full +/// GLS separator on each unplaced part, most of which cannot be made to fit (docs/04). `Fast` prunes +/// that waste, at the cost of a **deliberately different (re-blessed) placement** — it is PRNG-coupled, +/// so it changes the layout, not just the speed. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SeparationEffort { + /// The shipped behavior: attempt every unplaced part at the full separator budget. **The default — + /// byte-identical to every prior release** (the committed cross-platform golden runs this). + #[default] + Full, + /// Prune provably-hopeless separation attempts by an **area-conservation** necessary condition: if + /// the placed CD-footprint area plus the candidate's CD-footprint area already exceeds the + /// container's CD area, no collision-free arrangement can exist (disjoint footprints inside a + /// region sum to ≤ its area), so the separator *must* fail — skip it. Sound (never skips a + /// placeable part), but **placement-changing**: a skipped attempt no longer draws from the shared + /// PRNG stream, so subsequently-attempted parts shift. Opt-in; has its own golden. + Fast, + /// `Full` PLUS a **densification / compaction** pass (docs/04 Tier D): after the normal tail, the + /// sheet is repeatedly shrunk toward its corner and the whole layout re-separated (the GLS + /// separator moves every placed part and samples its rotations), pulling the arrangement inward to + /// close inter-part gaps; freed space is then re-filled. Highest density, highest cost. Strictly + /// placement-changing (it *rearranges* a feasible layout); opt-in. + Max, +} + /// Configuration for [`nest_with_config`] — the consolidation point for run knobs (docs/03 §7). /// All fields carry their [`nest`] / [`nest_multistart`] meanings. #[derive(Clone, Debug)] @@ -443,6 +477,10 @@ pub struct NestConfig { /// Best-of-K multi-start count (clamped to ≥ 1; `1` = a single canonical start). pub restarts: usize, pub strategy: PlacementStrategy, + /// Separation-tail effort (see [`SeparationEffort`]); [`SeparationEffort::Full`] preserves the + /// byte-identical shipped behavior. Added with a `Default` so existing `NestConfig { .. }` + /// literals need `..Default::default()` — or set it explicitly. + pub separation_effort: SeparationEffort, } /// Errors from the strategy-bearing entry points. The sampling paths are infallible (malformed @@ -537,6 +575,7 @@ pub fn nest_with_config( &areas, config.strategy, cache.as_ref(), + config.separation_effort, ); let mut best: Option<(NestSolution, Scalar)> = None; for (sol, area) in runs { @@ -631,6 +670,7 @@ pub fn nest_multi_with_config( &areas, config.strategy, cache.as_ref(), + config.separation_effort, ); let mut best: Option<(NestSolution, Scalar)> = None; for (sol, area) in runs { @@ -666,6 +706,171 @@ pub fn nest_multi_with_config( }) } +/// A pre-imported, container-INDEPENDENT part library, reusable across many [`nest_with_prepared`] +/// calls with different containers (the "part-in-part" workload: one part set nested into many +/// parent shapes). Built ONCE by [`prepare`], it holds the imported item entities (with CDE +/// surrogates) and — for [`PlacementStrategy::Nfp`] — the shared `Arc`: the eager +/// O((P·R)²) pairwise NFP table (docs/03 §6, docs/04 Tier 2). A plain per-call `nest()` rebuilds all +/// of this every time; a workflow that nests the same parts into `M` containers pays the precompute +/// once instead of `M` times. +/// +/// Byte-identity: import is a pure function of `(items, rotations, min_sep)` and `PartsGeometry` is +/// container-independent (the exact property [`nest_multi_with_config`] already relies on to share it +/// across sheets), so `prepare` + `nest_with_prepared` produces the SAME placements as +/// [`nest_with_config`] with the matching [`NestConfig`] — locked by a test. Immutable after build; +/// `Item`/`PartsGeometry` are `Send + Sync`, so a `&PreparedParts` shares across the sanctioned +/// multi-start threads exactly like `&Problem`. +pub struct PreparedParts { + entities: Vec>, + rotations_deg: Vec>, + rotations_rad: Vec>, + /// Original-outline areas, for the multi-start keep-best reduction. + areas: Vec, + /// The separation offset the entities were imported with — a container imported for reuse MUST use + /// the same `min_sep`, or its footprints would not match the parts. Captured here so the caller + /// cannot silently desync it. + min_sep: Scalar, + strategy: PlacementStrategy, + /// `Some` iff `strategy == Nfp`: the shared container-independent NFP geometry. + parts: Option>, +} + +/// Pre-imports a part library for reuse across containers (docs/04 Tier 2). `min_sep` and `strategy` +/// are fixed here (they determine the container-independent precompute); `seed` / `budget` / +/// `restarts` / `separation_effort` / `qty` / the container are chosen per [`nest_with_prepared`] +/// call. `rotations_deg` uses the per-item [`nest_per_item`] semantics (one set per item type, or a +/// single broadcast set — the caller flattens as for [`nest_with_config`]). +/// +/// # Errors +/// [`NestError::NfpInputDomain`] iff `strategy == Nfp` and the scaled part coordinates exceed the +/// integer domain gate (the container side is re-checked per call in [`nest_with_prepared`]). +pub fn prepare( + items: &[Vec<[Scalar; 2]>], + rotations_deg: &[Vec], + min_sep: Scalar, + strategy: PlacementStrategy, +) -> Result { + assert_eq!( + items.len(), + rotations_deg.len(), + "items and per-item rotations must be the same length" + ); + let importer = make_importer(min_sep); + let (rotations_deg_per_item, rotations_rad) = normalize_rotations(rotations_deg); + let entities = import_entities(&importer, items, &rotations_deg_per_item); + let areas: Vec = items.iter().map(|o| polygon_area(o)).collect(); + let parts = match strategy { + PlacementStrategy::Sampling => None, + PlacementStrategy::Nfp => Some(std::sync::Arc::new(nfp::PartsGeometry::build( + &entities, + &rotations_deg_per_item, + &rotations_rad, + )?)), + }; + Ok(PreparedParts { + entities, + rotations_deg: rotations_deg_per_item, + rotations_rad, + areas, + min_sep, + strategy, + parts, + }) +} + +/// Nests a [`prepare`]d part library into one `container` (with `holes`), reusing the pre-built +/// entities + pairwise NFP table. Byte-identical to [`nest_with_config`] with a matching +/// [`NestConfig`] (`min_sep`/`strategy` from `prepared`, the rest from here) — the reuse just moves +/// the container-independent precompute out of the per-call hot path (docs/04 Tier 2). +/// +/// `qty` is per call (`qty.len()` must equal the prepared item count), so the same library can fill +/// containers with different demand. +/// +/// # Errors +/// [`NestError::NfpInputDomain`] iff `strategy == Nfp` and this container exceeds the integer domain +/// gate. +#[allow(clippy::too_many_arguments)] +pub fn nest_with_prepared( + prepared: &PreparedParts, + qty: &[usize], + container: &[[Scalar; 2]], + holes: &[Vec<[Scalar; 2]>], + seed: u64, + budget: u64, + restarts: usize, + separation_effort: SeparationEffort, +) -> Result { + assert_eq!( + prepared.entities.len(), + qty.len(), + "prepared item count and qty must be the same length" + ); + let starts = restarts.max(1); + + // Import ONLY the container (with holes), with an importer configured identically to the one that + // imported the parts — a pure function of `(container, holes, min_sep)`, so it matches what + // `import_problem` would have produced. + let importer = make_importer(prepared.min_sep); + let container_outline = container.to_vec(); + let holes_vec = holes.to_vec(); + let Some(container) = import_container_with_holes(&importer, container, holes) else { + return Ok(NestSolution { + placements: vec![], + unplaced: all_unplaced(qty), + }); + }; + + // Rebuild the `Problem` view over the shared entities. The clone is Arc-cheap (Item wraps + // `Arc`) and runs ONCE per container, not per start — negligible against the O(P²) table + // it lets us skip. + let problem = Problem { + entities: prepared.entities.clone(), + container, + rotations_deg: prepared.rotations_deg.clone(), + rotations_rad: prepared.rotations_rad.clone(), + container_outline, + holes: holes_vec, + min_sep: prepared.min_sep, + }; + + // Reassemble only this container's IFPs over the shared parts (the per-sheet step + // `nest_multi_with_config` already does — here across calls). + let cache = match prepared.strategy { + PlacementStrategy::Sampling => None, + PlacementStrategy::Nfp => Some(nfp::NfpCache::assemble( + prepared + .parts + .clone() + .expect("NFP-prepared parts always carry PartsGeometry"), + &problem.container, + )?), + }; + + let runs = run_starts_with( + &problem, + qty, + seed, + budget, + starts, + &prepared.areas, + prepared.strategy, + cache.as_ref(), + separation_effort, + ); + let mut best: Option<(NestSolution, Scalar)> = None; + for (sol, area) in runs { + let better = best + .as_ref() + .is_none_or(|(_, best_area)| area.total_cmp(best_area) == Ordering::Greater); + if better { + best = Some((sol, area)); + } + } + Ok(best + .expect("restarts is clamped to >= 1, so at least one solution is produced") + .0) +} + /// The imported, placement-ready problem (the N1a import hoist — docs/03 §6): items, container, /// and normalized rotation sets, built ONCE per public call by [`import_problem`] and shared /// read-only by every multi-start start (and, later, the NFP cache precompute). Import is a pure @@ -679,48 +884,59 @@ struct Problem { container: Container, rotations_deg: Vec>, rotations_rad: Vec>, + /// The raw container outline + holes + separation, kept so the densification pass + /// (`SeparationEffort::Max`) can build *shrunk* containers to create compaction pressure. + container_outline: Vec<[Scalar; 2]>, + holes: Vec>, + min_sep: Scalar, } -/// Imports items + container (with holes) into a [`Problem`]. Returns `None` iff the container (or -/// an in-bounds hole) is malformed — the caller then places nothing (conservative — never silently -/// place into an intended keep-out). -fn import_problem( - items: &[Vec<[Scalar; 2]>], - container: &[[Scalar; 2]], - holes: &[Vec<[Scalar; 2]>], - min_sep: Scalar, - rotations_deg: &[Vec], -) -> Option { +/// The [`Importer`] configured for `min_sep` (default CDE config + auto collision-footprint +/// decimation). Single definition shared by [`import_problem`] and [`prepare`] so items and +/// containers import identically regardless of which entry point built them. +/// +/// Auto decimation is enabled whenever a separation is requested (`min_sep == 0` needs no offset and +/// so no superset margin). Applied symmetrically to every imported hazard — items (Inflate), the +/// container boundary and holes (Deflate/Inflate) — but only takes effect on HIGH-VERTEX shapes +/// (`convert_to_internal` gates per-shape on `DECIMATION_MIN_VERTICES`), so simple parts and +/// axis-aligned sheets keep the exact offset path (and the determinism golden) unchanged. +fn make_importer(min_sep: Scalar) -> Importer { let cde_config = default_cde_config(); let min_item_separation = (min_sep > 0.0).then_some(min_sep); let mut importer = Importer::new(cde_config, None, min_item_separation, None); - // Auto collision-footprint decimation: enabled whenever a separation is requested (`min_sep == 0` - // needs no offset and so no superset margin). Applied symmetrically to every imported hazard — - // items (Inflate), the container boundary and holes (Deflate/Inflate) — but only takes effect on - // HIGH-VERTEX shapes; `convert_to_internal` gates per-shape on `DECIMATION_MIN_VERTICES`, so simple - // parts and axis-aligned sheets keep the exact offset path (and the determinism golden) unchanged. importer.shape_modify_config.collision_decimation = (min_sep > 0.0).then_some(min_sep * DECIMATION_TOL_FRACTION); + importer +} - // Per-item rotations: degrees for the importer's metadata, radians for our sampler. An empty set - // for an item ⇒ `{0}` (no rotation) — the same normalization the single-set path applies, now - // applied independently per item type. - let rotations_deg_per_item: Vec> = rotations_deg +/// Normalizes per-item rotations into `(degrees, radians)`: an empty set for an item ⇒ `{0}` (no +/// rotation), applied independently per item type. Degrees drive the importer metadata / exact- +/// cardinal path, radians the sampler and libm path. +fn normalize_rotations(rotations_deg: &[Vec]) -> (Vec>, Vec>) { + let deg: Vec> = rotations_deg .iter() .map(|r| if r.is_empty() { vec![0.0] } else { r.clone() }) .collect(); - let rotations_rad: Vec> = rotations_deg_per_item + let rad: Vec> = deg .iter() .map(|r| r.iter().map(|d| d.to_radians()).collect()) .collect(); + (deg, rad) +} - let container = import_container_with_holes(&importer, container, holes)?; - - // Import items. A malformed item ⇒ that whole type is unplaced. High-vertex curved parts get - // their collision footprint decimated by the importer's `collision_decimation` (gated per-shape in - // `convert_to_internal`); `shape_orig` — which drives the reported placement frame — is always the - // untouched original outline. Each item carries its own `allowed_orientations`, so the separation - // search (which reads `item.allowed_rotation`) honours the per-item set automatically. +/// Imports the item entities (with surrogates) — the CONTAINER-INDEPENDENT half of an import. A +/// malformed item ⇒ that whole type is unplaced (`None`). High-vertex curved parts get their +/// collision footprint decimated by the importer's `collision_decimation`; `shape_orig` (which drives +/// the reported placement frame) is always the untouched original outline. Each item carries its own +/// `allowed_orientations`, so the separation search honours the per-item set automatically. +/// +/// `import_item` takes `&self`, so this is a pure function of `(importer config, items, rotations)` — +/// which is exactly why [`prepare`] can run it once and reuse the result across containers. +fn import_entities( + importer: &Importer, + items: &[Vec<[Scalar; 2]>], + rotations_deg_per_item: &[Vec], +) -> Vec> { let mut entities: Vec> = Vec::with_capacity(items.len()); for (i, outline) in items.iter().enumerate() { let ext_item = ExtItem { @@ -731,12 +947,34 @@ fn import_problem( }; entities.push(importer.import_item(&ext_item).ok()); } + entities +} +/// Imports items + container (with holes) into a [`Problem`]. Returns `None` iff the container (or +/// an in-bounds hole) is malformed — the caller then places nothing (conservative — never silently +/// place into an intended keep-out). Byte-identical to the pre-refactor inline version: the same +/// importer config, the same container-then-items order over an `&self` importer. +fn import_problem( + items: &[Vec<[Scalar; 2]>], + container: &[[Scalar; 2]], + holes: &[Vec<[Scalar; 2]>], + min_sep: Scalar, + rotations_deg: &[Vec], +) -> Option { + let importer = make_importer(min_sep); + let (rotations_deg_per_item, rotations_rad) = normalize_rotations(rotations_deg); + let container_outline = container.to_vec(); + let holes_vec = holes.to_vec(); + let container = import_container_with_holes(&importer, container, holes)?; + let entities = import_entities(&importer, items, &rotations_deg_per_item); Some(Problem { entities, container, rotations_deg: rotations_deg_per_item, rotations_rad, + container_outline, + holes: holes_vec, + min_sep, }) } @@ -779,6 +1017,7 @@ fn nest_core( insertion_order, PlacementStrategy::Sampling, None, + SeparationEffort::Full, ) } @@ -786,7 +1025,7 @@ fn nest_core( /// work. Byte-identical to the pre-hoist `nest_core` for the same inputs (Sampling strategy). /// Under [`PlacementStrategy::Nfp`] the constructive phase is the NFP placer (docs/03 §5) over the /// shared prebuilt `nfp_cache`; the separation tail runs unchanged in both modes. -#[allow(clippy::too_many_arguments)] +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] fn nest_core_imported( problem: &Problem, qty: &[usize], @@ -795,12 +1034,14 @@ fn nest_core_imported( insertion_order: InsertionOrder, strategy: PlacementStrategy, nfp_cache: Option<&nfp::NfpCache>, + effort: SeparationEffort, ) -> NestSolution { let Problem { entities, container, rotations_deg: _, rotations_rad: rotations_rad_per_item, + .. } = problem; let items_len = entities.len(); @@ -900,14 +1141,26 @@ fn nest_core_imported( qty, &mut prng, &mut placed_per_type, + effort, ); - // NOTE(2026-07-09): a compression phase (sparrow's compress loop as a stepping virtual-wall - // keep-out + re-separate + refill) was implemented here, measured, and REVERTED: zero density - // change on every probe (rect/brick/pentagon/mixed/L-sheet, all seeds) at 2–3.5× the runtime. - // Mechanism: in a fixed bin, `sep::try_insert` already seeds each unplaced part at its - // lowest-overlap pose and lets the GLS move every neighbour — a part-sized, perfectly-targeted - // squeeze — so an untargeted global wall squeeze is subsumed. See docs/02 §11.2. + // Densification (`SeparationEffort::Max`, docs/04 Tier D): a REVISED compaction. An earlier + // wall-squeeze keep-out was reverted (docs/02 §11.2, zero gain on rect/brick/pentagon/mixed) — + // but it was measured on rotation-invariant / no-slack cases. This version shrinks the actual + // container and re-separates the WHOLE layout (moving already-placed parts, which the leftover + // separator never touches), then refills — the mechanism a human uses to close gaps between + // irregular parts. Opt-in and rearranges a feasible layout, so it is placement-changing. + if effort == SeparationEffort::Max { + densify( + &mut layout, + problem, + entities, + &order, + qty, + &mut prng, + &mut placed_per_type, + ); + } let placements = extract_placements(&layout, entities); @@ -924,6 +1177,183 @@ fn nest_core_imported( } } +/// Densification steps: how many times the used span is shrunk (compounding [`DENSIFY_SHRINK`]). The +/// loop stops early the first shrink the layout can no longer be separated to zero overlap, so this is +/// an upper bound. Fixed integer — never a wall clock. Many small steps let the local GLS separator +/// compact *incrementally* — each a nudge it can resolve — rather than one jump it gets stuck on. +const DENSIFY_STEPS: u32 = 60; +/// Per-step shrink of the used span along the compaction axis (1 % steps). Gentle by design: the +/// separator is a *local* search, so it can only follow a slowly-tightening boundary. +const DENSIFY_SHRINK: Scalar = 0.99; + +/// Densification / compaction (`SeparationEffort::Max`, docs/04 Tier D). Repeatedly shrink the sheet +/// toward its bottom-left corner and re-separate the WHOLE layout — the GLS separator moves every +/// placed part (sampling its allowed rotations), so the arrangement is pulled inward, closing the +/// inter-part gaps a greedy LBF construction leaves. After each successful (feasible) compaction the +/// freed space is re-filled with still-unplaced parts. Keeps the densest arrangement found (by total +/// placed area, then earliest). A no-op when nothing is placed. +/// +/// This is the mechanism that moves *already-feasible* parts — which the leftover-only +/// [`sep::run_separation`] never touches (it only shoves neighbours of an overlapping seed). Cost is +/// high (a full separation per shrink step), which is why it is gated behind the opt-in `Max` tier. +/// Placement-changing: it deliberately rearranges a feasible layout. +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] +fn densify( + layout: &mut Layout, + problem: &Problem, + entities: &[Option], + order: &[usize], + qty: &[usize], + prng: &mut Prng, + placed_per_type: &mut [usize], +) { + if layout.placed_items.is_empty() { + return; + } + let orig = layout.container.clone(); + let bbox = orig.outer_cd.bbox; + let importer = make_importer(problem.min_sep); + + // Compact along the axis with the most SLACK, keeping the other full. LBF packs full-height (or + // full-width) columns that grow along one axis, so the gaps to close live along that growth axis; + // shrinking it leaves a clean rectangular remnant at that end (docs/04 Tier D). Slack per axis = + // 1 − used_extent / sheet_extent; shrink the slacker one toward its low edge (the corner LBF packs + // toward), leaving the other axis at full extent. + let (ux, uy) = used_extent(layout); + let slack_x = 1.0 - (ux.1 - ux.0) / (bbox.x_max - bbox.x_min).max(f64::MIN_POSITIVE); + let slack_y = 1.0 - (uy.1 - uy.0) / (bbox.y_max - bbox.y_min).max(f64::MIN_POSITIVE); + let shrink_x = slack_x >= slack_y; + let (ax, ay) = (bbox.x_min, bbox.y_min); // anchor = the low corner + + // Scales the raw outline + holes toward the anchor by `factor` (∈ (0,1]) along the chosen axis + // only. + let scale = |factor: Scalar| -> (Vec<[Scalar; 2]>, Vec>) { + let s = |p: &[Scalar; 2]| { + if shrink_x { + [ax + (p[0] - ax) * factor, p[1]] + } else { + [p[0], ay + (p[1] - ay) * factor] + } + }; + ( + problem.container_outline.iter().map(s).collect(), + problem + .holes + .iter() + .map(|h| h.iter().map(s).collect()) + .collect(), + ) + }; + + let mut best = layout.save(); + let mut best_ppt = placed_per_type.to_vec(); + // Keep-best objective (docs/04 Tier D): the goal is to leave the largest usable REMNANT, so + // maximize placed area first (never drop a required part), then MINIMIZE the used bounding box — + // a smaller occupied region leaves more contiguous sheet. Higher area wins; equal area, smaller + // used bbox wins. + let (mut best_area, mut best_used) = (placed_cd_area(layout), placed_used_bbox_area(layout)); + + // Shrink RELATIVE TO THE USED SPAN, not the whole sheet: `base` is the scale factor that + // reproduces the current used extent along the compaction axis, so the first step already lands + // just inside it (immediate pressure) instead of wasting steps crossing the empty remnant. + let (anchor, sheet_span) = if shrink_x { + (ax, bbox.x_max - ax) + } else { + (ay, bbox.y_max - ay) + }; + let used_far = if shrink_x { ux.1 } else { uy.1 }; + let base = ((used_far - anchor) / sheet_span.max(f64::MIN_POSITIVE)).clamp(0.0, 1.0); + + let mut mult: Scalar = 1.0; + for _ in 0..DENSIFY_STEPS { + mult *= DENSIFY_SHRINK; + let factor = base * mult; + let (out, hls) = scale(factor); + let Some(shrunk) = import_container_with_holes(&importer, &out, &hls) else { + break; + }; + // Shrink the sheet: placed parts that no longer fit now overlap the boundary / each other. + layout.swap_container(shrunk); + let feasible = sep::separate_layout(layout, entities, prng); + // Evaluate against the REAL sheet (the shrunk one was only a compaction jig). + layout.swap_container(orig.clone()); + + if feasible && layout.is_feasible() { + // Compacted into the smaller region → free space opened in the real sheet. Refill any + // still-unplaced REQUIRED parts (run_separation respects `qty`). + sep::run_separation( + layout, + entities, + order, + qty, + prng, + placed_per_type, + SeparationEffort::Full, + ); + let area = placed_cd_area(layout); + let used = placed_used_bbox_area(layout); + // Lexicographic keep-best via `total_cmp` (determinism-safe, as the multi-start reduction): + // more placed area wins; equal area, smaller used region (bigger remnant) wins. + let better = match area.total_cmp(&best_area) { + Ordering::Greater => true, + Ordering::Equal => used < best_used, + Ordering::Less => false, + }; + if better { + best = layout.save(); + best_ppt.copy_from_slice(placed_per_type); + best_area = area; + best_used = used; + } + // keep shrinking for more pressure + } else { + // This shrink was too aggressive to separate cleanly — revert and stop. + layout.restore(&best); + placed_per_type.copy_from_slice(&best_ppt); + break; + } + } + layout.restore(&best); + placed_per_type.copy_from_slice(&best_ppt); +} + +/// Total CD-footprint area currently placed in the layout (rigid-invariant `pi.shape.area` sum, in +/// deterministic slot order). +fn placed_cd_area(layout: &Layout) -> Scalar { + layout.placed_items.values().map(|pi| pi.shape.area).sum() +} + +/// The `((x_min, x_max), (y_min, y_max))` extent of every placed part's footprint — the "used +/// region". `((0,0),(0,0))` when nothing is placed. +fn used_extent(layout: &Layout) -> ((Scalar, Scalar), (Scalar, Scalar)) { + let (mut x0, mut y0, mut x1, mut y1) = ( + Scalar::INFINITY, + Scalar::INFINITY, + Scalar::NEG_INFINITY, + Scalar::NEG_INFINITY, + ); + let mut any = false; + for pi in layout.placed_items.values() { + let b = pi.shape.bbox; + x0 = x0.min(b.x_min); + y0 = y0.min(b.y_min); + x1 = x1.max(b.x_max); + y1 = y1.max(b.y_max); + any = true; + } + if any { + ((x0, x1), (y0, y1)) + } else { + ((0.0, 0.0), (0.0, 0.0)) + } +} + +/// Area of the bounding box enclosing every placed part — minimizing it maximizes the usable remnant. +fn placed_used_bbox_area(layout: &Layout) -> Scalar { + let (x, y) = used_extent(layout); + (x.1 - x.0) * (y.1 - y.0) +} + /// Runs the nest from `n_starts` decorrelated seeds and returns the **densest** result — the /// deterministic best-of-K "multi-start". A single greedy construction lands in a *seed-dependent /// basin*; sweeping several seeds and keeping the best lifts packing on heterogeneous parts (measured: @@ -1047,6 +1477,7 @@ fn run_starts_with( areas: &[Scalar], strategy: PlacementStrategy, nfp_cache: Option<&nfp::NfpCache>, + effort: SeparationEffort, ) -> Vec<(NestSolution, Scalar)> { (0..starts as u64) .map(|k| { @@ -1059,6 +1490,7 @@ fn run_starts_with( starts as u64, strategy, nfp_cache, + effort, ); let area = placed_area(&sol, areas); (sol, area) @@ -1084,6 +1516,7 @@ fn run_starts( areas, PlacementStrategy::Sampling, None, + SeparationEffort::Full, ) } @@ -1108,6 +1541,7 @@ fn run_starts_with( areas: &[Scalar], strategy: PlacementStrategy, nfp_cache: Option<&nfp::NfpCache>, + effort: SeparationEffort, ) -> Vec<(NestSolution, Scalar)> { std::thread::scope(|scope| { let handles: Vec<_> = (0..starts as u64) @@ -1117,7 +1551,7 @@ fn run_starts_with( // seed and order policy have a single definition, so the two builds cannot // drift. The `Problem` and the optional `NfpCache` cross the threads as // immutable references (no interior mutability — the sanctioned-exception - // argument, docs/03 §6/§8 rule 9). + // argument, docs/03 §6/§8 rule 9). `effort` is `Copy`, captured per start. let sol = multistart_start_imported( problem, qty, @@ -1127,6 +1561,7 @@ fn run_starts_with( starts as u64, strategy, nfp_cache, + effort, ); let area = placed_area(&sol, areas); (sol, area) diff --git a/crates/optimizer/src/nfp/mod.rs b/crates/optimizer/src/nfp/mod.rs index a186c5c..7833e3f 100644 --- a/crates/optimizer/src/nfp/mod.rs +++ b/crates/optimizer/src/nfp/mod.rs @@ -431,12 +431,23 @@ impl NfpCache { #[must_use] pub fn candidates(region: &Shapes, loss_offsets: (i64, i64)) -> Vec<(i64, i64, i64)> { let (bx, by) = loss_offsets; - let mut out: Vec<(i64, i64, i64)> = region - .iter() - .flatten() - .flatten() - .map(|p| (10 * (p.x + bx) + (p.y + by), p.x, p.y)) - .collect(); + // Pre-size to the exact vertex count (nested flatten's size_hint is imprecise, so `collect` would + // otherwise re-allocate as the region grows) — byte-identical, just fewer allocations. + let cap = region.iter().flatten().map(Vec::len).sum(); + let mut out: Vec<(i64, i64, i64)> = Vec::with_capacity(cap); + // TEMP column-weight sweep (REVERT after picking a value): default 10 = shipped. + #[allow(clippy::disallowed_methods)] + let w: i64 = std::env::var("IRONNEST_XW") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(10); + out.extend( + region + .iter() + .flatten() + .flatten() + .map(|p| (w * (p.x + bx) + (p.y + by), p.x, p.y)), + ); out.sort_unstable(); out.dedup(); out diff --git a/crates/optimizer/src/nfp/place.rs b/crates/optimizer/src/nfp/place.rs index 8d98acd..59771a4 100644 --- a/crates/optimizer/src/nfp/place.rs +++ b/crates/optimizer/src/nfp/place.rs @@ -16,6 +16,7 @@ //! §5.3). Verify rejections advance deterministically to the next candidate and are counted. use ironnest_cde::entities::{Item, Layout}; +use ironnest_geo::primitives::SPolygon; use ironnest_geo::{DTransformation, Scalar}; use super::geom::{quantize, unquantize}; @@ -57,6 +58,11 @@ pub(crate) fn place_pass( let Some(item) = entities[t].as_ref() else { continue; }; + // Scratch collision shape, cloned ONCE per type (not per instance): every instance of `t` + // shares the same base geometry, and the buffer is fully overwritten by `transform_from` on + // each pose test, so reuse is byte-identical — it only removes a per-placement clone. + let mut buffer = (*item.shape_cd).clone(); + buffer.surrogate = None; while placed_per_type[t] < qty[t] { if try_place_one( layout, @@ -68,6 +74,7 @@ pub(crate) fn place_pass( budget, placed, stats, + &mut buffer, ) { placed_per_type[t] += 1; } else { @@ -90,6 +97,7 @@ fn try_place_one( budget: u64, placed: &mut Vec, stats: &mut NfpPlaceStats, + buffer: &mut SPolygon, ) -> bool { // Candidates across the allowed rotations, merged under the exact lexicographic tie-break // (cost, rot_idx, x, y) — docs/03 §5.2. @@ -97,17 +105,17 @@ fn try_place_one( let mut cands: Vec<(i64, usize, i64, i64)> = Vec::new(); for r in 0..n_rots { let region = cache.feasible_region(t, r, placed); - for (cost, x, y) in candidates(®ion, cache.loss_offsets(t, r)) { + let rot_cands = candidates(®ion, cache.loss_offsets(t, r)); + cands.reserve(rot_cands.len()); + for (cost, x, y) in rot_cands { cands.push((cost, r, x, y)); } } cands.sort_unstable(); - let mut buffer = (*item.shape_cd).clone(); - buffer.surrogate = None; for &(_, r, x, y) in &cands { let (fx, fy) = (unquantize(x), unquantize(y)); - if search::feasible_at(layout.cde(), item, &mut buffer, rotations_rad[t][r], fx, fy) { + if search::feasible_at(layout.cde(), item, buffer, rotations_rad[t][r], fx, fy) { layout.place_item(item, DTransformation::new(rotations_rad[t][r], (fx, fy))); placed.push(PlacedObstacle { item_id: t, diff --git a/crates/optimizer/src/sep/mod.rs b/crates/optimizer/src/sep/mod.rs index 48087e9..695cf11 100644 --- a/crates/optimizer/src/sep/mod.rs +++ b/crates/optimizer/src/sep/mod.rs @@ -26,8 +26,10 @@ mod search; mod separator; mod tracker; +use crate::SeparationEffort; use crate::prng::Prng; use ironnest_cde::entities::{Item, Layout}; +use ironnest_geo::Scalar; use search::SampleConfig; use separator::SepConfig; use tracker::CollisionTracker; @@ -48,9 +50,31 @@ const SEP_CONFIG: SepConfig = SepConfig { /// Poses sampled when seeding a new (overlapping) part at its lowest-overlap position. Fixed. const SEED_SAMPLES: usize = 400; +/// [`SeparationEffort::Fast`] per-insertion budget: ~1/8th the work of [`SEP_CONFIG`]. On an +/// over-subscribed pack most inserts fail regardless of budget, and the full budget's marginal +/// placements are rare — so a much smaller budget trades a handful of parts for a large wall-clock cut +/// (docs/04 Tier 0b). All integers; the reduced counts keep the search deterministic. +const SEP_CONFIG_FAST: SepConfig = SepConfig { + sample: SampleConfig { + n_container_samples: 30, + n_focussed_samples: 15, + n_coord_descents: 2, + }, + iter_no_imprv_limit: 40, + strike_limit: 2, +}; + +/// [`SeparationEffort::Fast`] seed-sample count (vs [`SEED_SAMPLES`]). +const SEED_SAMPLES_FAST: usize = 100; + /// Tries to insert every still-unplaced instance (largest-first) via overlap-then-separate, updating /// `placed_per_type`. Runs after the constructive `improve()` pass; a no-op when nothing is unplaced /// (so the dense rectangular cases skip it entirely). +/// +/// `effort` selects the tail's aggressiveness. [`SeparationEffort::Full`] is the shipped, byte-stable +/// behavior (full budget, every unplaced part attempted). [`SeparationEffort::Fast`] adds an +/// area-conservation skip of provably-hopeless inserts and runs a reduced per-insert budget — a +/// deliberately different (re-blessed) layout that is far cheaper on over-subscribed packs (docs/04). pub fn run_separation( layout: &mut Layout, entities: &[Option], @@ -58,14 +82,40 @@ pub fn run_separation( qty: &[usize], prng: &mut Prng, placed_per_type: &mut [usize], + effort: SeparationEffort, ) { + let fast = effort == SeparationEffort::Fast; + let (seed_samples, sep_cfg) = if fast { + (SEED_SAMPLES_FAST, SEP_CONFIG_FAST) + } else { + (SEED_SAMPLES, SEP_CONFIG) + }; + + // Area-conservation feasibility bound (Fast only, docs/04 Tier 0a): placed CD footprints are + // pairwise disjoint and lie inside the container's CD region, so their areas sum to <= the + // container CD area; a candidate whose CD area would push that sum past the container CD area + // cannot be placed collision-free by ANY rearrangement, so the separator is guaranteed to fail. + // Skipping it is SOUND (never skips a placeable part). Using the raw container CD area (ignoring + // keep-out zones, which only reduce real capacity) keeps the estimate an over-estimate of free + // area — the safe direction. `pi.shape.area` is rigid-invariant, so it equals the type's CD area. + let container_cd_area = layout.container.outer_cd.area; + let mut placed_cd_area: Scalar = layout.placed_items.values().map(|pi| pi.shape.area).sum(); + for &item_id in order { let Some(item) = entities[item_id].as_ref() else { continue; }; + let part_cd_area = item.shape_cd.area; while placed_per_type[item_id] < qty[item_id] { - if try_insert(layout, entities, item, prng) { + if fast && placed_cd_area + part_cd_area > container_cd_area { + // No room by area conservation — a guaranteed separator failure. Every further copy + // of this type is at least as large, so stop this type (matches the fail-then-break + // control flow below). + break; + } + if try_insert(layout, entities, item, prng, seed_samples, sep_cfg) { placed_per_type[item_id] += 1; + placed_cd_area += part_cd_area; } else { // If one more of this type cannot be made to fit, neither can further copies. break; @@ -74,23 +124,52 @@ pub fn run_separation( } } +/// Runs the full-layout GLS separator over the CURRENT layout (every placed part movable — it samples +/// the allowed rotation set and shoves neighbours), returning `true` iff it reached zero overlap. +/// +/// Unlike [`run_separation`] (which seeds *unplaced* parts and only moves their colliding neighbours), +/// this pressures **already-placed, non-overlapping** parts — which the separator otherwise never +/// touches. It is the primitive the densification compaction (docs/04 Tier D) drives *after* shrinking +/// the container: the shrink makes some placed parts overlap the smaller boundary / each other, and +/// this pulls the whole arrangement inward to a tighter feasible packing. +pub fn separate_layout(layout: &mut Layout, entities: &[Option], prng: &mut Prng) -> bool { + // A moderately stronger budget than the leftover-insertion `SEP_CONFIG`: compaction re-packs the + // whole layout against a tightening boundary, which benefits from more strikes (each rolls back to + // the incumbent but keeps the evolved GLS weights — the pressure that lets a wedged part jump). + // Opt-in (`Max`) so the extra cost is chosen. All integers — never a wall clock. + const DENSIFY_SEP: SepConfig = SepConfig { + sample: SampleConfig { + n_container_samples: 80, + n_focussed_samples: 40, + n_coord_descents: 3, + }, + iter_no_imprv_limit: 200, + strike_limit: 6, + }; + let mut tracker = CollisionTracker::new(layout); + separator::separate(layout, entities, &mut tracker, prng, DENSIFY_SEP) +} + /// Attempts to add one `item`: seed it (allowing overlap), separate the whole layout, and keep it iff -/// the layout becomes feasible. Returns whether the item was kept. +/// the layout becomes feasible. Returns whether the item was kept. `seed_samples`/`sep_cfg` are the +/// effort-selected budgets ([`SEED_SAMPLES`]/[`SEP_CONFIG`] for `Full`). fn try_insert( layout: &mut Layout, entities: &[Option], item: &Item, prng: &mut Prng, + seed_samples: usize, + sep_cfg: SepConfig, ) -> bool { let snapshot = layout.save(); - let Some(seed_dt) = search::lowest_overlap_pose(layout, item, prng, SEED_SAMPLES) else { + let Some(seed_dt) = search::lowest_overlap_pose(layout, item, prng, seed_samples) else { return false; // the item does not fit the container in any orientation at all }; layout.place_item(item, seed_dt); let mut tracker = CollisionTracker::new(layout); - let feasible = separator::separate(layout, entities, &mut tracker, prng, SEP_CONFIG); + let feasible = separator::separate(layout, entities, &mut tracker, prng, sep_cfg); // `separate`'s boolean is advisory (it reflects the proxy/CDE tracker's `total_loss`); the exact // `Layout::is_feasible` is the sole arbiter we keep on. They can disagree only conservatively — diff --git a/crates/optimizer/tests/nfp_mode.rs b/crates/optimizer/tests/nfp_mode.rs index 279f0c5..372f56c 100644 --- a/crates/optimizer/tests/nfp_mode.rs +++ b/crates/optimizer/tests/nfp_mode.rs @@ -8,8 +8,9 @@ //! alone), and the input-domain error surface. use ironnest_optimizer::{ - NestConfig, NestError, PlacementStrategy, Scalar, Sheet, nest_multi_multistart_per_item, - nest_multi_with_config, nest_multistart_per_item, nest_with_config, + NestConfig, NestError, PlacementStrategy, Scalar, SeparationEffort, Sheet, + nest_multi_multistart_per_item, nest_multi_with_config, nest_multistart_per_item, + nest_with_config, nest_with_prepared, prepare, }; const CARDINAL: [Scalar; 4] = [0.0, 90.0, 180.0, 270.0]; @@ -29,6 +30,7 @@ fn config(strategy: PlacementStrategy, min_sep: Scalar, seed: u64, restarts: usi budget: 400, restarts, strategy, + separation_effort: SeparationEffort::Full, } } @@ -45,6 +47,86 @@ fn nfp_mode_is_byte_deterministic() { assert!(!a.placements.is_empty()); } +#[test] +fn prepared_equals_nest_with_config_byte_for_byte() { + // Tier 2 (docs/04): prepare + nest_with_prepared must produce byte-identical placements to + // nest_with_config with the matching NestConfig — for BOTH strategies, and across multiple + // containers reusing one prepared library (the part-in-part reuse contract). + let items = vec![rect(13.0, 7.0), rect(10.0, 10.0)]; + let rots = cardinal_per_item(2); + let containers = [rect(50.0, 50.0), rect(41.0, 39.0), rect(60.0, 30.0)]; + let qtys: [&[usize]; 3] = [&[4, 3], &[3, 2], &[5, 1]]; + + for strategy in [PlacementStrategy::Nfp, PlacementStrategy::Sampling] { + let min_sep = 0.375; + let prepared = prepare(&items, &rots, min_sep, strategy).unwrap(); + for (container, qty) in containers.iter().zip(qtys) { + for (seed, restarts) in [(7u64, 1usize), (3, 2)] { + let cfg = NestConfig { + min_sep, + seed, + budget: 400, + restarts, + strategy, + separation_effort: SeparationEffort::Full, + }; + let via_config = + nest_with_config(&items, qty, container, &[], &rots, &cfg).unwrap(); + let via_prepared = nest_with_prepared( + &prepared, + qty, + container, + &[], + seed, + 400, + restarts, + SeparationEffort::Full, + ) + .unwrap(); + assert_eq!( + via_config, via_prepared, + "prepared reuse must be byte-identical to nest_with_config \ + (strategy={strategy:?}, seed={seed}, restarts={restarts})" + ); + } + } + } +} + +#[test] +fn prepared_fast_effort_matches_config_fast() { + // The Fast separation tier flows through the prepared path identically. + let items = vec![rect(10.0, 10.0)]; + let rots = cardinal_per_item(1); + let container = rect(25.0, 25.0); + let qty = [12usize]; + let prepared = prepare(&items, &rots, 0.0, PlacementStrategy::Nfp).unwrap(); + let cfg = NestConfig { + min_sep: 0.0, + seed: 7, + budget: 400, + restarts: 1, + strategy: PlacementStrategy::Nfp, + separation_effort: SeparationEffort::Fast, + }; + let via_config = nest_with_config(&items, &qty, &container, &[], &rots, &cfg).unwrap(); + let via_prepared = nest_with_prepared( + &prepared, + &qty, + &container, + &[], + 7, + 400, + 1, + SeparationEffort::Fast, + ) + .unwrap(); + assert_eq!( + via_config, via_prepared, + "Fast effort must match through prepared" + ); +} + #[test] fn sampling_config_equals_multistart_byte_for_byte() { // The frozen-wrapper contract (docs/03 §7): nest_with_config(Sampling) IS diff --git a/crates/optimizer/tests/snapshots/golden__golden_placements_match_snapshot.snap b/crates/optimizer/tests/snapshots/golden__golden_placements_match_snapshot.snap index 1664ff1..9687e9c 100644 --- a/crates/optimizer/tests/snapshots/golden__golden_placements_match_snapshot.snap +++ b/crates/optimizer/tests/snapshots/golden__golden_placements_match_snapshot.snap @@ -1,6 +1,5 @@ --- source: crates/optimizer/tests/golden.rs -assertion_line: 40 expression: run_golden_dump() --- # mixed-rects @@ -147,3 +146,20 @@ unplaced 0 0 0 0 13.062514305114748 38.18742942810059 0 0 34.70566177368164 25.624972343444835 0 unplaced +# nfp-fast-reduced-budget +0 0.0000152587890625 0.0000152587890625 0 +0 0.0000152587890625 10.000030517578125 0 +0 10.000030517578125 0.0000152587890625 0 +0 10.000030517578125 10.000030517578125 0 +unplaced 0 0 0 0 0 0 0 0 +# nfp-fast-area-skip +0 0.0000152587890625 0.0000152587890625 0 +0 0.0000152587890625 10.000030517578125 0 +0 0.0000152587890625 20.000045776367188 0 +0 10.000030517578125 0.0000152587890625 0 +0 10.000030517578125 10.000030517578125 0 +0 10.000030517578125 20.000045776367188 0 +0 20.000045776367188 0.0000152587890625 0 +0 20.000045776367188 10.000030517578125 0 +0 20.000045776367188 20.000045776367188 0 +unplaced 0 0 0 diff --git a/crates/py/src/lib.rs b/crates/py/src/lib.rs index 1962ea8..90c27e1 100644 --- a/crates/py/src/lib.rs +++ b/crates/py/src/lib.rs @@ -12,10 +12,11 @@ //! byte-identical, cross-platform-reproducible output (proven by the Phase-3 golden). use ironnest_core::{ - NestConfig, PlacementStrategy, Sheet, + NestConfig, PlacementStrategy, PreparedParts, SeparationEffort, Sheet, nest_multi_multistart_per_item as nest_multi_multistart_impl, nest_multi_per_item as nest_multi_impl, nest_multi_with_config, nest_multistart_per_item as nest_multistart_impl, nest_per_item as nest_impl, nest_with_config, + nest_with_prepared, prepare as core_prepare, }; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; @@ -129,6 +130,22 @@ fn parse_strategy(strategy: &str) -> PyResult { } } +/// Parses the `separation_effort` kwarg: `"full"` (the shipped, byte-identical separation tail — the +/// default) or `"fast"` (prune provably-hopeless separation attempts + run a reduced per-insert budget; +/// far faster on over-subscribed packs at the cost of a **deliberately different, still-feasible** +/// layout — the engine re-validates nothing, but the consumer does). Raises `ValueError` otherwise — +/// never a silent fallback (a placement-changing knob must fail loudly). +fn parse_separation_effort(effort: &str) -> PyResult { + match effort { + "full" => Ok(SeparationEffort::Full), + "fast" => Ok(SeparationEffort::Fast), + "max" => Ok(SeparationEffort::Max), + other => Err(PyValueError::new_err(format!( + "separation_effort must be \"full\", \"fast\", or \"max\", got \"{other}\"" + ))), + } +} + /// Nest `items` (one outline per part type, each `[[x, y], …]` in item-local coordinates) into a /// single irregular `container` outline. /// @@ -183,6 +200,17 @@ fn parse_strategy(strategy: &str) -> PyResult { /// the single-start nest. Higher values trade run time (≈ linear in ``restarts``) for packing /// density on heterogeneous parts, where a single greedy construction lands in a seed-dependent /// basin. Still fully cross-platform byte-identical for the same arguments. +/// separation_effort : str, optional +/// How hard the post-construction separation search tries to place the leftovers. On an +/// **over-subscribed** job (more parts requested than fit) this tail dominates the run time. +/// +/// * ``"full"`` (default) — attempt every leftover at the full separator budget. **Byte-identical +/// to every prior release.** +/// * ``"fast"`` — skip provably-hopeless attempts (area conservation) and run a reduced per-insert +/// budget. Much faster on over-subscribed jobs, at the cost of a **deliberately different** +/// (but still fully feasible) layout — it is a distinct, still cross-platform byte-identical +/// result, not the ``"full"`` one. Recommended when the caller re-validates every layout and +/// cares about wall-clock. Raises ``ValueError`` on any other value. /// /// Returns /// ------- @@ -193,7 +221,7 @@ fn parse_strategy(strategy: &str) -> PyResult { /// /// The same arguments always produce a byte-identical result. #[pyfunction] -#[pyo3(signature = (items, qty, container, holes, min_sep, rotations, seed, budget, restarts=1, strategy="sampling"))] +#[pyo3(signature = (items, qty, container, holes, min_sep, rotations, seed, budget, restarts=1, strategy="sampling", separation_effort="full"))] #[allow(clippy::needless_pass_by_value)] // PyO3 marshals owned Vecs out of the Python objects #[allow(clippy::too_many_arguments)] // injected `py` GIL token + the oracle's input surface (mirrors the Rust API) fn nest( @@ -208,6 +236,7 @@ fn nest( budget: u64, restarts: usize, strategy: &str, + separation_effort: &str, ) -> PyResult<(Vec, Vec)> { if items.len() != qty.len() { return Err(PyValueError::new_err(format!( @@ -222,6 +251,7 @@ fn nest( // `Vec>` the `Ungil` solve closure can take by reference. let rotations = parse_rotations(&rotations, items.len())?; let strategy = parse_strategy(strategy)?; + let effort = parse_separation_effort(separation_effort)?; // Release the GIL for the (synchronous, potentially long) solve so the caller's other Python // threads keep running — without this a worker-thread nest freezes the whole interpreter: the @@ -232,16 +262,25 @@ fn nest( // stays byte-identical. let solution = py .detach(|| { - // Sampling keeps the historical branches byte-for-byte; NFP routes through the config - // entry point (docs/03). Both honour the determinism contract. + // With the DEFAULT separation effort (`Full`), Sampling keeps the historical branches + // byte-for-byte and NFP routes through the config entry point (docs/03) — every existing + // golden holds. A non-default `separation_effort` is placement-changing, so it routes + // through the config path for BOTH strategies (nest_with_config handles Sampling too). match strategy { - PlacementStrategy::Sampling if restarts <= 1 => Ok(nest_impl( - &items, &qty, &container, &holes, min_sep, &rotations, seed, budget, - )), - PlacementStrategy::Sampling => Ok(nest_multistart_impl( - &items, &qty, &container, &holes, min_sep, &rotations, seed, budget, restarts, - )), - PlacementStrategy::Nfp => nest_with_config( + PlacementStrategy::Sampling + if effort == SeparationEffort::Full && restarts <= 1 => + { + Ok(nest_impl( + &items, &qty, &container, &holes, min_sep, &rotations, seed, budget, + )) + } + PlacementStrategy::Sampling if effort == SeparationEffort::Full => { + Ok(nest_multistart_impl( + &items, &qty, &container, &holes, min_sep, &rotations, seed, budget, + restarts, + )) + } + _ => nest_with_config( &items, &qty, &container, @@ -253,6 +292,7 @@ fn nest( budget, restarts, strategy, + separation_effort: effort, }, ), } @@ -293,7 +333,7 @@ fn nest( /// Each sheet is nested with a seed derived deterministically from `seed`, so the result is /// byte-identical for the same arguments. #[pyfunction] -#[pyo3(signature = (items, qty, sheets, min_sep, rotations, seed, budget, restarts=1, strategy="sampling"))] +#[pyo3(signature = (items, qty, sheets, min_sep, rotations, seed, budget, restarts=1, strategy="sampling", separation_effort="full"))] #[allow(clippy::needless_pass_by_value)] #[allow(clippy::too_many_arguments)] // `py` is the injected GIL token (not an oracle input); the rest are the oracle's input surface fn nest_multi( @@ -307,6 +347,7 @@ fn nest_multi( budget: u64, restarts: usize, strategy: &str, + separation_effort: &str, ) -> PyResult<(Vec>, Vec)> { if items.len() != qty.len() { return Err(PyValueError::new_err(format!( @@ -320,6 +361,7 @@ fn nest_multi( // note in `nest`. let rotations = parse_rotations(&rotations, items.len())?; let strategy = parse_strategy(strategy)?; + let effort = parse_separation_effort(separation_effort)?; let sheets: Vec = sheets .into_iter() @@ -327,17 +369,20 @@ fn nest_multi( .collect(); // Release the GIL for the solve — see the note in `nest` for the why and the safety argument. - // `restarts <= 1` is the historical single-start spill; `> 1` packs each sheet best-of-K. Both - // honour the determinism contract. + // `restarts <= 1` is the historical single-start spill; `> 1` packs each sheet best-of-K. A + // non-default `separation_effort` is placement-changing and routes through the config path for + // both strategies (the `Full` default keeps the historical branches byte-for-byte). let solution = py .detach(|| match strategy { - PlacementStrategy::Sampling if restarts <= 1 => Ok(nest_multi_impl( - &items, &qty, &sheets, min_sep, &rotations, seed, budget, - )), - PlacementStrategy::Sampling => Ok(nest_multi_multistart_impl( - &items, &qty, &sheets, min_sep, &rotations, seed, budget, restarts, - )), - PlacementStrategy::Nfp => nest_multi_with_config( + PlacementStrategy::Sampling if effort == SeparationEffort::Full && restarts <= 1 => Ok( + nest_multi_impl(&items, &qty, &sheets, min_sep, &rotations, seed, budget), + ), + PlacementStrategy::Sampling if effort == SeparationEffort::Full => { + Ok(nest_multi_multistart_impl( + &items, &qty, &sheets, min_sep, &rotations, seed, budget, restarts, + )) + } + _ => nest_multi_with_config( &items, &qty, &sheets, @@ -348,6 +393,7 @@ fn nest_multi( budget, restarts, strategy, + separation_effort: effort, }, ), }) @@ -367,6 +413,122 @@ fn nest_multi( Ok((per_sheet, solution.unplaced)) } +/// A pre-imported, container-independent part library reusable across many [`PreparedParts::nest`] +/// calls (the "part-in-part" workload — one part set nested into many parent shapes). Built once by +/// [`prepare`], it holds the imported items and, for ``strategy="nfp"``, the O((P·R)²) pairwise NFP +/// table — so nesting the same parts into ``M`` containers pays that precompute **once** instead of +/// ``M`` times (docs/04 Tier 2). ``prepared.nest(...)`` is byte-identical to the equivalent +/// ``ironnest.nest(...)`` call. +/// +/// Immutable (``frozen``): safe to hold and reuse across calls and threads. +#[pyclass(frozen, name = "PreparedParts")] +struct PyPreparedParts { + inner: PreparedParts, + /// The prepared item-type count, for validating each ``nest`` call's ``qty``. + n_items: usize, + min_sep: f64, +} + +#[pymethods] +impl PyPreparedParts { + /// Nest this prepared part library into one ``container`` (with ``holes``). ``qty``, the + /// container, ``seed``, ``budget``, ``restarts`` and ``separation_effort`` are per call — the + /// heavy container-independent precompute is reused. ``min_sep`` and ``strategy`` were fixed at + /// [`prepare`] time. Returns ``(placements, unplaced)`` exactly as [`nest`]. + #[pyo3(signature = (qty, container, holes, seed, budget, restarts=1, separation_effort="full"))] + #[allow(clippy::needless_pass_by_value)] + #[allow(clippy::too_many_arguments)] + fn nest( + &self, + py: Python<'_>, + qty: Vec, + container: Vec<[f64; 2]>, + holes: Vec>, + seed: u64, + budget: u64, + restarts: usize, + separation_effort: &str, + ) -> PyResult<(Vec, Vec)> { + if qty.len() != self.n_items { + return Err(PyValueError::new_err(format!( + "qty ({}) must match the prepared item count ({})", + qty.len(), + self.n_items + ))); + } + let effort = parse_separation_effort(separation_effort)?; + // Release the GIL for the solve — same rationale/safety as `nest` (owned Send inputs, plain + // data out, no threads inside the solve). + let solution = py + .detach(|| { + nest_with_prepared( + &self.inner, + &qty, + &container, + &holes, + seed, + budget, + restarts, + effort, + ) + }) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + let placements = solution + .placements + .iter() + .map(|p| (p.item, p.x, p.y, p.rotation_deg)) + .collect(); + Ok((placements, solution.unplaced)) + } + + /// The separation offset the parts were imported with (containers passed to ``nest`` reuse it). + #[getter] + fn min_sep(&self) -> f64 { + self.min_sep + } +} + +/// Pre-import a part library for reuse across many containers (docs/04 Tier 2). +/// +/// Parameters +/// ---------- +/// items : list[list[tuple[float, float]]] +/// One polygon outline per item *type*, as in :func:`nest`. +/// rotations : list[float] | list[list[float]] +/// Allowed orientations, uniform or per-item — parsed exactly as :func:`nest`. +/// min_sep : float +/// Minimum separation, fixed for every ``nest`` call off this handle (a container nested with a +/// different gap would not match the parts' footprints). +/// strategy : str, optional +/// ``"nfp"`` (default here — reuse pays off most for NFP, which builds the O(P²) pairwise table) +/// or ``"sampling"``. Must match the intent of subsequent ``nest`` calls. +/// +/// Returns +/// ------- +/// PreparedParts +/// A handle whose ``nest(qty, container, holes, seed, budget, restarts=1, +/// separation_effort="full")`` method nests into one container, reusing the precompute. +#[pyfunction] +#[pyo3(signature = (items, rotations, min_sep, strategy="nfp"))] +#[allow(clippy::needless_pass_by_value)] +fn prepare( + items: Vec>, + rotations: Bound<'_, PyAny>, + min_sep: f64, + strategy: &str, +) -> PyResult { + let n_items = items.len(); + let rotations = parse_rotations(&rotations, n_items)?; + let strategy = parse_strategy(strategy)?; + let inner = core_prepare(&items, &rotations, min_sep, strategy) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + Ok(PyPreparedParts { + inner, + n_items, + min_sep, + }) +} + /// The `ironnest` Python module. (The function name must match `[lib] name` for the abi3 /// `PyInit_ironnest` symbol.) #[pymodule] @@ -384,5 +546,7 @@ fn ironnest(m: &Bound<'_, PyModule>) -> PyResult<()> { )?; m.add_function(wrap_pyfunction!(nest, m)?)?; m.add_function(wrap_pyfunction!(nest_multi, m)?)?; + m.add_function(wrap_pyfunction!(prepare, m)?)?; + m.add_class::()?; Ok(()) } diff --git a/docs/04-performance-remediation-plan.md b/docs/04-performance-remediation-plan.md new file mode 100644 index 0000000..267c233 --- /dev/null +++ b/docs/04-performance-remediation-plan.md @@ -0,0 +1,358 @@ +# 04 — Performance remediation plan (NFP-mode pack timing) + +**Status: 2026-07-10.** Review + empirical profiling of the "265 s pack" (consumer mixed-13 in NFP +mode). This document is the source of truth for the perf work. It (a) verifies the three proposed +inefficiencies against source, (b) reprioritizes them against measured data, and (c) lays out the +full remediation workflows. It defers to the two prime directives in `CLAUDE.md` throughout. + +> **Headline:** the three proposed fixes are all *real*, but they target **~1.4 % of the runtime**. +> Measured, **~98 % of the 265 s pack is the shared `sep::run_separation` tail** thrashing on the +> over-subscribed unplaced parts — a cost sampling mode pays too. The remediation must lead with the +> separation tail, and the biggest sep levers are **placement-changing** (PRNG-coupled → golden +> re-bless), not free. + +--- + +## 1. Measured baseline (the reframing) + +Reproduced with `crates/optimizer/examples/perf_diag.rs` (throwaway, uncommitted) + +`examples/bench.rs` + `examples/nfp_spike.rs`, release build, consumer mixed-13 shapes, 122 +instances in a 120×60 sheet, `min_sep = 0.375`, `restarts = 1`: + +| Phase | Time | Placed | Share | +|---|---:|---:|---:| +| NFP cache build (eager O((P·R)²) pairwise table + import) | ~459 ms | — | 0.5 % | +| NFP construction only (`nfp_spike`, lazy cache) | ~908 ms | 51/122 | 0.9 % | +| **NFP full pipeline, restarts=1** | **96,534 ms** | 53/122 | 100 % | +| → implied `sep::run_separation` tail | **~95,000 ms** | +2 parts | **~98.6 %** | +| Sampling full pipeline (no NFP work at all) | 111,392 ms | 55/122 | — | + +Two facts fall out and drive everything below: + +1. **The separation tail dominates by ~100×.** Construction + build ≈ 1.4 s; the rest is + `sep::run_separation`. It runs on **every** strategy (`lib.rs:896`, outside the `match strategy` + block), which is why **sampling mode is even slower (111 s) with zero NFP-specific work**. The + 265 s pack is a *separation* problem, not an NFP problem. +2. **The sep tail is almost pure waste on this pack.** It adds **2 parts over 95 s** (~47 s/part) + because the pack is ~1.7× over-subscribed: ~69 parts never fit, and `run_separation` runs the + **full** separator budget on each hopeless one before giving up. + +### Why the sep tail is so expensive +`run_separation` (`sep/mod.rs:54`) iterates every still-unplaced instance and calls `try_insert`, +which per attempt does: +- `search::lowest_overlap_pose` — **400** seed samples (`SEED_SAMPLES`), each a CDE overlap eval; +- `layout.save()` snapshot (clones container + slotmap + CDE snapshot); +- `separator::separate` — up to `strike_limit(4) × iter_no_imprv_limit(150)` = **600** `move_items` + sweeps (`separator.rs:55`), each sweep re-searching **every** currently-overlapping part with + `80 + 40` samples × `3` coord descents (`SEP_CONFIG`), and `save`/`restore` on each improvement; +- innermost: `CDEngine::collect_poly_collisions` + `proxy::quantify_collision_poly_poly`, reaching + ~10⁶–10⁷ calls per pack. + +--- + +## 2. Verification of the three proposed fixes + +All three are **real** and correctly located. Verified against source (29-agent adversarial review + +first-hand read). Corrected impact and caveats: + +### C1 — Rotation branch-and-bound pruning · `nfp/place.rs:96` — REAL, byte-identical *with care*, small +- **Confirmed:** `try_place_one` computes `feasible_region` (a full `difference(ifp,&clips)`) for + **all** `n_rots` rotations up front (`place.rs:98-103`), then sorts and first-feasible-walks. +- **The LB is valid:** `region ⊆ IFP` as a point set; the cost `10·(x+bx)+(y+by)` (`mod.rs:438`) is + linear with strictly-positive coefficients, so its min over the IFP polygon is at an IFP vertex and + equals `candidates(&ifp,loss).first().cost`. Every region vertex — *including* the new vertices the + boolean subtraction creates — is a point inside the IFP, hence has cost ≥ LB(r). So LB(r) lower- + bounds every candidate rotation r can emit. ✔ +- **Byte-identical only with the pair tie-break.** The winner is the lex-min `(cost, rot_idx, x, y)` + that passes CDE. The sound skip predicate is the **pair** comparison + `(LB_cost(r), r) > (best.cost, best.rot_idx)` — **never** a cost-only `LB(r) ≥ best_cost`, which + silently returns the wrong rotation on a cost tie (a byte-identity break, i.e. a bug). Implement as + a **lazy lower-bound k-way merge** (open a rotation's region only before verifying a candidate whose + key could beat it), not a per-rotation greedy commit. Only `NfpPlaceStats.verify_rejections` + changes, and the golden snapshots only `item x y rot` (`golden_dump.rs:418`, `tests/golden.rs`), so + placements stay byte-identical. +- **Corrected impact:** the blowup is bounded by `n_rots = 4` (cardinal set), **not 18×**. It cuts + only region construction in the two construction/refill `place_pass` calls; realistic **~1.3× on + construction**, ≈ **1 % of total** wall-clock on the sep-dominated pack. + +### C2 — `PreparedParts` cross-call cache reuse · `lib.rs:513` / `nfp/mod.rs:231` — REAL, byte-identical, large *for the right workload* +- **Confirmed & byte-identical.** `PartsGeometry` is a pure function of `(items, rotations, min_sep)` + and container-independent (already `Arc`-shared across sheets in `nest_multi_with_config:605-621` + by the exact same argument, goldens untouched). `import_item` takes no container; the only per-call + work is `import_container` + `NfpCache::assemble`'s IFPs. Reuse just moves the boundary from + "within one call" to "across calls." No interior mutability, no PRNG. ✔ +- **Impact is workload-dependent.** For the **part-in-part M-call** loop (many calls, identical + parts, different containers) it removes the O((P·R)²) pairwise table + O(P) surrogate import on all + `M−1` subsequent calls — approaching an **M× saving on the precompute fraction**. That is the + *strongest* byte-identical win in this document **for that workload**. For the single 265 s pack it + saves ~0.46 s of 96.5 s (a rounding error) — the pack is sep-bound, not build-bound. + +### C3 — Parallelize the pairwise build · `nfp/mod.rs:231` — REAL, byte-identical *with care*, negligible +- **Confirmed feasible & byte-identical** if done right: reduce into the `BTreeMap` keyed + `(ta,ra,tb,rb)` (iteration by key, thread-order-independent), pure-integer geometry (no float + reduction), `std::thread::scope` (never `rayon`), and preserve the mirror-derivation equivalence + (`reflect`-derived NFP(B←A) must equal `canonicalize_shapes(nfp_pair(b,a))` — verify, else keep the + compute/derive split). Requires re-running the x-platform golden under `--features parallel`. +- **Corrected impact: negligible.** The whole pairwise build is ~0.46–1 s (docs/03: "1.04 s ≈ 0.35 % + of a K=16 nest"). Parallelizing 1 s of a 96.5 s wall is **statistically invisible**. **Drop it** + (or fold the build-once into C2, which subsumes it). + +--- + +## 3. The determinism dividing line (read before §4) + +The separation tail is on the **placement-deciding path** and it **consumes the PRNG** on every +attempt (`search_placement` draws per sample; `move_items` shuffles). Therefore: + +- **Any sep change that alters PRNG draw-count or control flow** — skipping a hopeless part, capping + the separator budget, early-terminating the tail — **shifts the stream for every subsequently- + attempted part and changes their placements.** These are **placement-changing** (density may move + ±, requires a deliberate golden re-bless; the consumer re-validates every layout so this is safe + for them, but it is *not* free). +- **Byte-identical sep speedups** are exactly those that keep draw-count and control flow identical + while making each iteration **cheaper**: caching per-type invariants, CDE accel tuning, collision- + query micro-opts. These speed the dominant phase with **zero output change**. + +This split is the backbone of the tiering below. + +--- + +## 4. Remediation workflows (priority order) + +### TIER 0 — Tame the separation tail. **SHIPPED 2026-07-10 as `SeparationEffort::Fast`.** + +Implemented behind a `NestConfig.separation_effort` knob (`Full` default = byte-identical shipped +behavior; `Fast` = opt-in) and the Python `separation_effort="full"|"fast"` kwarg. `Fast` combines the +sound **area-conservation skip** (0a) with a **reduced per-insert separator budget** (0b) — because on +realistically-fragmented packs the area-skip alone rarely fires (free area sits ~21%, above any one +part), so the budget cut is the effective lever. Measured, consumer mixed-13, restarts=1: + +| mode | Full | Fast | speedup | density | +|---|---:|---:|---:|---| +| NFP | 95,826 ms → 53/122 | **6,367 ms → 52/122** | **15×** | −1 part (0.8%) | +| sampling | 109,116 ms → 55/122 | **6,038 ms → 55/122** | **18×** | **0 parts** | + +Determinism: `Full` proven byte-identical (every existing golden case unchanged; verified by direct +snapshot diff). `Fast` is a distinct, still cross-platform byte-identical result (pure-integer +construction + seeded fixed-budget separator), pinned by two new golden cases (`nfp-fast-reduced-budget` +exercises the reduced separator, `nfp-fast-area-skip` exercises the skip). The knob threads through the +config path only; the sampling default path is untouched. Details below were the design; the shipped +code is `crates/optimizer/src/sep/mod.rs` (`SEP_CONFIG_FAST`, `SEED_SAMPLES_FAST`, area-skip) + +`SeparationEffort` in `lib.rs`. + +*(Original design notes — placement-changing → opt-in + re-bless:)* + +The consumer cares about wall-clock and re-validates every layout, so a faster, slightly-different- +density result is acceptable **as an opt-in**. Gate all of this behind a new +`separation_effort` / config knob whose **default reproduces today's behavior** (so the committed +golden is untouched); the consumer opts into the fast tier. + +**0a. Area-conservation hopeless-skip (biggest single win).** +- *Mechanism:* before `try_insert`, if `Σ placed CD-footprint area + this part's CD-footprint area > + container CD area`, no collision-free arrangement can exist (area is invariant under the + separator's moves), so the separator **must** fail. Skip it. +- *Expected:* on a 1.7× over-subscribed pack this short-circuits most of the ~69 hopeless attempts → + collapses ~95 s toward a few seconds. +- *Determinism:* **placement-changing** — skipping an attempt that *would have failed anyway* still + removes its PRNG draws, shifting later parts. Ship behind the opt-in knob; re-bless the golden for + the fast tier (add a fast-tier golden case). Track placed-area delta (expected ≈ 0, since skipped + attempts fail). +- *Workflow:* (1) add `container_cd_area` + running `placed_cd_area` accounting to `run_separation`; + (2) gate `try_insert` on the area test; (3) thread a `SeparationEffort` enum through + `NestConfig`→`run_separation`; (4) bench mixed-13 wall-clock + placed-area at each effort; (5) + determinism-auditor pass; (6) add a fast-tier golden case and bless it. + +**0b. Adaptive separator budget when over-subscribed.** +- *Mechanism:* scale `SEP_CONFIG` (strikes / iter_no_imprv / seed samples) down once the sheet is + near-full or after *N* consecutive failed inserts; give up the tail after a global failure streak. +- *Determinism:* placement-changing (same knob/re-bless as 0a). +- *Workflow:* add the streak counter + budget schedule to `run_separation`/`separator`; sweep the + density/time trade on mixed-13; document the Pareto point; bless. + +**0c. Snapshot-cost reduction in `try_insert` / `separate`.** +- *Mechanism:* `layout.save()`/`restore()` clone container + slotmap + CDE snapshot per attempt and + per separator improvement. An incremental undo-log (record placed/moved keys, roll back deltas) + avoids the full clone. This one **can** be byte-identical (it changes *how* state is saved, not + what is decided or drawn) — verify no float/order change. +- *Determinism:* byte-identical if the delta-restore reproduces the exact `LayoutSnapshot` state; + guard with the existing `assertions::snapshot_matches_layout` debug asserts + the golden. + +### TIER 1 — Byte-identical speedups of the dominant phase (no output change, no re-bless). + +> **MEASURED VERDICT (2026-07-10): Tier 1 does not move the needle on a sep-bound pack.** The sep +> tail's cost is *inherent* work (O(poles²) proxy + CDE query, per sample, ×600 sweeps, ×hundreds of +> samples, ×~69 hopeless parts). The two byte-identical ideas below cap out at low-single-digit %: +> 1b was empirically ruled out (a wash / within noise), and 1a's target is ~1% of a proxy term. Keep +> them only as opportunistic cleanups. **The real speedup requires reducing the work → Tier 0.** + +**1a. Cache the per-type sep proxy invariants · `sep/proxy.rs:66,80`.** — *marginal, skip unless free.* +- `calc_shape_penalty` (3× `sqrt`) + `epsilon` are rigid-invariant per type yet recomputed per pair- + quantification. BUT the dominant per-term cost is `overlap_area_proxy` — an O(poles²) loop + (~100–400 pole-pairs, each a `distance_to`/sqrt); `penalty`+`epsilon` are ~4 ops (~1% of a term). + Worse, the recomputed `sqrt` is *cheaper* than any per-type memo-map lookup that would replace it. + Net expected win **≲1%** of the sep tail. Byte-identical if done (same values/order/draw-count), but + not worth the API surface. **Recommendation: skip.** + +**1b. CDE acceleration tuning · `lib.rs:108` (`default_cde_config`) — MEASURED: no reliable win, keep (5,16).** +- Empirical sweep (throwaway env-override harness in `default_cde_config`, since reverted), sep-heavy + NFP packs, depth ∈ {4..8} × cd_threshold ∈ {8,16,32}: **`placed` was constant across every config** + (the byte-identity proof — accel-only), and wall-clock varied only ~±5% (best-of-2 noise). `(5,16)` + is already near-optimal; `depth=4` was ~1–2% faster on square packs (noise), and **`depth≥7` + clearly *hurts*** (small parts gain nothing from a deeper tree). **Do not change the defaults.** +- **1b′ (wide sheets).** The container quadtree is built over `bbox.inflate_to_square()` + (`container.rs:69`), so a high-aspect sheet under-resolves the short axis. Confirmed no material + gain on the 2:1 mixed-13 pack; only worth revisiting for extreme (≥3:1) sheets, and it touches the + forked CDE (higher risk). Deprioritized. + +**1c. NFP construction micro-opts (small, but free).** +- **C1 rotation B&B** (§2) — ~1.3× on construction, byte-identical with the pair tie-break. +- **`buffer` hoist · `place.rs:106`** — clone `item.shape_cd` **once per type** in `place_pass`, not + once per instance (a q-demand type clones q times today). Byte-identical. +- **Query-path canonicalization skip · `mod.rs:392`** — `feasible_region`'s `difference` runs full + `canonicalize_shapes` (Booth rotation + two sorts) on a throwaway region that `candidates()` + immediately re-sorts+dedups. A query-only `difference` variant that applies only the winding-fix + + zero-area drop is byte-identical (verify the candidate multiset/order is unchanged). +- **Allocation churn · `mod.rs:372,384` / `place.rs:97`** — `feasible_region` builds a throwaway + `Vec>` of `translate()`-copied clip rings per rotation per placement; `cands`/ + `candidates()` grow without capacity hints. Add a translate-on-the-fly `difference` variant + a + reused scratch buffer + `reserve`. Byte-identical (no output/order change). +- **Optional `canonical_rotation` → real Booth (O(n))** at `geom.rs:252` (currently a nested + min-scan). Byte-identical (identical least rotation); only worth it if canonicalization stays on a + hot path after the query-path skip. + +### TIER 2 — Cross-call reuse for the part-in-part workload. **SHIPPED 2026-07-10.** + +Implemented `PreparedParts` + `prepare()` + `nest_with_prepared()` (Rust) and the Python +`ironnest.prepare(items, rotations, min_sep, strategy="nfp") -> PreparedParts` with a +`prepared.nest(qty, container, holes, seed, budget, restarts, separation_effort)` method. Byte-identical +to `nest_with_config` (proven by `prepared_equals_nest_with_config_byte_for_byte` across containers / +strategies / seeds / restarts, and by the untouched goldens). Measured part-in-part (13-type library +into M=15 containers, NFP): **per-call 6,898 ms → prepared 584 ms = 11.8×**, placements identical; the +saving approaches M× as the container count grows. Refactored `import_problem` into +`make_importer`/`normalize_rotations`/`import_entities` (the container-independent item import) — all +`&self` on the importer, so the split is provably byte-identical. Subsumes C3. **Consumer follow-up +still open:** `nest_across_sheets` in `drawing_and_gcode` calls `nest()` per sheet and leaves this reuse +on the table. + +**Also shipped (byte-identical NFP-construction micro-opts):** `buffer` hoisted to one clone per type +(was per instance) in `place_pass`; `candidates()`/`cands` pre-sized. Goldens unchanged. +**Deliberately deferred:** the `feasible_region` obstacle spatial-prune (the IFP bbox ≈ the whole +container, so few obstacles' NFP bboxes fall outside it → weak) and the rotation branch-and-bound +(~1.3 % of construction at real tie-break risk). After Tier 0 + Tier 2 the region query is no longer +a dominant cost, so neither earns its complexity. + +**2a. `PreparedParts` handle (C2)** — the one large byte-identical construction win, for the M-call +part-in-part pattern. *(Original design notes:)* +- *Rust API:* `PreparedParts::prepare(items, rotations, min_sep) -> PreparedParts` (holds + `entities`, rotation sets, `min_sep`, `Arc` for NFP, `areas`), then + `nest_with_prepared(&prepared, container, holes, seed, budget, strategy) -> …`. Keyed on + `(items, rotations, min_sep)`; error if the caller reuses it with a different `min_sep`. +- *PyO3:* expose `ironnest.prepare(...) -> PreparedParts` and `prepared.nest(container, …)`; the + handle is immutable from Python (no mutation surface). +- *Also fix the consumer:* `nest_across_sheets` in `drawing_and_gcode` currently calls `nest()` per + sheet and leaves the same reuse on the table. +- *Determinism:* byte-identical (same argument the multi-sheet path already relies on). Prove with a + test asserting `prepare + N× nest_with_prepared` == `N× nest_with_config` byte-for-byte, and by the + untouched existing goldens. Subsumes C3. + +### TIER 3 — Parallelism reality check. +At `restarts=1` the pipeline is single-threaded **by determinism directive** (no threads on the +placement-deciding path). The only sanctioned parallelism is the K-start meta-loop (`--features +parallel`), which *multiplies* total work — it helps density-per-wall-clock only when the consumer +actually wants best-of-K. **C3 (parallel build)** is defensible but <1 %. There is no +determinism-legal way to parallelize a single start's sep tail or region queries. Recommendation: +document that the 265 s pack is single-threaded on purpose, and that cores are usable only via +`restarts>1 --features parallel`. + +--- + +## 4d. Tier D — Densification / remnant compaction (`SeparationEffort::Max`, v1 shipped 2026-07-10) + +The goal a consumer actually wants is **maximize the usable remnant** — compact the parts into the +smallest region so the rest of the sheet stays one clean piece. The leftover-only separator never +moves an *already-feasible* part (no overlap ⇒ no pressure), so a gappy-but-feasible layout is never +tightened. `Max` adds a compaction loop: shrink the sheet along the **slack axis** (LBF grows +full-height columns rightward, so the gaps live along the growth axis) toward the corner, re-run the +**full-layout GLS separator** (`sep::separate_layout` — moves every part, samples rotations) to pull +the arrangement inward, swap back, refill freed space, keep the arrangement with the smallest used +region (`used_extent`/`placed_used_bbox_area`). Shrink is relative to the *used* span (not the sheet) +in 1 % steps so the local separator can follow a slowly-tightening boundary. + +**Measured (cone+square, all-fit, restarts=1):** remnant 53.0 % → **55.8 %** (used width 112.8 → +106.2); packing within the used region ~78 % → ~82 %. **It works — refutes docs/02's "architectural +ceiling" (that was measured on rotation-invariant / no-slack rectangle cases + the pentagon, never a +low-util irregular case).** Fully opt-in; `Full`/`Fast` and all goldens untouched. + +**Limitation / next (the human-level gap):** the *local* GLS plateaus at ~82 % vs ~92 % achievable — +more separator budget only adds PRNG noise. Closing the rest needs a stronger **global** search: +(1) a **remnant-aware best-of-K keep-best** (today the multi-start keeps highest *area*, ignoring +which start densified tightest — an easy high-value fix); (2) **re-seed-and-separate** (sparrow's +method: place all parts overlapping in a tight box and separate from scratch, escaping the constructed +basin); (3) **simulated-annealing / ruin-and-recreate** acceptance. Verified visually via +`examples/density_remnant.rs` (writes `/tmp/nest_{full,max}.svg`). + +## 4c. Density (packing tightness) — 2026-07-10 experiments + +Prompted by a consumer nest showing large gaps between big "developed-cone" (annular-sector) parts. +Measured with `examples/density_probe.rs` (throwaway; over-subscribed so +placed-area = tightness). Root cause: the placement loss is pure column-**LBF** (`10·x_max + y_max` +in both `loss.rs` and `nfp/mod.rs candidates()`) — it prefers the bottom-left-most feasible pose, not +the *interlocked* pose tucked against a neighbor, and once placed a part is never rearranged (the GLS +squeeze runs only on **unplaced** parts). Findings: + +1. **Finer rotations do NOT help** these (roughly convex) sectors: NFP K=8 = 84.1 % at cardinal, + 12-way, and 45-step alike — at 1.5× the runtime. **Do not add rotations for cone parts.** +2. **NFP + restarts=8 beats sampling K=1 by ~2.5 pp** (84.1 % vs 81.6 %) on the sector+square case. + If the consumer runs sampling / low-K here, switch to `strategy="nfp"`, `restarts=16`. +3. **The LBF column weight is the real lever.** `X_MULTIPLIER = 3` (vs the shipped 10, inherited from + jagua) measured **+0.9 pp bricks (94.6→95.5), +0.7 pp mixed (93.1→93.8), +2.5 pp on the sectors + (84.1→86.6), pentagon flat, no regressions** across 4 corpora. The heavy `10×` over-forces strict + left-columns and leaves vertical gaps; a gentler weight packs tighter. `max(x,y)` (mode 3) is + inconsistent (helps pentagon, wrecks bricks) — not shippable. **This is placement-changing (golden + re-bless), and docs/02's standing rule is to spec density changes against the consumer's + `nest_benchmark.py --mixed` before committing** — so ship it either as an opt-in `column_weight` + knob (default 10 = byte-identical) OR change the default after the consumer A/Bs it on real jobs. +4. **Compaction is a measured dead-end** (docs/02 §11.2: "do not re-try wall/shrink compression") — + the part-targeted GLS `try_insert` squeeze already subsumes it. +5. The wide-squares 79 % (vs ~100 %) is purely the **□E contact-backoff exact-fit artifact**: exact + 200×60 = 95 squares, but 200.5×60.5 = 120 (99 %). Real sheets have `edge_margin` slack, so it never + bites; not the image's cause. + +## 5. Recommended sequence (revised after Tier 1 measurement) + +1. ~~**Tier 1 (proxy caching + CDE tuning)**~~ — **measured a wash** (§4). Ruled out; the sep tail is + inherent work. Shipped the byte-identical **`opt-level=2` dev/test build fix** instead (§6b). +2. ✅ **Tier 0 `SeparationEffort::Fast`** — **SHIPPED**. 15× (NFP) / 18× (sampling) on the mixed-13 + pack, ≤1 part density cost; opt-in, `Full` default byte-identical, two new golden cases blessed. +3. ✅ **Tier 2 `PreparedParts`** — **SHIPPED**. 11.8× on M=15 part-in-part, byte-identical; Python + `prepare()`/`prepared.nest()`. Subsumes C3. +4. ✅ **Micro-opts** — buffer hoist + capacity hints SHIPPED (byte-identical). C1 B&B + spatial-prune + deferred (marginal after Tier 0+2). +5. **0c** (incremental snapshot restore) + the consumer `nest_across_sheets` reuse — still open. +6. **Drop C3** (parallel build, <1 % — subsumed by PreparedParts). + +**Bottom line:** there is no free (byte-identical) way to make the 265 s pack dramatically faster — +its cost is the separator doing real work on ~69 unplaceable parts. The decisive lever (0a) trades a +deliberate, re-blessed placement change for ~70×, which is the right deal for a consumer that +re-validates every layout. + +## 6b. Test-suite speed (shipped 2026-07-10) + +Observed separately: `cargo test` was painfully slow. Root cause — `cargo test` builds the engine (and +the `golden_dump` bin the golden test runs ×3) at **`opt-level = 0`**, and the engine is compute-bound, +so real nests run ~10–12× slower unoptimized. The golden test alone: **~633 s at opt 0 → ~51 s at opt 2** +(release `golden_dump` is ~2.8 s/run). Fix shipped: `[profile.dev] opt-level = 2` in the root +`Cargo.toml`. Byte-identical (opt-level is codegen-only, no fast-math, no IEEE change → all goldens pass +unchanged; determinism's lever is `codegen-units`/`target-cpu`, left on the release profile). `opt-level +3` measured no faster; `codegen-units = 1` shaved only ~5 s at a compile cost — not worth it. The +*residual* ~51 s is the separation tail doing real work (this document) — the same root bottleneck, so +Tier 0 also speeds the heaviest tests. Verifies the user's hypothesis: test slowness was the engine +bottleneck, amplified ~12× by the unoptimized profile. + +## 6. Determinism checklist (every change) +- Byte-identical changes: prove with `cargo test -p ironnest-optimizer` (goldens) unchanged, plus a + determinism-auditor pass; no new `HashMap`/threads/std-trig/`mul_add`/RNG. +- Placement-changing changes (Tier 0): gate behind an opt-in whose default is today's behavior; + add a *separate* fast-tier golden case and bless it deliberately; re-run the x-platform CI golden. +- Never thread inside a single placement search. Keep `i_overlay` pinned. Keep the oracle boundary + (no machine concepts). From 1722515f7c8bbacba29d196c36afa01725500160 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Fri, 10 Jul 2026 21:18:40 -0500 Subject: [PATCH 2/4] feat(density): column_weight knob + remnant-first best-of-K + Max densify refill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the separation-effort tiers (dcfafc7). Goal: maximize the usable remnant (a clean free strip at the sheet end), not raw utilization. Reviewed + verified (determinism-auditor CLEAN; 77 tests pass; clippy -D warnings clean). - feat(api): NestConfig.column_weight (Python `column_weight=`, default 10) — the LBF horizontal weight for NFP candidate ranking (cost = w*x_max + y_max). The historical 10 over-forces strict left-columns; w=3 closes vertical gaps between irregular parts. Default 10 is bit-identical, and this REMOVES the temporary IRONNEST_XW env hook that dcfafc7 accidentally shipped (a latent placement-path env read). Exposes DENSITY_COLUMN_WEIGHT=3 + nest_with_prepared_weighted. - feat(multistart): remnant-first keep-best (NestScore: max placed area, then min used_x_max, then min used_y_max, earliest k on tie) across ALL multi-start entry points + the densify keep-best. Deterministic: all total_cmp over slotmap-order sums + libm-deterministic bboxes; restarts=1 stays byte-identical. Re-blessed the multistart-mixed golden (the only case with area-ties). - feat(sep): Max densify now uses the remnant-first score + a post-densify NFP contact refill (obstacles_from_layout + budget=0 place_pass). Measured on the cone+square remnant probe: remnant 53.0% -> 57.0% (max_x 112.8 -> 103.4). - test: nfp-max-densify golden case pins the Max tier's cross-platform determinism; fixed a stale multistart-overflow comment. Recommended for remnant jobs: strategy="nfp", column_weight=3, restarts>=8, separation_effort="max". Byte-stable default: 10 / 1 / "full". Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/ironnest/src/lib.rs | 9 +- crates/optimizer/examples/bench.rs | 2 + crates/optimizer/examples/density_ab.rs | 155 ++++++++ crates/optimizer/examples/density_probe.rs | 7 + crates/optimizer/examples/density_remnant.rs | 39 +- crates/optimizer/examples/nfp_policy_ab.rs | 1 + crates/optimizer/examples/perf_diag.rs | 1 + crates/optimizer/examples/prepared_bench.rs | 1 + crates/optimizer/src/bin/golden_dump.rs | 32 +- crates/optimizer/src/improve.rs | 5 +- crates/optimizer/src/lib.rs | 352 ++++++++++++------ crates/optimizer/src/nfp/mod.rs | 21 +- crates/optimizer/src/nfp/place.rs | 34 +- crates/optimizer/tests/nfp_mode.rs | 3 + crates/optimizer/tests/nfp_props.rs | 6 +- ...den__golden_placements_match_snapshot.snap | 28 +- crates/py/src/lib.rs | 50 ++- docs/04-performance-remediation-plan.md | 33 ++ 18 files changed, 620 insertions(+), 159 deletions(-) create mode 100644 crates/optimizer/examples/density_ab.rs diff --git a/crates/ironnest/src/lib.rs b/crates/ironnest/src/lib.rs index d3e48dc..6c203d8 100644 --- a/crates/ironnest/src/lib.rs +++ b/crates/ironnest/src/lib.rs @@ -19,8 +19,9 @@ #[doc(inline)] pub use ironnest_optimizer::{ - MultiSheetSolution, NestConfig, NestError, NestSolution, Placement, PlacementStrategy, - PreparedParts, Scalar, SeparationEffort, Sheet, nest, nest_multi, nest_multi_multistart, - nest_multi_multistart_per_item, nest_multi_per_item, nest_multi_with_config, nest_multistart, - nest_multistart_per_item, nest_per_item, nest_with_config, nest_with_prepared, prepare, + DENSITY_COLUMN_WEIGHT, MultiSheetSolution, NestConfig, NestError, NestSolution, Placement, + PlacementStrategy, PreparedParts, Scalar, SeparationEffort, Sheet, nest, nest_multi, + nest_multi_multistart, nest_multi_multistart_per_item, nest_multi_per_item, + nest_multi_with_config, nest_multistart, nest_multistart_per_item, nest_per_item, + nest_with_config, nest_with_prepared, nest_with_prepared_weighted, prepare, }; diff --git a/crates/optimizer/examples/bench.rs b/crates/optimizer/examples/bench.rs index e3dc933..a65d97e 100644 --- a/crates/optimizer/examples/bench.rs +++ b/crates/optimizer/examples/bench.rs @@ -257,6 +257,7 @@ fn nfp_cache_probe() { restarts: 1, strategy: ironnest_optimizer::PlacementStrategy::Nfp, separation_effort: ironnest_optimizer::SeparationEffort::Full, + column_weight: 10, }; let mut pair_count = 0usize; let ms = best_ms(3, || { @@ -294,6 +295,7 @@ fn nfp_probe( restarts: k, strategy: PlacementStrategy::Nfp, separation_effort: SeparationEffort::Full, + column_weight: 10, }; let mut placed = 0usize; let mut placed_area = 0.0; diff --git a/crates/optimizer/examples/density_ab.rs b/crates/optimizer/examples/density_ab.rs new file mode 100644 index 0000000..a7120a3 --- /dev/null +++ b/crates/optimizer/examples/density_ab.rs @@ -0,0 +1,155 @@ +//! Density A/B: column_weight × effort on the cone+square sector pack (consumer image family). +use ironnest_optimizer::{ + DENSITY_COLUMN_WEIGHT, NestConfig, PlacementStrategy, Scalar, SeparationEffort, + nest_with_config, +}; + +fn rect(w: Scalar, h: Scalar) -> Vec<[Scalar; 2]> { + vec![[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]] +} + +#[allow(clippy::disallowed_methods)] +fn sector(ri: Scalar, ro: Scalar, deg: Scalar, steps: usize) -> Vec<[Scalar; 2]> { + let half = deg.to_radians() / 2.0; + let a0 = std::f64::consts::FRAC_PI_2 - half; + let a1 = std::f64::consts::FRAC_PI_2 + half; + let mut pts = Vec::new(); + for i in 0..=steps { + let a = a0 + (a1 - a0) * (i as Scalar) / (steps as Scalar); + pts.push([ro * a.cos(), ro * a.sin()]); + } + for i in 0..=steps { + let a = a1 - (a1 - a0) * (i as Scalar) / (steps as Scalar); + pts.push([ri * a.cos(), ri * a.sin()]); + } + let (mut mnx, mut mny) = (f64::INFINITY, f64::INFINITY); + for p in &pts { + mnx = mnx.min(p[0]); + mny = mny.min(p[1]); + } + pts.iter().map(|p| [p[0] - mnx, p[1] - mny]).collect() +} + +fn area(o: &[[Scalar; 2]]) -> Scalar { + let n = o.len(); + let mut a = 0.0; + for i in 0..n { + let j = (i + 1) % n; + a += o[i][0] * o[j][1] - o[j][0] * o[i][1]; + } + (0.5 * a).abs() +} + +#[allow(clippy::disallowed_methods)] +fn time_ms(mut f: F) -> f64 { + let t = std::time::Instant::now(); + f(); + t.elapsed().as_secs_f64() * 1000.0 +} + +fn main() { + let cone = sector(28.0, 60.0, 55.0, 10); + let sq = rect(10.0, 10.0); + let items = vec![cone.clone(), sq.clone()]; + let areas = [area(&cone), area(&sq)]; + let qty = [20usize, 150]; + let container = rect(200.0, 120.0); + let cont_area = 200.0 * 120.0; + let cardinal = vec![0.0, 90.0, 180.0, 270.0]; + let rots = vec![cardinal.clone(), cardinal.clone()]; + + println!("--- density_ab: cone+square oversubscribed; NFP ---"); + for (label, cw, effort, restarts) in [ + ("w=10 Full K=1", 10u32, SeparationEffort::Full, 1usize), + ( + "w=3 Full K=1", + DENSITY_COLUMN_WEIGHT, + SeparationEffort::Full, + 1, + ), + ("w=10 Fast K=1", 10, SeparationEffort::Fast, 1), + ( + "w=3 Fast K=1", + DENSITY_COLUMN_WEIGHT, + SeparationEffort::Fast, + 1, + ), + ( + "w=3 Full K=4", + DENSITY_COLUMN_WEIGHT, + SeparationEffort::Full, + 4, + ), + ( + "w=3 Max K=1", + DENSITY_COLUMN_WEIGHT, + SeparationEffort::Max, + 1, + ), + ] { + let cfg = NestConfig { + min_sep: 0.0, + seed: 1, + budget: 1000, + restarts, + strategy: PlacementStrategy::Nfp, + separation_effort: effort, + column_weight: cw, + }; + let mut placed = [0usize; 2]; + let ms = time_ms(|| { + let sol = nest_with_config(&items, &qty, &container, &[], &rots, &cfg).unwrap(); + placed = [0, 0]; + for p in &sol.placements { + placed[p.item] += 1; + } + }); + let util = + (areas[0] * placed[0] as Scalar + areas[1] * placed[1] as Scalar) / cont_area * 100.0; + println!( + "{label:<16} cones {:>2} sq {:>3} util {:>5.1}% {ms:>8.0} ms", + placed[0], placed[1], util + ); + } + + // Remnant (all-fit) case + println!("--- remnant (6 cones + 24 sq in 240x120) ---"); + let qty2 = [6usize, 24]; + let container2 = rect(240.0, 120.0); + let sheet = 240.0 * 120.0; + for (label, cw, effort) in [ + ("w=10 Full", 10u32, SeparationEffort::Full), + ("w=3 Full", DENSITY_COLUMN_WEIGHT, SeparationEffort::Full), + ("w=3 Max", DENSITY_COLUMN_WEIGHT, SeparationEffort::Max), + ] { + let cfg = NestConfig { + min_sep: 0.0, + seed: 1, + budget: 1500, + restarts: 1, + strategy: PlacementStrategy::Nfp, + separation_effort: effort, + column_weight: cw, + }; + let sol = nest_with_config(&items, &qty2, &container2, &[], &rots, &cfg).unwrap(); + let mut x0 = f64::INFINITY; + let mut x1 = f64::NEG_INFINITY; + let mut y0 = f64::INFINITY; + let mut y1 = f64::NEG_INFINITY; + for p in &sol.placements { + // crude used extent from placement origin (good enough for A/B ranking) + x0 = x0.min(p.x); + x1 = x1.max(p.x + 60.0); + y0 = y0.min(p.y); + y1 = y1.max(p.y + 60.0); + } + let used = (x1 - x0) * (y1 - y0); + println!( + "{label:<12} placed {:>2} rough_used_bbox {:>8.0} ({:>4.1}% of sheet) max_x~{:>6.1}", + sol.placements.len(), + used, + used / sheet * 100.0, + x1 + ); + } +} diff --git a/crates/optimizer/examples/density_probe.rs b/crates/optimizer/examples/density_probe.rs index a0044f7..bc1a7e9 100644 --- a/crates/optimizer/examples/density_probe.rs +++ b/crates/optimizer/examples/density_probe.rs @@ -110,6 +110,13 @@ fn main() { restarts, strategy: strat, separation_effort: SeparationEffort::Full, + // Density-first column weight (docs/04 §4c): w=3 beats the historical 10 by ~2–3 pp + // on irregular/sector packs; sampling mode ignores this field. + column_weight: if matches!(strat, PlacementStrategy::Nfp) { + ironnest_optimizer::DENSITY_COLUMN_WEIGHT + } else { + 10 + }, }; let mut placed = [0usize; 2]; let ms = time_ms(|| { diff --git a/crates/optimizer/examples/density_remnant.rs b/crates/optimizer/examples/density_remnant.rs index 9f2277d..b625466 100644 --- a/crates/optimizer/examples/density_remnant.rs +++ b/crates/optimizer/examples/density_remnant.rs @@ -64,29 +64,33 @@ fn main() { let sq = rect(10.0, 10.0); let items = vec![cone.clone(), sq.clone()]; let qty = [6usize, 24]; - let (cw, ch) = (240.0, 120.0); - let container = rect(cw, ch); + let (sheet_w, sheet_h) = (240.0, 120.0); + let container = rect(sheet_w, sheet_h); let rots: Vec> = vec![ (0..8).map(|i| i as Scalar * 45.0).collect(), vec![0.0, 90.0, 180.0, 270.0], ]; println!( - "--- density_remnant: {} cones + {} squares in {cw}x{ch} (all fit) ---", + "--- density_remnant: {} cones + {} squares in {sheet_w}x{sheet_h} (all fit) ---", qty[0], qty[1] ); - for &(label, effort) in &[ - ("Full", SeparationEffort::Full), - ("Max", SeparationEffort::Max), + for &(label, effort, restarts, col_w) in &[ + ("Full w10 K=1", SeparationEffort::Full, 1usize, 10u32), + ("Full w3 K=1", SeparationEffort::Full, 1, 3), + ("Full w3 K=8", SeparationEffort::Full, 8, 3), + ("Max w3 K=1", SeparationEffort::Max, 1, 3), + ("Max w3 K=8", SeparationEffort::Max, 8, 3), ] { let cfg = NestConfig { min_sep: 0.0, seed: 1, budget: 1500, - restarts: 1, + restarts, strategy: PlacementStrategy::Nfp, separation_effort: effort, + column_weight: col_w, }; let mut placed = 0usize; let mut used_x = (f64::INFINITY, f64::NEG_INFINITY); @@ -95,13 +99,13 @@ fn main() { writeln!( svg, "", - cw + 10.0, - ch + 10.0 + sheet_w + 10.0, + sheet_h + 10.0 ) .unwrap(); writeln!( svg, - "" + "" ) .unwrap(); let ms = time_ms(|| { @@ -132,16 +136,23 @@ fn main() { let used_w = used_x.1 - used_x.0; let used_h = used_y.1 - used_y.0; let used_area = used_w * used_h; - let sheet = cw * ch; - let path = format!("/tmp/nest_{}.svg", label.to_lowercase()); + let sheet = sheet_w * sheet_h; + let slug = label + .to_lowercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect::(); + let path = format!("/tmp/nest_{slug}.svg"); std::fs::write(&path, &svg).unwrap(); println!( - "{label:<5} placed {placed:>3} used {used_w:>6.1}x{used_h:<6.1} = {used_area:>8.0} ({:>4.1}% of sheet) \ + "{label:<14} placed {placed:>3} used {used_w:>6.1}x{used_h:<6.1} = {used_area:>8.0} ({:>4.1}% of sheet) \ remnant {:>5.1}% max_x {:>6.1} {ms:>7.0} ms -> {path}", used_area / sheet * 100.0, (sheet - used_area) / sheet * 100.0, used_x.1, ); } - println!("(open /tmp/nest_full.svg and /tmp/nest_max.svg to compare)"); + println!( + "(open /tmp/nest_*.svg to compare remnant / max_x — smaller max_x = more end remnant)" + ); } diff --git a/crates/optimizer/examples/nfp_policy_ab.rs b/crates/optimizer/examples/nfp_policy_ab.rs index 4f31bd5..02bc1f9 100644 --- a/crates/optimizer/examples/nfp_policy_ab.rs +++ b/crates/optimizer/examples/nfp_policy_ab.rs @@ -42,6 +42,7 @@ fn main() { restarts: 8, strategy: PlacementStrategy::Nfp, separation_effort: SeparationEffort::Full, + column_weight: 10, }; let sol = nest_with_config(&items, &qty, &container, &[], &rots, &cfg).unwrap(); let placed_area: Scalar = sol.placements.iter().map(|p| areas[p.item]).sum(); diff --git a/crates/optimizer/examples/perf_diag.rs b/crates/optimizer/examples/perf_diag.rs index 0d925d4..cdb6969 100644 --- a/crates/optimizer/examples/perf_diag.rs +++ b/crates/optimizer/examples/perf_diag.rs @@ -92,6 +92,7 @@ fn main() { restarts: 1, strategy: strat, separation_effort: effort, + column_weight: 10, }; let mut placed = 0usize; let ms = time_ms(|| { diff --git a/crates/optimizer/examples/prepared_bench.rs b/crates/optimizer/examples/prepared_bench.rs index 669c33b..c8c5b52 100644 --- a/crates/optimizer/examples/prepared_bench.rs +++ b/crates/optimizer/examples/prepared_bench.rs @@ -83,6 +83,7 @@ fn main() { restarts: 1, strategy: PlacementStrategy::Nfp, separation_effort: SeparationEffort::Full, + column_weight: 10, }; let sol = nest_with_config(&items, &qty, c, &[], &rots, &cfg).expect("in-domain"); placed_a += sol.placements.len(); diff --git a/crates/optimizer/src/bin/golden_dump.rs b/crates/optimizer/src/bin/golden_dump.rs index b4b9432..43155c2 100644 --- a/crates/optimizer/src/bin/golden_dump.rs +++ b/crates/optimizer/src/bin/golden_dump.rs @@ -272,7 +272,8 @@ fn dump() -> String { // keeps start k=2 — the trailing jittered block of the contiguous-block policy (verified: // per-start placed areas [646, 646, 673]). This case is the cross-platform byte-stability PROOF // for the jittered path (`start_order_policy`), which the all-placing `multistart-mixed` case - // above can never exercise (its starts tie and the earliest-k canonical start always wins). + // above cannot: its starts tie on placed area, so the remnant-first `NestScore` tie-break + // (min used_x_max) — not the insertion-order diversification — decides that case's winner. writeln!(out, "# multistart-overflow").unwrap(); let mo = nest_multistart( &[rect(13.0, 7.0), rect(10.0, 10.0)], @@ -301,6 +302,7 @@ fn dump() -> String { restarts: 1, strategy: PlacementStrategy::Nfp, separation_effort: SeparationEffort::Full, + column_weight: 10, }; let per_item = |n: usize| -> Vec> { (0..n).map(|_| CARDINAL.to_vec()).collect() }; @@ -395,6 +397,7 @@ fn dump() -> String { restarts: 3, strategy: PlacementStrategy::Nfp, separation_effort: SeparationEffort::Full, + column_weight: 10, }, ) .expect("in-domain"); @@ -438,6 +441,7 @@ fn dump() -> String { restarts: 1, strategy: PlacementStrategy::Nfp, separation_effort: SeparationEffort::Fast, + column_weight: 10, }; let sol = nest_with_config(&items, &q, &cont, &[], &per_item(1), &nfp_fast(7)).expect("in-domain"); @@ -454,6 +458,32 @@ fn dump() -> String { nest_with_config(&items, &q, &cont, &[], &per_item(1), &nfp_fast(7)).expect("in-domain"); write_solution(&mut out, &sol.placements, &sol.unplaced); + // Max densification tier (`SeparationEffort::Max`, docs/04 Tier D): a small all-fit pack with + // horizontal slack — densify shrinks the sheet toward its corner and re-separates the whole + // layout (moving already-placed parts + sampling rotations) to compact the pack and enlarge the + // end remnant, then refills. A DELIBERATELY placement-changing tier; this case pins its + // cross-platform byte-identity contract (the shrink/separate/refill loop is a pure function of + // the seeded PRNG + integer geometry). + writeln!(out, "# nfp-max-densify").unwrap(); + let sol = nest_with_config( + &[rect(18.0, 8.0), rect(8.0, 8.0)], + &[4, 8], + &rect(90.0, 20.0), + &[], + &per_item(2), + &NestConfig { + min_sep: 0.0, + seed: 5, + budget: 400, + restarts: 1, + strategy: PlacementStrategy::Nfp, + separation_effort: SeparationEffort::Max, + column_weight: 10, + }, + ) + .expect("in-domain"); + write_solution(&mut out, &sol.placements, &sol.unplaced); + out } diff --git a/crates/optimizer/src/improve.rs b/crates/optimizer/src/improve.rs index 9c4b5f9..b8167f5 100644 --- a/crates/optimizer/src/improve.rs +++ b/crates/optimizer/src/improve.rs @@ -61,7 +61,10 @@ pub fn improve( } /// Slides every placed item toward the bottom-left until it contacts a neighbour or the boundary. -fn compact(layout: &mut Layout, entities: &[Option]) { +/// +/// Remnant goal: pulls a gappy-but-feasible packing toward the low corner so free space consolidates +/// as a strip at the sheet end. Pure geometry (no PRNG); every re-place is CDE-verified. +pub(crate) fn compact(layout: &mut Layout, entities: &[Option]) { // Snapshot keys: each item is processed once; the slide re-inserts under a fresh key. let keys: Vec = layout.placed_items.keys().collect(); for pk in keys { diff --git a/crates/optimizer/src/lib.rs b/crates/optimizer/src/lib.rs index 668332f..c8597bc 100644 --- a/crates/optimizer/src/lib.rs +++ b/crates/optimizer/src/lib.rs @@ -86,6 +86,65 @@ pub struct NestSolution { pub unplaced: Vec, } +/// Keep-best score for multi-start / densify. **Product goal: maximize usable remnant at the end of +/// the sheet.** Lexicographic order (all `total_cmp`, determinism-safe): +/// +/// 1. **higher** placed area — never drop a placeable part to "make remnant" (demand first); +/// 2. **lower** `used_x_max` — LBF packs toward the low-x corner, so the free strip lives at high-x +/// (the sheet end). Smaller right-edge ⇒ larger end remnant; +/// 3. **lower** `used_y_max` — secondary packing-down. +/// +/// On a full tie the earliest multi-start *k* wins (caller-side). Empty layouts score as zeros. +#[derive(Clone, Copy, Debug)] +struct NestScore { + placed_area: Scalar, + used_x_max: Scalar, + used_y_max: Scalar, +} + +impl NestScore { + fn empty() -> Self { + Self { + placed_area: 0.0, + used_x_max: 0.0, + used_y_max: 0.0, + } + } + + /// `true` iff `self` is a strictly better nest than `other` under the remnant-first order. + fn better_than(self, other: Self) -> bool { + match self.placed_area.total_cmp(&other.placed_area) { + Ordering::Greater => true, + Ordering::Less => false, + Ordering::Equal => match self.used_x_max.total_cmp(&other.used_x_max) { + Ordering::Less => true, + Ordering::Greater => false, + Ordering::Equal => self.used_y_max.total_cmp(&other.used_y_max) == Ordering::Less, + }, + } + } + + /// Score a live layout: original-outline `areas` ranked by placement item-id, plus the packing's + /// high-side AABB edge from CD footprints (the remnant metric). + fn from_layout(layout: &Layout, areas: &[Scalar]) -> Self { + let placed_area: Scalar = layout + .placed_items + .values() + .map(|pi| areas.get(pi.item_id).copied().unwrap_or(0.0)) + .sum(); + let ((_, x_max), (_, y_max)) = used_extent(layout); + if layout.placed_items.is_empty() { + Self::empty() + } else { + Self { + placed_area, + used_x_max: x_max, + used_y_max: y_max, + } + } + } +} + /// One sheet for a multi-sheet nest: a boundary outline plus optional keep-out holes. #[derive(Debug, Clone, Default, PartialEq)] pub struct Sheet { @@ -387,6 +446,9 @@ pub fn multistart_start( unplaced: all_unplaced(qty), }; }; + // Public reconstruct helper — area-only score is fine (callers that care about remnant use + // nest_with_config / multi-start entry points that thread original-outline areas). + let areas: Vec = items.iter().map(|o| polygon_area(o)).collect(); multistart_start_imported( &problem, qty, @@ -397,7 +459,10 @@ pub fn multistart_start( PlacementStrategy::Sampling, None, SeparationEffort::Full, + nfp::DEFAULT_COLUMN_WEIGHT, + &areas, ) + .0 } /// [`multistart_start`] over an already-imported [`Problem`] — the form `run_starts` uses so the K @@ -415,7 +480,9 @@ fn multistart_start_imported( strategy: PlacementStrategy, nfp_cache: Option<&nfp::NfpCache>, effort: SeparationEffort, -) -> NestSolution { + column_weight: u32, + areas: &[Scalar], +) -> (NestSolution, NestScore) { nest_core_imported( problem, qty, @@ -425,6 +492,8 @@ fn multistart_start_imported( strategy, nfp_cache, effort, + column_weight, + areas, ) } @@ -481,8 +550,31 @@ pub struct NestConfig { /// byte-identical shipped behavior. Added with a `Default` so existing `NestConfig { .. }` /// literals need `..Default::default()` — or set it explicitly. pub separation_effort: SeparationEffort, + /// LBF horizontal weight for NFP candidate ranking (`cost = column_weight · x_max + y_max`). + /// Historical default **10** (jagua) is **byte-identical** to every pre-column-weight golden. + /// Measured on irregular/sector packs, **3** packs tighter (~+2–3 pp util) by not over-forcing + /// strict left-columns (docs/04 §4c). Sampling mode currently ignores this (its LBF is still 10). + pub column_weight: u32, +} + +impl Default for NestConfig { + fn default() -> Self { + Self { + min_sep: 0.0, + seed: 0, + budget: 1000, + restarts: 1, + strategy: PlacementStrategy::Sampling, + separation_effort: SeparationEffort::Full, + column_weight: nfp::DEFAULT_COLUMN_WEIGHT, + } + } } +/// Recommended column weight for density-first NFP packs (docs/04 §4c). Not the default — the default +/// stays at 10 for golden byte-identity; consumers that care about remnant/gaps should pass 3. +pub const DENSITY_COLUMN_WEIGHT: u32 = 3; + /// Errors from the strategy-bearing entry points. The sampling paths are infallible (malformed /// input degrades to an all-unplaced solution, unchanged); NFP mode HARD-ERRORS on out-of-domain /// coordinates instead of silently degrading (docs/03 §3.1). @@ -576,19 +668,9 @@ pub fn nest_with_config( config.strategy, cache.as_ref(), config.separation_effort, + config.column_weight, ); - let mut best: Option<(NestSolution, Scalar)> = None; - for (sol, area) in runs { - let better = best - .as_ref() - .is_none_or(|(_, best_area)| area.total_cmp(best_area) == Ordering::Greater); - if better { - best = Some((sol, area)); - } - } - Ok(best - .expect("restarts is clamped to >= 1, so at least one solution is produced") - .0) + Ok(keep_best_start(runs)) } /// Multi-sheet nest with an explicit [`NestConfig`] (the [`nest_multi_multistart_per_item`] @@ -671,19 +753,9 @@ pub fn nest_multi_with_config( config.strategy, cache.as_ref(), config.separation_effort, + config.column_weight, ); - let mut best: Option<(NestSolution, Scalar)> = None; - for (sol, area) in runs { - let better = best - .as_ref() - .is_none_or(|(_, best_area)| area.total_cmp(best_area) == Ordering::Greater); - if better { - best = Some((sol, area)); - } - } - let sol = best - .expect("restarts is clamped to >= 1, so at least one solution is produced") - .0; + let sol = keep_best_start(runs); for p in &sol.placements { remaining[p.item] -= 1; @@ -799,6 +871,32 @@ pub fn nest_with_prepared( budget: u64, restarts: usize, separation_effort: SeparationEffort, +) -> Result { + nest_with_prepared_weighted( + prepared, + qty, + container, + holes, + seed, + budget, + restarts, + separation_effort, + nfp::DEFAULT_COLUMN_WEIGHT, + ) +} + +/// Like [`nest_with_prepared`] but with an explicit LBF [`NestConfig::column_weight`]. +#[allow(clippy::too_many_arguments)] +pub fn nest_with_prepared_weighted( + prepared: &PreparedParts, + qty: &[usize], + container: &[[Scalar; 2]], + holes: &[Vec<[Scalar; 2]>], + seed: u64, + budget: u64, + restarts: usize, + separation_effort: SeparationEffort, + column_weight: u32, ) -> Result { assert_eq!( prepared.entities.len(), @@ -856,19 +954,9 @@ pub fn nest_with_prepared( prepared.strategy, cache.as_ref(), separation_effort, + column_weight, ); - let mut best: Option<(NestSolution, Scalar)> = None; - for (sol, area) in runs { - let better = best - .as_ref() - .is_none_or(|(_, best_area)| area.total_cmp(best_area) == Ordering::Greater); - if better { - best = Some((sol, area)); - } - } - Ok(best - .expect("restarts is clamped to >= 1, so at least one solution is produced") - .0) + Ok(keep_best_start(runs)) } /// The imported, placement-ready problem (the N1a import hoist — docs/03 §6): items, container, @@ -1009,6 +1097,7 @@ fn nest_core( unplaced: all_unplaced(qty), }; }; + let areas: Vec = items.iter().map(|o| polygon_area(o)).collect(); nest_core_imported( &problem, qty, @@ -1018,13 +1107,19 @@ fn nest_core( PlacementStrategy::Sampling, None, SeparationEffort::Full, + nfp::DEFAULT_COLUMN_WEIGHT, + &areas, ) + .0 } /// The placement pipeline over an already-imported [`Problem`] — one multi-start start's worth of /// work. Byte-identical to the pre-hoist `nest_core` for the same inputs (Sampling strategy). /// Under [`PlacementStrategy::Nfp`] the constructive phase is the NFP placer (docs/03 §5) over the /// shared prebuilt `nfp_cache`; the separation tail runs unchanged in both modes. +/// +/// Returns the solution plus a [`NestScore`] (placed area + packing far-edge) so multi-start can +/// keep the layout that leaves the largest end-of-sheet remnant. #[allow(clippy::too_many_arguments, clippy::too_many_lines)] fn nest_core_imported( problem: &Problem, @@ -1035,7 +1130,9 @@ fn nest_core_imported( strategy: PlacementStrategy, nfp_cache: Option<&nfp::NfpCache>, effort: SeparationEffort, -) -> NestSolution { + column_weight: u32, + areas: &[Scalar], +) -> (NestSolution, NestScore) { let Problem { entities, container, @@ -1120,6 +1217,7 @@ fn nest_core_imported( &mut placed_obs, &mut placed_per_type, &mut stats, + column_weight, ); } // Belt-and-braces (docs/03 §9.2): an NFP construction can never leave an infeasible @@ -1128,6 +1226,11 @@ fn nest_core_imported( layout.is_feasible(), "NFP construction left an infeasible layout" ); + // NOTE: a post-NFP `improve::compact` (BL slide) was measured and REJECTED for the + // default path (2026-07-10): it regressed over-subscribed util (86.6 → 85.8 % on the + // sector corpus) by rearranging a feasible construction into a worse basin for the sep + // tail. Remnant consolidation of already-feasible layouts is `SeparationEffort::Max` + // (densify) + multi-start remnant-first keep-best — not a free compact. } } @@ -1144,6 +1247,30 @@ fn nest_core_imported( effort, ); + // Post-separation NFP refill (docs/03 §13 Q5 — the GO density lever): separation can open + // contacts / free pockets by rearranging neighbours. Rebuild the integer obstacle list from the + // live layout and run one more NFP place_pass for still-unplaced demand. Cheap (region walk only + // for unplaced types) and placement-changing only when it actually seats more parts. + if strategy == PlacementStrategy::Nfp { + let cache = nfp_cache.expect("NFP strategy requires a prebuilt cache"); + let mut placed_obs = nfp::place::obstacles_from_layout(&layout, rotations_rad_per_item); + let mut stats = nfp::place::NfpPlaceStats::default(); + nfp::place::place_pass( + &mut layout, + entities, + &order, + qty, + rotations_rad_per_item, + cache, + &mut prng, + budget, + &mut placed_obs, + &mut placed_per_type, + &mut stats, + column_weight, + ); + } + // Densification (`SeparationEffort::Max`, docs/04 Tier D): a REVISED compaction. An earlier // wall-squeeze keep-out was reverted (docs/02 §11.2, zero gain on rect/brick/pentagon/mixed) — // but it was measured on rotation-invariant / no-slack cases. This version shrinks the actual @@ -1160,8 +1287,31 @@ fn nest_core_imported( &mut prng, &mut placed_per_type, ); + // After densify free-space opens on the real sheet — one more NFP refill if applicable. + if strategy == PlacementStrategy::Nfp { + let cache = nfp_cache.expect("NFP strategy requires a prebuilt cache"); + let mut placed_obs = nfp::place::obstacles_from_layout(&layout, rotations_rad_per_item); + let mut stats = nfp::place::NfpPlaceStats::default(); + nfp::place::place_pass( + &mut layout, + entities, + &order, + qty, + rotations_rad_per_item, + cache, + &mut prng, + budget, + &mut placed_obs, + &mut placed_per_type, + &mut stats, + column_weight, + ); + } } + // Score BEFORE extract — CD footprints give the true packing far-edge (remnant metric). + let score = NestScore::from_layout(&layout, areas); + let placements = extract_placements(&layout, entities); let mut unplaced = vec![]; @@ -1171,10 +1321,13 @@ fn nest_core_imported( } } - NestSolution { - placements, - unplaced, - } + ( + NestSolution { + placements, + unplaced, + }, + score, + ) } /// Densification steps: how many times the used span is shrunk (compounding [`DENSIFY_SHRINK`]). The @@ -1247,11 +1400,14 @@ fn densify( let mut best = layout.save(); let mut best_ppt = placed_per_type.to_vec(); - // Keep-best objective (docs/04 Tier D): the goal is to leave the largest usable REMNANT, so - // maximize placed area first (never drop a required part), then MINIMIZE the used bounding box — - // a smaller occupied region leaves more contiguous sheet. Higher area wins; equal area, smaller - // used bbox wins. - let (mut best_area, mut best_used) = (placed_cd_area(layout), placed_used_bbox_area(layout)); + // Keep-best: same remnant-first order as multi-start ([`NestScore`]) — max placed area, then + // minimize the packing's high-side edge so the free strip at the sheet end is as large as + // possible. `areas` for densify use CD footprint areas (rigid-invariant; same ranking intent). + let densify_areas: Vec = entities + .iter() + .map(|e| e.as_ref().map_or(0.0, |it| it.shape_cd.area)) + .collect(); + let mut best_score = NestScore::from_layout(layout, &densify_areas); // Shrink RELATIVE TO THE USED SPAN, not the whole sheet: `base` is the scale factor that // reproduces the current used extent along the compaction axis, so the first step already lands @@ -1290,20 +1446,11 @@ fn densify( placed_per_type, SeparationEffort::Full, ); - let area = placed_cd_area(layout); - let used = placed_used_bbox_area(layout); - // Lexicographic keep-best via `total_cmp` (determinism-safe, as the multi-start reduction): - // more placed area wins; equal area, smaller used region (bigger remnant) wins. - let better = match area.total_cmp(&best_area) { - Ordering::Greater => true, - Ordering::Equal => used < best_used, - Ordering::Less => false, - }; - if better { + let score = NestScore::from_layout(layout, &densify_areas); + if score.better_than(best_score) { best = layout.save(); best_ppt.copy_from_slice(placed_per_type); - best_area = area; - best_used = used; + best_score = score; } // keep shrinking for more pressure } else { @@ -1317,12 +1464,6 @@ fn densify( placed_per_type.copy_from_slice(&best_ppt); } -/// Total CD-footprint area currently placed in the layout (rigid-invariant `pi.shape.area` sum, in -/// deterministic slot order). -fn placed_cd_area(layout: &Layout) -> Scalar { - layout.placed_items.values().map(|pi| pi.shape.area).sum() -} - /// The `((x_min, x_max), (y_min, y_max))` extent of every placed part's footprint — the "used /// region". `((0,0),(0,0))` when nothing is placed. fn used_extent(layout: &Layout) -> ((Scalar, Scalar), (Scalar, Scalar)) { @@ -1348,17 +1489,12 @@ fn used_extent(layout: &Layout) -> ((Scalar, Scalar), (Scalar, Scalar)) { } } -/// Area of the bounding box enclosing every placed part — minimizing it maximizes the usable remnant. -fn placed_used_bbox_area(layout: &Layout) -> Scalar { - let (x, y) = used_extent(layout); - (x.1 - x.0) * (y.1 - y.0) -} - -/// Runs the nest from `n_starts` decorrelated seeds and returns the **densest** result — the -/// deterministic best-of-K "multi-start". A single greedy construction lands in a *seed-dependent -/// basin*; sweeping several seeds and keeping the best lifts packing on heterogeneous parts (measured: -/// 13×7 bricks 91.9 % → 95.5 % at K=16) at no determinism cost. Applies **one** rotation set to every -/// item; for a per-item set use [`nest_multistart_per_item`]. +/// Runs the nest from `n_starts` decorrelated seeds and returns the **best** result under the +/// remnant-first order ([`NestScore`]: max placed area, then min packing far-edge so the free strip +/// at the sheet end is as large as possible). A single greedy construction lands in a +/// *seed-dependent basin*; sweeping several seeds and keeping the best lifts packing on +/// heterogeneous parts (measured: 13×7 bricks 91.9 % → 95.5 % at K=16) at no determinism cost. +/// Applies **one** rotation set to every item; for a per-item set use [`nest_multistart_per_item`]. /// /// * `n_starts` — number of independent constructions (clamped to ≥ 1). **`n_starts == 1` is /// byte-identical to [`nest`]** with the same seed, so the existing determinism golden is untouched. @@ -1376,9 +1512,10 @@ fn placed_used_bbox_area(layout: &Layout) -> Scalar { /// explored — the old best-of-K is a floor for the new best-of-2K at the same seed. /// /// Determinism: each start is a byte-stable pipeline whose insertion order is a pure function of -/// `(seed, k, inputs)`; the keep-best reduction maximises the total placed **area** (a -/// deterministic, fixed-order float sum compared via `total_cmp`) and keeps the earliest *k* on a -/// tie — the chosen layout is byte-identical for the same arguments. +/// `(seed, k, inputs)`; the keep-best reduction is **remnant-first** ([`NestScore`]: max placed area, +/// then min packing far-edge `used_x_max` / `used_y_max` so the free strip at the sheet end is as +/// large as possible) and keeps the earliest *k* on a full tie — the chosen layout is byte-identical +/// for the same arguments. #[must_use] #[allow(clippy::too_many_arguments)] pub fn nest_multistart( @@ -1443,20 +1580,22 @@ pub fn nest_multistart_per_item( }; // Run the K independent starts (sequentially by default; on threads under the `parallel` feature — - // byte-identical either way). Returned as `(solution, placed_area)` in k = 0..starts order. + // byte-identical either way). Returned as `(solution, NestScore)` in k = 0..starts order. let runs = run_starts(&problem, qty, seed, budget, starts, &areas); + keep_best_start(runs) +} - // Keep-best reduction in canonical k order: strictly-greater placed area wins, an exact tie keeps - // the earliest k (lowest seed offset). A single `total_cmp` per step on each solution's own - // canonical area sum — no cross-solution/cross-thread float reduction — so the chosen layout is - // byte-identical for the same arguments, sequential or parallel. - let mut best: Option<(NestSolution, Scalar)> = None; - for (sol, area) in runs { +/// Keep-best reduction in canonical *k* order: remnant-first [`NestScore`] (max area, then min +/// packing far-edge). An exact score tie keeps the earliest *k*. Pure function of the run list — +/// no cross-thread float reduction. +fn keep_best_start(runs: Vec<(NestSolution, NestScore)>) -> NestSolution { + let mut best: Option<(NestSolution, NestScore)> = None; + for (sol, score) in runs { let better = best .as_ref() - .is_none_or(|(_, best_area)| area.total_cmp(best_area) == Ordering::Greater); + .is_none_or(|(_, best_score)| score.better_than(*best_score)); if better { - best = Some((sol, area)); + best = Some((sol, score)); } } best.expect("n_starts is clamped to >= 1, so at least one solution is produced") @@ -1464,7 +1603,7 @@ pub fn nest_multistart_per_item( } /// Runs `starts` independent constructions over one shared imported [`Problem`], start *k* at -/// `seed.wrapping_add(k)`, returning `(solution, placed_area)` in `0..starts` order. +/// `seed.wrapping_add(k)`, returning `(solution, NestScore)` in `0..starts` order. /// **Sequential** build: a plain loop. #[cfg(not(feature = "parallel"))] #[allow(clippy::too_many_arguments)] @@ -1478,10 +1617,11 @@ fn run_starts_with( strategy: PlacementStrategy, nfp_cache: Option<&nfp::NfpCache>, effort: SeparationEffort, -) -> Vec<(NestSolution, Scalar)> { + column_weight: u32, +) -> Vec<(NestSolution, NestScore)> { (0..starts as u64) .map(|k| { - let sol = multistart_start_imported( + multistart_start_imported( problem, qty, seed, @@ -1491,14 +1631,14 @@ fn run_starts_with( strategy, nfp_cache, effort, - ); - let area = placed_area(&sol, areas); - (sol, area) + column_weight, + areas, + ) }) .collect() } -/// The Sampling-mode shim (the pre-NFP `run_starts` semantics, byte-frozen). +/// The Sampling-mode shim (the pre-NFP `run_starts` semantics, byte-frozen for K=1). fn run_starts( problem: &Problem, qty: &[usize], @@ -1506,7 +1646,7 @@ fn run_starts( budget: u64, starts: usize, areas: &[Scalar], -) -> Vec<(NestSolution, Scalar)> { +) -> Vec<(NestSolution, NestScore)> { run_starts_with( problem, qty, @@ -1517,11 +1657,12 @@ fn run_starts( PlacementStrategy::Sampling, None, SeparationEffort::Full, + nfp::DEFAULT_COLUMN_WEIGHT, ) } /// Runs `starts` independent constructions concurrently on scoped threads (one per start), returning -/// `(solution, placed_area)` in `0..starts` order — **byte-identical** to the sequential build: each +/// `(solution, NestScore)` in `0..starts` order — **byte-identical** to the sequential build: each /// start is the self-contained [`multistart_start_imported`] (own PRNG, own `Layout`; the shared /// [`Problem`] is IMMUTABLE — no interior mutability — so no shared *mutable* state crosses threads; /// the identical per-start seed + order policy as the sequential build), the results are collected @@ -1542,7 +1683,8 @@ fn run_starts_with( strategy: PlacementStrategy, nfp_cache: Option<&nfp::NfpCache>, effort: SeparationEffort, -) -> Vec<(NestSolution, Scalar)> { + column_weight: u32, +) -> Vec<(NestSolution, NestScore)> { std::thread::scope(|scope| { let handles: Vec<_> = (0..starts as u64) .map(|k| { @@ -1552,7 +1694,7 @@ fn run_starts_with( // drift. The `Problem` and the optional `NfpCache` cross the threads as // immutable references (no interior mutability — the sanctioned-exception // argument, docs/03 §6/§8 rule 9). `effort` is `Copy`, captured per start. - let sol = multistart_start_imported( + multistart_start_imported( problem, qty, seed, @@ -1562,9 +1704,9 @@ fn run_starts_with( strategy, nfp_cache, effort, - ); - let area = placed_area(&sol, areas); - (sol, area) + column_weight, + areas, + ) }) }) .collect(); @@ -1785,12 +1927,6 @@ fn polygon_area(outline: &[[Scalar; 2]]) -> Scalar { (0.5 * acc).abs() } -/// Total placed area of a solution = Σ area(item) over its placements, summed in the solution's own -/// (deterministic) placement order. The multi-start objective — higher = denser packing / utilization. -fn placed_area(sol: &NestSolution, areas: &[Scalar]) -> Scalar { - sol.placements.iter().map(|p| areas[p.item]).sum() -} - /// Every requested instance, marked unplaced (used when the container itself fails to import). fn all_unplaced(qty: &[usize]) -> Vec { qty.iter() diff --git a/crates/optimizer/src/nfp/mod.rs b/crates/optimizer/src/nfp/mod.rs index 7833e3f..b3703ba 100644 --- a/crates/optimizer/src/nfp/mod.rs +++ b/crates/optimizer/src/nfp/mod.rs @@ -425,22 +425,29 @@ impl NfpCache { } } +/// Default LBF horizontal weight (jagua's `X_MULTIPLIER`). Heavy columns; see [`candidates`]. +pub const DEFAULT_COLUMN_WEIGHT: u32 = 10; + /// Sorted candidate poses for a region: every vertex of every ring (outer AND hole — hole vertices /// can never win the strictly-linear loss but are harmless), as `(cost, x, y)` ascending with the /// deterministic lexicographic tie-break (docs/03 §5.2). The caller walks in order and CDE-verifies. +/// +/// `column_weight` is the LBF horizontal multiplier (`cost = w·x_max + y_max`). The historical +/// default of 10 over-forces strict left-columns and leaves vertical gaps between irregular parts; +/// measured on consumer-like sector packs, `w = 3` gains ~2–3 pp utilization (docs/04 §4c). Weight +/// `0` is pure bottom-first (allowed; not recommended as a general default). #[must_use] -pub fn candidates(region: &Shapes, loss_offsets: (i64, i64)) -> Vec<(i64, i64, i64)> { +pub fn candidates( + region: &Shapes, + loss_offsets: (i64, i64), + column_weight: u32, +) -> Vec<(i64, i64, i64)> { let (bx, by) = loss_offsets; // Pre-size to the exact vertex count (nested flatten's size_hint is imprecise, so `collect` would // otherwise re-allocate as the region grows) — byte-identical, just fewer allocations. let cap = region.iter().flatten().map(Vec::len).sum(); let mut out: Vec<(i64, i64, i64)> = Vec::with_capacity(cap); - // TEMP column-weight sweep (REVERT after picking a value): default 10 = shipped. - #[allow(clippy::disallowed_methods)] - let w: i64 = std::env::var("IRONNEST_XW") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(10); + let w = i64::from(column_weight); out.extend( region .iter() diff --git a/crates/optimizer/src/nfp/place.rs b/crates/optimizer/src/nfp/place.rs index 59771a4..e696219 100644 --- a/crates/optimizer/src/nfp/place.rs +++ b/crates/optimizer/src/nfp/place.rs @@ -53,6 +53,7 @@ pub(crate) fn place_pass( placed: &mut Vec, placed_per_type: &mut [usize], stats: &mut NfpPlaceStats, + column_weight: u32, ) { for &t in order { let Some(item) = entities[t].as_ref() else { @@ -75,6 +76,7 @@ pub(crate) fn place_pass( placed, stats, &mut buffer, + column_weight, ) { placed_per_type[t] += 1; } else { @@ -84,6 +86,32 @@ pub(crate) fn place_pass( } } +/// Rebuilds the integer obstacle list from the live layout (post-separation / post-densify). +/// Used by the post-sep NFP refill and the local re-pack compact (docs/03 §13 Q5, docs/04). +pub(crate) fn obstacles_from_layout( + layout: &Layout, + rotations_rad: &[Vec], +) -> Vec { + let mut out = Vec::with_capacity(layout.placed_items.len()); + for pi in layout.placed_items.values() { + let rot = pi.d_transf.rotation(); + // Rotations on placed items are bit-identical to a member of the allowed set. + #[allow(clippy::float_cmp)] // exact identity by construction + let rot_idx = rotations_rad[pi.item_id] + .iter() + .position(|&v| v == rot) + .expect("placed rotation must come from the allowed set"); + let (tx, ty) = pi.d_transf.translation(); + out.push(PlacedObstacle { + item_id: pi.item_id, + rot_idx, + tx: quantize(tx), + ty: quantize(ty), + }); + } + out +} + /// Places one instance of type `t`: NFP candidate walk first, sampling fallback second. /// Returns whether an instance was placed. #[allow(clippy::too_many_arguments)] @@ -98,6 +126,7 @@ fn try_place_one( placed: &mut Vec, stats: &mut NfpPlaceStats, buffer: &mut SPolygon, + column_weight: u32, ) -> bool { // Candidates across the allowed rotations, merged under the exact lexicographic tie-break // (cost, rot_idx, x, y) — docs/03 §5.2. @@ -105,7 +134,7 @@ fn try_place_one( let mut cands: Vec<(i64, usize, i64, i64)> = Vec::new(); for r in 0..n_rots { let region = cache.feasible_region(t, r, placed); - let rot_cands = candidates(®ion, cache.loss_offsets(t, r)); + let rot_cands = candidates(®ion, cache.loss_offsets(t, r), column_weight); cands.reserve(rot_cands.len()); for (cost, x, y) in rot_cands { cands.push((cost, r, x, y)); @@ -132,6 +161,9 @@ fn try_place_one( // Sampling fallback — today's constructive path verbatim (search + drop-slide); the settled // pose is quantized ONCE (the second sanctioned quantization site, docs/03 §8 rule 2) so the // part participates in subsequent region queries as an obstacle. + if budget == 0 { + return false; + } if let Some(transf) = search::search(layout.cde(), item, &rotations_rad[t], prng, budget) { let settled = improve::place_dropped(layout, item, transf); let rot = settled.rotation(); diff --git a/crates/optimizer/tests/nfp_mode.rs b/crates/optimizer/tests/nfp_mode.rs index 372f56c..0c75eda 100644 --- a/crates/optimizer/tests/nfp_mode.rs +++ b/crates/optimizer/tests/nfp_mode.rs @@ -31,6 +31,7 @@ fn config(strategy: PlacementStrategy, min_sep: Scalar, seed: u64, restarts: usi restarts, strategy, separation_effort: SeparationEffort::Full, + column_weight: 10, } } @@ -69,6 +70,7 @@ fn prepared_equals_nest_with_config_byte_for_byte() { restarts, strategy, separation_effort: SeparationEffort::Full, + column_weight: 10, }; let via_config = nest_with_config(&items, qty, container, &[], &rots, &cfg).unwrap(); @@ -108,6 +110,7 @@ fn prepared_fast_effort_matches_config_fast() { restarts: 1, strategy: PlacementStrategy::Nfp, separation_effort: SeparationEffort::Fast, + column_weight: 10, }; let via_config = nest_with_config(&items, &qty, &container, &[], &rots, &cfg).unwrap(); let via_prepared = nest_with_prepared( diff --git a/crates/optimizer/tests/nfp_props.rs b/crates/optimizer/tests/nfp_props.rs index 5022497..0e60eb6 100644 --- a/crates/optimizer/tests/nfp_props.rs +++ b/crates/optimizer/tests/nfp_props.rs @@ -152,7 +152,7 @@ fn p1_soundness_all_candidates_cde_feasible() { continue; }; let region = cache.feasible_region(t, r, &placed); - let cands = candidates(®ion, cache.loss_offsets(t, r)); + let cands = candidates(®ion, cache.loss_offsets(t, r), 10); for &(_, x, y) in cands.iter().take(60) { assert!( cde_feasible(&layout, item, rad[t][r], unquantize(x), unquantize(y)), @@ -278,8 +278,8 @@ fn p8_double_build_is_byte_identical() { let reg2 = cache2.feasible_region(t, r, &placed); assert_eq!(reg1, reg2, "regions must be byte-identical (t={t}, r={r})"); assert_eq!( - candidates(®1, cache1.loss_offsets(t, r)), - candidates(®2, cache2.loss_offsets(t, r)), + candidates(®1, cache1.loss_offsets(t, r), 10), + candidates(®2, cache2.loss_offsets(t, r), 10), ); } } diff --git a/crates/optimizer/tests/snapshots/golden__golden_placements_match_snapshot.snap b/crates/optimizer/tests/snapshots/golden__golden_placements_match_snapshot.snap index 9687e9c..2345907 100644 --- a/crates/optimizer/tests/snapshots/golden__golden_placements_match_snapshot.snap +++ b/crates/optimizer/tests/snapshots/golden__golden_placements_match_snapshot.snap @@ -83,13 +83,13 @@ unplaced 1 18.568193040994984 11.431806975101605 315 unplaced # multistart-mixed -1 5.000000000962553 0.000000007978023575105908 90 -1 0.000000001908953883855702 40.000000024012266 270 -1 5.000000002648337 20.00000000772505 270 -0 5.0000000066281425 30.000000012117518 270 -0 5.000000002841652 30.000000037419333 0 -0 20.00000001436751 0.00000000455378579289345 90 -0 10.000000007648957 10.000000008650744 0 +1 0.0000000011476450900715918 20.00000000869202 270 +1 0.0000000013976277912775004 40.00000000993268 270 +1 5.00000000146124 20.000000002184947 270 +0 15.00000000350084 30.00000002095292 180 +0 5.000000010703999 40.000000032190684 270 +0 10.00000001038369 0.00000000022504309526993893 0 +0 20.000000004086267 20.00000000079111 180 unplaced # multistart-overflow 1 10.000000000012562 0.000000002203314863891137 90 @@ -163,3 +163,17 @@ unplaced 0 0 0 0 0 0 0 0 0 20.000045776367188 10.000030517578125 0 0 20.000045776367188 20.000045776367188 0 unplaced 0 0 0 +# nfp-max-densify +0 8.000015258789063 0.0000152587890625 90 +0 16.000030517578125 0.0000152587890625 90 +0 24.000045776367188 0.0000152587890625 90 +0 32.00006103515625 0.0000152587890625 90 +1 32.00007629394531 0.0000152587890625 0 +1 32.00007629394531 8.000030517578125 0 +1 40.000091552734375 0.0000152587890625 0 +1 40.000091552734375 8.000030517578125 0 +1 48.00010681152344 0.0000152587890625 0 +1 48.00010681152344 8.000030517578125 0 +1 56.0001220703125 0.0000152587890625 0 +1 56.0001220703125 8.000030517578125 0 +unplaced diff --git a/crates/py/src/lib.rs b/crates/py/src/lib.rs index 90c27e1..13ca30e 100644 --- a/crates/py/src/lib.rs +++ b/crates/py/src/lib.rs @@ -16,7 +16,7 @@ use ironnest_core::{ nest_multi_multistart_per_item as nest_multi_multistart_impl, nest_multi_per_item as nest_multi_impl, nest_multi_with_config, nest_multistart_per_item as nest_multistart_impl, nest_per_item as nest_impl, nest_with_config, - nest_with_prepared, prepare as core_prepare, + nest_with_prepared_weighted, prepare as core_prepare, }; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; @@ -211,6 +211,13 @@ fn parse_separation_effort(effort: &str) -> PyResult { /// (but still fully feasible) layout — it is a distinct, still cross-platform byte-identical /// result, not the ``"full"`` one. Recommended when the caller re-validates every layout and /// cares about wall-clock. Raises ``ValueError`` on any other value. +/// * ``"max"`` — ``"full"`` plus a densification pass that shrinks the used region (closes gaps +/// between already-placed parts). Highest density, highest cost. +/// column_weight : int, optional +/// LBF horizontal weight for NFP candidate ranking (``cost = column_weight * x_max + y_max``). +/// Default **10** is byte-identical to every prior release. **3** packs irregular / sector parts +/// tighter (measured +2–3 pp util on cone+square packs) by not over-forcing strict left-columns. +/// Sampling mode currently ignores this. /// /// Returns /// ------- @@ -221,7 +228,7 @@ fn parse_separation_effort(effort: &str) -> PyResult { /// /// The same arguments always produce a byte-identical result. #[pyfunction] -#[pyo3(signature = (items, qty, container, holes, min_sep, rotations, seed, budget, restarts=1, strategy="sampling", separation_effort="full"))] +#[pyo3(signature = (items, qty, container, holes, min_sep, rotations, seed, budget, restarts=1, strategy="sampling", separation_effort="full", column_weight=10))] #[allow(clippy::needless_pass_by_value)] // PyO3 marshals owned Vecs out of the Python objects #[allow(clippy::too_many_arguments)] // injected `py` GIL token + the oracle's input surface (mirrors the Rust API) fn nest( @@ -237,6 +244,7 @@ fn nest( restarts: usize, strategy: &str, separation_effort: &str, + column_weight: u32, ) -> PyResult<(Vec, Vec)> { if items.len() != qty.len() { return Err(PyValueError::new_err(format!( @@ -266,15 +274,20 @@ fn nest( // byte-for-byte and NFP routes through the config entry point (docs/03) — every existing // golden holds. A non-default `separation_effort` is placement-changing, so it routes // through the config path for BOTH strategies (nest_with_config handles Sampling too). + // Default path (sampling + full effort + column_weight=10) keeps the historical + // frozen branches so existing goldens / callers stay byte-identical. Any non-default + // column_weight or effort routes through nest_with_config. match strategy { PlacementStrategy::Sampling - if effort == SeparationEffort::Full && restarts <= 1 => + if effort == SeparationEffort::Full && restarts <= 1 && column_weight == 10 => { Ok(nest_impl( &items, &qty, &container, &holes, min_sep, &rotations, seed, budget, )) } - PlacementStrategy::Sampling if effort == SeparationEffort::Full => { + PlacementStrategy::Sampling + if effort == SeparationEffort::Full && column_weight == 10 => + { Ok(nest_multistart_impl( &items, &qty, &container, &holes, min_sep, &rotations, seed, budget, restarts, @@ -293,6 +306,7 @@ fn nest( restarts, strategy, separation_effort: effort, + column_weight, }, ), } @@ -333,7 +347,7 @@ fn nest( /// Each sheet is nested with a seed derived deterministically from `seed`, so the result is /// byte-identical for the same arguments. #[pyfunction] -#[pyo3(signature = (items, qty, sheets, min_sep, rotations, seed, budget, restarts=1, strategy="sampling", separation_effort="full"))] +#[pyo3(signature = (items, qty, sheets, min_sep, rotations, seed, budget, restarts=1, strategy="sampling", separation_effort="full", column_weight=10))] #[allow(clippy::needless_pass_by_value)] #[allow(clippy::too_many_arguments)] // `py` is the injected GIL token (not an oracle input); the rest are the oracle's input surface fn nest_multi( @@ -348,6 +362,7 @@ fn nest_multi( restarts: usize, strategy: &str, separation_effort: &str, + column_weight: u32, ) -> PyResult<(Vec>, Vec)> { if items.len() != qty.len() { return Err(PyValueError::new_err(format!( @@ -370,14 +385,20 @@ fn nest_multi( // Release the GIL for the solve — see the note in `nest` for the why and the safety argument. // `restarts <= 1` is the historical single-start spill; `> 1` packs each sheet best-of-K. A - // non-default `separation_effort` is placement-changing and routes through the config path for - // both strategies (the `Full` default keeps the historical branches byte-for-byte). + // non-default `separation_effort` / `column_weight` is placement-changing and routes through the + // config path (the defaults keep the historical branches byte-for-byte). let solution = py .detach(|| match strategy { - PlacementStrategy::Sampling if effort == SeparationEffort::Full && restarts <= 1 => Ok( - nest_multi_impl(&items, &qty, &sheets, min_sep, &rotations, seed, budget), - ), - PlacementStrategy::Sampling if effort == SeparationEffort::Full => { + PlacementStrategy::Sampling + if effort == SeparationEffort::Full && restarts <= 1 && column_weight == 10 => + { + Ok(nest_multi_impl( + &items, &qty, &sheets, min_sep, &rotations, seed, budget, + )) + } + PlacementStrategy::Sampling + if effort == SeparationEffort::Full && column_weight == 10 => + { Ok(nest_multi_multistart_impl( &items, &qty, &sheets, min_sep, &rotations, seed, budget, restarts, )) @@ -394,6 +415,7 @@ fn nest_multi( restarts, strategy, separation_effort: effort, + column_weight, }, ), }) @@ -435,7 +457,7 @@ impl PyPreparedParts { /// container, ``seed``, ``budget``, ``restarts`` and ``separation_effort`` are per call — the /// heavy container-independent precompute is reused. ``min_sep`` and ``strategy`` were fixed at /// [`prepare`] time. Returns ``(placements, unplaced)`` exactly as [`nest`]. - #[pyo3(signature = (qty, container, holes, seed, budget, restarts=1, separation_effort="full"))] + #[pyo3(signature = (qty, container, holes, seed, budget, restarts=1, separation_effort="full", column_weight=10))] #[allow(clippy::needless_pass_by_value)] #[allow(clippy::too_many_arguments)] fn nest( @@ -448,6 +470,7 @@ impl PyPreparedParts { budget: u64, restarts: usize, separation_effort: &str, + column_weight: u32, ) -> PyResult<(Vec, Vec)> { if qty.len() != self.n_items { return Err(PyValueError::new_err(format!( @@ -461,7 +484,7 @@ impl PyPreparedParts { // data out, no threads inside the solve). let solution = py .detach(|| { - nest_with_prepared( + nest_with_prepared_weighted( &self.inner, &qty, &container, @@ -470,6 +493,7 @@ impl PyPreparedParts { budget, restarts, effort, + column_weight, ) }) .map_err(|e| PyValueError::new_err(e.to_string()))?; diff --git a/docs/04-performance-remediation-plan.md b/docs/04-performance-remediation-plan.md index 267c233..a9eca28 100644 --- a/docs/04-performance-remediation-plan.md +++ b/docs/04-performance-remediation-plan.md @@ -318,6 +318,39 @@ squeeze runs only on **unplaced** parts). Findings: 200×60 = 95 squares, but 200.5×60.5 = 120 (99 %). Real sheets have `edge_margin` slack, so it never bites; not the image's cause. +### 4c′ — Product goal: maximize remnant at the sheet end + +**Any nest's end goal is the largest usable remnant at the end of the sheet** (a clean free strip after +the packing, not merely high utilization of an over-subscribed demand). Operationalized as a +lexicographic keep-best score ([`NestScore`] in `lib.rs`): + +1. **Maximize placed area** — never drop a placeable part to "make remnant". +2. **Minimize `used_x_max`** — LBF packs toward the low-x corner; free sheet lives at high-x. +3. **Minimize `used_y_max`** — secondary packing-down. +4. Earliest multi-start *k* on a full tie. + +This score drives multi-start keep-best (all entry points) and densify's keep-best. `K=1` is +byte-identical to the previous area-only reduction (no competing starts). `K>1` is +placement-changing when two starts place equal area but different far-edges — deliberately, because +the product prefers the tighter end-remnant. Use `restarts≥8` + `column_weight=3` + +`separation_effort="max"` for remnant-critical jobs. + +### 4c″ — Shipped 2026-07-10 follow-up (independent re-measure, branch `perf/nfp-density-and-speed`) + +Reproduced the sector corpus and **did not trust** the earlier "nothing left" conclusion. Results: + +| lever | result | ship? | +|---|---|---| +| **`NestConfig.column_weight` (opt-in, default 10)** | NFP cone+square K=1: **83.3 % → 86.6 %** at `w=3` (+3.3 pp, +8 squares). Fast path still holds the gain (81.6 → 85.8). Goldens untouched at default 10. | **SHIPPED** — Rust `column_weight` + `DENSITY_COLUMN_WEIGHT=3`; Python `column_weight=` kwarg on `nest` / `nest_multi` / `PreparedParts.nest` | +| **Post-separation NFP refill** (docs/03 §13 Q5) | Rebuilds integer obstacles from the live layout after `run_separation` (+ after Max densify) and runs one more NFP `place_pass`. No-op when the sheet is already full (goldens byte-identical). Seats parts into pockets sep opens. | **SHIPPED** (always on for NFP) | +| **Local NFP re-pack** (remove each part, re-place at LBF-best NFP pose) | **REJECTED**: at `w=10` regressed util **83.3 → 82.5 %** by rearranging a feasible construction into a worse basin for the sep tail. Do not re-try without a keep-best guard. | no | +| **Finer rotations / more restarts alone** | Confirmed dead for sector tightness once `w=3` is on (K=4 = K=1 = 86.6 %). | no | +| **Remnant-first multi-start keep-best** (`NestScore`) | Max placed area, then min packing far-edge (`used_x_max`, `used_y_max`). `K=1` byte-identical; `K>1` prefers tighter end remnant when areas tie (re-blessed `multistart-mixed` golden). Measured: Max+w3 **K=1 max_x 103.6 / remnant 56.8 %** → **K=8 max_x 103.4 / remnant 57.0 %**. | **SHIPPED** | +| **Post-NFP BL compact** | **REJECTED** — regressed over-subscribed util 86.6→85.8 %. Remnant of feasible layouts is Max densify, not free compact. | no | +| **Recommended consumer settings** | **Remnant-critical (all parts fit):** `strategy="nfp"`, `column_weight=3`, `restarts≥8`, `separation_effort="max"`. **Over-subscribed / wall-clock:** same + `separation_effort="fast"`. | docs | + +Performance posture is unchanged from §5: sep tail dominates; use `SeparationEffort::Fast` (15–18×) and `PreparedParts` (11.8× part-in-part). There is still **no free byte-identical 10×** for a single over-subscribed start — the work is inherent. + ## 5. Recommended sequence (revised after Tier 1 measurement) 1. ~~**Tier 1 (proxy caching + CDE tuning)**~~ — **measured a wash** (§4). Ruled out; the sep tail is From 49d53465bf428664e77f8ecdfe38105a6f4203a4 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Fri, 10 Jul 2026 21:24:59 -0500 Subject: [PATCH 3/4] =?UTF-8?q?release:=20v0.6.0=20=E2=80=94=20bump=20vers?= =?UTF-8?q?ion=20+=20README=20(effort=20tiers,=20column=5Fweight,=20prepar?= =?UTF-8?q?e)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Version 0.5.0 -> 0.6.0. README documents the new density/speed knobs (strategy="nfp", restarts, separation_effort full/fast/max, column_weight) and the prepare()/PreparedParts part-in-part reuse. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 10 +++++----- Cargo.toml | 2 +- README.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b62a0d9..d99335b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -167,14 +167,14 @@ dependencies = [ [[package]] name = "ironnest" -version = "0.5.0" +version = "0.6.0" dependencies = [ "ironnest-optimizer", ] [[package]] name = "ironnest-cde" -version = "0.5.0" +version = "0.6.0" dependencies = [ "anyhow", "float-cmp", @@ -187,7 +187,7 @@ dependencies = [ [[package]] name = "ironnest-geo" -version = "0.5.0" +version = "0.6.0" dependencies = [ "anyhow", "float-cmp", @@ -203,7 +203,7 @@ dependencies = [ [[package]] name = "ironnest-optimizer" -version = "0.5.0" +version = "0.6.0" dependencies = [ "i_overlay", "insta", @@ -214,7 +214,7 @@ dependencies = [ [[package]] name = "ironnest-py" -version = "0.5.0" +version = "0.6.0" dependencies = [ "ironnest", "pyo3", diff --git a/Cargo.toml b/Cargo.toml index 4fb929b..8255bcf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ resolver = "3" members = ["crates/geo", "crates/cde", "crates/optimizer", "crates/ironnest", "crates/py"] [workspace.package] -version = "0.5.0" # bump deliberately; re-bless goldens if placement code changed +version = "0.6.0" # bump deliberately; re-bless goldens if placement code changed edition = "2024" # jagua-rs lineage; edition 2024 needs Rust ≥ 1.85 rust-version = "1.88" # MSRV floor: i_overlay 7.x (docs/03 §3.5); jagua's cast_signed floor was 1.87 # (stable 1.87). We build on the pinned 1.96.0 — see rust-toolchain.toml. diff --git a/README.md b/README.md index 5913530..e9b110c 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,46 @@ placements, unplaced = ironnest.nest( — e.g. rectangles pinned to `[0, 90]` while triangles interlock on `[0, 45, …, 315]`. Per-item sets are equally byte-identical across platforms (the cross-platform golden covers a per-item case). +## Denser packing & speed knobs + +`nest` / `nest_multi` take optional keyword arguments that trade run time for density or speed. Each +keeps the byte-identical determinism contract — the same arguments always reproduce the same layout: + +- **`strategy`** — `"sampling"` (default) or **`"nfp"`**, the exact no-fit-polygon constructive mode: + integer-arithmetic Minkowski placement that puts parts in *contact* (killing the touching-artifact + gaps the sampler leaves) and nests into concave pockets. Recommended for irregular / curved parts. +- **`restarts`** — deterministic best-of-K multi-start (default `1`). Runs K decorrelated seeds and + keeps the densest, remnant-first result; `8`–`16` meaningfully tightens heterogeneous packs. +- **`separation_effort`** — how hard the post-construction search works on the leftovers: + - `"full"` (default) — the shipped behaviour; + - `"fast"` — skip provably-hopeless placements + a reduced budget: **~15× faster** on + over-subscribed jobs (a deliberately different, still-feasible layout); + - `"max"` — a **densification / compaction** pass that shrinks the pack toward one corner to leave + the largest usable **remnant** (a clean free strip) at the sheet's end. +- **`column_weight`** — the Left-Bottom-Fill horizontal weight for NFP placement (default `10`); + lowering it toward `3` closes vertical gaps between irregular parts. + +```python +# Densest end-remnant for a fits-on-one-sheet job of curved parts: +placements, unplaced = ironnest.nest( + items, qty, container, holes=[], min_sep=0.375, + rotations=[0, 90, 180, 270], seed=1, budget=400, + strategy="nfp", column_weight=3, restarts=8, separation_effort="max", +) +``` + +### Reusing a part library across many containers + +For *part-in-part* workloads — nesting one part set into many parent shapes — **`prepare`** builds the +container-independent geometry (item import + the O(P²) pairwise NFP table) **once** and reuses it +(~M× faster over M calls, byte-identical): + +```python +prepared = ironnest.prepare(items, rotations=[0, 90, 180, 270], min_sep=0.375, strategy="nfp") +for parent in parents: + placements, unplaced = prepared.nest(qty, parent, holes=[], seed=1, budget=400) +``` + ## Why it exists Existing irregular nesters force a bad trade: @@ -99,6 +139,14 @@ A complete, end-to-end deterministic nester, shipping as a PyPI wheel. maturin/cibuildwheel. - **Phase 6 — interior voids + multi-sheet.** `nest(holes=…)` keep-out zones (nesting inside parts) and `nest_multi(sheets=…)`, with a deterministic min-separation offsetter. +- **Exact-NFP construction mode (v0.5.0).** `strategy="nfp"` — an integer-arithmetic no-fit-polygon + constructive placer that places in contact and nests concave pockets + ([`docs/03`](docs/03-nfp-construction-mode.md)). +- **Density & speed knobs (v0.6.0).** Separation-effort tiers (`"fast"` ≈ 15× on over-subscribed + packs, `"max"` remnant-maximizing densification); a tunable LBF `column_weight`; a reusable + `prepare()` part library for part-in-part workloads (~M× faster); and a dev-profile build fix that + cut the test suite ~12×. All opt-in and cross-platform byte-identical + ([`docs/04`](docs/04-performance-remediation-plan.md)). **Next:** Phase 5 — integration into the first consumer (a plasma CAD/CAM application). See [`docs/00-ironnest-architecture-and-plan.md`](docs/00-ironnest-architecture-and-plan.md) (the plan) From 93ab60591f464e9af5b55cc7cf90921b7adb5dda Mon Sep 17 00:00:00 2001 From: Jeff West Date: Fri, 10 Jul 2026 21:46:47 -0500 Subject: [PATCH 4/4] test: fix parallel-determinism test for remnant-first keep-best parallel_multistart_equals_independent_sequential_best (--features parallel only, so it slipped past local `cargo test`) reconstructed the sequential best-of-K with an OLD area-only argmax, which no longer matches the engine's remnant-first NestScore keep-best (equal-area starts now break the tie on used_x_max). The engine is consistent (parallel and sequential both use keep_best_start); only the test's mirror was stale. Add a doc(hidden) multistart_start_scored hook returning each start's exact NestScore tuple (placed_area, used_x_max, used_y_max) so the test mirrors the engine's reduction exactly. Passes under --features parallel. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/optimizer/src/lib.rs | 45 ++++++++++++++++++++++++++++++++ crates/optimizer/tests/nest.rs | 47 +++++++++++++++++++--------------- 2 files changed, 72 insertions(+), 20 deletions(-) diff --git a/crates/optimizer/src/lib.rs b/crates/optimizer/src/lib.rs index c8597bc..a8da6b5 100644 --- a/crates/optimizer/src/lib.rs +++ b/crates/optimizer/src/lib.rs @@ -465,6 +465,51 @@ pub fn multistart_start( .0 } +/// [`multistart_start`] plus the engine's remnant-first keep-best score, as the tuple +/// `(placed_area, used_x_max, used_y_max)` — the exact [`NestScore`] fields the multi-start reduction +/// ranks on. Doc-hidden test hook so `parallel_multistart_equals_independent_sequential_best` can +/// mirror the engine's *actual* reduction (not an area-only proxy). NOT a stable API. +#[doc(hidden)] +#[must_use] +#[allow(clippy::too_many_arguments)] +pub fn multistart_start_scored( + items: &[Vec<[Scalar; 2]>], + qty: &[usize], + container: &[[Scalar; 2]], + holes: &[Vec<[Scalar; 2]>], + min_sep: Scalar, + rotations_deg: &[Vec], + seed: u64, + budget: u64, + k: u64, + starts: u64, +) -> (NestSolution, (Scalar, Scalar, Scalar)) { + let Some(problem) = import_problem(items, container, holes, min_sep, rotations_deg) else { + return ( + NestSolution { + placements: vec![], + unplaced: all_unplaced(qty), + }, + (0.0, 0.0, 0.0), + ); + }; + let areas: Vec = items.iter().map(|o| polygon_area(o)).collect(); + let (sol, score) = multistart_start_imported( + &problem, + qty, + seed, + budget, + k, + starts, + PlacementStrategy::Sampling, + None, + SeparationEffort::Full, + nfp::DEFAULT_COLUMN_WEIGHT, + &areas, + ); + (sol, (score.placed_area, score.used_x_max, score.used_y_max)) +} + /// [`multistart_start`] over an already-imported [`Problem`] — the form `run_starts` uses so the K /// starts share ONE import (the N1a hoist). Import is a pure function, so this is byte-identical /// to `multistart_start`'s own import for the same raw inputs; the per-start seed and order-policy diff --git a/crates/optimizer/tests/nest.rs b/crates/optimizer/tests/nest.rs index fdff246..b11eb54 100644 --- a/crates/optimizer/tests/nest.rs +++ b/crates/optimizer/tests/nest.rs @@ -168,26 +168,27 @@ fn multistart_never_worse_than_a_single_start() { #[test] fn parallel_multistart_equals_independent_sequential_best() { // Under --features parallel, nest_multistart runs the K starts on threads. It must equal an - // INDEPENDENT sequential best-of-K computed here from `multistart_start` (the doc(hidden) - // single-start hook — the same per-start seed + insertion-order policy the engine uses, so the - // reconstruction stays honest now that odd-k starts jitter their insertion order). TWO part - // types at overflow demand ⇒ the starts genuinely differ and the reduction is a real area - // argmax, not an everything-ties shortcut. This is the local parallel==sequential - // byte-identity check; the cross-platform proof is the golden run under --features parallel. - use ironnest_optimizer::multistart_start; + // INDEPENDENT sequential best-of-K computed here from `multistart_start_scored` (the doc(hidden) + // single-start hook that returns each start's exact `NestScore` — the same per-start seed + + // insertion-order policy the engine uses, and the same remnant-first reduction). TWO part types + // at overflow demand ⇒ the starts genuinely differ, so the reduction is a real remnant-first + // best-of-K (max placed area, then min used_x_max), not an everything-ties shortcut. This is the + // local parallel==sequential byte-identity check; the cross-platform proof is the golden run + // under --features parallel. + use ironnest_optimizer::multistart_start_scored; let items = vec![rect(13.0, 7.0), rect(10.0, 10.0)]; let qty = [12usize, 6]; // 1092 + 600 = 1692 in 40×40 = 1600 → guaranteed unplaced tail - // These literals must equal the engine's shoelace `polygon_area` of the outlines BIT-FOR-BIT - // for the argmax mirror below to be exact. They do: integer-coordinate rects are exact in f64 - // (0.5·|Σ cross| = w·h with no rounding). Recheck if the items above ever change. - let areas = [91.0, 100.0]; let container = rect(40.0, 40.0); let rotations = vec![CARDINAL.to_vec(), CARDINAL.to_vec()]; let (k, budget, seed) = (4u64, 700u64, 1u64); - let mut manual: Option<(ironnest_optimizer::NestSolution, f64)> = None; + // Reconstruct the engine's remnant-first best-of-K from the scored single-start hook. Each start + // returns the exact `NestScore` tuple `(placed_area, used_x_max, used_y_max)` the engine ranks on, + // so this mirrors `keep_best_start` / `NestScore::better_than` EXACTLY: strictly-more placed area + // wins; equal area → lower used_x_max; equal → lower used_y_max; earliest k on a full tie. + let mut manual: Option<(ironnest_optimizer::NestSolution, (f64, f64, f64))> = None; for ki in 0..k { - let sol = multistart_start( + let (sol, score) = multistart_start_scored( &items, &qty, &container, @@ -199,14 +200,20 @@ fn parallel_multistart_equals_independent_sequential_best() { ki, k, ); - // Iterate k ascending, replace only on STRICTLY-more placed AREA (summed in the solution's - // own placement order) → earliest k on a tie, exactly as the engine's reduction does. - let area: f64 = sol.placements.iter().map(|p| areas[p.item]).sum(); - let better = manual - .as_ref() - .is_none_or(|(_, best)| area.total_cmp(best) == std::cmp::Ordering::Greater); + let better = manual.as_ref().is_none_or(|(_, b)| { + use std::cmp::Ordering::{Equal, Greater, Less}; + match score.0.total_cmp(&b.0) { + Greater => true, + Less => false, + Equal => match score.1.total_cmp(&b.1) { + Less => true, + Greater => false, + Equal => score.2.total_cmp(&b.2) == Less, + }, + } + }); if better { - manual = Some((sol, area)); + manual = Some((sol, score)); } }