Architecture
This page describes how the Quale system is organized internally - the pipeline from .quale source files to evolved neural networks, the core components and their responsibilities, and the design decisions that keep the engine domain-agnostic.
For the DSL syntax itself, see the Language Reference. For CLI usage, see the CLI Reference.
System Pipeline
Section titled “System Pipeline”A Quale experiment flows through a linear pipeline from source text to evolved connectome:
.quale files | v[ Parser ] ------> Project (AST) | v[ Validator ] ----> Diagnostics (errors / warnings) | v[ Compiler ] -----> CompiledProject | | | +----------------+----------------+ | | | v v v[ Domain.Configure() ] [ EvolutionEngine ] | | | +----------------------------+ | | v v[ RunGeneration loop ] | vEvolved connectome (checkpoint)Stage 1: Parsing
Section titled “Stage 1: Parsing”The parser (parser/project.go) reads one or more .quale files and produces a Project AST. When given a directory, it scans all .quale files alphabetically and merges their definitions into a single project. There are no import statements - every file in the directory is part of the project.
The result is a collection of typed definition lists: BodyDefs, ItemDefs, WorldDefs, DynamicsDefs, FitnessDefs, and EvolveDefs.
Stage 2: Validation
Section titled “Stage 2: Validation”The validator checks the AST for semantic correctness - things the parser cannot verify syntactically. This includes:
- All references resolve (the
evolveblock’sbody: Survivorpoints to a real body definition) - Effect keys in
on_consumeblocks matchinternal()sensor names in the body - Item property names referenced by
item_property()sensors exist on at least one item - Spawn categories match item categories
- World capacity is not exceeded (total spawns + agents < interior cells)
- No duplicate definition names across files
Validation produces a DiagnosticList with source-located error messages. If any errors are present, compilation does not proceed.
Stage 3: Compilation
Section titled “Stage 3: Compilation”The compiler transforms the validated AST into a CompiledProject - a flat, domain-agnostic structure that the evolution engine and domains consume directly. Key transformations:
- Sensor/actuator expansion:
sensor food_nearby: directional(range: 20, directions: 4)expands into four named nodes:food_nearby_n,food_nearby_s,food_nearby_e,food_nearby_w. The compiler builds orderedSensorNames/ActuatorNameslists andSensorMap/ActuatorMapdictionaries mapping names to node ID slices. - Configuration merging: Evolution parameters from the
evolveblock are merged with defaults fromconfig.DefaultEvolutionConfig(). Explicitly set values override defaults; omitted values keep defaults. - Dynamics compilation: If the
evolveblock references adynamicsdefinition, it is compiled into aCompiledDynamicsstruct containing flat rule lists ready for tick-by-tick evaluation. - Plasticity compilation: If the
bodydefinition includes aplasticityblock, it is compiled into aCompiledPlasticitystruct that decorates each brain at evaluation time. - Region extraction: Region definitions from the body are carried forward as
RegionDefstructs for the initial genome builder.
Stage 4: Domain Configuration
Section titled “Stage 4: Domain Configuration”The engine passes the CompiledProject to Domain.Configure(), where the domain validates its own concerns:
- Are the item categories supported? (e.g., the survival domain only accepts
foodandwater) - Are the fitness metrics computable? (e.g., the rail domain knows how to compute
signal_responsebut notfood_eaten)
This is the boundary between language-level validation (parser) and simulation-level validation (domain).
Stage 5: Evolution
Section titled “Stage 5: Evolution”The EvolutionEngine runs the NEAT-style evolution loop: initialize a population of minimal genomes, evaluate each genome through the domain, speciate, reproduce, and repeat until convergence or the generation limit.
Core Components
Section titled “Core Components”SignalEngine (core/signal.go)
Section titled “SignalEngine (core/signal.go)”The SignalEngine processes one forward pass through a brain’s connectome each tick. It pre-computes a topological ordering of non-input nodes using Kahn’s algorithm during construction. The propagation loop is efficient but not fully allocation-free - readActuatorOutputs allocates a map each tick.
A single tick:
- Apply sensor inputs - write sensor values into mapped input nodes
- Propagate - iterate non-input nodes in topological order, accumulating weighted inputs and applying each node’s activation function (plus bias)
- Apply plasticity - if the brain has a
PlasticityConfig, run Hebbian learning, weight decay, and homeostatic regulation - Read actuator outputs - collect output values from actuator-mapped nodes
The SignalEngine is the innermost loop of the system. It runs once per tick, per scenario, per genome, per generation. Topological ordering and adjacency lists are pre-computed during construction to minimize per-tick work.
EvolutionEngine (evolution/population.go)
Section titled “EvolutionEngine (evolution/population.go)”The EvolutionEngine orchestrates the full NEAT-style evolution loop:
- Initialize population - create minimal genomes with input/output nodes (and region hidden nodes, if regions are defined)
- Evaluate - run each genome through the domain’s
Evaluate()method in parallel (one goroutine per genome, bounded by a semaphore equal to CPU count) - Speciate - group genomes into species based on compatibility distance
- Reproduce - select parents via tournament selection, produce offspring through crossover and mutation, with elitism for species with 5+ members
Key types:
EvalResult- fitness score plus a genericMetricsmap (domain-specific behavioral measurements)GenerationStats- per-generation aggregates (best/avg/worst fitness, species count, best/avg metrics)
For details on all evolution parameters, see the Evolution Configuration Reference.
DynamicsEngine (evolution/dynamics.go)
Section titled “DynamicsEngine (evolution/dynamics.go)”The DynamicsEngine applies compiled dynamics rules to agent state each tick. It maintains a State map as the single source of truth for all internal state (both sensor-visible and hidden). Each tick it:
- Applies per-tick rules (unconditional state mutations)
- Applies conditional rules (guarded state mutations, in declaration order)
- Clamps all states to configured bounds
- Checks death conditions
Domains interact with the DynamicsEngine through GetState() and SetState() to read sensor values and apply actuator effects (like item consumption modifying hunger).
MutationEngine (evolution/mutation.go)
Section titled “MutationEngine (evolution/mutation.go)”The MutationEngine applies eight NEAT-style mutation operators to genomes, each gated by a per-operator probability:
| Operator | Effect |
|---|---|
weight_shift | Perturb or randomize connection weights |
bias_shift | Perturb node biases |
add_node | Split a connection, inserting a new hidden node |
remove_node | Remove a hidden node and create bypass connections |
add_connection | Add a connection between two unconnected nodes |
remove_connection | Disable a connection (biased toward weak connections) |
rewire | Move one endpoint of a connection to a different node |
change_activation | Switch a hidden node’s activation function |
When regions are defined, structural mutations are region-aware: new nodes inherit region assignments and new connections preferentially stay within the same region.
Domain-Agnostic Architecture
Section titled “Domain-Agnostic Architecture”The Quale engine knows nothing about survival, rail, or any specific simulation domain. The boundary is enforced by the Domain interface:
+-------------------+ +-------------------+| Quale Engine | | Domain Layer || | | || Parser | | Survival Domain || Validator |<---------| Rail Domain || Compiler | | (your domain) || EvolutionEngine | | || SignalEngine | +-------------------+| DynamicsEngine || MutationEngine |+-------------------+The dependency arrow points inward: domains depend on the engine (they import evolution.Domain, core.Brain, etc.), but the engine never imports any domain package. Domains register themselves via init() functions, and main.go uses blank imports to trigger registration:
import ( _ "quale/domains/rail" // registers "rail" domain _ "quale/domains/survival" // registers "survival" domain)This means:
- Adding a new domain requires zero changes to the engine
- The engine’s tests do not depend on any domain implementation
- Domains can be developed and tested independently
For details on implementing a new domain, see Domain Adapters.
Self-Registering Domain Pattern
Section titled “Self-Registering Domain Pattern”Each domain package contains a register.go file with an init() function:
func init() { evolution.RegisterDomain("rail", func() evolution.Domain { return New() })}The registry is a simple map of name to factory function. GetDomain(name) creates a fresh instance. AvailableDomains() lists registered names (used by the CLI’s --domain flag help text).
Tick Pipeline
Section titled “Tick Pipeline”Each simulation tick follows a fixed sequence. The domain’s scenario runner drives this loop, calling into the engine’s components:
1. Read sensors Domain reads world state, produces map[string]float64 for the brain
2. Brain propagate SignalEngine.Tick() runs the forward pass through the connectome
3. Apply plasticity SignalEngine.applyPlasticity() runs Hebbian learning, weight decay, and homeostatic regulation (if configured)
4. Read actuators Domain reads brain outputs, interprets them as actions (move, eat, brake, etc.)
5. Apply dynamics DynamicsEngine.Tick() applies per-tick and conditional state cascade rules, checks death conditions
6. Domain bookkeeping Domain updates world state (item respawn timers, agent position, fitness metric accumulators)Steps 1-6 repeat for each tick in a scenario. Multiple scenarios are run per genome evaluation, with results averaged. The domain is responsible for steps 1, 4, and 6; the engine handles steps 2, 3, and 5.
Parallel Evaluation
Section titled “Parallel Evaluation”Genome evaluation is the system’s primary bottleneck. Each generation evaluates population_size genomes, each running scenarios independent scenarios of ticks ticks.
The engine parallelizes this by evaluating genomes concurrently:
- One goroutine per genome
- A semaphore limits concurrency to
runtime.NumCPU()goroutines - Each goroutine receives its own deterministic RNG (seeded from the engine’s RNG before the parallel section begins, ensuring reproducibility regardless of scheduling order)
- Each goroutine builds its own
Brainfrom the genome, creates its own world state, and runs independently - no shared mutable state
The Domain.Evaluate() method must be goroutine-safe. Since each call receives its own Brain, rng, and creates its own world, this is satisfied naturally without locks.
File Format Summary
Section titled “File Format Summary”| Extension | Purpose | Format |
|---|---|---|
.quale | Source specification files | Text (Quale DSL) |
.quale-ckpt | Evolution checkpoints (full population state) | Binary (Go gob encoding) |
.quale-brain | Evolved brain snapshots | Binary (future: protobuf; current: gob) |
.quale-replay | Recorded simulation runs | Binary (future) |
.quale-map | Custom world layouts | Text (future) |