Files
evolution/src/lib/neatArena/genome.ts
2026-01-14 11:13:33 +11:00

236 lines
6.2 KiB
TypeScript

/**
* NEAT Genome Implementation
*
* Represents a neural network genome with node genes and connection genes.
* Implements the core NEAT genome structure as described in the original paper.
*/
export type NodeType = 'input' | 'hidden' | 'output';
export type ActivationFunction = 'tanh' | 'sigmoid' | 'relu' | 'linear';
/**
* Node gene - represents a neuron
*/
export interface NodeGene {
id: number;
type: NodeType;
activation: ActivationFunction;
}
/**
* Connection gene - represents a synapse
*/
export interface ConnectionGene {
innovation: number;
from: number;
to: number;
weight: number;
enabled: boolean;
}
/**
* Complete genome
*/
/**
* Complete genome
*/
export interface Genome {
id: number;
nodes: NodeGene[];
connections: ConnectionGene[];
fitness: number;
}
/**
* Global innovation tracker for historical markings
*/
export class InnovationTracker {
private currentInnovation: number = 0;
private innovationHistory: Map<string, number> = new Map();
/**
* Get or create innovation number for a connection
*/
getInnovation(from: number, to: number): number {
const key = `${from}->${to}`;
if (this.innovationHistory.has(key)) {
return this.innovationHistory.get(key)!;
}
const innovation = this.currentInnovation++;
this.innovationHistory.set(key, innovation);
return innovation;
}
/**
* Reset innovation tracking (useful for new experiments)
*/
reset(): void {
this.currentInnovation = 0;
this.innovationHistory.clear();
}
/**
* Get current innovation count
*/
getCurrentInnovation(): number {
return this.currentInnovation;
}
}
let nextGenomeId = 0;
/**
* Create a minimal genome with only input and output nodes, fully connected
*/
export function createMinimalGenome(
inputCount: number,
outputCount: number,
innovationTracker: InnovationTracker
): Genome {
const nodes: NodeGene[] = [];
const connections: ConnectionGene[] = [];
// Create input nodes (IDs 0 to inputCount-1)
// PLUS one extra for Bias
for (let i = 0; i < inputCount + 1; i++) {
nodes.push({
id: i,
type: 'input',
activation: 'linear',
});
}
// Create output nodes (IDs starting from inputCount + 1)
// Fix: Bias node uses ID `inputCount`, so outputs must start at `inputCount + 1`
for (let i = 0; i < outputCount; i++) {
nodes.push({
id: inputCount + 1 + i,
type: 'output',
activation: 'tanh',
});
}
// Create fully connected minimal genome
// Iterate through all inputs INCLUDING Bias
for (let i = 0; i < inputCount + 1; i++) {
const inputNode = i;
for (let o = 0; o < outputCount; o++) {
const outputNode = inputCount + 1 + o; // target the shifted output IDs
const innovation = innovationTracker.getInnovation(inputNode, outputNode);
let weight = (Math.random() * 2.0) - 1.0;
// FORCE AGGRESSION:
// If connection is from BIAS node (index == inputCount) TO SHOOT node (index 3 of output)
// Warning: Output indices are 0..4 relative to output block.
// Shoot is 4th output (moveX, moveY, turn, shoot, reserved).
if (inputNode === inputCount && o === 3) {
weight = 1.0 + Math.random(); // Range [1.0, 2.0] -> Strong Positive Bias
}
connections.push({
innovation,
from: inputNode,
to: outputNode,
weight,
enabled: true,
});
}
}
return {
id: nextGenomeId++,
nodes,
connections,
fitness: 0,
};
}
/**
* Clone a genome (deep copy)
*/
export function cloneGenome(genome: Genome): Genome {
return {
id: nextGenomeId++,
nodes: genome.nodes.map(n => ({ ...n })),
connections: genome.connections.map(c => ({ ...c })),
fitness: genome.fitness,
};
}
/**
* Get next available node ID
*/
export function getNextNodeId(genome: Genome): number {
return Math.max(...genome.nodes.map(n => n.id)) + 1;
}
/**
* Check if a connection already exists
*/
export function connectionExists(genome: Genome, from: number, to: number): boolean {
return genome.connections.some(c => c.from === from && c.to === to);
}
/**
* Check if adding a connection would create a cycle (for feedforward networks)
*/
export function wouldCreateCycle(genome: Genome, from: number, to: number): boolean {
// Build adjacency list
const adj = new Map<number, number[]>();
for (const node of genome.nodes) {
adj.set(node.id, []);
}
for (const conn of genome.connections) {
if (!conn.enabled) continue;
if (!adj.has(conn.from)) adj.set(conn.from, []);
adj.get(conn.from)!.push(conn.to);
}
// Add the proposed connection
if (!adj.has(from)) adj.set(from, []);
adj.get(from)!.push(to);
// DFS to detect cycle
const visited = new Set<number>();
const recStack = new Set<number>();
const hasCycle = (nodeId: number): boolean => {
visited.add(nodeId);
recStack.add(nodeId);
const neighbors = adj.get(nodeId) || [];
for (const neighbor of neighbors) {
if (!visited.has(neighbor)) {
if (hasCycle(neighbor)) return true;
} else if (recStack.has(neighbor)) {
return true;
}
}
recStack.delete(nodeId);
return false;
};
// Check from the 'from' node
return hasCycle(from);
}
/**
* Serialize genome to JSON
*/
export function serializeGenome(genome: Genome): string {
return JSON.stringify(genome, null, 2);
}
/**
* Deserialize genome from JSON
*/
export function deserializeGenome(json: string): Genome {
return JSON.parse(json);
}