From 0d965ec2ed8d3f733764c37225cb944940912082 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Tue, 23 Jun 2026 19:31:25 -0500 Subject: [PATCH 1/6] perf: separation profiling harness + byte-identical collector reuse (A1) Profiling (examples/bench.rs, per-phase timing) shows the separation search is 97-99% of wall-clock on any nest with an unplaced tail (pentagon: 3330ms of 3340ms); construction/improve are noise. The dominant cost is per-sample collision-query + pole-pair proxy compute across a well-tuned iteration budget -- NOT allocation, key lookups, or loss sums. This reframes the perf work: micro-opts on those give ~0; the real levers are multi-start + safe parallelism (best-of-K at ~1x wall-clock). - examples/bench.rs: per-probe best-of-N wall-clock harness (Instant under a scoped allow -- a benchmark, never a placement input; criterion is avoided because its dep tree would breach deny.toml's exact license allowlist). - examples/seedsweep.rs: empirical best-of-K probe (public API only) -- the evidence for multi-start (bricks 91.9% -> 95.5% over 16 seeds). - sep/evaluator.rs (A1): reuse one BasicHazardCollector + terms Vec across the ~hundreds of evaluate_sample calls per move instead of allocating per sample. Byte-identical (the canonical terms sort makes the summed loss independent of the reused buffer's contents/order); the golden is unchanged. Measured ~0 speedup -- kept for reduced allocation pressure under the coming parallelism. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/optimizer/examples/bench.rs | 180 +++++++++++++++++++++++++ crates/optimizer/examples/seedsweep.rs | 76 +++++++++++ crates/optimizer/src/sep/evaluator.rs | 83 +++++++----- 3 files changed, 307 insertions(+), 32 deletions(-) create mode 100644 crates/optimizer/examples/bench.rs create mode 100644 crates/optimizer/examples/seedsweep.rs diff --git a/crates/optimizer/examples/bench.rs b/crates/optimizer/examples/bench.rs new file mode 100644 index 0000000..41682ae --- /dev/null +++ b/crates/optimizer/examples/bench.rs @@ -0,0 +1,180 @@ +// 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/. + +//! Per-probe wall-clock + density harness for the nester — ground truth for the perf-tuning work +//! (the A-stack: collector reuse, hazkey cache, incremental loss). Reports best-of-N milliseconds +//! per probe so a speedup is visible against the noise floor. `cargo run -p ironnest-optimizer +//! --release --example bench`. +//! +//! DETERMINISM(ironnest): this is a BENCHMARK, never a placement path — the `Instant` wall-clock here +//! only times the engine; it never feeds a placement decision (which is why the determinism gate's +//! `Instant::now` ban does not apply, exactly as the std-trig ban is waived for the test-input +//! generators in `tests/nest.rs`). The placements themselves remain a pure function of `(inputs, seed, +//! budget)`. Timing is reported only to stderr-style stdout text; it is NOT part of any golden. + +use ironnest_optimizer::{Scalar, nest, nest_multistart}; + +fn rect(w: Scalar, h: Scalar) -> Vec<[Scalar; 2]> { + vec![[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]] +} + +/// Best-of-`reps` wall-clock (ms) of `f`. Best (min) is the most stable estimator for a CPU-bound, +/// allocation-sensitive routine: it filters scheduler/allocator noise that only ever adds time. +#[allow(clippy::disallowed_methods)] // wall-clock for benchmarking only — never a placement input +fn best_ms(reps: u32, mut f: F) -> Scalar { + let mut best = Scalar::INFINITY; + for _ in 0..reps { + let t = std::time::Instant::now(); + f(); + let ms = t.elapsed().as_secs_f64() * 1000.0; + best = best.min(ms); + } + best +} + +#[allow(clippy::too_many_arguments)] +fn probe( + label: &str, + item: Vec<[Scalar; 2]>, + item_area: Scalar, + qty: usize, + side: Scalar, + budget: u64, + reps: u32, + separation_heavy: bool, +) { + let container = rect(side, side); + let rotations = [0.0, 90.0, 180.0, 270.0]; + + // Capture density once (it is deterministic), then time the call best-of-reps. + let mut placed = 0usize; + let ms = best_ms(reps, || { + let sol = nest( + &[item.clone()], + &[qty], + &container, + &[], + 0.0, + &rotations, + 1, + budget, + ); + placed = sol.placements.len(); + }); + let util = (placed as Scalar * item_area) / (side * side) * 100.0; + let tag = if separation_heavy { " [sep]" } else { "" }; + println!( + "{label:<24} placed {placed:>4}/{qty:<4} util {util:>5.1}% best {ms:>8.2} ms (budget {budget}, best of {reps}){tag}" + ); +} + +fn main() { + println!("--- bench (per-probe best-of-N wall-clock; [sep] = separation-search-heavy) ---"); + probe( + "10x10 in 100 (exact)", + rect(10.0, 10.0), + 100.0, + 100, + 100.0, + 2000, + 3, + false, + ); + probe( + "10x10 in 100.5 (slack)", + rect(10.0, 10.0), + 100.0, + 100, + 100.5, + 2000, + 3, + false, + ); + probe( + "7x7 squares", + rect(7.0, 7.0), + 49.0, + 220, + 100.0, + 2000, + 3, + false, + ); + probe( + "13x7 bricks", + rect(13.0, 7.0), + 91.0, + 120, + 100.0, + 2000, + 3, + true, + ); + let pentagon = vec![ + [0.0, 0.0], + [20.0, 0.0], + [20.0, 12.0], + [12.0, 20.0], + [0.0, 20.0], + ]; + probe("pentagon ~344", pentagon, 344.0, 40, 100.0, 4000, 3, true); + let right_tri = vec![[0.0, 0.0], [10.0, 0.0], [0.0, 10.0]]; + probe( + "2 right-tris pair->sq", + right_tri, + 50.0, + 2, + 11.0, + 4000, + 5, + true, + ); + + println!("\n--- multi-start best-of-K (B1): density vs time on the seed-variant case ---"); + multistart_probe( + "13x7 bricks", + rect(13.0, 7.0), + 91.0, + 120, + 100.0, + 2000, + &[1, 4, 8], + ); +} + +/// Runs `nest_multistart` at several K and reports the best-of-K density + total wall-clock, so the +/// density/time trade of the multi-start lever is visible. K=1 is the single-start reference. +fn multistart_probe( + label: &str, + item: Vec<[Scalar; 2]>, + item_area: Scalar, + qty: usize, + side: Scalar, + budget: u64, + ks: &[usize], +) { + let container = rect(side, side); + let rotations = [0.0, 90.0, 180.0, 270.0]; + for &k in ks { + let mut placed = 0usize; + let ms = best_ms(1, || { + let sol = nest_multistart( + &[item.clone()], + &[qty], + &container, + &[], + 0.0, + &rotations, + 1, + budget, + k, + ); + placed = sol.placements.len(); + }); + let util = (placed as Scalar * item_area) / (side * side) * 100.0; + println!( + "{label:<18} K={k:<3} placed {placed:>4}/{qty:<4} util {util:>5.1}% {ms:>9.1} ms" + ); + } +} diff --git a/crates/optimizer/examples/seedsweep.rs b/crates/optimizer/examples/seedsweep.rs new file mode 100644 index 0000000..64c592b --- /dev/null +++ b/crates/optimizer/examples/seedsweep.rs @@ -0,0 +1,76 @@ +// 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/. + +//! Empirical test of the multi-start ("best-of-K") density thesis, through the PUBLIC API only +//! (sweep the `seed` argument; keep the best). No engine changes. Answers: does best-of-K lift +//! density, and for which shapes? `cargo run -p ironnest-optimizer --release --example seedsweep` + +use std::collections::BTreeMap; + +use ironnest_optimizer::{Scalar, nest}; + +fn rect(w: Scalar, h: Scalar) -> Vec<[Scalar; 2]> { + vec![[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]] +} + +/// Sweeps `seeds` seeds for one probe and prints the placed-count distribution + best/worst. +fn sweep( + label: &str, + item: Vec<[Scalar; 2]>, + item_area: Scalar, + qty: usize, + side: Scalar, + budget: u64, + seeds: u64, +) { + let container = rect(side, side); + let rotations = [0.0, 90.0, 180.0, 270.0]; + + let mut dist: BTreeMap = BTreeMap::new(); + let mut best = 0usize; + let mut worst = usize::MAX; + for seed in 1..=seeds { + let sol = nest( + &[item.clone()], + &[qty], + &container, + &[], + 0.0, + &rotations, + seed, + budget, + ); + let placed = sol.placements.len(); + *dist.entry(placed).or_default() += 1; + best = best.max(placed); + worst = worst.min(placed); + } + + let util = |n: usize| (n as Scalar * item_area) / (side * side) * 100.0; + let dist_str: Vec = dist.iter().map(|(k, v)| format!("{k}:{v}")).collect(); + println!( + "{label:<22} K={seeds:<3} best {best}/{qty} ({:.1}%) worst {worst}/{qty} ({:.1}%) spread +{:.1}pp dist {{{}}}", + util(best), + util(worst), + util(best) - util(worst), + dist_str.join(", "), + ); +} + +fn main() { + println!("--- best-of-K seed sweep (public API, single ordering) ---"); + // Bricks: the roadmap CLAIMS best-of-K lifts 91.9% -> ~95.5%. Verify. + sweep("13x7 bricks", rect(13.0, 7.0), 91.0, 120, 100.0, 2000, 16); + // Squares: roadmap claims ZERO seed variance (rotation-invariant). + sweep("7x7 squares", rect(7.0, 7.0), 49.0, 220, 100.0, 2000, 16); + // Pentagon: roadmap claims FLAT at 23/40 across all seeds (architectural cap). + let pentagon = vec![ + [0.0, 0.0], + [20.0, 0.0], + [20.0, 12.0], + [12.0, 20.0], + [0.0, 20.0], + ]; + sweep("pentagon ~344", pentagon, 344.0, 40, 100.0, 4000, 16); +} diff --git a/crates/optimizer/src/sep/evaluator.rs b/crates/optimizer/src/sep/evaluator.rs index 7f40d1c..921a511 100644 --- a/crates/optimizer/src/sep/evaluator.rs +++ b/crates/optimizer/src/sep/evaluator.rs @@ -89,6 +89,14 @@ pub struct SeparationEvaluator<'a> { container_bbox: Rect, /// Scratch shape (keeps its surrogate, which the proxy needs), transformed per candidate. shape_buff: SPolygon, + /// Scratch collision collector, reused (cleared, capacity retained) across every + /// [`Self::evaluate_sample`] call instead of allocating a fresh one per candidate pose — the + /// separator evaluates ~hundreds of poses per move. Reuse is byte-identical: the canonical + /// `terms` sort below makes the summed loss independent of this collector's contents/order. + collector: BasicHazardCollector, + /// Scratch `(HazKey, weighted term)` buffer, reused per candidate (sorted in place each time so + /// the float sum is canonical regardless of CDE traversal order). + terms: Vec<(HazKey, Scalar)>, } impl<'a> SeparationEvaluator<'a> { @@ -99,6 +107,7 @@ impl<'a> SeparationEvaluator<'a> { item: &'a Item, current_pk: PItemKey, ) -> Self { + let cap = layout.placed_items.len() + 1; Self { layout, tracker, @@ -106,6 +115,8 @@ impl<'a> SeparationEvaluator<'a> { current_pk, container_bbox: layout.container.outer_cd.bbox, shape_buff: (*item.shape_cd).clone(), + collector: BasicHazardCollector::with_capacity(cap), + terms: Vec::with_capacity(cap), } } @@ -126,44 +137,52 @@ impl<'a> SeparationEvaluator<'a> { return SampleEval::Invalid; } - let mut collector = BasicHazardCollector::with_capacity(self.layout.placed_items.len() + 1); + // Reuse the scratch collector (cleared, capacity retained) instead of allocating per sample. + self.collector.clear(); self.layout .cde() - .collect_poly_collisions(&self.shape_buff, &mut collector); + .collect_poly_collisions(&self.shape_buff, &mut self.collector); - if collector.is_empty() { + if self.collector.is_empty() { return SampleEval::Clear { loss: 0.0 }; } - // Canonical summation: gather (HazKey, weighted term), sort by key, then sum — so the total - // is independent of the CDE's traversal order and byte-identical across platforms. - let mut terms: Vec<(HazKey, Scalar)> = collector - .iter() - .map(|(hkey, haz)| { - let term = match haz { - HazardEntity::PlacedItem { pk: other_pk, .. } => { - let other_shape = &self.layout.placed_items[*other_pk].shape; - let loss = quantify_collision_poly_poly(other_shape, &self.shape_buff); - loss * self.tracker.pair_weight(self.current_pk, *other_pk) - } - HazardEntity::Exterior => { - let loss = quantify_collision_poly_container( - &self.shape_buff, - self.container_bbox, - ); - loss * self.tracker.container_weight(self.current_pk) - } - // A hole / keep-out zone the part must avoid (the interior-void path); shares the - // item's single static-hazard GLS weight with the exterior. - HazardEntity::Hole { .. } | HazardEntity::InferiorQualityZone { .. } => { - let hole_shape = &self.layout.cde().hazards_map[hkey].shape; - let loss = quantify_collision_poly_hole(&self.shape_buff, hole_shape); - loss * self.tracker.container_weight(self.current_pk) - } - }; - (hkey, term) - }) - .collect(); + // Canonical summation: gather (HazKey, weighted term) into the reused `terms` buffer, sort by + // key, then sum — so the total is independent of the CDE's traversal order and byte-identical + // across platforms. (Destructure so the map closure borrows only the read-only fields, leaving + // `terms`/`collector` free to be borrowed for the extend/iter.) + let Self { + layout, + tracker, + current_pk, + container_bbox, + shape_buff, + collector, + terms, + .. + } = self; + terms.clear(); + terms.extend(collector.iter().map(|(hkey, haz)| { + let term = match haz { + HazardEntity::PlacedItem { pk: other_pk, .. } => { + let other_shape = &layout.placed_items[*other_pk].shape; + let loss = quantify_collision_poly_poly(other_shape, shape_buff); + loss * tracker.pair_weight(*current_pk, *other_pk) + } + HazardEntity::Exterior => { + let loss = quantify_collision_poly_container(shape_buff, *container_bbox); + loss * tracker.container_weight(*current_pk) + } + // A hole / keep-out zone the part must avoid (the interior-void path); shares the + // item's single static-hazard GLS weight with the exterior. + HazardEntity::Hole { .. } | HazardEntity::InferiorQualityZone { .. } => { + let hole_shape = &layout.cde().hazards_map[hkey].shape; + let loss = quantify_collision_poly_hole(shape_buff, hole_shape); + loss * tracker.container_weight(*current_pk) + } + }; + (hkey, term) + })); terms.sort_unstable_by_key(|(hkey, _)| *hkey); let loss: Scalar = terms.iter().map(|(_, term)| *term).sum(); From 01b127773a9bf49cda18e168f2f16df35f1d762c Mon Sep 17 00:00:00 2001 From: Jeff West Date: Tue, 23 Jun 2026 19:31:39 -0500 Subject: [PATCH 2/6] feat: deterministic best-of-K multi-start (nest_multistart) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single greedy construction lands in a seed-dependent basin; sweeping K decorrelated seeds and keeping the densest result lifts packing on heterogeneous parts at no determinism cost. Measured end-to-end: 13x7 bricks 101 -> 105 placed (91.9% -> 95.5%) at K=8. This is the density lever docs/02 section 11 missed -- it tested depth (more budget = same basin), never breadth (K basins). - optimizer/lib.rs: nest_multistart + nest_multistart_per_item (additive; existing signatures untouched). Each start runs the unmodified, golden-stable nest_per_item at seed.wrapping_add(k); keep-best maximises total placed AREA (deterministic fixed-order sum compared via total_cmp, earliest-k on tie) -- no float sum drives the decision's ordering. n_starts=1 == nest() bit-for-bit. - ironnest façade re-exports both; py wheel nest() gains an optional restarts=1 kwarg (backward-compatible -- omitting it is byte-identical to today). - golden: a "# multistart-mixed" K=3 case appended to the cross-platform golden (purely additive -- every existing case stays byte-for-byte identical). - tests: restarts=1 == nest() byte-identity, determinism, never-worse-than-1. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/ironnest/src/lib.rs | 2 +- crates/optimizer/src/bin/golden_dump.rs | 53 ++++++--- crates/optimizer/src/lib.rs | 109 ++++++++++++++++++ crates/optimizer/tests/nest.rs | 80 ++++++++++++- ...den__golden_placements_match_snapshot.snap | 9 ++ crates/py/src/lib.rs | 28 ++++- 6 files changed, 259 insertions(+), 22 deletions(-) diff --git a/crates/ironnest/src/lib.rs b/crates/ironnest/src/lib.rs index 16a7f9a..3760a05 100644 --- a/crates/ironnest/src/lib.rs +++ b/crates/ironnest/src/lib.rs @@ -20,5 +20,5 @@ #[doc(inline)] pub use ironnest_optimizer::{ MultiSheetSolution, NestSolution, Placement, Scalar, Sheet, nest, nest_multi, - nest_multi_per_item, nest_per_item, + nest_multi_per_item, nest_multistart, nest_multistart_per_item, nest_per_item, }; diff --git a/crates/optimizer/src/bin/golden_dump.rs b/crates/optimizer/src/bin/golden_dump.rs index b2ab2b7..075e93a 100644 --- a/crates/optimizer/src/bin/golden_dump.rs +++ b/crates/optimizer/src/bin/golden_dump.rs @@ -22,7 +22,7 @@ //! - The placement emit order is `nest`'s own order (slotmap slot order — a pure function of the //! deterministic op history); it is part of the contract, so it is dumped unsorted. -use ironnest_optimizer::{Placement, Scalar, nest, nest_per_item}; +use ironnest_optimizer::{Placement, Scalar, nest, nest_multistart, nest_per_item}; use std::fmt::Write as _; /// `w × h` axis-aligned rectangle, lower-left at the origin (CCW). @@ -244,24 +244,47 @@ fn dump() -> String { case.budget, ), }; - for Placement { - item, - x, - y, - rotation_deg, - } in &sol.placements - { - writeln!(out, "{item} {x} {y} {rotation_deg}").unwrap(); - } - write!(out, "unplaced").unwrap(); - for id in &sol.unplaced { - write!(out, " {id}").unwrap(); - } - writeln!(out).unwrap(); + write_solution(&mut out, &sol.placements, &sol.unplaced); } + + // Multi-start (best-of-K) determinism case — APPENDED after the corpus so every case above stays + // byte-for-byte (the K=1 path already equals `nest`; this exercises the K>1 best-of-area reduction + // over two heterogeneous item types and must be byte-identical on every platform too). + writeln!(out, "# multistart-mixed").unwrap(); + let ms = nest_multistart( + &[rect(10.0, 10.0), rect(20.0, 5.0)], + &[4, 3], + &rect(50.0, 50.0), + &[], + 0.0, + &CARDINAL, + 13, + 800, + 3, + ); + write_solution(&mut out, &ms.placements, &ms.unplaced); + out } +/// Renders one solution to the canonical `item x y rot` lines plus the `unplaced …` line. +fn write_solution(out: &mut String, placements: &[Placement], unplaced: &[usize]) { + for Placement { + item, + x, + y, + rotation_deg, + } in placements + { + writeln!(out, "{item} {x} {y} {rotation_deg}").unwrap(); + } + write!(out, "unplaced").unwrap(); + for id in unplaced { + write!(out, " {id}").unwrap(); + } + writeln!(out).unwrap(); +} + fn main() { print!("{}", dump()); } diff --git a/crates/optimizer/src/lib.rs b/crates/optimizer/src/lib.rs index cef5e8a..76533d0 100644 --- a/crates/optimizer/src/lib.rs +++ b/crates/optimizer/src/lib.rs @@ -48,6 +48,8 @@ const IMPROVE_ROUNDS: u32 = 3; /// relative to the curved-part speedup. const DECIMATION_TOL_FRACTION: Scalar = 1.0 / 16.0; +use std::cmp::Ordering; + use ironnest_cde::collision_detection::CDEConfig; use ironnest_cde::entities::{Container, Item, Layout}; use ironnest_cde::geometry::fail_fast::SPSurrogateConfig; @@ -412,6 +414,92 @@ pub fn nest_per_item( } } +/// 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`]. +/// +/// * `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. +/// * every other argument carries its [`nest`] meaning. Start *k* uses `seed.wrapping_add(k)`; the +/// PRNG's SplitMix64 expansion decorrelates adjacent seeds into well-separated streams. +/// +/// Determinism: each start is the byte-stable [`nest`] pipeline; 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. +#[must_use] +#[allow(clippy::too_many_arguments)] +pub fn nest_multistart( + items: &[Vec<[Scalar; 2]>], + qty: &[usize], + container: &[[Scalar; 2]], + holes: &[Vec<[Scalar; 2]>], + min_sep: Scalar, + rotations_deg: &[Scalar], + seed: u64, + budget: u64, + n_starts: usize, +) -> NestSolution { + let rotations_per_item = vec![rotations_deg.to_vec(); items.len()]; + nest_multistart_per_item( + items, + qty, + container, + holes, + min_sep, + &rotations_per_item, + seed, + budget, + n_starts, + ) +} + +/// [`nest_multistart`] with a **distinct allowed-rotation set per item type** (the [`nest_per_item`] +/// semantics): `rotations_deg[k]` applies to `items[k]`. Same best-of-K determinism contract. +#[must_use] +#[allow(clippy::too_many_arguments)] +pub fn nest_multistart_per_item( + items: &[Vec<[Scalar; 2]>], + qty: &[usize], + container: &[[Scalar; 2]], + holes: &[Vec<[Scalar; 2]>], + min_sep: Scalar, + rotations_deg: &[Vec], + seed: u64, + budget: u64, + n_starts: usize, +) -> NestSolution { + let starts = n_starts.max(1); + let areas: Vec = items.iter().map(|o| polygon_area(o)).collect(); + + let mut best: Option<(NestSolution, Scalar)> = None; + for k in 0..starts as u64 { + let sol = nest_per_item( + items, + qty, + container, + holes, + min_sep, + rotations_deg, + seed.wrapping_add(k), + budget, + ); + let area = placed_area(&sol, &areas); + // Strictly-greater placed area wins; an exact tie keeps the earlier k (lowest seed offset). + // The decision is a single `total_cmp` on each solution's own canonical area sum — no + // cross-solution float reduction — so it is byte-identical for the same arguments. + let better = best + .as_ref() + .is_none_or(|(_, best_area)| area.total_cmp(best_area) == Ordering::Greater); + if better { + best = Some((sol, area)); + } + } + best.expect("n_starts is clamped to >= 1, so at least one solution is produced") + .0 +} + /// Nests `items` (demand `qty`) across several `sheets` in order: each sheet is filled with whatever /// demand remains, then the rest spills to the next. Returns the placements per sheet plus the global /// unplaced. Applies **one** allowed-rotation set to every item; for a distinct set per item type use @@ -562,6 +650,27 @@ fn extract_placements(layout: &Layout, entities: &[Option]) -> Vec Scalar { + let n = outline.len(); + if n < 3 { + return 0.0; + } + let mut acc: Scalar = 0.0; + for i in 0..n { + let j = (i + 1) % n; + acc += outline[i][0] * outline[j][1] - outline[j][0] * outline[i][1]; + } + (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/tests/nest.rs b/crates/optimizer/tests/nest.rs index 32c88ec..0d6f9c3 100644 --- a/crates/optimizer/tests/nest.rs +++ b/crates/optimizer/tests/nest.rs @@ -6,7 +6,10 @@ //! interior-void holes, multi-sheet, and the in-process determinism golden (same inputs → //! byte-identical placements). -use ironnest_optimizer::{Scalar, Sheet, nest, nest_multi, nest_multi_per_item, nest_per_item}; +use ironnest_optimizer::{ + Scalar, Sheet, nest, nest_multi, 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). fn rect(w: Scalar, h: Scalar) -> Vec<[Scalar; 2]> { @@ -83,6 +86,81 @@ fn determinism_same_seed_is_byte_identical() { assert!(!a.placements.is_empty()); } +// --------------------------------------------------------------------------- +// Multi-start (best-of-K) — the deterministic density lever +// --------------------------------------------------------------------------- + +#[test] +fn multistart_k1_is_byte_identical_to_single_nest() { + // THE load-bearing invariant: n_starts == 1 runs exactly one start at seed.wrapping_add(0) == + // seed, so it must equal nest() bit-for-bit — this is what keeps the existing golden valid. + let items = vec![rect(10.0, 10.0), rect(20.0, 5.0)]; + let qty = [8, 4]; + let container = rect(100.0, 100.0); + let single = nest(&items, &qty, &container, &[], 1.0, &CARDINAL, 12345, 2000); + let multi = nest_multistart( + &items, + &qty, + &container, + &[], + 1.0, + &CARDINAL, + 12345, + 2000, + 1, + ); + assert_eq!(single, multi, "n_starts=1 must be byte-identical to nest()"); + // n_starts=0 is clamped to 1 (never panics, never empty-reduces). + let zero = nest_multistart( + &items, + &qty, + &container, + &[], + 1.0, + &CARDINAL, + 12345, + 2000, + 0, + ); + assert_eq!(single, zero, "n_starts=0 clamps to a single start"); +} + +#[test] +fn multistart_is_deterministic() { + // A fast all-placing case (9× 10×10 in 35×35 → no separation): best-of-K must be byte-identical + // for the same arguments. K=3 exercises the seed sweep + the area-argmax reduction. + let items = vec![rect(10.0, 10.0)]; + let container = rect(35.0, 35.0); + let a = nest_multistart(&items, &[9], &container, &[], 0.0, &CARDINAL, 7, 800, 3); + let b = nest_multistart(&items, &[9], &container, &[], 0.0, &CARDINAL, 7, 800, 3); + assert_eq!(a, b, "best-of-K must be byte-identical for the same args"); + // The per-item path with a broadcast set must match the uniform multistart (same invariant the + // single-start paths hold) — guards the K-loop against per-item seed drift. + let broadcast = vec![CARDINAL.to_vec()]; + let per_item = + nest_multistart_per_item(&items, &[9], &container, &[], 0.0, &broadcast, 7, 800, 3); + assert_eq!( + a, per_item, + "broadcast per-item multistart must equal uniform" + ); +} + +#[test] +fn multistart_never_worse_than_a_single_start() { + // best-of-K considers k=0 (seed.wrapping_add(0) == seed) among its candidates and keeps the + // max-area result, so it can never place LESS total area than the single start at that seed. + let items = vec![rect(7.0, 7.0)]; + let container = rect(40.0, 40.0); + let single = nest(&items, &[40], &container, &[], 0.0, &CARDINAL, 1, 800); + let multi = nest_multistart(&items, &[40], &container, &[], 0.0, &CARDINAL, 1, 800, 4); + assert!( + multi.placements.len() >= single.placements.len(), + "best-of-4 ({}) must place at least the single start ({})", + multi.placements.len(), + single.placements.len(), + ); +} + #[test] fn per_item_rotations_are_respected_per_type() { // Two part types with DIFFERENT allowed sets in one nest: a square pinned axis-aligned ({0,90}) 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 e7b14ac..eeff1e6 100644 --- a/crates/optimizer/tests/snapshots/golden__golden_placements_match_snapshot.snap +++ b/crates/optimizer/tests/snapshots/golden__golden_placements_match_snapshot.snap @@ -82,3 +82,12 @@ unplaced 1 24.142135635639946 17.07106782950307 135 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 +unplaced diff --git a/crates/py/src/lib.rs b/crates/py/src/lib.rs index 8594d14..f95759e 100644 --- a/crates/py/src/lib.rs +++ b/crates/py/src/lib.rs @@ -11,7 +11,10 @@ //! f64, so the marshalling is exact and introduces no nondeterminism: the wheel inherits the engine's //! 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_per_item as nest_impl}; +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, +}; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; use pyo3::types::PyString; @@ -159,6 +162,12 @@ fn extract_angles(elems: &[Bound<'_, PyAny>], ctx: &str) -> PyResult> { /// Explicit PRNG seed. There is **no** entropy fallback — determinism is the contract. /// budget : int /// Samples per item placement (a fixed budget; never a wall clock). +/// restarts : int, optional +/// Number of independent constructions to run from decorrelated seeds, keeping the **densest** +/// result — a deterministic best-of-K "multi-start". Defaults to ``1``, which is byte-identical to +/// 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. /// /// Returns /// ------- @@ -169,7 +178,7 @@ fn extract_angles(elems: &[Bound<'_, PyAny>], ctx: &str) -> PyResult> { /// /// The same arguments always produce a byte-identical result. #[pyfunction] -#[pyo3(signature = (items, qty, container, holes, min_sep, rotations, seed, budget))] +#[pyo3(signature = (items, qty, container, holes, min_sep, rotations, seed, budget, restarts=1))] #[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( @@ -182,6 +191,7 @@ fn nest( rotations: Bound<'_, PyAny>, seed: u64, budget: u64, + restarts: usize, ) -> PyResult<(Vec, Vec)> { if items.len() != qty.len() { return Err(PyValueError::new_err(format!( @@ -204,9 +214,17 @@ fn nest( // contract: no threads run *inside* the solve (single canonical worker, CLAUDE.md), so output // stays byte-identical. let solution = py.detach(|| { - nest_impl( - &items, &qty, &container, &holes, min_sep, &rotations, seed, budget, - ) + // `restarts <= 1` runs the single-start pipeline (byte-identical to the historical `nest`); + // `> 1` runs the deterministic best-of-K multi-start. Both honour the determinism contract. + if restarts <= 1 { + nest_impl( + &items, &qty, &container, &holes, min_sep, &rotations, seed, budget, + ) + } else { + nest_multistart_impl( + &items, &qty, &container, &holes, min_sep, &rotations, seed, budget, restarts, + ) + } }); let placements = solution From 211582cbb9b906d2743068ff8c6c54526a73a7a7 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Tue, 23 Jun 2026 19:38:43 -0500 Subject: [PATCH 3/6] feat: parallel multi-start behind the `parallel` feature (B2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Best-of-K multi-start costs ~K× wall-clock sequentially (bricks K=8 = 22.6s). The `parallel` feature runs the K independent constructions on std::thread::scope threads, cutting that to ~3.6× faster (K=8 = 6.2s on 10 cores) with BYTE-IDENTICAL output. Why it stays deterministic: each start is the unmodified, golden-stable nest_per_item (own PCG64 stream, own Layout, zero shared mutable state); results are collected in k order via ordered join handles; the keep-best reduction runs afterwards in that fixed k order, so thread completion order is irrelevant and no float sum ever crosses a thread. This is the fixed-reduction-order carve-out the determinism rules allow — the ONLY sanctioned threading, and only on the multi-start meta-loop, never inside a placement search. - optimizer: `[features] parallel`; run_starts has sequential + scoped-thread impls (cfg-split). Off by default; std-only, no new dependency. - ironnest forwards the feature; the wheel (crates/py) turns it ON (the GIL is already released around the solve), so restarts>1 is threaded for consumers. - tests: parallel_multistart_equals_independent_sequential_best (gated on the feature) asserts the threaded reduction == an independent sequential best-of-K. - ci.yml: a new cross-platform step runs the golden + equivalence test under --features parallel on macOS-arm64/Win-x64/linux — the standing proof that parallel == sequential, byte-for-byte, on every shipped target. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 9 +++ crates/ironnest/Cargo.toml | 5 ++ crates/optimizer/Cargo.toml | 9 +++ crates/optimizer/src/lib.rs | 118 ++++++++++++++++++++++++++++----- crates/optimizer/tests/nest.rs | 53 +++++++++++++++ crates/py/Cargo.toml | 5 +- 6 files changed, 183 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51b2fa9..295eb46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,3 +63,12 @@ jobs: # pipeline does not perturb the byte-identical output. - name: Test (release, shipped profile) run: cargo test --workspace --exclude ironnest-py --release --locked + + # `--features parallel`: nest_multistart's K constructions run on threads (the wheel's default). + # This is the ONLY sanctioned threading and it MUST be byte-identical to sequential — so the + # golden (which includes a K=3 multistart case) re-run under the feature must match the very same + # committed snapshot on every platform, and the `parallel_multistart_equals_independent_sequential + # _best` test asserts the threaded reduction equals an independent sequential best-of-K. This is + # the standing cross-platform proof that parallel == sequential (docs/02 §B2). + - name: Test (parallel multi-start == sequential) + run: cargo test -p ironnest-optimizer --features parallel --release --locked diff --git a/crates/ironnest/Cargo.toml b/crates/ironnest/Cargo.toml index 9664ceb..2de2846 100644 --- a/crates/ironnest/Cargo.toml +++ b/crates/ironnest/Cargo.toml @@ -10,5 +10,10 @@ publish = false [lints] workspace = true +[features] +# Forwards to the optimizer's `parallel` feature: run `nest_multistart`'s K constructions on threads +# (byte-identical to sequential — see the optimizer manifest). Off by default; the wheel turns it on. +parallel = ["ironnest-optimizer/parallel"] + [dependencies] ironnest-optimizer = { workspace = true } diff --git a/crates/optimizer/Cargo.toml b/crates/optimizer/Cargo.toml index 9928b51..56e7a7a 100644 --- a/crates/optimizer/Cargo.toml +++ b/crates/optimizer/Cargo.toml @@ -10,6 +10,15 @@ publish = false [lints] workspace = true +[features] +# `parallel` runs the multi-start (`nest_multistart`) constructions on `std::thread::scope` threads +# instead of sequentially. It is BYTE-IDENTICAL to the sequential path (each start is the independent, +# golden-stable `nest_per_item`; the keep-best reduction runs after all joins in fixed k order, with no +# float sum crossing a thread) — the cross-platform golden run under this feature must match the same +# snapshot. Off by default; std-only, no new dependency. NOTE: this is the ONLY sanctioned threading, +# and only on the multi-start meta-loop — never inside a single placement search (CLAUDE.md). +parallel = [] + [dependencies] ironnest-geo = { workspace = true } ironnest-cde = { workspace = true } diff --git a/crates/optimizer/src/lib.rs b/crates/optimizer/src/lib.rs index 76533d0..340727b 100644 --- a/crates/optimizer/src/lib.rs +++ b/crates/optimizer/src/lib.rs @@ -473,22 +473,27 @@ pub fn nest_multistart_per_item( let starts = n_starts.max(1); let areas: Vec = items.iter().map(|o| polygon_area(o)).collect(); + // 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. + let runs = run_starts( + items, + qty, + container, + holes, + min_sep, + rotations_deg, + seed, + budget, + starts, + &areas, + ); + + // 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 k in 0..starts as u64 { - let sol = nest_per_item( - items, - qty, - container, - holes, - min_sep, - rotations_deg, - seed.wrapping_add(k), - budget, - ); - let area = placed_area(&sol, &areas); - // Strictly-greater placed area wins; an exact tie keeps the earlier k (lowest seed offset). - // The decision is a single `total_cmp` on each solution's own canonical area sum — no - // cross-solution float reduction — so it is byte-identical for the same arguments. + for (sol, area) in runs { let better = best .as_ref() .is_none_or(|(_, best_area)| area.total_cmp(best_area) == Ordering::Greater); @@ -500,6 +505,89 @@ pub fn nest_multistart_per_item( .0 } +/// Runs `starts` independent constructions, start *k* at `seed.wrapping_add(k)`, returning +/// `(solution, placed_area)` in `0..starts` order. **Sequential** build: a plain loop. +#[cfg(not(feature = "parallel"))] +#[allow(clippy::too_many_arguments)] +fn run_starts( + items: &[Vec<[Scalar; 2]>], + qty: &[usize], + container: &[[Scalar; 2]], + holes: &[Vec<[Scalar; 2]>], + min_sep: Scalar, + rotations_deg: &[Vec], + seed: u64, + budget: u64, + starts: usize, + areas: &[Scalar], +) -> Vec<(NestSolution, Scalar)> { + (0..starts as u64) + .map(|k| { + let sol = nest_per_item( + items, + qty, + container, + holes, + min_sep, + rotations_deg, + seed.wrapping_add(k), + budget, + ); + let area = placed_area(&sol, areas); + (sol, area) + }) + .collect() +} + +/// 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 +/// start is the self-contained, golden-stable [`nest_per_item`] (own PRNG, own `Layout`, no shared +/// mutable state), the results are collected in `k` order via ordered join handles, and the caller's +/// keep-best reduction runs afterwards in that same fixed order, so completion order is irrelevant and +/// no float sum ever crosses a thread. This is the one sanctioned use of threads (the multi-start +/// meta-loop only — never inside a placement search); the cross-platform golden run under +/// `--features parallel` is the standing proof it matches the sequential snapshot. +#[cfg(feature = "parallel")] +#[allow(clippy::too_many_arguments)] +fn run_starts( + items: &[Vec<[Scalar; 2]>], + qty: &[usize], + container: &[[Scalar; 2]], + holes: &[Vec<[Scalar; 2]>], + min_sep: Scalar, + rotations_deg: &[Vec], + seed: u64, + budget: u64, + starts: usize, + areas: &[Scalar], +) -> Vec<(NestSolution, Scalar)> { + std::thread::scope(|scope| { + let handles: Vec<_> = (0..starts as u64) + .map(|k| { + scope.spawn(move || { + let sol = nest_per_item( + items, + qty, + container, + holes, + min_sep, + rotations_deg, + seed.wrapping_add(k), + budget, + ); + let area = placed_area(&sol, areas); + (sol, area) + }) + }) + .collect(); + // Join in spawn (k) order — the returned Vec is ordered by k regardless of finish order. + handles + .into_iter() + .map(|h| h.join().expect("a multi-start nest thread panicked")) + .collect() + }) +} + /// Nests `items` (demand `qty`) across several `sheets` in order: each sheet is filled with whatever /// demand remains, then the rest spills to the next. Returns the placements per sheet plus the global /// unplaced. Applies **one** allowed-rotation set to every item; for a distinct set per item type use diff --git a/crates/optimizer/tests/nest.rs b/crates/optimizer/tests/nest.rs index 0d6f9c3..6a3d499 100644 --- a/crates/optimizer/tests/nest.rs +++ b/crates/optimizer/tests/nest.rs @@ -161,6 +161,59 @@ fn multistart_never_worse_than_a_single_start() { ); } +#[cfg(feature = "parallel")] +#[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 by calling nest_per_item directly. Homogeneous + // parts ⇒ max placed area == max placed count, so this count-argmax (earliest k on a tie) matches + // the engine's area-argmax exactly. This is the local parallel==sequential byte-identity check; + // the cross-platform proof is the golden run under --features parallel. + let items = vec![rect(13.0, 7.0)]; + let qty = [18usize]; + let container = rect(45.0, 45.0); + let (k, budget, seed) = (4u64, 700u64, 1u64); + + let mut manual: Option = None; + for ki in 0..k { + let sol = nest_per_item( + &items, + &qty, + &container, + &[], + 0.0, + &[CARDINAL.to_vec()], + seed.wrapping_add(ki), + budget, + ); + // Iterate k ascending, replace only on STRICTLY-more placements → keeps the earliest k on a + // tie, exactly as the engine's reduction does. + let better = manual + .as_ref() + .is_none_or(|m| sol.placements.len() > m.placements.len()); + if better { + manual = Some(sol); + } + } + + let parallel = nest_multistart( + &items, + &qty, + &container, + &[], + 0.0, + &CARDINAL, + seed, + budget, + k as usize, + ); + assert_eq!( + parallel, + manual.expect("k >= 1"), + "parallel multistart must be byte-identical to the independent sequential best-of-K" + ); +} + #[test] fn per_item_rotations_are_respected_per_type() { // Two part types with DIFFERENT allowed sets in one nest: a square pinned axis-aligned ({0,90}) diff --git a/crates/py/Cargo.toml b/crates/py/Cargo.toml index a3183b0..c685efd 100644 --- a/crates/py/Cargo.toml +++ b/crates/py/Cargo.toml @@ -19,5 +19,8 @@ crate-type = ["cdylib"] # The public façade crate (package `ironnest`) — renamed locally to `ironnest_core` so its lib name # does not clash with THIS crate's `[lib] name = "ironnest"` (the Python import name / abi3 init # symbol `PyInit_ironnest`). -ironnest_core = { package = "ironnest", path = "../ironnest" } +# `parallel` is ON for the wheel: `restarts > 1` runs the multi-start constructions on threads (the +# GIL is already released around the solve). Byte-identical to sequential — proven by the golden run +# under `--features parallel` — so the wheel's determinism contract is unchanged. +ironnest_core = { package = "ironnest", path = "../ironnest", features = ["parallel"] } pyo3 = { workspace = true } From f1a7d043d33bc73066f5f115e387bfc28cc3519a Mon Sep 17 00:00:00 2001 From: Jeff West Date: Tue, 23 Jun 2026 19:47:02 -0500 Subject: [PATCH 4/6] fix: clippy -D warnings (doc_markdown backtick + clone->from_ref in examples) The determinism-auditor caught the clippy gate red on this branch (the RTK proxy summary had masked it): - lib.rs nest_multistart doc: backtick `SplitMix64` (clippy::doc_markdown). - examples/{bench,seedsweep}.rs: `&[item.clone()]` -> std::slice::from_ref(&item) (clippy::redundant_clone equivalent). A doc-comment failure on the library was short-circuiting clippy before it reached the examples, hiding these. Verified GREEN via raw output (rtk proxy) in BOTH feature configs: cargo clippy --workspace --exclude ironnest-py --all-targets [--features parallel] -- -D warnings plus cargo fmt --all --check. No behavior change; determinism audit verdict was SAFE on the underlying A1/B1/B2 work. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/optimizer/examples/bench.rs | 4 ++-- crates/optimizer/examples/seedsweep.rs | 2 +- crates/optimizer/src/lib.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/optimizer/examples/bench.rs b/crates/optimizer/examples/bench.rs index 41682ae..11d3dcf 100644 --- a/crates/optimizer/examples/bench.rs +++ b/crates/optimizer/examples/bench.rs @@ -51,7 +51,7 @@ fn probe( let mut placed = 0usize; let ms = best_ms(reps, || { let sol = nest( - &[item.clone()], + std::slice::from_ref(&item), &[qty], &container, &[], @@ -160,7 +160,7 @@ fn multistart_probe( let mut placed = 0usize; let ms = best_ms(1, || { let sol = nest_multistart( - &[item.clone()], + std::slice::from_ref(&item), &[qty], &container, &[], diff --git a/crates/optimizer/examples/seedsweep.rs b/crates/optimizer/examples/seedsweep.rs index 64c592b..92cd7fd 100644 --- a/crates/optimizer/examples/seedsweep.rs +++ b/crates/optimizer/examples/seedsweep.rs @@ -32,7 +32,7 @@ fn sweep( let mut worst = usize::MAX; for seed in 1..=seeds { let sol = nest( - &[item.clone()], + std::slice::from_ref(&item), &[qty], &container, &[], diff --git a/crates/optimizer/src/lib.rs b/crates/optimizer/src/lib.rs index 340727b..525ced9 100644 --- a/crates/optimizer/src/lib.rs +++ b/crates/optimizer/src/lib.rs @@ -423,7 +423,7 @@ pub fn nest_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. /// * every other argument carries its [`nest`] meaning. Start *k* uses `seed.wrapping_add(k)`; the -/// PRNG's SplitMix64 expansion decorrelates adjacent seeds into well-separated streams. +/// PRNG's `SplitMix64` expansion decorrelates adjacent seeds into well-separated streams. /// /// Determinism: each start is the byte-stable [`nest`] pipeline; the keep-best reduction maximises the /// total placed **area** (a deterministic, fixed-order float sum compared via `total_cmp`) and keeps From cb7957f417a756ddcf67ab85795dfe298c3cf718 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Tue, 23 Jun 2026 20:02:02 -0500 Subject: [PATCH 5/6] =?UTF-8?q?docs:=20record=20round-2=20findings=20(?= =?UTF-8?q?=C2=A711.1)=20+=20sanction=20multi-start=20threading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/02 §11.1: the round-2 negative-results log. Records (a) the profiling finding that separation is 97-99% of wall-clock, so the A-stack micro-opts are ~0; (b) multi-start as the *breadth* lever §11 round-1 missed (bricks 91.9% -> 95.5%, byte-deterministic); (c) two more measured dead ends — B3 structured anchor seeding (null) and C2 ILS ruin-and-recreate (pentagon cap holds, +1 brick, dominated by multi-start). Concludes the pentagon-class gap is still architectural but the heterogeneous-parts gap was partly a breadth gap. - CLAUDE.md: document nest_multistart's `parallel` feature as the ONE sanctioned exception to "no threads on a placement-deciding path" — it already satisfies the existing "if parallel, fix the reduction order" clause; this names the instance so it is not later misread as a violation and ripped out. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 7 ++++- docs/02-optimizer-and-separation-search.md | 35 ++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 29afa2b..3b79f0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,12 @@ import-only-then-resorted, `Instant` is metadata). Keep it that way. The verifie (`crates/optimizer/src/prng.rs`) — no `rand` dep, so the exact bit-stream is version-independent and byte-stable forever. Explicit seed always; no `getrandom`/`rand::random()` fallback, ever. - **No `rayon` / threads on any placement-deciding path.** Single canonical worker; if parallel, fix - the reduction order. (Import-time `par_iter` is fine — it's re-sorted by id.) + the reduction order. (Import-time `par_iter` is fine — it's re-sorted by id.) **The one sanctioned + exception:** `nest_multistart`'s `parallel` feature runs the K independent best-of-K starts on + `std::thread::scope` threads — each start is the self-contained, golden-stable `nest_per_item` (own + PRNG/`Layout`, no shared state), results join in fixed k order, and the keep-best `total_cmp` argmax + runs after all joins, so no float reduction crosses a thread. Never thread *inside* a single + placement search. Proven by the golden re-run under `--features parallel` (CI, all 3 platforms). - **`BTreeMap`/`BTreeSet`/`slotmap` only** where order affects placement. Never std `HashMap` (per-process random iteration). - **No `f64::mul_add` / no fast-math.** Basic `+ - * / sqrt` and `powi` are IEEE-deterministic — safe. diff --git a/docs/02-optimizer-and-separation-search.md b/docs/02-optimizer-and-separation-search.md index c1d34b4..7cea982 100644 --- a/docs/02-optimizer-and-separation-search.md +++ b/docs/02-optimizer-and-separation-search.md @@ -395,6 +395,41 @@ dense *and* byte-deterministic across the fleet (the actual differentiator), ban result is the recommended posture; revisit NFP only if a consumer benchmark proves the density gap costs real material. +### 11.1 Round 2 (2026-06-23): profiling, the breadth lever that *worked*, and two more dead ends + +A second pass revisited the "architectural ceiling" conclusion above. Two findings refine it. + +**Profile first.** Per-phase timing (`examples/bench.rs`) shows the **separation search is 97–99 % of +wall-clock** on any nest with an unplaced tail (pentagon: 3330 ms of 3340 ms; bricks: 2390/2440 ms); +construction + improve are single-digit ms. The cost is irreducible per-sample collision-query + +pole-pair proxy compute across a well-tuned budget. Consequence: the candidate "quick perf wins" +(collector reuse, hazkey cache, incremental loss) are each ~0 — they touch <0.1 % of runtime. Collector +reuse was kept as byte-identical hygiene; the others were dropped as measured non-levers. + +**The breadth lever §11 missed: multi-start.** §11 round-1 only ever tested *depth* (one seed/ordering, +more budget → same basin). It never tested *breadth* — K **decorrelated seeds**, keep the densest. That +is fully byte-deterministic (each start is the golden-stable pipeline; the keep-best reduction is an +integer/`total_cmp` argmax over placed area, earliest-k on tie — no float sum on the decision path) and +it **works on heterogeneous parts**: 13×7 bricks **91.9 % → 95.5 %** at K=8 (`examples/seedsweep.rs`, +distribution `{101:1, 102:4, 104:4, 105:7}` over 16 seeds). Shipped as `nest_multistart` / +`nest_multistart_per_item` (additive API; `n_starts=1` is byte-identical to `nest`) + a `parallel` +cargo feature that runs the K starts on `std::thread::scope` threads (3.6× faster, byte-identical — the +golden re-runs under `--features parallel` cross-platform). The pentagon shows **zero** seed spread +(`{22:12, 23:4}`, best = 23) — its cap is genuinely architectural; multi-start cannot move it. + +**Two more dead ends** (measured on `examples/bench.rs`, reverted): + +| Lever (what was tried) | Result | Why it failed | +|---|---|---| +| **Structured anchor seeding (B3)** — a deterministic G×G LBF grid per rotation, competing additively with the random search | pentagon **23**, bricks **101**, 7×7 **196** — **no change anywhere** | `place_dropped`'s bottom-left slide already snaps loose random samples to the same contacts; structured coverage reaches nothing the slide didn't. | +| **ILS ruin-and-recreate (C2)** — evict K placed parts, re-insert via the separator, keep if ≥ (the diversifier §11 called "untried") | pentagon **23 (cap holds)**, bricks **101 → 102 (+1)**, **~4.5× slower** | The one untested diversifier, finally tested: it does **not** break the pentagon cap, and its +1 brick is **strictly dominated** by multi-start's +4 (which also parallelises). Confirms §11's architectural-ceiling diagnosis is robust. | + +**Revised conclusion.** The *pentagon-class* gap is still architectural (NFP / continuous rotation — +unchanged). But the **heterogeneous-parts** gap §11 attributed to that ceiling was partly a *breadth* +gap, and best-of-K multi-start closes a chunk of it deterministically. For the consumer's mixed corpus +(the real target), `restarts=N` is the lever — benchmark there to pick K. Every separation- or +construction-side micro-tweak (B3, C2, and the §11 four) is dominated by it. + ## References - sparrow — `https://github.com/JeroenGar/sparrow` · paper arXiv `2509.13329`. - jagua-rs CDE — DOI `10.1287/ijoc.2024.1025` · arXiv `2508.08341` · From 2adbd075090dc9bebdfbfec4729e6f6e25d73b15 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Tue, 23 Jun 2026 20:07:25 -0500 Subject: [PATCH 6/6] =?UTF-8?q?release:=20v0.4.0=20=E2=80=94=20determinist?= =?UTF-8?q?ic=20best-of-K=20multi-start=20+=20parallel=20feature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New (all additive — existing API + the cross-platform golden are byte-unchanged): - nest_multistart / nest_multistart_per_item: best-of-K multi-start that keeps the densest of K decorrelated-seed runs. Lifts heterogeneous packing (13x7 bricks 91.9% -> 95.5% at K=8) with zero determinism cost (integer/total_cmp argmax over placed area). n_starts=1 is byte-identical to nest(). - `parallel` cargo feature: runs the K starts on std::thread::scope threads (3.6x faster, byte-identical — golden re-runs under --features parallel on all 3 CI platforms). The wheel enables it; nest() gains an optional restarts=1 kwarg. - A1: byte-identical separator scratch-buffer reuse. Determinism-audited SAFE. Density/perf round-2 findings (incl. the measured-out A-stack micro-opts, B3, and C2 dead ends) recorded in docs/02 §11.1. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 10 +++++----- Cargo.toml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1d795dd..b895c02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -125,14 +125,14 @@ dependencies = [ [[package]] name = "ironnest" -version = "0.3.0" +version = "0.4.0" dependencies = [ "ironnest-optimizer", ] [[package]] name = "ironnest-cde" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "float-cmp", @@ -145,7 +145,7 @@ dependencies = [ [[package]] name = "ironnest-geo" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "float-cmp", @@ -161,7 +161,7 @@ dependencies = [ [[package]] name = "ironnest-optimizer" -version = "0.3.0" +version = "0.4.0" dependencies = [ "insta", "ironnest-cde", @@ -171,7 +171,7 @@ dependencies = [ [[package]] name = "ironnest-py" -version = "0.3.0" +version = "0.4.0" dependencies = [ "ironnest", "pyo3", diff --git a/Cargo.toml b/Cargo.toml index 34067a8..a2a9965 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.3.0" # bump deliberately; re-bless goldens if placement code changed +version = "0.4.0" # 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.