aetherscan 1.0.0__py3-none-any.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.
@@ -0,0 +1,932 @@
1
+ """
2
+ Disk-backed (memmap) round datasets for Aetherscan training.
3
+
4
+ Each training round's data lives on disk as .npy memmaps under
5
+ {round_data_dir}/{save_tag}/round_{k:02d}/ instead of ~294 GB of main-process RAM:
6
+ workers write generated cadences straight into the memmaps, training reads them back
7
+ through np.load(mmap_mode="r"), and the OS page cache keeps steady-state reads at
8
+ RAM speed while remaining evictable under memory pressure (no more OOM kills).
9
+
10
+ This module owns:
11
+ - RoundDataPaths: the on-disk layout of one round's dataset.
12
+ - The atomic `.done` manifest protocol (written only after all workers finish, via
13
+ .tmp -> os.replace like preprocessing's _extract_stamps_worker).
14
+ - Startup archive/reuse/delete semantics for a tag's round-data directory.
15
+ - RoundDataProducer: a dedicated process that generates round k+1 while round k
16
+ trains (and pre-generates the RF dataset while the last round trains), streaming
17
+ stats back to the main process (DB writes stay in main — the DB queue is a thread
18
+ queue.Queue, not process-safe).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import contextlib
24
+ import json
25
+ import logging
26
+ import math
27
+ import multiprocessing
28
+ import os
29
+ import queue
30
+ import re
31
+ import shutil
32
+ import signal
33
+ import sys
34
+ import threading
35
+ import time
36
+ import traceback
37
+ from dataclasses import dataclass
38
+ from logging.handlers import QueueListener
39
+
40
+ import numpy as np
41
+ import psutil
42
+
43
+ from aetherscan.benchmark import record_stage, round_stage_name
44
+ from aetherscan.logger import init_worker_logging
45
+ from aetherscan.manager import get_manager
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+ # The producer process is started with the SPAWN method, not fork: the training parent holds
50
+ # deep TF / NCCL / gRPC / CUDA thread state whose locks a forked child can inherit in a locked
51
+ # state — an early producer prototype deadlocked on a futex before reaching its own code. A
52
+ # spawned child runs a fresh interpreter with none of that baggage (its own pool workers are
53
+ # then forked from the clean single-threaded producer, which is safe).
54
+ _MP_CONTEXT = multiprocessing.get_context("spawn")
55
+
56
+ # Directory-name pattern for one round's dataset (1-based, mirrors checkpoint round_XX tags)
57
+ _ROUND_DIR_PATTERN = re.compile(r"^round_(\d+)$")
58
+
59
+ # Number of values sampled per array for the manifest's cheap corruption check
60
+ _MANIFEST_SAMPLE_COUNT = 8
61
+
62
+ # Pidfile written next to a tag's round dirs by RoundDataProducer.start() (removed on graceful
63
+ # shutdown). An ungraceful main-process death (SIGKILL/OOM) leaves the producer orphaned with
64
+ # an argv of `... spawn_main ...` that no process-discovery pattern can attribute to Aetherscan
65
+ # — this file is what lets utils/kill_pipeline.sh and _reap_stale_producer() find the tree.
66
+ _PRODUCER_PIDFILE = "producer.pid"
67
+
68
+ # How long the producer's request loop waits on its queue before re-checking that the parent
69
+ # process is still alive (see the ppid watch in _producer_main).
70
+ _PARENT_POLL_INTERVAL_S = 5.0
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class RoundDataPaths:
75
+ """On-disk layout of one round's dataset: {main,true,false,labels}.npy, sibling
76
+ {main,true,false}_lognorm.npy parameter arrays, + a .done manifest."""
77
+
78
+ round_dir: str
79
+ round_idx: int
80
+
81
+ @classmethod
82
+ def for_round(cls, base_dir: str, round_idx: int) -> RoundDataPaths:
83
+ """Build the paths for 1-based round `round_idx` under `base_dir` (round_{k:02d})."""
84
+ return cls(round_dir=os.path.join(base_dir, f"round_{round_idx:02d}"), round_idx=round_idx)
85
+
86
+ @property
87
+ def main_path(self) -> str:
88
+ return os.path.join(self.round_dir, "main.npy")
89
+
90
+ @property
91
+ def true_path(self) -> str:
92
+ return os.path.join(self.round_dir, "true.npy")
93
+
94
+ @property
95
+ def false_path(self) -> str:
96
+ return os.path.join(self.round_dir, "false.npy")
97
+
98
+ @property
99
+ def labels_path(self) -> str:
100
+ return os.path.join(self.round_dir, "labels.npy")
101
+
102
+ @property
103
+ def done_path(self) -> str:
104
+ return os.path.join(self.round_dir, f"round_{self.round_idx:02d}.done")
105
+
106
+ @property
107
+ def array_paths(self) -> dict[str, str]:
108
+ """Mapping of array name -> .npy path for the three cadence arrays."""
109
+ return {"main": self.main_path, "true": self.true_path, "false": self.false_path}
110
+
111
+ @property
112
+ def lognorm_paths(self) -> dict[str, str]:
113
+ """Mapping of array name -> sibling .npy path holding that array's per-observation
114
+ (min_log, range_log) log-norm parameters, shape (n_samples, num_observations, 2)."""
115
+ return {
116
+ name: os.path.join(self.round_dir, f"{name}_lognorm.npy")
117
+ for name in ("main", "true", "false")
118
+ }
119
+
120
+
121
+ def _array_checksum(arr: np.ndarray) -> dict:
122
+ """
123
+ Cheap, sha-less checksum for a (possibly huge) memmap: total element count plus a few
124
+ deterministically-sampled flat-index values. Positions are derived from the element count,
125
+ so validation re-samples the same spots without storing an RNG state.
126
+
127
+ NOTE: this is a smoke test, not integrity protection. It reliably catches truncation and
128
+ gross corruption (wrong element count, shape, swapped arrays), but sub-threshold damage
129
+ slips through: a randomly corrupted fraction f of one array is only detected with
130
+ probability 1-(1-f)^_MANIFEST_SAMPLE_COUNT (e.g. a single lost page in a ~90 GB array is
131
+ ~never sampled). That is acceptable because a crash leaves NO manifest at all (the dir is
132
+ then treated as garbage and regenerated), so the checksum only guards against same-run
133
+ disk/NFS corruption between writing .done and reading it back — not power-loss/torn-page
134
+ scenarios. If stronger guarantees are ever needed, upgrade to a full content hash.
135
+ """
136
+ # NOTE: reshape(-1) is a zero-copy view here (and in validate_done_manifest) because
137
+ # open_memmap/np.save arrays are always C-contiguous; on a non-contiguous array it would
138
+ # silently materialize a full in-RAM copy — switch to .ravel()/.flat if the layout ever changes
139
+ flat = arr.reshape(-1)
140
+ n = int(flat.size)
141
+ rng = np.random.default_rng(n)
142
+ positions = sorted({int(i) for i in rng.integers(0, n, size=min(_MANIFEST_SAMPLE_COUNT, n))})
143
+ if arr.dtype.kind in ("U", "S"):
144
+ samples = [[i, str(flat[i])] for i in positions]
145
+ else:
146
+ samples = [[i, float(flat[i])] for i in positions]
147
+ return {"elements": n, "samples": samples}
148
+
149
+
150
+ def _checksum_value_matches(recorded, actual) -> bool:
151
+ """Compare one manifest sample against the on-disk value (NaN == NaN counts as a match)."""
152
+ if isinstance(recorded, str):
153
+ return recorded == str(actual)
154
+ actual = float(actual)
155
+ return recorded == actual or (math.isnan(recorded) and math.isnan(actual))
156
+
157
+
158
+ def _manifest_paths(paths: RoundDataPaths) -> dict[str, str]:
159
+ """Every array the .done manifest covers: cadence arrays, lognorm siblings, labels."""
160
+ return {
161
+ **paths.array_paths,
162
+ **{f"{name}_lognorm": p for name, p in paths.lognorm_paths.items()},
163
+ "labels": paths.labels_path,
164
+ }
165
+
166
+
167
+ def build_manifest(
168
+ paths: RoundDataPaths,
169
+ n_samples: int,
170
+ snr_base: float,
171
+ snr_range: float,
172
+ wall_time_s: float,
173
+ chunk_count: int,
174
+ array_dtype: str = "float32",
175
+ ) -> dict:
176
+ """Assemble the .done manifest dict by re-opening the finished arrays read-only.
177
+ `array_dtype` is the requested cadence-array dtype; the per-array `dtypes` map records
178
+ what is actually on disk (labels/lognorm sidecars stay float32/U regardless)."""
179
+ shapes: dict[str, list[int]] = {}
180
+ dtypes: dict[str, str] = {}
181
+ checksums: dict[str, dict] = {}
182
+ for name, path in _manifest_paths(paths).items():
183
+ arr = np.load(path, mmap_mode="r")
184
+ shapes[name] = list(arr.shape)
185
+ dtypes[name] = str(arr.dtype)
186
+ checksums[name] = _array_checksum(arr)
187
+ del arr
188
+ return {
189
+ "round_idx": paths.round_idx,
190
+ "n_samples": int(n_samples),
191
+ "shapes": shapes,
192
+ "dtypes": dtypes,
193
+ "array_dtype": str(array_dtype),
194
+ "snr_base": float(snr_base),
195
+ "snr_range": float(snr_range),
196
+ "checksums": checksums,
197
+ "wall_time_s": float(wall_time_s),
198
+ "chunk_count": int(chunk_count),
199
+ "created_at": time.time(),
200
+ }
201
+
202
+
203
+ def write_done_manifest(paths: RoundDataPaths, manifest: dict) -> None:
204
+ """Write the manifest atomically (.tmp -> os.replace) so a crash can't leave a partial
205
+ .done file — a round dir either has a complete manifest or is treated as garbage."""
206
+ tmp_path = paths.done_path + ".tmp"
207
+ with open(tmp_path, "w") as f:
208
+ json.dump(manifest, f, indent=2)
209
+ os.replace(tmp_path, paths.done_path)
210
+ logger.info(f"Wrote round-data manifest: {paths.done_path}")
211
+
212
+
213
+ def validate_done_manifest(
214
+ paths: RoundDataPaths,
215
+ expected_n_samples: int | None = None,
216
+ expected_array_dtype: str | None = None,
217
+ ) -> dict | None:
218
+ """
219
+ Validate a round dir's .done manifest against the arrays on disk. Returns the manifest
220
+ dict when everything checks out (round index, optional expected sample count, per-array
221
+ existence, shape, element count, and the sampled checksum values), else None.
222
+
223
+ NOTE: validation does NOT compare the manifest's snr_base/snr_range against what the caller
224
+ wants — it only proves the arrays match the manifest that was written with them. Reusing a
225
+ validated round dir is safe TODAY only because _setup_directories deletes every round dir
226
+ >= start_round before generation begins, so a reused dir is always one written earlier in
227
+ THIS run's curriculum. If cross-run round reuse is ever added (the obvious ~295 GB-saving
228
+ optimization), a dir generated under a different SNR curriculum would validate and be
229
+ silently trained on — add an expected_snr guard here (and at the _producer_main reuse
230
+ short-circuit) before relaxing the delete-on-startup policy.
231
+ """
232
+ try:
233
+ if not os.path.isfile(paths.done_path):
234
+ return None
235
+ with open(paths.done_path) as f:
236
+ manifest = json.load(f)
237
+
238
+ if manifest.get("round_idx") != paths.round_idx:
239
+ logger.warning(f"Manifest round_idx mismatch in {paths.done_path}")
240
+ return None
241
+ if expected_n_samples is not None and manifest.get("n_samples") != expected_n_samples:
242
+ logger.warning(
243
+ f"Manifest n_samples ({manifest.get('n_samples')}) != expected "
244
+ f"({expected_n_samples}) in {paths.done_path}"
245
+ )
246
+ return None
247
+ # Cadence-array dtype gate: a dir generated under a different round_array_dtype must
248
+ # not be silently reused (a float16 round fed to a float32-config resume — or vice
249
+ # versa — changes input numerics mid-run). Manifests predating the dtypes key are all
250
+ # float32 rounds.
251
+ if expected_array_dtype is not None:
252
+ manifest_dtype = manifest.get("array_dtype", "float32")
253
+ if manifest_dtype != expected_array_dtype:
254
+ logger.warning(
255
+ f"Manifest array_dtype ({manifest_dtype}) != expected "
256
+ f"({expected_array_dtype}) in {paths.done_path}"
257
+ )
258
+ return None
259
+
260
+ manifest_dtypes = manifest.get("dtypes")
261
+ for name, path in _manifest_paths(paths).items():
262
+ if not os.path.isfile(path):
263
+ logger.warning(f"Manifest array missing on disk: {path}")
264
+ return None
265
+ arr = np.load(path, mmap_mode="r")
266
+ try:
267
+ if list(arr.shape) != manifest["shapes"][name]:
268
+ logger.warning(f"Shape mismatch for {path}")
269
+ return None
270
+ if manifest_dtypes is not None and str(arr.dtype) != manifest_dtypes[name]:
271
+ logger.warning(f"Dtype mismatch for {path}")
272
+ return None
273
+ checksum = manifest["checksums"][name]
274
+ if int(arr.size) != checksum["elements"]:
275
+ logger.warning(f"Element-count mismatch for {path}")
276
+ return None
277
+ flat = arr.reshape(-1)
278
+ for position, recorded in checksum["samples"]:
279
+ if not _checksum_value_matches(recorded, flat[position]):
280
+ logger.warning(f"Sampled-value mismatch for {path} at {position}")
281
+ return None
282
+ finally:
283
+ del arr
284
+
285
+ return manifest
286
+ except Exception as e:
287
+ logger.warning(f"Failed to validate round-data manifest {paths.done_path}: {e}")
288
+ return None
289
+
290
+
291
+ def load_round_arrays(paths: RoundDataPaths) -> dict[str, np.ndarray]:
292
+ """
293
+ Open one round's arrays for training. The three cadence arrays come back as
294
+ copy-on-write memmaps (np.load(mmap_mode="c")) so nothing is pulled into RAM until
295
+ batches gather it — the OS page cache keeps hot pages resident and evicts them under
296
+ memory pressure instead of OOM-killing the process. mmap_mode="c" (MAP_PRIVATE) rather
297
+ than "r": reads behave identically and the on-disk files stay write-protected, but the
298
+ mapping is writable from numpy's point of view, which lets train._as_cpu_tensor export
299
+ it zero-copy over dlpack for the graph-side gather (numpy refuses dlpack export of
300
+ read-only arrays; nothing ever writes, so no private pages are materialized). Labels and
301
+ the main array's log-norm parameters ("lognorm", consumed by the latent-traversal plot)
302
+ are small (~24 MB for the main array at full scale) and loaded eagerly.
303
+ """
304
+ return {
305
+ "concatenated": np.load(paths.main_path, mmap_mode="c"),
306
+ "true": np.load(paths.true_path, mmap_mode="c"),
307
+ "false": np.load(paths.false_path, mmap_mode="c"),
308
+ "labels": np.load(paths.labels_path),
309
+ "lognorm": np.load(paths.lognorm_paths["main"]),
310
+ }
311
+
312
+
313
+ def _reap_stale_producer(base_dir: str, term_timeout: float = 5.0) -> None:
314
+ """
315
+ Restart-race guard: a previous run's producer that survived an ungraceful main-process
316
+ death (see the pidfile written by RoundDataProducer.start()) may still be writing into
317
+ this tag's round_XX dirs — racing prepare_round_data_dir's rmtree against those live
318
+ writes corrupts the very dirs this run is about to regenerate or reuse. Read the recorded
319
+ PID and terminate that tree (SIGTERM first, so the producer's handler reaps its own pool;
320
+ then SIGKILL any survivors) before any deletion happens. Always removes the pidfile.
321
+ """
322
+ pidfile = os.path.join(base_dir, _PRODUCER_PIDFILE)
323
+ if not os.path.exists(pidfile):
324
+ return
325
+
326
+ try:
327
+ with open(pidfile) as f:
328
+ pid = int(f.read().strip())
329
+ except (OSError, ValueError):
330
+ pid = None
331
+
332
+ if pid is not None:
333
+ try:
334
+ proc = psutil.Process(pid)
335
+ # PID-reuse guard: the recorded producer was already alive when its pidfile was
336
+ # written, so a process created after the pidfile's mtime cannot be it.
337
+ if proc.create_time() <= os.path.getmtime(pidfile) + 1.0:
338
+ logger.warning(
339
+ f"Reaping orphaned RoundDataProducer (PID {pid}) recorded in {pidfile}"
340
+ )
341
+ # Snapshot the subtree while the producer is alive: once it dies, its
342
+ # children reparent and can no longer be found through its PID.
343
+ children = proc.children(recursive=True)
344
+ proc.terminate()
345
+ with contextlib.suppress(psutil.TimeoutExpired):
346
+ proc.wait(timeout=term_timeout)
347
+ for child in children:
348
+ with contextlib.suppress(psutil.NoSuchProcess):
349
+ child.kill()
350
+ with contextlib.suppress(psutil.NoSuchProcess):
351
+ proc.kill()
352
+ except psutil.NoSuchProcess:
353
+ pass # already gone — the common case (producer exited via its own ppid watch)
354
+ except Exception as e:
355
+ logger.warning(f"Failed to reap producer recorded in {pidfile}: {e}")
356
+
357
+ with contextlib.suppress(OSError):
358
+ os.remove(pidfile)
359
+
360
+
361
+ def prepare_round_data_dir(
362
+ base_dir: str, start_round: int, expected_array_dtype: str = "float32"
363
+ ) -> None:
364
+ """
365
+ Startup cleanup of a tag's round-data directory, mirroring checkpoint-archiving semantics
366
+ (train.archive_directory) for resumable runs — except entries are deleted rather than
367
+ archived (a round is ~295 GB; archiving would silently double the disk budget):
368
+
369
+ - any orphaned producer recorded in the tag's pidfile is reaped first (it could still be
370
+ writing into the round dirs this cleanup is about to delete or validate),
371
+ - round dirs with index >= start_round are deleted (regenerated by this run),
372
+ - round dirs with index < start_round are kept only if their .done manifest validates
373
+ (reusable instead of regenerated); .done-less or corrupt dirs are deleted,
374
+ - any non-round entry (e.g. a stale RF dataset dir) is deleted.
375
+ """
376
+ os.makedirs(base_dir, exist_ok=True)
377
+ _reap_stale_producer(base_dir)
378
+
379
+ for entry in sorted(os.listdir(base_dir)):
380
+ entry_path = os.path.join(base_dir, entry)
381
+ if not os.path.isdir(entry_path):
382
+ continue
383
+ match = _ROUND_DIR_PATTERN.match(entry)
384
+ if match is None:
385
+ logger.info(f"Deleting stale round-data entry: {entry_path}")
386
+ shutil.rmtree(entry_path, ignore_errors=True)
387
+ continue
388
+ round_idx = int(match.group(1))
389
+ if round_idx >= start_round:
390
+ logger.info(f"Deleting round-data dir for round >= {start_round}: {entry_path}")
391
+ shutil.rmtree(entry_path, ignore_errors=True)
392
+ elif (
393
+ validate_done_manifest(
394
+ RoundDataPaths(entry_path, round_idx), expected_array_dtype=expected_array_dtype
395
+ )
396
+ is None
397
+ ):
398
+ logger.info(f"Deleting round-data dir with missing/invalid manifest: {entry_path}")
399
+ shutil.rmtree(entry_path, ignore_errors=True)
400
+ else:
401
+ logger.info(f"Keeping validated round-data dir: {entry_path}")
402
+
403
+
404
+ # ---------------------------------------------------------------------------
405
+ # RoundDataProducer — background generation process
406
+ # ---------------------------------------------------------------------------
407
+
408
+ # Producer-process-local handle to its worker pool, so the SIGTERM handler can
409
+ # terminate the pool's children before the producer itself dies.
410
+ _PRODUCER_POOL = None
411
+
412
+
413
+ def _default_generate(paths, round_idx, snr_base, snr_range, pool, params, stats_cb, progress_cb):
414
+ """Real generation entry point for the producer process (test hooks can replace it)."""
415
+ # Deferred import: data_generation pulls in setigen/scipy, which the parent may not need
416
+ # at round_data import time (cli.py must stay stdlib-importable via config/cli only).
417
+ from aetherscan.data_generation import generate_round_to_memmap # noqa: PLC0415
418
+
419
+ return generate_round_to_memmap(
420
+ paths=paths,
421
+ n_samples=params["n_samples"],
422
+ snr_base=snr_base,
423
+ snr_range=snr_range,
424
+ width_bin=params["width_bin"],
425
+ num_observations=params["num_observations"],
426
+ time_bins=params["time_bins"],
427
+ chunk_size=params["chunk_size"],
428
+ task_size=params["task_size"],
429
+ freq_resolution=params["freq_resolution"],
430
+ time_resolution=params["time_resolution"],
431
+ pool=pool,
432
+ round_num=round_idx,
433
+ seed=params["seed"],
434
+ stats_cb=stats_cb,
435
+ progress_cb=progress_cb,
436
+ array_dtype=params.get("array_dtype", "float32"),
437
+ )
438
+
439
+
440
+ def _producer_main(
441
+ request_queue,
442
+ result_queue,
443
+ params: dict,
444
+ generate_fn=None,
445
+ log_queue=None,
446
+ parent_pid: int | None = None,
447
+ poll_interval: float = _PARENT_POLL_INTERVAL_S,
448
+ ) -> None:
449
+ """
450
+ Producer process entry point (spawn-started — see _MP_CONTEXT). Owns a private worker pool
451
+ (workers attach to the background-plate shared memory created by the main process) and
452
+ generates rounds on request, isolated from the main process's GIL (TF's prefetch threads no
453
+ longer slow generation down, and generation no longer steals cycles from training).
454
+
455
+ Protocol (multiprocessing.Queues):
456
+ - in: ("generate", round_idx, snr_base, snr_range[, overrides]) | ("shutdown",)
457
+ `overrides` (optional dict, absent on plain round requests — the historical
458
+ 4-tuple shape) customizes one request without touching `params`:
459
+ - "dir_name": write into {base_dir}/{dir_name} (paths round_idx pinned to 0 —
460
+ see the request loop) instead of round_{round_idx:02d}
461
+ - "n_samples": per-request sample count (the RF dataset is num_samples_rf,
462
+ not the num_samples_beta_vae this producer was constructed with)
463
+ - out: ("stats", round_idx, segment_dict) per class-segment per chunk
464
+ ("progress", round_idx, chunk, n_chunks) per chunk
465
+ ("timing", round_idx, start_ts, end_ts) generation wall-clock span, sent
466
+ before "done" (skipped on reuse —
467
+ no generation happened); the
468
+ main-process drainer records it as
469
+ a pipeline_stages row (this
470
+ process can't reach the DB writer
471
+ queue, a thread queue.Queue)
472
+ ("done", round_idx, manifest) on success (or valid reuse)
473
+ ("error", round_idx, traceback_str) on failure (producer keeps serving)
474
+ ("shutdown_ack",) right before a graceful exit
475
+
476
+ `log_queue` is a spawn-context multiprocessing queue relayed into the main process's
477
+ logging pipeline by RoundDataProducer.start() — a spawned child has no inherited Logger
478
+ singleton, so it (and its pool workers) attach QueueHandlers to this queue explicitly.
479
+ `generate_fn` is a test seam: when provided, no worker pool is created and the stub is
480
+ called in place of the real memmap generation (see tests/unit/test_round_data.py).
481
+
482
+ `parent_pid` is the PID of the process this producer must not outlive (passed by
483
+ RoundDataProducer.start(); defaults to os.getppid() for the thread-driven tests, where
484
+ it never changes). The request loop polls it every `poll_interval` seconds: an
485
+ ungraceful parent death (SIGKILL/OOM — no cleanup runs, no "shutdown" is ever sent)
486
+ reparents this process, and the watch terminates the pool and exits instead of leaving
487
+ an orphan generating at full CPU and pinning the background SHM forever.
488
+ """
489
+ global _PRODUCER_POOL
490
+
491
+ if parent_pid is None:
492
+ parent_pid = os.getppid()
493
+
494
+ is_main_thread = threading.current_thread() is threading.main_thread()
495
+ if is_main_thread:
496
+ # The log queue is a multiprocessing.Queue — safe to use from this process.
497
+ init_worker_logging(log_queue)
498
+
499
+ # Ignore SIGINT: the main process's ResourceManager coordinates Ctrl-C cleanup.
500
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
501
+
502
+ # SIGTERM handler mirrors data_generation._init_worker's:
503
+ # WARN: DO NOT LOG ANYTHING IN THIS HANDLER — the log QueueHandler's feeder thread
504
+ # needs the GIL, which may be held elsewhere, and a blocked handler deadlocks
505
+ # termination (see data_generation.py's canonical worker handler).
506
+ def _cleanup_on_sigterm(signum, frame):
507
+ with contextlib.suppress(Exception):
508
+ if _PRODUCER_POOL is not None:
509
+ # Forward termination to the pool's children so they don't outlive us
510
+ # (SIGKILL on this process would otherwise orphan them).
511
+ _PRODUCER_POOL.terminate()
512
+ signal.signal(signal.SIGTERM, signal.SIG_DFL)
513
+ os.kill(os.getpid(), signal.SIGTERM)
514
+
515
+ signal.signal(signal.SIGTERM, _cleanup_on_sigterm)
516
+
517
+ if sys.platform == "linux":
518
+ # Belt-and-braces (Linux only): ask the kernel to SIGTERM this process the moment
519
+ # its parent dies — covers mid-generation parent death, when the request-loop
520
+ # ppid watch below isn't polling. PR_SET_PDEATHSIG is set here in the child's
521
+ # main entry (it fires on exit of the *thread* that set it, and is cleared
522
+ # across this process's own fork() — the pool workers don't inherit it, but the
523
+ # SIGTERM handler above terminates the pool for them).
524
+ with contextlib.suppress(Exception):
525
+ import ctypes # noqa: PLC0415
526
+
527
+ _pr_set_pdeathsig = 1 # linux/prctl.h
528
+ ctypes.CDLL("libc.so.6", use_errno=True).prctl(
529
+ _pr_set_pdeathsig, signal.SIGTERM, 0, 0, 0
530
+ )
531
+
532
+ pool = None
533
+ if generate_fn is None:
534
+ generate_fn = _default_generate
535
+ # Deferred import (setigen/scipy) — only the real producer needs it.
536
+ from aetherscan.data_generation import _init_worker # noqa: PLC0415
537
+
538
+ # Plain fork-context Pool: this spawned process is single-threaded, so forking its
539
+ # workers is safe — and they attach to the parent-created background SHM by name.
540
+ pool = multiprocessing.Pool(
541
+ processes=max(1, params["n_processes"]),
542
+ initializer=_init_worker,
543
+ initargs=(
544
+ params["shm_name"],
545
+ tuple(params["background_shape"]),
546
+ np.dtype(params["background_dtype"]),
547
+ log_queue,
548
+ ),
549
+ )
550
+ _PRODUCER_POOL = pool
551
+
552
+ logger.info(f"RoundDataProducer started (PID {os.getpid()})")
553
+
554
+ try:
555
+ while True:
556
+ try:
557
+ message = request_queue.get(timeout=poll_interval)
558
+ except queue.Empty:
559
+ # Parent-death watch: reparenting (to init or a subreaper) changes our ppid.
560
+ # Exit without a shutdown_ack — there is no one left to ack to.
561
+ if os.getppid() != parent_pid:
562
+ logger.warning(
563
+ "RoundDataProducer: parent process died; terminating pool and exiting"
564
+ )
565
+ break
566
+ continue
567
+ if message[0] == "shutdown":
568
+ logger.info("RoundDataProducer received shutdown request")
569
+ result_queue.put(("shutdown_ack",))
570
+ break
571
+ if message[0] != "generate":
572
+ logger.warning(f"RoundDataProducer ignoring unknown message: {message[0]!r}")
573
+ continue
574
+
575
+ _, round_idx, snr_base, snr_range = message[:4]
576
+ overrides = message[4] if len(message) > 4 else {}
577
+ n_samples = overrides.get("n_samples", params["n_samples"])
578
+ # The override rides a shallow copy so generate_fn keeps its params-dict
579
+ # signature; plain round requests pass `params` through untouched
580
+ request_params = (
581
+ params if n_samples == params["n_samples"] else {**params, "n_samples": n_samples}
582
+ )
583
+ dir_name = overrides.get("dir_name")
584
+ if dir_name is None:
585
+ paths = RoundDataPaths.for_round(params["base_dir"], round_idx)
586
+ else:
587
+ # Explicit-dir request (the RF dataset). paths.round_idx is pinned to 0 to
588
+ # mirror train_random_forest's rf_paths exactly — same round_00.done name,
589
+ # same manifest round_idx — so the parent's validate_done_manifest accepts
590
+ # the result no matter which side generated it. The MESSAGE round_idx is the
591
+ # request's seed identity, not a dir index: it reaches generate_fn as
592
+ # round_num (for the RF set the num_training_rounds+1 sentinel — the SAME
593
+ # value train_random_forest's in-process path passes), so per-task seeds
594
+ # derive identically and the arrays are byte-identical to in-process
595
+ # generation. Keep in sync with train._obtain_rf_dataset.
596
+ paths = RoundDataPaths(os.path.join(params["base_dir"], dir_name), 0)
597
+
598
+ existing = validate_done_manifest(
599
+ paths,
600
+ expected_n_samples=n_samples,
601
+ expected_array_dtype=params.get("array_dtype", "float32"),
602
+ )
603
+ if existing is not None:
604
+ logger.info(f"RoundDataProducer: reusing validated round {round_idx} data")
605
+ result_queue.put(("done", round_idx, existing))
606
+ continue
607
+
608
+ try:
609
+ logger.info(
610
+ f"RoundDataProducer: generating round {round_idx} data "
611
+ f"(SNR {snr_base}-{snr_base + snr_range})"
612
+ )
613
+ generation_start = time.time()
614
+ manifest = generate_fn(
615
+ paths,
616
+ round_idx,
617
+ snr_base,
618
+ snr_range,
619
+ pool,
620
+ request_params,
621
+ lambda segment, _r=round_idx: result_queue.put(("stats", _r, segment)),
622
+ lambda chunk, n_chunks, _r=round_idx: result_queue.put(
623
+ ("progress", _r, chunk, n_chunks)
624
+ ),
625
+ )
626
+ logger.info(f"RoundDataProducer: round {round_idx} data complete")
627
+ # Timing before done: queue FIFO means the drainer records the stage span
628
+ # before await_round() unblocks on the done message
629
+ result_queue.put(("timing", round_idx, generation_start, time.time()))
630
+ result_queue.put(("done", round_idx, manifest))
631
+ except Exception:
632
+ result_queue.put(("error", round_idx, traceback.format_exc()))
633
+ finally:
634
+ _PRODUCER_POOL = None
635
+ if pool is not None:
636
+ with contextlib.suppress(Exception):
637
+ pool.terminate()
638
+ pool.join()
639
+
640
+
641
+ class RoundDataProducer:
642
+ """
643
+ Main-process handle for the background round-data generation process.
644
+
645
+ Lifecycle: start() spawns the producer process (registered with ResourceManager for
646
+ terminate -> join -> kill cleanup) plus a drainer thread that consumes the result queue —
647
+ writing streamed injection stats to the DB from the main process (the DB writer queue is
648
+ a thread queue.Queue, not process-safe) while the GPUs train. request_generation() is
649
+ fire-and-forget; await_round() blocks until that round's done/error message arrives.
650
+ """
651
+
652
+ def __init__(
653
+ self,
654
+ *,
655
+ base_dir: str,
656
+ n_samples: int,
657
+ shm_name: str,
658
+ background_shape: tuple,
659
+ background_dtype: str,
660
+ n_processes: int,
661
+ width_bin: int,
662
+ num_observations: int,
663
+ time_bins: int,
664
+ chunk_size: int,
665
+ task_size: int,
666
+ freq_resolution: float,
667
+ time_resolution: float,
668
+ db,
669
+ tag: str,
670
+ seed: int | None = None,
671
+ array_dtype: str = "float32",
672
+ ):
673
+ self._params = {
674
+ "base_dir": base_dir,
675
+ "n_samples": n_samples,
676
+ "shm_name": shm_name,
677
+ "background_shape": tuple(background_shape),
678
+ "background_dtype": str(background_dtype),
679
+ "n_processes": n_processes,
680
+ "width_bin": width_bin,
681
+ "num_observations": num_observations,
682
+ "time_bins": time_bins,
683
+ "chunk_size": chunk_size,
684
+ "task_size": task_size,
685
+ "freq_resolution": freq_resolution,
686
+ "time_resolution": time_resolution,
687
+ # Pipeline root seed (config.reproducibility.seed) — crosses the spawn boundary with the
688
+ # rest of the params so producer-generated rounds derive the same per-round
689
+ # streams as in-process generation. None = OS entropy
690
+ "seed": seed,
691
+ # On-disk dtype for the cadence arrays (config.training.round_array_dtype)
692
+ "array_dtype": str(array_dtype),
693
+ }
694
+ self._db = db
695
+ self._tag = tag
696
+ # Queues come from the spawn context so they can cross the spawn pickling boundary
697
+ # (mixing contexts raises "A SemLock created in a fork context is being shared with a
698
+ # process in a spawn context" — which also rules out handing the child the Logger
699
+ # singleton's fork-context queue directly; see the relay in start())
700
+ self._request_queue: multiprocessing.Queue = _MP_CONTEXT.Queue()
701
+ self._result_queue: multiprocessing.Queue = _MP_CONTEXT.Queue()
702
+ self._producer_log_queue: multiprocessing.Queue = _MP_CONTEXT.Queue()
703
+ self._log_relay: QueueListener | None = None
704
+ self._process: multiprocessing.Process | None = None
705
+ self._drainer: threading.Thread | None = None
706
+ self._drainer_done = False
707
+ self._results: dict[int, tuple[str, object]] = {}
708
+ # Per-request stage-name overrides for the drainer's timing handler (main-side
709
+ # only, never crosses the queue): non-round requests like the RF dataset record
710
+ # their span under the right stage subtree instead of a nonexistent round_XX
711
+ self._stage_names: dict[int, str] = {}
712
+ self._condition = threading.Condition()
713
+ self._pidfile = os.path.join(base_dir, _PRODUCER_PIDFILE)
714
+
715
+ def start(self) -> None:
716
+ """Spawn the producer process and the main-side result drainer thread."""
717
+ # A spawned child has no inherited Logger singleton, and the singleton's fork-context
718
+ # queue can't cross the spawn boundary — so the producer tree logs into its own
719
+ # spawn-context queue, and this main-side QueueListener relays each record into the
720
+ # main process's root handlers (i.e. into the normal file/console/Slack pipeline).
721
+ self._log_relay = QueueListener(
722
+ self._producer_log_queue,
723
+ *logging.getLogger().handlers,
724
+ respect_handler_level=False,
725
+ )
726
+ self._log_relay.start()
727
+
728
+ self._process = _MP_CONTEXT.Process(
729
+ target=_producer_main,
730
+ args=(
731
+ self._request_queue,
732
+ self._result_queue,
733
+ self._params,
734
+ None,
735
+ self._producer_log_queue,
736
+ # This process's real PID, not the child's getppid(): if the parent dies
737
+ # before the child captures its ppid, the child would capture the reaper's
738
+ # PID and its parent-death watch could never fire.
739
+ os.getpid(),
740
+ ),
741
+ name="RoundDataProducer",
742
+ )
743
+
744
+ # The spawned child re-imports the aetherscan module chain, which includes TF; blank
745
+ # out GPU visibility for the child's entire process tree so nothing in the producer
746
+ # can ever initialize CUDA (generation is pure CPU).
747
+ # NOTE: another thread reading os.environ during this brief window would see the
748
+ # blanked value — intentionally acceptable ONLY because the parent's TF read the var
749
+ # once at its own GPU init (long before this point) and nothing else in the pipeline
750
+ # spawns GPU-visible subprocesses concurrently with training startup.
751
+ original_cuda_visible = os.environ.get("CUDA_VISIBLE_DEVICES")
752
+ os.environ["CUDA_VISIBLE_DEVICES"] = ""
753
+ try:
754
+ self._process.start()
755
+ finally:
756
+ if original_cuda_visible is None:
757
+ os.environ.pop("CUDA_VISIBLE_DEVICES", None)
758
+ else:
759
+ os.environ["CUDA_VISIBLE_DEVICES"] = original_cuda_visible
760
+
761
+ # Record the producer PID for post-mortem discovery: the orphan left by an ungraceful
762
+ # main-process death has a bare spawn_main argv that utils/kill_pipeline.sh's pattern
763
+ # can't attribute to Aetherscan, so the kill script (and the next run's
764
+ # _reap_stale_producer) read this file instead. Removed on graceful shutdown.
765
+ try:
766
+ os.makedirs(self._params["base_dir"], exist_ok=True)
767
+ with open(self._pidfile, "w") as f:
768
+ f.write(str(self._process.pid))
769
+ except OSError as e:
770
+ logger.warning(f"Failed to write producer pidfile {self._pidfile}: {e}")
771
+
772
+ manager = get_manager()
773
+ if manager is not None:
774
+ manager.register_process(self._process, name="RoundDataProducer")
775
+
776
+ self._drainer = threading.Thread(
777
+ target=self._drain_results, name="RoundDataDrainer", daemon=True
778
+ )
779
+ self._drainer.start()
780
+ logger.info(f"RoundDataProducer process started (PID {self._process.pid})")
781
+
782
+ def request_generation(
783
+ self,
784
+ round_idx: int,
785
+ snr_base: float,
786
+ snr_range: float,
787
+ *,
788
+ dir_name: str | None = None,
789
+ n_samples: int | None = None,
790
+ stage_name: str | None = None,
791
+ ) -> None:
792
+ """Queue one generation request (non-blocking). Plain requests generate 1-based
793
+ round `round_idx`; `dir_name`/`n_samples` override the output dir and sample count
794
+ (the RF dataset — `round_idx` then carries the request's seed-identity round_num,
795
+ see _producer_main). `stage_name` renames the timing span the drainer records to
796
+ "{stage_name}.data_generation" (absolute; default round_stage_name(round_idx))."""
797
+ if stage_name is not None:
798
+ self._stage_names[round_idx] = stage_name
799
+ target = f"'{dir_name}' dataset" if dir_name is not None else f"round {round_idx} data"
800
+ logger.info(f"Requesting background generation of {target}")
801
+ overrides: dict = {}
802
+ if dir_name is not None:
803
+ overrides["dir_name"] = dir_name
804
+ if n_samples is not None:
805
+ overrides["n_samples"] = n_samples
806
+ if overrides:
807
+ self._request_queue.put(("generate", round_idx, snr_base, snr_range, overrides))
808
+ else:
809
+ # Plain round requests keep the historical 4-tuple shape
810
+ self._request_queue.put(("generate", round_idx, snr_base, snr_range))
811
+
812
+ def await_round(self, round_idx: int) -> dict:
813
+ """
814
+ Block until round `round_idx`'s done/error message arrives; return the manifest on
815
+ success, raise RuntimeError (carrying the producer traceback) on generation failure
816
+ or if the producer died without answering.
817
+ """
818
+ while True:
819
+ with self._condition:
820
+ if round_idx in self._results:
821
+ kind, payload = self._results[round_idx]
822
+ break
823
+ if self._drainer_done:
824
+ raise RuntimeError(
825
+ f"RoundDataProducer exited before producing round {round_idx} data"
826
+ )
827
+ # NOTE: the drainer's notify_all() wakes this immediately on done/error; the
828
+ # 1 s timeout is not a polling latency, just a liveness re-check so a drainer
829
+ # that died without notifying can't strand this wait forever
830
+ self._condition.wait(timeout=1.0)
831
+
832
+ if kind == "error":
833
+ raise RuntimeError(f"Round {round_idx} data generation failed in producer:\n{payload}")
834
+ return payload
835
+
836
+ def shutdown(self, timeout: float = 10.0) -> None:
837
+ """Request a graceful producer exit, escalating to terminate/kill via the manager's
838
+ ManagedProcess if the producer doesn't wind down in time (e.g. mid-generation)."""
839
+ if self._process is None:
840
+ return
841
+
842
+ with contextlib.suppress(Exception):
843
+ self._request_queue.put(("shutdown",))
844
+ self._process.join(timeout)
845
+
846
+ manager = get_manager()
847
+ if manager is not None:
848
+ # No-op join if already exited; terminate -> join -> kill escalation otherwise.
849
+ manager.close_process(self._process)
850
+ elif self._process.is_alive():
851
+ self._process.terminate()
852
+ self._process.join(timeout)
853
+
854
+ if self._drainer is not None:
855
+ self._drainer.join(timeout=timeout)
856
+ if self._log_relay is not None:
857
+ with contextlib.suppress(Exception):
858
+ self._log_relay.stop()
859
+ self._log_relay = None
860
+ self._process = None
861
+ # Graceful shutdown: the pidfile only exists to mark an orphan candidate.
862
+ with contextlib.suppress(OSError):
863
+ os.remove(self._pidfile)
864
+ logger.info("RoundDataProducer shut down")
865
+
866
+ def _handle_message(self, message: tuple) -> None:
867
+ """Dispatch one producer message (runs on the drainer thread)."""
868
+ kind = message[0]
869
+ if kind == "stats":
870
+ # Deferred import: keep round_data importable without the setigen stack.
871
+ from aetherscan.data_generation import write_segment_stats # noqa: PLC0415
872
+
873
+ _, _round_idx, segment = message
874
+ write_segment_stats(self._db, self._tag, segment)
875
+ elif kind == "progress":
876
+ _, round_idx, chunk, n_chunks = message
877
+ logger.info(f"Round {round_idx} data generation: chunk {chunk}/{n_chunks} complete")
878
+ elif kind == "timing":
879
+ # The producer's generation wall-clock span, recorded from this (main) process
880
+ # because the DB writer queue is thread-only
881
+ _, round_idx, start_ts, end_ts = message
882
+ stage = self._stage_names.get(round_idx) or round_stage_name(round_idx)
883
+ record_stage(
884
+ f"{stage}.data_generation",
885
+ start_ts,
886
+ end_ts,
887
+ tag=self._tag,
888
+ metadata={"source": "producer"},
889
+ )
890
+ elif kind in ("done", "error"):
891
+ _, round_idx, payload = message
892
+ with self._condition:
893
+ self._results[round_idx] = (kind, payload)
894
+ self._condition.notify_all()
895
+ else:
896
+ logger.warning(f"RoundDataDrainer ignoring unknown message: {kind!r}")
897
+
898
+ def _drain_results(self) -> None:
899
+ """Drainer thread body: consume producer messages until shutdown-ack or producer death,
900
+ then sweep any messages still buffered in the queue. A failure handling one message
901
+ (e.g. a DB hiccup on a stats write) must not kill the drainer — done/error messages
902
+ for in-flight rounds still need to unblock await_round()."""
903
+
904
+ def _handle_safely(message: tuple) -> None:
905
+ try:
906
+ self._handle_message(message)
907
+ except Exception as e:
908
+ logger.error(f"RoundDataDrainer failed to handle {message[0]!r} message: {e}")
909
+
910
+ while True:
911
+ try:
912
+ message = self._result_queue.get(timeout=1.0)
913
+ except queue.Empty:
914
+ if self._process is None or not self._process.is_alive():
915
+ break
916
+ continue
917
+ if message[0] == "shutdown_ack":
918
+ break
919
+ _handle_safely(message)
920
+
921
+ # Final sweep: the producer may have exited with messages still in the pipe.
922
+ while True:
923
+ try:
924
+ message = self._result_queue.get_nowait()
925
+ except queue.Empty:
926
+ break
927
+ if message[0] != "shutdown_ack":
928
+ _handle_safely(message)
929
+
930
+ with self._condition:
931
+ self._drainer_done = True
932
+ self._condition.notify_all()