diff --git a/src/core/candidate.rs b/src/core/candidate.rs new file mode 100644 index 0000000..9839b4f --- /dev/null +++ b/src/core/candidate.rs @@ -0,0 +1,35 @@ +//! A decision paired with its evaluation. + +use crate::core::evaluation::Evaluation; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +/// A decision together with its evaluated objective values. +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq)] +pub struct Candidate { + /// The decision (input to the problem). + pub decision: D, + /// The evaluated objective values and constraint violation. + pub evaluation: Evaluation, +} + +impl Candidate { + /// Pair a decision with its evaluation. + pub fn new(decision: D, evaluation: Evaluation) -> Self { + Self { decision, evaluation } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_pairs_decision_and_evaluation() { + let c = Candidate::new(vec![1.0, 2.0], Evaluation::new(vec![5.0])); + assert_eq!(c.decision, vec![1.0, 2.0]); + assert_eq!(c.evaluation.objectives, vec![5.0]); + } +} diff --git a/src/core/evaluation.rs b/src/core/evaluation.rs new file mode 100644 index 0000000..8839807 --- /dev/null +++ b/src/core/evaluation.rs @@ -0,0 +1,58 @@ +//! Objective values and total constraint violation for a single decision. + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +/// The result of evaluating a decision: objective values plus total constraint violation. +/// +/// A non-positive `constraint_violation` means the candidate is feasible. +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq)] +pub struct Evaluation { + /// Objective values in the order declared by the problem. + pub objectives: Vec, + /// Total constraint violation. `<= 0.0` is feasible; positive is infeasible. + pub constraint_violation: f64, +} + +impl Evaluation { + /// Build a feasible evaluation from objective values. + pub fn new(objectives: Vec) -> Self { + Self { objectives, constraint_violation: 0.0 } + } + + /// Build an evaluation with a known total constraint violation. + pub fn constrained(objectives: Vec, constraint_violation: f64) -> Self { + Self { objectives, constraint_violation } + } + + /// Returns `true` when `constraint_violation <= 0.0`. + pub fn is_feasible(&self) -> bool { + self.constraint_violation <= 0.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_is_feasible() { + let e = Evaluation::new(vec![1.0, 2.0]); + assert_eq!(e.constraint_violation, 0.0); + assert!(e.is_feasible()); + } + + #[test] + fn constrained_sets_violation() { + let e = Evaluation::constrained(vec![0.0], 0.5); + assert!(!e.is_feasible()); + assert_eq!(e.constraint_violation, 0.5); + } + + #[test] + fn zero_or_negative_violation_is_feasible() { + assert!(Evaluation::constrained(vec![0.0], 0.0).is_feasible()); + assert!(Evaluation::constrained(vec![0.0], -1.0).is_feasible()); + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs new file mode 100644 index 0000000..498b5ff --- /dev/null +++ b/src/core/mod.rs @@ -0,0 +1,15 @@ +//! Concrete data types and the `Problem` trait that the rest of the crate is built on. + +pub mod candidate; +pub mod evaluation; +pub mod objective; +pub mod population; +pub mod result; +pub mod rng; + +pub use candidate::*; +pub use evaluation::*; +pub use objective::*; +pub use population::*; +pub use result::*; +pub use rng::*; diff --git a/src/core/objective.rs b/src/core/objective.rs new file mode 100644 index 0000000..79838f2 --- /dev/null +++ b/src/core/objective.rs @@ -0,0 +1,138 @@ +//! Objective directions, named objectives, and the objective space. + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +/// Whether an objective should be minimized or maximized. +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + /// Smaller objective values are better. + Minimize, + /// Larger objective values are better. + Maximize, +} + +/// A named objective and its optimization direction. +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Objective { + /// Human-readable name of the objective. + pub name: String, + /// Whether to minimize or maximize. + pub direction: Direction, +} + +impl Objective { + /// Create a minimize objective with the given name. + pub fn minimize(name: impl Into) -> Self { + Self { name: name.into(), direction: Direction::Minimize } + } + + /// Create a maximize objective with the given name. + pub fn maximize(name: impl Into) -> Self { + Self { name: name.into(), direction: Direction::Maximize } + } +} + +/// The collection of objectives that define a problem's objective space. +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ObjectiveSpace { + /// Objectives in declaration order. + pub objectives: Vec, +} + +impl ObjectiveSpace { + /// Build an objective space from the given objectives. + pub fn new(objectives: Vec) -> Self { + Self { objectives } + } + + /// Number of objectives. + pub fn len(&self) -> usize { + self.objectives.len() + } + + /// Returns `true` if there are zero objectives. + pub fn is_empty(&self) -> bool { + self.objectives.is_empty() + } + + /// Returns `true` if there is exactly one objective. + pub fn is_single_objective(&self) -> bool { + self.objectives.len() == 1 + } + + /// Returns `true` if there are two or more objectives. + pub fn is_multi_objective(&self) -> bool { + self.objectives.len() >= 2 + } + + /// Convert objective values into minimization orientation. + /// + /// Minimize objectives are returned unchanged; Maximize objectives are + /// negated. In v1, this zips to the shorter of the two lengths. + pub fn as_minimization(&self, values: &[f64]) -> Vec { + debug_assert_eq!( + values.len(), + self.objectives.len(), + "objective value count must match ObjectiveSpace length", + ); + self.objectives + .iter() + .zip(values.iter()) + .map(|(obj, &v)| match obj.direction { + Direction::Minimize => v, + Direction::Maximize => -v, + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn minimize_constructor_sets_direction() { + let o = Objective::minimize("cost"); + assert_eq!(o.name, "cost"); + assert_eq!(o.direction, Direction::Minimize); + } + + #[test] + fn maximize_constructor_sets_direction() { + let o = Objective::maximize("accuracy"); + assert_eq!(o.name, "accuracy"); + assert_eq!(o.direction, Direction::Maximize); + } + + #[test] + fn as_minimization_negates_maximize_only() { + let space = ObjectiveSpace::new(vec![ + Objective::minimize("cost"), + Objective::maximize("accuracy"), + ]); + assert_eq!(space.as_minimization(&[10.0, 0.8]), vec![10.0, -0.8]); + } + + #[test] + fn lengths_and_predicates() { + let single = ObjectiveSpace::new(vec![Objective::minimize("f")]); + assert!(single.is_single_objective()); + assert!(!single.is_multi_objective()); + assert!(!single.is_empty()); + assert_eq!(single.len(), 1); + + let multi = ObjectiveSpace::new(vec![ + Objective::minimize("f1"), + Objective::minimize("f2"), + ]); + assert!(multi.is_multi_objective()); + assert!(!multi.is_single_objective()); + + let empty = ObjectiveSpace::new(Vec::new()); + assert!(empty.is_empty()); + } +} diff --git a/src/core/population.rs b/src/core/population.rs new file mode 100644 index 0000000..830c143 --- /dev/null +++ b/src/core/population.rs @@ -0,0 +1,78 @@ +//! A friendly wrapper around `Vec>`. + +use crate::core::candidate::Candidate; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +/// A collection of evaluated candidates. +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq)] +pub struct Population { + /// The candidates. + pub candidates: Vec>, +} + +impl Population { + /// Wrap a vector of candidates as a `Population`. + pub fn new(candidates: Vec>) -> Self { + Self { candidates } + } + + /// Number of candidates. + pub fn len(&self) -> usize { + self.candidates.len() + } + + /// Returns `true` if there are no candidates. + pub fn is_empty(&self) -> bool { + self.candidates.is_empty() + } + + /// Iterate over the candidates by reference. + pub fn iter(&self) -> impl Iterator> { + self.candidates.iter() + } + + /// Unwrap into the inner `Vec>`. + pub fn into_vec(self) -> Vec> { + self.candidates + } +} + +impl From>> for Population { + fn from(candidates: Vec>) -> Self { + Self::new(candidates) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::evaluation::Evaluation; + + fn cand(x: f64) -> Candidate { + Candidate::new(x, Evaluation::new(vec![x])) + } + + #[test] + fn new_len_iter_into_vec() { + let pop = Population::new(vec![cand(1.0), cand(2.0)]); + assert_eq!(pop.len(), 2); + assert!(!pop.is_empty()); + assert_eq!(pop.iter().count(), 2); + assert_eq!(pop.into_vec().len(), 2); + } + + #[test] + fn from_vec_works() { + let pop: Population = vec![cand(1.0)].into(); + assert_eq!(pop.len(), 1); + } + + #[test] + fn empty_population() { + let pop: Population = Population::new(Vec::new()); + assert!(pop.is_empty()); + } +} diff --git a/src/core/result.rs b/src/core/result.rs new file mode 100644 index 0000000..425877f --- /dev/null +++ b/src/core/result.rs @@ -0,0 +1,69 @@ +//! Standard return type for optimizers. + +use crate::core::candidate::Candidate; +use crate::core::population::Population; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +/// The output of an optimization run. +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone)] +pub struct OptimizationResult { + /// The final population (or all sampled candidates, depending on algorithm). + pub population: Population, + /// The non-dominated subset of the final population or archive. + pub pareto_front: Vec>, + /// The single-objective best, or `None` for multi-objective problems. + pub best: Option>, + /// Total number of `Problem::evaluate` calls. + pub evaluations: usize, + /// Total number of major optimizer iterations. + pub generations: usize, +} + +impl OptimizationResult { + /// Construct an `OptimizationResult` from its parts. + pub fn new( + population: Population, + pareto_front: Vec>, + best: Option>, + evaluations: usize, + generations: usize, + ) -> Self { + Self { population, pareto_front, best, evaluations, generations } + } + + /// The final population. + pub fn population(&self) -> &Population { + &self.population + } + + /// The non-dominated subset. + pub fn pareto_front(&self) -> &[Candidate] { + &self.pareto_front + } + + /// The single-objective best, when meaningful. + pub fn best(&self) -> Option<&Candidate> { + self.best.as_ref() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::evaluation::Evaluation; + + #[test] + fn accessors_return_expected_data() { + let cand = Candidate::new(1.0_f64, Evaluation::new(vec![1.0])); + let pop = Population::new(vec![cand.clone()]); + let r = OptimizationResult::new(pop, vec![cand.clone()], Some(cand.clone()), 5, 2); + assert_eq!(r.population().len(), 1); + assert_eq!(r.pareto_front().len(), 1); + assert!(r.best().is_some()); + assert_eq!(r.evaluations, 5); + assert_eq!(r.generations, 2); + } +} diff --git a/src/core/rng.rs b/src/core/rng.rs new file mode 100644 index 0000000..8701942 --- /dev/null +++ b/src/core/rng.rs @@ -0,0 +1,38 @@ +//! Single seeded RNG type used throughout the crate. + +use rand::SeedableRng; + +/// The standard RNG used by `Initializer`, `Variation`, and built-in optimizers. +/// +/// Fixed to a single concrete type so the public traits never need to be +/// generic over the RNG. +pub type Rng = rand::rngs::StdRng; + +/// Build a deterministic [`Rng`] from a 64-bit seed. +pub fn rng_from_seed(seed: u64) -> Rng { + Rng::seed_from_u64(seed) +} + +#[cfg(test)] +mod tests { + use super::*; + use rand::Rng as _; + + #[test] + fn same_seed_same_sequence() { + let mut a = rng_from_seed(42); + let mut b = rng_from_seed(42); + let av: u64 = a.random(); + let bv: u64 = b.random(); + assert_eq!(av, bv); + } + + #[test] + fn different_seed_different_sequence() { + let mut a = rng_from_seed(1); + let mut b = rng_from_seed(2); + let av: u64 = a.random(); + let bv: u64 = b.random(); + assert_ne!(av, bv); + } +} diff --git a/src/lib.rs b/src/lib.rs index b93cf3f..d288c43 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,14 +1,5 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right -} +//! `heuropt` — a practical Rust toolkit for heuristic single-, multi-, and +//! many-objective optimization. See `docs/heuropt_tech_design_spec.md` for the +//! full design. -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} +pub mod core;