Source code for BitstreamEvolutionProtocols

  1"""Core protocols and interfaces for the BitstreamEvolution framework.
  2
  3Defines the abstract contracts (``Protocol`` classes) that all concrete
  4implementations must satisfy: ``Individual``, ``Circuit``, ``Population``,
  5``Fitness``, ``Hardware``, ``Reproducer``, ``GenerateMeasurements``, and
  6``EvaluatePopulationFitness``. Start here to understand the system architecture.
  7"""
  8
  9from dataclasses import dataclass
 10from collections.abc import Iterator, Callable, Iterable
 11from typing import Generic, Protocol, Optional, TypeVar, Any
 12from abc import ABC
 13from pathlib import Path
 14from returns.result import Result, Success, Failure # type: ignore
 15from enum import Enum, auto
 16import asyncio
 17
 18# TODO:
 19# Mutation - is this performed on Individuals or Circuits (very likely on Individuals)
 20# - In that case, we need some mechanism/interface for mutating them (like a function defined on Individual)
 21# - Also in that case, we need a way to link those to Circuits, or have a way to transmit the filepaths to the hardware file between Individual and Circuit
 22#   - Could just get copied in CircuitFactory for when there's a 1-to-1 relationship between Individuals and Circuits
 23# Only thing is I think we need some protocol for taking/collecting a measurement. Potentially replacing the "data_request" field in Measurement
 24# - This would have diff. implementations for VarMax, PulseCount, ToneDiscrimination, etc.
 25# - Not 100% sure on the interface for this, it should take in one or more Measurements and compute their results, and take in 
 26#   a Circuit?
 27
 28"""
 29TODO: Check if a protocol *populations:Population can be implemented by a function with no such variable.
 30i.e. can val(p:int) match the type of vals(p:int, *populations:Population)?
 31"""
 32
