Understand

Writing a genetic algorithm

Inherit an algorithm, supply a fitness, run it by Path — the knapsack from start to answer.

An evolutionary algorithm in Hilbert is two declarations: a category in the Prelude that states the algorithm once, and a process that names it as its parent and says what the problem is. Nothing about the algorithm is written in C#, and nothing about the problem is written in the category.

1. State the problem as a fitness

A knapsack choice is a bit per item. A whole population is a matrix \(P \in \{0,1\}^{n \times g}\), so the value of every choice at once is one contraction, and an overweight choice is fined the total value per unit over:

-- Knapsack.hb
open Prelude.Evolution

process Knapsack(values, weights, capacity, size, genes, seed) : BitGenetic {
  def packed(P)  ≔ einsum("pi,i->p", P, values)
  def load(P)    ≔ einsum("pi,i->p", P, weights)
  def fitness(P) ≔ packed(P) − einsum("i->", values) · max(load(P) − capacity, 0)
}

Everything maximises — to minimise a cost, negate it, as Evolution’s Ellipsoid, Rosenbrock and Rastrigin samples all write fitness(P) ≔ 0 − cost(P). The parameters size, genes and seed are the ones Genetic reads; forget one and the build says 'Knapsack' is Genetic and has no 'size'; add it to the parameter list at the process line.

2. Run it

The build generates one class, Knapsack, flattened from the category and the process. Construct it and advance it:

var knapsack = new Knapsack(values, weights, 80d, size: 40d, genes: 10d, seed: 20260915d);
knapsack.Path(40L);                             // forty generations; the path is the population at each one
var answer = knapsack.Best().Values;            // [1, 1, 1, 1, 1, 0, 0, 0, 0, 0]
var worth  = knapsack.BestFitness().Values[0];   // 390, for this seed at generation 24 of the 40

values and weights are Tensors (item worths and weights); capacity is a scalar Tensor, which a plain 80d converts to. Two objects built from one seed evolve one population: every draw is fresh, renewed before each generation from a generator seeded once, so Path is a pure function of the seed and the generation count — this is the exact case EvolutionCase.Knapsack_from_seed_20260915_reaches_its_brute_forced_optimum asserts, against the ten items and the capacity of 80 that make 390 the only optimum brute force finds.

3. Change an operator

A child replaces anything its category defines by redefining it with the same parameters:

process Knapsack(values, weights, capacity, size, genes, seed) : BitGenetic {
  def cross(a, b, k) ≔ twoPoint(a, b, cuts)
  def elites         ≔ 2
  def fitness(P)     ≔ einsum("pi,i->p", P, values) − einsum("i->", values) · max(einsum("pi,i->p", P, weights) − capacity, 0)
}

Changing the number of parameters is refused, naming the category that actually declares it — cross is BitGenetic’s, not Genetic’s own: 'Knapsack' overrides cross with 2 parameters; BitGenetic declares it with 3.

4. Pick another algorithm

Your genome Category Supply
bits BitGenetic fitness(P)
reals in a box RealGenetic, Differential, Swarm, Strategy, Covariance fitness(P)
whole numbers IntegerGenetic fitness(P)
a permutation PermutationGenetic fitness(P)
several objectives Pareto objectives(P)
a tour over a distance matrix Colony nothing

Ants.hb in the sample project is process Ants(size, cities, distances, seed) : Colony { } — an empty body, since the colony needs no problem-specific definition at all.

The Genetics operators also work in any process of your own, without a category — Seeker.hb in the sample project evolves one-max that way, with its own draws declared once and read a generation at a time by generationOf. A spawn draw must never be the same draw a step reads: Seeker gives the starting population its own draw, born, separate from mix — sharing one would cross the first population by the very uniforms that just made it.