Skip to content

Domain Adapters

For how the domain layer fits into the overall pipeline, see Architecture. For the .quale syntax, see the Language Reference.

The Quale system is split into two layers:

  • Quale engine (parser + evolution + CLI) - domain-agnostic. Knows nothing about survival, rail, networks, or any specific simulation.
  • Domain layer (Domain implementation + simulation) - domain-specific. Implements the actual simulation that the .quale file describes.

The Domain interface is the bridge between them.


.quale file --> Parser --> CompiledProject --> Domain.Configure() --> Domain.Evaluate()
| |
Validates categories, Runs simulation,
metrics, and world config returns EvalResult

Domains self-register via Go init() functions into a global registry. The engine has zero domain imports - it discovers domains entirely through the registry. In main.go, blank imports trigger the init() registration:

import (
_ "quale/domains/rail" // registers "rail" domain
_ "quale/domains/survival" // registers "survival" domain
)

At runtime, evolution.GetDomain(name) creates a fresh domain instance by name:

dom, err := evolution.GetDomain("rail")
dom.Configure(compiledProject)
// engine calls dom.Evaluate() concurrently during evolution

Every domain implements the evolution.Domain interface defined in evolution/domain.go:

type Domain interface {
// Configure initializes the domain from a compiled Quale project.
// Called once before evolution begins.
Configure(project *parser.CompiledProject) error
// Evaluate runs a brain through the domain's simulation across multiple
// independent scenarios, returning aggregated fitness and behavioral
// metrics. Called concurrently from multiple goroutines.
Evaluate(brain *core.Brain, connectionCount int, scenarios int, rng *rand.Rand) EvalResult
// PrintStats formats and prints a generation's statistics to stdout.
// Each domain customizes this to display relevant behavioral metrics.
PrintStats(stats GenerationStats)
}

The registry is driven by factory functions:

evolution/domain.go
var domainRegistry = map[string]func() Domain{}
func RegisterDomain(name string, factory func() Domain) { ... }
func GetDomain(name string) (Domain, error) { ... }
func AvailableDomains() []string { ... }

Each domain package registers itself from register.go:

domains/rail/register.go
func init() {
evolution.RegisterDomain("rail", func() evolution.Domain {
return New()
})
}

Located in domains/survival/. Agents navigate a grid world collecting food and water while avoiding harmful items.

.quale CategoryDomain TypeDescription
"food"CategoryFoodSolid food items (hunger)
"water"CategoryWaterLiquid items (thirst)

Any other category (e.g., "signal", "crossing") produces an error.

.quale MetricDomain MappingDescription
survivalFitnessWeights.SurvivalFraction of ticks alive
healthFitnessWeights.HealthFinal health value
energyFitnessWeights.EnergyFinal energy value
food_eatenFitnessWeights.FoodEatenSafe food consumed
water_drunkFitnessWeights.WaterDrunkWater consumed
sicknessFitnessWeights.SicknessCumulative health damage
idleFitnessWeights.IdleFraction of ticks with no movement
complexityFitnessWeights.ComplexityPenaltyGenome connection count

Located in domains/rail/. Agents operate a train along the Beenleigh line (22 stations, 28.5 km), responding to QLD 4-aspect signals, stopping at stations, and managing cognitive state (stress, fatigue, boredom). Fitness is scored as a driving assessor’s scorecard with per-station and per-signal grading.

.quale CategoryDomain TypeDescription
"signal"CategorySignalLineside signal (red, yellow, green)
"crossing"CategoryCrossingLevel crossing
"station"CategoryStationPassenger station requiring a scheduled stop

Fitness is computed as a driving assessor’s scorecard. Each component is normalized to 0..1 before weighting. Per-station and per-signal grading replaces aggregate ratios to prevent exploitation.

.quale MetricDomain MappingDescription
route_completionScorecardWeights.RouteCompletionFraction of route completed (terminus = 1.0)
station_scoreScorecardWeights.StationScoreFraction of stations correctly stopped at (per-station)
timekeepingScorecardWeights.TimekeepingAverage per-station punctuality (120s tolerance)
signal_responseScorecardWeights.SignalResponseAverage signal interaction grade (speed vs aspect)
missed_stopsScorecardWeights.MissedStopsPer-station penalty for blowing past a platform
jerkScorecardWeights.JerkSmooth driving penalty (rate of change of acceleration)
complexityScorecardWeights.ComplexityGenome connection count