[docs] 33@dataclass 34class GenData: 35 """ 36 The most basic Generation Data Object. This simply requires a generation_number. 37 """ 38 generation_number: int
39 40# This is the "Evolution Generation Info Incrementer" in the diagram. It is a function.
[docs] 41class GenDataFactory(Protocol): 42 """ 43 The most basic Function Protocol that converts the Generation Data 44 from the previous iteration to the one for the next generation. 45 It also constructs the initial GenData object (gen_data is None) 46 and determines when the final generation occours (returns None). 47 """ 48 def __call__(self, gen_data:GenData|None) -> GenData|None: ...
49 # If want to say it can't have any other positional only arguments, use: 50 #def __call__(self,gen_data:GenData|None,/, **kwds:Any) -> GenData|None: ... 51 52class GenDataIncrementer(): 53 def __init__(self, max_gen_num:int): 54 self.__max_gen_num = max_gen_num 55 def __call__(self, gen_data: Optional[GenData]) -> Optional[GenData]: 56 if gen_data is None: 57 return GenData(generation_number=0) 58 if gen_data.generation_number < self.__max_gen_num-1: 59 return GenData(gen_data.generation_number + 1) 60 else: 61 return None 62 63 64# TODO: Replace this with Self if update to python 3.12 65F = TypeVar('F',bound='Fitness') 66# Want this to match the specific instance of the thing implementing Fitness it was matched with, 67# not anything that matches the protocol, so this may not be exactly correct. 68
[docs] 69class Fitness(Protocol): 70 """ 71 The most basic Class Protocol that contains the result of an evaluation, allowing you to 72 compare the resulting fitness. This should match most numeric types (i.e. int, float) 73 but allows you to do something more complex as desired. 74 75 The slash here ensures that the arguments before it can only be provided as positional arguments. 76 """ 77 def __lt__(self: F, o: F, /)->bool: ... 78 def __gt__(self: F, o: F, /)->bool: ... 79 def __eq__(self: F, o: F, /)->bool: ... 80 def __le__(self: F, o: F, /)->bool: ... 81 def __ge__(self: F, o: F, /)->bool: ...
82
[docs] 83class Individual(Protocol): 84 """ 85 The most basic Class Protocol for creating the Individuals that evolution will act upon. 86 This specifies basically nothing. 87 """ 88 ...
89
[docs] 90class FPGA_Model(Enum): 91 "All of the FPGA models that any part of our code supports" 92 ICE40 = auto() # Check
93 94# This class may even be able to have somewhat of a universal application for for particular FPGA models.
[docs] 95@dataclass 96class FPGA_Compilation_Data: 97 "This contains all data needed to compile data for a particular FPGA." 98 model: FPGA_Model 99 "The id for the particular FPGA (Maybe?)" 100 id: str
101
[docs] 102class Circuit(Protocol): 103 """ 104 The most basic Class Protocol for creating the Circuits that are evaluated on physical FPGAs. 105 This may or may not be an Individual based on what the Individual represents. 106 It will if the Individual represents a circuit in its entirety. 107 It will not if you are simultaniously evolving multiple sub-sections that need to be combined to make the circuit to be evaluated. 108 """
[docs] 109 def compile(self, fpga: FPGA_Compilation_Data) -> Result[None,Exception]: 110 """ 111 This looks at the fpga and compiles the circuit for it if it can. 112 If it can it does all of its work in the working_dir and returns with Ok(Path) for the path to the file/directory containing this data. 113 If it cannot, it should return an exception Err(Exception) explaining why it failed. 114 This should never raise an Exception, only return one as described. 115 """ 116 ...
117
[docs] 118class Population: 119 """ 120 This is the Population object used to hold individuals and their fitnesses durring evolution. 121 It starts out with its full list of individuals, and optionally fitnesses. 122 No individuals in the list may be duplicates. (determined using == ) 123 If there is no fitness it is None. 124 Fitnesses can be added to individuals in the population as desired, and once all individuals are added, the population can be sorted. 125 The Population can also be itterated through. (iter() -then-> next()) 126 """ 127 128 # May want specific type variables.
[docs] 129 def __init__(self,individuals: Iterable[Individual], fitnesses:Optional[Iterable[Fitness|None]]=None): 130 "Can raise ValueError if Individuals are not unique." 131 ind = list(individuals) 132 133 if len(set(ind)) != len(ind): 134 raise ValueError("There are duplicate individuals in this population. Each Individual in a population must be a unique object.") 135 136 fit_list = list(fitnesses) if fitnesses is not None else [None]*len(ind) 137 fit = fit_list if len(fit_list) == len(ind) else [None]*len(ind) 138 139 self.population_list:list[tuple[Individual,Optional[Fitness]]] = list(zip(ind,fit))
140 # = [(Individual, Fitness), (Individual2, Fitness2), ...] 141 142 def __iter__(self)->Iterator[tuple[Individual,Optional[Fitness]]]: 143 # call iter() to get iterator, then next() 144 # If wanted to be safe, return a copy that can't change 145 return iter(self.population_list) 146 147 def __len__(self)->int: 148 return len(self.population_list) 149 150 def set_fitness_by_index(self,index:int,fitness:Fitness)->None: 151 self.population_list[index] = (self.population_list[index][0],fitness) 152
[docs] 153 def set_fitness(self, individual:Individual, fitness:Fitness)->None: 154 "This can return value error if provided individual is not in the population." 155 for i,if_tup in enumerate(self.population_list): 156 if if_tup[0] == individual: 157 self.set_fitness_by_index(index=i,fitness=fitness) 158 return 159 raise ValueError(f"Could not find provided Individual in the population. Individual: {individual}")
160 161 def set_fitness_of_unevaluated_individuals(self,default_fitness:Fitness)->None: 162 for i,if_tup in enumerate(self.population_list): 163 if if_tup[1] is None: 164 self.set_fitness_by_index(index=i,fitness=default_fitness) 165
[docs] 166 def sort(self,key:Callable[[Fitness],Fitness],reverse:bool)->None: 167 "Operates on the Population fitness function like the standard sort for a list. May Raise TypeError if population not fully evaluated." 168 try: 169 self.population_list.sort( 170 key=lambda if_tup: key(if_tup[1]), # type: ignore 171 reverse= reverse 172 ) 173 except TypeError: 174 raise TypeError(f"Population does not have all individual's fitnesses fully specified. {sum([t[1] is None for t in self.population_list])} individuals did not have a fitness assigned.")
175 176
[docs] 177class CircuitFactory(Protocol): 178 """ 179 The most basic Protocol for turning Individuals into Circuits in whatever way best fits your application. 180 How the circuit is built should be fully specified here, and any unique roles the individuals have should be specified here; 181 however, how these individuals are selected and matched to roles is not. 182 """
[docs] 183 def __call__(self, populations: list[Population]) -> dict[Circuit,list[tuple[Population,Individual]]]: 184 """ 185 This takes the population of Individuals and constructs the necessary Circuit from it as requested. 186 It returns the circuits as keys in a dictionary, where the associated values are 187 a list of tupples for each Individual used to generate the circuit (or all individuals whose fitness is impacted directly by the circuit's fitness) 188 and includes the population the individual was from, and the individual itself. 189 """ 190 ...
191
[docs] 192class Reproducer(Protocol): 193 "Gets a population and returns another population filled with the children of this generation. (reproduce + mutation)" 194 def __call__(self,population:Population)->Population: ...
195
[docs] 196class GenerateInitialPopulation(Protocol): 197 "Somehow gets you an initial implementation." 198 def __call__(self)->Population: ...
199
[docs] 200class MeasurementError(Exception): 201 ...
[docs] 202class MeasurementNotTaken(MeasurementError): 203 ...
204
[docs] 205class DataRequest(Enum): 206 NONE = auto() 207 WAVEFORM = auto() 208 OSCILLATIONS = auto()
209 210C = TypeVar("C", bound=Circuit) #circuit type used 211M = TypeVar("M", bound=Any) # type of measurement taken
[docs] 212class Measurement(Generic[C,M]): 213 "All measurement data, this could even be a class potentially" 214 # FPGA_request:str 215 # data_request:Enum 216 # circuit:Circuit 217 # FPGA_used:Optional[str] 218 # result = Result[Any,Exception] 219 # argument = Any
[docs] 220 def __init__(self, FPGA_request:str, data_request:DataRequest,circuit_to_measure:Circuit, num_samples: int)->None: 221 """ 222 .. TODO:: 223 Figure out the format for an FPGA Request, potentially also changing the type, and adjust that here. 224 """ 225 self.FPGA_request:str = FPGA_request #may want to refine typing here 226 self.data_request:Enum = data_request 227 self.circuit:C = circuit_to_measure 228 self.FPGA_used:Optional[str] = None 229 self.result: Result[M,Exception] = Failure(MeasurementNotTaken("Initialized Measurement option has not yet been measured.")) 230 # The Any should be the measurement data, which we may want to standardize at some point 231 self.num_samples = num_samples
232 233 def record_FPGA_used(self,FPGA:str)->None: 234 self.FPGA_used = FPGA 235 236 def record_measurement_result(self,result:M|Exception): 237 if isinstance(result,Exception): 238 self.result = Failure(result) 239 else: 240 self.result = Success(result)
241
[docs] 242class EvaluatePopulationFitness(Protocol): 243 "Fully Evaluates a Population, the fitnesses in the population are fully specified. The populations involved will be edited in place. Any populations not provided will not be edited." 244 def __call__(self,population:Population,measurements:list[Measurement])->None: ...
245
[docs] 246class GenerateMeasurements(Protocol): 247 "Generate the measurements to take for the given populations. Returns a dict where all new measurements are given, and map to the individuals whose fitnesses they impact and the population the individuals are in." 248 def __call__(self, factory: CircuitFactory, populations: list[Population]) -> dict[Measurement,list[tuple[Population,Individual]]]: ...
249
[docs] 250class Hardware(Protocol): 251 """ 252 Used to Evaluate Measurements. Compile hardware would be responsible for compiling the Circuit 253 in the Measurement object passed to it in request_measurement(). 254 255 Intended concurrency model (server-client): 256 ============================================ 257 request_measurement() is async so that multiple measurements can be dispatched concurrently 258 via asyncio.gather() in Evolution.run(). This is meaningful when Hardware is a client that 259 sends requests over a network to a hardware server — the await genuinely suspends while 260 waiting for the network response, allowing other coroutines to run in the meantime. 261 262 The current Microcontroller implementation uses blocking pyserial and does NOT achieve 263 real concurrency. As the codebase moves to a server-client model, asyncio.gather() will 264 provide true parallelism across multiple FPGAs automatically. The server-side should use 265 serial_asyncio (https://pypi.org/project/serial-asyncio/) in place of pyserial so that 266 serial reads are genuinely non-blocking within the server's event loop. 267 268 Per-FPGA exclusivity constraint: 269 ================================= 270 Each physical Icestick (or FPGA device) can only be accessed by one process at a time — 271 iceprog holds exclusive USB access while programming the device. Implementations of this 272 protocol MUST ensure that concurrent calls to request_measurement() targeting the same 273 physical FPGA are serialized. Recommended approaches for the server: 274 275 Option A — asyncio.Semaphore(1) per FPGA: 276 Each FPGA gets its own Semaphore. request_measurement acquires the semaphore for the 277 target device before proceeding. Requests for different FPGAs run concurrently; 278 requests for the same FPGA queue up automatically. 279 280 Option B — Per-FPGA asyncio.Queue with a dedicated worker coroutine: 281 One worker coroutine per FPGA dequeues and processes measurements one at a time. 282 Naturally serializes access while allowing inter-FPGA parallelism. 283 284 See .claude/docs/hardware_concurrency.md for full discussion. 285 """ 286 #Has FPGAs 287 #Has Active Measurements being evaluated 288 #Has Pending Measurements to be evaluated 289 async def request_measurement(self, measurement: Measurement)->Measurement: ... 290 def get_available_FPGAs(self)->list[str]: ... # This could be a list of ids, or some sort of FPGA object with the UUID included and other relevant data.
291 292# Example usage 293if False: 294 #https://www.pythontutorial.net/python-concurrency/python-async-await/ 295 async def evalMeas(Measurements:list[Measurement]): 296 hw = Hardware(...) 297 for m in Measurements: 298 await Hardware.request_measurement(m) 299 300 asyncio.run(evalMeas(...)) # or something of this nature 301 302 303 304 305## Interesting Experiment to consider if update python version; 306 307 308#from mypy_extensions import Arg,VarArg,KwArg 309 310# "type" requires python 3.12+ 311#type GenDataFactory = Callable[[Arg(GenData|None),VarArg(Any),KwArg(Any)],GenData|None]