feat(core): add data types and Rng alias

Plain-data structs and the seeded Rng alias from spec §7. Each lives in
its own file under src/core/ with unit tests:

- Direction, Objective, ObjectiveSpace (with as_minimization negating
  only Maximize axes)
- Evaluation (is_feasible == constraint_violation <= 0.0)
- Candidate<D>, Population<D> (concrete, public fields, From<Vec<...>>)
- OptimizationResult<D>
- type Rng = rand::rngs::StdRng + rng_from_seed, so no public trait is
  generic over the RNG (spec §2.5)

All public types behind #[cfg_attr(feature = "serde", derive(...))] so
the optional feature wires up without changing the default surface.
This commit is contained in:
2026-05-04 19:18:01 -06:00
parent b827310822
commit f6f41eda35
8 changed files with 435 additions and 13 deletions
+35
View File
@@ -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<D> {
/// The decision (input to the problem).
pub decision: D,
/// The evaluated objective values and constraint violation.
pub evaluation: Evaluation,
}
impl<D> Candidate<D> {
/// 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]);
}
}
+58
View File
@@ -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<f64>,
/// 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<f64>) -> Self {
Self { objectives, constraint_violation: 0.0 }
}
/// Build an evaluation with a known total constraint violation.
pub fn constrained(objectives: Vec<f64>, 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());
}
}
+15
View File
@@ -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::*;
+138
View File
@@ -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<String>) -> Self {
Self { name: name.into(), direction: Direction::Minimize }
}
/// Create a maximize objective with the given name.
pub fn maximize(name: impl Into<String>) -> 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<Objective>,
}
impl ObjectiveSpace {
/// Build an objective space from the given objectives.
pub fn new(objectives: Vec<Objective>) -> 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<f64> {
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());
}
}
+78
View File
@@ -0,0 +1,78 @@
//! A friendly wrapper around `Vec<Candidate<D>>`.
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<D> {
/// The candidates.
pub candidates: Vec<Candidate<D>>,
}
impl<D> Population<D> {
/// Wrap a vector of candidates as a `Population`.
pub fn new(candidates: Vec<Candidate<D>>) -> 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<Item = &Candidate<D>> {
self.candidates.iter()
}
/// Unwrap into the inner `Vec<Candidate<D>>`.
pub fn into_vec(self) -> Vec<Candidate<D>> {
self.candidates
}
}
impl<D> From<Vec<Candidate<D>>> for Population<D> {
fn from(candidates: Vec<Candidate<D>>) -> Self {
Self::new(candidates)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::evaluation::Evaluation;
fn cand(x: f64) -> Candidate<f64> {
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<f64> = vec![cand(1.0)].into();
assert_eq!(pop.len(), 1);
}
#[test]
fn empty_population() {
let pop: Population<f64> = Population::new(Vec::new());
assert!(pop.is_empty());
}
}
+69
View File
@@ -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<D> {
/// The final population (or all sampled candidates, depending on algorithm).
pub population: Population<D>,
/// The non-dominated subset of the final population or archive.
pub pareto_front: Vec<Candidate<D>>,
/// The single-objective best, or `None` for multi-objective problems.
pub best: Option<Candidate<D>>,
/// Total number of `Problem::evaluate` calls.
pub evaluations: usize,
/// Total number of major optimizer iterations.
pub generations: usize,
}
impl<D> OptimizationResult<D> {
/// Construct an `OptimizationResult` from its parts.
pub fn new(
population: Population<D>,
pareto_front: Vec<Candidate<D>>,
best: Option<Candidate<D>>,
evaluations: usize,
generations: usize,
) -> Self {
Self { population, pareto_front, best, evaluations, generations }
}
/// The final population.
pub fn population(&self) -> &Population<D> {
&self.population
}
/// The non-dominated subset.
pub fn pareto_front(&self) -> &[Candidate<D>] {
&self.pareto_front
}
/// The single-objective best, when meaningful.
pub fn best(&self) -> Option<&Candidate<D>> {
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);
}
}
+38
View File
@@ -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);
}
}
+4 -13
View File
@@ -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;