compileiq 1.0.0__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
compileiq/ciq.py ADDED
@@ -0,0 +1,745 @@
1
+ import pathlib
2
+ import os
3
+ import sys
4
+ import shutil
5
+ import socket
6
+ import json
7
+ import warnings
8
+ from datetime import datetime
9
+ from tqdm.auto import tqdm
10
+ from uuid import uuid4
11
+ from pydantic import (
12
+ BaseModel,
13
+ Field,
14
+ model_validator,
15
+ ConfigDict,
16
+ PrivateAttr,
17
+ field_validator,
18
+ )
19
+ from typing import (
20
+ Any,
21
+ Callable,
22
+ Dict,
23
+ List,
24
+ Optional,
25
+ TypeAlias,
26
+ cast,
27
+ )
28
+ from compileiq.utils.validation import Score, SingleScore, MultiScore
29
+ from compileiq.tracker import (
30
+ _TRACKER_TYPES_TO_CLASSES,
31
+ )
32
+ from compileiq.config.const import _CACHE_DIR, KEEP_CACHE_FILES
33
+ from compileiq.types import (
34
+ BaseTracker,
35
+ ParamArg,
36
+ TrackerTypes,
37
+ Worker,
38
+ WorkerTypes,
39
+ SearchConfiguration,
40
+ InternalSearchConfiguration,
41
+ TrackerConfig,
42
+ DefaultTrackerConfig,
43
+ )
44
+ from compileiq.search_spaces.compilers import SearchSpaceProvider
45
+ from compileiq.results import SearchResult
46
+ from compileiq.core.core_comms import CoreIPC, initialize_socket
47
+ from compileiq.core.core_types import (
48
+ ParameterSet,
49
+ CompletionMessage,
50
+ EvaluatedParamResponse,
51
+ ResponseTemplate,
52
+ )
53
+ from compileiq.utils._setup_files import (
54
+ setup_legacy_search_config,
55
+ setup_search_space,
56
+ get_core_filepaths,
57
+ )
58
+ from compileiq.utils.helpers import (
59
+ restore_nested_search_space,
60
+ _decode_from_core,
61
+ )
62
+
63
+ SearchSpaceInput: TypeAlias = (
64
+ Dict[str, Any]
65
+ | pathlib.Path
66
+ | List[Dict | pathlib.Path | SearchSpaceProvider]
67
+ | SearchSpaceProvider
68
+ )
69
+ SearchConfigInput: TypeAlias = Dict[str, Any] | SearchConfiguration | pathlib.Path
70
+ ResolvedSearchSpaceInput: TypeAlias = Dict[str, Any] | pathlib.Path | List[Dict | pathlib.Path]
71
+ SearchSpaceResolutionMetadataDict: TypeAlias = dict[str, str | int | None]
72
+
73
+
74
+ def _expand_objective_values(
75
+ value: SingleScore | MultiScore, num_objectives: int
76
+ ) -> list[int | float | str]:
77
+ if num_objectives > 1:
78
+ assert isinstance(
79
+ value, (list, tuple)
80
+ ), f"num_objectives > 1 requires list/tuple score; got {type(value).__name__}"
81
+ return cast(list[int | float | str], list(value))
82
+ assert not isinstance(value, (list, tuple))
83
+ return [value]
84
+
85
+
86
+ class Search(BaseModel):
87
+ """
88
+ Your main class to start a CompileIQ search.
89
+ Instantiate this class with your objective function, search space, and search configuration,
90
+ then call `start()` to run the search and retrieve the results.
91
+ """
92
+
93
+ ## User defined (Public) Attributes
94
+ objective_function: Callable = Field(
95
+ description=(
96
+ "A Python function that runs the task and returns score(s). "
97
+ "The function must have all imports and objects declared inside."
98
+ )
99
+ )
100
+ search_space: SearchSpaceInput = Field(
101
+ description=(
102
+ "The user search space for CompileIQ to explore. "
103
+ "The objective function will receive a single set following this declaration. "
104
+ "Accepted values: a dict mapping string keys to compileiq search_spaces functions, "
105
+ "a path to an existing search-space file, or a SearchSpaceProvider instance."
106
+ )
107
+ )
108
+ search_config: SearchConfigInput = Field(
109
+ description=(
110
+ "Search configuration parameters such as generation number and mutation rate. "
111
+ "Accepted values: a SearchConfiguration object, a dict with SearchConfiguration keys, "
112
+ "or a path to an existing .config file."
113
+ )
114
+ )
115
+ worker_type: WorkerTypes | type[Worker] = Field(
116
+ default=WorkerTypes.DEFAULT,
117
+ description=(
118
+ "Selects which worker implementation runs your objective function. "
119
+ "Built-in options via WorkerTypes: NATIVE (default, local multiprocessing), "
120
+ "RAY (distributed via Ray), or ASYNC (asyncio concurrency). "
121
+ "A Worker subclass can also be passed directly for custom implementations."
122
+ ),
123
+ )
124
+ tracker_config: TrackerConfig = Field(
125
+ default_factory=DefaultTrackerConfig,
126
+ description=(
127
+ "A TrackerConfig that defines how experiment tracking will be handled. "
128
+ "Refer to TrackerTypes for available options."
129
+ ),
130
+ )
131
+ debug: bool = Field(
132
+ default=False,
133
+ description=(
134
+ "When enabled, the cached log from the core subprocess is not deleted, "
135
+ "allowing inspection of its output."
136
+ ),
137
+ )
138
+ cache_folder: Optional[pathlib.Path] = Field(
139
+ default=None,
140
+ description=(
141
+ "Base directory for cache files created during the run. "
142
+ "Cleaned up at the end unless `CIQ_KEEP_CACHE=1` is set. "
143
+ "Defaults to `~/.cache/compileiq` if not provided."
144
+ ),
145
+ )
146
+ dump_results: Optional[pathlib.Path] = Field(
147
+ default=None,
148
+ description=(
149
+ "If set, the results CSV is written to this path after every evaluation batch "
150
+ "(typically one batch per pool_size evaluations). No file is written if None."
151
+ ),
152
+ )
153
+ disable_progress_bar: bool = Field(
154
+ default=False,
155
+ description="Disables the TQDM progress bar.",
156
+ )
157
+ exit_on_failure: bool = Field(
158
+ default=True,
159
+ description=(
160
+ "When True, execution terminates with a RuntimeError if all objectives fail "
161
+ "in the first generation. Set to False if your search has an inherently high "
162
+ "failure rate."
163
+ ),
164
+ )
165
+
166
+ ## Private Attributes
167
+ _create_new_id: Callable = lambda _: (
168
+ datetime.now().strftime("%Y-%m-%d-%H_%M_%S-") + str(uuid4())
169
+ )
170
+ # CompileIQ Core will give us the id once generation starts
171
+ run_id: int | None = Field(None, init=False)
172
+ _worker: Worker = PrivateAttr()
173
+ _tracker: BaseTracker = PrivateAttr()
174
+ current_generation: int = Field(
175
+ 0, init=False, description="Current generation of the search, starting at 0."
176
+ )
177
+ _search_config: InternalSearchConfiguration = PrivateAttr()
178
+ _result: SearchResult = PrivateAttr()
179
+ _using_file_backed_search_space: bool | list[bool] = False
180
+ _multi_config: bool = False
181
+ _search_space_resolution_metadata: list[SearchSpaceResolutionMetadataDict] | None = (
182
+ PrivateAttr(default=None)
183
+ )
184
+
185
+ # Cache directory management
186
+ _base_cache_dir: Optional[pathlib.Path] = PrivateAttr(default=None)
187
+
188
+ # Communication with Core
189
+ _listen_socket: socket.socket = PrivateAttr(default_factory=initialize_socket)
190
+ _core_socket: Optional[socket.socket] = PrivateAttr(default=None)
191
+ _core_ipc: CoreIPC = PrivateAttr()
192
+
193
+ # Pydantic config
194
+ model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
195
+
196
+ @field_validator("search_space", mode="after")
197
+ def validate_windows(cls, value):
198
+ if sys.platform == "win32":
199
+ if isinstance(value, list):
200
+ # If using multiple configs
201
+ raise ValueError("Windows does not support multiple config search spaces")
202
+
203
+ return value
204
+
205
+ @field_validator("search_config", mode="after")
206
+ def normalize_search_config(cls, value: SearchConfigInput) -> SearchConfigInput:
207
+ if isinstance(value, SearchConfiguration):
208
+ return value
209
+ if isinstance(value, dict):
210
+ return SearchConfiguration(**value)
211
+ return SearchConfiguration.from_legacy(str(value))
212
+
213
+ def _do_init_folders(self) -> None:
214
+ # Determine the base cache directory (only on first call)
215
+ if self._base_cache_dir is None:
216
+ if self.cache_folder is None:
217
+ self._base_cache_dir = pathlib.Path(_CACHE_DIR)
218
+ else:
219
+ self._base_cache_dir = pathlib.Path(self.cache_folder)
220
+
221
+ # Always create a flat path: base / new_id (never nest deeper)
222
+ # (this is crucial for Win32 where filepath lengths are bounded)
223
+ self.cache_folder = self._base_cache_dir / str(self._create_new_id())
224
+ self.cache_folder.mkdir(parents=True, exist_ok=True)
225
+
226
+ @model_validator(mode="after")
227
+ def _init_folders(self):
228
+ self._do_init_folders()
229
+ return self
230
+
231
+ def _do_setup_search_config(self) -> None:
232
+ """
233
+ Converting user-defined configuration to internal representation
234
+ """
235
+
236
+ search_config = cast(SearchConfiguration, self.search_config)
237
+ self._search_config = InternalSearchConfiguration(**search_config.model_dump())
238
+
239
+ _, search_space_config_filepath = get_core_filepaths(str(self.cache_folder))
240
+
241
+ # Windows workaround with paths
242
+ if sys.platform == "win32":
243
+ search_space_config_filepath = search_space_config_filepath.replace("\\", "\\\\")
244
+
245
+ self._search_config.dna_config = search_space_config_filepath
246
+
247
+ @model_validator(mode="after")
248
+ def _setup_search_config(self):
249
+ self._do_setup_search_config()
250
+ return self
251
+
252
+ def _do_setup(self) -> None:
253
+ """
254
+ Using this as a secondary __init__ to perform validation and start variables
255
+ that depend on user-defined values, perform additional validation and create
256
+ required files in `cache_folder`.
257
+ """
258
+
259
+ # Preparing search space - resolve SearchSpaceProvider instances.
260
+ metadata_records: list[SearchSpaceResolutionMetadataDict] = []
261
+
262
+ def resolve_provider(
263
+ search_space: Dict[str, Any] | pathlib.Path | SearchSpaceProvider,
264
+ ) -> Dict[str, Any] | pathlib.Path:
265
+ if not isinstance(search_space, SearchSpaceProvider):
266
+ return search_space
267
+ resolved = search_space.retrieve()
268
+ metadata = getattr(search_space, "resolution_metadata", None)
269
+ if metadata is not None:
270
+ metadata_records.append(metadata.as_dict())
271
+ return resolved
272
+
273
+ if isinstance(self.search_space, list):
274
+ # Multi-config searches may mix raw dict/path search spaces with
275
+ # provider-backed entries. Resolve only the provider entries.
276
+ self.search_space = [
277
+ resolve_provider(search_space) for search_space in self.search_space
278
+ ]
279
+ else:
280
+ # Common case: a single raw search space or one provider-backed
281
+ # search space that resolves to a local binary path.
282
+ self.search_space = resolve_provider(self.search_space)
283
+
284
+ if metadata_records:
285
+ self._search_space_resolution_metadata = metadata_records
286
+ self._multi_config = isinstance(self.search_space, list)
287
+ if isinstance(self.search_space, list):
288
+ self._using_file_backed_search_space = [
289
+ isinstance(sspace, pathlib.Path) for sspace in self.search_space
290
+ ]
291
+ else:
292
+ self._using_file_backed_search_space = isinstance(self.search_space, pathlib.Path)
293
+
294
+ # Initializing Core IPC
295
+ self._core_ipc = CoreIPC()
296
+
297
+ if self.tracker_config.type is None:
298
+ raise ValueError("Tracker is not initialized")
299
+
300
+ if not isinstance(self.tracker_config, TrackerConfig):
301
+ raise ValueError(
302
+ f"Tracker configuration is not a TrackerConfig, got {type(self.tracker_config)}"
303
+ )
304
+ self._tracker = _TRACKER_TYPES_TO_CLASSES[TrackerTypes(self.tracker_config.type)](
305
+ self.tracker_config
306
+ )
307
+
308
+ wt = self.worker_type
309
+ if isinstance(wt, WorkerTypes):
310
+ worker_cls = wt.worker_type()
311
+ elif isinstance(wt, type) and issubclass(wt, Worker):
312
+ worker_cls = wt
313
+ else:
314
+ raise RuntimeError(f"Expected a WorkerTypes or Worker subclass, but found {wt}")
315
+
316
+ assert self.cache_folder is not None, "_init_folders populates cache_folder before _setup"
317
+ self._worker = worker_cls.create(
318
+ cache_folder=self.cache_folder,
319
+ normalize=self._search_config.normalize,
320
+ tracker=self._tracker,
321
+ )
322
+
323
+ @model_validator(mode="after")
324
+ def _setup(self):
325
+ self._do_setup()
326
+ return self
327
+
328
+ def __enter__(self):
329
+ return self
330
+
331
+ def __exit__(self, exc_type, exc_value, exc_traceback):
332
+ del self
333
+
334
+ def __del__(self):
335
+ if hasattr(self, "_listen_socket") and self._listen_socket is not None:
336
+ self._listen_socket.close()
337
+ if hasattr(self, "_worker") and self._worker is not None:
338
+ del self._worker
339
+ if hasattr(self, "_core_ipc") and self._core_ipc is not None:
340
+ del self._core_ipc
341
+ if (
342
+ hasattr(self, "cache_folder")
343
+ and self.cache_folder is not None
344
+ and os.path.exists(self.cache_folder)
345
+ and not KEEP_CACHE_FILES
346
+ ):
347
+ self._clean_files()
348
+
349
+ def sample(self, num_samples: int = 1) -> List[ParamArg]:
350
+ """
351
+ Instead of performing a full search, this function will just sample `num_samples`
352
+ from the search space provided by the user.
353
+
354
+ Args:
355
+ num_samples (`int`): Number of samples to retrieve from the search space.
356
+ Returns:
357
+ A list of parameter sets sampled from the search space, in the same format as the
358
+ parameters sent to the objective function during a normal search.
359
+ """
360
+ assert self.cache_folder is not None
361
+ main_config_filepath, search_space_config_filepath = get_core_filepaths(self.cache_folder)
362
+
363
+ hijacked_config = self._search_config.model_copy(deep=True)
364
+ hijacked_config.num_objectives = 1
365
+ hijacked_config.pool_size = max(num_samples, 6)
366
+ hijacked_config.cull_size = 2
367
+ try:
368
+ hijacked_config.dna_config = setup_search_space(
369
+ cast(ResolvedSearchSpaceInput, self.search_space), search_space_config_filepath
370
+ )
371
+ setup_legacy_search_config(hijacked_config, main_config_filepath)
372
+
373
+ # Starting Core as a subprocess
374
+ _ = self._core_ipc.start(
375
+ server_socket=self._listen_socket,
376
+ main_config_filepath=main_config_filepath,
377
+ silent=not self.debug,
378
+ )
379
+
380
+ # Wait for core to connect (returns accepted socket and its addr)
381
+ self._core_socket, _ = self._listen_socket.accept()
382
+
383
+ # Executing function throughout the generations
384
+ parameter_sets = self._core_ipc.receive_from_core(self._core_socket)
385
+ if isinstance(parameter_sets, CompletionMessage):
386
+ raise RuntimeError(
387
+ "Something went wrong with the core, enable `debug=True` for debugging."
388
+ )
389
+
390
+ func_args = self._load_params(parameter_sets)
391
+
392
+ finally:
393
+ self._core_ipc.stop()
394
+ if not KEEP_CACHE_FILES:
395
+ if isinstance(hijacked_config.dna_config, list):
396
+ for path in hijacked_config.dna_config:
397
+ os.remove(path)
398
+ else:
399
+ os.remove(hijacked_config.dna_config)
400
+ os.remove(main_config_filepath)
401
+
402
+ return func_args[:num_samples]
403
+
404
+ def start(
405
+ self,
406
+ num_workers: int | None = None,
407
+ task_timeout: int | float | None = None,
408
+ **additional_worker_kwargs,
409
+ ) -> SearchResult:
410
+ """
411
+ The CompileIQ core is started as a subprocess through here.
412
+
413
+ The communication between python process and the subprocess is done through sockets:
414
+
415
+ 1. During __init__ we prepare the python socket server for communication with the
416
+ core subprocess.
417
+ 2. The `search_space.json` and `main_config.json` files are created inside the cache
418
+ folder. (Core needs these.)
419
+ 3. We start the core process with the correct environment variables and wait for
420
+ communication.
421
+ 4. The core process will start sending parameter candidates which we will execute
422
+ using multiprocess.
423
+ 5. The Python process returns the scores through the socket.
424
+ 6. At some point the core process sends a completion flag indicating the end of the
425
+ tune process.
426
+
427
+ Args:
428
+ num_workers:
429
+ The maximum number of processes spawned to run parallel searches.
430
+ This parameter is ignored by workers where `respects_num_workers`
431
+ is `False`.
432
+
433
+ task_timeout:
434
+ The maximum time (in seconds) allowed for a single execution of the objective.
435
+ If the timeout is reached, the worker will treat it as a failed execution and return
436
+ a failed Score. This is useful to prevent workers from hanging indefinitely on
437
+ certain parameter sets.
438
+
439
+ **additional_worker_kwargs:
440
+ Additional keyword arguments forwarded to the worker's ``run()`` method.
441
+ Each worker accepts the kwargs it cares about. For example:
442
+
443
+ - RayWorker: accepts Ray task resource options
444
+ - Custom workers: accept any kwargs they define
445
+
446
+ Returns (`SearchResult`):
447
+ An object with the search results.
448
+ """
449
+
450
+ if num_workers is not None:
451
+ if not self._worker.respects_num_workers:
452
+ warnings.warn(
453
+ f"num_workers is not supported by {type(self._worker).__name__}", stacklevel=2
454
+ )
455
+ elif num_workers < 1:
456
+ raise ValueError("num_workers must be a positive integer.")
457
+
458
+ if task_timeout is not None and not self._worker.supports_timeout:
459
+ warnings.warn(
460
+ f"task_timeout is not supported by {type(self._worker).__name__}", stacklevel=2
461
+ )
462
+
463
+ worker_count = num_workers if isinstance(num_workers, int) else 1
464
+
465
+ if self.cache_folder is None or not self.cache_folder.exists():
466
+ self._do_init_folders()
467
+ assert self.cache_folder is not None
468
+
469
+ # Initializing Result df
470
+ self._result = SearchResult._initialize_empty(
471
+ num_scores=self._search_config.num_objectives,
472
+ problem_type=self._search_config.problem_type,
473
+ norm_scores=self._search_config.normalize,
474
+ )
475
+
476
+ main_config_filepath, search_space_config_filepath = get_core_filepaths(self.cache_folder)
477
+ try:
478
+ # Configure the core input files.
479
+ self._search_config.dna_config = setup_search_space(
480
+ cast(ResolvedSearchSpaceInput, self.search_space), search_space_config_filepath
481
+ )
482
+ setup_legacy_search_config(self._search_config, main_config_filepath)
483
+
484
+ # Starting Core as a subprocess
485
+ _ = self._core_ipc.start(
486
+ server_socket=self._listen_socket,
487
+ main_config_filepath=main_config_filepath,
488
+ silent=not self.debug,
489
+ )
490
+
491
+ # Wait for core to connect (returns accepted socket and its addr)
492
+ self._core_socket, _ = self._listen_socket.accept()
493
+
494
+ self._tracker.search_starts(
495
+ search_space_resolution_metadata=self._search_space_resolution_metadata
496
+ )
497
+
498
+ # Executing function throughout the generations
499
+ self._result = self._process_candidates(
500
+ worker_count,
501
+ task_timeout=task_timeout,
502
+ **additional_worker_kwargs,
503
+ )
504
+
505
+ finally:
506
+ self._tracker.search_ends()
507
+ self.current_generation = 0
508
+ self._core_ipc.stop()
509
+ if not KEEP_CACHE_FILES:
510
+ self._clean_files()
511
+
512
+ return self._result
513
+
514
+ @property
515
+ def search_space_resolution_metadata(self) -> list[SearchSpaceResolutionMetadataDict] | None:
516
+ """One metadata record per resolved provider, or None if no provider was used."""
517
+ if self._search_space_resolution_metadata is None:
518
+ return None
519
+ return [dict(metadata) for metadata in self._search_space_resolution_metadata]
520
+
521
+ def _process_candidates(
522
+ self,
523
+ num_workers: int,
524
+ task_timeout: Optional[int | float] = None,
525
+ **additional_worker_kwargs,
526
+ ) -> SearchResult:
527
+ """
528
+ We receive the parameter set, call upon the worker to execute the objective function, and
529
+ send the score back through the socket, until we receive a completion message.
530
+ """
531
+ pbar = tqdm(
532
+ total=self._search_config.generations,
533
+ ascii="░▒█",
534
+ colour="green",
535
+ disable=self.disable_progress_bar,
536
+ smoothing=0.8,
537
+ bar_format="{desc} {n_fmt}/{total_fmt}|{bar}| [elapsed: {elapsed} · eta: {remaining}]"
538
+ " {postfix}",
539
+ )
540
+ pbar.set_description("🧬 Generation")
541
+ while True:
542
+ try:
543
+ # receiving params ('knobs') from core subprocess
544
+ assert self._core_socket is not None
545
+ parameter_sets = self._core_ipc.receive_from_core(self._core_socket)
546
+ except Exception as e:
547
+ pbar.close()
548
+ raise e
549
+
550
+ # Verify if the message signals the end of the run
551
+ if isinstance(parameter_sets, CompletionMessage):
552
+ if not (parameter_sets.complete):
553
+ raise RuntimeError(
554
+ "Something went wrong with the core, enable `debug=True` for debugging."
555
+ )
556
+
557
+ pbar.update()
558
+ self._result.clear_duplicates()
559
+ return self._result
560
+
561
+ else:
562
+ # Standard flow receiving knobs
563
+ self.run_id = parameter_sets.invocation_id
564
+
565
+ # Updating Progress Bar and Tracker
566
+ if parameter_sets.generation_num != self.current_generation:
567
+ self.current_generation = parameter_sets.generation_num
568
+ self._worker.current_generation = self.current_generation
569
+
570
+ if self._search_config.num_objectives == 1 and self.current_generation > 0:
571
+ # We limit best score display to single objective because multi-objective is
572
+ # a pareto front and it's not straightforward to define a single "best"
573
+ try:
574
+ best_score = self._result.get_best_result()
575
+ except Exception:
576
+ best_score = None
577
+
578
+ if isinstance(best_score, dict):
579
+ gen_from_best = int(best_score["generation"])
580
+ best_score_value = (
581
+ best_score["score_1"]
582
+ if not self._search_config.normalize
583
+ else best_score["norm_score_1"]
584
+ )
585
+ pbar.set_postfix(
586
+ {
587
+ "🏆 best_score": f"{best_score_value:.4f}",
588
+ "at_gen": gen_from_best,
589
+ }
590
+ )
591
+
592
+ pbar.update()
593
+ self._tracker.generation_starts(self.current_generation)
594
+
595
+ elif self.current_generation == 0:
596
+ self._tracker.generation_starts(0)
597
+
598
+ # Processing parameters into dictionaries (if possible)
599
+ func_args = self._load_params(parameter_sets)
600
+ param_ids = [single_param.id for single_param in parameter_sets.params]
601
+ scores = self._worker.run(
602
+ function=self.objective_function,
603
+ tracker=self._tracker,
604
+ params_pool=func_args,
605
+ params_ids=param_ids,
606
+ num_workers=num_workers,
607
+ num_function_returns=self._search_config.num_objectives,
608
+ task_timeout=task_timeout,
609
+ **additional_worker_kwargs,
610
+ )
611
+
612
+ if len(scores) < len(parameter_sets.params):
613
+ raise RuntimeError(
614
+ "The worker returned less scores than the number of parameter sets "
615
+ "requested by the core."
616
+ )
617
+
618
+ if (
619
+ self.exit_on_failure
620
+ and self.current_generation == 0
621
+ and self._check_fail_count(scores)
622
+ ):
623
+ raise RuntimeError(
624
+ "All objective functions failed in the first gen. Are you sure this "
625
+ "is expected behavior? You can disable this error by setting"
626
+ "`exit_on_failure=False`."
627
+ )
628
+
629
+ # Storing worker results and preparing response to Core
630
+ scores_response = ResponseTemplate(evaluated_params=[])
631
+ assert isinstance(scores_response.evaluated_params, list)
632
+ num_objectives = self._search_config.num_objectives
633
+ for score in scores:
634
+ self._result.add_result(
635
+ score, parameter_sets.generation_num, self._search_config.normalize
636
+ )
637
+
638
+ # Preparing response to Core
639
+ # Baselines are not reported back to core
640
+ if not score.is_baseline:
641
+ raw_score_value = (
642
+ score.norm_score if self._search_config.normalize else score.score
643
+ )
644
+ assert raw_score_value is not None
645
+ score_value = _expand_objective_values(raw_score_value, num_objectives)
646
+ assert isinstance(score.param_id, int), (
647
+ f"non-baseline score must have int param_id; got "
648
+ f"{type(score.param_id).__name__}"
649
+ )
650
+
651
+ response = EvaluatedParamResponse(id=score.param_id, scores=score_value)
652
+ scores_response.evaluated_params.append(response)
653
+
654
+ # Verifying all scores are returned
655
+ if len(param_ids) != len(scores_response.evaluated_params):
656
+ raise RuntimeError(
657
+ "Worker did not return all scores passed down for calculation."
658
+ f"Sent {len(param_ids)} and only returned "
659
+ f"{len(scores_response.evaluated_params)}"
660
+ )
661
+
662
+ # Checkpointing intermediate results every batch
663
+ if self.dump_results is not None:
664
+ self._result.save(self.dump_results)
665
+
666
+ self._tracker.generation_ends(self.current_generation)
667
+
668
+ # Sending scores to the Core subprocess
669
+ self._core_ipc.send_to_core(
670
+ self._core_socket,
671
+ scores_response,
672
+ )
673
+
674
+ def _load_params(self, parameter_sets: ParameterSet) -> List[ParamArg]:
675
+ """
676
+ Parse params received from core.
677
+
678
+ File-backed search spaces may return opaque strings for user code to handle.
679
+ Dictionary-defined search spaces must return JSON objects because those are
680
+ restored into the user-facing config dictionary.
681
+ """
682
+
683
+ def parse_param_payload(param: str, is_file_backed: bool = False):
684
+ try:
685
+ param_set = json.loads(param)
686
+ except (ValueError, json.JSONDecodeError):
687
+ try:
688
+ import json5
689
+
690
+ param_set = json5.loads(param)
691
+ except Exception:
692
+ if not is_file_backed:
693
+ raise RuntimeError(
694
+ "Core returned an unparseable parameter payload for a "
695
+ "dictionary-defined CompileIQ search space. Expected a "
696
+ f"JSON object string, got: {param!r}"
697
+ ) from None
698
+ return param
699
+
700
+ if is_file_backed:
701
+ return param_set
702
+
703
+ if not isinstance(param_set, dict):
704
+ raise RuntimeError(
705
+ "Core returned a non-object parameter payload for a dictionary-defined "
706
+ "CompileIQ search space. Expected a JSON object string, got "
707
+ f"{type(param_set).__name__}: {param!r}"
708
+ )
709
+
710
+ return restore_nested_search_space(param_set)
711
+
712
+ params_from_generation: List[ParamArg] = []
713
+ for param in parameter_sets.params:
714
+ if self._multi_config:
715
+ assert isinstance(self._using_file_backed_search_space, list)
716
+ # When specifying multiple configs, a list of base64 strings is returned
717
+ # Each string is the representation from one config (in the same order as
718
+ # provided by the user)
719
+ decoded_param = list(map(_decode_from_core, json.loads(param.knobs)))
720
+ single_pset = [
721
+ parse_param_payload(single_param, self._using_file_backed_search_space[i])
722
+ for i, single_param in enumerate(decoded_param)
723
+ ]
724
+ else:
725
+ assert isinstance(self._using_file_backed_search_space, bool)
726
+ single_pset = parse_param_payload(
727
+ param.knobs, self._using_file_backed_search_space
728
+ )
729
+
730
+ params_from_generation.append(cast(ParamArg, single_pset))
731
+
732
+ return params_from_generation
733
+
734
+ def _clean_files(self):
735
+ """Deletes cache files from this run"""
736
+ if hasattr(self, "_tracker") and self._tracker is not None:
737
+ self._tracker.cleanup()
738
+ if self.cache_folder is not None and pathlib.Path(self.cache_folder).exists():
739
+ shutil.rmtree(self.cache_folder, ignore_errors=True)
740
+
741
+ def _check_fail_count(self, scores: List[Score]):
742
+ """
743
+ In case all non-baseline scores are failures, this function returns True.
744
+ """
745
+ return all([score.failed for score in scores if not score.is_baseline])