diff --git a/Cargo.lock b/Cargo.lock index b895c02..e49342a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -125,14 +125,14 @@ dependencies = [ [[package]] name = "ironnest" -version = "0.4.0" +version = "0.4.1" dependencies = [ "ironnest-optimizer", ] [[package]] name = "ironnest-cde" -version = "0.4.0" +version = "0.4.1" dependencies = [ "anyhow", "float-cmp", @@ -145,7 +145,7 @@ dependencies = [ [[package]] name = "ironnest-geo" -version = "0.4.0" +version = "0.4.1" dependencies = [ "anyhow", "float-cmp", @@ -161,7 +161,7 @@ dependencies = [ [[package]] name = "ironnest-optimizer" -version = "0.4.0" +version = "0.4.1" dependencies = [ "insta", "ironnest-cde", @@ -171,7 +171,7 @@ dependencies = [ [[package]] name = "ironnest-py" -version = "0.4.0" +version = "0.4.1" dependencies = [ "ironnest", "pyo3", diff --git a/Cargo.toml b/Cargo.toml index a2a9965..0209946 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.4.0" # bump deliberately; re-bless goldens if placement code changed +version = "0.4.1" # bump deliberately; re-bless goldens if placement code changed edition = "2024" # jagua-rs lineage; edition 2024 needs Rust ≥ 1.85 rust-version = "1.87" # MSRV floor: jagua's forked code uses {integer}::cast_signed/cast_unsigned # (stable 1.87). We build on the pinned 1.96.0 — see rust-toolchain.toml. diff --git a/crates/ironnest/src/lib.rs b/crates/ironnest/src/lib.rs index 3760a05..579ac22 100644 --- a/crates/ironnest/src/lib.rs +++ b/crates/ironnest/src/lib.rs @@ -20,5 +20,6 @@ #[doc(inline)] pub use ironnest_optimizer::{ MultiSheetSolution, NestSolution, Placement, Scalar, Sheet, nest, nest_multi, - nest_multi_per_item, nest_multistart, nest_multistart_per_item, nest_per_item, + nest_multi_multistart, nest_multi_multistart_per_item, nest_multi_per_item, nest_multistart, + nest_multistart_per_item, nest_per_item, }; diff --git a/crates/optimizer/src/lib.rs b/crates/optimizer/src/lib.rs index 525ced9..d223ff1 100644 --- a/crates/optimizer/src/lib.rs +++ b/crates/optimizer/src/lib.rs @@ -607,10 +607,49 @@ pub fn nest_multi( seed: u64, budget: u64, ) -> MultiSheetSolution { - // Broadcast the single rotation set to every item, then run the per-item path (byte-identical — - // see [`nest`]). + nest_multi_multistart(items, qty, sheets, min_sep, rotations_deg, seed, budget, 1) +} + +/// Like [`nest_multi`], but with a **distinct allowed-rotation set per item type** — `rotations_deg[k]` +/// applies to `items[k]` (`rotations_deg.len() == items.len()`). Carries the per-item semantics of +/// [`nest_per_item`] across the multi-sheet spill. Same determinism contract as [`nest_multi`]. +#[must_use] +#[allow(clippy::too_many_arguments)] +pub fn nest_multi_per_item( + items: &[Vec<[Scalar; 2]>], + qty: &[usize], + sheets: &[Sheet], + min_sep: Scalar, + rotations_deg: &[Vec], + seed: u64, + budget: u64, +) -> MultiSheetSolution { + nest_multi_multistart_per_item(items, qty, sheets, min_sep, rotations_deg, seed, budget, 1) +} + +/// [`nest_multi`] with **deterministic best-of-K multi-start applied to every sheet**: each sheet is +/// packed with the densest of `n_starts` decorrelated-seed runs *before* the leftover demand spills to +/// the next. Maximising each sheet's fill directly reduces what spills forward, so fewer sheets / less +/// material are used on a mixed-parts job. Applies **one** rotation set to every item; for a per-item +/// set use [`nest_multi_multistart_per_item`]. +/// +/// `n_starts` is clamped to ≥ 1; **`n_starts == 1` is byte-identical to [`nest_multi`]**, so existing +/// behaviour is unchanged. Same determinism contract — the per-sheet best-of-K is byte-deterministic +/// (see [`nest_multistart`]); under the `parallel` feature each sheet's K starts run on threads. +#[must_use] +#[allow(clippy::too_many_arguments)] +pub fn nest_multi_multistart( + items: &[Vec<[Scalar; 2]>], + qty: &[usize], + sheets: &[Sheet], + min_sep: Scalar, + rotations_deg: &[Scalar], + seed: u64, + budget: u64, + n_starts: usize, +) -> MultiSheetSolution { let rotations_per_item = vec![rotations_deg.to_vec(); items.len()]; - nest_multi_per_item( + nest_multi_multistart_per_item( items, qty, sheets, @@ -618,15 +657,16 @@ pub fn nest_multi( &rotations_per_item, seed, budget, + n_starts, ) } -/// Like [`nest_multi`], but with a **distinct allowed-rotation set per item type** — `rotations_deg[k]` -/// applies to `items[k]` (`rotations_deg.len() == items.len()`). Carries the per-item semantics of -/// [`nest_per_item`] across the multi-sheet spill. Same determinism contract as [`nest_multi`]. +/// [`nest_multi_multistart`] with a **distinct allowed-rotation set per item type** (the +/// [`nest_per_item`] semantics). Each sheet runs a best-of-`n_starts` pack against the remaining +/// demand; `n_starts == 1` reduces to [`nest_multi_per_item`] byte-for-byte. #[must_use] #[allow(clippy::too_many_arguments)] -pub fn nest_multi_per_item( +pub fn nest_multi_multistart_per_item( items: &[Vec<[Scalar; 2]>], qty: &[usize], sheets: &[Sheet], @@ -634,6 +674,7 @@ pub fn nest_multi_per_item( rotations_deg: &[Vec], seed: u64, budget: u64, + n_starts: usize, ) -> MultiSheetSolution { assert_eq!( items.len(), @@ -651,9 +692,11 @@ pub fn nest_multi_per_item( for (i, sheet) in sheets.iter().enumerate() { // A distinct, deterministic per-sheet seed (SplitMix64 in the PRNG de-correlates adjacent - // seeds, so `seed + i` gives well-separated streams). + // seeds, so `seed + i` gives well-separated streams). Each sheet's K best-of-K runs then + // derive from that seed (`sheet_seed + k`); the K runs of a given sheet are decorrelated from + // each other — the property best-of-K relies on — independent of the cross-sheet stride. let sheet_seed = seed.wrapping_add(i as u64); - let sol = nest_per_item( + let sol = nest_multistart_per_item( items, &remaining, &sheet.outline, @@ -662,6 +705,7 @@ pub fn nest_multi_per_item( rotations_deg, sheet_seed, budget, + n_starts, ); for p in &sol.placements { diff --git a/crates/optimizer/tests/nest.rs b/crates/optimizer/tests/nest.rs index 6a3d499..c63664a 100644 --- a/crates/optimizer/tests/nest.rs +++ b/crates/optimizer/tests/nest.rs @@ -7,8 +7,8 @@ //! byte-identical placements). use ironnest_optimizer::{ - Scalar, Sheet, nest, nest_multi, nest_multi_per_item, nest_multistart, - nest_multistart_per_item, nest_per_item, + Scalar, Sheet, nest, nest_multi, nest_multi_multistart, nest_multi_multistart_per_item, + nest_multi_per_item, nest_multistart, nest_multistart_per_item, nest_per_item, }; /// An axis-aligned `w × h` rectangle with its lower-left corner at the origin (CCW). @@ -675,6 +675,90 @@ fn multi_sheet_demand_exceeding_capacity_no_underflow() { assert_eq!(sol.unplaced.len(), 9); } +// --------------------------------------------------------------------------- +// Multi-sheet multi-start (best-of-K per sheet) +// --------------------------------------------------------------------------- + +#[test] +fn multi_sheet_multistart_k1_is_byte_identical_to_nest_multi() { + // restarts=1 must equal nest_multi bit-for-bit (keeps the existing multi-sheet behaviour) — the + // same load-bearing invariant the single-sheet path holds. + let items = vec![rect(10.0, 10.0)]; + let sheets = vec![ + Sheet { + outline: rect(35.0, 35.0), + holes: vec![], + }, + Sheet { + outline: rect(35.0, 35.0), + holes: vec![], + }, + ]; + let single = nest_multi(&items, &[16], &sheets, 0.0, &CARDINAL, 9, 1200); + let multi = nest_multi_multistart(&items, &[16], &sheets, 0.0, &CARDINAL, 9, 1200, 1); + assert_eq!(single, multi, "restarts=1 must equal nest_multi"); + // n_starts=0 clamps to 1. + let zero = nest_multi_multistart(&items, &[16], &sheets, 0.0, &CARDINAL, 9, 1200, 0); + assert_eq!(single, zero, "restarts=0 clamps to a single start"); +} + +#[test] +fn multi_sheet_multistart_is_deterministic_and_broadcast_matches() { + let items = vec![rect(13.0, 7.0)]; + let sheets = vec![ + Sheet { + outline: rect(50.0, 50.0), + holes: vec![], + }, + Sheet { + outline: rect(50.0, 50.0), + holes: vec![], + }, + ]; + let a = nest_multi_multistart(&items, &[40], &sheets, 0.0, &CARDINAL, 1, 1000, 3); + let b = nest_multi_multistart(&items, &[40], &sheets, 0.0, &CARDINAL, 1, 1000, 3); + assert_eq!( + a, b, + "multi-sheet best-of-K must be byte-identical for the same args" + ); + let broadcast = vec![CARDINAL.to_vec()]; + let per_item = + nest_multi_multistart_per_item(&items, &[40], &sheets, 0.0, &broadcast, 1, 1000, 3); + assert_eq!( + a, per_item, + "broadcast per-item multi-sheet multistart must equal uniform" + ); +} + +#[test] +fn multi_sheet_multistart_never_places_fewer_than_single() { + // Homogeneous parts ⇒ each sheet's best-of-K places ≥ the single start on the same remaining, and + // the greedy spill carries that forward (a denser earlier sheet leaves at most that many fewer for + // the next), so the total placed can never drop below the single-start total. + let items = vec![rect(13.0, 7.0)]; + let sheets = vec![ + Sheet { + outline: rect(50.0, 50.0), + holes: vec![], + }, + Sheet { + outline: rect(50.0, 50.0), + holes: vec![], + }, + ]; + let placed = |s: &ironnest_optimizer::MultiSheetSolution| -> usize { + s.per_sheet.iter().map(Vec::len).sum() + }; + let single = nest_multi(&items, &[40], &sheets, 0.0, &CARDINAL, 1, 1000); + let multi = nest_multi_multistart(&items, &[40], &sheets, 0.0, &CARDINAL, 1, 1000, 4); + assert!( + placed(&multi) >= placed(&single), + "best-of-4 multi-sheet ({}) must place at least the single start ({})", + placed(&multi), + placed(&single), + ); +} + // --------------------------------------------------------------------------- // Collision-footprint decimation for high-vertex curved parts // diff --git a/crates/py/src/lib.rs b/crates/py/src/lib.rs index f95759e..dc5180b 100644 --- a/crates/py/src/lib.rs +++ b/crates/py/src/lib.rs @@ -12,8 +12,9 @@ //! byte-identical, cross-platform-reproducible output (proven by the Phase-3 golden). use ironnest_core::{ - Sheet, nest_multi_per_item as nest_multi_impl, - nest_multistart_per_item as nest_multistart_impl, nest_per_item as nest_impl, + Sheet, nest_multi_multistart_per_item as nest_multi_multistart_impl, + nest_multi_per_item as nest_multi_impl, nest_multistart_per_item as nest_multistart_impl, + nest_per_item as nest_impl, }; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; @@ -246,6 +247,11 @@ fn nest( /// identically. /// sheets : list[tuple[list[tuple[float, float]], list[list[tuple[float, float]]]]] /// Each sheet is ``(outline, holes)`` — a boundary outline plus its keep-out holes. +/// restarts : int, optional +/// Best-of-K multi-start applied **per sheet** (default ``1`` = byte-identical to the historical +/// behaviour). Each sheet is packed with the densest of ``restarts`` decorrelated-seed runs before +/// leftover demand spills to the next, so fewer sheets / less material are used on a mixed-parts +/// job. Same determinism contract; the K runs of each sheet execute in parallel (GIL released). /// /// Returns /// ------- @@ -256,7 +262,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))] +#[pyo3(signature = (items, qty, sheets, min_sep, rotations, seed, budget, restarts=1))] #[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( @@ -268,6 +274,7 @@ fn nest_multi( rotations: Bound<'_, PyAny>, seed: u64, budget: u64, + restarts: usize, ) -> PyResult<(Vec>, Vec)> { if items.len() != qty.len() { return Err(PyValueError::new_err(format!( @@ -287,8 +294,17 @@ fn nest_multi( .collect(); // Release the GIL for the solve — see the note in `nest` for the why and the safety argument. - let solution = - py.detach(|| nest_multi_impl(&items, &qty, &sheets, min_sep, &rotations, seed, budget)); + // `restarts <= 1` is the historical single-start spill; `> 1` packs each sheet best-of-K. Both + // honour the determinism contract. + let solution = py.detach(|| { + if restarts <= 1 { + nest_multi_impl(&items, &qty, &sheets, min_sep, &rotations, seed, budget) + } else { + nest_multi_multistart_impl( + &items, &qty, &sheets, min_sep, &rotations, seed, budget, restarts, + ) + } + }); let per_sheet = solution .per_sheet