gp-lite
v2.4.1
Published
Tiny GA/GP engine for TypeScript/JS with first-class types, Zod validation, deterministic RNG, and budget-aware runs.
Maintainers
Readme
gp-lite
Genetic algorithm/programming library for TypeScript. Evolves solutions through selection, crossover, and mutation.
Install
# npm
npm i gp-lite
# pnpm
pnpm add gp-lite
# bun
bun add gp-liteQuick Start
import { GPLite, type GPProblem, mulberry32 } from "gp-lite"
// Define your genome type and problem
const target = "hello world"
const alphabet = "abcdefghijklmnopqrstuvwxyz "
const TextEvolution: GPProblem<string> = {
createRandom: (rng) =>
Array.from({ length: target.length },
() => alphabet[rng.int(alphabet.length)]
).join(''),
fitness: (text) =>
Array.from(text).reduce((sum, ch, i) =>
sum + (ch === target[i] ? 1 : 0), 0
),
mutate: (text, rng) => {
const i = rng.int(text.length)
const ch = alphabet[rng.int(alphabet.length)]
return text.slice(0, i) + ch + text.slice(i + 1)
},
crossover: (a, b, rng) => {
const cut = rng.int(a.length)
return [
a.slice(0, cut) + b.slice(cut),
b.slice(0, cut) + a.slice(cut)
]
},
}
const gp = new GPLite(TextEvolution, {
popSize: 100,
generations: 300,
rng: mulberry32(42), // deterministic
})
const result = gp.run()
console.log(result.bestGenome) // "hello world"API
GPProblem
Define how to create, evaluate, and evolve genomes:
interface GPProblem<T> {
createRandom: (rng: RNG) => T
fitness: (genome: T) => number
mutate: (genome: T, rng: RNG) => T
crossover: (a: T, b: T, rng: RNG) => T | T[] // single child or array
}All operators must return new instances (immutable).
Crossover patterns:
// Single child (direct return)
crossover: (a, b, rng) => rng.float() < 0.5 ? a : b
// Single child (wrapped)
crossover: (a, b, rng) => [combine(a, b)]
// Two children (classic)
crossover: (a, b, rng) => [child1, child2]GPLite Constructor
new GPLite(problem: GPProblem<T>, config?: GPConfig<T>)Config options:
{
popSize: 100, // population size
generations: 1000, // max iterations
// Operators (must sum to 1)
cxProb: 0.8, // crossover probability
mutProb: 0.2, // mutation probability
immigration: 0.02, // random newcomers per gen
// Selection
tournament: 3, // tournament size
elite: 2, // preserve N best
// Early stopping
targetFitness: 100, // stop if reached
stall: 50, // stop after N gens without improvement
maxWallMs: 5000, // time budget (ms)
maxEvaluations: 10000, // evaluation budget
// Utilities
rng: mulberry32(42), // seeded RNG for reproducibility
cacheFitness: true, // memoize fitness calls
fitnessKey: (g) => g, // custom cache key function
// Hooks
hooks: {
onIteration: ({ generation, bestFitness }) => {},
onGenerationStart: ({ generation }) => {},
onGenerationEnd: ({ generation, bestGenome }) => {},
},
// Logging
verbose: true, // log progress to console
exposePopulation: true, // include full population in hooks
}Run
const result = gp.run()Returns:
{
bestGenome: T,
bestFitness: number,
generations: number,
stopReason: "generations" | "target" | "stall" | "time" | "evaluations",
bestGenomeHistory: T[], // best genome at each generation
metrics: {
evaluations: number,
elapsedMs: number,
immigrants: number, // random genomes added
}
}Async
GPLite accepts both sync and async problems:
import { GPLite, type GPProblemAsync } from "gp-lite"
const problem: GPProblemAsync<T> = {
createRandom: async (rng) => ...,
fitness: async (genome) => ...,
mutate: async (genome, rng) => ...,
crossover: async (a, b, rng) => ...,
}
const gp = new GPLite(problem, {
concurrency: 4, // parallel evaluations
fitnessTimeoutMs: 1000, // per-eval timeout
signal: abortController.signal,
})
const result = await gp.run()For pure synchronous code without async overhead, use GPLiteSync.
Examples
Schedule Optimization
type Schedule = number[] // employee ID per shift
const problem: GPProblem<Schedule> = {
createRandom: (rng) =>
Array.from({ length: 21 }, () => rng.int(10)),
fitness: (schedule) => {
const hours = new Array(10).fill(0)
let score = 0
schedule.forEach((emp, shift) => {
hours[emp] += 8
if (shift >= 15) score += 10 // weekend bonus
})
hours.forEach(h => {
if (h > 40) score -= (h - 40) * 20 // overtime penalty
})
return score
},
mutate: (s, rng) => {
const copy = s.slice()
copy[rng.int(s.length)] = rng.int(10)
return copy
},
crossover: (a, b, rng) => {
const cut = rng.int(a.length)
return [
[...a.slice(0, cut), ...b.slice(cut)],
[...b.slice(0, cut), ...a.slice(cut)]
]
},
}Neural Architecture Search
type Architecture = {
layers: number[]
activations: string[]
dropout: number[]
}
const problem: GPProblemAsync<Architecture> = {
fitness: async (arch) => {
const model = buildModel(arch)
const { accuracy } = await trainAndValidate(model)
return accuracy
},
// ...
}
const gp = new GPLite(problem, {
concurrency: 8,
fitnessTimeoutMs: 30000,
})Utilities
Simple run() function
For quick experiments without a full engine:
import { run } from "gp-lite"
const result = run<string>({
initialPopulation: ["...", "..."], // starting population
iterations: 300,
fitness: (g) => score(g),
mutate: (g, rng) => mutated(g),
crossover: (a, b, rng) => child, // single child or array
cxProb: 0.8,
verbose: true,
})Async version: runAsync() with same API.
Progress tracking
Built-in logging:
const gp = new GPLite(problem, {
verbose: true, // logs every generation
})
// Or customize:
const gp = new GPLite(problem, {
verbose: { every: 10 }, // log every 10 generations
})Custom tracking with hooks:
const gp = new GPLite(problem, {
hooks: {
onIteration: ({ generation, bestFitness, avgFitness }) => {
console.log(`Gen ${generation}: ${bestFitness}`)
}
}
})Access full population in hooks:
const gp = new GPLite(problem, {
exposePopulation: true,
hooks: {
onGenerationEnd: ({ population }) => {
// population is sorted by fitness (best first)
console.log('Top 3:', population.slice(0, 3))
}
}
})Cost estimation
import { estimateRun } from "gp-lite"
const estimate = estimateRun(
{ popSize: 200, generations: 500 },
{ units: {
perEvaluationMs: 10,
perEvaluationCost: 0.001
}}
)
console.log(`Time: ${estimate.timeMs.expectedTotal}ms`)
console.log(`Cost: $${estimate.monetary.expectedTotal}`)Unit tests
Test your fitness function:
const problem: GPProblem<number[]> = {
// ... operators ...
unitTests: [
{
name: "optimal solution",
genome: [1, 1, 1, 1, 1],
test: (fitness) => fitness === 5,
expect: { exact: 5 }
},
{
name: "fitness is non-negative",
genome: [-1, -1, -1],
test: (fitness) => fitness >= 0,
expect: { min: 0 }
}
]
}
const results = gp.runUnitTests()Validation
Uses Zod for runtime validation:
import { validateConfig, validateProblem } from "gp-lite"
validateConfig(config) // throws on invalid
validateProblem(problem) // throws on invalidCLI
Estimate costs from command line:
npm run build
gp-lite-estimate --config examples/run.json
gp-lite-estimate --popSize 200 --generations 500 \
--perEvaluationMs 10 --perEvaluationCost 0.001 --jsonTips
Stuck at local optimum?
- Increase
mutProborimmigration - Decrease
tournamentsize - Increase
popSize
Too many invalid genomes?
- Relax fitness constraints
- Add repair logic in
mutate - Return
-Infinityfor invalid genomes
Non-deterministic results?
- Always use seeded RNG:
rng: mulberry32(seed) - Ensure pure functions (no external state/randomness)
Slow performance?
- Enable
cacheFitness: true - Reduce
popSizeorgenerations - Use
maxWallMsfor time limits - Profile your fitness function
Advanced
Dynamic parameters
const gp = new GPLite(problem, {
hooks: {
onGenerationEnd: ({ generation }) => {
gp.cfg.mutProb *= 0.99 // decay mutation
}
}
})Multi-objective (NSGA-II)
For true Pareto optimization, use GPLiteMulti:
import { GPLiteMulti, type GPProblemMulti, type VectorFitness } from "gp-lite"
interface MyFitness extends VectorFitness {
speed: number
accuracy: number
cost: number
}
const problem: GPProblemMulti<Solution, MyFitness> = {
createRandom: (rng) => ...,
fitness: (solution) => ({
speed: measureSpeed(solution),
accuracy: measureAccuracy(solution),
cost: measureCost(solution),
}),
mutate: (solution, rng) => ...,
crossover: (a, b, rng) => ...,
}
const gp = new GPLiteMulti(problem, {
popSize: 100,
generations: 200,
objectives: { speed: "max", accuracy: "max", cost: "min" },
})
const result = await gp.run()
console.log(result.paretoFront) // non-dominated solutions
console.log(result.hypervolume) // convergence metricFor simple weighted-sum approach (single-objective):
fitness: (solution) => {
const speed = measureSpeed(solution)
const accuracy = measureAccuracy(solution)
const cost = measureCost(solution)
return speed * 0.3 + accuracy * 0.5 - cost * 0.2
}Repair invalid solutions
mutate: (genome, rng) => {
let mutated = doMutation(genome, rng)
return repairConstraints(mutated)
}Or mark as invalid:
fitness: (genome) => {
if (!isValid(genome)) return -Infinity
return score(genome)
}Resources
- DEFAULTS.md - All default values and proportions
- ARCHITECTURE.md - Implementation details
- CHANGELOG.md - Version history
License
MIT