Signal response grading: Each signal encounter is graded based on the driver’s speed relative to the zone limit for that aspect. Green requires maintaining speed (>= 50% of limit). Double yellow requires preparation (<= 80% of limit). Yellow requires active braking (<= 50% of limit). Red is a SPAD (death).

Jerk penalty: Measures cumulative |d(acceleration)/dt| across the journey. Below 0.5 m/s^3 is comfortable (no penalty). Above that, the penalty scales linearly to full at 2.0 m/s^3.


When a .quale file includes a dynamics block, the parser compiles it into CompiledDynamics on the CompiledProject. Domains can use evolution.NewDynamicsEngine(compiled.Dynamics) to apply state cascade rules each tick instead of hardcoding them.

The DynamicsEngine (defined in evolution/dynamics.go) manages a State map as the single source of truth for all internal agent state. Each tick it:

  1. Applies per-tick rules (unconditional state mutations)
  2. Applies conditional rules (guarded state mutations)
  3. Clamps all states to configured bounds
  4. Checks death conditions
de := evolution.NewDynamicsEngine(compiled.Dynamics)
// Each tick in the simulation loop:
died := de.Tick()
// Domains can read and write state for sensor/actuator integration:
fatigue := de.GetState("fatigue")
de.SetState("energy", newValue)

This decouples state dynamics from domain code - the .quale file declares how states evolve, and the engine applies those rules generically.


When a body definition includes a plasticity block, the compiler produces a CompiledPlasticity struct on the CompiledProject. The engine decorates each brain with these rules before evaluation so that the SignalEngine applies Hebbian learning, weight decay, and homeostatic regulation during signal propagation.

This happens automatically in EvolutionEngine.EvaluateGenome():

// Engine applies plasticity config to the brain before calling Domain.Evaluate().
if ee.PlasticityConfig != nil {
brain.PlasticityConfig = &core.PlasticityConfig{
HebbianEnabled: ee.PlasticityConfig.HebbianEnabled,
HebbianRate: ee.PlasticityConfig.HebbianRate,
MaxWeight: ee.PlasticityConfig.MaxWeight,
DecayEnabled: ee.PlasticityConfig.DecayEnabled,
DecayRate: ee.PlasticityConfig.DecayRate,
MinWeight: ee.PlasticityConfig.MinWeight,
HomeostaticEnabled: ee.PlasticityConfig.HomeostaticEnabled,
TargetActivity: ee.PlasticityConfig.TargetActivity,
AdjustmentRate: ee.PlasticityConfig.AdjustmentRate,
}
}

Plasticity is transparent for single-agent evaluation. However, multi-agent scenarios that copy the brain (e.g., copyBrain in domains/survival/scenario.go) may not preserve PlasticityConfig and Regions on the copied brain. This is a known limitation - secondary agent brains in multi-agent scenarios may not receive plasticity or homeostatic regulation. Domain updates are needed to properly copy these fields to secondary agent brains.

For the .quale syntax, see the Plasticity section in the Language Reference.


When a body defines regions, the engine builds runtime Region structs from the genome’s node-region assignments before evaluation. Each node with a non-negative RegionID is grouped into its region, and each region receives an initial gain of 1.0.

// Engine builds regions from genome before calling Domain.Evaluate().
brain.Regions = buildRegions(genome)

Regions serve two purposes at runtime:

  1. Signal modulation - during propagation, signals entering a region node are multiplied by that region’s gain
  2. Homeostatic regulation - when plasticity is active, each region tracks average activity and adjusts gain toward the target level

Domains do not interact with regions directly. The signal engine handles all region mechanics during Tick(). Note that multi-agent scenarios using copyBrain must also copy the Regions map for homeostatic regulation to function on secondary agent brains (see the plasticity limitation note above).

For the .quale syntax, see the Regions section in the Language Reference.