Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,12 @@ jobs:
# pipeline does not perturb the byte-identical output.
- name: Test (release, shipped profile)
run: cargo test --workspace --exclude ironnest-py --release --locked

# `--features parallel`: nest_multistart's K constructions run on threads (the wheel's default).
# This is the ONLY sanctioned threading and it MUST be byte-identical to sequential — so the
# golden (which includes a K=3 multistart case) re-run under the feature must match the very same
# committed snapshot on every platform, and the `parallel_multistart_equals_independent_sequential
# _best` test asserts the threaded reduction equals an independent sequential best-of-K. This is
# the standing cross-platform proof that parallel == sequential (docs/02 §B2).
- name: Test (parallel multi-start == sequential)
run: cargo test -p ironnest-optimizer --features parallel --release --locked
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,12 @@ import-only-then-resorted, `Instant` is metadata). Keep it that way. The verifie
(`crates/optimizer/src/prng.rs`) — no `rand` dep, so the exact bit-stream is version-independent
and byte-stable forever. Explicit seed always; no `getrandom`/`rand::random()` fallback, ever.
- **No `rayon` / threads on any placement-deciding path.** Single canonical worker; if parallel, fix
the reduction order. (Import-time `par_iter` is fine — it's re-sorted by id.)
the reduction order. (Import-time `par_iter` is fine — it's re-sorted by id.) **The one sanctioned
exception:** `nest_multistart`'s `parallel` feature runs the K independent best-of-K starts on
`std::thread::scope` threads — each start is the self-contained, golden-stable `nest_per_item` (own
PRNG/`Layout`, no shared state), results join in fixed k order, and the keep-best `total_cmp` argmax
runs after all joins, so no float reduction crosses a thread. Never thread *inside* a single
placement search. Proven by the golden re-run under `--features parallel` (CI, all 3 platforms).
- **`BTreeMap`/`BTreeSet`/`slotmap` only** where order affects placement. Never std `HashMap`
(per-process random iteration).
- **No `f64::mul_add` / no fast-math.** Basic `+ - * / sqrt` and `powi` are IEEE-deterministic — safe.
Expand Down
10 changes: 5 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ resolver = "3"
members = ["crates/geo", "crates/cde", "crates/optimizer", "crates/ironnest", "crates/py"]

[workspace.package]
version = "0.3.0" # bump deliberately; re-bless goldens if placement code changed
version = "0.4.0" # bump deliberately; re-bless goldens if placement code changed
edition = "2024" # jagua-rs lineage; edition 2024 needs Rust ≥ 1.85
rust-version = "1.87" # MSRV floor: jagua's forked code uses {integer}::cast_signed/cast_unsigned
# (stable 1.87). We build on the pinned 1.96.0 — see rust-toolchain.toml.
Expand Down
5 changes: 5 additions & 0 deletions crates/ironnest/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,10 @@ publish = false
[lints]
workspace = true

[features]
# Forwards to the optimizer's `parallel` feature: run `nest_multistart`'s K constructions on threads
# (byte-identical to sequential — see the optimizer manifest). Off by default; the wheel turns it on.
parallel = ["ironnest-optimizer/parallel"]

[dependencies]
ironnest-optimizer = { workspace = true }
2 changes: 1 addition & 1 deletion crates/ironnest/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,5 @@
#[doc(inline)]
pub use ironnest_optimizer::{
MultiSheetSolution, NestSolution, Placement, Scalar, Sheet, nest, nest_multi,
nest_multi_per_item, nest_per_item,
nest_multi_per_item, nest_multistart, nest_multistart_per_item, nest_per_item,
};
9 changes: 9 additions & 0 deletions crates/optimizer/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ publish = false
[lints]
workspace = true

[features]
# `parallel` runs the multi-start (`nest_multistart`) constructions on `std::thread::scope` threads
# instead of sequentially. It is BYTE-IDENTICAL to the sequential path (each start is the independent,
# golden-stable `nest_per_item`; the keep-best reduction runs after all joins in fixed k order, with no
# float sum crossing a thread) — the cross-platform golden run under this feature must match the same
# snapshot. Off by default; std-only, no new dependency. NOTE: this is the ONLY sanctioned threading,
# and only on the multi-start meta-loop — never inside a single placement search (CLAUDE.md).
parallel = []

[dependencies]
ironnest-geo = { workspace = true }
ironnest-cde = { workspace = true }
Expand Down
180 changes: 180 additions & 0 deletions crates/optimizer/examples/bench.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

//! Per-probe wall-clock + density harness for the nester — ground truth for the perf-tuning work
//! (the A-stack: collector reuse, hazkey cache, incremental loss). Reports best-of-N milliseconds
//! per probe so a speedup is visible against the noise floor. `cargo run -p ironnest-optimizer
//! --release --example bench`.
//!
//! DETERMINISM(ironnest): this is a BENCHMARK, never a placement path — the `Instant` wall-clock here
//! only times the engine; it never feeds a placement decision (which is why the determinism gate's
//! `Instant::now` ban does not apply, exactly as the std-trig ban is waived for the test-input
//! generators in `tests/nest.rs`). The placements themselves remain a pure function of `(inputs, seed,
//! budget)`. Timing is reported only to stderr-style stdout text; it is NOT part of any golden.

use ironnest_optimizer::{Scalar, nest, nest_multistart};

fn rect(w: Scalar, h: Scalar) -> Vec<[Scalar; 2]> {
vec![[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]]
}

/// Best-of-`reps` wall-clock (ms) of `f`. Best (min) is the most stable estimator for a CPU-bound,
/// allocation-sensitive routine: it filters scheduler/allocator noise that only ever adds time.
#[allow(clippy::disallowed_methods)] // wall-clock for benchmarking only — never a placement input
fn best_ms<F: FnMut()>(reps: u32, mut f: F) -> Scalar {
let mut best = Scalar::INFINITY;
for _ in 0..reps {
let t = std::time::Instant::now();
f();
let ms = t.elapsed().as_secs_f64() * 1000.0;
best = best.min(ms);
}
best
}

#[allow(clippy::too_many_arguments)]
fn probe(
label: &str,
item: Vec<[Scalar; 2]>,
item_area: Scalar,
qty: usize,
side: Scalar,
budget: u64,
reps: u32,
separation_heavy: bool,
) {
let container = rect(side, side);
let rotations = [0.0, 90.0, 180.0, 270.0];

// Capture density once (it is deterministic), then time the call best-of-reps.
let mut placed = 0usize;
let ms = best_ms(reps, || {
let sol = nest(
std::slice::from_ref(&item),
&[qty],
&container,
&[],
0.0,
&rotations,
1,
budget,
);
placed = sol.placements.len();
});
let util = (placed as Scalar * item_area) / (side * side) * 100.0;
let tag = if separation_heavy { " [sep]" } else { "" };
println!(
"{label:<24} placed {placed:>4}/{qty:<4} util {util:>5.1}% best {ms:>8.2} ms (budget {budget}, best of {reps}){tag}"
);
}

fn main() {
println!("--- bench (per-probe best-of-N wall-clock; [sep] = separation-search-heavy) ---");
probe(
"10x10 in 100 (exact)",
rect(10.0, 10.0),
100.0,
100,
100.0,
2000,
3,
false,
);
probe(
"10x10 in 100.5 (slack)",
rect(10.0, 10.0),
100.0,
100,
100.5,
2000,
3,
false,
);
probe(
"7x7 squares",
rect(7.0, 7.0),
49.0,
220,
100.0,
2000,
3,
false,
);
probe(
"13x7 bricks",
rect(13.0, 7.0),
91.0,
120,
100.0,
2000,
3,
true,
);
let pentagon = vec![
[0.0, 0.0],
[20.0, 0.0],
[20.0, 12.0],
[12.0, 20.0],
[0.0, 20.0],
];
probe("pentagon ~344", pentagon, 344.0, 40, 100.0, 4000, 3, true);
let right_tri = vec![[0.0, 0.0], [10.0, 0.0], [0.0, 10.0]];
probe(
"2 right-tris pair->sq",
right_tri,
50.0,
2,
11.0,
4000,
5,
true,
);

println!("\n--- multi-start best-of-K (B1): density vs time on the seed-variant case ---");
multistart_probe(
"13x7 bricks",
rect(13.0, 7.0),
91.0,
120,
100.0,
2000,
&[1, 4, 8],
);
}

/// Runs `nest_multistart` at several K and reports the best-of-K density + total wall-clock, so the
/// density/time trade of the multi-start lever is visible. K=1 is the single-start reference.
fn multistart_probe(
label: &str,
item: Vec<[Scalar; 2]>,
item_area: Scalar,
qty: usize,
side: Scalar,
budget: u64,
ks: &[usize],
) {
let container = rect(side, side);
let rotations = [0.0, 90.0, 180.0, 270.0];
for &k in ks {
let mut placed = 0usize;
let ms = best_ms(1, || {
let sol = nest_multistart(
std::slice::from_ref(&item),
&[qty],
&container,
&[],
0.0,
&rotations,
1,
budget,
k,
);
placed = sol.placements.len();
});
let util = (placed as Scalar * item_area) / (side * side) * 100.0;
println!(
"{label:<18} K={k:<3} placed {placed:>4}/{qty:<4} util {util:>5.1}% {ms:>9.1} ms"
);
}
}
76 changes: 76 additions & 0 deletions crates/optimizer/examples/seedsweep.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

//! Empirical test of the multi-start ("best-of-K") density thesis, through the PUBLIC API only
//! (sweep the `seed` argument; keep the best). No engine changes. Answers: does best-of-K lift
//! density, and for which shapes? `cargo run -p ironnest-optimizer --release --example seedsweep`

use std::collections::BTreeMap;

use ironnest_optimizer::{Scalar, nest};

fn rect(w: Scalar, h: Scalar) -> Vec<[Scalar; 2]> {
vec![[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]]
}

/// Sweeps `seeds` seeds for one probe and prints the placed-count distribution + best/worst.
fn sweep(
label: &str,
item: Vec<[Scalar; 2]>,
item_area: Scalar,
qty: usize,
side: Scalar,
budget: u64,
seeds: u64,
) {
let container = rect(side, side);
let rotations = [0.0, 90.0, 180.0, 270.0];

let mut dist: BTreeMap<usize, usize> = BTreeMap::new();
let mut best = 0usize;
let mut worst = usize::MAX;
for seed in 1..=seeds {
let sol = nest(
std::slice::from_ref(&item),
&[qty],
&container,
&[],
0.0,
&rotations,
seed,
budget,
);
let placed = sol.placements.len();
*dist.entry(placed).or_default() += 1;
best = best.max(placed);
worst = worst.min(placed);
}

let util = |n: usize| (n as Scalar * item_area) / (side * side) * 100.0;
let dist_str: Vec<String> = dist.iter().map(|(k, v)| format!("{k}:{v}")).collect();
println!(
"{label:<22} K={seeds:<3} best {best}/{qty} ({:.1}%) worst {worst}/{qty} ({:.1}%) spread +{:.1}pp dist {{{}}}",
util(best),
util(worst),
util(best) - util(worst),
dist_str.join(", "),
);
}

fn main() {
println!("--- best-of-K seed sweep (public API, single ordering) ---");
// Bricks: the roadmap CLAIMS best-of-K lifts 91.9% -> ~95.5%. Verify.
sweep("13x7 bricks", rect(13.0, 7.0), 91.0, 120, 100.0, 2000, 16);
// Squares: roadmap claims ZERO seed variance (rotation-invariant).
sweep("7x7 squares", rect(7.0, 7.0), 49.0, 220, 100.0, 2000, 16);
// Pentagon: roadmap claims FLAT at 23/40 across all seeds (architectural cap).
let pentagon = vec![
[0.0, 0.0],
[20.0, 0.0],
[20.0, 12.0],
[12.0, 20.0],
[0.0, 20.0],
];
sweep("pentagon ~344", pentagon, 344.0, 40, 100.0, 4000, 16);
}
Loading
Loading