Initial Proposal#
Below are a series of slides from the initial proposal for the architecture. These are non-final, but were going in the right direction.
To see this as the original presentation, look here.
I then attempted to implement a basic example of it in the TrivialImplementation.py file.
Experiment Structure#
flowchart TD
A([Run Evolution]) --> B["Generate Initial Population"]
B -->|"Population + Gen. Info"| C["Evolution Generation Info Incrementer"]
C -->|"None returned"| X([Exit])
C -->|"Population + New Gen. Info"| D["Generate Measurements"]
D -->|"Gen. Info + List Of Measurements"| E["Evaluate Measurements"]
E -->|"Gen. Info + List Of Measurements"| F["Evaluate Fitnesses"]
D -->|"Population"| F
F -->|"Gen. Info + Population w/ fitness"| G["Reproduce"]
G -->|"Gen. Info + New Population"| C
These objects build and choreograph the entire evolution process as shown in the diagram above. The orchestrator is provided with callable functions implementing each stage of evolution and is responsible for wiring them together and driving the loop.
Data Structures#
Measurement Data Structure#
flowchart TD
subgraph Measurement
FR["FPGA Request"]
DR["Data Request"]
subgraph Circuit["Circuit (to evaluate)"]
CI["Constructed from Individuals"]
end
FU["FPGA Used: Hardware_UUID:FPGA_UUID"]
MR["Measurement Result: Data or Errors"]
end
The Measurement object contains four fields:
FPGA Request (essentially an ENUM): controls FPGA selection — options are “don’t care,” “request specific FPGA UUIDs,” or “avoid specific FPGA UUIDs.”
Data Request (essentially an ENUM): specifies the type of measurement to be taken on the FPGA.
Measurement Result (essentially a Result object): stores either the collected data, or a description of the failure — e.g. no satisfactory FPGA available, cannot perform the measurement, or cannot compile the Individual.
Measurement UUID: intended to allow a single Individual evaluation to spawn multiple Measurements that can later be recombined; in practice, matching by Individual pointer may be sufficient.
Individual Data Structure#
flowchart TD
subgraph Individual
desc["Highly Implementation Dependent (Probably mostly data)"]
end
An Individual stores the representation that is manipulated durring evolution to fully represent the things being evolved. It must be compilable into a form that can be loaded and run on an FPGA in order to be evaluated for its fitness. An open design question at proposal time was where to place the compilation logic — in the Individual itself, or in the Hardware controller — and whether Individuals should be linked to a specific FPGA type or brand (subclass).
Population Representation#
flowchart TD
subgraph Population
ind1["Individuals"]
end
subgraph PopWithFitness["Population w/ fitness"]
ind2["Individuals"]
fit["Fitnesses"]
end
The Population design deliberately avoids sub-populations with explicit links between them or to a parent population.
Each Population will always be dealt with as if it was the only one that exists in the processes that use it, even if
one population is divided into multiple populations durring the course of an experiment and then recombined into one
population at the end of the experiment.
Conceptually it is a simple list with a few extra features, and could even be implemented as a
plain type alias (e.g. list[Individual] -> Population).
Generation Metadata (i.e. ‘Gen. Info’)#
flowchart TD
subgraph EvolutionGenerationInfo["Evolution Generation Info"]
gennum["Generation #"]
etc["..."]
end
EvolutionGenerationInfo holds the metadata for a particular generation of the evolution run
(e.g. the generation number). It is intended to be treated as immutable — a new instance is
produced each generation rather than mutating the existing one in place.
Functions#
Generation Info Factory (a.k.a.’Incrementer’)#
flowchart TD
old["Evolution Generation Info (OLD)"]
op["Old Population"]
np["New Population"]
ci["Config Info (partial)"]
etc["etc..."]
incr[["Evolution Generation Info Incrementer"]]
new["Evolution Generation Info (NEW)"]
old ==> incr
op --> incr
np --> incr
ci --> incr
etc --> incr
incr ==> new
The Incrementer takes in the old EvolutionGenerationInfo and decides what the next generation
looks like. When it returns None, the evolution run is complete. It has access to both the old
and new population as well as partial config info, giving it enough context to make informed
decisions about when to terminate.
Generate Measurements#
flowchart TD
pop["Population"]
geninfo["Evolution Generation Info"]
gen[["Generate Measurements"]]
list["List Of Measurements"]
pop ==> gen
geninfo --> gen
gen ==> list
Converts a population of Individuals into a list of Measurements to be performed. The structure of those measurements depends on the fitness function and evaluation approach being used.
Evaluate Measurements#
flowchart TD
list["List Of Measurements"]
eval[["Evaluate Measurements (Done by Hardware Object)"]]
result["List Of Measurements w/ Results"]
list ==> eval
eval ==> result
Fills in the Result field of each Measurement with the requested data. This step is performed
by the Hardware object, which dispatches each Measurement to an FPGA and collects the results.
Evaluate Fitness#
flowchart TD
pop["Population"]
meas["List Of Measurements"]
geninfo["Evolution Generation Info"]
eval[["Evaluate Fitnesses"]]
result["Population w/ fitness"]
pop ==> eval
meas ==> eval
geninfo --> eval
eval ==> result
Interprets the completed measurement results and assigns fitness values to the corresponding Individuals in the population.
Reproduce#
flowchart TD
popfit["Population w/ fitness"]
geninfo["Evolution Generation Info"]
reprod[["Reproduce"]]
newpop["New Population"]
popfit ==> reprod
geninfo --> reprod
reprod ==> newpop
Transforms the population-with-fitnesses into a new population for the next generation, using the fitness values to guide selection and mutation.
Generate Circuit#
flowchart TD
ind["Individual(s)"]
gen[["Generate Circuit"]]
subgraph meas["Measurement"]
circ["Circuit"]
end
ind ==> gen
gen ==> circ
Converts one or more Individuals into a Circuit that can be loaded onto an FPGA and evaluated. The resulting Circuit is placed into a Measurement object. Note that Circuits may be specific to particular FPGAs, or be composed of multiple individuals that are combined.
Hardware Controller#
flowchart TD
subgraph req[".request_Measurements()"]
m1["Measurement (A)
(Circuit, FPGA Request, Data Request)"]
m2["Measurement (B)"]
me["Measurement (...)"]
end
hw["Hardware Object
(async, one FPGA per Measurement)"]
subgraph comp[".get_completed_Measurements()"]
r1["Measurement (A)
(FPGA Used + Ok(data))"]
r2["Measurement (B)
(FPGA Used + Err(failure))"]
re["Measurement (...)"]
end
req ==> hw ==> comp
flowchart TD
m_in["Measurement (input)
(Circuit, FPGA Request, Data Request)"]
m_out["Measurement (output)
(return FPGA Used + Result or Error)"]
subgraph hw_obj["Hardware Object"]
ctrl[["Hardware Controller Logic
(selects one FPGA per Measurement based on FPGA Request field)"]]
subgraph bank["FPGA Bank
(local / remote / etc.)"]
f_pool["FPGA Info (N available)"]
end
end
m_in ==> ctrl
f_pool -.->|"available
pool"| ctrl
ctrl ==>|"assigns to one FPGA,
async evaluate"| m_out
The Hardware Object exposes a request/response API: callers submit a batch of Measurement requests and retrieve completed results separately, allowing evaluations to proceed asynchronously. Each Measurement carries an FPGA Request field that the Hardware Controller uses to select an appropriate FPGA from its FPGA Bank — options include “don’t care,” requesting specific FPGA UUIDs, or excluding certain UUIDs.
FPGAs in the bank may be accessed in different ways — directly connected via USB, hosted on a remote server, or reached via a custom protocol — but the Hardware Object abstracts these differences from callers.
When an FPGA completes (or fails) its evaluation, it writes two fields back into the Measurement:
FPGA Used: the UUID of the FPGA that performed the evaluation, archived so results can be traced back to specific hardware.
Measurement Result: either the collected data (Ok) or a description of the failure (Err) — stored as a Result type analogous to Rust’s
Result<T, E>. Failure causes include no suitable FPGA available, a compilation error, or a hardware-level failure.
FPGAs are referenced in software using a <Hardware_UUID>:<FPGA_UUID> pair. This identifier
is not necessarily consistent between program runs but remains stable for the duration of a single
run. UUIDs can be correlated to uniquely identifiable information on the physical FPGA hardware
and stored to request measurements on specific hardware units.
Example Run Using Architecture#
This example walks through one complete pulse-count evolution run. The population contains four individuals (A, B, C, D), and the run is configured to stop after 500 generations. The fitness function counts oscillation pulses — a circuit that oscillates faster receives a higher fitness score. Follow the execution from initialization through termination.
Generate Initial Population#
The run begins before the main loop starts. An initial population is built by randomly generating individuals, immediately testing each on an FPGA to measure its pulse count, and discarding any that produce zero pulses. A circuit with zero oscillations would receive a fitness of zero regardless of any other property, making it useless as evolutionary starting material. Each individual that does produce pulses is mutated slightly before being added to the population, giving the run a diverse, non-trivial starting point.
flowchart TD
subgraph GenInitPop["Generate Initial Population"]
rand["Randomly Generate Individual"]
measure["Measure Pulse Count"]
check{"Non-zero pulses?"}
indiv["Individual (valid)"]
mutate["Perform Mutation"]
rand --> measure --> check
check -->|"No"| rand
check -->|"Yes"| indiv --> mutate
end
start((" ")) --> rand
mutate --> popout["Population: A, B, C, D\n(Fitness: None)"]
The output is a population {A, B, C, D} with Fitness None — they have not yet been
evaluated within the evolution loop.
First Generation: Initialize Gen Info#
Before the first generation runs, the Incrementer is called to produce the initial
EvolutionGenerationInfo. The incoming Gen Info is None, which is the signal to
initialize rather than increment. The Incrementer creates fresh Gen Info with
Generation # = 0.
flowchart TD
geninfoin["Gen Info input: None (first run)"]
prevpop["Prev. Population: None"]
nextpop["Next Population: A, B, C, D (Fitness: None)"]
config["Config Info"]
subgraph Incr["Evolution Generation Info Incrementer"]
isnone{"Generation Info is None?"}
initgen["Generate Initial Gen Info"]
genchk{"Generation Number < 500"}
isnone -->|"True"| initgen
isnone -->|"False"| genchk
increment["New Gen Info: Gen # = 0"]
retnone["Return None"]
genchk -->|"True"| increment
genchk -->|"False"| retnone
end
geninfoin --> isnone
prevpop --> Incr
nextpop --> Incr
config --> Incr
initgen --> out["Gen Info: Generation # = 0"]
The Incrementer returns Gen Info with Generation # = 0 and the main loop enters its first full cycle.
Generating Measurements#
With Gen Info established, the loop creates a Measurement object for each individual. In this example Individuals are already in circuit form (translation is trivial), so each Measurement is initialized with:
FPGA Request: ANY — no preference for which FPGA runs it.
Data Request: Pulse Count — the FPGA should count output pulses.
Measurement Result: Err(Unevaluated) — not yet run.
flowchart TD
pop4["Population: A, B, C, D (Fitness: None)"]
geninfo4["Evolution Generation Info"]
config4["Config Info"]
subgraph GenMeas["Generate Measurements"]
translate["Translate Individuals to Circuits"]
foreach["For Each Circuit: Generate Measurement"]
returnlist["Return List"]
translate --> foreach --> returnlist
end
pop4 --> translate
geninfo4 --> GenMeas
config4 --> GenMeas
returnlist --> mout["Measurements: A, B, C, D (unevaluated)"]
Four measurements {A, B, C, D} are produced, each holding a circuit ready to be run on hardware.
Evaluating Measurements#
The four unevaluated measurements are submitted to the Hardware Object. The Hardware Controller assigns each to an available FPGA from its bank, ideally dispatching them in parallel. Each FPGA loads its assigned circuit, counts pulses over a fixed window, and writes the result back into the Measurement. Two representative outcomes from this generation:
Measurement A: FPGA “1234:0” — Ok(4_000 Pulses)
Measurement C: FPGA “1234:0” — Err(Serial Failure) — hardware communication failed; no pulse count was recorded.
flowchart TD
minput["Measurements: A, B, C, D (unevaluated)"]
subgraph EvalMeas["Evaluate Measurements"]
pass["Pass each Measurement to FPGA\n(via Hardware Object)"]
update["Update Measurement with data from FPGA"]
collect["Collect all measurements and return as list"]
pass --> update --> collect
end
minput --> pass
collect --> mresult["Measurements: A, B, C, D (evaluated)
e.g. A: Ok(4_000 Pulses), C: Err(Serial Failure)"]
All four measurements are returned with their Result fields populated — either a successful pulse count or a typed error.
Evaluating Fitness#
The fitness evaluator receives both the population (fitnesses still None) and the completed measurements. It correlates each individual to its measurement, then scores:
If success: apply the fitness function — here, based on pulse count, so more pulses yields higher fitness.
If failed (e.g. serial error): assign Fitness = 0 as a safe default.
flowchart TD
meas6["Measurements: A, B, C, D (with results)"]
pop6["Population: A, B, C, D (Fitness: None)"]
geninfo6["Evolution Generation Info"]
config6["Config Info"]
subgraph EvalFit["Evaluate Fitness"]
correlate["Correlate individuals to associated measurements"]
foreach6["For Each Individual: Read Measurement(s)"]
iferror["Fitness = 0"]
ifsuccess["Apply fitness function"]
putpop["Map fitnesses onto population"]
correlate --> foreach6
foreach6 -->|"If Error"| iferror
foreach6 -->|"If Success"| ifsuccess
iferror --> putpop
ifsuccess --> putpop
end
meas6 --> correlate
pop6 --> correlate
pop6 --> putpop
geninfo6 --> EvalFit
config6 --> EvalFit
putpop --> fitout["Population: A=0.98, B=0.86, C=0.00, D=0.05"]
The population now carries scores: A=0.98, B=0.86, D=0.05, and C=0.00 — zeroed out because its serial failure returned no usable data.
Reproducing#
The reproducer constructs the next generation from the scored population. The strategy here (though implementations may vary) selects the top 50% (A and B), duplicates each, preserves one copy as an elite (unmutated), and mutates the other copy to produce A’ and B’. Fitnesses are cleared on the new population since they have not yet been measured in this context. C and D, the two lowest scorers, are dropped entirely.
flowchart TD
pop7["Population: A=0.98, B=0.86, C=0.00, D=0.05"]
geninfo7["Evolution Generation Info"]
config7["Config Info"]
subgraph Reprod["Reproduction"]
select["Select top 50% (A, B)"]
dup["Duplicate Individuals"]
elites["Don't mutate Elites (A, B)"]
mutated["Mutate Copy (A', B')"]
create["Create new population"]
select --> dup
dup -->|"Copy Kept"| elites
dup -->|"Mutated Copy"| mutated
elites --> create
mutated --> create
end
pop7 --> select
geninfo7 --> Reprod
config7 --> Reprod
create --> repout["Population: A, B, A', B' (Fitness: None)"]
The new population {A, B, A’, B’} is ready for the next measurement cycle.
Subsequent Generations: Incrementer Advances#
The new population and the just-completed generation data re-enter the Incrementer.
Gen Info is not None this time, so the incrementer evaluates the stopping condition.
Gen # = 0 < 500 is True, so it increments to Generation # = 1 and the loop continues.
flowchart TD
geninfoin9["Gen Info input: Generation # = 0"]
prevpop9["Prev. Population: A=0.98, B=0.86, C=0.00, D=0.05"]
nextpop9["Next Population: A, B, A', B' (Fitness: None)"]
config9["Config Info"]
subgraph Incr9["Evolution Generation Info Incrementer"]
isnone9{"Gen Info is None?"}
initgen9["Generate Initial Gen Info"]
genchk9{"Gen # < 500"}
increment9["New Gen Info: (Gen #)++"]
retnone9["Return None"]
isnone9 -->|"True"| initgen9
isnone9 -->|"False"| genchk9
genchk9 -->|"True"| increment9
genchk9 -->|"False"| retnone9
end
geninfoin9 --> isnone9
prevpop9 --> Incr9
nextpop9 --> Incr9
config9 --> Incr9
increment9 --> out9["Gen Info: Generation # = 1"]
This measure, evaluate, reproduce, and increment cycle repeats for generations 1 through 499. With each pass the population accumulates beneficial mutations, gradually converging toward circuits with higher pulse counts.
Termination: Generation 500#
After 500 complete generations, the Incrementer is called with Gen # = 500. The
stopping condition 500 < 500 is False, so the Incrementer returns None. The
evolution loop treats None as the termination signal: it exits, and the final
evolved population is preserved for analysis.
flowchart TD
geninfoin11["Gen Info input: Generation # = 500"]
prevpop11["Prev. Population (with fitnesses)"]
nextpop11["Next Population (evolved, Fitness: None)"]
config11["Config Info"]
subgraph Incr11["Evolution Generation Info Incrementer"]
isnone11{"Gen Info is None?"}
initgen11["Generate Initial Gen Info"]
genchk11{"Gen # < 500"}
increment11["New Gen Info: (Gen #)++"]
retnone11["Return None"]
isnone11 -->|"True"| initgen11
isnone11 -->|"False"| genchk11
genchk11 -->|"True"| increment11
genchk11 -->|"False"| retnone11
end
geninfoin11 --> isnone11
prevpop11 --> Incr11
nextpop11 --> Incr11
config11 --> Incr11
retnone11 --> out11(["None -- Evolution complete"])
The run is complete. The final population contains circuits shaped by 500 generations of selection and mutation toward maximizing pulse count.