Skip to main content

bin_packing/one_d/
model.rs

1//! Data model types for 1D cutting stock problems and solutions.
2
3use serde::{Deserialize, Serialize};
4
5use crate::{BinPackingError, Result};
6
7/// Maximum allowed value for stock/demand lengths and kerf/trim. Chosen so that
8/// `length * quantity` in the exact backend (where `quantity` is a `u32`-bound
9/// `usize`) cannot overflow a `u64`. Matches the 2D model's `MAX_DIMENSION`.
10const MAX_DIMENSION: u32 = 1 << 30;
11
12/// Algorithm selector for [`solve_1d`](super::solve_1d).
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
14#[serde(rename_all = "snake_case")]
15pub enum OneDAlgorithm {
16    /// Try multiple strategies and return the best.
17    #[default]
18    Auto,
19    /// Deterministic first-fit-decreasing construction.
20    FirstFitDecreasing,
21    /// Deterministic best-fit-decreasing construction.
22    BestFitDecreasing,
23    /// Multistart local search with bin-elimination repair.
24    LocalSearch,
25    /// Exact column generation with pattern-search refinement.
26    ColumnGeneration,
27}
28
29/// A linear stock entry that demands can be cut from.
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31pub struct Stock1D {
32    /// Human-readable identifier for this stock type.
33    pub name: String,
34    /// Raw length of the stock before any trim is removed.
35    pub length: u32,
36    /// Material lost to the saw between adjacent cuts.
37    #[serde(default)]
38    pub kerf: u32,
39    /// Unusable material removed from the stock length before packing.
40    #[serde(default)]
41    pub trim: u32,
42    /// Per-unit cost of consuming a piece of this stock type.
43    #[serde(default = "default_stock_cost")]
44    pub cost: f64,
45    /// Optional cap on the number of pieces of this stock type that may be used.
46    #[serde(default)]
47    pub available: Option<usize>,
48}
49
50/// A demand for a set of identical 1D cuts.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct CutDemand1D {
53    /// Human-readable identifier for the demand.
54    pub name: String,
55    /// Required length of each cut.
56    pub length: u32,
57    /// Number of identical cuts required.
58    pub quantity: usize,
59}
60
61/// A single cut assigned to a packed stock layout.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct CutAssignment1D {
64    /// Name of the originating demand.
65    pub name: String,
66    /// Length of this individual cut.
67    pub length: u32,
68}
69
70/// A single packed stock layout produced by the solver.
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72pub struct StockLayout1D {
73    /// Name of the stock type consumed by this layout.
74    pub stock_name: String,
75    /// Raw length of the stock piece.
76    pub stock_length: u32,
77    /// Length occupied by placed cuts (including kerf contributions).
78    pub used_length: u32,
79    /// Remaining length after the placed cuts.
80    pub remaining_length: u32,
81    /// Length wasted on this layout.
82    pub waste: u32,
83    /// Cost of consuming this stock piece.
84    pub cost: f64,
85    /// Cuts assigned to this layout.
86    pub cuts: Vec<CutAssignment1D>,
87}
88
89/// Per-stock procurement summary for a 1D cut list.
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91pub struct StockRequirement1D {
92    /// Name of the stock type.
93    pub stock_name: String,
94    /// Raw length of the stock piece.
95    pub stock_length: u32,
96    /// Usable length after trim is removed.
97    pub usable_length: u32,
98    /// Cost of consuming one piece of this stock type.
99    pub cost: f64,
100    /// Declared inventory available for this stock type, if capped.
101    pub available_quantity: Option<usize>,
102    /// Number of pieces used in the returned solution.
103    pub used_quantity: usize,
104    /// Number of pieces required to satisfy the full cut list with the chosen stock mix.
105    pub required_quantity: usize,
106    /// Additional pieces needed beyond `available_quantity`.
107    pub additional_quantity_needed: usize,
108}
109
110/// Metrics captured while running a 1D solver.
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112pub struct SolverMetrics1D {
113    /// Number of top-level solver iterations performed.
114    pub iterations: usize,
115    /// Number of candidate patterns generated during column generation.
116    pub generated_patterns: usize,
117    /// Number of patterns enumerated in the exact backend.
118    pub enumerated_patterns: usize,
119    /// Number of states explored during local search or branching.
120    pub explored_states: usize,
121    /// Free-form notes emitted by the solver for diagnostics.
122    pub notes: Vec<String>,
123}
124
125/// A complete solution returned by [`solve_1d`](super::solve_1d).
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127pub struct OneDSolution {
128    /// Name of the algorithm that produced this solution.
129    pub algorithm: String,
130    /// Whether the solution is proven optimal.
131    pub exact: bool,
132    /// Optional lower bound on the optimal objective.
133    pub lower_bound: Option<f64>,
134    /// Number of stock pieces consumed.
135    pub stock_count: usize,
136    /// Total wasted length across all layouts.
137    pub total_waste: u64,
138    /// Total material cost across all layouts.
139    pub total_cost: f64,
140    /// Per-stock layouts in descending order of utilization.
141    pub layouts: Vec<StockLayout1D>,
142    /// Per-stock requirement summary, including any shortage against declared availability.
143    #[serde(default)]
144    pub stock_requirements: Vec<StockRequirement1D>,
145    /// Cuts the solver was unable to place.
146    pub unplaced: Vec<CutAssignment1D>,
147    /// Metrics captured while solving.
148    pub metrics: SolverMetrics1D,
149}
150
151/// Input problem passed to [`solve_1d`](super::solve_1d).
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
153pub struct OneDProblem {
154    /// Available stock types.
155    pub stock: Vec<Stock1D>,
156    /// Demands to be cut from the stock.
157    pub demands: Vec<CutDemand1D>,
158}
159
160/// Options controlling how [`solve_1d`](super::solve_1d) runs.
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162pub struct OneDOptions {
163    /// Algorithm to dispatch to.
164    #[serde(default)]
165    pub algorithm: OneDAlgorithm,
166    /// Number of multistart restarts used by local search.
167    #[serde(default = "default_multistart_runs")]
168    pub multistart_runs: usize,
169    /// Number of improvement rounds per local search start.
170    #[serde(default = "default_improvement_rounds")]
171    pub improvement_rounds: usize,
172    /// Number of column-generation rounds in the exact backend.
173    #[serde(default = "default_column_generation_rounds")]
174    pub column_generation_rounds: usize,
175    /// Maximum number of patterns enumerated by the exact backend.
176    #[serde(default = "default_exact_pattern_limit")]
177    pub exact_pattern_limit: usize,
178    /// Maximum number of demand types for the Auto mode to attempt exact solving.
179    #[serde(default = "default_auto_exact_max_types")]
180    pub auto_exact_max_types: usize,
181    /// Maximum total quantity for the Auto mode to attempt exact solving.
182    #[serde(default = "default_auto_exact_max_quantity")]
183    pub auto_exact_max_quantity: usize,
184    /// Optional seed for reproducible randomized algorithms.
185    #[serde(default)]
186    pub seed: Option<u64>,
187}
188
189impl Default for OneDOptions {
190    fn default() -> Self {
191        Self {
192            algorithm: OneDAlgorithm::Auto,
193            multistart_runs: default_multistart_runs(),
194            improvement_rounds: default_improvement_rounds(),
195            column_generation_rounds: default_column_generation_rounds(),
196            exact_pattern_limit: default_exact_pattern_limit(),
197            auto_exact_max_types: default_auto_exact_max_types(),
198            auto_exact_max_quantity: default_auto_exact_max_quantity(),
199            seed: None,
200        }
201    }
202}
203
204#[derive(Debug, Clone, PartialEq, Eq)]
205pub(crate) struct PieceInstance {
206    pub(crate) demand_index: usize,
207    pub(crate) name: String,
208    pub(crate) length: u32,
209}
210
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub(crate) struct PackedBin {
213    pub(crate) stock_index: usize,
214    pub(crate) pieces: Vec<PieceInstance>,
215    occupied_length: u32,
216}
217
218impl Stock1D {
219    pub(crate) fn usable_length(&self) -> u32 {
220        self.length.saturating_sub(self.trim)
221    }
222
223    pub(crate) fn adjusted_capacity(&self) -> u32 {
224        self.usable_length().saturating_add(self.kerf)
225    }
226}
227
228impl OneDProblem {
229    pub(crate) fn validate(&self) -> Result<()> {
230        if self.stock.is_empty() {
231            return Err(BinPackingError::InvalidInput(
232                "at least one stock entry is required".to_string(),
233            ));
234        }
235
236        if self.demands.is_empty() {
237            return Err(BinPackingError::InvalidInput(
238                "at least one demand entry is required".to_string(),
239            ));
240        }
241
242        for stock in &self.stock {
243            if stock.length == 0 {
244                return Err(BinPackingError::InvalidInput(format!(
245                    "stock `{}` must have a positive length",
246                    stock.name
247                )));
248            }
249
250            if stock.length > MAX_DIMENSION {
251                return Err(BinPackingError::InvalidInput(format!(
252                    "stock `{}` length {} exceeds the supported maximum of {MAX_DIMENSION}",
253                    stock.name, stock.length
254                )));
255            }
256
257            if stock.kerf > MAX_DIMENSION {
258                return Err(BinPackingError::InvalidInput(format!(
259                    "stock `{}` kerf {} exceeds the supported maximum of {MAX_DIMENSION}",
260                    stock.name, stock.kerf
261                )));
262            }
263
264            if stock.trim > MAX_DIMENSION {
265                return Err(BinPackingError::InvalidInput(format!(
266                    "stock `{}` trim {} exceeds the supported maximum of {MAX_DIMENSION}",
267                    stock.name, stock.trim
268                )));
269            }
270
271            if stock.usable_length() == 0 {
272                return Err(BinPackingError::InvalidInput(format!(
273                    "stock `{}` has no usable length after trim",
274                    stock.name
275                )));
276            }
277
278            if !stock.cost.is_finite() || stock.cost < 0.0 {
279                return Err(BinPackingError::InvalidInput(format!(
280                    "stock `{}` must have a finite non-negative cost",
281                    stock.name
282                )));
283            }
284        }
285
286        for demand in &self.demands {
287            if demand.length == 0 {
288                return Err(BinPackingError::InvalidInput(format!(
289                    "demand `{}` must have a positive length",
290                    demand.name
291                )));
292            }
293
294            if demand.length > MAX_DIMENSION {
295                return Err(BinPackingError::InvalidInput(format!(
296                    "demand `{}` length {} exceeds the supported maximum of {MAX_DIMENSION}",
297                    demand.name, demand.length
298                )));
299            }
300
301            if demand.quantity == 0 {
302                return Err(BinPackingError::InvalidInput(format!(
303                    "demand `{}` must have a positive quantity",
304                    demand.name
305                )));
306            }
307        }
308
309        Ok(())
310    }
311
312    pub(crate) fn ensure_feasible_demands(&self) -> Result<()> {
313        for demand in &self.demands {
314            let feasible = self.stock.iter().any(|stock| stock.usable_length() >= demand.length);
315            if !feasible {
316                return Err(BinPackingError::Infeasible1D {
317                    item: demand.name.clone(),
318                    length: demand.length,
319                });
320            }
321        }
322
323        Ok(())
324    }
325
326    pub(crate) fn total_quantity(&self) -> usize {
327        self.demands.iter().map(|demand| demand.quantity).sum()
328    }
329
330    pub(crate) fn expanded_pieces(&self) -> Vec<PieceInstance> {
331        let mut pieces = Vec::with_capacity(self.total_quantity());
332
333        for (index, demand) in self.demands.iter().enumerate() {
334            for _ in 0..demand.quantity {
335                pieces.push(PieceInstance {
336                    demand_index: index,
337                    name: demand.name.clone(),
338                    length: demand.length,
339                });
340            }
341        }
342
343        pieces
344    }
345}
346
347impl PackedBin {
348    pub(crate) fn new(stock_index: usize) -> Self {
349        Self { stock_index, pieces: Vec::new(), occupied_length: 0 }
350    }
351
352    pub(crate) fn delta_for_piece(&self, piece: &PieceInstance, stock: &Stock1D) -> Option<u32> {
353        let delta = if self.pieces.is_empty() {
354            piece.length
355        } else {
356            piece.length.saturating_add(stock.kerf)
357        };
358
359        (self.occupied_length.saturating_add(delta) <= stock.usable_length()).then_some(delta)
360    }
361
362    pub(crate) fn can_fit_piece(&self, piece: &PieceInstance, stock: &Stock1D) -> bool {
363        self.delta_for_piece(piece, stock).is_some()
364    }
365
366    pub(crate) fn add_piece(&mut self, piece: PieceInstance, stock: &Stock1D) -> bool {
367        if let Some(delta) = self.delta_for_piece(&piece, stock) {
368            self.occupied_length = self.occupied_length.saturating_add(delta);
369            self.pieces.push(piece);
370            true
371        } else {
372            false
373        }
374    }
375
376    pub(crate) fn used_length(&self) -> u32 {
377        self.occupied_length
378    }
379
380    pub(crate) fn remaining_length(&self, stock: &Stock1D) -> u32 {
381        stock.usable_length().saturating_sub(self.occupied_length)
382    }
383}
384
385impl OneDSolution {
386    pub(crate) fn from_bins(
387        algorithm: impl Into<String>,
388        exact: bool,
389        lower_bound: Option<f64>,
390        stock: &[Stock1D],
391        bins: &[PackedBin],
392        unplaced: &[PieceInstance],
393        metrics: SolverMetrics1D,
394    ) -> Self {
395        let used_counts = count_stock_usage(stock.len(), bins);
396        let mut layouts = bins
397            .iter()
398            .map(|bin| {
399                let stock_entry = &stock[bin.stock_index];
400                let used_length = bin.used_length();
401                let remaining_length = bin.remaining_length(stock_entry);
402
403                StockLayout1D {
404                    stock_name: stock_entry.name.clone(),
405                    stock_length: stock_entry.length,
406                    used_length,
407                    remaining_length,
408                    waste: remaining_length,
409                    cost: stock_entry.cost,
410                    cuts: bin
411                        .pieces
412                        .iter()
413                        .map(|piece| CutAssignment1D {
414                            name: piece.name.clone(),
415                            length: piece.length,
416                        })
417                        .collect(),
418                }
419            })
420            .collect::<Vec<_>>();
421
422        layouts.sort_by(|left, right| {
423            right
424                .used_length
425                .cmp(&left.used_length)
426                .then_with(|| left.stock_name.cmp(&right.stock_name))
427        });
428
429        let total_waste = layouts.iter().map(|layout| u64::from(layout.waste)).sum();
430        let total_cost = layouts.iter().map(|layout| layout.cost).sum();
431
432        let mut unplaced_cuts = unplaced
433            .iter()
434            .map(|piece| CutAssignment1D { name: piece.name.clone(), length: piece.length })
435            .collect::<Vec<_>>();
436        unplaced_cuts.sort_by(|left, right| right.length.cmp(&left.length));
437
438        Self {
439            algorithm: algorithm.into(),
440            exact,
441            lower_bound,
442            stock_count: layouts.len(),
443            total_waste,
444            total_cost,
445            layouts,
446            stock_requirements: build_stock_requirements(stock, &used_counts, &used_counts),
447            unplaced: unplaced_cuts,
448            metrics,
449        }
450    }
451
452    pub(crate) fn set_required_stock_counts(
453        &mut self,
454        stock: &[Stock1D],
455        required_counts: &[usize],
456    ) {
457        let used_counts = self
458            .stock_requirements
459            .iter()
460            .map(|requirement| requirement.used_quantity)
461            .collect::<Vec<_>>();
462        self.stock_requirements = build_stock_requirements(stock, &used_counts, required_counts);
463    }
464
465    pub(crate) fn is_better_than(&self, other: &Self) -> bool {
466        (
467            self.unplaced.len(),
468            self.stock_count,
469            self.total_waste,
470            OrderedFloat(self.total_cost),
471            !self.exact,
472        ) < (
473            other.unplaced.len(),
474            other.stock_count,
475            other.total_waste,
476            OrderedFloat(other.total_cost),
477            !other.exact,
478        )
479    }
480}
481
482#[derive(Debug, Clone, Copy, PartialEq)]
483struct OrderedFloat(pub f64);
484
485impl Eq for OrderedFloat {}
486
487impl PartialOrd for OrderedFloat {
488    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
489        Some(self.cmp(other))
490    }
491}
492
493impl Ord for OrderedFloat {
494    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
495        self.0.total_cmp(&other.0)
496    }
497}
498
499fn default_stock_cost() -> f64 {
500    1.0
501}
502
503fn count_stock_usage(stock_count: usize, bins: &[PackedBin]) -> Vec<usize> {
504    let mut counts = vec![0_usize; stock_count];
505    for bin in bins {
506        counts[bin.stock_index] = counts[bin.stock_index].saturating_add(1);
507    }
508    counts
509}
510
511fn build_stock_requirements(
512    stock: &[Stock1D],
513    used_counts: &[usize],
514    required_counts: &[usize],
515) -> Vec<StockRequirement1D> {
516    stock
517        .iter()
518        .enumerate()
519        .map(|(index, stock_entry)| {
520            let used_quantity = *used_counts.get(index).unwrap_or(&0);
521            let required_quantity = *required_counts.get(index).unwrap_or(&used_quantity);
522            let additional_quantity_needed = stock_entry
523                .available
524                .map(|available| required_quantity.saturating_sub(available))
525                .unwrap_or(0);
526
527            StockRequirement1D {
528                stock_name: stock_entry.name.clone(),
529                stock_length: stock_entry.length,
530                usable_length: stock_entry.usable_length(),
531                cost: stock_entry.cost,
532                available_quantity: stock_entry.available,
533                used_quantity,
534                required_quantity,
535                additional_quantity_needed,
536            }
537        })
538        .collect()
539}
540
541fn default_multistart_runs() -> usize {
542    16
543}
544
545fn default_improvement_rounds() -> usize {
546    24
547}
548
549fn default_column_generation_rounds() -> usize {
550    32
551}
552
553fn default_exact_pattern_limit() -> usize {
554    25_000
555}
556
557fn default_auto_exact_max_types() -> usize {
558    14
559}
560
561fn default_auto_exact_max_quantity() -> usize {
562    96
563}
564
565#[cfg(test)]
566mod tests {
567    use serde_json::json;
568
569    use super::*;
570
571    fn sample_problem() -> OneDProblem {
572        OneDProblem {
573            stock: vec![Stock1D {
574                name: "bar".to_string(),
575                length: 10,
576                kerf: 1,
577                trim: 2,
578                cost: 1.0,
579                available: None,
580            }],
581            demands: vec![
582                CutDemand1D { name: "leg".to_string(), length: 4, quantity: 2 },
583                CutDemand1D { name: "brace".to_string(), length: 3, quantity: 1 },
584            ],
585        }
586    }
587
588    #[test]
589    fn serde_defaults_fill_in_optional_stock_and_option_fields() {
590        let stock: Stock1D =
591            serde_json::from_value(json!({ "name": "bar", "length": 12 })).expect("stock");
592        assert_eq!(stock.cost, 1.0);
593        assert_eq!(stock.kerf, 0);
594        assert_eq!(stock.trim, 0);
595        assert_eq!(stock.available, None);
596
597        let options: OneDOptions = serde_json::from_value(json!({})).expect("options");
598        assert_eq!(options, OneDOptions::default());
599    }
600
601    #[test]
602    fn validation_rejects_missing_or_invalid_one_d_inputs() {
603        let missing_stock = OneDProblem { stock: Vec::new(), demands: sample_problem().demands };
604        assert!(matches!(
605            missing_stock.validate(),
606            Err(BinPackingError::InvalidInput(message))
607                if message == "at least one stock entry is required"
608        ));
609
610        let missing_demands = OneDProblem { stock: sample_problem().stock, demands: Vec::new() };
611        assert!(matches!(
612            missing_demands.validate(),
613            Err(BinPackingError::InvalidInput(message))
614                if message == "at least one demand entry is required"
615        ));
616
617        let zero_length_stock = OneDProblem {
618            stock: vec![Stock1D {
619                name: "bar".to_string(),
620                length: 0,
621                kerf: 0,
622                trim: 0,
623                cost: 1.0,
624                available: None,
625            }],
626            demands: vec![CutDemand1D { name: "cut".to_string(), length: 1, quantity: 1 }],
627        };
628        assert!(matches!(
629            zero_length_stock.validate(),
630            Err(BinPackingError::InvalidInput(message))
631                if message == "stock `bar` must have a positive length"
632        ));
633
634        let trimmed_away_stock = OneDProblem {
635            stock: vec![Stock1D {
636                name: "bar".to_string(),
637                length: 5,
638                kerf: 0,
639                trim: 5,
640                cost: 1.0,
641                available: None,
642            }],
643            demands: vec![CutDemand1D { name: "cut".to_string(), length: 1, quantity: 1 }],
644        };
645        assert!(matches!(
646            trimmed_away_stock.validate(),
647            Err(BinPackingError::InvalidInput(message))
648                if message == "stock `bar` has no usable length after trim"
649        ));
650
651        let zero_length_demand = OneDProblem {
652            stock: vec![Stock1D {
653                name: "bar".to_string(),
654                length: 5,
655                kerf: 0,
656                trim: 0,
657                cost: 1.0,
658                available: None,
659            }],
660            demands: vec![CutDemand1D { name: "cut".to_string(), length: 0, quantity: 1 }],
661        };
662        assert!(matches!(
663            zero_length_demand.validate(),
664            Err(BinPackingError::InvalidInput(message))
665                if message == "demand `cut` must have a positive length"
666        ));
667
668        let zero_quantity_demand = OneDProblem {
669            stock: vec![Stock1D {
670                name: "bar".to_string(),
671                length: 5,
672                kerf: 0,
673                trim: 0,
674                cost: 1.0,
675                available: None,
676            }],
677            demands: vec![CutDemand1D { name: "cut".to_string(), length: 1, quantity: 0 }],
678        };
679        assert!(matches!(
680            zero_quantity_demand.validate(),
681            Err(BinPackingError::InvalidInput(message))
682                if message == "demand `cut` must have a positive quantity"
683        ));
684    }
685
686    /// Regression test for the `MAX_DIMENSION` validation added to prevent the
687    /// exact backend from silently saturating `length * quantity` computations.
688    #[test]
689    fn validation_rejects_lengths_above_max_dimension() {
690        let oversized_stock = OneDProblem {
691            stock: vec![Stock1D {
692                name: "huge".to_string(),
693                length: MAX_DIMENSION + 1,
694                kerf: 0,
695                trim: 0,
696                cost: 1.0,
697                available: None,
698            }],
699            demands: vec![CutDemand1D { name: "cut".to_string(), length: 1, quantity: 1 }],
700        };
701        assert!(matches!(
702            oversized_stock.validate(),
703            Err(BinPackingError::InvalidInput(message))
704                if message.starts_with("stock `huge` length")
705        ));
706
707        let oversized_kerf = OneDProblem {
708            stock: vec![Stock1D {
709                name: "bar".to_string(),
710                length: 100,
711                kerf: MAX_DIMENSION + 1,
712                trim: 0,
713                cost: 1.0,
714                available: None,
715            }],
716            demands: vec![CutDemand1D { name: "cut".to_string(), length: 1, quantity: 1 }],
717        };
718        assert!(matches!(
719            oversized_kerf.validate(),
720            Err(BinPackingError::InvalidInput(message))
721                if message.starts_with("stock `bar` kerf")
722        ));
723
724        let oversized_trim = OneDProblem {
725            stock: vec![Stock1D {
726                name: "bar".to_string(),
727                length: 100,
728                kerf: 0,
729                trim: MAX_DIMENSION + 1,
730                cost: 1.0,
731                available: None,
732            }],
733            demands: vec![CutDemand1D { name: "cut".to_string(), length: 1, quantity: 1 }],
734        };
735        assert!(matches!(
736            oversized_trim.validate(),
737            Err(BinPackingError::InvalidInput(message))
738                if message.starts_with("stock `bar` trim")
739        ));
740
741        let oversized_demand = OneDProblem {
742            stock: vec![Stock1D {
743                name: "bar".to_string(),
744                length: 100,
745                kerf: 0,
746                trim: 0,
747                cost: 1.0,
748                available: None,
749            }],
750            demands: vec![CutDemand1D {
751                name: "cut".to_string(),
752                length: MAX_DIMENSION + 1,
753                quantity: 1,
754            }],
755        };
756        assert!(matches!(
757            oversized_demand.validate(),
758            Err(BinPackingError::InvalidInput(message))
759                if message.starts_with("demand `cut` length")
760        ));
761    }
762
763    #[test]
764    fn feasibility_and_piece_expansion_follow_declared_demands() {
765        let feasible = sample_problem();
766        feasible.validate().expect("sample input should validate");
767        feasible.ensure_feasible_demands().expect("sample input should be feasible");
768        assert_eq!(feasible.total_quantity(), 3);
769
770        let pieces = feasible.expanded_pieces();
771        assert_eq!(pieces.len(), 3);
772        assert_eq!(pieces[0].demand_index, 0);
773        assert_eq!(pieces[0].name, "leg");
774        assert_eq!(pieces[2].demand_index, 1);
775        assert_eq!(pieces[2].name, "brace");
776
777        let infeasible = OneDProblem {
778            stock: feasible.stock.clone(),
779            demands: vec![CutDemand1D { name: "oversized".to_string(), length: 9, quantity: 1 }],
780        };
781        assert!(matches!(
782            infeasible.ensure_feasible_demands(),
783            Err(BinPackingError::Infeasible1D { item, length })
784                if item == "oversized" && length == 9
785        ));
786    }
787
788    #[test]
789    fn packed_bin_accounts_for_kerf_and_rejects_overflow() {
790        let stock = Stock1D {
791            name: "bar".to_string(),
792            length: 12,
793            kerf: 1,
794            trim: 2,
795            cost: 1.0,
796            available: None,
797        };
798        assert_eq!(stock.usable_length(), 10);
799        assert_eq!(stock.adjusted_capacity(), 11);
800
801        let first = PieceInstance { demand_index: 0, name: "A".to_string(), length: 6 };
802        let second = PieceInstance { demand_index: 1, name: "B".to_string(), length: 3 };
803        let oversized = PieceInstance { demand_index: 2, name: "C".to_string(), length: 4 };
804
805        let mut bin = PackedBin::new(0);
806        assert!(bin.can_fit_piece(&first, &stock));
807        assert_eq!(bin.delta_for_piece(&first, &stock), Some(6));
808        assert!(bin.add_piece(first.clone(), &stock));
809        assert_eq!(bin.used_length(), 6);
810        assert_eq!(bin.remaining_length(&stock), 4);
811
812        assert_eq!(bin.delta_for_piece(&second, &stock), Some(4));
813        assert!(bin.add_piece(second, &stock));
814        assert_eq!(bin.used_length(), 10);
815        assert_eq!(bin.remaining_length(&stock), 0);
816
817        assert!(!bin.can_fit_piece(&oversized, &stock));
818        assert_eq!(bin.delta_for_piece(&oversized, &stock), None);
819        assert!(!bin.add_piece(oversized, &stock));
820    }
821
822    #[test]
823    fn solution_helpers_sort_layouts_and_prefer_exact_ties() {
824        let stock = vec![
825            Stock1D {
826                name: "slow".to_string(),
827                length: 10,
828                kerf: 0,
829                trim: 0,
830                cost: 2.0,
831                available: None,
832            },
833            Stock1D {
834                name: "fast".to_string(),
835                length: 10,
836                kerf: 0,
837                trim: 0,
838                cost: 1.0,
839                available: None,
840            },
841        ];
842
843        let bins = vec![
844            PackedBin {
845                stock_index: 0,
846                pieces: vec![PieceInstance {
847                    demand_index: 0,
848                    name: "small".to_string(),
849                    length: 3,
850                }],
851                occupied_length: 3,
852            },
853            PackedBin {
854                stock_index: 1,
855                pieces: vec![
856                    PieceInstance { demand_index: 1, name: "large".to_string(), length: 6 },
857                    PieceInstance { demand_index: 2, name: "medium".to_string(), length: 2 },
858                ],
859                occupied_length: 8,
860            },
861        ];
862        let unplaced = vec![
863            PieceInstance { demand_index: 0, name: "tiny".to_string(), length: 1 },
864            PieceInstance { demand_index: 1, name: "big".to_string(), length: 7 },
865        ];
866        let metrics = SolverMetrics1D {
867            iterations: 2,
868            generated_patterns: 0,
869            enumerated_patterns: 0,
870            explored_states: 0,
871            notes: vec!["test".to_string()],
872        };
873
874        let exact = OneDSolution::from_bins(
875            "column_generation",
876            true,
877            Some(2.0),
878            &stock,
879            &bins,
880            &unplaced,
881            metrics.clone(),
882        );
883        assert_eq!(exact.layouts[0].stock_name, "fast");
884        assert_eq!(exact.layouts[1].stock_name, "slow");
885        assert_eq!(exact.unplaced[0].name, "big");
886        assert_eq!(exact.total_cost, 3.0);
887        assert_eq!(exact.total_waste, 9);
888        assert_eq!(exact.stock_requirements.len(), 2);
889        assert_eq!(exact.stock_requirements[0].stock_name, "slow");
890        assert_eq!(exact.stock_requirements[0].used_quantity, 1);
891        assert_eq!(exact.stock_requirements[0].required_quantity, 1);
892        assert_eq!(exact.stock_requirements[1].stock_name, "fast");
893        assert_eq!(exact.stock_requirements[1].used_quantity, 1);
894        assert_eq!(exact.stock_requirements[1].additional_quantity_needed, 0);
895
896        let heuristic = OneDSolution { exact: false, ..exact.clone() };
897        assert!(exact.is_better_than(&heuristic));
898    }
899
900    #[test]
901    fn stock_requirements_can_record_shortfalls_against_inventory() {
902        let stock = vec![Stock1D {
903            name: "bar".to_string(),
904            length: 10,
905            kerf: 0,
906            trim: 0,
907            cost: 1.0,
908            available: Some(1),
909        }];
910        let bins = vec![PackedBin {
911            stock_index: 0,
912            pieces: vec![
913                PieceInstance { demand_index: 0, name: "A".to_string(), length: 5 },
914                PieceInstance { demand_index: 1, name: "B".to_string(), length: 5 },
915            ],
916            occupied_length: 10,
917        }];
918        let mut solution = OneDSolution::from_bins(
919            "first_fit_decreasing",
920            false,
921            None,
922            &stock,
923            &bins,
924            &[],
925            SolverMetrics1D {
926                iterations: 1,
927                generated_patterns: 0,
928                enumerated_patterns: 0,
929                explored_states: 0,
930                notes: Vec::new(),
931            },
932        );
933
934        solution.set_required_stock_counts(&stock, &[2]);
935
936        assert_eq!(solution.stock_requirements.len(), 1);
937        assert_eq!(solution.stock_requirements[0].available_quantity, Some(1));
938        assert_eq!(solution.stock_requirements[0].used_quantity, 1);
939        assert_eq!(solution.stock_requirements[0].required_quantity, 2);
940        assert_eq!(solution.stock_requirements[0].additional_quantity_needed, 1);
941    }
942
943    /// `OneDSolution::is_better_than` compares on a 5-key tuple:
944    /// (unplaced.len, stock_count, total_waste, total_cost, !exact).
945    /// The existing `solution_helpers_sort_layouts_and_prefer_exact_ties`
946    /// test covers the last key; this one pins down the other four.
947    #[test]
948    fn one_d_is_better_than_tie_breaks_on_each_key() {
949        let stock = vec![Stock1D {
950            name: "bar".to_string(),
951            length: 10,
952            kerf: 0,
953            trim: 0,
954            cost: 1.0,
955            available: None,
956        }];
957        let bins = vec![PackedBin {
958            stock_index: 0,
959            pieces: vec![PieceInstance { demand_index: 0, name: "cut".to_string(), length: 5 }],
960            occupied_length: 5,
961        }];
962        let base = OneDSolution::from_bins(
963            "test",
964            false,
965            None,
966            &stock,
967            &bins,
968            &[],
969            SolverMetrics1D {
970                iterations: 0,
971                generated_patterns: 0,
972                enumerated_patterns: 0,
973                explored_states: 0,
974                notes: Vec::new(),
975            },
976        );
977
978        // Fewer unplaced wins (primary key).
979        let more_unplaced = OneDSolution {
980            unplaced: vec![CutAssignment1D { name: "u".to_string(), length: 1 }],
981            ..base.clone()
982        };
983        assert!(base.is_better_than(&more_unplaced));
984        assert!(!more_unplaced.is_better_than(&base));
985
986        // Fewer stock wins when unplaced ties.
987        let more_stock = OneDSolution { stock_count: base.stock_count + 1, ..base.clone() };
988        assert!(base.is_better_than(&more_stock));
989
990        // Less waste wins when unplaced and stock_count tie.
991        let more_waste = OneDSolution { total_waste: base.total_waste + 10, ..base.clone() };
992        assert!(base.is_better_than(&more_waste));
993
994        // Lower cost wins when every preceding key ties.
995        let more_cost = OneDSolution { total_cost: base.total_cost + 1.0, ..base.clone() };
996        assert!(base.is_better_than(&more_cost));
997
998        // Identical solutions are not "better than" each other.
999        assert!(!base.is_better_than(&base));
1000    }
1001}