From 41f31158e655fd1df8b08af92f08692107c29019 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Fri, 10 Jul 2026 10:46:11 -0500 Subject: [PATCH 1/2] =?UTF-8?q?release:=20v0.5.0=20=E2=80=94=20exact-NFP?= =?UTF-8?q?=20construction=20mode=20(strategy=3D"nfp")=20+=20multi-start?= =?UTF-8?q?=20order=20diversification=20+=20evaluator=20early-terminate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optimizer round 3 (docs/02 §11.2): - Deterministic early-terminate in the separation evaluator (strict-greater bailout on canonically-ordered partial sums) — ~13-15% faster separation, provably output-identical (goldens byte-frozen). - Multi-start order diversification: contiguous block split (leading half canonical largest-first, trailing half seeded-jittered insertion order). Doubling n_starts keeps every canonical stream the smaller run explored, so the old best-of-K is a floor for the new best-of-2K. +1.0pp mean on diameter-misleading mixes; consumer corpus 82.76% -> 83.44% at restarts=16. - Compression phase (sparrow compress loop) implemented, measured (zero gain, 2-3.5x cost), reverted with mechanism analysis: fixed-bin insertion-driven separation subsumes untargeted wall squeezes. - N1a import hoist: items/container imported once per call (Problem), shared read-only across the K multi-start threads. Feature #4 — exact-NFP construction mode (docs/03), opt-in, default-off: - Integer geometry substrate (crates/optimizer/src/nfp/): 2^20 fixed grid, i64 coords + i128 predicates, orientation-normalized convolution union-fill (winding-cancellation fix), convex edge-merge fast path, quads-only face-classified frame IFP (correct for concave containers), □E=16 contact backoff, Booth-canonicalized shapes, eager mirror-derived pair cache with domain gate. i_overlay = 7.0.2 exact-pinned (integer i64 API only; MIT/Apache-2.0; source-audited for determinism hazards). MSRV 1.87 -> 1.88. - Placer: exact (cost, rot_idx, x, y) candidate walk over IFP-minus-NFPs region vertices (exact for the linear LBF loss), CDE-verified, placed directly (no slide); per-part sampling fallback; separation tail unchanged. - Public API: PlacementStrategy / NestConfig / NestError + nest_with_config + nest_multi_with_config (Sampling configs are test-locked frozen wrappers of the existing entry points). PartsGeometry shared across sheets in multi. - Wheel: strategy="sampling"|"nfp" kwarg on nest() and nest_multi(). - 7 new golden cases incl. nfp-contact-columns (100% util contact grid — the exact-fit artifact killed by construction), nfp-concave-pocket (part nested inside a C-part pocket), and jittered-winner multistart cases pinning both diversification paths cross-platform. - Acceptance (consumer 13-shape corpus, paired seeds, K=16): NFP 83.88% vs sampling 83.27% mean (+0.61pp, floor held on every seed, 1.02x wall-clock; 84.03% = all-time best on this corpus) -> SHIP OPT-IN per docs/03 §10. 88 tests / 14 suites green (release + parallel + debug legs); clippy -D warnings clean on both feature sets; all pre-existing goldens byte-identical (sampling behavior frozen for n_starts=1; n_starts>1 layouts changed vs v0.4.x per the order-diversification note in nest_multistart docs). Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 41 +- Cargo.lock | 53 +- Cargo.toml | 4 +- crates/ironnest/src/lib.rs | 7 +- crates/optimizer/Cargo.toml | 1 + crates/optimizer/examples/bench.rs | 196 ++++- crates/optimizer/examples/nfp_policy_ab.rs | 53 ++ crates/optimizer/examples/nfp_spike.rs | 654 ++++++++++++++++ crates/optimizer/examples/orderdiv.rs | 71 ++ crates/optimizer/src/bin/golden_dump.rs | 149 +++- crates/optimizer/src/improve.rs | 11 +- crates/optimizer/src/lib.rs | 741 +++++++++++++++--- crates/optimizer/src/nfp/geom.rs | 420 ++++++++++ crates/optimizer/src/nfp/minkowski.rs | 402 ++++++++++ crates/optimizer/src/nfp/mod.rs | 443 +++++++++++ crates/optimizer/src/nfp/place.rs | 148 ++++ crates/optimizer/src/sep/evaluator.rs | 176 +++-- crates/optimizer/src/sep/search.rs | 22 +- crates/optimizer/tests/nest.rs | 62 +- crates/optimizer/tests/nfp_mode.rs | 205 +++++ crates/optimizer/tests/nfp_props.rs | 332 ++++++++ ...den__golden_placements_match_snapshot.snap | 56 ++ crates/py/src/lib.rs | 99 ++- docs/02-optimizer-and-separation-search.md | 84 ++ docs/03-nfp-construction-mode.md | 670 ++++++++++++++++ 25 files changed, 4874 insertions(+), 226 deletions(-) create mode 100644 crates/optimizer/examples/nfp_policy_ab.rs create mode 100644 crates/optimizer/examples/nfp_spike.rs create mode 100644 crates/optimizer/examples/orderdiv.rs create mode 100644 crates/optimizer/src/nfp/geom.rs create mode 100644 crates/optimizer/src/nfp/minkowski.rs create mode 100644 crates/optimizer/src/nfp/mod.rs create mode 100644 crates/optimizer/src/nfp/place.rs create mode 100644 crates/optimizer/tests/nfp_mode.rs create mode 100644 crates/optimizer/tests/nfp_props.rs create mode 100644 docs/03-nfp-construction-mode.md diff --git a/CLAUDE.md b/CLAUDE.md index 3b79f0a..0021df7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,9 +7,10 @@ byte-reproducible** — with **our own placement optimizer** on top. **Read first:** [`docs/00-ironnest-architecture-and-plan.md`](docs/00-ironnest-architecture-and-plan.md) (the plan), [`docs/01-jagua-source-verification.md`](docs/01-jagua-source-verification.md) (the -source-verified fork grounding), and [`docs/02-optimizer-and-separation-search.md`](docs/02-optimizer-and-separation-search.md) -(the optimizer design + the next build: the separation search). They are the source of truth; this -file is the short version. +source-verified fork grounding), [`docs/02-optimizer-and-separation-search.md`](docs/02-optimizer-and-separation-search.md) +(the optimizer design + measured results), and [`docs/03-nfp-construction-mode.md`](docs/03-nfp-construction-mode.md) +(the designed-but-not-built exact-NFP construction mode — feature #4). They are the source of +truth; this file is the short version. **Status (2026-06-21):** Phase 1 (fork→f64) ✅ · Phase 2a (constructive+compaction nester, 92–99% on rectangular parts) ✅ · Phase 2b (sparrow GLS **separation search**, `crates/optimizer/src/sep/`; @@ -20,8 +21,26 @@ gate on macOS-arm64/Win-x64/linux) ✅ · Phase 4 (**PyO3 `ironnest` wheel** via Phase 6 (**interior-void holes + multi-sheet**: `nest(holes…)` keep-out zones = "nesting inside parts", `nest_multi(sheets…)`; bound in the wheel — `docs/00` §10) ✅ · Determinism-hardening (**risk #2 resolved**: vendored libm-deterministic min-sep offsetter in `crates/geo/src/buffer/`; dropped -`geo`/`atomic-polyfill`; min_sep>0 now x-platform byte-identical) ✅ · **Next: Phase 5 — consumer -integration** (`drawing_and_gcode` #258). See `docs/00` §10. +`geo`/`atomic-polyfill`; min_sep>0 now x-platform byte-identical) ✅ · Optimizer round 3 (2026-07-09, +`docs/02` §11.2): evaluator **early-terminate** (~13 % faster separation, byte-identical proof) + +multi-start **order diversification** (parity split; +1 pp on diameter-misleading mixes) ✅; wall +compression measured → reverted (insertion-driven separation subsumes it — don't re-try) · +**Feature #4 (exact-NFP construction, `docs/03`): N0 GO → N1a ✅ → N1 ✅ (2026-07-10)** — +N0 `examples/nfp_spike.rs`: mixed-13 consumer corpus 79.7 % in 0.94 s construction-only (full +sampling K=1 pipeline: 80–83 % in 150–214 s); contact-columns 100/100; interlock by construction. +N1a: import hoist (`Problem`/`import_problem`), goldens byte-identical. N1: integer substrate in +`crates/optimizer/src/nfp/` (doc-hidden pub) — 2^20 grid/i64/i128, convex edge-merge fast path, +orientation-normalized convolution union-fill, **quads-only face-classified IFP** (P1 caught the +concave-pocket leak of the naive frame seed — docs/03 §11.2), □E=16 backoff, eager mirror-derived +pair cache, domain gate; `i_overlay = 7.0.2` exact-pinned (integer i64 API only). N2 ✅: placer + +`PlacementStrategy`/`NestConfig`/`NestError`/`nest_with_config` (+`nest_multi_with_config`), 6 NFP +goldens blessed. N3 ✅: block-split policy wins the NFP A/B (94.01 vs 93.49 %); `PartsGeometry` +shared across sheets; `nfp-multistart` golden (jittered winner) incl. `--features parallel`. +N4 ✅ core: wheel ships `strategy="sampling"|"nfp"` on both pyfunctions; **ACCEPTANCE = SHIP +OPT-IN** — consumer corpus K=16 paired: NFP 83.88 % vs sampling 83.27 % mean (+0.61 pp, floor held +every seed, 1.02× clock, 84.03 % all-time best). Follow-ups: consumer adapter `strategy=` + +`placement_mode` audit key (`drawing_and_gcode` #258), ESICUP tracker, rejection-counter +surfacing, post-sep NFP refill (the GO lever). See `docs/03`. ## ⛔ Two prime directives @@ -64,9 +83,10 @@ import-only-then-resorted, `Instant` is metadata). Keep it that way. The verifie - **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 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 + `std::thread::scope` threads — each start is the self-contained, golden-stable `multistart_start` + (own PRNG/`Layout`, no shared state; per-start seed + insertion-order policy have a single + definition shared with the sequential build), 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). @@ -76,8 +96,9 @@ import-only-then-resorted, `Instant` is metadata). Keep it that way. The verifie ## The fork - Upstream: **jagua-rs 0.7.2** (commit `43e8137`), MPL-2.0, Rust **edition 2024**. Edition 2024 needs - Rust ≥ 1.85, but jagua's code uses `{int}::cast_signed/cast_unsigned` (stable 1.87) → **our MSRV - floor is 1.87** (toolchain pinned to 1.96). All deps pure-Rust (no C / no `build.rs`) → clean wheels. + Rust ≥ 1.85; jagua's code uses `{int}::cast_signed/cast_unsigned` (stable 1.87) and `i_overlay` + 7.x needs 1.88 → **our MSRV floor is 1.88** (toolchain pinned to 1.96). All deps pure-Rust (no C / + no `build.rs`) → clean wheels. - **Fork ≈ 5,800 LOC** (see `docs/01` §B): all of `geometry/` (ex-SVG) + `collision_detection/` + `entities/` + `io/import.rs`+`ext_repr.rs`+`export.rs` + `probs/bpp/` + `util/fpa.rs`. **Skip** `io/svg/`, `probs/spp`+`mspp`, and the `lbf` binary (read `lbf` only for the diff --git a/Cargo.lock b/Cargo.lock index e49342a..bb29739 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -111,6 +111,48 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "i_float" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "519b2780a1187eeed09145e5d168863c87f0d704abe84084206ebd897f99c6dc" +dependencies = [ + "libm", +] + +[[package]] +name = "i_key_sort" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d73d122b937fca067feb0ad74f62388920272b27c356d4df2d0cfdd59e044cf0" + +[[package]] +name = "i_overlay" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf284de04038af713dad8b4a88d2bc97f1c8c0a87fc1f43e28e3b965d0136d9e" +dependencies = [ + "i_float", + "i_key_sort", + "i_shape", + "i_tree", +] + +[[package]] +name = "i_shape" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b2ee7bae072887c579a2f2fd2a7fa1c29cceecfae1998ef940061c5570afff" +dependencies = [ + "i_float", +] + +[[package]] +name = "i_tree" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9d4a992a9fe83130f41ceacceac3bb116a4355dfc9c8d6ecea4f1e4b4c6caf" + [[package]] name = "insta" version = "1.48.0" @@ -125,14 +167,14 @@ dependencies = [ [[package]] name = "ironnest" -version = "0.4.1" +version = "0.5.0" dependencies = [ "ironnest-optimizer", ] [[package]] name = "ironnest-cde" -version = "0.4.1" +version = "0.5.0" dependencies = [ "anyhow", "float-cmp", @@ -145,7 +187,7 @@ dependencies = [ [[package]] name = "ironnest-geo" -version = "0.4.1" +version = "0.5.0" dependencies = [ "anyhow", "float-cmp", @@ -161,8 +203,9 @@ dependencies = [ [[package]] name = "ironnest-optimizer" -version = "0.4.1" +version = "0.5.0" dependencies = [ + "i_overlay", "insta", "ironnest-cde", "ironnest-geo", @@ -171,7 +214,7 @@ dependencies = [ [[package]] name = "ironnest-py" -version = "0.4.1" +version = "0.5.0" dependencies = [ "ironnest", "pyo3", diff --git a/Cargo.toml b/Cargo.toml index 0209946..b4dd347 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,9 +5,9 @@ resolver = "3" members = ["crates/geo", "crates/cde", "crates/optimizer", "crates/ironnest", "crates/py"] [workspace.package] -version = "0.4.1" # bump deliberately; re-bless goldens if placement code changed +version = "0.5.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 +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. license = "MPL-2.0" # uniform across the repo diff --git a/crates/ironnest/src/lib.rs b/crates/ironnest/src/lib.rs index 579ac22..a387c67 100644 --- a/crates/ironnest/src/lib.rs +++ b/crates/ironnest/src/lib.rs @@ -19,7 +19,8 @@ #[doc(inline)] pub use ironnest_optimizer::{ - MultiSheetSolution, NestSolution, Placement, 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, + 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, }; diff --git a/crates/optimizer/Cargo.toml b/crates/optimizer/Cargo.toml index 56e7a7a..c848295 100644 --- a/crates/optimizer/Cargo.toml +++ b/crates/optimizer/Cargo.toml @@ -23,6 +23,7 @@ parallel = [] ironnest-geo = { workspace = true } ironnest-cde = { workspace = true } slotmap = "1.0" # deterministic keyed storage — `SecondaryMap` for the separation tracker (matches cde) +i_overlay = "=7.0.2" # Deliberately NO `rand`/`rand_pcg`: the placement PRNG is a small, self-contained PCG64 (see # `prng.rs`) so the exact bit-stream is version-independent and byte-stable forever — determinism is # the product. No threads, no wall-clock, no external RNG. diff --git a/crates/optimizer/examples/bench.rs b/crates/optimizer/examples/bench.rs index 11d3dcf..157f720 100644 --- a/crates/optimizer/examples/bench.rs +++ b/crates/optimizer/examples/bench.rs @@ -13,7 +13,9 @@ //! 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}; +use ironnest_optimizer::{ + NestConfig, PlacementStrategy, Scalar, nest, nest_multistart, 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]] @@ -141,6 +143,198 @@ fn main() { 2000, &[1, 4, 8], ); + + println!("\n--- multi-start on a MIXED corpus (order diversification lever) ---"); + // Four part types at ~105% area demand: the unplaced tail is guaranteed, so the placed-area + // utilization directly measures how well the K starts explore different insertion orders. + // (Single-type probes cannot see the order-diversification lever at all.) + let mixed_items = vec![ + rect(13.0, 7.0), // brick, area 91 + rect(10.0, 10.0), // square, area 100 + rect(7.0, 7.0), // small square, area 49 + vec![[0.0, 0.0], [10.0, 0.0], [0.0, 10.0]], // right triangle, area 50 + ]; + let mixed_areas = [91.0, 100.0, 49.0, 50.0]; + let mixed_qty = [45usize, 30, 45, 24]; // 4095+3000+2205+1200 = 10500 in 10000 (105%) + multistart_mixed_probe( + "mixed 4-type (105%)", + &mixed_items, + &mixed_areas, + &mixed_qty, + 100.0, + 2000, + &[1, 4, 8], + ); + + println!("\n--- NFP mode (feature #4): full pipeline (NFP construction + sep tail) ---"); + let pentagon = vec![ + [0.0, 0.0], + [20.0, 0.0], + [20.0, 12.0], + [12.0, 20.0], + [0.0, 20.0], + ]; + nfp_probe( + "pentagon ~344 (NFP)", + &[pentagon], + &[344.0], + &[40], + 100.0, + &[1, 8], + ); + nfp_probe( + "13x7 bricks (NFP)", + &[rect(13.0, 7.0)], + &[91.0], + &[120], + 100.0, + &[1, 8], + ); + nfp_probe( + "mixed 4-type (NFP)", + &mixed_items, + &mixed_areas, + &mixed_qty, + 100.0, + &[1, 8, 16], + ); + nfp_cache_probe(); +} + +/// The NFP cache-build cost on a 13-type × 4-rotation corpus (docs/03 §9.4 gate: cache build ≤ 5 % +/// of a budget-500-equivalent nest). Measures the ONCE-PER-CALL cost every consumer nest pays. +fn nfp_cache_probe() { + use ironnest_optimizer::nfp; + // 13 heterogeneous plate shapes (sizes matching the consumer harness's spread). + let mut 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], + ], + ]; + items.truncate(13); + let qty: Vec = items.iter().map(|_| 1).collect(); + let container = rect(120.0, 60.0); + let rots: Vec> = items + .iter() + .map(|_| vec![0.0, 90.0, 180.0, 270.0]) + .collect(); + // Build cost is measured through a minimal-qty nest (import + cache build dominate). + let cfg = ironnest_optimizer::NestConfig { + min_sep: 0.375, + seed: 1, + budget: 1, + restarts: 1, + strategy: ironnest_optimizer::PlacementStrategy::Nfp, + }; + let mut pair_count = 0usize; + let ms = best_ms(3, || { + let sol = ironnest_optimizer::nest_with_config(&items, &qty, &container, &[], &rots, &cfg) + .expect("in-domain"); + pair_count = sol.placements.len(); // keep the call un-elided + }); + let _ = pair_count; + let _ = &nfp::NFP_BACKOFF_E; // anchor: the doc-hidden module is intentionally exercised + println!( + "\n--- NFP cache probe: 13 types × 4 rots (import + full cache build + 13 placements) ---" + ); + println!("nfp-cache mixed-13 best {ms:>8.2} ms (build once per nest() call)"); +} + +/// Full-pipeline NFP-mode probe at several K, comparable against the sampling rows above. +fn nfp_probe( + label: &str, + items: &[Vec<[Scalar; 2]>], + areas: &[Scalar], + qty: &[usize], + side: Scalar, + ks: &[usize], +) { + let container = rect(side, side); + let rots: Vec> = items + .iter() + .map(|_| vec![0.0, 90.0, 180.0, 270.0]) + .collect(); + for &k in ks { + let cfg = NestConfig { + min_sep: 0.0, + seed: 1, + budget: 2000, // drives only the sampling fallback + the sep tail's insertion attempts + restarts: k, + strategy: PlacementStrategy::Nfp, + }; + let mut placed = 0usize; + let mut placed_area = 0.0; + let ms = best_ms(1, || { + let sol = nest_with_config(items, qty, &container, &[], &rots, &cfg) + .expect("bench corpus is in-domain"); + placed = sol.placements.len(); + placed_area = sol.placements.iter().map(|p| areas[p.item]).sum(); + }); + let total: usize = qty.iter().sum(); + let util = placed_area / (side * side) * 100.0; + println!( + "{label:<18} K={k:<3} placed {placed:>4}/{total:<4} util {util:>5.1}% {ms:>9.1} ms" + ); + } +} + +/// [`multistart_probe`] over a heterogeneous corpus: reports placed-AREA utilization (the +/// multi-start objective) rather than a single type's count. +fn multistart_mixed_probe( + label: &str, + items: &[Vec<[Scalar; 2]>], + areas: &[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 mut placed_area = 0.0; + let ms = best_ms(1, || { + let sol = nest_multistart(items, qty, &container, &[], 0.0, &rotations, 1, budget, k); + placed = sol.placements.len(); + placed_area = sol.placements.iter().map(|p| areas[p.item]).sum(); + }); + let total: usize = qty.iter().sum(); + let util = placed_area / (side * side) * 100.0; + println!( + "{label:<18} K={k:<3} placed {placed:>4}/{total:<4} util {util:>5.1}% {ms:>9.1} ms" + ); + } } /// Runs `nest_multistart` at several K and reports the best-of-K density + total wall-clock, so the diff --git a/crates/optimizer/examples/nfp_policy_ab.rs b/crates/optimizer/examples/nfp_policy_ab.rs new file mode 100644 index 0000000..6c2aed6 --- /dev/null +++ b/crates/optimizer/examples/nfp_policy_ab.rs @@ -0,0 +1,53 @@ +// 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/. + +//! N3 policy A/B probe (docs/03 §5.3): NFP-mode multistart on the over-subscribed mixed corpus, +//! K=8 over five base seeds, mean placed-area utilization. Recorded result (2026-07-10): the +//! shipped **block split wins in NFP mode too** — 94.01 % mean vs 93.49 % for all-jittered-k>0. +//! The corrected §5.3 premise holds: canonical starts diversify through the sampling-fallback and +//! separation-tail PRNG streams, so discarding them costs density exactly as it did in sampling +//! 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}; + +fn rect(w: Scalar, h: Scalar) -> Vec<[Scalar; 2]> { + vec![[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]] +} + +fn main() { + let items = vec![ + rect(13.0, 7.0), + rect(10.0, 10.0), + rect(7.0, 7.0), + vec![[0.0, 0.0], [10.0, 0.0], [0.0, 10.0]], + ]; + let areas = [91.0, 100.0, 49.0, 50.0]; + let qty = [45usize, 30, 45, 24]; // 105 % over-subscribed in 100×100 + let container = rect(100.0, 100.0); + let rots: Vec> = items + .iter() + .map(|_| vec![0.0, 90.0, 180.0, 270.0]) + .collect(); + + let mut sum = 0.0; + for seed in 1..=5u64 { + let cfg = NestConfig { + min_sep: 0.0, + seed, + budget: 2000, + restarts: 8, + strategy: PlacementStrategy::Nfp, + }; + let sol = nest_with_config(&items, &qty, &container, &[], &rots, &cfg).unwrap(); + let placed_area: Scalar = sol.placements.iter().map(|p| areas[p.item]).sum(); + let util = placed_area / 10_000.0 * 100.0; + sum += util; + println!( + "seed {seed}: util {util:.1}% placed {}/144", + sol.placements.len() + ); + } + println!("mean util over 5 seeds: {:.2}%", sum / 5.0); +} diff --git a/crates/optimizer/examples/nfp_spike.rs b/crates/optimizer/examples/nfp_spike.rs new file mode 100644 index 0000000..b208618 --- /dev/null +++ b/crates/optimizer/examples/nfp_spike.rs @@ -0,0 +1,654 @@ +// 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/. + +//! **N0 spike** (docs/03 §11): THROWAWAY end-to-end probe of the exact-NFP constructive mode. +//! Quantize → orientation-normalized convolution union-fill NFP → frame-trick IFP → □E backoff → +//! single-start greedy vertex placement, CDE-verified. Measures the docs/03 §10 kill criterion +//! (pentagon ≥ 24/40 by construction) plus bricks / interlock / contact-columns and wall-clock. +//! No cache sharing, no mode switch, no canonicalization polish — N1/N2 own those. +//! `cargo run -p ironnest-optimizer --release --example nfp_spike` +//! +//! DETERMINISM(ironnest): spike only — `Instant` here is benchmark timing (bench.rs waiver), never +//! a placement input. The placement pipeline itself is deterministic (integer geometry + fixed +//! orders; zero PRNG). + +use std::cmp::Ordering; +use std::collections::BTreeMap; + +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::{Overlay, ShapeType}; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::i_float::int::point::IntPoint; +use i_overlay::i_shape::int::shape::IntShapes; + +use ironnest_cde::collision_detection::CDEConfig; +use ironnest_cde::collision_detection::hazards::filter::NoFilter; +use ironnest_cde::entities::{Item, Layout}; +use ironnest_cde::geometry::fail_fast::SPSurrogateConfig; +use ironnest_cde::geometry::geo_traits::TransformableFrom; +use ironnest_cde::io::ext_repr::{ExtContainer, ExtItem, ExtSPolygon, ExtShape}; +use ironnest_cde::io::import::Importer; +use ironnest_geo::{DTransformation, Scalar}; + +type I64Pt = IntPoint; + +/// 2^20 grid units per input unit (docs/03 §3.1). +const SCALE: Scalar = 1_048_576.0; +const INV_SCALE: Scalar = 1.0 / SCALE; +/// Contact backoff □E half-width, grid units (docs/03 §4). +const E: i64 = 16; +/// Frame-trick ring width, grid units (any nonzero works; 1 full unit for slack). +const FRAME_MARGIN: i64 = 1 << 20; + +fn q(v: Scalar) -> i64 { + (v * SCALE).round() as i64 +} +fn unq(n: i64) -> Scalar { + (n as Scalar) * INV_SCALE +} + +/// Exact i128 doubled signed area (shoelace) of an integer ring. +fn area2(ring: &[I64Pt]) -> i128 { + let n = ring.len(); + let mut acc: i128 = 0; + for i in 0..n { + let a = ring[i]; + let b = ring[(i + 1) % n]; + acc += (a.x as i128) * (b.y as i128) - (b.x as i128) * (a.y as i128); + } + acc +} + +/// Orientation-normalize a ring to CCW (docs/03 §3.2 — the winding-cancellation fix). +/// Returns `None` for zero-area (degenerate) rings. +fn ccw(mut ring: Vec) -> Option> { + match area2(&ring).cmp(&0) { + Ordering::Greater => Some(ring), + Ordering::Less => { + ring.reverse(); + Some(ring) + } + Ordering::Equal => None, + } +} + +/// Convex hull (Andrew monotone chain) of integer points — used for B ⊕ □E on the spike's convex +/// parts (Minkowski of a convex polygon with a square = hull of the four corner-translates). +fn convex_hull(mut pts: Vec) -> Vec { + pts.sort_by_key(|a| (a.x, a.y)); + pts.dedup(); + let n = pts.len(); + if n < 3 { + return pts; + } + let cross = |o: I64Pt, a: I64Pt, b: I64Pt| -> i128 { + ((a.x - o.x) as i128) * ((b.y - o.y) as i128) + - ((a.y - o.y) as i128) * ((b.x - o.x) as i128) + }; + let mut hull: Vec = Vec::with_capacity(2 * n); + for &p in &pts { + while hull.len() >= 2 && cross(hull[hull.len() - 2], hull[hull.len() - 1], p) <= 0 { + hull.pop(); + } + hull.push(p); + } + let lower = hull.len() + 1; + for &p in pts.iter().rev() { + while hull.len() >= lower && cross(hull[hull.len() - 2], hull[hull.len() - 1], p) <= 0 { + hull.pop(); + } + hull.push(p); + } + hull.pop(); + hull +} + +/// B ⊕ □E for a CONVEX integer polygon: hull of the 4 corner-translates. +fn inflate_box_convex(poly: &[I64Pt], e: i64) -> Vec { + let mut pts = Vec::with_capacity(poly.len() * 4); + for &p in poly { + for (dx, dy) in [(-e, -e), (e, -e), (e, e), (-e, e)] { + pts.push(I64Pt::new(p.x + dx, p.y + dy)); + } + } + convex_hull(pts) +} + +/// Exact convexity test on an integer CCW ring (zero-crosses = collinear vertices allowed). +fn is_convex(poly: &[I64Pt]) -> bool { + let n = poly.len(); + (0..n).all(|i| { + let o = poly[i]; + let a = poly[(i + 1) % n]; + let b = poly[(i + 2) % n]; + ((a.x - o.x) as i128) * ((b.y - o.y) as i128) + - ((a.y - o.y) as i128) * ((b.x - o.x) as i128) + >= 0 + }) +} + +/// B ⊕ □E for ANY simple integer polygon: convex → hull shortcut; concave → the convolution +/// union-fill itself (□E is symmetric, so nfp_union_fill(B, □E) = B ⊕ (−□E) = B ⊕ □E). +/// Returns the outer ring of the sum (its holes cannot exist for a simple B ⊕ square). +fn inflate_box(poly: &[I64Pt], e: i64) -> Vec { + let ring = ccw(poly.to_vec()).expect("footprint must have area"); + if is_convex(&ring) { + return inflate_box_convex(&ring, e); + } + let square = vec![ + I64Pt::new(-e, -e), + I64Pt::new(e, -e), + I64Pt::new(e, e), + I64Pt::new(-e, e), + ]; + let sum = nfp_union_fill(std::slice::from_ref(&ring), &square); + sum.into_iter() + .max_by_key(|shape| shape.first().map_or(0i128, |r| area2(r))) + .and_then(|shape| shape.into_iter().next()) + .expect("B ⊕ □E must be nonempty") +} + +/// Union-of-subject-pieces: run all contours as Subject under NonZero, extract Subject fill. +fn union_pieces(pieces: Vec>) -> IntShapes { + let cap = pieces.iter().map(Vec::len).sum(); + let mut ov: Overlay = Overlay::new(cap); + for p in &pieces { + ov.add_contour(p, ShapeType::Subject); + } + ov.overlay(OverlayRule::Subject, FillRule::NonZero) +} + +/// Convolution union-fill NFP (docs/03 §3.2): cycles of A (outer CCW + optional CW holes) convolved +/// with the REFLECTED moving footprint −B'. Every quad and seed is CCW-normalized before the union. +/// Output: the Minkowski sum A ⊕ (−B′) as shapes-with-holes. The moving part's reference point is +/// its (centroid) origin, so "pose t collides" ⟺ t ∈ NFP translated by A's position. +fn nfp_union_fill(a_cycles: &[Vec], b_prime: &[I64Pt]) -> IntShapes { + let rb: Vec = b_prime.iter().map(|p| I64Pt::new(-p.x, -p.y)).collect(); + let mut pieces: Vec> = Vec::new(); + + for cycle in a_cycles { + let n = cycle.len(); + let m = rb.len(); + for i in 0..n { + let a1 = cycle[i]; + let a2 = cycle[(i + 1) % n]; + for j in 0..m { + let b1 = rb[j]; + let b2 = rb[(j + 1) % m]; + let quad = vec![ + I64Pt::new(a1.x + b2.x, a1.y + b2.y), + I64Pt::new(a1.x + b1.x, a1.y + b1.y), + I64Pt::new(a2.x + b1.x, a2.y + b1.y), + I64Pt::new(a2.x + b2.x, a2.y + b2.y), + ]; + if let Some(qd) = ccw(quad) { + pieces.push(qd); + } + } + } + } + // Interior seeds: A translated to −B′'s first vertex, −B′ translated to A's first vertex. + // The A-seed exists to fill "B strictly inside A's solid" pose pockets. When A HAS HOLES + // (the frame-trick ring), the translated OUTER contour would flood the hole region (this bug + // emptied the IFP twice in this spike) — and no B-pose fits strictly inside a ring thinner + // than B anyway (FRAME_MARGIN ≪ part dims), so for holed A the A-seed is omitted entirely. + // Omission can only under-fill (safe direction: over-permissive region, CDE rejects). N1 does + // winding-correct shape-group seeding properly. + let a_has_holes = a_cycles.iter().any(|c| area2(c) < 0); + let b0 = rb[0]; + if !a_has_holes { + for cycle in a_cycles { + let seed: Vec = cycle + .iter() + .map(|p| I64Pt::new(p.x + b0.x, p.y + b0.y)) + .collect(); + if let Some(s) = ccw(seed) { + pieces.push(s); + } + } + } + let a0 = a_cycles[0][0]; + let seed_b: Vec = rb + .iter() + .map(|p| I64Pt::new(p.x + a0.x, p.y + a0.y)) + .collect(); + if let Some(s) = ccw(seed_b) { + pieces.push(s); + } + + union_pieces(pieces) +} + +/// IFP via the frame trick (docs/03 §3.2): frame = container bbox expanded by FRAME_MARGIN with the +/// container as a hole; the HOLES of NFP(frame, B′) are the IFP loops (returned CCW). +fn ifp_frame_trick(container: &[I64Pt], b_prime: &[I64Pt]) -> Vec> { + let (mut x0, mut y0, mut x1, mut y1) = (i64::MAX, i64::MAX, i64::MIN, i64::MIN); + for p in container { + x0 = x0.min(p.x); + y0 = y0.min(p.y); + x1 = x1.max(p.x); + y1 = y1.max(p.y); + } + let (fx0, fy0, fx1, fy1) = ( + x0 - FRAME_MARGIN, + y0 - FRAME_MARGIN, + x1 + FRAME_MARGIN, + y1 + FRAME_MARGIN, + ); + let frame_outer = vec![ + I64Pt::new(fx0, fy0), + I64Pt::new(fx1, fy0), + I64Pt::new(fx1, fy1), + I64Pt::new(fx0, fy1), + ]; + // Container as the frame's hole: CW cycle. + let mut hole = container.to_vec(); + if area2(&hole) > 0 { + hole.reverse(); + } + let cycles = vec![frame_outer, hole]; + let nfp = nfp_union_fill(&cycles, b_prime); + + // IFP = holes of the result (inner contours, emitted CW by i_overlay) → reverse to CCW. + let mut loops = Vec::new(); + for shape in &nfp { + for ring in shape.iter().skip(1) { + let mut r = ring.clone(); + r.reverse(); + loops.push(r); + } + } + loops +} + +// ------------------------------------------------------------------------------------------- +// Spike harness +// ------------------------------------------------------------------------------------------- + +fn rect(w: Scalar, h: Scalar) -> Vec<[Scalar; 2]> { + vec![[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]] +} + +fn default_cde_config() -> CDEConfig { + CDEConfig { + quadtree_depth: 5, + cd_threshold: 16, + item_surrogate_config: SPSurrogateConfig { + n_pole_limits: [(100, 0.0), (20, 0.75), (10, 0.90)], + n_ff_poles: 2, + n_ff_piers: 0, + }, + } +} + +/// Cardinal rotation of centered f64 vertices by exact swap/negate, then quantize. +/// rot_idx: 0 → 0°, 1 → 90° CCW, 2 → 180°, 3 → 270°. +fn quantized_rotated(item: &Item, rot_idx: usize) -> Vec { + item.shape_cd + .vertices + .iter() + .map(|p| { + let (x, y) = (p.0, p.1); + let (rx, ry) = match rot_idx { + 0 => (x, y), + 1 => (-y, x), + 2 => (-x, -y), + _ => (y, -x), + }; + I64Pt::new(q(rx), q(ry)) + }) + .collect() +} + +struct Placed { + item_id: usize, + rot_idx: usize, + x: i64, + y: i64, +} + +struct ProbeResult { + placed: usize, + rejected_candidates: usize, + ms: f64, +} + +/// Shoelace area of an input outline. +fn outline_area(o: &[[Scalar; 2]]) -> Scalar { + let n = o.len(); + let mut acc = 0.0; + for i in 0..n { + let j = (i + 1) % n; + acc += o[i][0] * o[j][1] - o[j][0] * o[i][1]; + } + (0.5 * acc).abs() +} + +#[allow(clippy::too_many_lines)] +fn run_probe( + label: &str, + items_outlines: &[Vec<[Scalar; 2]>], + qty: &[usize], + container_outline: &[[Scalar; 2]], + container_area: Scalar, + min_sep: Scalar, +) -> ProbeResult { + #[allow(clippy::disallowed_methods)] // spike timing only — never a placement input + let t0 = std::time::Instant::now(); + + let item_areas: Vec = items_outlines.iter().map(|o| outline_area(o)).collect(); + let mut importer = Importer::new( + default_cde_config(), + None, + (min_sep > 0.0).then_some(min_sep), + None, + ); + // Mirror lib.rs: collision-footprint decimation for high-vertex curved parts at min_sep > 0. + importer.shape_modify_config.collision_decimation = (min_sep > 0.0).then_some(min_sep / 16.0); + let ext_container = ExtContainer { + id: 0, + shape: ExtShape::SimplePolygon(ExtSPolygon( + container_outline.iter().map(|p| (p[0], p[1])).collect(), + )), + zones: vec![], + }; + let container = importer.import_container(&ext_container).unwrap(); + let container_int: Vec = container + .outer_cd + .vertices + .iter() + .map(|p| I64Pt::new(q(p.0), q(p.1))) + .collect(); + + let cardinal = [0.0, 90.0, 180.0, 270.0]; + let items: Vec = items_outlines + .iter() + .enumerate() + .map(|(i, o)| { + importer + .import_item(&ExtItem { + id: i as u64, + allowed_orientations: Some(cardinal.to_vec()), + shape: ExtShape::SimplePolygon(ExtSPolygon( + o.iter().map(|p| (p[0], p[1])).collect(), + )), + min_quality: None, + }) + .unwrap() + }) + .collect(); + let rot_rads: [Scalar; 4] = [ + 0.0, + 90f64.to_radians(), + 180f64.to_radians(), + 270f64.to_radians(), + ]; + + // Per (type, rot): quantized footprint and its □E inflation; integer bbox max for the loss. + let mut foot: Vec<[Vec; 4]> = Vec::new(); + let mut foot_e: Vec<[Vec; 4]> = Vec::new(); + let mut bbox_max: Vec<[(i64, i64); 4]> = Vec::new(); + for item in &items { + let mut f: [Vec; 4] = Default::default(); + let mut fe: [Vec; 4] = Default::default(); + let mut bm = [(0i64, 0i64); 4]; + for r in 0..4 { + let p = quantized_rotated(item, r); + let pe = inflate_box(&p, E); + let (mut mx, mut my) = (i64::MIN, i64::MIN); + for v in &p { + mx = mx.max(v.x); + my = my.max(v.y); + } + bm[r] = (mx, my); + f[r] = p; + fe[r] = pe; + } + foot.push(f); + foot_e.push(fe); + bbox_max.push(bm); + } + + // IFPs per (type, rot) via the frame trick (rect-container oracle asserted below). + let mut ifp: Vec<[Vec>; 4]> = Vec::new(); + for fe in &foot_e { + let mut per_rot: [Vec>; 4] = Default::default(); + for (r, slot) in per_rot.iter_mut().enumerate() { + *slot = ifp_frame_trick(&container_int, &fe[r]); + } + ifp.push(per_rot); + } + + // Pairwise NFP cache: (placed_type, placed_rot, moving_type, moving_rot) → shapes. + let mut nfp_cache: BTreeMap<(usize, usize, usize, usize), IntShapes> = BTreeMap::new(); + + // Greedy largest-first placement. + let mut order: Vec = (0..items.len()).collect(); + order.sort_by(|&a, &b| { + items[b] + .shape_cd + .diameter + .total_cmp(&items[a].shape_cd.diameter) + }); + + let mut layout = Layout::new(container); + let mut placed: Vec = Vec::new(); + let mut placed_count = 0usize; + let mut rejected = 0usize; + let mut buffers: Vec<_> = items + .iter() + .map(|it| { + let mut b = (*it.shape_cd).clone(); + b.surrogate = None; + b + }) + .collect::>(); + + for &t in &order { + 'instance: for _ in 0..qty[t] { + // Candidates across rotations: (cost, rot, x, y). + let mut cands: Vec<(i64, usize, i64, i64)> = Vec::new(); + for r in 0..4 { + // Region = IFP(t, r) ∖ ⋃ placed NFPs (one flat difference). + let cap = ifp[t][r].iter().map(Vec::len).sum::() + placed.len() * 16; + let mut ov: Overlay = Overlay::new(cap); + for loop_ in &ifp[t][r] { + ov.add_contour(loop_, ShapeType::Subject); + } + for pl in &placed { + let key = (pl.item_id, pl.rot_idx, t, r); + let nfp = nfp_cache.entry(key).or_insert_with(|| { + nfp_union_fill( + std::slice::from_ref(&foot[pl.item_id][pl.rot_idx]), + &foot_e[t][r], + ) + }); + for shape in nfp.iter() { + for (ci, ring) in shape.iter().enumerate() { + let tr: Vec = ring + .iter() + .map(|p| I64Pt::new(p.x + pl.x, p.y + pl.y)) + .collect(); + let _ = ci; + ov.add_contour(&tr, ShapeType::Clip); + } + } + } + let region = ov.overlay(OverlayRule::Difference, FillRule::NonZero); + for shape in ®ion { + for ring in shape { + for p in ring { + let cost = 10 * (p.x + bbox_max[t][r].0) + (p.y + bbox_max[t][r].1); + cands.push((cost, r, p.x, p.y)); + } + } + } + } + cands.sort_unstable(); + + for &(_, r, x, y) in &cands { + let dt = DTransformation::new(rot_rads[r], (unq(x), unq(y))); + let transf = dt.compose(); + let buffer = &mut buffers[t]; + buffer.transform_from(&items[t].shape_cd, &transf); + if !layout.cde().detect_poly_collision(buffer, &NoFilter) { + layout.place_item(&items[t], dt); + placed.push(Placed { + item_id: t, + rot_idx: r, + x, + y, + }); + placed_count += 1; + continue 'instance; + } + rejected += 1; + } + // No candidate worked for this instance; larger instances of the same type won't + // either (identical geometry) — stop this type. + break; + } + } + + let ms = t0.elapsed().as_secs_f64() * 1000.0; + let placed_area: Scalar = placed.iter().map(|p| item_areas[p.item_id]).sum(); + let util = placed_area / container_area * 100.0; + let total: usize = qty.iter().sum(); + println!( + "{label:<26} placed {placed_count:>4}/{total:<4} util {util:>5.1}% {ms:>9.1} ms (verify-rejected {rejected})" + ); + ProbeResult { + placed: placed_count, + rejected_candidates: rejected, + ms, + } +} + +fn main() { + println!("--- N0 spike: exact-NFP constructive placement (single start, no PRNG) ---"); + let pentagon = vec![ + [0.0, 0.0], + [20.0, 0.0], + [20.0, 12.0], + [12.0, 20.0], + [0.0, 20.0], + ]; + let right_tri = vec![[0.0, 0.0], [10.0, 0.0], [0.0, 10.0]]; + + let r1 = run_probe( + "pentagon ~344 (KILL>=24)", + &[pentagon], + &[40], + &rect(100.0, 100.0), + 10_000.0, + 0.0, + ); + let r2 = run_probe( + "13x7 bricks", + &[rect(13.0, 7.0)], + &[120], + &rect(100.0, 100.0), + 10_000.0, + 0.0, + ); + let r3 = run_probe( + "2 right-tris pair->sq", + &[right_tri], + &[2], + &rect(11.0, 11.0), + 121.0, + 0.0, + ); + let r4 = run_probe( + "10x10 x100 contact-cols", + &[rect(10.0, 10.0)], + &[100], + &rect(100.01, 100.01), + 100.01 * 100.01, + 0.0, + ); + + // The consumer acceptance corpus (nest_benchmark.py mixed_cutlist, geometry verbatim; qty = + // base × 2 → 122 instances, the harness's own scale for a 120×60 sheet at 1.3× oversub), + // min_sep = the consumer's 0.375-in part gap. Exercises concave L-brackets (concave×concave + // convolution) and 48-gon discs (decimation path). Utilization here is not apples-to-apples + // with the harness (no edge-margin erosion, no restarts) — the signal is density-per-time. + 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 r5 = run_probe( + "mixed-13 (consumer shapes)", + &mixed_items, + &mixed_qty, + &rect(120.0, 60.0), + 7200.0, + 0.375, + ); + + println!( + "\nkill criterion: pentagon >= 24 -> {}", + if r1.placed >= 24 { "PASS" } else { "FAIL" } + ); + println!( + "interlock by construction: 2/2 -> {}", + if r3.placed == 2 { "PASS" } else { "FAIL" } + ); + println!( + "contact columns: 100/100 -> {}", + if r4.placed == 100 { "PASS" } else { "FAIL" } + ); + println!( + "total verify-rejections: {}", + r1.rejected_candidates + + r2.rejected_candidates + + r3.rejected_candidates + + r4.rejected_candidates + + r5.rejected_candidates + ); + let _ = r2.ms; +} + +/// Regular n-gon of bbox ≈ size × size anchored near the origin — the consumer harness's +/// `ngon_part`, reproduced. Test-INPUT generation only, so std trig carries the same waiver as +/// the generators in tests/nest.rs (the outline is data, not a placement decision). +#[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() +} diff --git a/crates/optimizer/examples/orderdiv.rs b/crates/optimizer/examples/orderdiv.rs new file mode 100644 index 0000000..219f08b --- /dev/null +++ b/crates/optimizer/examples/orderdiv.rs @@ -0,0 +1,71 @@ +// 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 probe for the multi-start **order-diversification** lever (the `start_order_policy` +//! contiguous-block split): two mixed corpora at K=8 over five base seeds, reporting per-seed and +//! mean placed-area utilization. Recorded result (2026-07-09, J=0.3, block policy): on the +//! similar-diameter corpus the split costs ~0.4 pp vs all-canonical (93.83 % vs 94.22 %); on the +//! diameter-misleading strips corpus it gains ~1.3 pp mean (95.45 % vs 94.15 %, best seeds 95.9 % +//! vs 94.8 %) — order jitter pays exactly where largest-diameter-first is a trap, at little cost +//! elsewhere. (A k-parity split scored similarly on these corpora but breaks the doubling-K floor +//! guarantee — see `start_order_policy`.) Re-run when touching the policy or `ORDER_JITTER`. +//! `cargo run -p ironnest-optimizer --release --features parallel --example orderdiv` + +use ironnest_optimizer::{Scalar, nest_multistart}; + +fn rect(w: Scalar, h: Scalar) -> Vec<[Scalar; 2]> { + vec![[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]] +} + +fn corpus(label: &str, items: &[Vec<[Scalar; 2]>], areas: &[Scalar], qty: &[usize]) { + let container = rect(100.0, 100.0); + let rotations = [0.0, 90.0, 180.0, 270.0]; + let total: usize = qty.iter().sum(); + + let mut sum = 0.0; + for base_seed in 1..=5u64 { + let sol = nest_multistart( + items, + qty, + &container, + &[], + 0.0, + &rotations, + base_seed, + 2000, + 8, + ); + let placed_area: Scalar = sol.placements.iter().map(|p| areas[p.item]).sum(); + let util = placed_area / 10_000.0 * 100.0; + sum += util; + println!( + "{label:<16} seed {base_seed}: util {util:.1}% placed {}/{total}", + sol.placements.len() + ); + } + println!("{label:<16} mean util over 5 seeds: {:.2}%\n", sum / 5.0); +} + +fn main() { + // Similar-diameter mix: canonical largest-first is already near-optimal. + corpus( + "mixed 4-type", + &[ + rect(13.0, 7.0), + rect(10.0, 10.0), + rect(7.0, 7.0), + vec![[0.0, 0.0], [10.0, 0.0], [0.0, 10.0]], + ], + &[91.0, 100.0, 49.0, 50.0], + &[45, 30, 45, 24], + ); + // Diameter-misleading mix: long skinny strips out-diameter the big squares, so the canonical + // order places low-area strips first — the case order diversification should rescue. + corpus( + "strips+squares", + &[rect(20.0, 2.0), rect(12.0, 12.0), rect(6.0, 6.0)], + &[40.0, 144.0, 36.0], + &[30, 40, 80], // 1200 + 5760 + 2880 = 9840 (98.4%) + ); +} diff --git a/crates/optimizer/src/bin/golden_dump.rs b/crates/optimizer/src/bin/golden_dump.rs index 075e93a..7ae51f0 100644 --- a/crates/optimizer/src/bin/golden_dump.rs +++ b/crates/optimizer/src/bin/golden_dump.rs @@ -22,7 +22,10 @@ //! - 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_multistart, nest_per_item}; +use ironnest_optimizer::{ + NestConfig, Placement, PlacementStrategy, Scalar, nest, nest_multistart, nest_per_item, + nest_with_config, +}; use std::fmt::Write as _; /// `w × h` axis-aligned rectangle, lower-left at the origin (CCW). @@ -264,6 +267,150 @@ fn dump() -> String { ); write_solution(&mut out, &ms.placements, &ms.unplaced); + // Multi-start where the JITTERED-order start wins (the order-diversification path): overflow + // demand (946 area in 900) so the starts genuinely differ, and at this seed the best-of-3 argmax + // 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). + writeln!(out, "# multistart-overflow").unwrap(); + let mo = nest_multistart( + &[rect(13.0, 7.0), rect(10.0, 10.0)], + &[6, 4], + &rect(30.0, 30.0), + &[], + 0.0, + &CARDINAL, + 4, + 500, + 3, + ); + write_solution(&mut out, &mo.placements, &mo.unplaced); + + // --- NFP-mode cases (feature #4, docs/03 §9.2) — APPENDED, existing cases byte-frozen. ----- + // The integer NFP pipeline is byte-deterministic by construction; these cases are the + // cross-platform PROOF for: contact-quality placement (the exact-fit artifact killer), the + // concave-pocket NFP-hole path, the concave-container + keep-out IFP classification, the + // min_sep-inflated footprint path, and the DP-decimation → offset → quantize chain. All fully + // place in construction (no debug-CI separation cost); `nfp-multistart` follows in N3 with the + // policy it exercises. + let nfp_cfg = |min_sep: Scalar, seed: u64| NestConfig { + min_sep, + seed, + budget: 400, + restarts: 1, + strategy: PlacementStrategy::Nfp, + }; + let per_item = |n: usize| -> Vec> { (0..n).map(|_| CARDINAL.to_vec()).collect() }; + + // Contact-quality: 9 squares of 10 in a 30.01 sheet — 3 columns of 3, ~100 % util (the + // sampling engine's touching-artifact makes this impossible for it). + writeln!(out, "# nfp-contact-columns").unwrap(); + let sol = nest_with_config( + &[rect(10.0, 10.0)], + &[9], + &rect(30.01, 30.01), + &[], + &per_item(1), + &nfp_cfg(0.0, 1), + ) + .expect("in-domain"); + write_solution(&mut out, &sol.placements, &sol.unplaced); + + // Concave pocket: a C-part whose 12×12 pocket must host one of the two 8×8 squares. + writeln!(out, "# nfp-concave-pocket").unwrap(); + let c_part = vec![ + [0.0, 0.0], + [20.0, 0.0], + [20.0, 4.0], + [8.0, 4.0], + [8.0, 16.0], + [20.0, 16.0], + [20.0, 20.0], + [0.0, 20.0], + ]; + let sol = nest_with_config( + &[c_part, rect(8.0, 8.0)], + &[1, 2], + &rect(42.0, 30.0), + &[], + &per_item(2), + &nfp_cfg(0.0, 2), + ) + .expect("in-domain"); + write_solution(&mut out, &sol.placements, &sol.unplaced); + + // Concave L-container + keep-out hole: the face-classified IFP + zone folding, end to end. + writeln!(out, "# nfp-l-sheet-hole").unwrap(); + let l_container = vec![ + [0.0, 0.0], + [60.0, 0.0], + [60.0, 30.0], + [30.0, 30.0], + [30.0, 60.0], + [0.0, 60.0], + ]; + let keepout = vec![[12.0, 12.0], [20.0, 12.0], [20.0, 20.0], [12.0, 20.0]]; + let sol = nest_with_config( + &[rect(13.0, 7.0)], + &[6], + &l_container, + &[keepout], + &per_item(1), + &nfp_cfg(0.0, 3), + ) + .expect("in-domain"); + write_solution(&mut out, &sol.placements, &sol.unplaced); + + // min_sep through the whole integer pipeline (inflated footprints → NFP → □E → verify). + writeln!(out, "# nfp-min-sep").unwrap(); + let sol = nest_with_config( + &[rect(13.0, 7.0), rect(10.0, 10.0)], + &[4, 2], + &rect(45.0, 45.0), + &[], + &per_item(2), + &nfp_cfg(0.375, 5), + ) + .expect("in-domain"); + write_solution(&mut out, &sol.placements, &sol.unplaced); + + // NFP-mode multi-start where the JITTERED start wins: overflow demand, and at this seed the + // best-of-3 argmax keeps start k=2 (the trailing jittered block) — verified: the K=3 result + // differs from BOTH canonical starts (K=1 at seed and seed+1), placed areas 673 vs 646. This + // pins the NFP-mode multistart path (shared read-only cache incl. under `--features parallel`, + // block policy, jitter) into the cross-platform byte-identity contract. + writeln!(out, "# nfp-multistart").unwrap(); + let sol = nest_with_config( + &[rect(13.0, 7.0), rect(10.0, 10.0)], + &[6, 4], + &rect(30.0, 30.0), + &[], + &per_item(2), + &NestConfig { + min_sep: 0.0, + seed: 3, + budget: 500, + restarts: 3, + strategy: PlacementStrategy::Nfp, + }, + ) + .expect("in-domain"); + write_solution(&mut out, &sol.placements, &sol.unplaced); + + // Decimated curved part at min_sep > 0: DP-decimation → offset → quantize → NFP chain. + writeln!(out, "# nfp-decimated-circle").unwrap(); + let sol = nest_with_config( + &[circle_64(12.0)], + &[3], + &rect(60.0, 60.0), + &[], + &per_item(1), + &nfp_cfg(1.0, 8), + ) + .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 0969ef1..9c4b5f9 100644 --- a/crates/optimizer/src/improve.rs +++ b/crates/optimizer/src/improve.rs @@ -75,10 +75,17 @@ fn compact(layout: &mut Layout, entities: &[Option]) { /// Places `item` at `transf` after first sliding it to its bottom-left contact pose — "drop" it into /// place (true Left-Bottom-Fill) so it sits flush against existing parts instead of at the loose -/// sampled position. `transf` must be feasible with `item` not yet in the layout. -pub(crate) fn place_dropped(layout: &mut Layout, item: &Item, transf: DTransformation) { +/// sampled position. `transf` must be feasible with `item` not yet in the layout. Returns the +/// settled pose (the NFP mode's sampling fallback registers it as an obstacle; the sampling +/// pipeline ignores it). +pub(crate) fn place_dropped( + layout: &mut Layout, + item: &Item, + transf: DTransformation, +) -> DTransformation { let settled = slide_bottom_left(layout, item, &transf); layout.place_item(item, settled); + settled } /// The pose `item` settles into when slid toward the bottom-left from `current` (which must be a diff --git a/crates/optimizer/src/lib.rs b/crates/optimizer/src/lib.rs index d223ff1..1fd56d6 100644 --- a/crates/optimizer/src/lib.rs +++ b/crates/optimizer/src/lib.rs @@ -26,6 +26,10 @@ mod improve; mod loss; +/// The exact-NFP construction substrate (feature #4, docs/03). Doc-hidden pub: exposed only so +/// `tests/nfp_props.rs` can drive it directly; NOT a stable API (the `multistart_start` precedent). +#[doc(hidden)] +pub mod nfp; mod prng; mod search; mod sep; @@ -288,6 +292,211 @@ pub fn nest_per_item( seed: u64, budget: u64, ) -> NestSolution { + nest_core( + items, + qty, + container, + holes, + min_sep, + rotations_deg, + seed, + budget, + InsertionOrder::Canonical, + ) +} + +/// How a nest run orders item types for insertion. Construction, refill, and the separation +/// insertion driver all follow this one order. +#[derive(Clone, Copy, Debug)] +enum InsertionOrder { + /// Largest-first (descending CD-shape diameter; stable on ties) — the single-start canonical + /// order. [`nest`] / [`nest_per_item`] always use this, as does multi-start's start `k = 0`. + Canonical, + /// Largest-first under seeded multiplicative noise on the sort keys — the multi-start + /// **order-diversification** lever (the trailing start block). A single greedy order explores only one + /// basin per PRNG stream; jittering the order lets different starts try genuinely different + /// insertion sequences (the dimension Deepnest's GA searches). One factor in + /// `[1 − ORDER_JITTER, 1 + ORDER_JITTER)` is drawn per successfully-imported item type from the + /// start's own seeded PRNG (ascending-id draw order — fixed; import success is itself a pure + /// function of the input), so the order is a pure function of `(seed, inputs)`. + Jittered, +} + +/// Multiplicative half-width of the [`InsertionOrder::Jittered`] sort-key noise. `0.3` lets a part +/// up to ~2× smaller in diameter occasionally insert first, which is enough to reorder heterogeneous +/// mixes without degenerating into a random shuffle (largest-first remains the strong prior). +const ORDER_JITTER: Scalar = 0.3; + +/// The insertion-order policy for multi-start start `k` of `starts`: the K starts are **split +/// between the two diversity axes in contiguous blocks** — the first `⌈K/2⌉` starts (including the +/// canonical `k = 0`, so `n_starts == 1` ≡ [`nest`]) keep the largest-first order and diversify +/// through sampling noise alone; the remaining starts additionally jitter the insertion order. A +/// union of both axes measurably beats spending every start on either one (jittering all `k > 0` +/// regressed the mixed-corpus probe — order noise *replaced* the proven sampling-noise breadth +/// instead of adding to it). Pure function of `(k, starts)` — deterministic. +/// +/// **Why contiguous blocks, not `k`-parity interleaving:** the canonical half must occupy the seed +/// streams `seed..seed+⌈K/2⌉` *consecutively*, so `nest_multistart(seed, 2K)`'s canonical block +/// covers exactly the stream range `nest_multistart(seed, K)` covered in an all-canonical build — +/// making the old result a **guaranteed floor** when K is doubled. Parity interleaving (tried +/// first) aliased "canonical" with every-other absolute stream, so for a fixed base seed half the +/// canonical streams became permanently unreachable at any K — measured on the consumer corpus as +/// a floor drop (82.76 % → 81.9 %) that raising K could not repair. +/// +/// Cross-platform byte-stability of the jittered path is proven by the `multistart-overflow` +/// golden case (`bin/golden_dump.rs`), whose winning start is a jittered one. +fn start_order_policy(k: u64, starts: u64) -> InsertionOrder { + if k < starts.div_ceil(2) { + InsertionOrder::Canonical + } else { + InsertionOrder::Jittered + } +} + +/// One multi-start start, exactly as [`nest_multistart_per_item`] runs it: seed +/// `seed.wrapping_add(k)` plus the per-`(k, starts)` insertion-order policy +/// ([`start_order_policy`]). The single source of truth for the start wiring — both `run_starts` +/// builds call this, which is what makes the sequential and parallel builds byte-identical by +/// construction. +/// +/// Hidden from docs: public only so the integration tests can reconstruct the engine's exact +/// per-start behaviour; NOT a stable API. +#[doc(hidden)] +#[must_use] +#[allow(clippy::too_many_arguments)] +pub fn multistart_start( + 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 { + let Some(problem) = import_problem(items, container, holes, min_sep, rotations_deg) else { + return NestSolution { + placements: vec![], + unplaced: all_unplaced(qty), + }; + }; + multistart_start_imported( + &problem, + qty, + seed, + budget, + k, + starts, + PlacementStrategy::Sampling, + None, + ) +} + +/// [`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 +/// wiring still has exactly one definition (here). +#[allow(clippy::too_many_arguments)] +fn multistart_start_imported( + problem: &Problem, + qty: &[usize], + seed: u64, + budget: u64, + k: u64, + starts: u64, + strategy: PlacementStrategy, + nfp_cache: Option<&nfp::NfpCache>, +) -> NestSolution { + nest_core_imported( + problem, + qty, + seed.wrapping_add(k), + budget, + start_order_policy(k, starts), + strategy, + nfp_cache, + ) +} + +/// Which constructive placement engine a nest run uses (feature #4, docs/03). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum PlacementStrategy { + /// The shipped sampling pipeline (uniform sampling + bottom-left slide + improvement rounds). + /// The default — byte-identical to every pre-NFP release. + #[default] + Sampling, + /// Exact-NFP constructive placement (docs/03): feasible region = IFP ∖ ⋃NFP in scaled integer + /// arithmetic, candidates at region vertices, the CDE as the verification arbiter, sampling + /// fallback per part. The separation tail runs unchanged. + Nfp, +} + +/// 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)] +pub struct NestConfig { + pub min_sep: Scalar, + pub seed: u64, + pub budget: u64, + /// Best-of-K multi-start count (clamped to ≥ 1; `1` = a single canonical start). + pub restarts: usize, + pub strategy: PlacementStrategy, +} + +/// 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). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NestError { + /// The NFP integer domain gate: worst-case scaled boolean-op coordinate too large. + NfpInputDomain { worst_case_grid: i64 }, +} + +impl std::fmt::Display for NestError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + NestError::NfpInputDomain { worst_case_grid } => write!( + f, + "NFP input domain exceeded (worst-case scaled coordinate {worst_case_grid}); \ + shrink the problem coordinates or use PlacementStrategy::Sampling" + ), + } + } +} + +impl std::error::Error for NestError {} + +impl From for NestError { + fn from(e: nfp::NfpError) -> Self { + match e { + nfp::NfpError::InputDomain { worst_case_grid } => { + NestError::NfpInputDomain { worst_case_grid } + } + } + } +} + +/// Nests with an explicit [`NestConfig`] — the strategy-bearing entry point (per-item rotation +/// semantics, best-of-K multi-start). With `strategy: Sampling` this is exactly +/// [`nest_multistart_per_item`] (byte-identical; locked by test). With `strategy: Nfp` the +/// constructive phase is the exact-NFP placer over ONE cache built per call and shared read-only +/// by all K starts (docs/03 §6); everything else — the separation tail, the multi-start block +/// policy, the keep-best reduction — is unchanged. +/// +/// # Errors +/// [`NestError::NfpInputDomain`] iff `strategy == Nfp` and the scaled coordinates exceed the +/// integer domain gate. +#[allow(clippy::too_many_arguments)] +pub fn nest_with_config( + items: &[Vec<[Scalar; 2]>], + qty: &[usize], + container: &[[Scalar; 2]], + holes: &[Vec<[Scalar; 2]>], + rotations_deg: &[Vec], + config: &NestConfig, +) -> Result { assert_eq!( items.len(), qty.len(), @@ -298,7 +507,190 @@ pub fn nest_per_item( rotations_deg.len(), "items and per-item rotations must be the same length" ); + let starts = config.restarts.max(1); + let areas: Vec = items.iter().map(|o| polygon_area(o)).collect(); + + let Some(problem) = import_problem(items, container, holes, config.min_sep, rotations_deg) + else { + return Ok(NestSolution { + placements: vec![], + unplaced: all_unplaced(qty), + }); + }; + // The NFP cache: built ONCE per call, before the starts fork; shared read-only (docs/03 §6). + let cache = match config.strategy { + PlacementStrategy::Sampling => None, + PlacementStrategy::Nfp => Some(nfp::NfpCache::build( + &problem.entities, + &problem.rotations_deg, + &problem.rotations_rad, + &problem.container, + )?), + }; + let runs = run_starts_with( + &problem, + qty, + config.seed, + config.budget, + starts, + &areas, + config.strategy, + cache.as_ref(), + ); + 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) +} + +/// Multi-sheet nest with an explicit [`NestConfig`] (the [`nest_multi_multistart_per_item`] +/// semantics: sheets fill in order, leftovers spill forward, per-sheet best-of-K). With +/// `strategy: Sampling` this is exactly [`nest_multi_multistart_per_item`] (byte-identical; +/// test-locked). With `strategy: Nfp` the container-independent [`nfp::PartsGeometry`] — +/// footprints and the pairwise NFP table — is built **once** and shared across every sheet +/// (docs/03 §6); only the per-sheet IFPs are assembled per container. +/// +/// # Errors +/// [`NestError::NfpInputDomain`] iff `strategy == Nfp` and any sheet (or the parts) exceeds the +/// integer domain gate. +#[allow(clippy::too_many_arguments)] +pub fn nest_multi_with_config( + items: &[Vec<[Scalar; 2]>], + qty: &[usize], + sheets: &[Sheet], + rotations_deg: &[Vec], + config: &NestConfig, +) -> Result { + assert_eq!( + items.len(), + qty.len(), + "items and qty must be the same length" + ); + assert_eq!( + items.len(), + rotations_deg.len(), + "items and per-item rotations must be the same length" + ); + let starts = config.restarts.max(1); + let areas: Vec = items.iter().map(|o| polygon_area(o)).collect(); + + let mut parts: Option> = None; + let mut remaining = qty.to_vec(); + let mut per_sheet = Vec::with_capacity(sheets.len()); + + for (i, sheet) in sheets.iter().enumerate() { + // Same per-sheet seed derivation as `nest_multi_multistart_per_item`. + let sheet_seed = config.seed.wrapping_add(i as u64); + let Some(problem) = import_problem( + items, + &sheet.outline, + &sheet.holes, + config.min_sep, + rotations_deg, + ) else { + // Malformed sheet: nothing places on it; demand spills forward (the existing multi + // semantics — a malformed container yields an all-unplaced sheet). + per_sheet.push(Vec::new()); + continue; + }; + let cache = match config.strategy { + PlacementStrategy::Sampling => None, + PlacementStrategy::Nfp => { + // Parts built once (items are identical on every sheet — import is a pure + // function, so sheet 0's footprints are bit-identical to any sheet's). + let p = if let Some(p) = &parts { + p.clone() + } else { + let built = std::sync::Arc::new(nfp::PartsGeometry::build( + &problem.entities, + &problem.rotations_deg, + &problem.rotations_rad, + )?); + parts = Some(built.clone()); + built + }; + Some(nfp::NfpCache::assemble(p, &problem.container)?) + } + }; + + let runs = run_starts_with( + &problem, + &remaining, + sheet_seed, + config.budget, + starts, + &areas, + config.strategy, + cache.as_ref(), + ); + 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; + + for p in &sol.placements { + remaining[p.item] -= 1; + } + per_sheet.push(sol.placements); + if remaining.iter().all(|&r| r == 0) { + break; + } + } + + per_sheet.resize_with(sheets.len(), Vec::new); + let unplaced = remaining + .iter() + .enumerate() + .flat_map(|(i, &n)| std::iter::repeat_n(i, n)) + .collect(); + Ok(MultiSheetSolution { + per_sheet, + unplaced, + }) +} + +/// 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 +/// function of its inputs — no PRNG, no ambient state — so "import once, reuse per start" is +/// byte-identical to the previous "import inside every start"; the untouched goldens are the +/// proof. No interior mutability anywhere inside, so a `&Problem` crossing the sanctioned +/// multi-start threads is shared IMMUTABLE state (the parallel-exception argument is unchanged); +/// each start clones the container into its own `Layout`. +struct Problem { + entities: Vec>, + container: Container, + rotations_deg: Vec>, + rotations_rad: Vec>, +} + +/// 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 { 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); @@ -317,19 +709,12 @@ pub fn nest_per_item( .iter() .map(|r| if r.is_empty() { vec![0.0] } else { r.clone() }) .collect(); - let rotations_rad_per_item: Vec> = rotations_deg_per_item + let rotations_rad: Vec> = rotations_deg_per_item .iter() .map(|r| r.iter().map(|d| d.to_radians()).collect()) .collect(); - // Import the container (with its holes as quality-0 keep-out zones). A malformed container or - // hole ⇒ nothing can be placed (conservative — never silently place into an intended keep-out). - let Some(container_entity) = import_container_with_holes(&importer, container, holes) else { - return NestSolution { - placements: vec![], - unplaced: all_unplaced(qty), - }; - }; + 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 @@ -347,59 +732,184 @@ pub fn nest_per_item( entities.push(importer.import_item(&ext_item).ok()); } - let mut layout = Layout::new(container_entity); - let mut prng = Prng::seed_from_u64(seed); + Some(Problem { + entities, + container, + rotations_deg: rotations_deg_per_item, + rotations_rad, + }) +} - // Placement order: largest items first (descending CD-shape diameter); stable on ties. - let mut order: Vec = (0..items.len()) - .filter(|&i| entities[i].is_some()) - .collect(); - order.sort_by(|&a, &b| { - let da = entities[a].as_ref().unwrap().shape_cd.diameter; - let db = entities[b].as_ref().unwrap().shape_cd.diameter; - // descending; diameters are finite (valid polygons) so partial_cmp never returns None. - db.partial_cmp(&da).unwrap() - }); - - let mut placed_per_type = vec![0usize; items.len()]; - constructive_fill( - &mut layout, - &entities, - &order, - qty, - &rotations_rad_per_item, - &mut prng, - budget, - &mut placed_per_type, +/// The full nest pipeline behind [`nest`] / [`nest_per_item`] / the multi-start drivers. +#[must_use] +#[allow(clippy::too_many_arguments)] +fn nest_core( + items: &[Vec<[Scalar; 2]>], + qty: &[usize], + container: &[[Scalar; 2]], + holes: &[Vec<[Scalar; 2]>], + min_sep: Scalar, + rotations_deg: &[Vec], + seed: u64, + budget: u64, + insertion_order: InsertionOrder, +) -> NestSolution { + assert_eq!( + items.len(), + qty.len(), + "items and qty must be the same length" + ); + assert_eq!( + items.len(), + rotations_deg.len(), + "items and per-item rotations must be the same length" ); - // Improvement: compact placed items toward the bottom-left, then fill freed space. Fixed number - // of rounds (no wall clock); deterministic via the same seeded PRNG stream. - improve::improve( - &mut layout, - &entities, - &order, + let Some(problem) = import_problem(items, container, holes, min_sep, rotations_deg) else { + return NestSolution { + placements: vec![], + unplaced: all_unplaced(qty), + }; + }; + nest_core_imported( + &problem, qty, - &rotations_rad_per_item, - &mut prng, + seed, budget, - IMPROVE_ROUNDS, - &mut placed_per_type, - ); + insertion_order, + PlacementStrategy::Sampling, + None, + ) +} + +/// 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. +#[allow(clippy::too_many_arguments)] +fn nest_core_imported( + problem: &Problem, + qty: &[usize], + seed: u64, + budget: u64, + insertion_order: InsertionOrder, + strategy: PlacementStrategy, + nfp_cache: Option<&nfp::NfpCache>, +) -> NestSolution { + let Problem { + entities, + container, + rotations_deg: _, + rotations_rad: rotations_rad_per_item, + } = problem; + let items_len = entities.len(); + + let mut layout = Layout::new(container.clone()); + let mut prng = Prng::seed_from_u64(seed); + + // Placement order: largest items first (descending CD-shape diameter); stable on ties. Under + // `Jittered` the diameters are first scaled by seeded per-type noise factors — drawn here, + // BEFORE any placement sampling, in ascending-id order (a fixed draw order is part of the + // determinism contract). `Canonical` draws nothing, so the single-start PRNG stream (and the + // existing goldens) are untouched. + let sort_keys: Vec = entities + .iter() + .map(|e| { + e.as_ref().map_or(0.0, |item| { + let d = item.shape_cd.diameter; + match insertion_order { + InsertionOrder::Canonical => d, + InsertionOrder::Jittered => { + d * prng.range(1.0 - ORDER_JITTER, 1.0 + ORDER_JITTER) + } + } + }) + }) + .collect(); + let mut order: Vec = (0..items_len).filter(|&i| entities[i].is_some()).collect(); + // Descending; `total_cmp` is a deterministic total order (keys are finite for valid polygons) + // and the stable sort keeps ascending-id order on exact ties. + order.sort_by(|&a, &b| sort_keys[b].total_cmp(&sort_keys[a])); + + let mut placed_per_type = vec![0usize; items_len]; + match strategy { + PlacementStrategy::Sampling => { + constructive_fill( + &mut layout, + entities, + &order, + qty, + rotations_rad_per_item, + &mut prng, + budget, + &mut placed_per_type, + ); + + // Improvement: compact placed items toward the bottom-left, then fill freed space. + // Fixed number of rounds (no wall clock); deterministic via the same seeded PRNG stream. + improve::improve( + &mut layout, + entities, + &order, + qty, + rotations_rad_per_item, + &mut prng, + budget, + IMPROVE_ROUNDS, + &mut placed_per_type, + ); + } + PlacementStrategy::Nfp => { + let cache = nfp_cache.expect("NFP strategy requires a prebuilt cache"); + let mut placed_obs: Vec = Vec::new(); + let mut stats = nfp::place::NfpPlaceStats::default(); + // Construction + exactly ONE refill pass (docs/03 §5.3): the slide compaction is + // skipped (it would smear the □E backoff), so a second identical pass — which can + // exploit space opened by later types' fallback placements — is all a refill can do. + for _ in 0..2 { + 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, + ); + } + // Belt-and-braces (docs/03 §9.2): an NFP construction can never leave an infeasible + // layout — every pose passed the CDE arbiter. + debug_assert!( + layout.is_feasible(), + "NFP construction left an infeasible layout" + ); + } + } // Separation search (Phase 2b): for any still-unplaced part, allow overlap then shove neighbours // apart (sparrow GLS) to discover interlocking arrangements greedy construction cannot reach. A // no-op when everything already placed, so the dense rectangular cases pay nothing. sep::run_separation( &mut layout, - &entities, + entities, &order, qty, &mut prng, &mut placed_per_type, ); - let placements = extract_placements(&layout, &entities); + // 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. + + let placements = extract_placements(&layout, entities); let mut unplaced = vec![]; for (i, (&want, &got)) in qty.iter().zip(&placed_per_type).enumerate() { @@ -425,9 +935,20 @@ pub fn nest_per_item( /// * 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. +/// The K starts are split between two diversity axes in contiguous blocks (v0.5, a +/// placement-changing change for `n_starts > 1` vs v0.4): the **first `⌈K/2⌉` starts** keep the +/// canonical largest-first insertion order and diversify through sampling noise alone; the +/// **remaining starts** additionally re-sort the insertion order under seeded multiplicative key +/// noise — breadth over the order dimension, which pays on mixes where largest-diameter-first is +/// misleading (e.g. long skinny parts; measured +1.0 pp mean on a strips+squares corpus at K=8, +/// ~no cost on similar-diameter mixes — `examples/orderdiv.rs`). Because the canonical block is +/// contiguous from `seed`, doubling `n_starts` keeps every canonical stream the smaller run +/// 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. #[must_use] #[allow(clippy::too_many_arguments)] pub fn nest_multistart( @@ -470,23 +991,30 @@ pub fn nest_multistart_per_item( budget: u64, n_starts: usize, ) -> NestSolution { + assert_eq!( + items.len(), + qty.len(), + "items and qty must be the same length" + ); + assert_eq!( + items.len(), + rotations_deg.len(), + "items and per-item rotations must be the same length" + ); let starts = n_starts.max(1); let areas: Vec = items.iter().map(|o| polygon_area(o)).collect(); + // Import ONCE (the N1a hoist); all K starts share the immutable `Problem` read-only. + let Some(problem) = import_problem(items, container, holes, min_sep, rotations_deg) else { + return NestSolution { + placements: vec![], + unplaced: all_unplaced(qty), + }; + }; + // 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, - ); + let runs = run_starts(&problem, qty, 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 @@ -505,33 +1033,32 @@ 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. +/// Runs `starts` independent constructions over one shared imported [`Problem`], 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]>], +fn run_starts_with( + problem: &Problem, qty: &[usize], - container: &[[Scalar; 2]], - holes: &[Vec<[Scalar; 2]>], - min_sep: Scalar, - rotations_deg: &[Vec], seed: u64, budget: u64, starts: usize, areas: &[Scalar], + strategy: PlacementStrategy, + nfp_cache: Option<&nfp::NfpCache>, ) -> Vec<(NestSolution, Scalar)> { (0..starts as u64) .map(|k| { - let sol = nest_per_item( - items, + let sol = multistart_start_imported( + problem, qty, - container, - holes, - min_sep, - rotations_deg, - seed.wrapping_add(k), + seed, budget, + k, + starts as u64, + strategy, + nfp_cache, ); let area = placed_area(&sol, areas); (sol, area) @@ -539,41 +1066,67 @@ fn run_starts( .collect() } +/// The Sampling-mode shim (the pre-NFP `run_starts` semantics, byte-frozen). +fn run_starts( + problem: &Problem, + qty: &[usize], + seed: u64, + budget: u64, + starts: usize, + areas: &[Scalar], +) -> Vec<(NestSolution, Scalar)> { + run_starts_with( + problem, + qty, + seed, + budget, + starts, + areas, + PlacementStrategy::Sampling, + None, + ) +} + /// 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. +/// 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 +/// 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]>], +fn run_starts_with( + problem: &Problem, qty: &[usize], - container: &[[Scalar; 2]], - holes: &[Vec<[Scalar; 2]>], - min_sep: Scalar, - rotations_deg: &[Vec], seed: u64, budget: u64, starts: usize, areas: &[Scalar], + strategy: PlacementStrategy, + nfp_cache: Option<&nfp::NfpCache>, ) -> 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, + // The same `multistart_start_imported` as the sequential build — the per-start + // 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). + let sol = multistart_start_imported( + problem, qty, - container, - holes, - min_sep, - rotations_deg, - seed.wrapping_add(k), + seed, budget, + k, + starts as u64, + strategy, + nfp_cache, ); let area = placed_area(&sol, areas); (sol, area) diff --git a/crates/optimizer/src/nfp/geom.rs b/crates/optimizer/src/nfp/geom.rs new file mode 100644 index 0000000..3e4bc14 --- /dev/null +++ b/crates/optimizer/src/nfp/geom.rs @@ -0,0 +1,420 @@ +// 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/. + +//! Integer-geometry primitives for the exact-NFP mode (docs/03 §3): quantization, exact i128 +//! predicates, orientation normalization, canonicalization, and the convex machinery +//! (hull, convexity test, edge-merge Minkowski sum). +//! +//! DETERMINISM(ironnest): everything here is `i64` coordinates with `i128` accumulation — exactly +//! specified two's-complement arithmetic, byte-identical on every platform by construction +//! (docs/03 §8 rules 1–4). The ONLY float↔int conversions in the whole NFP module are +//! [`quantize`]/[`unquantize`] (rule 2). + +use i_overlay::i_float::int::point::IntPoint; +use ironnest_geo::Scalar; + +/// One integer grid point (i64 engine). +pub type IPt = IntPoint; +/// One ring (closed contour, implicit last→first edge). +pub type Ring = Vec; + +/// Grid units per input unit: 2^20 — FIXED and GLOBAL (docs/03 §8 rule 1). `x * NFP_SCALE` is a +/// pure exponent shift (exact); quantization's `.round()` is the single lossy step. +pub const NFP_SCALE: Scalar = 1_048_576.0; +/// 2^-20 — exactly representable, so [`unquantize`] is exact for |n| < 2^53. +pub const NFP_INV_SCALE: Scalar = 1.0 / NFP_SCALE; + +/// f64 → grid. THE one rounding site (with pose quantization) in the NFP module (docs/03 §8 rule 2). +#[inline] +#[must_use] +pub fn quantize(v: Scalar) -> i64 { + #[allow(clippy::cast_possible_truncation)] // domain-validated: |v*S| ≪ 2^61 (NfpError gate) + { + (v * NFP_SCALE).round() as i64 + } +} + +/// Grid → f64 — exact (power-of-two scale, |n| < 2^53 by the domain gate). +#[inline] +#[must_use] +pub fn unquantize(n: i64) -> Scalar { + #[allow(clippy::cast_precision_loss)] // exact: |n| < 2^53 (domain gate), 2^-20 exact + { + (n as Scalar) * NFP_INV_SCALE + } +} + +/// Exact doubled signed area (shoelace) of a ring, in i128 (docs/03 §8 rule 4: every self-written +/// predicate accumulates in i128 — the orient2d-class worst case exceeds i64). +#[must_use] +pub fn area2(ring: &[IPt]) -> i128 { + let n = ring.len(); + let mut acc: i128 = 0; + for i in 0..n { + let a = ring[i]; + let b = ring[(i + 1) % n]; + acc += i128::from(a.x) * i128::from(b.y) - i128::from(b.x) * i128::from(a.y); + } + acc +} + +/// Exact orientation predicate: cross of (o→a) × (o→b), i128. +#[inline] +#[must_use] +pub fn cross(o: IPt, a: IPt, b: IPt) -> i128 { + i128::from(a.x - o.x) * i128::from(b.y - o.y) - i128::from(a.y - o.y) * i128::from(b.x - o.x) +} + +/// Orientation-normalizes a ring to CCW (docs/03 §3.2 — the winding-cancellation fix). +/// `None` for zero-area (degenerate) rings — they are dropped, never unioned. +#[must_use] +pub fn ccw(mut ring: Ring) -> Option { + use std::cmp::Ordering; + match area2(&ring).cmp(&0) { + Ordering::Greater => Some(ring), + Ordering::Less => { + ring.reverse(); + Some(ring) + } + Ordering::Equal => None, + } +} + +/// Removes exactly-collinear and duplicate vertices (quantization manufactures them). Keeps +/// orientation; returns `None` if fewer than 3 vertices survive. +#[must_use] +pub fn strip_collinear(ring: &[IPt]) -> Option { + let n = ring.len(); + if n < 3 { + return None; + } + let mut out: Ring = Vec::with_capacity(n); + for i in 0..n { + let prev = ring[(i + n - 1) % n]; + let cur = ring[i]; + let next = ring[(i + 1) % n]; + if cur != prev && cross(prev, cur, next) != 0 { + out.push(cur); + } + } + (out.len() >= 3).then_some(out) +} + +/// Exact convexity test on a CCW ring with no collinear vertices (run [`strip_collinear`] first): +/// every turn strictly left. +#[must_use] +pub fn is_convex_ccw(ring: &[IPt]) -> bool { + let n = ring.len(); + (0..n).all(|i| cross(ring[i], ring[(i + 1) % n], ring[(i + 2) % n]) > 0) +} + +/// Convex hull (Andrew monotone chain), CCW output, collinear points dropped. +#[must_use] +pub fn convex_hull(mut pts: Vec) -> Ring { + pts.sort_unstable_by_key(|p| (p.x, p.y)); + pts.dedup(); + let n = pts.len(); + if n < 3 { + return pts; + } + let mut hull: Ring = Vec::with_capacity(2 * n); + for &p in &pts { + while hull.len() >= 2 && cross(hull[hull.len() - 2], hull[hull.len() - 1], p) <= 0 { + hull.pop(); + } + hull.push(p); + } + let lower = hull.len() + 1; + for &p in pts.iter().rev() { + while hull.len() >= lower && cross(hull[hull.len() - 2], hull[hull.len() - 1], p) <= 0 { + hull.pop(); + } + hull.push(p); + } + hull.pop(); + hull +} + +/// Minkowski sum of two CONVEX CCW rings (no collinear vertices) by the classic sorted-edge merge — +/// O(n+m), no boolean engine (docs/03 §3.2 fast path). Exact integer arithmetic throughout. +#[must_use] +#[allow(clippy::many_single_char_names)] // p/q/i/j/n/m — standard computational-geometry naming +pub fn convex_minkowski(a: &[IPt], b: &[IPt]) -> Ring { + // Rotate each ring to start at its bottom-most (then leftmost) vertex. + let start = |r: &[IPt]| -> usize { + let mut best = 0; + for (i, p) in r.iter().enumerate() { + if (p.y, p.x) < (r[best].y, r[best].x) { + best = i; + } + } + best + }; + let (sa, sb) = (start(a), start(b)); + let (n, m) = (a.len(), b.len()); + let av = |i: usize| a[(sa + i) % n]; + let bv = |j: usize| b[(sb + j) % m]; + + let mut out: Ring = Vec::with_capacity(n + m); + let (mut i, mut j) = (0usize, 0usize); + while i < n || j < m { + let p = av(i % n); + let q = bv(j % m); + out.push(IPt::new(p.x + q.x, p.y + q.y)); + // Compare edge directions: advance the ring whose current edge turns first (CCW order). + let ea = { + let p2 = av((i + 1) % n); + IPt::new(p2.x - p.x, p2.y - p.y) + }; + let eb = { + let q2 = bv((j + 1) % m); + IPt::new(q2.x - q.x, q2.y - q.y) + }; + let cr = i128::from(ea.x) * i128::from(eb.y) - i128::from(ea.y) * i128::from(eb.x); + if j >= m || (i < n && cr > 0) { + i += 1; + } else if i >= n || cr < 0 { + j += 1; + } else { + // Parallel edges: advance both (their sum is one combined edge). + i += 1; + j += 1; + } + } + // The merge can emit collinear break-points where parallel runs met; strip for canonical output. + strip_collinear(&out).unwrap_or(out) +} + +/// Point reflection through the origin. A CCW ring stays CCW (reflection = rotation by 180°). +#[must_use] +pub fn reflect(ring: &[IPt]) -> Ring { + ring.iter().map(|p| IPt::new(-p.x, -p.y)).collect() +} + +/// Translates a ring by `(tx, ty)` — exact integer addition. +#[must_use] +pub fn translate(ring: &[IPt], tx: i64, ty: i64) -> Ring { + ring.iter().map(|p| IPt::new(p.x + tx, p.y + ty)).collect() +} + +/// Exact point-in-ring test (+x ray cast, crossings compared by i128 cross-multiplication). +/// Boundary points are NOT guaranteed either way — callers use strictly-interior probes +/// ([`interior_point`]). +#[must_use] +pub fn point_in_ring(ring: &[IPt], p: IPt) -> bool { + let n = ring.len(); + let mut inside = false; + for i in 0..n { + let a = ring[i]; + let b = ring[(i + 1) % n]; + if (a.y > p.y) != (b.y > p.y) { + let lhs = i128::from(b.x - a.x) * i128::from(p.y - a.y); + let rhs = i128::from(p.x - a.x) * i128::from(b.y - a.y); + let crosses = if b.y > a.y { lhs > rhs } else { lhs < rhs }; + if crosses { + inside = !inside; + } + } + } + inside +} + +/// A deterministic strictly-interior point of a simple ring: the deterministic ladder of +/// vertex-diagonal midpoints `(v_i + v_{i+2}) / 2`, first that tests strictly inside (ear +/// existence guarantees one exists for a valid simple ring; every probe is validated by +/// [`point_in_ring`], so truncating integer division is fine). `None` only for degenerate rings. +#[must_use] +pub fn interior_point(ring: &[IPt]) -> Option { + let n = ring.len(); + if n < 3 { + return None; + } + for i in 0..n { + let a = ring[i]; + let b = ring[(i + 2) % n]; + let mid = IPt::new(i64::midpoint(a.x, b.x), i64::midpoint(a.y, b.y)); + if point_in_ring(ring, mid) { + return Some(mid); + } + } + None +} + +// ------------------------------------------------------------------------------------------- +// Canonicalization (docs/03 §3.4 — applied to every NFP from ANY path) +// ------------------------------------------------------------------------------------------- + +/// Rotates a ring to its lexicographically-least rotation (Booth-style, restricted to the +/// occurrences of the minimal vertex — occurrence-independent, so pinched rings that visit their +/// minimal vertex twice still canonicalize identically regardless of the producer's start vertex). +#[must_use] +pub fn canonical_rotation(ring: &[IPt]) -> Ring { + let n = ring.len(); + if n == 0 { + return Vec::new(); + } + let key = |p: &IPt| (p.x, p.y); + let min_key = ring.iter().map(key).min().unwrap(); + let mut best: Option = None; + for s in 0..n { + if key(&ring[s]) != min_key { + continue; + } + let better = match best { + None => true, + Some(b) => (0..n).any(|i| { + let (rb, rs) = (key(&ring[(b + i) % n]), key(&ring[(s + i) % n])); + rs != rb && { + // first difference decides + (0..i).all(|j| key(&ring[(b + j) % n]) == key(&ring[(s + j) % n])) && rs < rb + } + }), + }; + if better { + best = Some(s); + } + } + let s = best.unwrap(); + (0..n).map(|i| ring[(s + i) % n]).collect() +} + +/// Canonical shapes-with-holes: winding fixed by exact area (outer CCW first, holes CW after), +/// every ring rotated to its least rotation, holes sorted, zero-area rings dropped. +/// The cache stores ONLY canonical geometry (docs/03 §8 rule 6). +#[must_use] +pub fn canonicalize_shapes(shapes: Vec>) -> Vec> { + let mut out: Vec> = Vec::with_capacity(shapes.len()); + for shape in shapes { + let mut rings: Vec = Vec::with_capacity(shape.len()); + for (idx, ring) in shape.into_iter().enumerate() { + let a2 = area2(&ring); + if a2 == 0 { + continue; // degenerate — dropped (exact test) + } + // Ring 0 is the outer (must be CCW); the rest are holes (must be CW). + let want_ccw = idx == 0; + let mut r = ring; + if (a2 > 0) != want_ccw { + r.reverse(); + } + rings.push(canonical_rotation(&r)); + } + if rings.is_empty() { + continue; + } + // Sort the holes (outer stays first) for producer-order independence. + rings[1..].sort_unstable_by(|a, b| { + (a.first().map(|p| (p.x, p.y)), a.len(), a as &[_]).cmp(&( + b.first().map(|p| (p.x, p.y)), + b.len(), + b as &[_], + )) + }); + out.push(rings); + } + // Sort shapes by their outer ring for producer-order independence. + out.sort_unstable_by(|a, b| { + (a[0].first().map(|p| (p.x, p.y)), a[0].len(), &a[0]).cmp(&( + b[0].first().map(|p| (p.x, p.y)), + b[0].len(), + &b[0], + )) + }); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pt(x: i64, y: i64) -> IPt { + IPt::new(x, y) + } + + #[test] + fn quantize_round_trip_within_half_grid() { + for v in [-0.3, 0.0, 1.234_567, 99.999_999, -511.5] { + let back = unquantize(quantize(v)); + assert!((back - v).abs() <= 0.5 * NFP_INV_SCALE, "v={v} back={back}"); + } + } + + #[test] + fn ccw_normalizes_and_drops_degenerate() { + let cw = vec![pt(0, 0), pt(0, 10), pt(10, 0)]; + let r = ccw(cw).unwrap(); + assert!(area2(&r) > 0); + assert!(ccw(vec![pt(0, 0), pt(5, 5), pt(10, 10)]).is_none()); + } + + #[test] + fn convex_minkowski_squares() { + // [0,10]² ⊕ [-2,2]² = [-2,12]² — exact. + let a = vec![pt(0, 0), pt(10, 0), pt(10, 10), pt(0, 10)]; + let b = vec![pt(-2, -2), pt(2, -2), pt(2, 2), pt(-2, 2)]; + let s = convex_minkowski(&a, &b); + let hull = convex_hull(s.clone()); + assert_eq!(hull.len(), 4); + let (mut x0, mut y0, mut x1, mut y1) = (i64::MAX, i64::MAX, i64::MIN, i64::MIN); + for p in &s { + x0 = x0.min(p.x); + y0 = y0.min(p.y); + x1 = x1.max(p.x); + y1 = y1.max(p.y); + } + assert_eq!((x0, y0, x1, y1), (-2, -2, 12, 12)); + assert_eq!(area2(&s), 2 * 14 * 14); + } + + #[test] + fn convex_minkowski_triangle_square_area() { + // area(A⊕B) = area(A) + area(B) + mixed term; verify against hull-of-sums brute force. + let a = vec![pt(0, 0), pt(8, 0), pt(0, 6)]; + let b = vec![pt(-1, -1), pt(3, -1), pt(3, 2), pt(-1, 2)]; + let s = convex_minkowski(&a, &b); + let mut brute: Vec = Vec::new(); + for p in &a { + for q in &b { + brute.push(pt(p.x + q.x, p.y + q.y)); + } + } + let hull = convex_hull(brute); + assert_eq!( + area2(&s), + area2(&hull), + "edge-merge must equal hull-of-sums" + ); + } + + #[test] + fn canonical_rotation_is_start_independent() { + let base = vec![pt(3, 3), pt(0, 0), pt(5, 0), pt(5, 5)]; + let c1 = canonical_rotation(&base); + let rotated: Ring = (0..base.len()) + .map(|i| base[(i + 2) % base.len()]) + .collect(); + let c2 = canonical_rotation(&rotated); + assert_eq!(c1, c2); + assert_eq!(c1[0], pt(0, 0)); + } + + #[test] + fn canonical_rotation_pinched_ring() { + // The minimal vertex (0,0) occurs twice; both starts must canonicalize identically. + let pinched = vec![pt(0, 0), pt(4, 0), pt(2, 1), pt(0, 0), pt(2, 3)]; + let a = canonical_rotation(&pinched); + let shifted: Ring = (0..pinched.len()) + .map(|i| pinched[(i + 3) % pinched.len()]) + .collect(); + let b = canonical_rotation(&shifted); + assert_eq!(a, b); + } + + #[test] + fn strip_collinear_removes_midpoints() { + let r = vec![pt(0, 0), pt(5, 0), pt(10, 0), pt(10, 10), pt(0, 10)]; + let s = strip_collinear(&r).unwrap(); + assert_eq!(s.len(), 4); + } +} diff --git a/crates/optimizer/src/nfp/minkowski.rs b/crates/optimizer/src/nfp/minkowski.rs new file mode 100644 index 0000000..aed0800 --- /dev/null +++ b/crates/optimizer/src/nfp/minkowski.rs @@ -0,0 +1,402 @@ +// 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/. + +//! NFP construction (docs/03 §3.2): the orientation-normalized convolution union-fill (the +//! Boost.Polygon / Deepnest lineage, corrected per the adversarial review), the convex×convex +//! edge-merge fast path, the □E contact inflation, and the frame-trick IFP. +//! +//! Correctness frame: every inserted piece — each edge-pair quad and each solid seed translate — +//! is a SUBSET of the true Minkowski sum, and every piece is CCW-normalized before the union, so +//! the overlay is an honest set union that never over-approximates. Under-fill (the only failure +//! direction on the obstacle side) yields over-permissive candidate regions whose bad candidates +//! the CDE rejects (docs/03 §2). Completeness for hole-free simple pairs follows from the case +//! split in docs/03 §3.2; holed inputs occur only in the frame-trick IFP, where the A-seed is +//! omitted (a frame ring thinner than any part admits no strictly-interior pose — see +//! [`ifp_frame_trick`]). + +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::{Overlay, ShapeType}; +use i_overlay::core::overlay_rule::OverlayRule; + +use super::geom::{ + IPt, Ring, area2, canonicalize_shapes, ccw, convex_minkowski, is_convex_ccw, reflect, + strip_collinear, +}; + +/// Shapes-with-holes: `[shape][ring]`, ring 0 = outer CCW, rings 1.. = holes CW (canonical form). +pub type Shapes = Vec>; + +/// Union of already-CCW-normalized solid pieces: all pieces run as `Subject` under `NonZero` and +/// the Subject fill is extracted. Output is canonicalized (docs/03 §8 rule 6). +fn union_pieces(pieces: &[Ring]) -> Shapes { + let cap = pieces.iter().map(Vec::len).sum(); + let mut ov: Overlay = Overlay::new(cap); + for p in pieces { + ov.add_contour(p, ShapeType::Subject); + } + canonicalize_shapes(ov.overlay(OverlayRule::Subject, FillRule::NonZero)) +} + +/// The convolution union-fill Minkowski sum `A ⊕ (−B′)` for possibly-concave, possibly-holed `A` +/// (cycles: outer CCW + holes CW) against a SIMPLE moving footprint `B′` (docs/03 §3.2): +/// +/// 1. every edge-pair quad `{a1+b2, a1+b1, a2+b1, a2+b2}` over all cycles of A × edges of −B′, +/// **CCW-normalized** (the winding-cancellation fix; zero-area quads dropped exactly); +/// 2. solid seed translates: A's SOLID cycles at −B′'s first vertex, −B′ at A's first vertex — +/// hole cycles never seed, and when A has holes the A-seed is omitted entirely (the translated +/// outer would flood the holes; see the module doc / the N0 spike lessons in docs/03 §11.1); +/// 3. one integer union. +/// +/// The moving part's reference point is its origin: pose `t` collides ⟺ `t ∈ NFP + t_A`. +#[must_use] +pub fn nfp_union_fill(a_cycles: &[Ring], b_prime: &[IPt]) -> Shapes { + let rb = reflect(b_prime); + let mut pieces: Vec = Vec::new(); + + for cycle in a_cycles { + let n = cycle.len(); + let m = rb.len(); + for i in 0..n { + let a1 = cycle[i]; + let a2 = cycle[(i + 1) % n]; + for j in 0..m { + let b1 = rb[j]; + let b2 = rb[(j + 1) % m]; + let quad = vec![ + IPt::new(a1.x + b2.x, a1.y + b2.y), + IPt::new(a1.x + b1.x, a1.y + b1.y), + IPt::new(a2.x + b1.x, a2.y + b1.y), + IPt::new(a2.x + b2.x, a2.y + b2.y), + ]; + if let Some(qd) = ccw(quad) { + pieces.push(qd); + } + } + } + } + + let a_has_holes = a_cycles.iter().any(|c| area2(c) < 0); + let b0 = rb[0]; + if !a_has_holes { + for cycle in a_cycles { + if let Some(s) = ccw(cycle + .iter() + .map(|p| IPt::new(p.x + b0.x, p.y + b0.y)) + .collect()) + { + pieces.push(s); + } + } + } + let a0 = a_cycles[0][0]; + if let Some(s) = ccw(rb + .iter() + .map(|p| IPt::new(p.x + a0.x, p.y + a0.y)) + .collect()) + { + pieces.push(s); + } + + union_pieces(&pieces) +} + +/// NFP of a moving footprint `b_prime` orbiting a SIMPLE (hole-free) obstacle footprint `a`: +/// convex×convex takes the exact O(n+m) edge-merge (no boolean); anything else takes the +/// convolution union-fill. Both paths emit canonical shapes. +#[must_use] +pub fn nfp_pair(a: &[IPt], b_prime: &[IPt]) -> Shapes { + let (a_stripped, b_stripped) = (strip_collinear(a), strip_collinear(b_prime)); + if let (Some(sa), Some(sb)) = (&a_stripped, &b_stripped) + && is_convex_ccw(sa) + && is_convex_ccw(sb) + { + let sum = convex_minkowski(sa, &reflect(sb)); + return canonicalize_shapes(vec![vec![sum]]); + } + let cycles = vec![a.to_vec()]; + nfp_union_fill(&cycles, b_prime) +} + +/// `B ⊕ □E` — the contact-backoff inflation (docs/03 §4), one per (type, rotation). +/// Convex footprints go through the exact edge-merge; concave through the union-fill (□E is +/// symmetric, so reflecting it is a no-op). Returns the single outer ring. +#[must_use] +pub fn inflate_box(footprint: &[IPt], e: i64) -> Ring { + let square = vec![ + IPt::new(-e, -e), + IPt::new(e, -e), + IPt::new(e, e), + IPt::new(-e, e), + ]; + if let Some(stripped) = strip_collinear(footprint) + && is_convex_ccw(&stripped) + { + return convex_minkowski(&stripped, &square); + } + let ring = ccw(footprint.to_vec()).expect("footprint must have area"); + let sum = nfp_union_fill(std::slice::from_ref(&ring), &square); + // A simple polygon ⊕ a square is one hole-free shape; take the (largest) outer defensively. + sum.into_iter() + .max_by_key(|shape| shape.first().map_or(0i128, |r| area2(r))) + .and_then(|shape| shape.into_iter().next()) + .expect("B ⊕ □E must be nonempty") +} + +/// The inner-fit polygon via a **quads-only frame sum + exact face classification** (the N1 +/// revision of the frame trick — docs/03 §3.2 as amended by the P1 counterexample on concave +/// containers, where the naive holed-frame seed rules under-fill any pocket thicker than the part +/// and leak it into the IFP): +/// +/// The edge-pair quads of `(frame ∪ container-hole) × −B′` alone cover EVERY boundary-touching +/// pose (`t = a − b` with `a ∈ ∂C_frame`, `b ∈ ∂B′` is by definition in `∂A ⊕ −∂B′`), so each +/// hole of the quads-union is a face of constant containment status. Classify each hole by one +/// exact integer test — a deterministic strictly-interior probe `t`, then "is `t + b₀` inside the +/// container?" (single-point containment suffices inside a crossing-free face) — and keep only +/// the truly-inside loops. Nested quads-union islands (containers with slots narrower than the +/// part) are subtracted at the end. All-integer, seed-free, correct for any simple container. +#[must_use] +pub fn ifp_frame_trick(container: &[IPt], b_prime: &[IPt], margin: i64) -> Shapes { + use super::geom::{interior_point, point_in_ring, reflect}; + + let (mut x0, mut y0, mut x1, mut y1) = (i64::MAX, i64::MAX, i64::MIN, i64::MIN); + for p in container { + x0 = x0.min(p.x); + y0 = y0.min(p.y); + x1 = x1.max(p.x); + y1 = y1.max(p.y); + } + let frame_outer = vec![ + IPt::new(x0 - margin, y0 - margin), + IPt::new(x1 + margin, y0 - margin), + IPt::new(x1 + margin, y1 + margin), + IPt::new(x0 - margin, y1 + margin), + ]; + // Container as the frame's hole (CW): both cycles contribute quads. + let mut hole = container.to_vec(); + if area2(&hole) > 0 { + hole.reverse(); + } + let cycles = [frame_outer, hole]; + + // Quads only — no seeds (they exist to fill solid-interior pose pockets, which classification + // makes unnecessary; a holed-A seed is also exactly what leaked the concave pocket). + let rb = reflect(b_prime); + let mut pieces: Vec = Vec::new(); + for cycle in &cycles { + let n = cycle.len(); + let m = rb.len(); + for i in 0..n { + let a1 = cycle[i]; + let a2 = cycle[(i + 1) % n]; + for j in 0..m { + let b1 = rb[j]; + let b2 = rb[(j + 1) % m]; + let quad = vec![ + IPt::new(a1.x + b2.x, a1.y + b2.y), + IPt::new(a1.x + b1.x, a1.y + b1.y), + IPt::new(a2.x + b1.x, a2.y + b1.y), + IPt::new(a2.x + b2.x, a2.y + b2.y), + ]; + if let Some(qd) = ccw(quad) { + pieces.push(qd); + } + } + } + } + let sum = union_pieces(&pieces); + // Classify every hole of the union: keep iff its face is a "B′ fully inside the container" + // face (probe + b₀ inside the container, tested exactly). + let container_ccw = ccw(container.to_vec()).expect("container must have area"); + let b0 = b_prime[0]; + let mut kept: Shapes = Vec::new(); + for shape in &sum { + for ring in shape.iter().skip(1) { + let mut solid = ring.clone(); + solid.reverse(); // CW hole → CCW loop + let Some(probe) = interior_point(&solid) else { + continue; + }; + if point_in_ring(&container_ccw, IPt::new(probe.x + b0.x, probe.y + b0.y)) { + kept.push(vec![solid]); + } + } + } + if kept.is_empty() { + return Vec::new(); + } + + // Subtract nested quads-union islands — forbidden-pose components floating INSIDE a kept + // loop (slots narrower than the part). Only shapes whose outer lies strictly inside a kept + // loop qualify (one-point containment suffices: faces never cross the union boundary); the + // principal ring outer CONTAINS the loops and must not be subtracted. + let islands: Vec> = sum + .iter() + .filter(|shape| { + let v = shape[0][0]; + kept.iter().any(|k| point_in_ring(&k[0], v)) + }) + .map(|shape| vec![shape[0].clone()]) + .collect(); + if islands.is_empty() { + return canonicalize_shapes(kept); + } + difference(&canonicalize_shapes(kept), &islands) +} + +/// `subject ∖ ⋃ clips` as ONE flat boolean pass over pristine inputs (docs/03 §8 rule 7 — region +/// queries never re-union snapped output). Subject and clip rings are added with their native +/// windings; `NonZero` resolves holes on both sides. +#[must_use] +pub fn difference(subject: &Shapes, clips: &[Vec]) -> Shapes { + let cap = subject.iter().flatten().map(Vec::len).sum::() + + clips.iter().flatten().map(Vec::len).sum::(); + let mut ov: Overlay = Overlay::new(cap); + for shape in subject { + for ring in shape { + ov.add_contour(ring, ShapeType::Subject); + } + } + for shape in clips { + for ring in shape { + ov.add_contour(ring, ShapeType::Clip); + } + } + canonicalize_shapes(ov.overlay(OverlayRule::Difference, FillRule::NonZero)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pt(x: i64, y: i64) -> IPt { + IPt::new(x, y) + } + fn sq(x0: i64, y0: i64, s: i64) -> Ring { + vec![ + pt(x0, y0), + pt(x0 + s, y0), + pt(x0 + s, y0 + s), + pt(x0, y0 + s), + ] + } + + #[test] + fn nfp_pair_convex_equals_union_fill() { + // P4 dual-run (docs/03 §9.1): on convex pairs the fast path and the convolution + // union-fill must agree exactly (canonical forms equal). + let cases: [(Ring, Ring); 3] = [ + (sq(0, 0, 100), sq(-10, -10, 20)), + (vec![pt(0, 0), pt(80, 0), pt(0, 60)], sq(-5, -5, 10)), + ( + vec![pt(0, 0), pt(50, 0), pt(70, 30), pt(20, 50)], + vec![pt(-10, 0), pt(0, -8), pt(10, 0), pt(0, 8)], + ), + ]; + for (a, b) in cases { + let fast = nfp_pair(&a, &b); + let general = nfp_union_fill(std::slice::from_ref(&a), &b); + assert_eq!(fast, general, "fast path and union-fill disagree"); + } + } + + #[test] + fn nfp_concave_pocket_has_hole_or_pocket_region() { + // A C-shaped obstacle and a small square that fits its pocket: the union-fill NFP of the + // C must NOT cover the pocket-interior poses (the winding-cancellation fix at work). + // C: outer ring of a 100×100 square with a 60-wide notch cut from the right side. + let c_part = vec![ + pt(0, 0), + pt(100, 0), + pt(100, 20), + pt(40, 20), + pt(40, 80), + pt(100, 80), + pt(100, 100), + pt(0, 100), + ]; + let small = sq(-5, -5, 10); + let nfp = nfp_pair(&c_part, &small); + // Pose (70, 50): the square sits strictly inside the notch (feasible ⇒ NOT in the NFP). + // Point-in-shapes test by winding (even-odd on canonical shapes-with-holes). + let inside = point_in_shapes(&nfp, pt(70, 50)); + assert!(!inside, "pocket pose must be outside the NFP"); + // Pose (20, 50): the square overlaps the C's solid left wall (must be IN the NFP). + assert!( + point_in_shapes(&nfp, pt(20, 50)), + "overlapping pose must be inside the NFP" + ); + } + + fn point_in_ring(ring: &[IPt], p: IPt) -> bool { + // Exact ray-cast (odd crossings of the +x ray); on-boundary counts as inside here (tests + // avoid boundary points). + let n = ring.len(); + let mut inside = false; + for i in 0..n { + let a = ring[i]; + let b = ring[(i + 1) % n]; + if (a.y > p.y) != (b.y > p.y) { + // x-coordinate of the crossing, compared exactly via cross-multiplication. + let lhs = i128::from(b.x - a.x) * i128::from(p.y - a.y); + let rhs = i128::from(p.x - a.x) * i128::from(b.y - a.y); + let crosses = if b.y > a.y { lhs > rhs } else { lhs < rhs }; + if crosses { + inside = !inside; + } + } + } + inside + } + + fn point_in_shapes(shapes: &Shapes, p: IPt) -> bool { + for shape in shapes { + if point_in_ring(&shape[0], p) { + let in_hole = shape[1..].iter().any(|h| point_in_ring(h, p)); + if !in_hole { + return true; + } + } + } + false + } + + #[test] + fn ifp_rect_oracle_exact() { + // P6 (docs/03 §9.1): for a rectangle container the IFP must equal the bbox-shrunken + // rectangle EXACTLY, per footprint bbox. + let container = sq(0, 0, 1000); + let b = vec![pt(-30, -20), pt(50, -20), pt(50, 40), pt(-30, 40)]; // bbox [-30,50]×[-20,40] + let ifp = ifp_frame_trick(&container, &b, 16); + assert_eq!(ifp.len(), 1, "one IFP loop for a rect container"); + let hull = super::super::geom::convex_hull(ifp[0][0].clone()); + // Feasible t: t ≥ (0−(−30), 0−(−20)) and t ≤ (1000−50, 1000−40) → [30,950]×[20,960]. + assert_eq!( + hull, + vec![pt(30, 20), pt(950, 20), pt(950, 960), pt(30, 960)] + ); + } + + #[test] + fn inflate_box_grows_bbox_by_e() { + let tri = vec![pt(0, 0), pt(100, 0), pt(0, 100)]; + let inflated = inflate_box(&tri, 16); + let (mut x0, mut y0, mut x1, mut y1) = (i64::MAX, i64::MAX, i64::MIN, i64::MIN); + for p in &inflated { + x0 = x0.min(p.x); + y0 = y0.min(p.y); + x1 = x1.max(p.x); + y1 = y1.max(p.y); + } + assert_eq!((x0, y0, x1, y1), (-16, -16, 116, 116)); + } + + #[test] + fn difference_carves_hole() { + let subject: Shapes = vec![vec![sq(0, 0, 100)]]; + let clips = vec![vec![sq(40, 40, 20)]]; + let d = difference(&subject, &clips); + assert_eq!(d.len(), 1); + assert_eq!(d[0].len(), 2, "one outer + one hole"); + } +} diff --git a/crates/optimizer/src/nfp/mod.rs b/crates/optimizer/src/nfp/mod.rs new file mode 100644 index 0000000..a186c5c --- /dev/null +++ b/crates/optimizer/src/nfp/mod.rs @@ -0,0 +1,443 @@ +// 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/. + +//! `nfp` — the exact no-fit-polygon construction substrate (feature #4, docs/03; N1). +//! +//! Layering (leaf → root): [`geom`] (integer primitives + canonicalization) → [`minkowski`] +//! (edge-merge fast path, orientation-normalized convolution union-fill, □E inflation, frame-trick +//! IFP) → this module (the **[`NfpCache`]**: per-(type, rotation) quantized footprints, IFPs with +//! keep-out zones folded in, eagerly-built pairwise NFPs with mirror derivation, and the +//! per-placement **feasible-region query**). The placer that consumes the region (candidate walk + +//! CDE verification + `PlacementStrategy` wiring) is N2. +//! +//! DETERMINISM(ironnest): pure integer geometry after exactly two quantization sites (shape +//! vertices at build; off-grid obstacle poses at query — docs/03 §8 rule 2); `i128` for every +//! self-written predicate (rule 4); cardinal rotations by exact swap/negate (rule 3); the i64 +//! i_overlay engine with default (single-threaded) solver (rule 5); every cached or returned shape +//! canonicalized (rule 6); `BTreeMap` keyed by rotation INDICES; no PRNG anywhere. The cache has +//! no interior mutability: built once per call, it crosses the sanctioned multi-start threads as +//! immutable `&NfpCache` (rule 9). +//! +//! Doc-hidden pub: exposed only so the integration test suite (`tests/nfp_props.rs`) can drive the +//! substrate directly; NOT a stable API. + +pub mod geom; +pub mod minkowski; +pub(crate) mod place; + +use std::collections::BTreeMap; +use std::fmt; + +use ironnest_cde::entities::{Container, Item}; +use ironnest_geo::Scalar; +use ironnest_geo::Transformation; +use ironnest_geo::geo_traits::Transformable; + +use geom::{IPt, Ring, canonicalize_shapes, quantize, reflect, translate}; +use minkowski::{Shapes, difference, ifp_frame_trick, inflate_box, nfp_pair}; + +/// Contact backoff □E half-width in grid units (docs/03 §4): initial 16 ≈ 1.5e-5 input units — +/// ≥ 4× every identified representation-error term, 4 orders below the consumer's part gap, and +/// freely raisable (even 16× stays under the acceptance harness's resolution). +pub const NFP_BACKOFF_E: i64 = 16; + +/// Upper bound for the frame-trick ring width, grid units (2^18 = 0.25 input unit). The effective +/// margin is `min(this, min-part-min-dimension / 2)` — strictly thinner than any part, which is +/// what licenses omitting the A-seed for the holed frame shape (docs/03 §11.1 lesson). +pub const NFP_FRAME_MARGIN_MAX: i64 = 1 << 18; + +/// Domain gate: every coordinate entering a boolean op must stay far inside the i64 engine's +/// exact-arithmetic range. 2^61 leaves > 30 bits of headroom over any realistic input (docs/03 +/// §3.1; the review-corrected bound). +const MAX_OP_COORD: i64 = 1 << 61; + +/// NFP-mode build errors. The mode HARD-ERRORS on out-of-domain input rather than silently +/// degrading (mode fallback would create density cliffs — docs/03 §3.1). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NfpError { + /// The derived worst-case boolean-op coordinate exceeds the validated domain. + InputDomain { worst_case_grid: i64 }, +} + +impl fmt::Display for NfpError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + NfpError::InputDomain { worst_case_grid } => write!( + f, + "NFP mode input domain exceeded: worst-case scaled coordinate {worst_case_grid} \ + >= 2^61 grid units — shrink the problem coordinates (docs/03 §3.1)" + ), + } + } +} + +/// A placed obstacle for the region query: item type, rotation-set index, and its pose translation +/// on the grid (poses placed by the NFP placer are grid-exact; fallback/sep poses are quantized +/// once by the caller — the second sanctioned quantization site). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PlacedObstacle { + pub item_id: usize, + pub rot_idx: usize, + pub tx: i64, + pub ty: i64, +} + +/// The CONTAINER-INDEPENDENT half of the NFP cache (docs/03 §6): quantized footprints, +/// □E-inflated movers, loss offsets, and the full pairwise NFP table. In a multi-sheet nest this +/// is built ONCE and shared (via `Arc`) across every sheet; only the IFPs are per-sheet. +pub struct PartsGeometry { + /// `[type][rot_idx]` → quantized CD footprint (canonical CCW). `None` for failed imports or + /// out-of-range rotation indices. + foot: Vec>>, + /// `[type][rot_idx]` → footprint ⊕ □E (the mover form). + foot_e: Vec>>, + /// `[type][rot_idx]` → integer `(x_max, y_max)` of the UNINFLATED footprint — the loss offsets. + bbox_max: Vec>, + /// `(placed_type, placed_rot, moving_type, moving_rot)` → NFP with the MOVING side inflated. + pairs: BTreeMap<(u16, u8, u16, u8), Shapes>, + /// Max |coordinate| over all footprints (grid) — the parts term of the domain gate. + max_part: i64, + /// Min bbox dimension over all footprints (grid) — bounds the frame margin. + min_part_dim: i64, +} + +/// The per-problem NFP cache: shared [`PartsGeometry`] + this container's IFPs (quality-zone +/// keep-outs folded in). Immutable after build; crosses the sanctioned multi-start threads as +/// `&NfpCache` (docs/03 §8 rule 9). +pub struct NfpCache { + parts: std::sync::Arc, + /// `[type][rot_idx]` → feasible container region for the □E-inflated mover (zones folded in). + ifp: Vec>>, +} + +impl PartsGeometry { + /// Builds the container-independent geometry. `rotations_deg`/`rotations_rad` are the + /// per-item normalized sets (parallel to `entities`); degrees drive the exact-cardinal fast + /// path, radians the libm path for arbitrary angles — the SAME radian values the CDE verify + /// transform uses, so both sides see identical rotated f64 vertices (docs/03 §3.1). + /// + /// Deterministic: iteration in ascending (type, rot) / key order throughout; no PRNG. + #[allow(clippy::too_many_lines)] // one linear build pipeline; splitting would obscure the order + #[allow(clippy::float_cmp)] // INTENTIONAL exact-literal dispatch: only the exact user-supplied + // cardinal degree literals take the swap/negate path; anything else (89.999…, 45.0) falls to + // the libm path — correctness never depends on the comparison matching. + pub fn build( + entities: &[Option], + rotations_deg: &[Vec], + rotations_rad: &[Vec], + ) -> Result { + let n_types = entities.len(); + + // ---- Quantized footprints per (type, rotation index). ---- + let mut foot: Vec>> = Vec::with_capacity(n_types); + for (t, ent) in entities.iter().enumerate() { + let n_rots = rotations_deg.get(t).map_or(0, Vec::len); + let mut per_rot: Vec> = Vec::with_capacity(n_rots); + match ent { + None => per_rot.resize(n_rots, None), + Some(item) => { + // Cardinal path: quantize the 0° footprint once, derive 90/180/270 by exact + // integer swap/negate (docs/03 §8 rule 3). + let zero: Ring = item + .shape_cd + .vertices + .iter() + .map(|p| IPt::new(quantize(p.0), quantize(p.1))) + .collect(); + for r in 0..n_rots { + let deg = rotations_deg[t][r]; + let ring = if deg == 0.0 { + zero.clone() + } else if deg == 90.0 { + zero.iter().map(|p| IPt::new(-p.y, p.x)).collect() + } else if deg == 180.0 { + zero.iter().map(|p| IPt::new(-p.x, -p.y)).collect() + } else if deg == 270.0 { + zero.iter().map(|p| IPt::new(p.y, -p.x)).collect() + } else { + // Arbitrary angle: libm-rotate the f64 vertices (the existing + // deterministic trig rule), then quantize. + let rot = Transformation::from_rotation(rotations_rad[t][r]); + item.shape_cd + .vertices + .iter() + .map(|p| { + let mut q = *p; + q.transform(&rot); + IPt::new(quantize(q.0), quantize(q.1)) + }) + .collect() + }; + per_rot.push(Some(ring)); + } + } + } + foot.push(per_rot); + } + + // ---- Parts-side domain gate (docs/03 §3.1); the container side is checked in + // `NfpCache::assemble` (per sheet). Must run BEFORE any Minkowski arithmetic below. + let mut max_part = 0i64; + let mut min_part_dim = i64::MAX; + for per_rot in &foot { + for ring in per_rot.iter().flatten() { + let (mut x0, mut y0, mut x1, mut y1) = (i64::MAX, i64::MAX, i64::MIN, i64::MIN); + for p in ring { + max_part = max_part.max(p.x.abs()).max(p.y.abs()); + x0 = x0.min(p.x); + y0 = y0.min(p.y); + x1 = x1.max(p.x); + y1 = y1.max(p.y); + } + min_part_dim = min_part_dim.min((x1 - x0).min(y1 - y0)); + } + } + let parts_worst = (2i64.saturating_mul(max_part)).saturating_add(NFP_BACKOFF_E); + if parts_worst >= MAX_OP_COORD { + return Err(NfpError::InputDomain { + worst_case_grid: parts_worst, + }); + } + + // ---- □E-inflated movers + integer loss offsets. ---- + let mut foot_e: Vec>> = Vec::with_capacity(n_types); + let mut bbox_max: Vec> = Vec::with_capacity(n_types); + for per_rot in &foot { + let mut fe: Vec> = Vec::with_capacity(per_rot.len()); + let mut bm: Vec<(i64, i64)> = Vec::with_capacity(per_rot.len()); + for ring in per_rot { + match ring { + None => { + fe.push(None); + bm.push((0, 0)); + } + Some(r) => { + fe.push(Some(inflate_box(r, NFP_BACKOFF_E))); + let (mut mx, mut my) = (i64::MIN, i64::MIN); + for p in r { + mx = mx.max(p.x); + my = my.max(p.y); + } + bm.push((mx, my)); + } + } + } + foot_e.push(fe); + bbox_max.push(bm); + } + + // ---- Pairwise NFPs, eager, ascending key order; mirrors derived by reflection. ---- + let mut pairs: BTreeMap<(u16, u8, u16, u8), Shapes> = BTreeMap::new(); + #[allow(clippy::cast_possible_truncation)] // type/rot counts are tiny (u16/u8 by contract) + #[allow(clippy::needless_range_loop)] + // indices key FOUR parallel tables + the BTreeMap key + for ta in 0..n_types { + for ra in 0..foot[ta].len() { + if foot[ta][ra].is_none() { + continue; + } + for tb in 0..n_types { + for rb in 0..foot[tb].len() { + if foot_e[tb][rb].is_none() { + continue; + } + let key = (ta as u16, ra as u8, tb as u16, rb as u8); + let mirror = (tb as u16, rb as u8, ta as u16, ra as u8); + if pairs.contains_key(&key) { + continue; + } + let nfp = nfp_pair( + foot[ta][ra].as_ref().unwrap(), + foot_e[tb][rb].as_ref().unwrap(), + ); + // Mirror: NFP(B←A) = −NFP(A←B), exact for the symmetric □E (docs/03 §6). + if mirror != key && !pairs.contains_key(&mirror) { + let reflected: Shapes = nfp + .iter() + .map(|shape| shape.iter().map(|ring| reflect(ring)).collect()) + .collect(); + pairs.insert(mirror, canonicalize_shapes(reflected)); + } + pairs.insert(key, nfp); + } + } + } + } + + Ok(Self { + foot, + foot_e, + bbox_max, + pairs, + max_part, + min_part_dim, + }) + } +} + +impl NfpCache { + /// Builds the full cache for one container: [`PartsGeometry::build`] + [`NfpCache::assemble`]. + pub fn build( + entities: &[Option], + rotations_deg: &[Vec], + rotations_rad: &[Vec], + container: &Container, + ) -> Result { + let parts = std::sync::Arc::new(PartsGeometry::build( + entities, + rotations_deg, + rotations_rad, + )?); + Self::assemble(parts, container) + } + + /// Assembles the per-container cache over shared [`PartsGeometry`] — this container's domain + /// gate, frame margin, and IFPs (quality-zone keep-outs folded in). In a multi-sheet nest the + /// parts are built once and this runs per sheet (docs/03 §6). + pub fn assemble( + parts: std::sync::Arc, + container: &Container, + ) -> Result { + let container_int: Ring = container + .outer_cd + .vertices + .iter() + .map(|p| IPt::new(quantize(p.0), quantize(p.1))) + .collect(); + let mut max_container = 0i64; + for p in &container_int { + max_container = max_container.max(p.x.abs()).max(p.y.abs()); + } + let frame_margin = NFP_FRAME_MARGIN_MAX.min((parts.min_part_dim / 2).max(1)); + let worst_case = max_container + .saturating_add(2i64.saturating_mul(parts.max_part)) + .saturating_add(NFP_BACKOFF_E) + .saturating_add(frame_margin); + if worst_case >= MAX_OP_COORD { + return Err(NfpError::InputDomain { + worst_case_grid: worst_case, + }); + } + + // IFPs (quads-only face-classified frame trick), zone keep-outs folded in per (type, rot). + let zones_int: Vec = container.quality_zones[0] + .iter() + .flat_map(|z| &z.shapes_cd) + .map(|s| { + s.vertices + .iter() + .map(|p| IPt::new(quantize(p.0), quantize(p.1))) + .collect() + }) + .collect(); + let mut ifp: Vec>> = Vec::with_capacity(parts.foot_e.len()); + for per_rot in &parts.foot_e { + let mut per: Vec> = Vec::with_capacity(per_rot.len()); + for mover in per_rot { + per.push(mover.as_ref().map(|m| { + let base = ifp_frame_trick(&container_int, m, frame_margin); + if zones_int.is_empty() { + base + } else { + // Each keep-out zone is an ordinary obstacle NFP subtracted once, at build. + let zone_nfps: Vec> = zones_int + .iter() + .map(|z| nfp_pair(z, m).into_iter().flatten().collect::>()) + .collect(); + difference(&base, &zone_nfps) + } + })); + } + ifp.push(per); + } + + Ok(Self { parts, ifp }) + } + + /// The feasible region for `(item_id, rot_idx)` given the placed obstacles: IFP ∖ ⋃ translated + /// pair NFPs — ONE flat boolean pass over pristine cached geometry (docs/03 §5.1). Canonical + /// output. Empty when the part cannot be placed at this rotation. + #[must_use] + pub fn feasible_region( + &self, + item_id: usize, + rot_idx: usize, + placed: &[PlacedObstacle], + ) -> Shapes { + let Some(Some(ifp)) = self.ifp.get(item_id).map(|per| &per[rot_idx]) else { + return Vec::new(); + }; + #[allow(clippy::cast_possible_truncation)] + let clips: Vec> = placed + .iter() + .filter_map(|pl| { + let key = ( + pl.item_id as u16, + pl.rot_idx as u8, + item_id as u16, + rot_idx as u8, + ); + self.parts.pairs.get(&key).map(|nfp| { + nfp.iter() + .flatten() + .map(|ring| translate(ring, pl.tx, pl.ty)) + .collect::>() + }) + }) + .collect(); + if clips.is_empty() { + return ifp.clone(); + } + difference(ifp, &clips) + } + + /// Integer LBF loss offsets for `(item_id, rot_idx)`: the uninflated footprint's `(x_max, y_max)`. + #[must_use] + pub fn loss_offsets(&self, item_id: usize, rot_idx: usize) -> (i64, i64) { + self.parts.bbox_max[item_id][rot_idx] + } + + /// The quantized footprint (test/diagnostic access). + #[must_use] + pub fn footprint(&self, item_id: usize, rot_idx: usize) -> Option<&Ring> { + self.parts + .foot + .get(item_id) + .and_then(|per| per.get(rot_idx)) + .and_then(Option::as_ref) + } + + /// The □E-inflated mover footprint (test/diagnostic access). + #[must_use] + pub fn mover(&self, item_id: usize, rot_idx: usize) -> Option<&Ring> { + self.parts + .foot_e + .get(item_id) + .and_then(|per| per.get(rot_idx)) + .and_then(Option::as_ref) + } + + /// Number of stored pair entries (diagnostic; the op-count-ceiling gates read this). + #[must_use] + pub fn pair_count(&self) -> usize { + self.parts.pairs.len() + } +} + +/// 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. +#[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(); + out.sort_unstable(); + out.dedup(); + out +} diff --git a/crates/optimizer/src/nfp/place.rs b/crates/optimizer/src/nfp/place.rs new file mode 100644 index 0000000..8d98acd --- /dev/null +++ b/crates/optimizer/src/nfp/place.rs @@ -0,0 +1,148 @@ +// 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/. + +//! The NFP constructive placer (docs/03 §5, N2): per part, assemble the feasible region (one flat +//! boolean over pristine cached geometry), walk the integer candidates ascending by the exact +//! linear loss, place the first CDE-verified pose **directly** (no bottom-left slide — the region +//! already encodes contact, and the slide would smear the deliberate □E backoff and burn 60–240 +//! CDE queries per placement re-deriving it), and fall back to today's sampling path verbatim +//! (`search::search` + `place_dropped`, slide included — fallback parts carry no backoff) when the +//! region is empty or every candidate is rejected. +//! +//! DETERMINISM(ironnest): candidate order is the exact integer `(cost, rot_idx, x, y)` +//! lexicographic walk; the placer itself draws NO PRNG — only the sampling fallback does, and its +//! triggering is a pure function of byte-deterministic integer regions + CDE verdicts (docs/03 +//! §5.3). Verify rejections advance deterministically to the next candidate and are counted. + +use ironnest_cde::entities::{Item, Layout}; +use ironnest_geo::{DTransformation, Scalar}; + +use super::geom::{quantize, unquantize}; +use super::{NfpCache, PlacedObstacle, candidates}; +use crate::prng::Prng; +use crate::{improve, search}; + +/// Diagnostic counters for the NFP pass (docs/03 §9.4 op-count gates; §10 rejection target). +#[derive(Debug, Default, Clone, Copy)] +pub struct NfpPlaceStats { + /// Candidates rejected by the CDE arbiter (structural target: 0 — each is an ε-budget datum). + pub verify_rejections: u64, + /// Parts placed by the sampling fallback (region empty / all candidates rejected). + pub fallback_placements: u64, + /// Parts placed at NFP candidates. + pub nfp_placements: u64, +} + +/// One NFP construction pass over `order` (docs/03 §5.3 runs this twice: construction + one +/// refill): for each type, place instances at the best verified NFP candidate until the type's +/// demand is met or nothing fits (NFP and fallback both exhausted ⇒ break the type, matching the +/// sampling `constructive_fill` semantics). Updates `placed`/`placed_per_type` in lockstep with +/// the layout. +#[allow(clippy::too_many_arguments)] +pub(crate) fn place_pass( + layout: &mut Layout, + entities: &[Option], + order: &[usize], + qty: &[usize], + rotations_rad: &[Vec], + cache: &NfpCache, + prng: &mut Prng, + budget: u64, + placed: &mut Vec, + placed_per_type: &mut [usize], + stats: &mut NfpPlaceStats, +) { + for &t in order { + let Some(item) = entities[t].as_ref() else { + continue; + }; + while placed_per_type[t] < qty[t] { + if try_place_one( + layout, + item, + t, + rotations_rad, + cache, + prng, + budget, + placed, + stats, + ) { + placed_per_type[t] += 1; + } else { + break; // neither NFP nor fallback fits this type — further copies can't either + } + } + } +} + +/// Places one instance of type `t`: NFP candidate walk first, sampling fallback second. +/// Returns whether an instance was placed. +#[allow(clippy::too_many_arguments)] +fn try_place_one( + layout: &mut Layout, + item: &Item, + t: usize, + rotations_rad: &[Vec], + cache: &NfpCache, + prng: &mut Prng, + budget: u64, + placed: &mut Vec, + stats: &mut NfpPlaceStats, +) -> bool { + // Candidates across the allowed rotations, merged under the exact lexicographic tie-break + // (cost, rot_idx, x, y) — docs/03 §5.2. + let n_rots = rotations_rad[t].len(); + 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)) { + 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) { + layout.place_item(item, DTransformation::new(rotations_rad[t][r], (fx, fy))); + placed.push(PlacedObstacle { + item_id: t, + rot_idx: r, + tx: x, + ty: y, + }); + stats.nfp_placements += 1; + return true; + } + stats.verify_rejections += 1; + } + + // 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 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(); + // The sampled rotation is bit-identical to a member of the allowed set (the sampler picks + // FROM the set); map it back to its index for the obstacle key. + #[allow(clippy::float_cmp)] // exact identity by construction — see comment above + let rot_idx = rotations_rad[t] + .iter() + .position(|&v| v == rot) + .expect("sampled rotation must come from the allowed set"); + let (tx, ty) = settled.translation(); + placed.push(PlacedObstacle { + item_id: t, + rot_idx, + tx: quantize(tx), + ty: quantize(ty), + }); + stats.fallback_placements += 1; + return true; + } + false +} diff --git a/crates/optimizer/src/sep/evaluator.rs b/crates/optimizer/src/sep/evaluator.rs index 921a511..df21823 100644 --- a/crates/optimizer/src/sep/evaluator.rs +++ b/crates/optimizer/src/sep/evaluator.rs @@ -11,10 +11,10 @@ //! otherwise [`SampleEval::Collision`] with the total weighted overlap. //! //! ironnest adaptation: the item being moved is **removed from the layout before** the search, so -//! there is no self-collision to filter (sparrow keeps it in and excludes it). We forgo sparrow's -//! `upper_bound` early-bailout in favour of a **canonical summation order** (sorted [`HazKey`]) so -//! the weighted loss is byte-identical regardless of CDE traversal order — robustness over the -//! constant-factor speedup. `+ − × ÷ sqrt` only. +//! there is no self-collision to filter (sparrow keeps it in and excludes it). The summation runs in +//! a **canonical order** (sorted [`HazKey`]) so the weighted loss is byte-identical regardless of +//! CDE traversal order — which is also what re-admits sparrow's `upper_bound` early-bailout +//! deterministically (see [`SeparationEvaluator::evaluate_sample`]). `+ − × ÷ sqrt` only. use super::proxy::{ quantify_collision_poly_container, quantify_collision_poly_hole, quantify_collision_poly_poly, @@ -51,6 +51,18 @@ impl SampleEval { SampleEval::Invalid => (2, Scalar::INFINITY), } } + + /// The weighted-overlap sum above which a `Collision` candidate can no longer rank better than + /// `self` — the early-terminate bound for [`SeparationEvaluator::evaluate_sample`]. A `Clear` + /// bound is unbeatable by any collision (threshold 0); an `Invalid` bound rejects nothing + /// (threshold ∞). + fn collision_threshold(self) -> Scalar { + match self { + SampleEval::Clear { .. } => 0.0, + SampleEval::Collision { loss } => loss, + SampleEval::Invalid => Scalar::INFINITY, + } + } } impl Ord for SampleEval { @@ -92,11 +104,11 @@ pub struct SeparationEvaluator<'a> { /// 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. + /// `hazards` 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)>, + /// Scratch `(HazKey, HazardEntity)` buffer, reused per candidate (sorted in place each time so + /// the quantify-and-sum loop runs in canonical order regardless of CDE traversal order). + hazards: Vec<(HazKey, HazardEntity)>, } impl<'a> SeparationEvaluator<'a> { @@ -116,13 +128,25 @@ impl<'a> SeparationEvaluator<'a> { container_bbox: layout.container.outer_cd.bbox, shape_buff: (*item.shape_cd).clone(), collector: BasicHazardCollector::with_capacity(cap), - terms: Vec::with_capacity(cap), + hazards: Vec::with_capacity(cap), } } /// Scores the pose `dt`: transform the item there, ask the CDE which hazards it hits, and sum /// their weighted overlap proxy in canonical [`HazKey`] order. - pub fn evaluate_sample(&mut self, dt: DTransformation) -> SampleEval { + /// + /// `bound` enables the **deterministic early-terminate** (sparrow's `upper_bound` bailout, + /// re-admitted now that the summation order is canonical): the running partial sum is compared + /// against the bound's collision threshold after every term and the remaining (expensive) + /// pole-pair quantifications are skipped once the candidate provably cannot rank better than + /// `bound`. Because every term is strictly positive and the bailout is **strict-greater**, a + /// truncated result is returned only when it (and therefore the full sum) already exceeds the + /// bound — callers compare it against that same bound and discard it, so every downstream + /// accept/reject/store decision is **identical** to the full summation, and any value that is + /// actually kept is an exact, fully-summed loss. The truncation point is a pure function of the + /// canonical term order and byte-identical partial sums, so it is cross-platform deterministic. + /// Pass [`SampleEval::Invalid`] (threshold ∞) to force an exact evaluation. + pub fn evaluate_sample(&mut self, dt: DTransformation, bound: SampleEval) -> SampleEval { let t = dt.compose(); self.shape_buff.transform_from(&self.item.shape_cd, &t); @@ -147,10 +171,13 @@ impl<'a> SeparationEvaluator<'a> { return SampleEval::Clear { loss: 0.0 }; } - // 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.) + // Canonical summation with early terminate: gather the colliding (HazKey, HazardEntity) + // pairs into the reused `hazards` buffer, sort by key (cheap — no quantification yet), then + // quantify-and-accumulate in that canonical order, abandoning the candidate once the partial + // sum strictly exceeds the bound's threshold. The total is independent of the CDE's traversal + // order and byte-identical across platforms. (Destructure so the loop borrows only the + // read-only fields, leaving `hazards`/`collector` free.) + let threshold = bound.collision_threshold(); let Self { layout, tracker, @@ -158,72 +185,119 @@ impl<'a> SeparationEvaluator<'a> { container_bbox, shape_buff, collector, - terms, + hazards, .. } = self; - terms.clear(); - terms.extend(collector.iter().map(|(hkey, haz)| { + hazards.clear(); + hazards.extend(collector.iter().map(|(hkey, haz)| (hkey, *haz))); + hazards.sort_unstable_by_key(|(hkey, _)| *hkey); + + let mut loss: Scalar = 0.0; + for (hkey, haz) in &*hazards { 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) + let pair_loss = quantify_collision_poly_poly(other_shape, shape_buff); + pair_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) + let cont_loss = quantify_collision_poly_container(shape_buff, *container_bbox); + cont_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) + let hole_shape = &layout.cde().hazards_map[*hkey].shape; + let hole_loss = quantify_collision_poly_hole(shape_buff, hole_shape); + hole_loss * tracker.container_weight(*current_pk) } }; - (hkey, term) - })); - terms.sort_unstable_by_key(|(hkey, _)| *hkey); - - let loss: Scalar = terms.iter().map(|(_, term)| *term).sum(); + loss += term; + if loss > threshold { + // Cannot rank better than `bound` (terms are strictly positive, so the full sum is + // ≥ this partial). Truncated — valid only for rejection against `bound`. + return SampleEval::Collision { loss }; + } + } SampleEval::Collision { loss } } } +/// Reusable scratch for [`unweighted_overlap`] — the seed sampler evaluates hundreds of poses per +/// insertion, so the collector and hazard buffer are allocated once by the caller and reused. +#[derive(Debug, Default)] +pub struct OverlapScratch { + collector: BasicHazardCollector, + hazards: Vec<(HazKey, HazardEntity)>, +} + /// The **unweighted** overlap of `shape` against everything currently in `layout` (no item is /// excluded — used to seed a not-yet-placed part at its lowest-overlap pose, where there is no GLS -/// weight row yet). Summed in canonical [`HazKey`] order. Returns `0.0` for a collision-free pose. +/// weight row yet). Quantified and summed in canonical [`HazKey`] order. Returns `0.0` for a +/// collision-free pose. +/// +/// `bound` is the same **strict-greater early-terminate** as +/// [`SeparationEvaluator::evaluate_sample`]: once the partial sum exceeds `bound` the remaining +/// quantifications are skipped and the (truncated, still `> bound`) partial is returned — callers +/// compare against that same bound, so decisions are identical to the full sum. Pass +/// [`Scalar::INFINITY`] for an exact evaluation. #[must_use] -pub fn unweighted_overlap(layout: &Layout, shape: &SPolygon) -> Scalar { - let mut collector = BasicHazardCollector::with_capacity(layout.placed_items.len() + 1); - layout.cde().collect_poly_collisions(shape, &mut collector); - if collector.is_empty() { +pub fn unweighted_overlap( + layout: &Layout, + shape: &SPolygon, + bound: Scalar, + scratch: &mut OverlapScratch, +) -> Scalar { + scratch.collector.clear(); + layout + .cde() + .collect_poly_collisions(shape, &mut scratch.collector); + if scratch.collector.is_empty() { return 0.0; } let container_bbox = layout.container.outer_cd.bbox; - let mut terms: Vec<(HazKey, Scalar)> = collector - .iter() - .map(|(hkey, haz)| { - let term = match haz { - HazardEntity::PlacedItem { pk: other_pk, .. } => { - quantify_collision_poly_poly(&layout.placed_items[*other_pk].shape, shape) - } - HazardEntity::Exterior => quantify_collision_poly_container(shape, container_bbox), - HazardEntity::Hole { .. } | HazardEntity::InferiorQualityZone { .. } => { - quantify_collision_poly_hole(shape, &layout.cde().hazards_map[hkey].shape) - } - }; - (hkey, term) - }) - .collect(); - terms.sort_unstable_by_key(|(hkey, _)| *hkey); - terms.iter().map(|(_, term)| *term).sum() + scratch.hazards.clear(); + scratch + .hazards + .extend(scratch.collector.iter().map(|(hkey, haz)| (hkey, *haz))); + scratch.hazards.sort_unstable_by_key(|(hkey, _)| *hkey); + + let mut loss: Scalar = 0.0; + for (hkey, haz) in &scratch.hazards { + let term = match haz { + HazardEntity::PlacedItem { pk: other_pk, .. } => { + quantify_collision_poly_poly(&layout.placed_items[*other_pk].shape, shape) + } + HazardEntity::Exterior => quantify_collision_poly_container(shape, container_bbox), + HazardEntity::Hole { .. } | HazardEntity::InferiorQualityZone { .. } => { + quantify_collision_poly_hole(shape, &layout.cde().hazards_map[*hkey].shape) + } + }; + loss += term; + if loss > bound { + return loss; // truncated — valid only for rejection against `bound` + } + } + loss } #[cfg(test)] mod tests { use super::*; + #[test] + #[allow(clippy::float_cmp)] // exact-value mapping IS the property under test (determinism) + fn collision_threshold_maps_bounds() { + // Clear is unbeatable by any collision → 0; Collision bounds by its loss; Invalid bounds + // nothing (∞ ⇒ exact evaluation). + assert_eq!(SampleEval::Clear { loss: 0.0 }.collision_threshold(), 0.0); + assert_eq!( + SampleEval::Collision { loss: 3.5 }.collision_threshold(), + 3.5 + ); + assert_eq!(SampleEval::Invalid.collision_threshold(), Scalar::INFINITY); + } + #[test] fn sample_eval_total_order() { let clear = SampleEval::Clear { loss: 0.0 }; diff --git a/crates/optimizer/src/sep/search.rs b/crates/optimizer/src/sep/search.rs index bf31f9d..b203299 100644 --- a/crates/optimizer/src/sep/search.rs +++ b/crates/optimizer/src/sep/search.rs @@ -12,7 +12,7 @@ //! translation axes) and never touches continuous trig; //! - candidate ties break deterministically ([`SampleEval`]'s total order; first-min on equal eval). -use super::evaluator::{SampleEval, SeparationEvaluator, unweighted_overlap}; +use super::evaluator::{OverlapScratch, SampleEval, SeparationEvaluator, unweighted_overlap}; use crate::prng::Prng; use ironnest_cde::entities::{Item, Layout}; use ironnest_cde::geometry::geo_enums::RotationRange; @@ -58,14 +58,17 @@ pub fn search_placement( let mut best = BestSamples::new(cfg.n_coord_descents, item_min_dim * UNIQUE_SAMPLE_THRESHOLD); // Always evaluate the current pose — separation must never make an item worse than staying put. - let ref_eval = evaluator.evaluate_sample(ref_dt); + // Evaluated exactly (Invalid bound = no early terminate): it seeds the best-samples set. + let ref_eval = evaluator.evaluate_sample(ref_dt, SampleEval::Invalid); best.report(ref_dt, ref_eval); - // Focused sampling around the item's pre-move footprint. + // Focused sampling around the item's pre-move footprint. `best.upper_bound()` is the + // early-terminate bound: a sample that provably cannot enter the best-samples set is abandoned + // mid-summation (decisions identical to the full sum — see `evaluate_sample`). if let Some(focused) = UniformBBoxSampler::new(focus_bbox, item, container_bbox) { for _ in 0..cfg.n_focussed_samples { let dt = focused.sample(prng); - let eval = evaluator.evaluate_sample(dt); + let eval = evaluator.evaluate_sample(dt, best.upper_bound()); best.report(dt, eval); } } @@ -74,7 +77,7 @@ pub fn search_placement( if let Some(container) = UniformBBoxSampler::new(container_bbox, item, container_bbox) { for _ in 0..cfg.n_container_samples { let dt = container.sample(prng); - let eval = evaluator.evaluate_sample(dt); + let eval = evaluator.evaluate_sample(dt, best.upper_bound()); best.report(dt, eval); } } @@ -194,12 +197,15 @@ pub fn lowest_overlap_pose( let container_bbox = layout.container.outer_cd.bbox; let sampler = UniformBBoxSampler::new(container_bbox, item, container_bbox)?; let mut buff = (*item.shape_cd).clone(); + let mut scratch = OverlapScratch::default(); let mut best: Option<(DTransformation, Scalar)> = None; for _ in 0..n_samples { let dt = sampler.sample(prng); buff.transform_from(&item.shape_cd, &dt.compose()); - let loss = unweighted_overlap(layout, &buff); + // Early-terminate against the best-so-far (strict-greater → decisions identical to exact). + let bound = best.as_ref().map_or(Scalar::INFINITY, |&(_, b)| b); + let loss = unweighted_overlap(layout, &buff, bound, &mut scratch); if best.as_ref().is_none_or(|(_, b)| loss < *b) { best = Some((dt, loss)); } @@ -330,7 +336,9 @@ fn refine_coord_desc( // is a fixed integer (never a clock) — determinism-safe. let mut iters = 0u32; while let Some(candidates) = cd.ask() { - let evals = candidates.map(|c| evaluator.evaluate_sample(c)); + // Early-terminate bound = the descent's current eval: a candidate that exceeds it is + // `worse` and discarded whether truncated or exact, so the descent path is identical. + let evals = candidates.map(|c| evaluator.evaluate_sample(c, cd.eval)); // First-min on ties → deterministic. let best = if evals[0] <= evals[1] { (candidates[0], evals[0]) diff --git a/crates/optimizer/tests/nest.rs b/crates/optimizer/tests/nest.rs index c63664a..fdff246 100644 --- a/crates/optimizer/tests/nest.rs +++ b/crates/optimizer/tests/nest.rs @@ -127,18 +127,21 @@ fn multistart_k1_is_byte_identical_to_single_nest() { #[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); + // A fast all-placing case over TWO part types (so the odd-k jittered-order starts actually + // reorder something): best-of-K must be byte-identical for the same arguments. K=3 exercises + // the seed sweep, both order policies (k=0,2 canonical / k=1 jittered), and the area-argmax + // reduction. + let items = vec![rect(10.0, 10.0), rect(12.0, 4.0)]; + let qty = [6, 4]; + let container = rect(40.0, 40.0); + let a = nest_multistart(&items, &qty, &container, &[], 0.0, &CARDINAL, 7, 800, 3); + let b = nest_multistart(&items, &qty, &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 broadcast = vec![CARDINAL.to_vec(), CARDINAL.to_vec()]; let per_item = - nest_multistart_per_item(&items, &[9], &container, &[], 0.0, &broadcast, 7, 800, 3); + nest_multistart_per_item(&items, &qty, &container, &[], 0.0, &broadcast, 7, 800, 3); assert_eq!( a, per_item, "broadcast per-item multistart must equal uniform" @@ -165,34 +168,45 @@ 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 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); + // 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; + 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 = None; + let mut manual: Option<(ironnest_optimizer::NestSolution, f64)> = None; for ki in 0..k { - let sol = nest_per_item( + let sol = multistart_start( &items, &qty, &container, &[], 0.0, - &[CARDINAL.to_vec()], - seed.wrapping_add(ki), + &rotations, + seed, budget, + ki, + k, ); - // Iterate k ascending, replace only on STRICTLY-more placements → keeps the earliest k on a - // tie, exactly as the engine's reduction does. + // 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(|m| sol.placements.len() > m.placements.len()); + .is_none_or(|(_, best)| area.total_cmp(best) == std::cmp::Ordering::Greater); if better { - manual = Some(sol); + manual = Some((sol, area)); } } @@ -209,7 +223,7 @@ fn parallel_multistart_equals_independent_sequential_best() { ); assert_eq!( parallel, - manual.expect("k >= 1"), + manual.expect("k >= 1").0, "parallel multistart must be byte-identical to the independent sequential best-of-K" ); } diff --git a/crates/optimizer/tests/nfp_mode.rs b/crates/optimizer/tests/nfp_mode.rs new file mode 100644 index 0000000..279f0c5 --- /dev/null +++ b/crates/optimizer/tests/nfp_mode.rs @@ -0,0 +1,205 @@ +// 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/. + +//! End-to-end tests for the NFP placement mode (docs/03 N2): byte-determinism, the +//! Sampling-config ≡ multistart equivalence (the frozen-wrapper contract), the capabilities the +//! sampling engine lacks (contact-quality exact-fit columns; interlock discovered by construction +//! 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, +}; + +const CARDINAL: [Scalar; 4] = [0.0, 90.0, 180.0, 270.0]; + +fn rect(w: Scalar, h: Scalar) -> Vec<[Scalar; 2]> { + vec![[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]] +} + +fn cardinal_per_item(n: usize) -> Vec> { + (0..n).map(|_| CARDINAL.to_vec()).collect() +} + +fn config(strategy: PlacementStrategy, min_sep: Scalar, seed: u64, restarts: usize) -> NestConfig { + NestConfig { + min_sep, + seed, + budget: 400, + restarts, + strategy, + } +} + +#[test] +fn nfp_mode_is_byte_deterministic() { + let items = vec![rect(13.0, 7.0), rect(10.0, 10.0)]; + let qty = [4usize, 3]; + let container = rect(50.0, 50.0); + let rots = cardinal_per_item(2); + let cfg = config(PlacementStrategy::Nfp, 0.375, 7, 2); + let a = nest_with_config(&items, &qty, &container, &[], &rots, &cfg).unwrap(); + let b = nest_with_config(&items, &qty, &container, &[], &rots, &cfg).unwrap(); + assert_eq!(a, b, "NFP mode must be byte-identical for the same args"); + assert!(!a.placements.is_empty()); +} + +#[test] +fn sampling_config_equals_multistart_byte_for_byte() { + // The frozen-wrapper contract (docs/03 §7): nest_with_config(Sampling) IS + // nest_multistart_per_item, bit for bit. + let items = vec![rect(13.0, 7.0), rect(10.0, 10.0)]; + let qty = [6usize, 4]; + let container = rect(60.0, 60.0); + let rots = cardinal_per_item(2); + let cfg = config(PlacementStrategy::Sampling, 0.5, 11, 3); + let via_config = nest_with_config(&items, &qty, &container, &[], &rots, &cfg).unwrap(); + let direct = nest_multistart_per_item(&items, &qty, &container, &[], 0.5, &rots, 11, 400, 3); + assert_eq!( + via_config, direct, + "Sampling config must be the frozen wrapper" + ); +} + +#[test] +fn nfp_contact_columns_beat_the_exact_fit_artifact() { + // 9 squares of 10×10 in a 30.01 sheet: needs contact-quality placement (3 columns of 3). + // The sampling engine's artifact caps this at 2 per row/column class of results. + let items = vec![rect(10.0, 10.0)]; + let qty = [9usize]; + let container = rect(30.01, 30.01); + let rots = cardinal_per_item(1); + let cfg = config(PlacementStrategy::Nfp, 0.0, 1, 1); + let sol = nest_with_config(&items, &qty, &container, &[], &rots, &cfg).unwrap(); + assert_eq!( + sol.placements.len(), + 9, + "NFP construction must place the full 3×3 contact grid (got {}/9)", + sol.placements.len() + ); +} + +#[test] +fn nfp_finds_interlock_by_construction() { + // Two right triangles that only fit interlocked (one at 180°) in an 11×11 sheet — the case + // the sampling engine needs the whole separation search to solve; NFP construction finds it + // directly (and the pipeline result must place both regardless of which phase does). + let right_tri = vec![[0.0, 0.0], [10.0, 0.0], [0.0, 10.0]]; + let items = vec![right_tri]; + let qty = [2usize]; + let container = rect(11.0, 11.0); + let rots = cardinal_per_item(1); + let cfg = config(PlacementStrategy::Nfp, 0.0, 42, 1); + let sol = nest_with_config(&items, &qty, &container, &[], &rots, &cfg).unwrap(); + assert_eq!(sol.placements.len(), 2, "interlock must place 2/2"); +} + +#[test] +fn nfp_l_container_with_keepout_hole() { + // Concave L-sheet + a keep-out hole: the IFP classification + zone-folding path end to end. + let l_container = vec![ + [0.0, 0.0], + [60.0, 0.0], + [60.0, 30.0], + [30.0, 30.0], + [30.0, 60.0], + [0.0, 60.0], + ]; + let hole = vec![[10.0, 10.0], [20.0, 10.0], [20.0, 20.0], [10.0, 20.0]]; + let items = vec![rect(13.0, 7.0)]; + let qty = [8usize]; + let rots = cardinal_per_item(1); + let cfg = config(PlacementStrategy::Nfp, 0.0, 3, 1); + let sol = nest_with_config(&items, &qty, &l_container, &[hole], &rots, &cfg).unwrap(); + assert!( + sol.placements.len() >= 6, + "L-sheet with keep-out must still pack most bricks (got {}/8)", + sol.placements.len() + ); + // And byte-determinism on this concave path too. + let again = nest_with_config( + &items, + &qty, + &l_container, + &[vec![[10.0, 10.0], [20.0, 10.0], [20.0, 20.0], [10.0, 20.0]]], + &rots, + &cfg, + ) + .unwrap(); + assert_eq!(sol, again); +} + +#[test] +fn nfp_domain_error_surfaces() { + let big = 3.0e12; + let items = vec![rect(big, big)]; + let qty = [1usize]; + let container = rect(big * 2.0, big * 2.0); + let rots = cardinal_per_item(1); + let cfg = config(PlacementStrategy::Nfp, 0.0, 1, 1); + match nest_with_config(&items, &qty, &container, &[], &rots, &cfg) { + Err(NestError::NfpInputDomain { .. }) => {} + other => panic!("expected NfpInputDomain, got {other:?}"), + } +} + +#[test] +fn sampling_multi_config_equals_multi_multistart_byte_for_byte() { + // The multi-sheet frozen-wrapper contract: nest_multi_with_config(Sampling) IS + // nest_multi_multistart_per_item, bit for bit. + let items = vec![rect(13.0, 7.0), rect(10.0, 10.0)]; + let qty = [10usize, 6]; + let sheets = vec![ + Sheet { + outline: rect(35.0, 35.0), + holes: vec![], + }, + Sheet { + outline: rect(30.0, 30.0), + holes: vec![], + }, + ]; + let rots = cardinal_per_item(2); + let cfg = config(PlacementStrategy::Sampling, 0.25, 9, 2); + let via_config = nest_multi_with_config(&items, &qty, &sheets, &rots, &cfg).unwrap(); + let direct = nest_multi_multistart_per_item(&items, &qty, &sheets, 0.25, &rots, 9, 400, 2); + assert_eq!( + via_config, direct, + "Sampling multi config must be the frozen wrapper" + ); +} + +#[test] +fn nfp_multi_sheet_spills_and_is_deterministic() { + // Overflow demand on sheet 1 spills to sheet 2; the container-independent PartsGeometry is + // built once and shared across sheets (behavioral proof: byte-determinism + full placement). + let items = vec![rect(13.0, 7.0), rect(10.0, 10.0)]; + let qty = [10usize, 6]; // 910 + 600 = 1510 > 35² = 1225 → guaranteed spill + let sheets = vec![ + Sheet { + outline: rect(35.0, 35.0), + holes: vec![], + }, + Sheet { + outline: rect(40.0, 40.0), + holes: vec![], + }, + ]; + let rots = cardinal_per_item(2); + let cfg = config(PlacementStrategy::Nfp, 0.0, 5, 2); + let a = nest_multi_with_config(&items, &qty, &sheets, &rots, &cfg).unwrap(); + let b = nest_multi_with_config(&items, &qty, &sheets, &rots, &cfg).unwrap(); + assert_eq!( + a, b, + "NFP multi-sheet must be byte-identical for the same args" + ); + assert!( + !a.per_sheet[0].is_empty() && !a.per_sheet[1].is_empty(), + "demand must spill to the second sheet" + ); + assert!( + a.unplaced.is_empty(), + "everything fits across the two sheets" + ); +} diff --git a/crates/optimizer/tests/nfp_props.rs b/crates/optimizer/tests/nfp_props.rs new file mode 100644 index 0000000..5022497 --- /dev/null +++ b/crates/optimizer/tests/nfp_props.rs @@ -0,0 +1,332 @@ +// 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/. + +//! Property tests for the exact-NFP substrate (docs/03 §9.1) against the CDE — the feasibility +//! arbiter. P1 (soundness — every region candidate must pass CDE verification, zero tolerance) is +//! the load-bearing test; plus the concave-container IFP check (the inverted-failure-direction +//! path, docs/03 §2), byte-determinism double-runs, the cardinal swap/negate ≤ 1-grid bound, and +//! the input-domain hard error. +//! +//! Drives the doc-hidden `ironnest_optimizer::nfp` module (test hook, NOT a stable API), with all +//! geometry built through the same `Importer` the engine uses. + +use ironnest_cde::collision_detection::CDEConfig; +use ironnest_cde::collision_detection::hazards::filter::NoFilter; +use ironnest_cde::entities::{Container, Item, Layout}; +use ironnest_cde::geometry::fail_fast::SPSurrogateConfig; +use ironnest_cde::geometry::geo_traits::{Transformable, TransformableFrom}; +use ironnest_cde::io::ext_repr::{ExtContainer, ExtItem, ExtSPolygon, ExtShape}; +use ironnest_cde::io::import::Importer; +use ironnest_geo::{DTransformation, Scalar, Transformation}; +use ironnest_optimizer::nfp::geom::{IPt, quantize, unquantize}; +use ironnest_optimizer::nfp::minkowski::Shapes; +use ironnest_optimizer::nfp::{NfpCache, NfpError, PlacedObstacle, candidates}; + +const CARDINAL: [Scalar; 4] = [0.0, 90.0, 180.0, 270.0]; + +fn cde_config() -> CDEConfig { + CDEConfig { + quadtree_depth: 5, + cd_threshold: 16, + item_surrogate_config: SPSurrogateConfig { + n_pole_limits: [(100, 0.0), (20, 0.75), (10, 0.90)], + n_ff_poles: 2, + n_ff_piers: 0, + }, + } +} + +/// `(entities, container, rotations_deg, rotations_rad)` — the imported-problem tuple. +type Imported = ( + Vec>, + Container, + Vec>, + Vec>, +); + +/// Imports a problem exactly the way the engine does (min_sep inflation + decimation switch). +fn import(outlines: &[Vec<[Scalar; 2]>], container: &[[Scalar; 2]], min_sep: Scalar) -> Imported { + let mut importer = Importer::new(cde_config(), None, (min_sep > 0.0).then_some(min_sep), None); + importer.shape_modify_config.collision_decimation = (min_sep > 0.0).then_some(min_sep / 16.0); + let cont = importer + .import_container(&ExtContainer { + id: 0, + shape: ExtShape::SimplePolygon(ExtSPolygon( + container.iter().map(|p| (p[0], p[1])).collect(), + )), + zones: vec![], + }) + .unwrap(); + let entities: Vec> = outlines + .iter() + .enumerate() + .map(|(i, o)| { + importer + .import_item(&ExtItem { + id: i as u64, + allowed_orientations: Some(CARDINAL.to_vec()), + shape: ExtShape::SimplePolygon(ExtSPolygon( + o.iter().map(|p| (p[0], p[1])).collect(), + )), + min_quality: None, + }) + .ok() + }) + .collect(); + let deg: Vec> = outlines.iter().map(|_| CARDINAL.to_vec()).collect(); + let rad: Vec> = deg + .iter() + .map(|r| r.iter().map(|d| d.to_radians()).collect()) + .collect(); + (entities, cont, deg, rad) +} + +fn rect(w: Scalar, h: Scalar) -> Vec<[Scalar; 2]> { + vec![[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]] +} + +/// True iff placing `item` at `(rot_rad, x, y)` collides with nothing in `layout`'s CDE — the +/// arbiter check, verbatim engine semantics. +fn cde_feasible(layout: &Layout, item: &Item, rot_rad: Scalar, x: Scalar, y: Scalar) -> bool { + let mut buffer = (*item.shape_cd).clone(); + buffer.surrogate = None; + let transf = DTransformation::new(rot_rad, (x, y)).compose(); + buffer.transform_from(&item.shape_cd, &transf); + !layout.cde().detect_poly_collision(&buffer, &NoFilter) +} + +/// P1 SOUNDNESS (zero tolerance): every candidate the region emits must pass CDE verification — +/// on an empty layout (pure IFP), and again after obstacles are placed (IFP ∖ NFPs). Exercised on +/// convex (pentagon/brick) and CONCAVE (L-bracket) parts, at min_sep 0 and 0.375, on a rectangular +/// and a concave L-shaped container. +#[test] +fn p1_soundness_all_candidates_cde_feasible() { + let pentagon = vec![ + [0.0, 0.0], + [20.0, 0.0], + [20.0, 12.0], + [12.0, 20.0], + [0.0, 20.0], + ]; + let l_bracket = 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], + ]; + let brick = rect(13.0, 7.0); + let l_container = vec![ + [0.0, 0.0], + [100.0, 0.0], + [100.0, 50.0], + [50.0, 50.0], + [50.0, 100.0], + [0.0, 100.0], + ]; + + for (container, min_sep) in [ + (rect(100.0, 100.0), 0.0), + (rect(100.0, 100.0), 0.375), + (l_container.clone(), 0.0), + (l_container, 0.375), + ] { + let (entities, cont, deg, rad) = import( + &[pentagon.clone(), l_bracket.clone(), brick.clone()], + &container, + min_sep, + ); + let cache = NfpCache::build(&entities, °, &rad, &cont).unwrap(); + let mut layout = Layout::new(cont.clone()); + let mut placed: Vec = Vec::new(); + let mut checked = 0usize; + + // Greedy: place up to 6 instances round-robin over (type, rotation), always at the best + // candidate; before placing, verify EVERY candidate we walk past (cap per step). + for step in 0..6 { + let t = step % entities.len(); + let r = step % 4; + let Some(item) = entities[t].as_ref() else { + continue; + }; + let region = cache.feasible_region(t, r, &placed); + let cands = candidates(®ion, cache.loss_offsets(t, r)); + for &(_, x, y) in cands.iter().take(60) { + assert!( + cde_feasible(&layout, item, rad[t][r], unquantize(x), unquantize(y)), + "P1 violated: candidate ({x},{y}) type {t} rot {r} min_sep {min_sep} rejected by CDE", + ); + checked += 1; + } + if let Some(&(_, x, y)) = cands.first() { + layout.place_item( + item, + DTransformation::new(rad[t][r], (unquantize(x), unquantize(y))), + ); + placed.push(PlacedObstacle { + item_id: t, + rot_idx: r, + tx: x, + ty: y, + }); + } + } + assert!( + checked > 50, + "P1 must actually exercise candidates (got {checked})" + ); + } +} + +/// Exact point-in-shapes test (NonZero irrelevant — canonical shapes are disjoint outer/holes). +fn point_in_ring(ring: &[IPt], p: IPt) -> bool { + let n = ring.len(); + let mut inside = false; + for i in 0..n { + let a = ring[i]; + let b = ring[(i + 1) % n]; + if (a.y > p.y) != (b.y > p.y) { + let lhs = i128::from(b.x - a.x) * i128::from(p.y - a.y); + let rhs = i128::from(p.x - a.x) * i128::from(b.y - a.y); + let crosses = if b.y > a.y { lhs > rhs } else { lhs < rhs }; + if crosses { + inside = !inside; + } + } + } + inside +} + +fn point_in_shapes(shapes: &Shapes, p: IPt) -> bool { + shapes + .iter() + .any(|shape| point_in_ring(&shape[0], p) && !shape[1..].iter().any(|h| point_in_ring(h, p))) +} + +/// P2-lite on the CONCAVE container (the inverted-failure-direction path, docs/03 §2): every +/// CDE-feasible pose on a coarse scan grid must lie inside the IFP dilated by a small L∞ margin +/// (E + quantization slack). A lost IFP loop or an under-permissive IFP fails here. +#[test] +fn p2_concave_container_ifp_not_under_permissive() { + let l_container = vec![ + [0.0, 0.0], + [100.0, 0.0], + [100.0, 50.0], + [50.0, 50.0], + [50.0, 100.0], + [0.0, 100.0], + ]; + let brick = rect(13.0, 7.0); + let (entities, cont, deg, rad) = import(&[brick], &l_container, 0.0); + let cache = NfpCache::build(&entities, °, &rad, &cont).unwrap(); + let layout = Layout::new(cont.clone()); + let item = entities[0].as_ref().unwrap(); + + // Slack: E (16) + representation error; 64 grid units ≈ 6e-5 units is generous and still + // far below the scan resolution. + let slack = 64i64; + #[allow(clippy::needless_range_loop)] // r keys region, rad, and the assertion label together + for r in 0..4usize { + let region = cache.feasible_region(0, r, &[]); + let mut feasible_seen = 0; + for gx in 0..20 { + for gy in 0..20 { + let (x, y) = (5.0 * f64::from(gx) + 2.5, 5.0 * f64::from(gy) + 2.5); + if cde_feasible(&layout, item, rad[0][r], x, y) { + feasible_seen += 1; + // Dilate by checking the four L∞-shifted probes too (any hit counts). + let p = IPt::new(quantize(x), quantize(y)); + let hit = [(0, 0), (slack, 0), (-slack, 0), (0, slack), (0, -slack)] + .iter() + .any(|&(dx, dy)| point_in_shapes(®ion, IPt::new(p.x + dx, p.y + dy))); + assert!( + hit, + "CDE-feasible pose ({x},{y}) rot {r} missing from the IFP region" + ); + } + } + } + assert!( + feasible_seen > 50, + "scan must find feasible poses (rot {r})" + ); + } +} + +/// P8: byte-determinism — two independent builds produce identical regions and candidate walks. +#[test] +fn p8_double_build_is_byte_identical() { + let parts = [rect(13.0, 7.0), rect(10.0, 10.0)]; + let container = rect(60.0, 60.0); + let (e1, c1, d1, r1) = import(&parts, &container, 0.375); + let (e2, c2, d2, r2) = import(&parts, &container, 0.375); + let cache1 = NfpCache::build(&e1, &d1, &r1, &c1).unwrap(); + let cache2 = NfpCache::build(&e2, &d2, &r2, &c2).unwrap(); + assert_eq!(cache1.pair_count(), cache2.pair_count()); + + let placed = [PlacedObstacle { + item_id: 0, + rot_idx: 1, + tx: quantize(20.0), + ty: quantize(20.0), + }]; + for t in 0..2 { + for r in 0..4 { + let reg1 = cache1.feasible_region(t, r, &placed); + 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)), + ); + } + } +} + +/// Cardinal swap/negate vs libm-rotate-then-quantize: ≤ 1 grid unit per coordinate (the +/// review-corrected knife-edge bound; docs/03 §3.1). +#[test] +fn cardinal_swap_negate_within_one_grid_of_libm() { + let pentagon = vec![ + [0.0, 0.0], + [20.0, 0.0], + [20.0, 12.0], + [12.0, 20.0], + [0.0, 20.0], + ]; + let (entities, cont, deg, rad) = import(&[pentagon], &rect(100.0, 100.0), 0.0); + let cache = NfpCache::build(&entities, °, &rad, &cont).unwrap(); + let item = entities[0].as_ref().unwrap(); + + #[allow(clippy::needless_range_loop)] // r keys cache, rad, and the assertion label together + for r in 1..4usize { + let cached = cache.footprint(0, r).unwrap(); + let rot = Transformation::from_rotation(rad[0][r]); + for (i, p) in item.shape_cd.vertices.iter().enumerate() { + let mut q = *p; + q.transform(&rot); + let (lx, ly) = (quantize(q.0), quantize(q.1)); + let c = cached[i]; + assert!( + (c.x - lx).abs() <= 1 && (c.y - ly).abs() <= 1, + "rot {r} vertex {i}: swap/negate ({},{}) vs libm ({lx},{ly})", + c.x, + c.y, + ); + } + } +} + +/// The input-domain gate hard-errors instead of silently degrading (docs/03 §3.1). +#[test] +fn input_domain_gate_errors_on_huge_coordinates() { + // 3e12 units × 2^20 ≈ 2^61.4 grid on the container span — past the 2^61 gate. + let big = 3.0e12; + let (entities, cont, deg, rad) = import(&[rect(big, big)], &rect(big * 2.0, big * 2.0), 0.0); + match NfpCache::build(&entities, °, &rad, &cont) { + Err(NfpError::InputDomain { .. }) => {} + Ok(_) => panic!("expected InputDomain error"), + } +} 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 eeff1e6..1664ff1 100644 --- a/crates/optimizer/tests/snapshots/golden__golden_placements_match_snapshot.snap +++ b/crates/optimizer/tests/snapshots/golden__golden_placements_match_snapshot.snap @@ -1,5 +1,6 @@ --- source: crates/optimizer/tests/golden.rs +assertion_line: 40 expression: run_golden_dump() --- # mixed-rects @@ -91,3 +92,58 @@ unplaced 0 20.00000001436751 0.00000000455378579289345 90 0 10.000000007648957 10.000000008650744 0 unplaced +# multistart-overflow +1 10.000000000012562 0.000000002203314863891137 90 +1 10.000000004042121 20.000000013370197 180 +1 10.000000003104407 10.000000001290292 270 +1 20.000000009716437 10.000000006885912 90 +0 0.000000002428094170170425 20.000000017468803 0 +0 20.000000013186394 13.000000000536026 270 +0 13.000000012612766 20.00000002146205 0 +unplaced 0 0 0 +# nfp-contact-columns +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 +# nfp-concave-pocket +0 0.0000152587890625 0.0000152587890625 0 +1 0.0000152587890625 20.000030517578125 0 +1 8.000030517578125 4.000030517578125 0 +unplaced +# nfp-l-sheet-hole +0 7.0000152587890625 0.0000152587890625 90 +0 7.0000152587890625 13.000030517578125 90 +0 7.0000152587890625 26.000045776367188 90 +0 7.0000152587890625 39.00006103515625 90 +0 14.000030517578125 20.000015258789063 90 +0 14.000030517578125 33.000030517578125 90 +unplaced +# nfp-min-sep +0 7.3750152587890625 0.3750152587890625 90 +0 7.3750152587890625 13.750030517578125 90 +0 7.3750152587890625 27.125045776367188 90 +0 14.750030517578125 0.3750152587890625 90 +1 7.750030517578125 13.750030517578125 0 +1 7.750030517578125 24.125045776367188 0 +unplaced +# nfp-multistart +1 0.0000152587890625 0.0000152587890625 0 +1 0.0000152587890625 10.000030517578125 0 +1 10.000030517578125 0.0000152587890625 0 +1 10.000030517578125 10.000030517578125 0 +0 0.0000152587890625 20.000045776367188 0 +0 27.000045776367188 0.0000152587890625 90 +0 13.000030517578125 20.000045776367188 0 +unplaced 0 0 0 +# nfp-decimated-circle +0 13.062514305114748 13.062514305114757 0 +0 13.062514305114748 38.18742942810059 0 +0 34.70566177368164 25.624972343444835 0 +unplaced diff --git a/crates/py/src/lib.rs b/crates/py/src/lib.rs index dc5180b..1962ea8 100644 --- a/crates/py/src/lib.rs +++ b/crates/py/src/lib.rs @@ -12,9 +12,10 @@ //! byte-identical, cross-platform-reproducible output (proven by the Phase-3 golden). use ironnest_core::{ - 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, + NestConfig, PlacementStrategy, 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, }; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; @@ -115,6 +116,19 @@ fn extract_angles(elems: &[Bound<'_, PyAny>], ctx: &str) -> PyResult> { Ok(out) } +/// Parses the `strategy` kwarg: `"sampling"` (the default engine) or `"nfp"` (the exact-NFP +/// constructive mode, feature #4 — docs/03). Raises `ValueError` on anything else — never a +/// silent fallback (a placement-changing knob must fail loudly). +fn parse_strategy(strategy: &str) -> PyResult { + match strategy { + "sampling" => Ok(PlacementStrategy::Sampling), + "nfp" => Ok(PlacementStrategy::Nfp), + other => Err(PyValueError::new_err(format!( + "strategy must be \"sampling\" or \"nfp\", got \"{other}\"" + ))), + } +} + /// Nest `items` (one outline per part type, each `[[x, y], …]` in item-local coordinates) into a /// single irregular `container` outline. /// @@ -179,7 +193,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, restarts=1))] +#[pyo3(signature = (items, qty, container, holes, min_sep, rotations, seed, budget, restarts=1, strategy="sampling"))] #[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( @@ -193,6 +207,7 @@ fn nest( seed: u64, budget: u64, restarts: usize, + strategy: &str, ) -> PyResult<(Vec, Vec)> { if items.len() != qty.len() { return Err(PyValueError::new_err(format!( @@ -206,6 +221,7 @@ fn nest( // it touches Python objects, so it must run while the GIL is held. The result is a plain, owned // `Vec>` the `Ungil` solve closure can take by reference. let rotations = parse_rotations(&rotations, items.len())?; + let strategy = parse_strategy(strategy)?; // 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 @@ -214,19 +230,34 @@ fn nest( // `Ungil` and this is the textbook PyO3 release pattern. It does NOT touch the determinism // contract: no threads run *inside* the solve (single canonical worker, CLAUDE.md), so output // stays byte-identical. - let solution = py.detach(|| { - // `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 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. + 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( + &items, + &qty, + &container, + &holes, + &rotations, + &NestConfig { + min_sep, + seed, + budget, + restarts, + strategy, + }, + ), + } + }) + .map_err(|e| PyValueError::new_err(e.to_string()))?; let placements = solution .placements @@ -262,7 +293,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))] +#[pyo3(signature = (items, qty, sheets, min_sep, rotations, seed, budget, restarts=1, strategy="sampling"))] #[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( @@ -275,6 +306,7 @@ fn nest_multi( seed: u64, budget: u64, restarts: usize, + strategy: &str, ) -> PyResult<(Vec>, Vec)> { if items.len() != qty.len() { return Err(PyValueError::new_err(format!( @@ -287,6 +319,7 @@ fn nest_multi( // Resolve `rotations` (uniform or per-item) into one set per item while the GIL is held — see the // note in `nest`. let rotations = parse_rotations(&rotations, items.len())?; + let strategy = parse_strategy(strategy)?; let sheets: Vec = sheets .into_iter() @@ -296,15 +329,29 @@ 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. 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( + 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( + &items, + &qty, + &sheets, + &rotations, + &NestConfig { + min_sep, + seed, + budget, + restarts, + strategy, + }, + ), + }) + .map_err(|e| PyValueError::new_err(e.to_string()))?; let per_sheet = solution .per_sheet diff --git a/docs/02-optimizer-and-separation-search.md b/docs/02-optimizer-and-separation-search.md index 7cea982..85159fb 100644 --- a/docs/02-optimizer-and-separation-search.md +++ b/docs/02-optimizer-and-separation-search.md @@ -430,6 +430,90 @@ gap, and best-of-K multi-start closes a chunk of it deterministically. For the c (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. +### 11.2 Round 3 (2026-07-09): Deepnest gap analysis — two levers shipped, one measured dead end + +A source-verified read of Deepnest/SVGnest (exact-NFP contact placement + a GA over insertion order +and rotations, NFP-cached, anytime) framed three near-term levers. All three were implemented and +measured; two shipped, one was reverted with a mechanism-level explanation. + +**Shipped 1 — deterministic early-terminate in the sample evaluator** (sparrow's `upper_bound` +bailout, §4.3, previously forgone). Now that the evaluator sums in canonical sorted-`HazKey` order, +a **strict-greater** bailout on the running partial sum is *provably decision-identical* to the full +sum: terms are strictly positive, so a truncated value (returned only when partial > bound) and the +full value land on the same side of every downstream compare, and any value that is *kept* is an +exact full sum. Bounds plumbed: `BestSamples::upper_bound()` into the sampling loops, the descent's +current eval into the coordinate descent, best-so-far into `lowest_overlap_pose` (plus an +`OverlapScratch` to stop per-sample allocations). **Result:** placements byte-identical (goldens +untouched — the proof the invariant holds), separation-heavy probes ~12–14 % faster (bricks +2423→2094 ms, pentagon 3374→2964 ms, bricks K=8 22.5→19.4 s). The remaining hot cost is +`collect_poly_collisions` itself, which a bound cannot skip without forking deeper into the CDE. + +**Shipped 2 — multi-start order diversification** (`start_order_policy`, `ORDER_JITTER = 0.3`). +Round 2's multi-start varies only the sampling noise; every start still inserts in the same +largest-first order — the one dimension Deepnest's GA searches that we didn't. Now the **trailing +`⌊K/2⌋` starts** re-sort with seeded multiplicative noise on the diameter keys (drawn ascending-id +from the start's own PRNG, before any placement draw); the leading `⌈K/2⌉` — including `k = 0`, so +`n_starts == 1 ≡ nest()` — stay canonical. Two split designs were measured: +- **Splitting at all matters**: jittering *all* `k > 0` regressed (order noise replaced the proven + sampling-noise breadth; mixed 4-type K=8 94.2 → 93.2 %). +- **Contiguous blocks beat k-parity interleaving.** Parity ("odd k jitter") aliases the canonical + half with every-other absolute seed stream, so for a fixed base seed half the canonical streams + are unreachable at *any* K — measured on the consumer corpus (below) as a floor drop no K could + repair. The block split keeps the canonical streams contiguous from `seed`, so + `nest_multistart(seed, 2K)` provably contains every canonical stream of `nest_multistart(seed, K)` + — **the old best-of-K is a floor for the new best-of-2K**. + +On 5 base seeds at K=8 (`examples/orderdiv.rs`, block policy): similar-diameter corpus 94.22 → +93.83 % (−0.4 pp); **diameter-misleading strips corpus 94.15 → 95.45 % mean, best seeds 94.8 → +95.9 %** — order jitter pays exactly where largest-diameter-first is a trap (long skinny parts +out-diameter compact high-area parts), at little cost elsewhere. The per-start wiring lives in one +place (`multistart_start`, a doc(hidden) test hook), so the sequential and `parallel` builds cannot +drift. Cross-platform byte-stability of the jittered path is pinned by the new +**`multistart-overflow` golden case** (overflow demand; the best-of-3 winner is the k=2 *jittered* +start, per-start areas [646, 646, 673]) — the pre-existing `multistart-mixed` case all-places and +ties, so it can only ever exercise k=0. + +**Consumer-corpus measurement** (`drawing_and_gcode` `tools/nest_benchmark.py --mixed` semantics: +13-type realistic cut list, 120×60 sheet, 1.3× over-subscribed, `part_gap 0.375` / `edge_margin +0.5`, cardinal rotations, budget 500, base seeds 1–4 — the #258 acceptance harness; Python BLF +baseline 75.5 %, rectpack floor 67.3 %): + +| config | per-seed utilization (seeds 1–4) | mean | wall-clock/nest | +|---|---|---|---| +| v0.4.1, `restarts=8` (current pin) | 82.76 / 82.76 / 82.76 / 82.76 | 82.76 % | 214–236 s | +| this round, `restarts=8` | 81.58 / 81.58 / 82.47 / 83.66 | 82.32 % | 177–204 s | +| this round, **`restarts=16`** | 83.66 / 83.66 / 83.66 / 82.76 | **83.44 %** | 311–345 s | + +v0.4.1's all-canonical best-of-8 **saturates at 82.76 % on every seed** — more canonical breadth +cannot move it. At the same K=8 the split halves canonical coverage, so the floor can dip (81.58 %); +at **`restarts=16`** the contiguous canonical block covers exactly v0.4.1's 8 streams, so its +82.76 % is the guaranteed floor (measured: held on all 4 seeds) and the 8 jittered starts add the +upside (83.66 % — the harness's all-time best — on 3 of 4 seeds): **+0.68 pp mean, +0.9 pp typical, +at ~1.45× the old wall-clock** (the early-terminate speedup funds part of the doubling). **Ship the +engine bump and `DEFAULT_RESTARTS = 16` together** — the new engine at the old K=8 pin is a small +mean regression, the pair is a strict improvement. + +**Reverted — the compression phase** (sparrow's compress loop, the §4.5 adaptation this doc +recommended trying gently after §11's 18 %-at-once failure). Implemented as a stepping virtual-wall +keep-out (2 % of occupied width per step, walled twin container per step, separate, adopt-on- +feasible, then refill via the insertion driver; fixed rounds/steps). **Zero density change on every +probe** — rectangles, bricks, pentagon, the mixed 105 % corpus, and an L-shaped-container probe +(internal-fragmentation case), across seeds — at 2–3.5× the runtime (bricks 2.1→7.0 s, mixed K=1 +3.6→12.7 s). *Mechanism, not tuning:* in a **fixed bin**, `sep::try_insert` already seeds each +unplaced part at its lowest-overlap pose and lets the GLS move **every** neighbour under strike +pressure — a part-sized, perfectly-targeted squeeze. An untargeted global wall squeeze is a strictly +weaker version of the mechanism that already ran, and its freed edge slice at 92–96 % util is always +sub-part-sized (our objective is discrete part admission, not sparrow's continuous strip width — the +squeeze only pays in part-sized quanta that don't exist at these utilizations). This also explains +§11's place-all-then-separate regression from first principles. **Do not re-try wall/shrink +compression in the fixed-bin engine.** + +**Standing conclusion.** With breadth (multi-start × order diversification) and the evaluator bailout +banked, the remaining Deepnest-class density gap is the §11 architectural one: **exact-NFP contact +placement** (integer-arithmetic Minkowski on discrete rotations — Deepnest's own robustness answer, +and *more* cross-platform-deterministic than floats, not less) and part-in-hole nesting. That is a +build, not a tune — spec it against the consumer's `nest_benchmark.py --mixed` before committing. + ## References - sparrow — `https://github.com/JeroenGar/sparrow` · paper arXiv `2509.13329`. - jagua-rs CDE — DOI `10.1287/ijoc.2024.1025` · arXiv `2508.08341` · diff --git a/docs/03-nfp-construction-mode.md b/docs/03-nfp-construction-mode.md new file mode 100644 index 0000000..bbcaa8a --- /dev/null +++ b/docs/03-nfp-construction-mode.md @@ -0,0 +1,670 @@ +# ironnest exact-NFP construction mode — design & build plan (feature #4) + +**Status (2026-07-10): N1a ✅ · N1 ✅ (§11.2) · N2 ✅ (§11.3) · N3 ✅ (§11.4) · N4 ✅ core +(§11.5) — ACCEPTANCE VERDICT: SHIP OPT-IN** (+0.61 pp mean over sampling at 1.02× wall-clock on the +consumer corpus, floor held on every seed, 84.03 % all-time best; wheel ships +`strategy="sampling"|"nfp"`). Remaining follow-ups: consumer adapter wiring (`drawing_and_gcode`), +ESICUP tracker corpus, verify-rejection counter surfacing, post-sep NFP refill (the GO lever). This +document is the source of truth for the opt-in exact no-fit-polygon (NFP) constructive placement +mode. Produced by a five-axis research pass (Rust ecosystem survey with source-level dependency +audits, NFP algorithm literature + reference-implementation reads, a file:line integration map of +this repo, a determinism/numerics spec, and a validation/acceptance protocol), then revised against +a four-critic adversarial panel whose confirmed findings are folded in below (the two largest: the +union-fill needs quad orientation-normalization or winding cancellation under-fills concave NFPs — +§3.2; and the acceptance framing must budget *construction* time, because the separation tail +dominates wall-clock — §10). Read [`00-…`](00-ironnest-architecture-and-plan.md) and +[`02-…`](02-optimizer-and-separation-search.md) (§11.2 is the motivating evidence) first. + +> **One-line orientation for a fresh session:** both existing search axes (sampling noise × +> insertion order) saturate at ~83–84 % on the consumer acceptance harness; the remaining gap lives +> in the *constructive* phase. This feature replaces it — opt-in, default-off — with Deepnest-style +> exact contact placement: feasible region = IFP ∖ ⋃NFP in **scaled integer arithmetic** +> (byte-deterministic by construction), candidates = region vertices (exact for our linear loss), +> the CDE stays the feasibility arbiter. The density thesis is falsifiable early and cheaply +> (§11 N0); the honest headline risk is not correctness but **value** — §10. + +--- + +## 1. Why NFP, and why now + +- **The saturation evidence (docs/02 §11.2).** v0.4.1's all-canonical best-of-8 scores exactly + 82.76 % on every base seed of the consumer harness; order diversification lifted the ceiling to + 83.66 % (restarts=16 mean 83.44 %). Every other §11 lever measured dead. +- **The Deepnest gap analysis (2026-07-09, source-verified).** Deepnest's density edge is exact NFP + contact placement: every candidate is a touching pose on the true feasible-region boundary, + computed once per (pair, rotation) and cached. +- **NFP is a density + consistency + per-placement-cost lever**: the constructive pass becomes + (near-)seed-independent, and each placement costs one boolean difference + vertex scan instead of + `budget` CDE queries. +- **Why the docs/02 §7.2 "avoid NFP first" verdict flips.** That verdict targeted *float orbiting* + NFP — genuinely a minefield (§3.3). Integer convolution (Boost.Polygon → Deepnest's + `minkowski.cc`, in production since 2016) is a different animal: pure integer arithmetic after + one deterministic snap is **more** cross-platform-deterministic than our float paths. +- **The honest counter-evidence (from the adversarial review, kept on purpose).** docs/02 §11.1's + B3 negative result — structured contact-anchor candidates changed *nothing*, because + `place_dropped`'s slide already snaps loose samples to axis-reachable contacts. NFP's genuinely + new placements are the **non-axis-reachable pocket contacts** (poses needing simultaneous-x/y + entry), whose share of real corpora is unquantified. And sparrow reaches ESICUP SOTA with + sampling+GLS, *no* NFP construction; Deepnest's edge was measured against engines without a GLS + separator. **Therefore the density thesis must be falsified or confirmed in N0 for ~2–4 days of + work, before the main spend** (§10, §11). + +**Non-goals:** part-in-hole nesting (a follow-on that reuses this substrate — the NFP output's +holes already encode it), continuous rotation, any change to sampling-mode behavior (byte-frozen), +any machine/kerf concept (prime directive #2). + +--- + +## 2. Decision summary + +| Decision | Choice | Rejected alternatives (why) | +|---|---|---| +| Boolean engine | **`i_overlay` = 7.0.2, integer API, i64 engine** (+ MIT family: i_float, i_shape, i_tree, i_key_sort), exact-pinned; **`Solver` strategy AND `Precision` pinned explicitly as part of the determinism contract** | clipper2 crates (C++ FFI/build.rs); geo BooleanOps (float wrapper + dropped dep tree); iron-shapes-booleanop (AGPL); writing our own (~17k-LOC class) | +| NFP algorithm | **Convolution union-fill with orientation-normalized pieces** (§3.2): every edge-pair quad and seed translate normalized to CCW by exact i128 shoelace *before* the union — an honest set union of true-NFP subsets, provably complete for hole-free simple pairs; **convex×convex O(n+m) edge-merge fast path** | Raw signed-winding quad soup (winding cancellation between CW/CCW quads under-fills concave NFPs — confirmed by worked counterexample in review); orbiting (§3.3); Ghosh/Bennell–Song tracing (libnest2d abandoned it); decomposition-first (same engine dep, worse output; fallback only) | +| IFP | **Frame trick** (bbox frame, container as hole → outer NFP → holes are the IFP) | Direct erosion via complement (unbounded complement, second winding convention) | +| Numerics | **Fixed global power-of-two grid 2^20/unit, i64 coords, i128 for ALL self-written predicates**; input domain validated with explicit headroom (§3.1) | Fixed decimal 1e7 (two lossy steps + i64 predicate overflow); adaptive per-call scale (cache incoherence, non-uniform ε) | +| Contact policy | **Fixed integer backoff `NFP_BACKOFF_E` (initial 16 grid units, sized empirically in N1 — §4)** as ONE L∞-square inflation of the moving footprint per (type, rot) | Per-candidate nudging (ambiguous direction); changing CDE touch semantics (`edge.rs` closed-interval predicate protects hazard-scope semantics engine-wide) | +| Candidates | **All vertices of the boolean-difference output**, integer loss `10·X + Y`, lexicographic `(cost, rot_idx, X, Y)` tie-break; first `feasible_at`-accepted wins; **sampling fallback (search + place_dropped, verbatim today's path) on empty region** | Edge sampling (vertex optimality is exact for a strictly linear loss — §5.2); raw input vertices (miss boolean-created crossings) | +| Cache | **Items imported ONCE per public call** (hoisted out of `nest_core` — §6), cache built before `run_starts` forks, `BTreeMap` keyed by rotation **indices**; point-reflection symmetry (mirrored entries **derived by negation, never recomputed**); region queries = **one flat multi-path union from pristine cached NFPs** per query (never re-union snapped output) | Per-start lazy cache (K× compute); incremental re-union of snapped accumulators (iterated snap-rounding has no additive error bound) | +| Mode switch | **`NestConfig` struct** (strategy + future knobs); the existing 8 public fns become thin frozen wrappers — a committed N2 deliverable, not an aside | 8 more fn variants (combinatorial explosion); parameter breaking all 8 signatures without consolidation | +| Arbiter | **Unchanged: the CDE**, via `feasible_at`; deterministic next-candidate on rejection; rejection counter surfaced (§10 gates it *on the acceptance harness* at 0) | Trusting the region (violates the advisory-signal + exact-arbiter pattern, `sep/mod.rs`) | + +**The safety property, stated precisely** (the review corrected the original over-claim): for +shapes entering the region difference **negatively** (obstacle and hole NFPs), any construction +error is an under-filled NFP ⇒ an over-permissive region ⇒ bad candidates the CDE rejects — +density loss, never overlap. For the **IFP** (which enters positively), under-fill of NFP(frame,·) +propagates as an *under*-permissive or lost IFP loop — silent density loss with **zero rejection +signal**; the backstop there is the sampling fallback, and the validation covers it directly +(P2/P6 on concave containers, §9.1). With orientation normalization (§3.2) both directions reduce +to snap-rounding effects only. + +--- + +## 3. The geometry substrate (`crates/optimizer/src/nfp/`) + +### 3.1 Numerics spec — frames, scale, and bounds + +**Coordinate frames (pinned; the repo has two and the review caught the gap):** items are +centroid-centered at import (`io/import.rs` applies `centering_transformation`); **containers are +not** (`pre_transform = DTransformation::empty()`). All NFP-module geometry lives in the +**container-native CDE frame**: cached NFPs are translated by placed items' internal +`d_transf.translation()` — **never** by the public `Placement (x, y)`, which folds the centroid +back out through a libm rotation (`search::original_to_placed`). Candidate poses produced by this +mode ARE `d_transf` translations, so they feed `Layout::place_item` directly. + +```rust +pub type IntCoord = i64; // engine + our coordinates +const NFP_SCALE: f64 = 1_048_576.0; // 2^20 grid units per input unit — FIXED, GLOBAL +const NFP_INV_SCALE: f64 = 1.0 / NFP_SCALE; // 2^-20, exactly representable +const NFP_BACKOFF_E: i64 = 16; // contact backoff, grid units (§4; empirically re-sized in N1) +const NFP_FRAME_MARGIN: i64 = 64; // frame-trick ring width, grid units +fn quantize(v: f64) -> i64 { (v * NFP_SCALE).round() as i64 } +fn unquantize(n: i64) -> f64 { (n as f64) * NFP_INV_SCALE } +``` + +**Quantization sites — exactly two** (rule §8-2): (1) shape vertices, once per (type, rotation) at +cache build, from the same f64 `shape_cd` collision shapes the CDE uses (post min_sep/2 inflation, +post decimation); (2) **obstacle poses**, once per off-grid pose — poses placed by this mode are +grid-exact by construction, but **sampling-fallback placements are arbitrary floats** and must be +quantized when they enter region queries (their ≤ 0.71g error is a budget line in §4). + +**Input-domain validation with explicit headroom.** Worst-case coordinate entering a boolean op = +container extent + part extent + E + frame margin + pose translation. The validation therefore +checks the **derived worst case**, not a bare input bound: +`max_scaled_op_coord = S·(container_span + 2·max_part_span) + NFP_BACKOFF_E + NFP_FRAME_MARGIN`, +required `< 2^61` (headroom argument below), with a practical input gate of `max|coord| ≤ 500` +units on the container-native frame (hard error — mode fallback would create density cliffs). A +`debug_assert!` on `max|coord|` guards **every boolean-op entry**. + +**Bounds, done honestly** (the original sketch failed at its own boundary — review finding, all +four critics): the oft-quoted `< 2^30` input bound in i_overlay's `split/cross_solver.rs` is +calibrated for the **i32 engine** (whose `Wide` is i64). The **i64 engine** computes intersections +in i128/u128-composite arithmetic; its effective coordinate bound is ~2^62. At S = 2^20 and a +500-unit domain: inputs ≤ 2^29; Minkowski sums ≤ 2^30 + E + margin; pose-translated NFPs ≤ +~1.5·2^30 ≈ 2^30.6 — all with **> 30 bits of headroom** against 2^62. Our own predicates: coordinate +differences reach ~2^31.6, single cross products ~2^63.2, and orient2d is a **difference of two +such products, i.e. up to ~2^64 — overflowing i64**; hence the blanket rule (§8-4): **every +self-written geometric predicate accumulates in i128.** N0 verifies the i64-engine bound claim +empirically with boundary-magnitude inputs before anything else is built. + +**Which polygons:** post-import `shape_cd` (min_sep/2-inflated, decimated when applicable) — never +raw input rings — so all existing min_sep/decimation guarantees inherit and the arbiter sees the +same geometry. Contact of two inflated CD shapes ⇒ original outlines exactly `min_sep` apart. + +**Rotations:** cardinal orientations of the quantized 0° footprint by **exact integer swap/negate**; +non-cardinal via libm-rotate (existing rule) → quantize. The swap/negate vs rotate-then-quantize +discrepancy is **≤ 1 full grid unit per coordinate** (knife-edge rounding near half-grid values — +the review's counterexample corrected the earlier ½g claim), absorbed by E. Cache keys use +rotation-set **indices**, never float degrees. + +### 3.2 NFP construction (orientation-normalized convolution union-fill) + +Port of Boost.Polygon's `convolve_two_polygon_sets` semantics (Deepnest's `minkowski.cc:20-92`), +**with one mandatory correction found in review**: + +1. For every edge pair (eA ∈ A-cycles, eB ∈ (−B)-cycles): emit the 4-point segment-sum quad + `{a1+b2, a1+b1, a2+b1, a2+b2}`. Holes participate as extra cycles. +2. **Orientation-normalize every quad (and seed polygon) to CCW via exact i128 shoelace sign + before insertion.** Without this, concave inputs emit both CW and CCW quads whose overlap nets + winding 0 under NonZero fill — an under-filled NFP interior in exactly the pocket-bridging + poses this feature exists to handle (worked counterexample in the review: a notched part × a + bar; the two notch-wall quads cancel over the bridge zone). Degenerate/bowtie quads from + parallel edges are normalized or dropped by exact zero-area test. +3. Seed interiors: translated copy of A at (−B)'s first vertex and of −B at A's first vertex + (CCW-normalized like everything else). +4. One `Overlay::overlay(Union, NonZero)` on i64. Output = NFP as polygons **with holes** + (an NFP hole = poses where B nests inside a pocket of A — the future part-in-hole substrate). + +With normalization, the overlay is an honest **set union of pieces that are each subsets of the +true NFP** — so it never over-approximates, and completeness for **hole-free simple pairs is +provable** (case split: boundary-contact poses are covered by some edge-pair quad; B-inside-A poses +are covered by the A-translate seed; A-inside-B poses by the −B-translate seed; no other +non-overlap-free pose class exists for connected simple polygons). Holed inputs keep the empirical +differential coverage (P3/P10). + +**Fast path:** convex×convex NFP via O(n+m) sorted-slope edge merge (no boolean). Convexity +predicate on **quantized** vertices: no negative i128 cross, zero-crosses (collinear triples) +merged; borderline-after-quantization shapes route deterministically to the boolean path. Fast-path +output passes through the same canonicalization as boolean output (§3.4). + +**IFP (frame trick):** rectangle frame = container bbox expanded by `NFP_FRAME_MARGIN`, container +as its hole; outer NFP(frame, B⊕□E) via the same routine; the result's **holes are the IFP loops**. +Container keep-out holes (quality-0 zones) each contribute an ordinary obstacle NFP. Note the +inverted failure direction here (§2) — covered by P2/P6-concave. + +### 3.3 Why not orbiting (unchanged; receipts in the research record) + +SVGnest ships an explicit NFP-null failure path; libnfporb's README disowns its own robustness; +libnest2d abandoned Bennell–Song mid-implementation (its consumer packs convex hulls to this day). +Tolerance-driven f64 control flow is the worst determinism fit, and the region difference needs a +boolean engine anyway. **Closed.** + +### 3.4 Canonicalization (every NFP from ANY path, before caching or consuming) + +i_overlay's output ordering is deterministic today but **uncontractual**; the fast path has no +inherent convention either. Canonical form: (a) winding by exact i128 signed area (outer CCW, holes +CW); (b) each ring reduced to its **lexicographically-least rotation** (Booth's algorithm — plain +"start at the minimal vertex" is ill-defined for pinched rings that visit their minimal vertex +twice, a real output class of snap-rounded NonZero unions); (c) rings sorted by (min-vertex, +length, lexicographic compare); (d) zero-area rings dropped (exact test). + +### 3.5 The `i_overlay` dependency (audited 2026-07-10) + +Source-audited at 7.0.2: `#![no_std]`; zero std HashMap/HashSet in library code; zero floats in the +integer pipeline; float-API transcendentals via libm 0.2.16 (the exact version already in our +lock); rayon only behind the off-by-default `allow_multithreading` feature (CI asserts it stays out +of the resolved feature set); RNG in dev-deps only; `sort_unstable` on integer keys (equal-key +order can shift across toolchain bumps — covered by the pinned-toolchain re-bless policy). MIT OR +Apache-2.0 family; the engine behind geo's `BooleanOps`. Pure Rust, no build.rs → wheel-clean. + +**Two contract items the review added:** (1) the `Solver`'s **`Precision` is pinned explicitly** — +its default escalating endpoint-snap (radius doubling per split round) can displace crossing +vertices by 2^(L/2) grid units on pathological inputs, which is why the §4 error budget is +*measured*, not assumed, and why N0 tests whether a non-escalating precision setting terminates on +convolution quad soups; (2) version bumps are re-bless events (like toolchain bumps); vendoring the +~34k-LOC family is the escalation path (precedent: `crates/geo/src/buffer/`). **MSRV floor rises +1.87 → 1.88** (docs + `rust-version`; the pinned 1.96 toolchain is unaffected). + +--- + +## 4. The epsilon-contact policy + +**Problem.** The CDE counts touching as collision (closed-interval edge predicate — verified; +protects hazard-scope semantics engine-wide and is not to be touched), and quantization/snap noise +straddles the true boundary. Unbacked-off exact-contact candidates would be rejected erratically. + +**Mechanism — one inflation, both effects.** Inflate the **moving part's** integer footprint by the +L∞ square □E once per (type, rot) *before* all NFP/IFP computation. By Minkowski associativity and +□E's symmetry (and since −(B ⊕ □E) = (−B) ⊕ □E): every obstacle NFP inflates by E and the IFP +deflates by E, with unchanged cache keys and no per-region offset op. + +**The error budget — stated as measured-not-proven** (the review dismantled the earlier "≤ 3g +proven" claim): identifiable additive terms are vertex quantization (≤ 0.71g per shape → ≤ 1.42g +after Minkowski), boolean crossing snap (≤ ~1g per boolean pass — one for the NFP union, one for +the region difference), cardinal swap/negate discrepancy (≤ 1g), off-grid fallback-pose +quantization (≤ 0.71g) — **≈ 3.6–4.6g worst case, PLUS i_overlay's escalating endpoint-snap, which +has no closed-form bound on adversarial inputs.** Therefore: (a) region queries are **one flat +multi-path union from pristine cached integer NFPs** — never an iterated re-union of snapped +output, killing the unbounded-drift path; (b) a **displacement-measurement property test** (region +boundary vs exact-rational reference over the seeded corpus) empirically sizes the real error, and +`NFP_BACKOFF_E` is set ≥ 4× the measured max (initial value 16; density cost of even E = 256 is +< 11 mil across a 120-unit row — far below harness resolution, so raising E is always available). + +**Guarantees (conditional on the measured E):** candidates sit ≥ E − (measured error) inside the +true feasible set ⇒ `feasible_at` acceptance is structurally expected; the CDE remains the arbiter +and rejections advance deterministically to the next candidate, **counted** (§10 gates the counter +at 0 on the acceptance harness; elsewhere a nonzero count is a release-blocking investigation +trigger, not an automatic feature kill — the review's tail-event argument). Separation floor: +placed outlines ≥ min_sep apart exactly as today, plus the residual backoff — the consumer's +0.375-in gap / 0.5-in margin re-validation is strictly *more* satisfied. Exact-fit artifact: +narrowed (10×10 ×100 in a 100.001 container places 100), not eliminated (exactly-100.0 still loses +a column) — documented. + +--- + +## 5. The placer + +### 5.1 Region assembly (per part, per allowed rotation) + +``` +feasible(B@rot) = IFP(container, B⊕□E @rot) // cached per (type, rot); holes folded in + ∖ ⋃ⱼ (NFP(placedⱼ, B⊕□E @rot) + d_transfⱼ.translation) // ONE flat union per query +``` + +Per query: gather the pristine cached NFPs of all placed obstacles, translate each by its placed +pose (grid-exact for NFP-placed parts; quantized-once for fallback-placed parts), and run **one** +multi-path union + difference. (The earlier incremental snapped-accumulator design is rejected — +iterated snap-rounding has no additive error bound. If profiling ever wants incrementality back, it +must cache *pre-union piece lists*, not snapped results.) Cost control comes from the convex fast +path, the per-(type,rot) reuse of cached NFPs, and — if measured necessary — a bbox prefilter +(obstacles whose inflated-NFP bbox cannot intersect the current best-candidate half-plane). + +### 5.2 Candidate selection (exact for our loss) + +`LbfLoss = 10·x_max + y_max` is strictly linear in translation at fixed rotation ⇒ the minimum over +the region is at a **vertex of the boolean-difference output** (including boolean-created crossing +vertices; hole-loop vertices can never win but are harmless to include). Integer loss +`10·(X + bbox_x_max_int(rot)) + (Y + bbox_y_max_int(rot))`, argmin tie-break lexicographic +`(cost, rot_idx, X, Y)`. Walk ascending; first `feasible_at`-accepted pose is placed **directly via +`place_item` — NFP placements bypass `place_dropped` entirely** (the slide would burn 60–240 CDE +queries per placement re-deriving a contact the region already encodes, smear the deliberate E +backoff, and turn grid-exact poses into floats — three of this doc's claims died in review until +this was made explicit). Empty region, or all candidates rejected (counted) ⇒ **fall back to +today's sampling path verbatim** — `search::search` + `place_dropped` — for that part; fallback +parts keep the slide because they carry no NFP backoff. + +### 5.3 Pipeline changes in NFP mode + +- `constructive_fill` / refill: strategy-dispatched placer. **Exactly one refill pass** (compaction + is skipped, so later rounds could differ only through sampling-fallback luck; today's + `IMPROVE_ROUNDS = 3` loop degenerates to cost). +- **Slide compaction skipped**; `sep::run_separation` runs **unchanged** on the unplaced tail + (post-separation NFP refill: recorded future option). +- **PRNG invariant, stated correctly** (the review killed the original "zero PRNG" claim): NFP + construction consumes PRNG **only through the sampling fallback**, and the separation tail + consumes it per start regardless — on over-subscribed inputs (the acceptance harness is 1.3× + over-subscribed) canonical starts do **not** collapse; they diversify through the seeded tail + exactly as today. Fallback triggering is itself a pure function of byte-deterministic integer + regions + CDE verdicts, so cross-platform stream alignment is not at risk. +- **Multi-start policy: keep the proven block split as the default** (canonical block first, + jittered block second — canonical starts still diversify via the seeded fallback + sep tail). + The earlier "k=0 canonical, all k>0 jittered" proposal was reasoned from the false collapse + premise and is structurally the configuration docs/02 §11.2 measured as a regression. N3 runs + the A/B (block split vs all-jittered vs a candidate second axis — a seeded permutation of the + candidate walk among the top-m tied-or-near candidates, integer + seeded + deterministic) on the + over-subscribed harness and keeps the winner. **The NFP-mode per-seed floor is empirical** — the + sampling-mode structural floor argument does not transfer; §10 requires the floor to hold on all + acceptance seeds at the calibrated K. + +--- + +## 6. Cache design & the import hoist + +**The structural fix found in review:** `shape_cd` footprints exist only after import, and import +currently runs **inside `nest_core`, per start** — so "build the cache before `run_starts` forks" +was impossible as written. The plan is therefore: **hoist the import** — items + container imported +**once per public call**, `entities` (and the importer's decimation config) passed into `nest_core` +by reference. This refactor is placement-inert by construction (imports are deterministic and +identical per start today) and must be proven inert by the untouched goldens **in its own +preparatory PR** before any NFP code lands (N1a, §11). It also removes today's K× redundant import +work — a small unconditional win. The cache is then built once (ascending key order), passed as +`&NfpCache` (no interior mutability ⇒ `Sync`) through **both** `run_starts` builds into +`multistart_start` → `nest_core` — and through the multi-sheet chain +(`nest_multi_multistart_per_item` shares pair-NFPs across sheets; IFPs are per-sheet). + +- **Key**: `(type_a: u16, rot_idx_a: u8, type_b: u16, rot_idx_b: u8)` in a `BTreeMap`. Mirrored + entries **derived by point reflection at insert time, never recomputed** (recomputing the swapped + direction is not bit-guaranteed to match — different seed translates + snap; deriving is exact + and safe). Day-one storage for the mixed harness: 52 footprints ⇒ **1,378 stored pair entries** + (= (2704 − 52)/2 + 52) + 52 IFPs — the review corrected the earlier 676 figure, which assumed the + deferred relative-rotation reduction. ~1–4 MB typical. +- **CLAUDE.md amendment (goes with §8):** the sanctioned parallel exception's wording extends from + "no shared state" to "no shared **mutable** state; the read-only `NfpCache` is built before the + fork and crosses threads as `&NfpCache` (`Sync`)". +- **Budget gate:** cache build ≤ 5 % of a budget-500-equivalent nest on the mixed corpus (the + consumer calls `nest()` several times per job). + +--- + +## 7. API surface, end to end + +**The committed decision (was an aside; the review made it load-bearing):** introduce a +**`NestConfig`** struct (strategy + seed/budget/restarts consolidation point for future knobs); the +existing 8 public fns are kept as **thin frozen wrappers** with byte-identical behavior. This is an +N2 deliverable with its own LOC line and an enumerated call-site inventory (golden_dump, tests, +4 examples, facade, py). **`crates/py` must compile at every phase boundary** — note explicitly: +`ci.yml` excludes `ironnest-py` from clippy/test, so a broken py crate is invisible until +`wheels.yml`; the phase checklists include a local `cargo check -p ironnest-py`. + +| Layer | Change | Notes | +|---|---|---| +| `crates/optimizer` | `NestConfig` + `PlacementStrategy`; `nfp/` module (`mod.rs` placer+region, `minkowski.rs`, `cache.rs`); import hoist (§6); **error channel**: the strategy-bearing entry points return `Result` (input-domain hard error); sampling-mode signatures untouched | `multistart_start` (doc-hidden test hook) and `tests/nest.rs` + `bin/golden_dump.rs` call sites update in lockstep — mechanical, output-inert, listed in the N2 diff inventory | +| `crates/ironnest` | re-export `NestConfig` + new entry | | +| `crates/py` | `strategy="sampling"` kwarg on **both** pyfunctions (`nest`, `nest_multi`); unknown value or domain error → `ValueError` | default byte-identical | +| consumer | `IronNester(strategy=…)`; append-only audit key `"placement_mode"` (only when non-default); **version-floor precedent** (`restarts=` — a new kwarg TypeErrors on old wheels; the functional-probe pattern is for input-shape changes, not kwargs) | follow-on `drawing_and_gcode` issue | +| `budget` semantics | drives **only** the sampling fallback and refill; the separation tail runs on its own fixed internal budgets (unchanged — the earlier wording was wrong); recorded as-is in the sidecar | | + +--- + +## 8. Determinism-rules addendum (paste into CLAUDE.md when N1 lands) + +> **Integer-NFP rules (feature #4):** +> 1. **One global grid.** `NFP_SCALE = 2^20`, `IntCoord = i64`. Never derive scale from input; +> never quantize twice. +> 2. **Exactly two quantization sites**: shape vertices at cache build (from the same f64 collision +> shapes the CDE uses) and off-grid obstacle poses (once per pose). No other float↔int +> conversion exists in the NFP module. +> 3. **Cardinal rotations are exact integer swap/negate** on the quantized 0° footprint; +> non-cardinal go libm-rotate → quantize. Cache keys use rotation **indices**, never floats. +> 4. **No float between quantization and unscaling.** Every self-written predicate accumulates in +> **i128**. Candidate loss is integer `10·X + Y`, tie-break lexicographic `(cost, rot_idx, X, Y)`. +> 5. **iOverlay i64 engine only, with `Solver` strategy and `Precision` pinned**; +> `allow_multithreading` stays off forever (CI-asserted); iShape family bumps are re-bless events. +> 6. **Canonicalize every NFP from any path** (winding by exact area; rings to lexicographically- +> least rotation via Booth; rings sorted; zero-area dropped). Mirrored cache entries are derived +> by negation, never recomputed. +> 7. **The CDE stays the feasibility arbiter**: every chosen pose passes `feasible_at`; rejection ⇒ +> deterministic next candidate, counted. Region queries are one flat union from pristine cached +> NFPs — never re-unions of snapped output. +> 8. NFP mode validates the **derived worst-case boolean-op coordinate** (container + 2·part + E + +> frame margin) against the engine bound with explicit headroom, and hard-errors otherwise. +> `NFP_BACKOFF_E` is a fixed integer sized ≥ 4× the *measured* boundary-displacement bound. +> 9. **Parallel-exception amendment**: no shared **mutable** state; the read-only `NfpCache` is +> built before the fork and crosses threads as `&NfpCache` (`Sync`). + +--- + +## 9. Validation + +### 9.1 Property tests (`tests/nfp_props.rs`; seeded PCG64 only) + +- **P1 Soundness**: every region vertex + seeded interior points ⇒ `feasible_at` accepts. Failure + attribution: E vs the *measured* displacement bound (§4), not a paper constant. +- **P2 Completeness**: CDE-accepted seeded poses lie in the region dilated by □(E + measured + bound + 4). **Run explicitly on concave containers and holed containers** — this is the test + that catches the inverted IFP failure direction (§2). +- **P3 Pair ground truth**: point-in-NFP ⇔ shapes overlap at that offset (boundary band exempt), + convex and concave seeded pairs. +- **P4 Convex reference oracle — run twice**: once against the production fast path, and once with + the fast path force-disabled so the **convolution union-fill** is checked against the exact + edge-merge oracle on convex pairs (otherwise the code with the completeness risk gets zero exact + coverage — review finding). +- **P5 Symmetry/rotation identities — scoped**: exact on the fast path; equality-modulo-snap + (symmetric-difference area ≤ tolerance) on the boolean path; the shipped cache *derives* mirrored + entries, so P5 is a diagnostic, not a shipping invariant. +- **P6 IFP exactness**: rectangles (exact bbox-shrink oracle) **and concave L-containers** + (seeded CDE-verified interior poses must lie in the extracted IFP). +- **P7 Monotonicity + convex area inequality.** **P8 Loss-vertex optimality + tie determinism.** + **P9 Degenerate geometry** (exact-fit slivers, collinear/duplicate vertices, pinched rings, + holes touching boundaries, part == container). +- **P11 Displacement measurement** (new, load-bearing): max region-boundary displacement vs an + exact-rational reference over the corpus → sizes E (§4). +- **P10 (dev-side, per release)**: shapely/GEOS + pyclipper differentials. +- Round-trip ≤ ½g; swap/negate vs libm-rotate ≤ **1g** (knife-edge corrected). + +### 9.2 Golden additions (append-only; 3 OS × debug/release/parallel) + +N2: `nfp-concave-pocket` · `nfp-l-sheet-hole` · `nfp-min-sep` · `nfp-decimated-circle` · +`nfp-contact-columns` (10 columns of 10×10 in a 100.01-wide sheet — the density signature AND the +ε-policy sentinel). N3 (with the policy it exercises): `nfp-multistart` (restarts=3, +jittered-start winner, run under `--features parallel` too — landing it in N2 would guarantee an +intra-feature re-bless when N3's policy lands; review caught the phase mismatch). Feasibility is +asserted via `debug_assert!(layout.is_feasible())` **inside `nest_core`'s NFP path** (the dump +binary only sees `NestSolution` — the earlier "assert in golden_dump" had no access path); CI's +debug leg executes it. Optionally golden 2–3 canonicalized integer NFP vertex lists (localizes any +x-platform divergence to construction vs search). + +### 9.3 ESICUP corpus (context + regression tracking — never the gate) + +Commit the 13 discrete-rotation academic instances **re-derived from the CC0 originals at +`github.com/ESICUP/datasets`** (using sparrow's MIT-licensed jagua-JSON conversions requires +carrying its notice — the provenance README states whichever source is actually used; review +caught the mismatch), plus `examples/esicup.rs` (deterministic fixed-iteration bisection on strip +length; `swim` pre-scaled ×1/16 for the input domain). **`package.exclude` the corpus** in the +owning crate's Cargo.toml so wheels/sdists don't ship it; note the data-licensing caveat against +CLAUDE.md's "MPL uniform" line in the README. sparrow's numbers (20 min × 100 runs × 16 cores) are +context, not a bar. + +### 9.4 Perf gates + +- `examples/bench.rs`: `nfp-cache mixed-13` (**1,378-entry** build), `nfp-place mixed-13` + (single-start end-to-end **with a construction-vs-separation time split** — the number §10 + actually gates), `nfp-union stress` (100 × 48-gon discs), existing probes in NFP mode. +- **Deterministic op-count ceilings in `cargo test`** (boolean ops, quantized vertices, candidates + evaluated, CDE verifies + rejections) — CI-safe, double as determinism probes. +- Consumer vertex-scaling table re-published honestly (NFP cost is vertex-dependent, bounded by + decimation). + +--- + +## 10. Acceptance protocol & rollout + +**The framing correction (review, critical):** separation is **97–99 % of wall-clock** on +unplaced-tail nests (docs/02 §11.1) and the acceptance corpus always has a tail — so NFP +construction time is *additive* unless NFP construction *substitutes* separation-tail work by +placing more parts constructively. The protocol therefore gates two separate numbers: + +1. **Per-start construction budget:** NFP construction (cache amortized) ≤ **1 s/start** on the + mixed harness — measured at N2 exit from the bench split, falsifiable immediately, replacing + the old un-decomposable "≤ 1.1× total" arm as the engineering gate. +2. **Sep-substitution hypothesis, stated with a number:** NFP construction must place enough more + parts per start that total wall-clock at the calibrated K stays ≤ **1.1×** the incumbent + (sampling, restarts=16, budget=500, cardinal). Measured at N4 on tuning seeds 101–104; K is the + calibration knob; the earlier "≥ 30 % less wall-clock" SHIP branch is **deleted** (arithmetically + unreachable while the sep tail dominates). + +**Prerequisite evidence for the density bar (review: the +1.0 pp bar is currently +under-justified):** N0 measures (a) the share of non-axis-reachable pocket contacts in existing +harness layouts (replayable from saved placements), and (b) a throwaway end-to-end NFP probe on the +pentagon/bricks/mixed probes. If N0 cannot show a mechanism for ≥ +1.0 pp, the feature's primary +claim is **re-framed before N1** (consistency + pentagon-class + per-placement cost, with a lower +density bar agreed with the consumer) or the build stops — that is what the spike is for. + +| Verdict (seeds 1–8, paired deltas) | Criteria | +|---|---| +| **GO** (default-flip candidate) | mean ≥ 84.5 % at ≤ 1.1× wall-clock; per-seed floor ≥ 82.76 % (empirical in NFP mode — §5.3); rejection counter == 0 **on the acceptance harness** | +| **SHIP OPT-IN** | mean +0.5–1.0 pp at ≤ 1.1× clock, floor holds | +| **NO-GO** | any seed < 82.76 %, or gain < 0.5 pp, or unexplained rejections | + +Rejections elsewhere (ESICUP, property fuzzing) are release-blocking investigation triggers with a +≤ 1e-6 rate bound + root-cause analysis — not an automatic kill (tail-event realism; review). + +**Architectural-ceiling proofs** (in-repo): pentagon ≥ 24/40 **at N0/N1 exit** (the cheapest +falsifier of the whole thesis — front-loaded by review); 2-right-triangle interlock by construction +alone; exact-fit columns at 100.01. + +**Rollout**: opt-in default-off (existing goldens byte-identical in the landing PR); default flip +no earlier than 0.6.0, coordinated with a consumer pin bump; gated on GO + zero acceptance-harness +rejections + two consecutive releases of green x-platform NFP goldens + no ESICUP instance losing +> 0.5 pp + `ManualNest.violations == 0` over a ≥ 50-nest consumer replay + perf gates. **Sampling +mode is kept indefinitely** (escape hatch, differential oracle, per-part fallback). + +--- + +## 11. Build plan (revised for honest scope; goldens green at every step) + +| Phase | Scope | Exit criteria | +|---|---|---| +| **N0 — spike (2–4 days, throwaway)** | i_overlay pinned; quantize + orientation-normalized convolution + one hacked single-start placement loop (no cache/canon/mode-switch polish); boundary-magnitude engine-bound test; Precision termination test on quad soups; pocket-contact share measurement on saved harness layouts | **pentagon ≥ 24 by construction** (kill criterion); construction ≤ ~1 s/start extrapolated; engine bound + precision behavior confirmed; density mechanism evidence for §10 | +| **N1a — import hoist (prep PR)** | items/container imported once per public call; `entities` passed into `nest_core` | goldens byte-identical (the proof of inertness); no NFP code | +| **N1 — geometry substrate** | `nfp/minkowski.rs`, `nfp/cache.rs`; convex fast path; normalized union-fill; frame IFP; □E; Booth canonicalization; P1–P9 + P11 displacement measurement → E sized | property tests green on 3 platforms; goldens untouched | +| **N2 — placer + NestConfig** | region assembly (flat unions); vertex/loss/tie-break; verify + fallback (search+place_dropped verbatim); **place_dropped bypass on NFP placements**; `NestConfig` + 8 frozen wrappers + error channel; single refill pass; 5 golden cases; bench construction/sep split; diff inventory incl. `multistart_start` hook + golden_dump + tests lockstep; `cargo check -p ironnest-py` | old goldens byte-identical, new blessed on 3 OS; interlock by construction; construction ≤ 1 s/start on the harness | +| **N3 — multi-start & multi-sheet** | shared read-only cache through both `run_starts` builds + multi-sheet chain; policy A/B (block split default vs alternatives, over-subscribed harness); `nfp-multistart` golden (seq + parallel); cache ≤ 5 % gate | parallel == sequential byte-identity; policy chosen on data; floor measured | +| **N4 — wheel + consumer + acceptance** | py kwarg (both pyfunctions) + facade; consumer adapter + audit key + version floor; ESICUP corpus + runner (+ package.exclude); §10 protocol on tuning then acceptance seeds | verdict recorded here with numbers | + +### 11.1 N0 results (2026-07-10, `examples/nfp_spike.rs`) — **VERDICT: GO to N1a/N1** + +| probe | NFP construction (single start, no sep, no PRNG) | incumbent reference | +|---|---|---| +| pentagon ×40 | 19 placed (65.4 %) · **3.6 ms** | sampling *construction+improve*: ~65 % (same basin); +sep: 23 | +| 13×7 bricks ×120 | 99 placed (90.1 %) · **21 ms** | sampling construction: ~87 %; full pipeline +sep: 101 @ ~2,100 ms | +| 2 right-tris → square | **2/2 by construction** · 1.4 ms | sampling needs the full GLS separation search | +| 10×10 ×100 in 100.01 | **100/100 (100.0 %)** · 5.1 ms | sampling: 81 (the exact-fit artifact) — killed by construction | +| **mixed-13 consumer corpus** (concave L-brackets, 48-gon discs, min_sep 0.375) | **51/122, 79.7 % · 937 ms** | full K=1 pipeline (constr+improve+sep): 80.3–82.8 % @ **150–214 s** | + +Reading: (a) the **kill criterion as originally phrased compared the wrong slices** — construction- +alone vs construction+separation; on the fair comparison NFP construction beats sampling +construction everywhere it differs (bricks +3 pp, contact-columns +19 placed, interlock without +sep) and lands ~1–3 pp under the *full* K=1 pipeline at **~1/200th the wall-clock** on the real +corpus. The pentagon's 19 = sampling's construction basin; its 23 needs the sep tail, which N2 +keeps. (b) The **per-start construction budget gate (≤ 1 s) holds** (937 ms on mixed-13, spike +quality — no incremental union caching yet). (c) The **sep-substitution hypothesis has favorable +evidence**: sep will start from ~80 % instead of sampling-construction's much lower base. +(d) **ε-policy validated**: 0 verify-rejections on all convex probes; 6 (of thousands of candidate +verifies) on the concave+decimated mixed corpus — safe by design (next-candidate), and exactly the +N1 error-budget homework (P11 displacement measurement → E sizing). (e) Two hard-won implementation +lessons encoded for N1: hole cycles must never be CCW-normalized into seed pieces, and the +translated *outer* seed of a holed shape floods its holes — for holed A either omit the A-seed +(safe: ring thinner than any part ⇒ no poses lost) or do winding-correct shape-group seeding. + +### 11.2 N1a + N1 results (2026-07-10, landed) + +- **N1a import hoist**: `Problem` struct + `import_problem()` (lib.rs); items/container imported + once per public call, shared read-only (`&Problem`, no interior mutability) across the K starts + through `multistart_start_imported` — the sequential/parallel single-definition wiring survives. + **Goldens byte-identical** (the inertness proof) on default and `parallel` builds. Side win: K× + fewer imports per multistart call. +- **N1 substrate** (`nfp/geom.rs`, `nfp/minkowski.rs`, `nfp/mod.rs` — doc-hidden pub test hook): + quantize/unquantize (2^20 grid, i64), i128 predicates, CCW normalization, collinear strip, + Booth-restricted canonical rotation + full shape canonicalization, convex hull, exact O(n+m) + convex edge-merge Minkowski, orientation-normalized convolution union-fill (solid-cycle seeds), + □E inflation (convex fast path / union-fill), **quads-only frame IFP with exact face + classification** (see below), zone folding, eager pair cache with point-reflection mirror + derivation, domain gate (`NfpError::InputDomain`, ≥ 2^61 hard error), region query as one flat + difference, integer candidate walk with the `(cost, x, y)` tie-break. +- **P1 caught a real design bug before it shipped** (the property suite earning its keep): the + original frame-trick seed rules leaked concave-container pockets into the IFP (an L-container's + notch appeared feasible; the "ring thinner than any part" A-seed-omission argument is false for + concave — and even non-rectangular convex — containers). **Fix: quads-only frame sum + exact face + classification** — edge quads alone cover every boundary-touching pose, so each hole of their + union is a constant-status face, classified by one exact integer point-in-container test of a + deterministic interior probe (vertex-diagonal midpoint ladder), with nested forbidden islands + subtracted. Seed-free, all-integer, correct for any simple container; docs §3.2's IFP paragraph + is superseded accordingly. +- Tests green: 12 substrate unit tests + 5 integration property tests (P1 soundness zero-tolerance + across {rect, L-container} × {min_sep 0, 0.375} × {convex, concave parts}; P2-lite concave IFP + completeness; P8 double-build byte-identity; cardinal swap/negate ≤ 1 grid vs libm; domain gate) + + all pre-existing suites; goldens untouched; clippy `-D warnings` clean on both feature sets. + +### 11.3 N2 results (2026-07-10, landed) + +**Shipped:** `PlacementStrategy { Sampling, Nfp }` + `NestConfig` + `NestError` + +`nest_with_config` (the strategy-bearing `Result` entry point; `Sampling` config is the frozen +wrapper — locked byte-for-byte against `nest_multistart_per_item` by test). `nfp/place.rs`: the +candidate-walk placer (exact `(cost, rot_idx, x, y)` tie-break, `feasible_at` verification, direct +`place_item` — **no slide** on NFP placements; sampling fallback = `search` + `place_dropped` +verbatim with the settled pose quantized once as an obstacle), construction + exactly one refill +pass, `debug_assert!(is_feasible)` on the NFP path. Strategy + the once-per-call `NfpCache` thread +through `run_starts_with` into both sequential and `parallel` builds (immutable `&NfpCache` across +the sanctioned threads). Sampling paths byte-frozen: **all pre-existing goldens unchanged.** + +**Goldens (5 new cases blessed; the §9.2 set minus `nfp-multistart`, which lands with its N3 +policy):** `nfp-contact-columns` (3×3 grid at exactly `E = 16/2^20` wall clearance — the artifact +killer, visible in the snapshot bytes), `nfp-concave-pocket` (a square placed INSIDE the C-part's +pocket by construction — the NFP-hole path), `nfp-l-sheet-hole`, `nfp-min-sep` (13.375 brick pitch +exactly), `nfp-decimated-circle`. + +**Full-pipeline bench (NFP construction + sep tail vs sampling, same seed/budget, min_sep 0 +synthetics):** pentagon K=1 23/40 @ 2.7 s (par with sampling's 23 @ 3.0 s — the sep tail is the +binding constraint on this shape, as §10's model predicted); **bricks K=1 103/120 = 93.7 % vs +sampling's 101/120 = 91.9 % (+1.8 pp at equal K)**; bricks K=8 105 par; mixed 4-type K=8 93.2 % +par, K=16 94.2 %. The min_sep-0 synthetics understate the mode (sep dominates and the □E backoff +concedes exact-contact-only wins); the decisive §10 acceptance run on the consumer corpus +(min_sep 0.375) is N4, after N3's multistart policy A/B. + +### 11.4 N3 results (2026-07-10, landed) + +- **Multistart policy A/B (settles §13 Q4 with data):** on the over-subscribed mixed 4-type corpus + at K=8 over 5 seeds in NFP mode, the shipped **block split wins** — 94.01 % mean vs 93.49 % for + all-jittered-k>0 (`examples/nfp_policy_ab.rs`, result recorded in its header). The corrected + §5.3 premise holds: canonical starts diversify through the sampling-fallback + separation-tail + PRNG streams. `start_order_policy` stays strategy-independent. +- **Multi-sheet:** `NfpCache` split into the container-independent **`PartsGeometry`** (footprints, + □E movers, loss offsets, pairwise table; its own parts-side domain gate) shared via `Arc` across + sheets, and the per-sheet IFP assemble (container-side gate + frame margin + zone folding). + `nest_multi_with_config` lands with the spill semantics of `nest_multi_multistart_per_item` + (Sampling config = frozen wrapper, test-locked; NFP spill + determinism test-covered). +- **`nfp-multistart` golden blessed:** overflow corpus at seed 3, K=3 — the K=3 result differs + from BOTH canonical starts (placed areas 673 vs 646), so the winner is the trailing jittered + start; the case pins the NFP multistart path (shared read-only cache, block policy, jitter) into + the byte-identity contract, including under `--features parallel` (CI re-runs the same snapshot). +- Suites: 75/76 green (release default/parallel) + workspace debug; clippy `-D warnings` clean. + +### 11.5 N4 (2026-07-10, in progress) + +- **Facade + wheel landed:** `crates/ironnest` re-exports the config API; + `crates/py` gains `strategy="sampling"` (default, byte-frozen) / `"nfp"` on BOTH pyfunctions, + loud `ValueError` on unknown values and on `NestError` (the domain gate), dispatched inside the + GIL-released solve. Wheel builds; kwarg + error surface smoke-tested through the consumer venv. +- **Consumer adapter integration** (IronNester `strategy=` + the append-only `placement_mode` + audit key + version floor) is tracked as a `drawing_and_gcode` follow-up — that repo currently + has unrelated uncommitted work on another branch, so the acceptance measurement runs through the + RAW wheel API with identical inputs for both strategies (paired deltas; absolute utils differ + slightly from the adapter's 83.44 baseline because the raw path skips edge-margin erosion). +- **ESICUP corpus + runner: deferred** (context/regression tracker, never the gate — §9.3); the + acceptance verdict below is the gate. +- **Acceptance run (2026-07-10, raw wheel API, consumer 13-shape corpus, 120×60, min_sep 0.375, + cardinal, budget 500, K=16, paired seeds 1–4): VERDICT = SHIP OPT-IN.** + + | seed | sampling K=16 | NFP K=16 | Δ | + |---|---|---|---| + | 1 | 83.22 % @ 296 s | **84.03 %** @ 339 s | +0.81 | + | 2 | 83.22 % @ 332 s | **84.03 %** @ 285 s | +0.81 | + | 3 | 83.22 % @ 255 s | **84.03 %** @ 273 s | +0.81 | + | 4 | 83.42 % @ 278 s | 83.42 % @ 285 s | ±0 | + | mean | 83.27 % | **83.88 %** | **+0.61 pp** | + + Floor: NFP ≥ sampling on every seed. Wall-clock: 1.02× mean (inside the ≤1.1× arm). 84.03 % is + the **highest utilization ever recorded on this corpus** (previous all-time: 83.66 %). Against + §10: +0.61 pp at equal clock with the floor held = **SHIP OPT-IN** (GO wanted ≥ +1.0 pp mean). + Cache-build gate: 1.04 s for the full 13-type build incl. import ≈ 0.35 % of a K=16 nest — + passes. GO remains reachable via the recorded levers: the post-separation NFP refill (§13 Q5), + higher K (NFP's ceiling has not saturated the way sampling's has), and surfacing the + verify-rejection counter through the wheel for the zero-rejection criterion. + +**Estimated scope (revised ~2× upward by review): ~1,600–2,200 LOC feature + ~1,200–1,800 LOC +tests/examples/tooling.** Itemized: convolution + normalization 250–350; fast path + convexity +150–200; IFP/region 150–200; cache/canonicalization/Booth 250–300; placer + verify + fallback +150–200; import hoist + NestConfig + threading through 8 wrappers + facade + py + error channel +400–600; counters/CI assertions 100; goldens ~150; esicup runner ~200; bench/density additions +~120; property harness + generators + oracles 600–900. Dependencies: the i_overlay family (5 +crates, exact-pinned). Decision point: this is a **4–6 week** feature at normal pace; the N0 spike +(2–4 days) buys the density evidence before committing. + +--- + +## 12. Risks + +| Risk | Sev | Mitigation | +|---|---|---| +| Density thesis fails (slide already captures most contacts; sparrow does SOTA without NFP) | **HIGH — the headline risk** | N0 kill criterion (pentagon ≥ 24 + pocket-contact share); reframe-or-stop gate before N1 (§10) | +| Sep-tail dominance makes equal-wall-clock unreachable | HIGH | per-start construction budget gate; sep-substitution hypothesis measured at N2/N4; K calibration | +| i_overlay hazard / version churn / escalating snap | MED | audit done; exact pin; Precision pinned; displacement measurement sizes E; canonicalization; vendoring path | +| Union-fill completeness on **holed** inputs (proof covers hole-free) | LOW-MED | orientation normalization (review fix) + P3/P10 differentials; CDE rejects escapes on the obstacle side; P2/P6-concave covers the IFP side | +| IFP failure direction is silent (no rejection signal) | MED | P2/P6 on concave/holed containers; fallback backstop; §2 states it honestly | +| NFP-mode multi-start diversity/floor | MED | block-split default (proven); N3 A/B; empirical floor requirement in §10 | +| Import-hoist refactor perturbs behavior | LOW | own PR, goldens as proof (N1a) | +| Cache build cost per consumer call | MED | ≤ 5 % gate; pair-NFP sharing; measured at N3 | +| Exact-fit/measure-zero placements dropped | LOW | narrowed not eliminated; documented; sampling mode unchanged | +| MSRV/licensing/packaging (1.88; 5 MIT crates; CC0 data; sdist bloat) | LOW | docs + deny allowlist + provenance README + package.exclude (N1/N4 checklists) | + +## 13. Open questions (tracked; none block N0) + +1. N0's construction-cost measurement (sizes K at equal cost; decides the sep-substitution target). +2. Pocket-contact share on real corpora (the density mechanism evidence — N0). +3. Whether a non-escalating i_overlay `Precision` terminates on convolution quad soups (N0; decides + E sizing pressure). +4. NFP-mode start-policy winner (N3 A/B) and the empirical floor at calibrated K. +5. Post-separation NFP refill; separation search consuming the NFP cache for exact directional + penetration (docs/02 §7.2) — recorded, out of scope. +6. Input-domain policy for metric consumers (explicit scale API param) — on demand. + +## References + +Boost.Polygon `convolve_two_polygon_sets` (tutorial `minkowski.cpp`); Deepnest `minkowski.cc` + +`background.js` (clipCache, frame trick); iShape-Rust `i_overlay` 7.0.2 family (audited +2026-07-10; `split/cross_solver.rs`, `core/solver.rs` Precision, `segm/boolean.rs` winding); +CGAL Minkowski_sum_2 manual; Rocha 2019 (arXiv 1903.11139); Burke et al. 2007; Bennell & Song 2008; +libnest2d `geometry_traits_nfp.hpp`; sparrow (arXiv 2509.13329) Tables 2–3; +`github.com/ESICUP/datasets` (CC0); docs/02 §11.1–11.2 (B3 negative result; saturation + +acceptance baselines 82.76 / 83.44 / 83.66 %). From 4bc0d09aa08eaa1fd7503584dad05c7beb62f35b Mon Sep 17 00:00:00 2001 From: Jeff West Date: Fri, 10 Jul 2026 10:56:08 -0500 Subject: [PATCH 2/2] chore: bump anyhow 1.0.102 -> 1.0.103 (RUSTSEC-2026-0190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clears the supply-chain gate: the advisory flags unsoundness in Error::downcast_mut on 1.0.102 (a jagua-fork import-path dependency; not on any placement path — goldens unaffected, lock-only change). Co-Authored-By: Claude Fable 5 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bb29739..b62a0d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "approx"