Skip to content

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.


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 ]
|
v
Evolved connectome (checkpoint)

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.

The validator checks the AST for semantic correctness - things the parser cannot verify syntactically. This includes:

  • All references resolve (the evolve block’s body: Survivor points to a real body definition)
  • Effect keys in on_consume blocks match internal() 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.

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 ordered SensorNames/ActuatorNames lists and SensorMap/ActuatorMap dictionaries mapping names to node ID slices.
  • Configuration merging: Evolution parameters from the evolve block are merged with defaults from config.DefaultEvolutionConfig(). Explicitly set values override defaults; omitted values keep defaults.
  • Dynamics compilation: If the evolve block references a dynamics definition, it is compiled into a CompiledDynamics struct containing flat rule lists ready for tick-by-tick evaluation.
  • Plasticity compilation: If the body definition includes a plasticity block, it is compiled into a CompiledPlasticity struct that decorates each brain at evaluation time.
  • Region extraction: Region definitions from the body are carried forward as RegionDef structs for the initial genome builder.

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 food and water)
  • Are the fitness metrics computable? (e.g., the rail domain knows how to compute signal_response but not food_eaten)

This is the boundary between language-level validation (parser) and simulation-level validation (domain).

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.


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:

  1. Apply sensor inputs - write sensor values into mapped input nodes
  2. Propagate - iterate non-input nodes in topological order, accumulating weighted inputs and applying each node’s activation function (plus bias)
  3. Apply plasticity - if the brain has a PlasticityConfig, run Hebbian learning, weight decay, and homeostatic regulation
  4. 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.

The EvolutionEngine orchestrates the full NEAT-style evolution loop:

  1. Initialize population - create minimal genomes with input/output nodes (and region hidden nodes, if regions are defined)
  2. Evaluate - run each genome through the domain’s Evaluate() method in parallel (one goroutine per genome, bounded by a semaphore equal to CPU count)
  3. Speciate - group genomes into species based on compatibility distance
  4. 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 generic Metrics map (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.

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:

  1. Applies per-tick rules (unconditional state mutations)
  2. Applies conditional rules (guarded state mutations, in declaration order)
  3. Clamps all states to configured bounds
  4. 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).

The MutationEngine applies eight NEAT-style mutation operators to genomes, each gated by a per-operator probability:

OperatorEffect
weight_shiftPerturb or randomize connection weights
bias_shiftPerturb node biases
add_nodeSplit a connection, inserting a new hidden node
remove_nodeRemove a hidden node and create bypass connections
add_connectionAdd a connection between two unconnected nodes
remove_connectionDisable a connection (biased toward weak connections)
rewireMove one endpoint of a connection to a different node
change_activationSwitch 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.


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.

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).


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.


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 Brain from 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.


ExtensionPurposeFormat
.qualeSource specification filesText (Quale DSL)
.quale-ckptEvolution checkpoints (full population state)Binary (Go gob encoding)
.quale-brainEvolved brain snapshotsBinary (future: protobuf; current: gob)
.quale-replayRecorded simulation runsBinary (future)
.quale-mapCustom world layoutsText (future)